diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4965e83..440cd5c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -170,6 +170,24 @@ jobs: chmod +x ci/e2e-onboarding-flows.sh ci/e2e-onboarding-flows.sh + e2e-custom-routing: + name: E2E Custom Routing Contract (8 cells) + runs-on: ubuntu-latest + timeout-minutes: 5 + # PR 5 of the 2026-05-10 architecture audit. Locks the + # phase_context routing contract: trust strict/normal, + # upstream_required vs optional, max_age_days override, solution + # tags, diarization paths/keywords. Backward compat: custom phases + # without phase_context keep the dependency-only behavior. + steps: + - uses: actions/checkout@v4 + - name: jq is present + run: jq --version + - name: Run custom routing E2E + run: | + chmod +x ci/e2e-custom-routing.sh + ci/e2e-custom-routing.sh + e2e-graph-aware-session: name: E2E Graph-aware Session + Next-step (10 cells) runs-on: ubuntu-latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7e24feb..13b02c7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3366,6 +3366,43 @@ jobs: fi echo "OK: guard concurrency tier uses the phase registry." + custom-routing-contract: + name: Custom routing contract wired into resolve.sh + runs-on: ubuntu-latest + # PR 5 of the 2026-05-10 architecture audit. Locks that bin/resolve.sh + # reads phase_context from config and emits the routing block. + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - name: resolve.sh reads phase_context and emits routing + run: | + set -e + script=bin/resolve.sh + if ! grep -qF 'phase_context' "$script"; then + echo "FAIL: $script does not read phase_context from config." + exit 1 + fi + if ! grep -qE '^\s*routing:' "$script"; then + echo "FAIL: $script does not emit a routing block in its JSON output." + exit 1 + fi + for field in routing_trust routing_required routing_optional routing_max_age routing_solution_tags routing_solution_limit routing_diarization_paths routing_diarization_keywords; do + if ! grep -qF "$field" "$script"; then + echo "FAIL: $script does not surface $field." + exit 1 + fi + done + echo "OK: custom routing contract wired into resolve.sh" + - name: Contract documented in reference/custom-stack-contract.md + run: | + set -e + if ! grep -qF 'phase_context' reference/custom-stack-contract.md; then + echo "FAIL: reference/custom-stack-contract.md does not document phase_context." + exit 1 + fi + echo "OK: phase_context documented in the contract reference" + session-graph-aware-wiring: name: session.sh + next-step.sh route through the phase registry runs-on: ubuntu-latest diff --git a/EXTENDING.md b/EXTENDING.md index 0b0175d..8885104 100644 --- a/EXTENDING.md +++ b/EXTENDING.md @@ -30,6 +30,8 @@ Add your own skills that plug into Nanostack's workflow. Your skills save artifa > ``` > > The stack manifest schema, the directory contract any new stack must satisfy, and the runtime guarantees the framework gives a stack are documented in [`reference/custom-stack-examples-technical-spec.md`](reference/custom-stack-examples-technical-spec.md). The 15-cell runtime end-to-end harness for the example stack is at [`ci/e2e-custom-stack-examples.sh`](ci/e2e-custom-stack-examples.sh) (51 assertions). +> +> **Routing the context your skill needs.** A custom skill can declare a `phase_context` block in `.nanostack/config.json` so the resolver loads what the skill actually needs: strict integrity trust, required vs optional upstreams, per-phase age windows, tagged solutions from `know-how/solutions`, and diarizations filtered by path or keyword. The full contract lives in [`reference/custom-stack-contract.md`](reference/custom-stack-contract.md) under "Custom routing contract". Custom skills without a `phase_context` entry keep the dependency-only behavior, so the feature is opt-in. ## Configure your stack diff --git a/bin/resolve.sh b/bin/resolve.sh index dfd219a..f31acc6 100755 --- a/bin/resolve.sh +++ b/bin/resolve.sh @@ -108,8 +108,9 @@ case "$PHASE" in # Custom-phase fallback. A phase that's registered in # .nanostack/config.json (custom_phases) returns minimal context: # upstream artifacts driven by phase_graph or skill frontmatter, - # everything else empty. An unregistered phase still exits 1 so - # set -e callers fail closed. + # plus the optional context routing block (phase_context) added in + # PR 5 of the 2026-05-10 architecture audit. An unregistered phase + # still exits 1 so set -e callers fail closed. if [ "$(nano_phase_kind "$PHASE" 2>/dev/null)" = "custom" ]; then PHASE_KIND="custom" DEPS="" @@ -180,6 +181,87 @@ case "$PHASE" in ;; esac +# ─── Custom routing contract (PR 5 of architecture vNext) ────── +# Custom skills can declare a `phase_context` block in +# .nanostack/config.json that tells the resolver what shape of +# context they need: which upstreams are required vs optional, what +# trust level (strict / normal) gates the artifact loads, a per- +# phase max_age override, plus solution_tags and diarization_paths +# to widen the lookup beyond dependency edges. Core phases ignore +# this block; their routing stays hardcoded in the case statement +# above. Custom phases with no phase_context entry keep their pre- +# PR-5 behavior (upstreams from deps, no solutions / diarizations). +ROUTING_TRUST="normal" +ROUTING_REQUIRED_JSON="[]" +ROUTING_OPTIONAL_JSON="[]" +ROUTING_MAX_AGE_DAYS="" +ROUTING_SOLUTION_TAGS_JSON="[]" +ROUTING_SOLUTION_LIMIT="" +ROUTING_DIARIZATION_PATHS_JSON="[]" +ROUTING_DIARIZATION_KEYWORDS_JSON="[]" +ROUTING_DECLARED=false +if [ "$PHASE_KIND" = "custom" ]; then + # Resolve the same config the phase registry used: prefer the + # project-local .nanostack/config.json, fall back to the global + # ~/.nanostack/config.json so a user-level routing entry still + # applies to a project that has no local config. Codex caught + # the missed-fallback regression on the PR 5 second review pass. + ROUTING_CFG="" + if declare -F _nano_phases_resolve_config >/dev/null 2>&1; then + ROUTING_CFG=$(_nano_phases_resolve_config 2>/dev/null || echo "") + fi + [ -z "$ROUTING_CFG" ] && ROUTING_CFG="$NANOSTACK_STORE/config.json" + if [ -f "$ROUTING_CFG" ] && command -v jq >/dev/null 2>&1; then + if jq -e --arg p "$PHASE" '.phase_context // {} | has($p)' "$ROUTING_CFG" >/dev/null 2>&1; then + ROUTING_DECLARED=true + ROUTING_TRUST=$(jq -r --arg p "$PHASE" '.phase_context[$p].trust // "normal"' "$ROUTING_CFG" 2>/dev/null) + ROUTING_REQUIRED_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].upstream_required // []' "$ROUTING_CFG" 2>/dev/null) + ROUTING_OPTIONAL_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].upstream_optional // []' "$ROUTING_CFG" 2>/dev/null) + ROUTING_MAX_AGE_DAYS=$(jq -r --arg p "$PHASE" '.phase_context[$p].max_age_days // ""' "$ROUTING_CFG" 2>/dev/null) + ROUTING_SOLUTION_TAGS_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].solutions.tags // []' "$ROUTING_CFG" 2>/dev/null) + ROUTING_SOLUTION_LIMIT=$(jq -r --arg p "$PHASE" '.phase_context[$p].solutions.limit // ""' "$ROUTING_CFG" 2>/dev/null) + ROUTING_DIARIZATION_PATHS_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].diarizations.paths // []' "$ROUTING_CFG" 2>/dev/null) + ROUTING_DIARIZATION_KEYWORDS_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].diarizations.keywords // []' "$ROUTING_CFG" 2>/dev/null) + case "$ROUTING_TRUST" in + strict|normal) ;; + *) ROUTING_TRUST="normal" ;; + esac + + # Routed upstreams that are not already in the dependency-derived + # UPSTREAM list still need their artifacts resolved so consumers + # see status + paths. Codex caught the missing wiring on the + # PR 5 first review pass: declaring upstream_optional: ["security"] + # without listing security in depends_on left it absent from + # upstream_status entirely. Default age for the merge follows the + # routing max_age_days when set, otherwise the per-phase 30-day + # custom default. + _routed_default_age=30 + [ -n "$ROUTING_MAX_AGE_DAYS" ] && [ "$ROUTING_MAX_AGE_DAYS" != "null" ] && _routed_default_age="$ROUTING_MAX_AGE_DAYS" + _add_routed_upstream() { + local extra="$1" + [ -z "$extra" ] && return 0 + case " $UPSTREAM " in + *" ${extra}:"*) return 0 ;; + esac + if [ "$extra" = "build" ]; then + UPSTREAM="${UPSTREAM:+$UPSTREAM }build:0" + else + UPSTREAM="${UPSTREAM:+$UPSTREAM }$extra:$_routed_default_age" + fi + } + while IFS= read -r r; do + [ -z "$r" ] || [ "$r" = "null" ] && continue + _add_routed_upstream "$r" + done < <(echo "$ROUTING_REQUIRED_JSON" | jq -r '.[]?' 2>/dev/null) + while IFS= read -r r; do + [ -z "$r" ] || [ "$r" = "null" ] && continue + _add_routed_upstream "$r" + done < <(echo "$ROUTING_OPTIONAL_JSON" | jq -r '.[]?' 2>/dev/null) + unset -f _add_routed_upstream + fi + fi +fi + # ─── 1. Resolve upstream artifacts ───────────────────────── # # upstream_artifacts keeps its historical shape: only verified paths @@ -190,6 +272,14 @@ esac # integrity field" without reimplementing the check. release-readiness # and other release gates can switch to --require-integrity in their # own find-artifact.sh calls when they need strict semantics. +# +# PR 5 of the architecture audit adds the phase_context routing +# block: strict trust upgrades artifact-loading to --require-integrity +# (rejects integrity_missing in addition to mismatch) and max_age_days +# overrides the per-phase age. upstream_required + upstream_optional +# from the routing block surface in the routing.required / optional +# lists for downstream consumers; missing-from-store required entries +# already report status=missing. ARTIFACTS_JSON="{" STATUS_JSON="{" @@ -207,6 +297,19 @@ for entry in $UPSTREAM; do age="$o_age" fi done + # Phase context max_age_days overrides per-phase default. CLI + # --max-age stays on top so an operator can widen the window for a + # specific run without editing config. + if [ -n "$ROUTING_MAX_AGE_DAYS" ] && [ "$ROUTING_MAX_AGE_DAYS" != "null" ]; then + age="$ROUTING_MAX_AGE_DAYS" + for override in $MAX_AGE_OVERRIDES; do + o_phase="${override%%:*}" + o_age="${override#*:}" + if [ "$o_phase" = "$phase" ] && [ -n "$o_age" ] && [ "$o_age" != "$override" ]; then + age="$o_age" + fi + done + fi # Look up the latest artifact for this phase WITHOUT --verify so we # can classify it ourselves via nano_artifact_trust. The verified @@ -241,11 +344,28 @@ for entry in $UPSTREAM; do # added) continue to work; release gates that need strict semantics # call find-artifact.sh --require-integrity themselves and read # upstream_status for the explicit signal. + # + # PR 5: phase_context.trust = strict rejects integrity_missing too, + # so a custom skill that declared strict trust never sees a path it + # cannot verify. Normal trust keeps the historical lenient behavior + # (legacy artifacts saved before .integrity was added still load). + ALLOWED_STATUSES="verified|integrity_missing" + if [ "$ROUTING_TRUST" = "strict" ]; then + ALLOWED_STATUSES="verified" + fi case "$STATUS" in verified|integrity_missing) - $FIRST || ARTIFACTS_JSON="$ARTIFACTS_JSON," - ARTIFACTS_JSON="$ARTIFACTS_JSON\"$phase\":\"$RAW\"" - FIRST=false + if [ "$ROUTING_TRUST" = "strict" ] && [ "$STATUS" != "verified" ]; then + if [ "$PHASE_KIND" = "custom" ]; then + $FIRST || ARTIFACTS_JSON="$ARTIFACTS_JSON," + ARTIFACTS_JSON="$ARTIFACTS_JSON\"$phase\":null" + FIRST=false + fi + else + $FIRST || ARTIFACTS_JSON="$ARTIFACTS_JSON," + ARTIFACTS_JSON="$ARTIFACTS_JSON\"$phase\":\"$RAW\"" + FIRST=false + fi ;; *) if [ "$PHASE_KIND" = "custom" ]; then @@ -278,6 +398,31 @@ STATUS_JSON="$STATUS_JSON}" # ─── 2. Resolve solutions ────────────────────────────────── SOLUTIONS_JSON="[]" + +# PR 5: when a custom phase declares solution_tags in phase_context, +# load matching solutions even though the core LOAD_SOLUTIONS flag is +# false for custom phases. Tag matching is case-insensitive substring +# over each solution's frontmatter tags field and its filename so a +# skill author does not need to invent a new index. +if [ "$SKIP_SOLUTIONS_FLAG" = false ] && [ "$PHASE_KIND" = "custom" ] \ + && [ "$ROUTING_SOLUTION_TAGS_JSON" != "[]" ] \ + && [ -d "$NANOSTACK_STORE/know-how/solutions" ]; then + custom_limit="${ROUTING_SOLUTION_LIMIT:-10}" + [ -z "$custom_limit" ] || [ "$custom_limit" = "null" ] && custom_limit=10 + tag_matches="" + while IFS= read -r tag; do + [ -z "$tag" ] && continue + while IFS= read -r sol; do + [ -z "$sol" ] && continue + tag_matches="${tag_matches}${sol} +" + done < <(grep -lriF -- "$tag" "$NANOSTACK_STORE/know-how/solutions" 2>/dev/null | head -"$custom_limit") + done < <(echo "$ROUTING_SOLUTION_TAGS_JSON" | jq -r '.[]?' 2>/dev/null) + if [ -n "$tag_matches" ]; then + SOLUTIONS_JSON=$(echo "$tag_matches" | sed '/^$/d' | sort -u | head -"$custom_limit" | jq -R . | jq -sc '.') + fi +fi + if [ "$LOAD_SOLUTIONS" = true ] && [ "$SKIP_SOLUTIONS_FLAG" = false ]; then DIFF_FILES="" if [ "$USE_DIFF" = true ]; then @@ -356,7 +501,67 @@ fi # ─── 4. Resolve diarizations ─────────────────────────────── DIARIZATIONS_JSON="[]" -if [ "$LOAD_DIARIZATIONS" = true ]; then +# PR 5: a custom phase can declare diarization paths or keywords in +# phase_context to load diarizations by topic instead of git diff. A +# diarization matches when its `subject:` line contains any of the +# declared paths or keywords (case-insensitive substring). This +# parallels the core LOAD_DIARIZATIONS path but does not require a +# diff to be present. +if [ "$PHASE_KIND" = "custom" ] \ + && { [ "$ROUTING_DIARIZATION_PATHS_JSON" != "[]" ] || [ "$ROUTING_DIARIZATION_KEYWORDS_JSON" != "[]" ]; }; then + DIARIZE_DIR="$NANOSTACK_STORE/know-how/diarizations" + if [ -d "$DIARIZE_DIR" ]; then + needles="" + while IFS= read -r needle; do + [ -z "$needle" ] && continue + needles="${needles}${needle} +" + done < <(echo "$ROUTING_DIARIZATION_PATHS_JSON $ROUTING_DIARIZATION_KEYWORDS_JSON" | jq -r '.[]?' 2>/dev/null) + if [ -n "$needles" ]; then + # Build the diarizations array through jq so quotes, backslashes + # or other JSON metacharacters in a subject or path do not break + # the final --argjson parse. Codex caught the string-concat + # injection on the PR 5 third review pass. + DIARIZATIONS_JSON='[]' + for dfile in "$DIARIZE_DIR"/*.md; do + [ -f "$dfile" ] || continue + SUBJECT=$(sed -n '/^---$/,/^---$/p' "$dfile" | grep -i '^subject:' | head -1 | sed 's/^subject: *//i') + [ -z "$SUBJECT" ] && continue + matched=false + while IFS= read -r needle; do + [ -z "$needle" ] && continue + # -F: literal substring, not regex. Codex caught the regex + # interpretation on the PR 5 second review pass: a path like + # app/users/[id]/page.tsx would match unrelated subjects + # such as app/users/i/page.tsx because [id] read as a class. + if printf '%s' "$SUBJECT" | grep -qiF -- "$needle" 2>/dev/null; then + matched=true + break + fi + done <<< "$needles" + if [ "$matched" = true ]; then + FILE_DATE=$(sed -n '/^---$/,/^---$/p' "$dfile" | grep -i '^date:' | head -1 | sed 's/^date: *//i') + AGE_DAYS="unknown" + if [ -n "$FILE_DATE" ]; then + if command -v gdate >/dev/null 2>&1; then DC="gdate"; else DC="date"; fi + FILE_EPOCH=$($DC -d "$FILE_DATE" +%s 2>/dev/null || echo 0) + NOW_EPOCH=$($DC +%s 2>/dev/null || echo 0) + if [ "$FILE_EPOCH" -gt 0 ]; then + AGE_DAYS=$(( (NOW_EPOCH - FILE_EPOCH) / 86400 )) + fi + fi + DIARIZATIONS_JSON=$(echo "$DIARIZATIONS_JSON" | jq \ + --arg path "$dfile" \ + --arg subject "$SUBJECT" \ + --arg age_days "$AGE_DAYS" \ + '. + [{path: $path, subject: $subject, age_days: $age_days}]') + fi + done + fi + fi +fi + +if [ "$LOAD_DIARIZATIONS" = true ] && [ "$DIARIZATIONS_JSON" = "[]" ]; then DIARIZE_DIR="$NANOSTACK_STORE/know-how/diarizations" if [ -d "$DIARIZE_DIR" ] && [ "$USE_DIFF" = true ] && [ -n "$DIFF_FILES" ]; then DIAR_RESULTS="[" @@ -423,6 +628,29 @@ fi # ─── Output ───────────────────────────────────────────────── +# PR 5: routing exposes the phase_context that was applied to this +# resolve call. Consumers can read it to know what trust level +# gated artifact loads, which upstreams were declared required vs +# optional, and which solution / diarization filters fired. The +# block is present for every phase: core phases get a "declared: +# false" placeholder so downstream code reads a uniform shape. +ROUTING_MAX_AGE_FOR_OUTPUT="null" +if [ -n "$ROUTING_MAX_AGE_DAYS" ] && [ "$ROUTING_MAX_AGE_DAYS" != "null" ]; then + ROUTING_MAX_AGE_FOR_OUTPUT="$ROUTING_MAX_AGE_DAYS" +fi +ROUTING_LIMIT_FOR_OUTPUT="null" +if [ -n "$ROUTING_SOLUTION_LIMIT" ] && [ "$ROUTING_SOLUTION_LIMIT" != "null" ]; then + ROUTING_LIMIT_FOR_OUTPUT="$ROUTING_SOLUTION_LIMIT" +elif [ "$ROUTING_SOLUTION_TAGS_JSON" != "[]" ]; then + # When tags are declared but limit is omitted, the resolver applies + # the documented default (10). Report the effective value so the + # routing block matches the actual lookup behavior. Codex flagged + # the silent default on the PR 5 fourth review pass. + ROUTING_LIMIT_FOR_OUTPUT="10" +fi +ROUTING_DECLARED_JSON=false +[ "$ROUTING_DECLARED" = true ] && ROUTING_DECLARED_JSON=true + jq -n \ --arg phase "$PHASE" \ --arg phase_kind "$PHASE_KIND" \ @@ -434,6 +662,15 @@ jq -n \ --argjson config "$CONFIG_JSON" \ --argjson goal "$GOAL" \ --argjson metrics "$METRICS_JSON" \ + --argjson routing_declared "$ROUTING_DECLARED_JSON" \ + --arg routing_trust "$ROUTING_TRUST" \ + --argjson routing_required "$ROUTING_REQUIRED_JSON" \ + --argjson routing_optional "$ROUTING_OPTIONAL_JSON" \ + --argjson routing_max_age "$ROUTING_MAX_AGE_FOR_OUTPUT" \ + --argjson routing_solution_tags "$ROUTING_SOLUTION_TAGS_JSON" \ + --argjson routing_solution_limit "$ROUTING_LIMIT_FOR_OUTPUT" \ + --argjson routing_diarization_paths "$ROUTING_DIARIZATION_PATHS_JSON" \ + --argjson routing_diarization_keywords "$ROUTING_DIARIZATION_KEYWORDS_JSON" \ '{ phase: $phase, phase_kind: $phase_kind, @@ -444,5 +681,20 @@ jq -n \ diarizations: $diarizations, config: $config, goal: $goal, - sprint_metrics: $metrics + sprint_metrics: $metrics, + routing: { + declared: $routing_declared, + trust: $routing_trust, + upstream_required: $routing_required, + upstream_optional: $routing_optional, + max_age_days: $routing_max_age, + solutions: { + tags: $routing_solution_tags, + limit: $routing_solution_limit + }, + diarizations: { + paths: $routing_diarization_paths, + keywords: $routing_diarization_keywords + } + } }' diff --git a/ci/e2e-custom-routing.sh b/ci/e2e-custom-routing.sh new file mode 100755 index 0000000..22dcfb1 --- /dev/null +++ b/ci/e2e-custom-routing.sh @@ -0,0 +1,492 @@ +#!/usr/bin/env bash +# e2e-custom-routing.sh — Custom routing contract (PR 5 of architecture vNext). +# +# Locks the phase_context contract end-to-end: +# +# - A custom phase with no phase_context block keeps the existing +# dependency-only behavior (routing.declared = false). +# - phase_context.trust = strict drops integrity_missing artifacts +# so a custom skill that asked for strict evidence never sees a +# path it cannot verify. +# - phase_context.upstream_required / upstream_optional surface in +# routing so consumers can read declared intent. +# - phase_context.max_age_days overrides the per-phase default. +# - phase_context.solutions.tags loads matching solutions filtered +# by content match, limited to solutions.limit. +# - phase_context.diarizations.paths / keywords loads matching +# diarizations without depending on git diff. +# +# Spec acceptance, verbatim: +# "A custom skill can ask for strict upstream artifacts without +# local helper code." +# "A custom skill can request solution search tags." +# "A custom skill can request diarizations by path or keyword." +# "Missing required upstreams are explicit in upstream_status, not +# silently null." +# "Backward compatibility: custom skills with no context: block +# keep current behavior." +set -e +set -u + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +TMP_ROOT=$(mktemp -d /tmp/nanostack-custom-routing.XXXXXX) +trap 'rm -rf "$TMP_ROOT"' EXIT + +PASS=0 +FAIL=0 +GREEN='\033[0;32m' +RED='\033[0;31m' +DIM='\033[0;90m' +NC='\033[0m' + +assert_eq() { + local name="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + PASS=$((PASS+1)) + printf " ${GREEN}OK${NC} %s\n" "$name" + else + FAIL=$((FAIL+1)) + printf " ${RED}FAIL${NC} %s\n" "$name" + printf " ${DIM}expected: %s${NC}\n" "$expected" + printf " ${DIM}actual: %s${NC}\n" "$actual" + fi +} + +new_project() { + local name="$1" + local proj="$TMP_ROOT/$name" + mkdir -p "$proj/.nanostack/skills/license-audit" \ + "$proj/.nanostack/know-how/solutions" \ + "$proj/.nanostack/know-how/diarizations" \ + "$proj/.nanostack/review" + cd "$proj" + git init -q + git config user.email "ci@routing.test" + git config user.name "ci" + export NANOSTACK_STORE="$proj/.nanostack" + cat > "$proj/.nanostack/skills/license-audit/SKILL.md" <<'EOF' +--- +name: license-audit +description: custom skill for routing tests +concurrency: read +depends_on: [review] +--- +EOF +} + +# Save a "verified" review artifact via the real save-artifact path. +save_verified_review() { + "$REPO/bin/save-artifact.sh" review \ + '{"phase":"review","summary":{"v":1,"blocking":0},"scope_drift":{"status":"clean"},"findings":[],"context_checkpoint":{"summary":"routing test"}}' >/dev/null +} + +# Save a review artifact WITHOUT .integrity (legacy / stripped). +save_integrity_missing_review() { + local ts="$(date -u +%Y%m%dT%H%M%S)" + printf '%s\n' '{"phase":"review","project":"'"$(pwd)"'","summary":"missing integrity"}' > "$NANOSTACK_STORE/review/${ts}.json" +} + +echo "Custom Routing Contract E2E" +echo "===========================" +echo "Tmp root: $TMP_ROOT" +echo + +# Cell 1: backward compat — no phase_context keeps the existing +# dependency-only behavior. routing.declared = false. +echo "[1] backward compat: no phase_context keeps current behavior" +new_project "cell1-backcompat" +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{"custom_phases": ["license-audit"]} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +assert_eq "routing.declared = false" "false" "$(echo "$out" | jq -r '.routing.declared')" +assert_eq "routing.trust = normal (default)" "normal" "$(echo "$out" | jq -r '.routing.trust')" +assert_eq "solutions empty (no tags)" "0" "$(echo "$out" | jq '.solutions | length')" +assert_eq "diarizations empty (no paths)" "0" "$(echo "$out" | jq '.diarizations | length')" + +# Cell 2: missing required upstream surfaces explicitly. Spec says +# upstream_status reports the state, not silently null. +echo "[2] missing required upstream is explicit in upstream_status" +new_project "cell2-missing" +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { + "upstream_required": ["review"] + } + } +} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +assert_eq "upstream_status.review = missing" "missing" \ + "$(echo "$out" | jq -r '.upstream_status.review')" +assert_eq "routing.upstream_required lists review" '["review"]' \ + "$(echo "$out" | jq -c '.routing.upstream_required')" + +# Cell 3: strict trust drops integrity_missing artifacts. +echo "[3] strict trust drops integrity_missing artifacts" +new_project "cell3-strict" +save_integrity_missing_review +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { "trust": "strict" } + } +} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +assert_eq "strict: upstream_status.review = integrity_missing (diagnostic preserved)" \ + "integrity_missing" "$(echo "$out" | jq -r '.upstream_status.review')" +assert_eq "strict: upstream_artifacts.review = null (artifact dropped)" \ + "null" "$(echo "$out" | jq -r '.upstream_artifacts.review')" + +# Cell 4: normal trust keeps the integrity_missing artifact in +# upstream_artifacts so legacy stores continue to load. +echo "[4] normal trust keeps integrity_missing artifact (legacy compat)" +new_project "cell4-normal" +save_integrity_missing_review +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { "trust": "normal" } + } +} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +assert_eq "normal: upstream_status.review = integrity_missing" \ + "integrity_missing" "$(echo "$out" | jq -r '.upstream_status.review')" +# upstream_artifacts.review should be a string path (not null), since +# legacy artifacts still load under normal trust. +art=$(echo "$out" | jq -r '.upstream_artifacts.review // ""') +assert_eq "normal: upstream_artifacts.review is the stored path" \ + "true" "$( [ -n "$art" ] && echo "true" || echo "false" )" + +# Cell 5: max_age_days overrides the default per-phase age. We mark +# an artifact 90 days old; the default is 30, so max_age_days = 120 +# is required to load it. +echo "[5] max_age_days overrides the per-phase age" +new_project "cell5-age" +save_verified_review +art_path=$(ls "$NANOSTACK_STORE/review/"*.json | head -1) +# Touch the artifact's mtime 90 days into the past. +touch -t "$(date -u -v-90d +%Y%m%d%H%M.%S 2>/dev/null || date -u --date='90 days ago' +%Y%m%d%H%M.%S)" "$art_path" 2>/dev/null || true +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { "max_age_days": 120 } + } +} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +assert_eq "max_age_days = 120 loads a 90-day-old verified artifact" \ + "verified" "$(echo "$out" | jq -r '.upstream_status.review')" +assert_eq "routing.max_age_days = 120 reported" "120" \ + "$(echo "$out" | jq -r '.routing.max_age_days')" + +# Cell 6: solution_tags loads matching solutions filtered by content. +echo "[6] solution_tags filters know-how/solutions" +new_project "cell6-solutions" +cat > "$NANOSTACK_STORE/know-how/solutions/license-resolution.md" <<'EOF' +--- +tags: [license, oss] +--- +license stuff +EOF +cat > "$NANOSTACK_STORE/know-how/solutions/random.md" <<'EOF' +--- +tags: [debug] +--- +unrelated content +EOF +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { + "solutions": { "tags": ["license"], "limit": 5 } + } + } +} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +sol_count=$(echo "$out" | jq '.solutions | length') +sol_first=$(echo "$out" | jq -r '.solutions[0] // ""') +assert_eq "solutions filtered to license-tagged file" "1" "$sol_count" +case "$sol_first" in + *license-resolution.md) PASS=$((PASS+1)); printf " ${GREEN}OK${NC} %s\n" "selected solution is license-resolution.md" ;; + *) FAIL=$((FAIL+1)); printf " ${RED}FAIL${NC} %s (got %s)\n" "selected solution is license-resolution.md" "$sol_first" ;; +esac + +# Cell 7: diarization paths / keywords. A diarization whose subject +# matches one of the declared paths is loaded. +echo "[7] diarization paths load matching subjects" +new_project "cell7-diarizations" +cat > "$NANOSTACK_STORE/know-how/diarizations/2026-04-01-pkg.md" <<'EOF' +--- +subject: package.json +date: 2026-04-01 +--- +notes about package.json +EOF +cat > "$NANOSTACK_STORE/know-how/diarizations/2026-04-01-other.md" <<'EOF' +--- +subject: src/unrelated +date: 2026-04-01 +--- +unrelated notes +EOF +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { + "diarizations": { "paths": ["package.json"], "keywords": [] } + } + } +} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +diar_count=$(echo "$out" | jq '.diarizations | length') +diar_subj=$(echo "$out" | jq -r '.diarizations[0].subject // ""') +assert_eq "diarizations filtered to package.json subject" "1" "$diar_count" +assert_eq "diarization subject = package.json" "package.json" "$diar_subj" + +# Cell 7a: a routed upstream that is NOT in depends_on still gets +# its artifact looked up. The routing contract is supposed to give +# skills a way to ask for context outside the dependency edges; +# Codex caught the missing wiring on the PR 5 first review pass +# (declaring upstream_optional: ["security"] without depends_on +# left security absent from upstream_status entirely). +echo "[7a] routed upstreams not in depends_on are still resolved" +new_project "cell7a-routed-only" +"$REPO/bin/save-artifact.sh" security \ + '{"phase":"security","summary":{"v":1},"findings":[],"context_checkpoint":{"summary":"routing test"}}' >/dev/null +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { + "upstream_optional": ["security"] + } + } +} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +assert_eq "upstream_status.security present even though only routed" \ + "verified" "$(echo "$out" | jq -r '.upstream_status.security')" +sec_art=$(echo "$out" | jq -r '.upstream_artifacts.security // ""') +assert_eq "upstream_artifacts.security is the resolved path" \ + "true" "$( [ -n "$sec_art" ] && echo "true" || echo "false" )" + +# Cell 7b: phase_context can live in the global ~/.nanostack/ +# config.json. nano_phase_kind already consults the global file as a +# fallback; resolve.sh now uses the same config-resolution path so +# routing intent is honored even when the project has no local +# config.json. Codex caught the missed fallback on the PR 5 second +# review pass. +echo "[7b] phase_context in the global ~/.nanostack/config.json is honored" +GLOBAL_HOME="$TMP_ROOT/global-home" +GLOBAL_PROJ="$TMP_ROOT/global-proj" +mkdir -p "$GLOBAL_HOME/.nanostack" \ + "$GLOBAL_PROJ/.nanostack/skills/license-audit" +cat > "$GLOBAL_HOME/.nanostack/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { + "trust": "strict", + "upstream_required": ["review"] + } + } +} +EOF +cat > "$GLOBAL_PROJ/.nanostack/skills/license-audit/SKILL.md" <<'EOF' +--- +name: license-audit +description: routed via global config +concurrency: read +--- +EOF +cd "$GLOBAL_PROJ" +git init -q +out=$(HOME="$GLOBAL_HOME" NANOSTACK_STORE="$GLOBAL_PROJ/.nanostack" \ + "$REPO/bin/resolve.sh" license-audit 2>/dev/null) +assert_eq "global config: routing.declared = true" "true" \ + "$(echo "$out" | jq -r '.routing.declared')" +assert_eq "global config: routing.trust = strict" "strict" \ + "$(echo "$out" | jq -r '.routing.trust')" + +# Cell 7c: diarization paths and keywords are matched literally, not +# as regex. A subject like app/users/[id]/page.tsx must not match a +# decoy app/users/i/page.tsx even though [id] reads as a character +# class under default grep. Codex caught the regex-vs-literal bug on +# the PR 5 second review pass. +echo "[7c] diarization paths are literal substrings, not regex" +new_project "cell7c-literal" +cat > "$NANOSTACK_STORE/know-how/diarizations/exact.md" <<'EOF' +--- +subject: app/users/[id]/page.tsx +date: 2026-04-01 +--- +EOF +cat > "$NANOSTACK_STORE/know-how/diarizations/decoy.md" <<'EOF' +--- +subject: app/users/i/page.tsx +date: 2026-04-01 +--- +EOF +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { + "diarizations": { "paths": ["app/users/[id]/page.tsx"], "keywords": [] } + } + } +} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +diar_subjects=$(echo "$out" | jq -c '.diarizations | map(.subject) | sort') +assert_eq "literal match: only the exact subject is loaded" \ + '["app/users/[id]/page.tsx"]' "$diar_subjects" + +# Cell 7d: solution_tags are also matched literally, not as regex. +# A tag like next.js or app/users/[id] must not match unrelated +# files because of the `.` or `[id]` characters. Codex caught the +# regex interpretation on the PR 5 third review pass. +echo "[7d] solution_tags are literal substrings, not regex" +new_project "cell7d-tag-literal" +cat > "$NANOSTACK_STORE/know-how/solutions/exact.md" <<'EOF' +--- +tags: [next.js] +--- +next.js notes +EOF +cat > "$NANOSTACK_STORE/know-how/solutions/decoy.md" <<'EOF' +--- +tags: [nextxjs] +--- +nextxjs notes (decoy must contain neither the literal tag nor a regex-equivalent) +EOF +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { + "solutions": { "tags": ["next.js"], "limit": 5 } + } + } +} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +sol_count=$(echo "$out" | jq '.solutions | length') +sol_first=$(echo "$out" | jq -r '.solutions[0] // ""') +assert_eq "literal tag: only the exact tag file matches" "1" "$sol_count" +case "$sol_first" in + *exact.md) PASS=$((PASS+1)); printf " ${GREEN}OK${NC} %s\n" "selected solution is exact.md" ;; + *) FAIL=$((FAIL+1)); printf " ${RED}FAIL${NC} %s (got %s)\n" "selected solution is exact.md" "$sol_first" ;; +esac + +# Cell 7e: diarization subjects/paths containing JSON metacharacters +# (quotes, backslashes) must still produce valid JSON. The previous +# string-concat path emitted invalid JSON for a subject like +# app/"weird"/path.tsx; the resolver now builds the array through jq +# so the escape is automatic. Codex caught the injection on the PR 5 +# third review pass. +echo "[7e] diarization subjects with JSON metacharacters parse cleanly" +new_project "cell7e-json-safe" +cat > "$NANOSTACK_STORE/know-how/diarizations/quoted.md" <<'EOF' +--- +subject: app/"weird"/path.tsx +date: 2026-04-01 +--- +EOF +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { + "diarizations": { "paths": ["weird"], "keywords": [] } + } + } +} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +diar_subject=$(echo "$out" | jq -r '.diarizations[0].subject // ""') +assert_eq "quote in subject lands intact" 'app/"weird"/path.tsx' "$diar_subject" + +# Cell 8: routing block surfaces every applied field so consumers +# can audit what the resolver did. +echo "[8] routing block surfaces every applied field" +new_project "cell8-routing-shape" +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { + "trust": "strict", + "upstream_required": ["review"], + "upstream_optional": ["security"], + "max_age_days": 7, + "solutions": { "tags": ["compliance"], "limit": 3 }, + "diarizations": { "paths": ["src/privacy"], "keywords": ["pii"] } + } + } +} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +assert_eq "routing.declared = true" "true" "$(echo "$out" | jq -r '.routing.declared')" +assert_eq "routing.trust = strict" "strict" "$(echo "$out" | jq -r '.routing.trust')" +assert_eq "routing.upstream_required" '["review"]' "$(echo "$out" | jq -c '.routing.upstream_required')" +assert_eq "routing.upstream_optional" '["security"]' "$(echo "$out" | jq -c '.routing.upstream_optional')" +assert_eq "routing.max_age_days" "7" "$(echo "$out" | jq -r '.routing.max_age_days')" +assert_eq "routing.solutions.tags" '["compliance"]' "$(echo "$out" | jq -c '.routing.solutions.tags')" +assert_eq "routing.solutions.limit" "3" "$(echo "$out" | jq -r '.routing.solutions.limit')" +assert_eq "routing.diarizations.paths" '["src/privacy"]' "$(echo "$out" | jq -c '.routing.diarizations.paths')" +assert_eq "routing.diarizations.keywords" '["pii"]' "$(echo "$out" | jq -c '.routing.diarizations.keywords')" + +# Cell 8a: when tags are declared but limit is omitted, the routing +# block reports the documented default (10) instead of null so +# consumers auditing the routing output see what actually applied. +# Codex flagged the silent default on the PR 5 fourth review pass. +echo "[8a] routing.solutions.limit reports the documented default" +new_project "cell8a-default-limit" +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { + "solutions": { "tags": ["any"] } + } + } +} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +assert_eq "limit omitted with tags declared reports default 10" "10" \ + "$(echo "$out" | jq -r '.routing.solutions.limit')" +# Sanity: a config without phase_context at all stays null (no +# default leaks when nothing was declared). +new_project "cell8a-no-context" +cat > "$NANOSTACK_STORE/config.json" <<'EOF' +{"custom_phases": ["license-audit"]} +EOF +out=$("$REPO/bin/resolve.sh" license-audit 2>/dev/null) +assert_eq "no phase_context: routing.solutions.limit stays null" "null" \ + "$(echo "$out" | jq -r '.routing.solutions.limit')" + +cd "$TMP_ROOT" + +echo +echo "===========================" +TOTAL=$((PASS + FAIL)) +if [ "$FAIL" -eq 0 ]; then + printf "${GREEN}Custom Routing E2E: %d checks passed, 0 failed${NC}\n" "$PASS" + exit 0 +else + printf "${RED}Custom Routing E2E: %d failed of %d total${NC}\n" "$FAIL" "$TOTAL" + exit 1 +fi diff --git a/reference/custom-stack-contract.md b/reference/custom-stack-contract.md index 802c9ab..293d54f 100644 --- a/reference/custom-stack-contract.md +++ b/reference/custom-stack-contract.md @@ -75,12 +75,48 @@ The resolver looks for the custom phase's dependency list in this order: 2. **`depends_on:` in the skill's `SKILL.md` frontmatter**. Both inline (`depends_on: [plan, build]`) and block (`depends_on:\n - plan\n - build`) YAML list forms parse. The skill is found via `nano_phase_skill_path`. 3. If neither lists the phase, `upstream_artifacts` is `{}`. -## What the resolver does NOT do for custom phases (yet) +## Custom routing contract (PR 5 of architecture vNext) -- It does not load solutions, precedents, or diarizations. Skills that want those can call the helpers directly (`bin/find-solution.sh`, etc.). -- It does not read custom routing rules from a config file. Routing is currently keyed on the dependency list only. -- It does not enforce the conductor's `concurrency` field. That work belongs to PR 5 (conductor custom graph). -- It does not check skill discovery files (`agents/openai.yaml`). That belongs to PR 3 (copy-paste template) and PR 6 (`bin/check-custom-skill.sh`). +A custom skill can declare a `phase_context` block in `.nanostack/config.json` to tell `bin/resolve.sh` what shape of context it needs. Without a `phase_context` entry the resolver keeps the dependency-only behavior; with one it applies the declared fields and surfaces them in the `routing` block of its JSON output so downstream consumers can audit what was applied. + +```json +{ + "custom_phases": ["license-audit"], + "phase_context": { + "license-audit": { + "trust": "strict", + "upstream_required": ["review"], + "upstream_optional": ["security"], + "max_age_days": 7, + "solutions": { + "tags": ["license", "compliance"], + "limit": 3 + }, + "diarizations": { + "paths": ["package.json", "src/privacy"], + "keywords": ["pii"] + } + } + } +} +``` + +| Field | Default | Effect | +|-------|---------|--------| +| `trust` | `"normal"` | `"strict"` rejects `integrity_missing` artifacts (so a tampered or legacy file never lands in `upstream_artifacts`); `"normal"` keeps the historical lenient load. The artifact's trust state is always reported in `upstream_status` either way. | +| `upstream_required` | `[]` | Surfaces declared-required upstreams in `routing.upstream_required`. Missing artifacts for these phases already report `upstream_status[phase] = "missing"`; the routing block makes the intent explicit. | +| `upstream_optional` | `[]` | Surfaces declared-optional upstreams in `routing.upstream_optional`. Informational; consumers can use it to soften missing-warning logic. | +| `max_age_days` | per-phase default (30 days for custom) | Overrides the per-phase max age window. CLI `--max-age :` still takes precedence. | +| `solutions.tags` | `[]` | When non-empty, the resolver loads matching solutions from `/know-how/solutions` (case-insensitive substring match against file content). | +| `solutions.limit` | `10` | Cap on the number of solution paths returned. | +| `diarizations.paths` / `diarizations.keywords` | `[]` | When non-empty, the resolver loads diarizations whose `subject:` field matches any path or keyword (case-insensitive substring). Does not require a git diff to be present. | + +The full `routing` block always appears in `bin/resolve.sh`'s JSON output: `routing.declared` is `false` for core phases and for custom phases without a context entry. Custom skills can also declare context routing rules directly in their `SKILL.md` frontmatter once the feature is wired (currently the JSON config in `.nanostack/config.json` is the canonical location). + +## What the resolver still leaves to skill authors + +- It does not enforce the conductor's `concurrency` field. That responsibility lives in `guard/bin/check-dangerous.sh` (Tier 2.4) and `conductor/bin/sprint.sh batch`. +- It does not check skill discovery files (`agents/openai.yaml`). That belongs to `bin/check-custom-skill.sh`. ## Lifecycle outputs