forgedock 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/bin/engine/phases-args.test.mjs +2 -1
  2. package/bin/engine/phases.mjs +349 -90
  3. package/bin/engine/runlog.mjs +25 -2
  4. package/bin/engine-cli.mjs +4 -1
  5. package/bin/engine.mjs +72 -19
  6. package/bin/hooks/interactive-engine.mjs +135 -209
  7. package/bin/hooks/pre-tool-use.mjs +16 -4
  8. package/bin/labels.json +10 -0
  9. package/bin/opencode-adapter.mjs +1 -1
  10. package/bin/orchestrate-preflight.mjs +1 -1
  11. package/bin/runner.mjs +258 -23
  12. package/bin/watch-utils.mjs +1 -0
  13. package/commands/autopilot.md +6 -6
  14. package/commands/cleanup.md +8 -5
  15. package/commands/orchestrate/phase-1-resolve.md +1 -1
  16. package/commands/orchestrate/phase-3-dependency.md +14 -19
  17. package/commands/orchestrate/phase-4-execution.md +80 -53
  18. package/commands/orchestrate/phase-6-report.md +6 -0
  19. package/commands/orchestrate/safety.md +1 -1
  20. package/commands/pipeline-health/phase-2-metrics.md +39 -0
  21. package/commands/pipeline-health/phase-5-report.md +1 -0
  22. package/commands/pipeline-resume.md +1 -1
  23. package/commands/pipeline-status.md +2 -2
  24. package/commands/recover-orphans.md +5 -5
  25. package/commands/review-pr-agents/api.md +1 -1
  26. package/commands/review-pr-agents/auth.md +1 -1
  27. package/commands/review-pr-agents/infra.md +3 -3
  28. package/commands/review-pr-agents/protocols.md +15 -6
  29. package/commands/review-pr-agents/scraper.md +1 -1
  30. package/commands/review-pr-agents/security.md +5 -5
  31. package/commands/review-pr-agents.md +12 -12
  32. package/commands/review-pr-staging.md +226 -8
  33. package/commands/review-pr.md +394 -53
  34. package/commands/work-on/build/architect.md +36 -0
  35. package/commands/work-on/build/implement.md +1 -1
  36. package/commands/work-on/build/validate.md +88 -4
  37. package/commands/work-on/build.md +106 -7
  38. package/commands/work-on/decompose.md +2 -2
  39. package/commands/work-on/investigate.md +14 -4
  40. package/commands/work-on/remediate.md +137 -25
  41. package/commands/work-on/review.md +25 -9
  42. package/commands/work-on.md +15 -15
  43. package/docs/CONFIG.md +29 -0
  44. package/docs/RELEASE-NOTES.md +45 -0
  45. package/docs/spec/forge-protocol-v1.md +22 -1
  46. package/docs/spec/label-state-machine.md +6 -1
  47. package/docs/spec/review-protocol.md +20 -5
  48. package/package.json +2 -1
  49. package/packages/protocol/src/phases.js +21 -0
  50. package/scripts/check-blast-radius.sh +164 -0
  51. package/scripts/check-blast-radius.test.sh +183 -0
  52. package/scripts/check-contract-scope.sh +137 -0
  53. package/scripts/check-contract-scope.test.sh +83 -0
  54. package/scripts/check-spec-bash.sh +60 -3
  55. package/scripts/check-spec-bash.test.sh +56 -0
  56. package/scripts/check-spec-markers.sh +29 -0
  57. package/scripts/check-test-wiring.sh +57 -0
  58. package/scripts/check-test-wiring.test.sh +70 -0
  59. package/scripts/classify-finding.sh +72 -8
  60. package/scripts/classify-finding.test.sh +77 -8
  61. package/scripts/close-dossier-no-main-commit.test.sh +12 -12
  62. package/scripts/diff-size.test.sh +3 -2
  63. package/scripts/doctor-pipeline-state.sh +35 -0
  64. package/scripts/forge-annotation.sh +3 -0
  65. package/scripts/forge-root.test.sh +103 -13
  66. package/scripts/forge-run.sh +2 -2
  67. package/scripts/recovery-claim-live.test.sh +10 -7
  68. package/scripts/remediate-basesync.test.sh +103 -0
  69. package/scripts/review-delta.sh +206 -0
  70. package/scripts/review-delta.test.sh +213 -0
  71. package/scripts/review-pr-domain-map.test.sh +87 -0
  72. package/scripts/review-pr-gate2.test.sh +84 -0
  73. package/scripts/review-pr-staging-scope.test.sh +99 -0
  74. package/scripts/run-shell-tests.sh +115 -0
  75. package/scripts/select-fix-targets.sh +17 -5
  76. package/scripts/transition-label.sh +3 -1
@@ -495,6 +495,8 @@ grep -rn "$GATED_FUNCTION" "$SERVICE_DIR" --include="*.py" | grep -v "#"
495
495
 
496
496
  For each call site found: read the surrounding gate condition (±10 lines). If the condition includes fields that do NOT require the gated resource (e.g., `extraction_schema` gated behind an LLM key check when `extraction_schema` is processed without an LLM), add that caller file to the FORGE:ARCHITECT affected-paths table with a note explaining the incorrect gate condition.
497
497
 
498
+ When Phase A2.8 builds the blast-radius manifest, run the sweep over the whole repository (`git grep`), not only `$SERVICE_DIR` — siblings in other directories are the ones a directory-scoped sweep misses.
499
+
498
500
  **Do NOT** omit sibling callers from the affected-paths table simply because they were not listed in the issue spec. The architect's scope is determined by code correctness, not by the issue spec's file list. A gate-condition bug that exists identically in 3 router files must be fixed in all 3 — even if the issue only named 1.
499
501
 
500
502
  ---
@@ -575,6 +577,30 @@ The comment below passes the table through in a `### Pattern Sweep` section so t
575
577
 
576
578
  ---
577
579
 
