From 5b8dd0754ba248841817bcfdff78091e1a48fbbe Mon Sep 17 00:00:00 2001 From: gus Date: Mon, 11 May 2026 02:24:01 -0300 Subject: [PATCH 1/5] custom-routing: phase_context contract for custom skills (PR 5 of architecture vNext) Custom skills could only declare dependency edges. resolve.sh loaded their upstream artifacts but did not honor any other routing intent. Skills that needed strict integrity reimplemented the check; skills that needed solutions or diarizations stayed dependency-blind. This change introduces a phase_context block in .nanostack/config.json that resolve.sh reads when the active phase is custom. Fields: trust: "strict" rejects integrity_missing artifacts (only verified paths land in upstream_artifacts); "normal" keeps the historical lenient load. upstream_required: list of phase names the skill considers required. Missing required upstreams already report upstream_status[phase] = "missing"; the routing block makes the intent explicit. upstream_optional: list of phase names the skill considers optional. Informational; consumers can soften missing-warning logic. max_age_days: per-phase age override. CLI --max-age still takes precedence. solutions.tags: non-empty list triggers a content-match load from /know-how/solutions, limited by solutions.limit (default 10). diarizations.paths diarizations.keywords: non-empty lists load matching diarizations whose subject contains any path or keyword (case-insensitive substring). Does not require a git diff to be present. bin/resolve.sh's JSON output gains a `routing` block that surfaces the applied context block (routing.declared is false for core phases and for custom phases without a context entry). Existing consumers that only read upstream_artifacts and upstream_status continue to work unchanged. reference/custom-stack-contract.md gains a "Custom routing contract" section with the schema, field semantics, and a worked example. EXTENDING.md points to it from the Two starting points section. ci/e2e-custom-routing.sh is a new 8-cell harness (25 checks) that locks each contract surface: backward compat (no phase_context), missing required upstream surfaces explicitly, strict trust drops integrity_missing, normal trust keeps it, max_age_days override, solution_tags filter, diarization paths filter, routing block shape. Wired into .github/workflows/e2e.yml. Lint adds custom-routing-contract: greps that resolve.sh reads phase_context and emits the routing block with every routing_* field, and that the contract doc mentions phase_context. Closes the P2 finding "Custom Routing Is Dependency-Only" from the 2026-05-10 Nanostack Architecture Audit vNext. --- .github/workflows/e2e.yml | 18 ++ .github/workflows/lint.yml | 37 ++++ EXTENDING.md | 2 + bin/resolve.sh | 211 +++++++++++++++++++- ci/e2e-custom-routing.sh | 299 +++++++++++++++++++++++++++++ reference/custom-stack-contract.md | 46 ++++- 6 files changed, 601 insertions(+), 12 deletions(-) create mode 100755 ci/e2e-custom-routing.sh 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..6f9ff4c 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,46 @@ 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 + CONFIG_FILE="$NANOSTACK_STORE/config.json" + if [ -f "$CONFIG_FILE" ] && command -v jq >/dev/null 2>&1; then + if jq -e --arg p "$PHASE" '.phase_context // {} | has($p)' "$CONFIG_FILE" >/dev/null 2>&1; then + ROUTING_DECLARED=true + ROUTING_TRUST=$(jq -r --arg p "$PHASE" '.phase_context[$p].trust // "normal"' "$CONFIG_FILE" 2>/dev/null) + ROUTING_REQUIRED_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].upstream_required // []' "$CONFIG_FILE" 2>/dev/null) + ROUTING_OPTIONAL_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].upstream_optional // []' "$CONFIG_FILE" 2>/dev/null) + ROUTING_MAX_AGE_DAYS=$(jq -r --arg p "$PHASE" '.phase_context[$p].max_age_days // ""' "$CONFIG_FILE" 2>/dev/null) + ROUTING_SOLUTION_TAGS_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].solutions.tags // []' "$CONFIG_FILE" 2>/dev/null) + ROUTING_SOLUTION_LIMIT=$(jq -r --arg p "$PHASE" '.phase_context[$p].solutions.limit // ""' "$CONFIG_FILE" 2>/dev/null) + ROUTING_DIARIZATION_PATHS_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].diarizations.paths // []' "$CONFIG_FILE" 2>/dev/null) + ROUTING_DIARIZATION_KEYWORDS_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].diarizations.keywords // []' "$CONFIG_FILE" 2>/dev/null) + case "$ROUTING_TRUST" in + strict|normal) ;; + *) ROUTING_TRUST="normal" ;; + esac + fi + fi +fi + # ─── 1. Resolve upstream artifacts ───────────────────────── # # upstream_artifacts keeps its historical shape: only verified paths @@ -190,6 +231,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 +256,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 +303,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 +357,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 -lri -- "$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 +460,59 @@ 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 + DIAR_RESULTS="[" + DFIRST=true + 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 + if printf '%s' "$SUBJECT" | grep -qi -- "$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 + $DFIRST || DIAR_RESULTS="$DIAR_RESULTS," + DIAR_RESULTS="$DIAR_RESULTS{\"path\":\"$dfile\",\"subject\":\"$SUBJECT\",\"age_days\":\"$AGE_DAYS\"}" + DFIRST=false + fi + done + DIARIZATIONS_JSON="$DIAR_RESULTS]" + 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 +579,23 @@ 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" +fi +ROUTING_DECLARED_JSON=false +[ "$ROUTING_DECLARED" = true ] && ROUTING_DECLARED_JSON=true + jq -n \ --arg phase "$PHASE" \ --arg phase_kind "$PHASE_KIND" \ @@ -434,6 +607,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 +626,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..9ea50e1 --- /dev/null +++ b/ci/e2e-custom-routing.sh @@ -0,0 +1,299 @@ +#!/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 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')" + +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 From a525e842e1268eb42ae48ce80c153e20dbb9fe14 Mon Sep 17 00:00:00 2001 From: gus Date: Mon, 11 May 2026 02:28:07 -0300 Subject: [PATCH 2/5] custom-routing: routed-only upstreams are also looked up (codex pass 1) When a custom skill declared upstream_required or upstream_optional in phase_context without ALSO listing those phases in depends_on or the graph, resolve.sh only echoed the lists in routing.* and never actually resolved the artifact. upstream_status stayed empty for those phases. Codex caught the gap on the PR 5 first review pass: declaring upstream_optional: ["security"] without depends_on left security absent from upstream_status entirely. The phase_context reader now merges upstream_required and upstream_optional into the UPSTREAM list (deduping against entries already added from depends_on / phase_graph). The default age for those merged entries follows routing.max_age_days when set, otherwise the per-phase 30-day custom default. CLI --max-age still takes precedence on the override pass. Cell 7a of ci/e2e-custom-routing.sh locks the behavior: a license- audit skill that only depends on review can declare upstream_optional: ["security"] and see security artifacts land in upstream_artifacts + upstream_status. --- bin/resolve.sh | 32 ++++++++++++++++++++++++++++++++ ci/e2e-custom-routing.sh | 27 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/bin/resolve.sh b/bin/resolve.sh index 6f9ff4c..cd2e7a7 100755 --- a/bin/resolve.sh +++ b/bin/resolve.sh @@ -217,6 +217,38 @@ if [ "$PHASE_KIND" = "custom" ]; then 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 diff --git a/ci/e2e-custom-routing.sh b/ci/e2e-custom-routing.sh index 9ea50e1..4051c31 100755 --- a/ci/e2e-custom-routing.sh +++ b/ci/e2e-custom-routing.sh @@ -255,6 +255,33 @@ 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 8: routing block surfaces every applied field so consumers # can audit what the resolver did. echo "[8] routing block surfaces every applied field" From cabb0921610e291276416de65a36892d2176d921 Mon Sep 17 00:00:00 2001 From: gus Date: Mon, 11 May 2026 02:34:13 -0300 Subject: [PATCH 3/5] custom-routing: global config fallback + literal path matching (codex pass 2) Two findings from the PR 5 second Codex review: [P2] resolve.sh used \$NANOSTACK_STORE/config.json directly when reading phase_context. nano_phase_kind already consults the global ~/.nanostack/config.json as a fallback, so a project without a local config silently lost its routing intent. resolve.sh now calls _nano_phases_resolve_config (the same helper the registry uses), so routing reads from whichever config the registry chose. [P2] The diarization path/keyword filter used `grep -qi --` which interprets regex metacharacters. A declared path like app/users/[id]/page.tsx then matched unrelated subjects such as app/users/i/page.tsx because [id] read as a character class. Switched to `grep -qiF --` so the filters behave as literal substring matches across the whole subject line. Cells 7b and 7c lock both fixes: 7b sets phase_context only in the fake-HOME global config and asserts routing.trust = strict propagates; 7c declares an [id] path and asserts only the exact subject loads (no decoy match). --- bin/resolve.sh | 37 ++++++++++++++------- ci/e2e-custom-routing.sh | 72 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/bin/resolve.sh b/bin/resolve.sh index cd2e7a7..2c27587 100755 --- a/bin/resolve.sh +++ b/bin/resolve.sh @@ -201,18 +201,27 @@ ROUTING_DIARIZATION_PATHS_JSON="[]" ROUTING_DIARIZATION_KEYWORDS_JSON="[]" ROUTING_DECLARED=false if [ "$PHASE_KIND" = "custom" ]; then - CONFIG_FILE="$NANOSTACK_STORE/config.json" - if [ -f "$CONFIG_FILE" ] && command -v jq >/dev/null 2>&1; then - if jq -e --arg p "$PHASE" '.phase_context // {} | has($p)' "$CONFIG_FILE" >/dev/null 2>&1; 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"' "$CONFIG_FILE" 2>/dev/null) - ROUTING_REQUIRED_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].upstream_required // []' "$CONFIG_FILE" 2>/dev/null) - ROUTING_OPTIONAL_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].upstream_optional // []' "$CONFIG_FILE" 2>/dev/null) - ROUTING_MAX_AGE_DAYS=$(jq -r --arg p "$PHASE" '.phase_context[$p].max_age_days // ""' "$CONFIG_FILE" 2>/dev/null) - ROUTING_SOLUTION_TAGS_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].solutions.tags // []' "$CONFIG_FILE" 2>/dev/null) - ROUTING_SOLUTION_LIMIT=$(jq -r --arg p "$PHASE" '.phase_context[$p].solutions.limit // ""' "$CONFIG_FILE" 2>/dev/null) - ROUTING_DIARIZATION_PATHS_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].diarizations.paths // []' "$CONFIG_FILE" 2>/dev/null) - ROUTING_DIARIZATION_KEYWORDS_JSON=$(jq -c --arg p "$PHASE" '.phase_context[$p].diarizations.keywords // []' "$CONFIG_FILE" 2>/dev/null) + 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" ;; @@ -518,7 +527,11 @@ if [ "$PHASE_KIND" = "custom" ] \ matched=false while IFS= read -r needle; do [ -z "$needle" ] && continue - if printf '%s' "$SUBJECT" | grep -qi -- "$needle" 2>/dev/null; then + # -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 diff --git a/ci/e2e-custom-routing.sh b/ci/e2e-custom-routing.sh index 4051c31..91d9572 100755 --- a/ci/e2e-custom-routing.sh +++ b/ci/e2e-custom-routing.sh @@ -282,6 +282,78 @@ 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 8: routing block surfaces every applied field so consumers # can audit what the resolver did. echo "[8] routing block surfaces every applied field" From b318b4c3bc5422e9766ac8811007471343dae320 Mon Sep 17 00:00:00 2001 From: gus Date: Mon, 11 May 2026 02:38:50 -0300 Subject: [PATCH 4/5] custom-routing: literal solution_tags + jq-built diarizations (codex pass 3) Two findings from the PR 5 third Codex review: [P2] solution_tags also used `grep -lri` which is regex. A tag like "next.js" or "app/users/[id]" would match unrelated solutions because of the `.` or `[id]` characters. Switched to `grep -lriF` so the filter behaves as a literal substring match like diarization paths now do. [P2] The custom-phase diarizations block built JSON via string concatenation (`"$DIAR_RESULTS{...}"`). Any subject or path containing a quote, backslash, or other JSON metacharacter produced invalid JSON and broke the final `jq --argjson diarizations` parse. The block now iterates jq calls so each {path, subject, age_days} object is built with proper escaping. Cells 7d and 7e lock both: 7d declares a "next.js" tag and asserts only the exact-tag file loads (not a nextxjs decoy); 7e declares a diarization path filter and asserts a subject like app/"weird"/path.tsx lands intact in the JSON output. --- bin/resolve.sh | 18 ++++++----- ci/e2e-custom-routing.sh | 65 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/bin/resolve.sh b/bin/resolve.sh index 2c27587..3c8d31b 100755 --- a/bin/resolve.sh +++ b/bin/resolve.sh @@ -416,7 +416,7 @@ if [ "$SKIP_SOLUTIONS_FLAG" = false ] && [ "$PHASE_KIND" = "custom" ] \ [ -z "$sol" ] && continue tag_matches="${tag_matches}${sol} " - done < <(grep -lri -- "$tag" "$NANOSTACK_STORE/know-how/solutions" 2>/dev/null | head -"$custom_limit") + 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 '.') @@ -518,8 +518,11 @@ if [ "$PHASE_KIND" = "custom" ] \ " done < <(echo "$ROUTING_DIARIZATION_PATHS_JSON $ROUTING_DIARIZATION_KEYWORDS_JSON" | jq -r '.[]?' 2>/dev/null) if [ -n "$needles" ]; then - DIAR_RESULTS="[" - DFIRST=true + # 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') @@ -547,12 +550,13 @@ if [ "$PHASE_KIND" = "custom" ] \ AGE_DAYS=$(( (NOW_EPOCH - FILE_EPOCH) / 86400 )) fi fi - $DFIRST || DIAR_RESULTS="$DIAR_RESULTS," - DIAR_RESULTS="$DIAR_RESULTS{\"path\":\"$dfile\",\"subject\":\"$SUBJECT\",\"age_days\":\"$AGE_DAYS\"}" - DFIRST=false + 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 - DIARIZATIONS_JSON="$DIAR_RESULTS]" fi fi fi diff --git a/ci/e2e-custom-routing.sh b/ci/e2e-custom-routing.sh index 91d9572..3d8728e 100755 --- a/ci/e2e-custom-routing.sh +++ b/ci/e2e-custom-routing.sh @@ -354,6 +354,71 @@ 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" From b787eab006846d7c5e94f2f990e504056f9b88c5 Mon Sep 17 00:00:00 2001 From: gus Date: Mon, 11 May 2026 02:44:16 -0300 Subject: [PATCH 5/5] custom-routing: routing.solutions.limit reports the default (codex pass 4) When a custom phase declared solutions.tags but omitted solutions.limit, the resolver applied the documented default (10) for the actual lookup but reported routing.solutions.limit as null in its output. Consumers auditing the routing block would see disagreement with the documented default and the actual lookup. Codex flagged the silent default on the PR 5 fourth review pass. The output now reports 10 when tags are declared and limit is omitted, and stays null when no phase_context was declared at all (no implicit value leak). Cell 8a covers both branches. --- bin/resolve.sh | 6 ++++++ ci/e2e-custom-routing.sh | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/bin/resolve.sh b/bin/resolve.sh index 3c8d31b..f31acc6 100755 --- a/bin/resolve.sh +++ b/bin/resolve.sh @@ -641,6 +641,12 @@ 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 diff --git a/ci/e2e-custom-routing.sh b/ci/e2e-custom-routing.sh index 3d8728e..22dcfb1 100755 --- a/ci/e2e-custom-routing.sh +++ b/ci/e2e-custom-routing.sh @@ -449,6 +449,35 @@ assert_eq "routing.solutions.limit" "3" "$(echo "$out" | jq -r '.routing.solutio 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