580
+ ## Phase A2.8: Blast-Radius Manifest *(skip when no existing symbol, field, flag, schema or contract changes)* <!-- Added: forge#3446 -->
581
+
582
+ The caller and sibling sweep (A1, A2.2) must leave a record a machine can check. For every **existing** symbol, field, flag, schema or contract the change modifies, emit one `SYMBOL:` record and one `HIT:` record per file that mentions it. `scripts/check-blast-radius.sh` re-runs each query against the final tree during validate (Phase V4.5) and fails when a matching file is neither changed nor marked `verified-unaffected`.
583
+
584
+ 1. **Pick a distinctive query per symbol** — a fixed string matching `^[A-Za-z0-9_.:/-]{3,80}$`, not starting with `-` (for example `billing_principal`, not `id`). The checker runs it as `git grep -l -F -e "$Q"`: a literal search, no regex. A query matching more than 200 files is rejected as too broad, so narrow it.
585
+ 2. **Run each query repo-wide** and record every hit file:
586
+ ```bash
587
+ Q="{SYMBOL_QUERY}"
588
+ git grep -l -F -e "$Q" -- .
589
+ ```
590
+ A grep failure (exit above 1) is not "zero hits": report it and stop instead of emitting an empty manifest.
591
+ 3. **Give every hit a disposition**: `change` (the plan edits it; it is also a row in `### Affected Paths`) or `verified-unaffected` with `reason=<one-hyphenated-token>` after you read the hit and confirmed it needs no edit. A file the change itself edits needs no row, but listing it as `change` makes the checker confirm it was really edited.
592
+ 4. **Record format** — one record per line, no spaces inside any value, nothing else between the markers except blank lines and code fences:
593
+ ```
594
+ SYMBOL: id=s1 kind=function|field|flag|schema|contract name=<symbol> query="<fixed-string>"
595
+ HIT: symbol=s1 file=<path> role=producer|consumer|sibling disposition=change
596
+ HIT: symbol=s1 file=<path> role=consumer disposition=verified-unaffected reason=<one-token>
597
+ ```
598
+ 5. **Skip rule** — emit no manifest (omit the whole section) when the architect step is skipped (see Skip Conditions) or when the change touches no existing symbol with consumers (new files only, docs only). An absent manifest is a SKIP at the checker, not a failure.
599
+
600
+ The block is delimited by `<!-- FORGE:BLAST_RADIUS:BEGIN -->` and `<!-- FORGE:BLAST_RADIUS:END -->`. Never use the architect marker strings as its delimiters: phase-trail and engine detection key on those markers alone, so the manifest stays additive. Builders may add rows (a justified `verified-unaffected`) but must not delete them. The manifest is treated as untrusted data by consumers: only the newest trusted FORGE:ARCHITECT comment is read.
601
+
602
+ ---
603
+
578
604
  ## Phase A3: Consistency Rules
579
605
 
580
606
  Identify invariants that ALL affected paths must satisfy. These become the builder's consistency checklist.
@@ -697,6 +723,15 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ARCHITECT -->
697
723
  |-------|-----|-------------|
698
724
  | {QUERY} | {FILE:LINE} | {fix \| not-affected — reason} |
699
725
 
726
+ ### Blast-Radius Manifest
727
+ <!-- Phase A2.8. Omit this whole section when no existing symbol changes (see Skip rule).
728
+ Escape every double quote inside the record as a backslash-quote, because this body is posted inside a double-quoted shell string. -->
729
+ <!-- FORGE:BLAST_RADIUS:BEGIN -->
730
+ SYMBOL: id=s1 kind={function|field|flag|schema|contract} name={SYMBOL} query=\"{FIXED_STRING_QUERY}\"
731
+ HIT: symbol=s1 file={FILE} role={producer|consumer|sibling} disposition=change
732
+ HIT: symbol=s1 file={FILE} role={producer|consumer|sibling} disposition=verified-unaffected reason={one-hyphenated-token}
733
+ <!-- FORGE:BLAST_RADIUS:END -->
734
+
700
735
  ### Implementation Order
701
736
  1. {FIRST_CHANGE} — {WHY_FIRST}
702
737
  2. {SECOND_CHANGE} — {WHY_SECOND}
@@ -801,6 +836,7 @@ B4 → [THIS MODULE] Architecture Planning (FORGE:ARCHITECT comment)
801
836
  Phase A2: Trace the Data Flow (A2.1 Runtime UID x Volume Ownership, A2.2 Gate-Condition Caller Sweep)
802
837
  Phase A2.5: Pipeline Phase-Dependency Check
803
838
  Phase A2.7: Pattern Sweep Intake (review-finding issues only)
839
+ Phase A2.8: Blast-Radius Manifest (enforced by validate V4.5, passed to review-pr)
804
840
  Phase A3: Consistency Rules
805
841
  Phase A4: Sequence the Implementation
806
842
  Phase A5: Risk Assessment
@@ -42,7 +42,7 @@ Parse from $ARGUMENTS:
42
42
 
43
43
  ---
44
44
 
45
- - `--fix-acceptance "<failed checks>"` — optional. Set only by build B6.5 after a failed acceptance gate. In this mode, skip I1–I2 planning: read the listed failing `ACCEPTANCE_CHECK` ids from the FORGE:INVESTIGATOR comment, change the code in `{WORKTREE_PATH}` until each check's target/matcher holds (do not edit the checks), update the BUILDER comment's `### Changes` list, and return `IMPLEMENT_RESULT: status: COMPLETE`. If a check is impossible as written (contradicts the contract), return `status: BLOCKED` with that reason.
45
+ - `--fix-acceptance "<failed checks>"` — optional. Set only by build B6.5 after a failed acceptance gate. In this mode, skip I1–I2 planning: read the listed failing `ACCEPTANCE_CHECK` ids from the FORGE:INVESTIGATOR comment, change the code in `{WORKTREE_PATH}` until each check's target/matcher holds (do not edit the checks), update the BUILDER comment's `### Changes` list, and return `IMPLEMENT_RESULT: status: COMPLETE`. If a check is impossible as written (contradicts the contract), return `status: BLOCKED` with that reason. **Decomposition refusal** <!-- Added: forge#3543 -->: never implement work that the FORGE:INVESTIGATOR `### Decomposition Assessment` (`**YES**`) assigns to a separate sub-issue, even if a listed check targets it; return `status: BLOCKED` with blocker "check <id> belongs to decomposed item <N>" instead.
46
46
 
47
47
  ## Phase I1: Load Context from GitHub
48
48
 
@@ -43,7 +43,7 @@ Parse from $ARGUMENTS:
43
43
 
44
44
  ## Skip Conditions
45
45
 
46
- Skip Phases V0–V4 (set `GATE_PASSED=true`, iterations 0) and go straight to Phase V5 (which posts the skip-path quality-gate marker, commits, and marks the build complete) if:
46
+ Skip Phases V0–V4.5 (set `GATE_PASSED=true`, iterations 0) and go straight to Phase V5 (which posts the skip-path quality-gate marker, commits, and marks the build complete) if:
47
47
  - Only 1 file was changed AND the shared predicate classifies it as documentation: `printf '%s\n' "<that file>" | bash "$FORGE_ROOT/scripts/is-docs-only.sh"` exits 0 (resolve `FORGE_ROOT` with the canonical block; unresolvable → the gate runs). A single file under `commands/`, `.claude/`, `.agents/`, `hooks/`, … or any agent-instruction Markdown is **code** for this purpose (it contains executable shell and agent instructions) and is never skipped. <!-- field test: phase-4-execution.md was skipped as "docs" -->
48
48
 
49
49
  In all other cases, the gate MUST run.
@@ -508,6 +508,86 @@ docker exec {CONTAINER_NAME} env | grep {VAR_NAME}
508
508
 
509
509
  ---
510
510
 
511
+ ## Phase V4.5: Blast-Radius Check *(runs after V4, before V5 — skipped only on the docs-only skip path)* <!-- Added: forge#3446 -->
512
+
513
+ The architect records the caller and sibling sweep as a machine-readable manifest between `<!-- FORGE:BLAST_RADIUS:BEGIN -->` and `<!-- FORGE:BLAST_RADIUS:END -->` inside its FORGE:ARCHITECT comment. This phase runs `check-blast-radius.sh`, a script and not a prompt, which re-runs every manifest query against the final tree and fails on any matching file that is neither changed nor marked `verified-unaffected`.
514
+
515
+ - **Trust**: the manifest drives a gate, so it is read only from the newest comment that `trusted-comments.sh` accepts (the same trust predicate as the phase-trail verifier). An untrusted comment is never parsed.
516
+ - **Install root only**: both scripts resolve from `FORGE_ROOT` and never from the worktree, because the branch under audit must not supply its own gate. An unresolvable script is DEGRADED, never a pass.
517
+ - **Exit codes**: `0` covered or no manifest (SKIP); `1` an `UNLISTED:` or `NOT_DONE:` line was printed, so set `GATE_PASSED=false`, fix the code (or add a justified `verified-unaffected` row to the plan by editing the FORGE:ARCHITECT comment; never delete a row), and re-run V1 onward; anything else, a failed fetch, or an unresolvable script is DEGRADED: append `blast-radius` to `SKIPPED_CHECKS` so it appears in `verification_skipped`, and print the reason. DEGRADED never passes silently and a failed fetch is never treated as "no manifest".
518
+ - **Carry-forward**: each Bash call is a fresh shell, so V4.5's variables do not reach V5. The block prints its result as explicit lines: `BLAST_RADIUS_STATE: <PASS|SKIP|FAIL|DEGRADED>`, plus `GATE_PASSED: false` on FAIL and `SKIPPED_CHECKS+=blast-radius` on DEGRADED. Record those lines in your notes; V5 Step 1 sets `GATE_PASSED` and V5 Step 4 sets `SKIPPED_CHECKS` from them, alongside the V2 notes.
519
+
520
+ ```bash
521
+ cd {WORKTREE_PATH}
522
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
523
+ FORGE_ROOT=""
524
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
525
+ _h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
526
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
527
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
528
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
529
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
530
+ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
531
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
532
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
533
+ # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
534
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
535
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
536
+ # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
537
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
538
+ while IFS= read -r _c; do
539
+ case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
540
+ done <<< "$_k"
541
+ fi
542
+ BR_SCRIPT="${FORGE_ROOT:+$FORGE_ROOT/scripts/check-blast-radius.sh}"; [ -f "$BR_SCRIPT" ] || BR_SCRIPT=""
543
+ TRUST_SCRIPT="${FORGE_ROOT:+$FORGE_ROOT/scripts/trusted-comments.sh}"; [ -f "$TRUST_SCRIPT" ] || TRUST_SCRIPT=""
544
+ BR_STATE=""; BR_NOTE=""; BR_FILE=$(mktemp "${TMPDIR:-/tmp}/forge-blast-radius.XXXXXX")
545
+ if [ -z "$BR_SCRIPT" ] || [ -z "$TRUST_SCRIPT" ]; then
546
+ BR_STATE="DEGRADED"; BR_NOTE="check-blast-radius.sh or trusted-comments.sh not found under FORGE_ROOT"
547
+ else
548
+ COMMENTS_JSON=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments 2>/dev/null); FETCH_RC=$?
549
+ if [ "$FETCH_RC" -ne 0 ]; then
550
+ BR_STATE="DEGRADED"; BR_NOTE="could not fetch issue comments (rc=${FETCH_RC})"
551
+ else
552
+ BODIES=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUST_SCRIPT" bodies '^<!-- FORGE:ARCHITECT -->'); TRUST_RC=$?
553
+ if [ "$TRUST_RC" -ne 0 ]; then
554
+ BR_STATE="DEGRADED"; BR_NOTE="trusted-comments.sh failed (rc=${TRUST_RC})"
555
+ elif [ -z "$BODIES" ]; then
556
+ BR_STATE="SKIP"; BR_NOTE="no trusted FORGE:ARCHITECT comment"
557
+ else
558
+ # one JSON string per line, newest last
559
+ printf '%s\n' "$BODIES" | tail -n 1 | jq -r . > "$BR_FILE"; JQ_RC=$?
560
+ if [ "$JQ_RC" -ne 0 ]; then
561
+ BR_STATE="DEGRADED"; BR_NOTE="could not decode the FORGE:ARCHITECT comment (rc=${JQ_RC})"
562
+ else
563
+ git fetch origin {PR_BASE} >/dev/null 2>&1 || true
564
+ BR_OUT=$(bash "$BR_SCRIPT" --manifest "$BR_FILE" --base "origin/{PR_BASE}" --repo "{WORKTREE_PATH}" 2>&1); BR_RC=$?
565
+ printf '%s\n' "$BR_OUT"
566
+ case "$BR_RC" in
567
+ 0) BR_STATE="PASS" ;;
568
+ 1) BR_STATE="FAIL"; GATE_PASSED=false ;;
569
+ *) BR_STATE="DEGRADED"; BR_NOTE="check-blast-radius.sh exited ${BR_RC}" ;;
570
+ esac
571
+ fi
572
+ fi
573
+ fi
574
+ fi
575
+ rm -f "$BR_FILE"
576
+ echo "BLAST_RADIUS: ${BR_STATE}${BR_NOTE:+ — $BR_NOTE}"
577
+ # Carry-forward lines for V5 (fresh shell): read them back in V5 Step 1 and Step 4.
578
+ echo "BLAST_RADIUS_STATE: ${BR_STATE}"
579
+ if [ "$BR_STATE" = "FAIL" ]; then
580
+ echo "GATE_PASSED: false"
581
+ elif [ "$BR_STATE" = "DEGRADED" ]; then
582
+ SKIPPED_CHECKS="${SKIPPED_CHECKS:+$SKIPPED_CHECKS, }blast-radius"
583
+ echo "SKIPPED_CHECKS+=blast-radius"
584
+ fi
585
+ ```
586
+
587
+ On `FAIL`, the blocker line for `VALIDATE_RESULT` is `blast-radius: <UNLISTED/NOT_DONE lines>`; the repair loop (build B6.5) fixes the listed files, not the checker or the manifest.
588
+
589
+ ---
590
+
511
591
  ## Phase V5: Marker, Commit, Audit, Complete (always — after GATE_PASSED=true)
512
592
 
513
593
  Phase V5 runs in this exact order: **(1)** post the `FORGE:QUALITY_GATE` marker, **(2)** commit, **(3)** ancestry audit, **(4)** append `FORGE:BUILDER:COMPLETE` and the verification status. Do not reorder.
@@ -518,7 +598,10 @@ The quality gate must leave a checkable artifact. `scripts/verify-phase-trail.sh
518
598
 
519
599
  **Skip-path marker**: when the Skip Conditions above set `GATE_PASSED=true` early (single config/docs file), still post the marker with `**Result**: PASS (skipped — single config/docs file)` and `**Iterations**: 0`, so the verifier never has to guess. The verifier's `--docs-only` waiver additionally covers diffs accepted by `scripts/is-docs-only.sh` (allowlisted Markdown only: `docs/**` or root README/CHANGELOG/CONTRIBUTING/SECURITY/GOVERNANCE; nested `AGENTS.md`/`CLAUDE.md`/`SKILL.md`/`GEMINI.md` and the instruction directories `commands/`, `devdocs/`, `templates/`, `skills/`, `agents/`, `hooks/`, `.claude/`, `.claude-plugin/`, `.agents/`, `.codex/`, `.cursor/`, `.github/`, `.opencode/`, `.gemini/`, `.kiro/` excluded; callers feed both sides of renames).
520
600
 
601
+ Set `GATE_PASSED` from the V1 loop result, then apply V4.5's printed carry-forward line: if V4.5 printed `GATE_PASSED: false` (`BLAST_RADIUS_STATE: FAIL`), `GATE_PASSED` is `false` here even if the V1 loop passed.
602
+
521
603
  ```bash
604
+ # GATE_PASSED: set from the V1 loop result, and forced to false when V4.5 printed "GATE_PASSED: false"
522
605
  GATE_RESULT=$([ "$GATE_PASSED" = "true" ] && echo PASS || echo FAIL)
523
606
  # Bind the PASS to what was actually gated: the staged tree is exactly the tree the V5 commit will have.
524
607
  # scripts/verify-phase-trail.sh --head-tree (work-on/review.md R1.5) rejects a PASS recorded for a different tree. <!-- Added: forge#3149 -->
@@ -647,12 +730,12 @@ If the output contains `ANCESTRY_FAILED=1`: do NOT append `:COMPLETE` and do NOT
647
730
 
648
731
  After the ancestry audit passes (or is skipped), patch the existing FORGE:BUILDER comment: add the `**Verification Status**` line, the best-effort `cost_usd:` line, and the `<!-- FORGE:BUILDER:COMPLETE -->` marker. This is the **only** place the marker is written — it signals that a real commit exists on the branch and the build is safe to resume-skip. <!-- Added: forge#1305 -->
649
732
 
650
- `SKIPPED_CHECKS` comes from Phase V2. Shell state may not persist between Bash calls, so set it here from your V2 notes before running the block (comma-separated check names, empty when every configured check ran).
733
+ `SKIPPED_CHECKS` comes from Phase V2 and Phase V4.5. Shell state may not persist between Bash calls, so set it here from your V2 notes before running the block (comma-separated check names, empty when every configured check ran), and append `blast-radius` when V4.5 printed `SKIPPED_CHECKS+=blast-radius` (`BLAST_RADIUS_STATE: DEGRADED`).
651
734
 
652
735
  **Cost line reconciliation**: the machine-readable `cost_usd:` line (best-effort; only when `PHASE_COST_USD` is available, never blocking) is the single cost signal. Do not add a separate `**Cost (build phase)**` line.
653
736
 
654
737
  ```bash
655
- # SKIPPED_CHECKS: set from the V2 notes, e.g. SKIPPED_CHECKS="python.format, typescript.typecheck/build"
738
+ # SKIPPED_CHECKS: set from the V2 notes plus V4.5's carry-forward line, e.g. SKIPPED_CHECKS="python.format, blast-radius"
656
739
  SKIPPED_CHECKS="${SKIPPED_CHECKS:-}"
657
740
  if [ -z "$SKIPPED_CHECKS" ]; then
658
741
  VERIFICATION_STATUS="✅ All configured verification commands passed"
@@ -708,7 +791,7 @@ VALIDATE_RESULT:
708
791
  blocker: {description if gate_passed=false}
709
792
  verification_skipped: [] # empty when all configured checks ran; list of skipped check names otherwise
710
793
  # e.g. ["python.format", "typescript.typecheck/build"]
711
- # populated from SKIPPED_CHECKS in Phase V2
794
+ # populated from SKIPPED_CHECKS in Phase V2 and V4.5
712
795
  ```
713
796
 
714
797
  ---
@@ -722,6 +805,7 @@ implement (work-on:build:implement) — code written, staged (not committed), FO
722
805
  → [THIS MODULE] validate
723
806
  V0 self-check → V1 quality-gate loop (Skill quality-gate, forked) → V2 format/verify + known-slow/learned tests
724
807
  → V3 proxy wiring → V3.5 DB advisory → V3.6 browser signals → V4 deploy completeness
808
+ → V4.5 blast-radius manifest check (check-blast-radius.sh, install-root only)
725
809
  → V5 marker → commit → ancestry audit → BUILDER:COMPLETE
726
810
  review (work-on:review) — push, PR, merge
727
811
  ```
@@ -23,7 +23,7 @@ argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --base {PR_BAS
23
23
  **Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet" (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154. This file's mechanical bits (classification, label transitions) stay at this tier because they are interleaved with the reasoning-heavy child phases in the same run. <!-- Added: forge#1827 -->
24
24
  **NEVER use plan mode (EnterPlanMode).**
25
25
 
26
- **CRITICAL: You MUST execute ALL phases B0–B6.5 (including B5.5) in order. Every child (B3 context, B4 architect, B5 implement, B6 validate) is invoked via `Skill(...)` — each is a forked sub-skill with its own isolated context. B3 and B4 are invoked for every complexity band except where the band is TRIVIAL or INVESTIGATION (B2/B2.1/B2.5 are also skipped for INVESTIGATION); the children post their own skip markers, so a skipped child is still visible on the issue. Skipping a child without the band justification degrades build quality and fails the phase-trail check.**
26
+ **CRITICAL: You MUST execute ALL phases B0–B6.5 (including B5.5) in order (the one exception is the B0 size-gate resume branch, which enters at B5.5 with its state verified). Every child (B3 context, B4 architect, B5 implement, B6 validate) is invoked via `Skill(...)` — each is a forked sub-skill with its own isolated context. B3 and B4 are invoked for every complexity band except where the band is TRIVIAL or INVESTIGATION (B2/B2.1/B2.5 are also skipped for INVESTIGATION); the children post their own skip markers, so a skipped child is still visible on the issue. Skipping a child without the band justification degrades build quality and fails the phase-trail check.**
27
27
 
28
28
  **Synchronous child consumption**: each child `Skill(...)` call (B3-B6) runs to completion in this phase's own turn, and you consume its result in that same turn. Never end or yield your turn to wait for a child, and never wait for a completion notification: notifications for forked children go to the root session, not to this phase, so a turn that yields is never resumed. A child return with no `*_RESULT:` block (running, backgrounded, empty) is not a result — re-read the GitHub markers for that child (`FORGE:CONTEXT`, `FORGE:ARCHITECT`, `FORGE:BUILDER`, `FORGE:VALIDATE`/`FORGE:QUALITY_GATE`) and apply the **bounded re-invoke rule**: re-invoke the same child with the same args at most 2 times per child on running/backgrounded/empty returns (the counter is per child and is not reset by a re-invoke). Before re-invoking, check state: if the child's marker is present, consume it instead of re-invoking; for implement and validate, also check the worktree (`git status --short` and `git log origin/{PR_BASE}..HEAD` in `{WORKTREE_PATH}`) — uncommitted in-flight edits or a fresh commit mean the original child may still be live, so wait for it by re-reading state rather than launching a second writer on the same worktree, and count that as a re-invoke attempt. After the cap is exhausted, take the Blocked exit (see Result emission and Blocked exit) with `blocker: child-stalled: <child>` (child = context|architect|implement|validate); it is terminal and is not re-invoked by the router.
29
29
 
@@ -173,6 +173,20 @@ EXISTING_FAST_PATH=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate
173
173
 
174
174
  **Resume check**:
175
175
  - If `<!-- FORGE:BUILDER:COMPLETE -->` is present in a BUILDER comment → build already complete. Derive BRANCH/WORKTREE_PATH as in B1A/B1C (without creating anything), then print `BUILD_RESULT: status: ALREADY_DONE`.
176
+ - **Size-gate resume** (checked BEFORE the partial-BUILDER rule below; first match wins) <!-- Added: forge#3463 -->: if a `<!-- FORGE:BUILDER -->` comment exists without `<!-- FORGE:BUILDER:COMPLETE -->` AND the latest trusted `FORGE:DIFF_SIZE` comment has `result: OVER`, the build stopped at a deliberate B5.5 exit (`NEEDS_DECOMPOSE` or the decompose-loop-guard Blocked exit), not in a crash. Keep the BUILDER comment, do not re-run B2-B5, and re-enter at **Phase B5.5**. Read the marker through the same anchored, trust-filtered lookup B5.5 uses (script resolution block verbatim):
177
+ ```bash
178
+ # <Script resolution block from above goes here, verbatim>
179
+ set -o pipefail
180
+ TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
181
+ [ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ] || { echo "DIFF_SIZE_LOOKUP=FAILED (trusted-comments.sh unresolved)"; exit 0; }
182
+ DS=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE') \
183
+ || { echo "DIFF_SIZE_LOOKUP=FAILED"; exit 0; }
184
+ LAST_RESULT=$(printf '%s\n' "$DS" | jq -r 'select(type == "string")' | sed -n 's/^result: *\([A-Z]*\).*/\1/p' | tail -n 1)
185
+ echo "DIFF_SIZE_RESULT=${LAST_RESULT:-<none>}"
186
+ ```
187
+ - `DIFF_SIZE_LOOKUP=FAILED` → Blocked exit with blocker `size-gate-unavailable: could not read FORGE:DIFF_SIZE` (a transient GitHub failure uses the `github-unavailable:` prefix). Never read a failed lookup as "no DIFF_SIZE".
188
+ - `DIFF_SIZE_RESULT=OVER` → verify, without creating anything, that the branch and worktree exist (derive BRANCH/WORKTREE_PATH as in B1A/B1C), that `git -C "$WORKTREE_PATH" branch --show-current` equals BRANCH, and that a staged diff is present (`! git -C "$WORKTREE_PATH" diff --cached --quiet`). Recover `COMPLEXITY_BAND` from `EXISTING_FAST_PATH` with the B0.5 resume extraction. When all hold, skip B0.5-B5 and continue at B5.5 (it re-measures, then applies the override / `NEEDS_DECOMPOSE` / `OK` logic).
189
+ - Anything else (no `FORGE:DIFF_SIZE`, `result` is not `OVER`, worktree or branch missing or mismatched, nothing staged, band unrecoverable) → never skip ahead on missing state; fall through to the next rule.
176
190
  - If `<!-- FORGE:BUILDER -->` exists BUT `<!-- FORGE:BUILDER:COMPLETE -->` is ABSENT → build was interrupted after the comment was posted but before the commit (validate V5). Delete the partial comment and restart from Phase B2 (contract): <!-- Added: forge#1305 -->
177
191
  ```bash
178
192
  PARTIAL_ID=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate \
@@ -336,7 +350,7 @@ case "$TIER" in
336
350
  prose)
337
351
  [ -n "${DRY_RUN:-}" ] && echo "DRY_RUN: set workflow:building" || \
338
352
  gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:building" \
339
- --remove-label "workflow:investigating,workflow:ready-to-build,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true
353
+ --remove-label "workflow:investigating,workflow:ready-to-build,workflow:in-review,workflow:remediating,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true
340
354
  ;;
341
355
  esac
342
356
  ```
@@ -347,7 +361,83 @@ esac
347
361
 
348
362
  **Skip if COMPLEXITY_BAND: INVESTIGATION** — go to B5. For every other band (TRIVIAL included) the contract is mandatory. If a `FORGE:CONTRACT` comment already exists on the issue (resume), do not post a second one — continue to B2.1.
349
363
 
350
- Post `<!-- FORGE:CONTRACT -->` comment documenting what will be built and why:
364
+ Post `<!-- FORGE:CONTRACT -->` comment documenting what will be built and why. Run B2.0 first: it files the follow-up issues for `deferred` out-of-scope items and validates the section, so `{OUT_OF_SCOPE_ITEMS}` below already holds real issue numbers.
365
+
366
+ ### B2.0: Out-of-scope dispositions — file deferred follow-ups, validate the section <!-- Added: forge#3447 -->
367
+
368
+ Every item in the contract's `### Out of Scope` section must carry exactly one disposition, or the section is the single line `None.`. Each bullet names at least one backticked path or symbol so review can match findings against it:
369
+
370
+ ```
371
+ - `path/or/symbol` — deferred → #N: <why it is deferred>
372
+ - `path/or/symbol` — not-affected: <evidence, e.g. the grep or call-graph check that shows no impact>
373
+ - `path/or/symbol` — accepted-risk: <reason the risk is acceptable>
374
+ ```
375
+
376
+ - **`deferred → #N`**: a follow-up issue `#N` already exists (filed in this step, before the contract is posted). Use it for a sibling path the work leaves alone but that still needs the change.
377
+ - **`not-affected: <evidence>`**: the path was checked and the change does not reach it. The evidence must be concrete. Review never demotes a finding against a `not-affected` item: a finding that contradicts the evidence means the contract was wrong.
378
+ - **`accepted-risk: <reason>`**: a known gap that is knowingly kept. Review demotes a non-security finding on this path to a note.
379
+ - **Known-flaw rule**: a contract that keeps a known-flawed design (a design the investigation or architect plan identified as flawed, a fail-open caller, an unsound threshold) must state the flaw and list it as a `deferred → #N` item with a linked follow-up. It may not hide behind `accepted-risk` or free text.
380
+ - Free-text exclusions without a disposition are invalid. `->` is accepted for `→`.
381
+
382
+ **File the `deferred` follow-ups** (skip the filing on resume when the contract already exists):
383
+
384
+ ```bash
385
+ # <Script resolution block from work-on/build.md, verbatim>
386
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
387
+ # For each deferred item (DEFERRED_PATH = the backticked path, DEFERRED_WHY = one line), repeat:
388
+ DEFERRED_MARKER="<!-- FORGE:DEFERRED_FROM: #{NUMBER} -->"
389
+ LOOKUP=$(gh issue list {GH_FLAG} --state open --search "\"FORGE:DEFERRED_FROM: #{NUMBER}\" in:body" \
390
+ --json number,body --limit 50 2>/dev/null) || LOOKUP="FAILED"
391
+ if [ "$LOOKUP" = "FAILED" ]; then
392
+ echo "DEFERRED_LOOKUP=FAILED" # a failed lookup is not "none found": Blocked exit, never re-file blind
393
+ else
394
+ DEFERRED_NUM=$(printf '%s' "$LOOKUP" | jq -r --arg p "$DEFERRED_PATH" \
395
+ '[.[] | select(.body | contains($p))] | first | .number // empty' 2>/dev/null)
396
+ if [ -z "$DEFERRED_NUM" ]; then
397
+ DEFERRED_URL=$(run gh issue create {GH_FLAG} --title "follow-up: ${DEFERRED_PATH} (deferred from #{NUMBER})" \
398
+ --body "## Problem
399
+
400
+ ${DEFERRED_WHY}
401
+
402
+ Deferred from #{NUMBER}: \`${DEFERRED_PATH}\` was declared out of scope in its contract and is tracked here instead of surfacing later as a review finding.
403
+
404
+ ## Affected Files
405
+
406
+ - \`${DEFERRED_PATH}\`
407
+
408
+ ## Acceptance Criteria
409
+
410
+ - [ ] The concern above is resolved for \`${DEFERRED_PATH}\`
411
+
412
+ ${DEFERRED_MARKER}")
413
+ DEFERRED_NUM=$(printf '%s' "$DEFERRED_URL" | grep -oE '[0-9]+$')
414
+ [ -n "${DRY_RUN:-}" ] && DEFERRED_NUM="0"
415
+ fi
416
+ echo "DEFERRED_ISSUE=${DEFERRED_NUM:-}"
417
+ fi
418
+ ```
419
+
420
+ - `DEFERRED_LOOKUP=FAILED`, or an empty `DEFERRED_NUM` after a real (non-`DRY_RUN`) create → Blocked exit with blocker `github-unavailable: could not file or look up deferred follow-up for <path>` (a transient failure follows the retry rule above; no `needs-human` for `github-unavailable:`).
421
+ - On resume the lookup reuses the open issue that carries the marker and the same path, so a retried build never files a duplicate.
422
+ - Prefer filing through the `/issue` create-hook's programmatic contract (`Skill(skill="{FORGE_SKILL_PREFIX}issue", ...)`) when it is available; the raw create above is the fallback and must keep the `FORGE:DEFERRED_FROM` marker in the body.
423
+
424
+ **Validate the composed section** with `scripts/check-contract-scope.sh validate` (the exact text that will replace `{OUT_OF_SCOPE_ITEMS}`):
425
+
426
+ ```bash
427
+ # <Script resolution block from work-on/build.md, verbatim>
428
+ SCOPE_FILE=$(mktemp "${TMPDIR:-/tmp}/contract-scope.XXXXXX")
429
+ printf '### Out of Scope\n\n%s\n' "{OUT_OF_SCOPE_ITEMS}" > "$SCOPE_FILE"
430
+ SCRIPT_REF=$(resolve_script 'check-contract-scope')
431
+ case "$SCRIPT_REF" in
432
+ prose:*) echo "SCOPE_CHECK=PROSE" ;; # apply the three-disposition rules above by hand
433
+ *) bash "${SCRIPT_REF#*:}" validate "$SCOPE_FILE"; echo "SCOPE_CHECK_RC=$?" ;;
434
+ esac
435
+ rm -f "$SCOPE_FILE"
436
+ ```
437
+
438
+ - `SCOPE_CHECK_RC=0` (or `SCOPE_CHECK=PROSE` after a manual check) → post the contract.
439
+ - `SCOPE_CHECK_RC=1` → the stderr lists each bad item. Fix the items (add the missing disposition, file the missing follow-up, name a backticked path), recompose, and re-run once. Still invalid → Blocked exit with blocker `contract-scope-invalid: <first bad item>`.
440
+ - `SCOPE_CHECK_RC=2` → a usage error in this step: fix the invocation and retry once, then Blocked exit.
351
441
 
352
442
  **Before posting, read the attribution config**:
353
443
  ```bash
@@ -387,6 +477,8 @@ run gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:CONTRACT -->
387
477
  ${ATTRIBUTION_LINE}"
388
478
  ```
389
479
 
480
+ `{OUT_OF_SCOPE_ITEMS}` is the validated B2.0 section body: `None.`, or typed bullets using the literal dispositions `deferred → #N`, `not-affected:` and `accepted-risk:`. Never post free text there.
481
+
390
482
  Contract must be grounded in the investigation report. Every deliverable file must appear in the affected files list from the investigator. Adversarially validate the proposed fix against adjacent system layers before posting.
391
483
 
392
484
  ### B2.1: Post FORGE:CLAIM on coordination issue (conditional — when running under orchestration batch) <!-- Added: forge#1736 -->
@@ -529,7 +621,12 @@ Review findings per PR rise steeply with diff size, so an oversized diff is spli
529
621
 
530
622
  ```bash
531
623
  # <Script resolution block from above goes here, verbatim>
532
- git -C "{WORKTREE_PATH}" fetch origin "{PR_BASE}" 2>/dev/null || true # fresh origin/base for the merge-base measurement
624
+ # Fresh origin/base for the merge-base measurement: retry with 10s/30s/60s backoff, record whether it ever succeeded
625
+ FETCH_OK=0
626
+ for _delay in 0 10 30 60; do
627
+ [ "$_delay" -gt 0 ] && sleep "$_delay"
628
+ if git -C "{WORKTREE_PATH}" fetch origin "{PR_BASE}" 2>/dev/null; then FETCH_OK=1; break; fi
629
+ done
533
630
  THRESHOLD=$(yq '.build.diff_size.threshold // 1000' forge.yaml 2>/dev/null || echo 1000)
534
631
  EXCLUDES=()
535
632
  while IFS= read -r _g; do [ -n "$_g" ] && [ "$_g" != "null" ] && EXCLUDES+=(--exclude-glob "$_g"); done \
@@ -539,12 +636,13 @@ case "$SCRIPT_REF" in
539
636
  prose:*) echo "SIZE_GATE: scripts/diff-size.sh unresolved (prose tier) — gate skipped with a note in the FORGE:BUILDER comment" ;;
540
637
  *)
541
638
  SIZE_OUT=$(bash "${SCRIPT_REF#*:}" --repo-path "{WORKTREE_PATH}" --base "{PR_BASE}" --threshold "$THRESHOLD" ${EXCLUDES[@]+"${EXCLUDES[@]}"}) && SIZE_RC=0 || SIZE_RC=$?
542
- echo "SIZE_RC=${SIZE_RC}"; printf '%s\n' "$SIZE_OUT" ;;
639
+ echo "SIZE_RC=${SIZE_RC}"; echo "FETCH_OK=${FETCH_OK}"; printf '%s\n' "$SIZE_OUT" ;;
543
640
  esac
544
641
  ```
545
642
 
546
643
  - Prose tier (script unresolvable) → skip the gate, continue to B6 (the script is part of the install; a missing install must not strand every build).
547
- - Exit 2, empty output, or output without `diff_lines=`, `threshold=` and `over=` → Blocked exit with blocker `size-gate-unavailable: diff-size.sh failed (rc=<N>)`. Do NOT treat an unmeasurable diff as under threshold.
644
+ - Exit 2, empty output, or output without `diff_lines=`, `threshold=` and `over=`, AND `FETCH_OK=0` (the base fetch failed after the 10s/30s/60s retries, worst case about 100s) → Blocked exit with blocker `github-unavailable: could not fetch origin/{PR_BASE} for diff-size measurement` (transient; the router retry rule applies).
645
+ - The same unmeasurable result with `FETCH_OK=1` (the fetch succeeded, so the script itself failed) → Blocked exit with blocker `size-gate-unavailable: diff-size.sh failed (rc=<N>)`. Either way, do NOT treat an unmeasurable diff as under threshold.
548
646
  - Otherwise parse `diff_lines`, `excluded_lines`, `threshold`, `over` and the `top=` lines.
549
647
 
550
648
  **Step 2 — look up an override** (only when `over=true`). Both lookups are anchored, trust-filtered through `scripts/trusted-comments.sh` (trusted association, Bot, or `FORGE_TRAIL_TRUSTED_LOGINS`), and fail closed:
@@ -602,7 +700,7 @@ BUILD_RESULT:
602
700
  blocker: diff-size-over-threshold: {diff_lines} > {threshold}; split proposal posted in FORGE:DIFF_SIZE
603
701
  ```
604
702
 
605
- **Resume**: a re-invoked build whose latest trusted `FORGE:DIFF_SIZE` says `result: OVER` re-measures; if still over with no override it re-emits `NEEDS_DECOMPOSE` without a duplicate comment. When a valid `FORGE:SIZE_OVERRIDE` has appeared since, B5.5 re-measures and continues via `OVERRIDDEN`.
703
+ **Resume**: a re-invoked build re-enters here via the B0 size-gate resume branch (partial `FORGE:BUILDER` plus latest trusted `FORGE:DIFF_SIZE` `result: OVER` plus a staged diff in the kept worktree), so it does not re-run implement; this covers both exits that leave BUILDER incomplete, `NEEDS_DECOMPOSE` and the decompose-loop-guard Blocked exit. B5.5 re-measures: if still over with no override it re-emits `NEEDS_DECOMPOSE` without a duplicate comment (the loop-guard case re-emits the same Blocked exit); when a valid `FORGE:SIZE_OVERRIDE` has appeared since, it continues via `OVERRIDDEN`.
606
704
  # MUST CONTINUE to Phase B6 — the size gate result is intermediate, NOT terminal (validation still required), except the NEEDS_DECOMPOSE and Blocked exits above.
607
705
 
608
706
  ---
@@ -782,6 +880,7 @@ fi
782
880
  If `GATE_PASS = false`, the build repairs itself **once** before anything escalates (a failing acceptance check is pipeline work, not a human decision):
783
881
 
784
882
  1. Count `<!-- FORGE:ACCEPTANCE_REPAIR: issue={NUMBER} -->` comments on the issue. If one already exists, skip to step 4.
883
+ 1.5. **Decomposition guard** <!-- Added: forge#3543 -->: read the `### Decomposition Assessment` of the latest FORGE:INVESTIGATOR comment. If it is `**YES**`, a failing check that belongs to a sub-item the assessment assigns to a separate issue (named in the check's `description`/`# item-N` tag, or matching a listed sub-issue title) is NOT repaired: exclude it from the `--fix-acceptance` list. When ambiguous, treat the check as decomposed-out (fail safe). If every remaining failure is decomposed-out, do not invoke implement: leave `needs-human` and print `BUILD_RESULT: status: BLOCKED`, blocker: "acceptance check {ID} belongs to decomposed item {N} — repair refuses to implement work the investigator assigned to a separate issue".
785
884
  2. Post that marker (with the failed check ids), remove `needs-human` if this gate added it, then invoke
786
885
  `Skill(skill="{FORGE_SKILL_PREFIX}work-on:build:implement", args="{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --worktree {WORKTREE_PATH} --branch {BRANCH} --base {PR_BASE} --fix-acceptance \"<failed check ids and details>\"")`
787
886
  then re-derive `{CHANGED_FILES}` from the repaired worktree (never reuse the pre-repair value, and never omit `--files`; an empty list falls back to this diff):
@@ -20,7 +20,7 @@ argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\""
20
20
  **Invoked by**: the work-on router, when `INVESTIGATE_RESULT.decompose = YES`. This skill runs in an isolated forked context: it sees only its args and re-reads everything else from GitHub.
21
21
  **Output**: Create sub-issues, update parent tracker, post `<!-- FORGE:DECOMPOSED -->` comment, set `workflow:decomposed`. STOP — each sub-issue runs its own /work-on. This skill is the SOLE owner of the `workflow:decomposed` label. On BLOCKED it adds `needs-human` and posts the blocker.
22
22
 
23
- **Engine coverage** (forge#2379): this subcommand's `command` name (`work-on/decompose`) and completion marker (`FORGE:DECOMPOSED:COMPLETE`) are registered as a real phase in the headless engine's phase table — `decompose` in `packages/protocol/src/phases.js`'s `PHASE_IDS`/`PHASE_MARKERS`, and the matching `decompose` entry in `bin/engine/phases.mjs`'s `PHASES` array. The engine's `investigate` phase hands off to it on `DECOMPOSE:YES` instead of terminating in place, so a headless `runIssue()` walk dispatches this subcommand rather than stopping short.
23
+ **Engine coverage** (forge#2379): this subcommand's `command` name (`work-on/decompose`) and completion marker (`FORGE:DECOMPOSED:COMPLETE`) are registered as a real phase in the headless engine's phase table — `decompose` in `packages/protocol/src/phases.js`'s `PHASE_IDS`/`PHASE_MARKERS`, and the matching `decompose` entry in `bin/engine/phases.mjs`'s `PHASES` array. The engine's `investigate` phase hands off to it on `<!-- DECOMPOSE:YES -->` (or a `**YES**` verdict under `### Decomposition Assessment`) in the newest trusted FORGE:INVESTIGATOR report, instead of terminating in place, so a headless `runIssue()` walk dispatches this subcommand rather than stopping short.
24
24
 
25
25
  **Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet" (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154. This file's mechanical bits (label transitions, sub-issue creation) stay at this tier because they're interleaved with the reasoning-heavy sub-issue design steps in the same `Skill()` invocation. <!-- Added: forge#1827 -->
26
26
  **NEVER use plan mode (EnterPlanMode).**
@@ -513,7 +513,7 @@ case "$TIER" in
513
513
  adaptive|universal) run bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} decomposed ;;
514
514
  prose)
515
515
  run gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:decomposed" \
516
- --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid" 2>/dev/null || true
516
+ --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:awaiting-merge,workflow:merged,workflow:invalid" 2>/dev/null || true
517
517
  ;;
518
518
  esac
519
519
  ```
@@ -416,7 +416,7 @@ RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIP
416
416
  case "$TIER" in
417
417
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} investigating ;;
418
418
  prose)
419
- gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:investigating" --remove-label "workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
419
+ gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:investigating" --remove-label "workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
420
420
  ;;
421
421
  esac
422
422
  ```
@@ -609,7 +609,7 @@ bash {REPO_PATH}/scripts/code-index.sh query --domain {DOMAIN_LABEL} --repo-path
609
609
 
610
610
  The comment MUST include a terminal sentinel at the very end, AFTER all required sections are present. **The sentinel is conditional on the resolved Verdict — it is NOT always `<!-- INVESTIGATION:COMPLETE -->`:**
611
611
 
612
- - **Verdict is INVALID** → close with `<!-- INVESTIGATION:INVALID -->`. This is a distinct, already-wired-up terminal marker: `bin/engine/phases.mjs`'s `detectOutcome` for the `investigate` phase checks for it explicitly (ahead of `INVESTIGATION:COMPLETE`) and routes to `terminalReason: "invalid"`; `bin/hooks/interactive-engine.mjs`'s `PHASE_MARKERS` table also already treats it as terminal. Emitting `INVESTIGATION:COMPLETE` for an INVALID verdict is what previously caused every completed investigation to read as `{verdict: "CONFIRMED"}` regardless of actual outcome — do NOT regress this (forge#2350).
612
+ - **Verdict is INVALID** → close with `<!-- INVESTIGATION:INVALID -->`. This is a distinct, already-wired-up terminal marker: `bin/engine/phases.mjs`'s `detectOutcome` for the `investigate` phase reads it as the closing line of the newest trusted `<!-- FORGE:INVESTIGATOR -->` comment (never as a substring of prose or from an untrusted author) and routes to `terminalReason: "invalid"`; `bin/hooks/interactive-engine.mjs`'s `PHASE_MARKERS` table also already treats it as terminal. Emitting `INVESTIGATION:COMPLETE` for an INVALID verdict is what previously caused every completed investigation to read as `{verdict: "CONFIRMED"}` regardless of actual outcome — do NOT regress this (forge#2350).
613
613
  - **Verdict is CONFIRMED or PARTIAL** → close with `<!-- INVESTIGATION:COMPLETE -->` as before (PARTIAL still routes to `ready-to-build` in Phase 1D — only INVALID gets the distinct terminal sentinel).
614
614
 
615
615
  Compute the sentinel once, before building the comment body:
@@ -635,6 +635,9 @@ ANNOTATION_BODY=$(node packages/protocol/src/cli.js emit INVESTIGATOR \
635
635
  --field "Task Type={TASK_TYPE}" \
636
636
  --field "Decomposition Assessment={YES|NO} — {reason}")
637
637
  # ANNOTATION_BODY now has opening tag + required fields + INVESTIGATION:COMPLETE sentinel.
638
+ # After appending the body sections, the Decomposition Assessment MUST be followed by exactly one
639
+ # machine-readable marker line (`<!-- DECOMPOSE:YES -->` or `<!-- DECOMPOSE:NO -->`) — see the
640
+ # "Decomposition marker" rule below the template. The codec does not add it for you.
638
641
  # NOTE: the codec's sentinel is fixed — do not use this path when Verdict=INVALID (see caveat above).
639
642
  # Append the Markdown body sections to it before posting.
640
643
  ```
@@ -700,6 +703,7 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:INVESTIGATOR -->
700
703
  ### Decomposition Assessment
701
704
  **{YES|NO}** — {reason}
702
705
  {if YES: proposed sub-issues with titles and dependencies}
706
+ <!-- DECOMPOSE:{YES|NO} -->
703
707
 
704
708
  ### Acceptance Spec <!-- Added: forge#1829 -->
705
709
  {For each item in the issue's ## Acceptance Criteria section, emit one machine-checkable check line using the format below. If the issue has no Acceptance Criteria section, derive checks from the Recommendation above. Each check MUST be specific, observable, and testable — not vague prose. Checks are consumed by build/validate Phase B6.5 as the merge gate.}
@@ -731,6 +735,12 @@ ${ANNOTATION_LINK_FOOTER}
731
735
  ${INVESTIGATION_SENTINEL}"
732
736
  ```
733
737
 
738
+ **Rules for the template above (instructions, not template text; never copy them into the posted comment):**
739
+
740
+ **Decomposition marker (MANDATORY, both output paths — template and codec)**: emit exactly one `<!-- DECOMPOSE:YES -->` or `<!-- DECOMPOSE:NO -->` line per investigator comment, matching the `**YES**`/`**NO**` verdict, on its own line (nothing else on that line) after the verdict line and any sub-issue list (the `**YES**`/`**NO**` line must stay directly under the heading — the resume parser reads it). The headless engine routes to `work-on/decompose` on this marker (or on the `**YES**` heading in older comments). Never quote or mention the opposite marker anywhere in the comment. Never emit it when Verdict=INVALID. <!-- Added: forge#3543 -->
741
+
742
+ **Decomposition scopes the Acceptance Spec**: when the assessment is YES, the Acceptance Spec MUST cover only the first, in-scope item — or tag each check with the sub-item it belongs to (append `# item-N` to the description). Never emit checks for work the assessment assigns to a separate sub-issue: build's acceptance gate would otherwise fail on them and its repair loop would pull that work into this branch. <!-- Added: forge#3543 -->
743
+
734
744
  **Do not hardcode `<!-- INVESTIGATION:COMPLETE -->` as the closing line.** The closing line MUST be the `${INVESTIGATION_SENTINEL}` variable computed above — it resolves to `<!-- INVESTIGATION:INVALID -->` for an INVALID verdict and `<!-- INVESTIGATION:COMPLETE -->` otherwise. `INVESTIGATION:COMPLETE` and `INVESTIGATION:INVALID` are mutually exclusive within a single posted comment — never emit both.
735
745
 
736
746
  ---
@@ -1183,7 +1193,7 @@ RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIP
1183
1193
  case "$TIER" in
1184
1194
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} ready-to-build ;;
1185
1195
  prose)
1186
- gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:ready-to-build" --remove-label "workflow:investigating,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
1196
+ gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:ready-to-build" --remove-label "workflow:investigating,workflow:building,workflow:in-review,workflow:remediating,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
1187
1197
  ;;
1188
1198
  esac
1189
1199
  ```
@@ -1203,7 +1213,7 @@ RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIP
1203
1213
  case "$TIER" in
1204
1214
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} invalid ;;
1205
1215
  prose)
1206
- gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:invalid" --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
1216
+ gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:invalid" --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:awaiting-merge,workflow:merged,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
1207
1217
  ;;
1208
1218
  esac
1209
1219
  gh issue close {NUMBER} {GH_FLAG} --comment "Closing as invalid: {reason from investigation}" # allowlist:check-command-side-effects