@garygentry/feature-forge 0.2.3 → 0.2.5

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 (159) hide show
  1. package/README.md +6 -1
  2. package/adapters/GENERATION-REPORT.md +5 -1
  3. package/adapters/claude/.feature-forge-bundle.json +1 -1
  4. package/adapters/claude/references/forge-config-schema.json +25 -3
  5. package/adapters/claude/references/pipeline-state-schema.json +3 -2
  6. package/adapters/claude/references/portable-root.md +6 -3
  7. package/adapters/claude/references/process-overview.md +10 -0
  8. package/adapters/claude/references/shared-conventions.md +27 -9
  9. package/adapters/claude/references/stage-exit-protocol.md +206 -0
  10. package/adapters/claude/scripts/epic-manifest.py +10 -0
  11. package/adapters/claude/scripts/forge-bootstrap.py +94 -16
  12. package/adapters/claude/scripts/forge-init.sh +7 -1
  13. package/adapters/claude/scripts/forge-root.sh +20 -1
  14. package/adapters/claude/scripts/forge-session.py +866 -31
  15. package/adapters/claude/skills/forge/SKILL.md +29 -15
  16. package/adapters/claude/skills/forge-0-epic/SKILL.md +19 -24
  17. package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +6 -4
  18. package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  19. package/adapters/claude/skills/forge-1-prd/SKILL.md +13 -4
  20. package/adapters/claude/skills/forge-2-tech/SKILL.md +13 -3
  21. package/adapters/claude/skills/forge-3-specs/SKILL.md +13 -3
  22. package/adapters/claude/skills/forge-4-backlog/SKILL.md +15 -5
  23. package/adapters/claude/skills/forge-5-loop/SKILL.md +19 -21
  24. package/adapters/claude/skills/forge-5-loop/references/result-reporting.md +10 -5
  25. package/adapters/claude/skills/forge-6-docs/SKILL.md +6 -6
  26. package/adapters/claude/skills/forge-bootstrap/SKILL.md +4 -4
  27. package/adapters/claude/skills/forge-fix/SKILL.md +29 -6
  28. package/adapters/claude/skills/forge-guide/SKILL.md +182 -0
  29. package/adapters/claude/skills/forge-init/SKILL.md +29 -1
  30. package/adapters/claude/skills/forge-verify/SKILL.md +46 -15
  31. package/adapters/claude/skills/forge-verify/references/verification-checklists.md +1 -1
  32. package/adapters/codex/.feature-forge-bundle.json +1 -1
  33. package/adapters/codex/references/forge-config-schema.json +25 -3
  34. package/adapters/codex/references/pipeline-state-schema.json +3 -2
  35. package/adapters/codex/references/portable-root.md +6 -3
  36. package/adapters/codex/references/process-overview.md +10 -0
  37. package/adapters/codex/references/shared-conventions.md +27 -9
  38. package/adapters/codex/references/stage-exit-protocol.md +206 -0
  39. package/adapters/codex/scripts/epic-manifest.py +10 -0
  40. package/adapters/codex/scripts/forge-bootstrap.py +94 -16
  41. package/adapters/codex/scripts/forge-init.sh +7 -1
  42. package/adapters/codex/scripts/forge-root.sh +20 -1
  43. package/adapters/codex/scripts/forge-session.py +866 -31
  44. package/adapters/codex/skills/forge/SKILL.md +34 -20
  45. package/adapters/codex/skills/forge-0-epic/SKILL.md +20 -25
  46. package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +6 -4
  47. package/adapters/codex/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  48. package/adapters/codex/skills/forge-1-prd/SKILL.md +13 -4
  49. package/adapters/codex/skills/forge-2-tech/SKILL.md +14 -4
  50. package/adapters/codex/skills/forge-3-specs/SKILL.md +13 -3
  51. package/adapters/codex/skills/forge-4-backlog/SKILL.md +15 -5
  52. package/adapters/codex/skills/forge-5-loop/SKILL.md +21 -23
  53. package/adapters/codex/skills/forge-5-loop/references/result-reporting.md +10 -5
  54. package/adapters/codex/skills/forge-6-docs/SKILL.md +6 -6
  55. package/adapters/codex/skills/forge-bootstrap/SKILL.md +4 -4
  56. package/adapters/codex/skills/forge-fix/SKILL.md +29 -6
  57. package/adapters/codex/skills/forge-guide/SKILL.md +191 -0
  58. package/adapters/codex/skills/forge-init/SKILL.md +29 -1
  59. package/adapters/codex/skills/forge-verify/SKILL.md +45 -14
  60. package/adapters/codex/skills/forge-verify/references/verification-checklists.md +1 -1
  61. package/adapters/copilot/.feature-forge-bundle.json +1 -1
  62. package/adapters/copilot/references/forge-config-schema.json +25 -3
  63. package/adapters/copilot/references/pipeline-state-schema.json +3 -2
  64. package/adapters/copilot/references/portable-root.md +6 -3
  65. package/adapters/copilot/references/process-overview.md +10 -0
  66. package/adapters/copilot/references/shared-conventions.md +27 -9
  67. package/adapters/copilot/references/stage-exit-protocol.md +206 -0
  68. package/adapters/copilot/scripts/epic-manifest.py +10 -0
  69. package/adapters/copilot/scripts/forge-bootstrap.py +94 -16
  70. package/adapters/copilot/scripts/forge-init.sh +7 -1
  71. package/adapters/copilot/scripts/forge-root.sh +20 -1
  72. package/adapters/copilot/scripts/forge-session.py +866 -31
  73. package/adapters/copilot/skills/forge/forge.md +34 -20
  74. package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +20 -25
  75. package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +6 -4
  76. package/adapters/copilot/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  77. package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +13 -4
  78. package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +14 -4
  79. package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +13 -3
  80. package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +15 -5
  81. package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +21 -23
  82. package/adapters/copilot/skills/forge-5-loop/references/result-reporting.md +10 -5
  83. package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +6 -6
  84. package/adapters/copilot/skills/forge-bootstrap/forge-bootstrap.md +4 -4
  85. package/adapters/copilot/skills/forge-fix/forge-fix.md +29 -6
  86. package/adapters/copilot/skills/forge-guide/forge-guide.md +191 -0
  87. package/adapters/copilot/skills/forge-init/forge-init.md +29 -1
  88. package/adapters/copilot/skills/forge-verify/forge-verify.md +45 -14
  89. package/adapters/copilot/skills/forge-verify/references/verification-checklists.md +1 -1
  90. package/adapters/cursor/.feature-forge-bundle.json +1 -1
  91. package/adapters/cursor/references/forge-config-schema.json +25 -3
  92. package/adapters/cursor/references/pipeline-state-schema.json +3 -2
  93. package/adapters/cursor/references/portable-root.md +6 -3
  94. package/adapters/cursor/references/process-overview.md +10 -0
  95. package/adapters/cursor/references/shared-conventions.md +27 -9
  96. package/adapters/cursor/references/stage-exit-protocol.md +206 -0
  97. package/adapters/cursor/scripts/epic-manifest.py +10 -0
  98. package/adapters/cursor/scripts/forge-bootstrap.py +94 -16
  99. package/adapters/cursor/scripts/forge-init.sh +7 -1
  100. package/adapters/cursor/scripts/forge-root.sh +20 -1
  101. package/adapters/cursor/scripts/forge-session.py +866 -31
  102. package/adapters/cursor/skills/forge/forge.mdc +34 -20
  103. package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +20 -25
  104. package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +6 -4
  105. package/adapters/cursor/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  106. package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +13 -4
  107. package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +14 -4
  108. package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +13 -3
  109. package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +15 -5
  110. package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +21 -23
  111. package/adapters/cursor/skills/forge-5-loop/references/result-reporting.md +10 -5
  112. package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +6 -6
  113. package/adapters/cursor/skills/forge-bootstrap/forge-bootstrap.mdc +4 -4
  114. package/adapters/cursor/skills/forge-fix/forge-fix.mdc +29 -6
  115. package/adapters/cursor/skills/forge-guide/forge-guide.mdc +192 -0
  116. package/adapters/cursor/skills/forge-init/forge-init.mdc +29 -1
  117. package/adapters/cursor/skills/forge-verify/forge-verify.mdc +45 -14
  118. package/adapters/cursor/skills/forge-verify/references/verification-checklists.md +1 -1
  119. package/adapters/gemini/.feature-forge-bundle.json +1 -1
  120. package/adapters/gemini/gemini-extension.json +5 -1
  121. package/adapters/gemini/references/forge-config-schema.json +25 -3
  122. package/adapters/gemini/references/pipeline-state-schema.json +3 -2
  123. package/adapters/gemini/references/portable-root.md +6 -3
  124. package/adapters/gemini/references/process-overview.md +10 -0
  125. package/adapters/gemini/references/shared-conventions.md +27 -9
  126. package/adapters/gemini/references/stage-exit-protocol.md +206 -0
  127. package/adapters/gemini/scripts/epic-manifest.py +10 -0
  128. package/adapters/gemini/scripts/forge-bootstrap.py +94 -16
  129. package/adapters/gemini/scripts/forge-init.sh +7 -1
  130. package/adapters/gemini/scripts/forge-root.sh +20 -1
  131. package/adapters/gemini/scripts/forge-session.py +866 -31
  132. package/adapters/gemini/skills/forge/forge.md +34 -20
  133. package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +20 -25
  134. package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +6 -4
  135. package/adapters/gemini/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  136. package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +13 -4
  137. package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +14 -4
  138. package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +13 -3
  139. package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +15 -5
  140. package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +21 -23
  141. package/adapters/gemini/skills/forge-5-loop/references/result-reporting.md +10 -5
  142. package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +6 -6
  143. package/adapters/gemini/skills/forge-bootstrap/forge-bootstrap.md +4 -4
  144. package/adapters/gemini/skills/forge-fix/forge-fix.md +29 -6
  145. package/adapters/gemini/skills/forge-guide/forge-guide.md +191 -0
  146. package/adapters/gemini/skills/forge-init/forge-init.md +29 -1
  147. package/adapters/gemini/skills/forge-verify/forge-verify.md +45 -14
  148. package/adapters/gemini/skills/forge-verify/references/verification-checklists.md +1 -1
  149. package/dist/apply.js +34 -8
  150. package/dist/cli.js +40 -4
  151. package/dist/fsutil.d.ts +0 -12
  152. package/dist/fsutil.js +10 -1
  153. package/dist/manifest.d.ts +1 -1
  154. package/dist/plan.js +22 -2
  155. package/dist/rauf.d.ts +4 -4
  156. package/dist/rauf.js +3 -3
  157. package/dist/report.js +1 -1
  158. package/dist/types.d.ts +1 -1
  159. package/package.json +1 -1
@@ -12,7 +12,7 @@ against the fenced block here, byte-for-byte.
12
12
  ## Canonical bootstrap prelude
13
13
 
14
14
  ```bash
15
- R="$(for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done)"
15
+ R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
16
16
  [ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
17
17
  ```
18
18
 
@@ -24,7 +24,7 @@ makes several calls, add the prelude once and reuse `$R` for each. A fresh block
24
24
  prelude (per-block re-resolution). Worked example:
25
25
 
26
26
  ```bash
27
- R="$(for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done)"
27
+ R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
28
28
  [ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
29
29
  python3 "$R/scripts/epic-manifest.py" render-status "{epic}" --specs-dir "{specsDir}" --json
30
30
  ```
@@ -43,7 +43,10 @@ python3 "$R/scripts/epic-manifest.py" render-status "{epic}" --specs-dir "{specs
43
43
  3. **Prelude candidate set is an agent-neutral bootstrap subset.** The prelude's `for d` list
44
44
  exists only to bootstrap-discover `forge-root.sh`; the authoritative multi-root probe lives in
45
45
  `forge-root.sh` step 2. The list enumerates install roots across agents — the Claude
46
- skill/plugin dirs **and** the agent-neutral `.agents/skills/feature-forge` dirs (`$HOME` and the
46
+ skill/plugin dirs — including the marketplace-cache layout
47
+ `~/.claude/plugins/cache/<marketplace>/feature-forge/<version>/`, listed before the
48
+ single-star plugins glob so a versioned cache install always beats the marketplace clone —
49
+ **and** the agent-neutral `.agents/skills/feature-forge` dirs (`$HOME` and the
47
50
  project-relative `./.agents/...`) — so a non-Claude install (e.g. Codex under `.agents/skills`)
48
51
  can still discover the resolver. When adding an install root, update `forge-root.sh` first;
49
52
  extend the prelude only if the new root is needed to bootstrap-discover `forge-root.sh` itself.
@@ -36,6 +36,16 @@ The pipeline compiles a fuzzy feature idea into a machine-executable `backlog.js
36
36
  **Output:** `{specsDir}/{feature}/.verification/VERIFY-<stage>-<timestamp>.md` (includes both findings and a Fix Execution Plan)
37
37
  **Method:** Clean-context analysis producing actionable findings with an ordered fix plan.
38
38
 
39
+ **Manual or automatic.** By default the navigator offers verification after each stage. Because
40
+ it runs in a fresh, read-only subagent (clean-room by construction, never needs a `/clear`), it is
41
+ safe to automate: set `autoVerify: true` (or per-stage via `autoVerifyStages`) in
42
+ `forge.config.json` and the navigator runs `forge-verify` automatically once a stage completes,
43
+ returning only a compact digest to the session. The freshness ledger (verify entries record the
44
+ artifact `version` they ran against) means a stage is re-verified only when its artifact changes,
45
+ and an explicitly `skipped` verify is respected. Fixing stays human-gated unless `autoFix: true` is
46
+ also set, which chains `forge-fix` only when preconditions hold (zero unresolved decisions, clean
47
+ tree, passing re-verify). See `references/shared-conventions.md` for the config keys.
48
+
39
49
  After verification, fixes can be applied via:
40
50
  - `/feature-forge:forge-fix <feature>` — reads the Fix Execution Plan from the findings document and applies changes (works in any session)
41
51
  - Plan mode workflow — enter plan mode, run verify, review plan, exit and execute
@@ -71,6 +71,9 @@ Extract these config values (use defaults if not present):
71
71
  - `autoInvokeNextStage` (default: `true` — the `/feature-forge:forge` navigator auto-invokes the next stage via the `Skill` tool after the user confirms; `false` keeps copy-paste behavior. Navigator-only.)
72
72
  - `contextWindowTokens` (default: `null` — context window used by the navigator's context-usage check; `null` infers from the session model and falls back to 200000. Set to the model's window, e.g. `1000000` on a 1M model. Navigator-only.)
73
73
  - `contextWarnThreshold` (default: `0.7` — fraction of the window past which the navigator recommends a clean session. Navigator-only.)
74
+ - `autoVerify` (default: `false` — when `true`, `forge-verify` runs automatically after a stage completes, no prompt. **In-stage-primary:** the just-completed authoring stage runs it itself, in-session, before the exit block (honoring the verify-before-clear principle). The navigator runs it only as a **catch-up** when verify is still pending (a host that could not dispatch a clean-room subagent, or a stage run before this behavior landed). Either way it runs in a fresh clean-room subagent, so it never needs a `/clear` and costs only a compact digest.)
75
+ - `autoVerifyStages` (default: `{}` — per-stage overrides for `autoVerify`, e.g. `{"forge-1-prd": false}`. Effective value = `autoVerifyStages[stage]` if present, else `autoVerify`. Keys are constrained to the five verify-capable stages; a typo is a config error surfaced as `invalidAutoVerifyKeys`. Both the in-stage run and the navigator catch-up read this same effective value.)
76
+ - `autoFix` (default: `false` — when `true`, `forge-fix` is chained after an auto-verify that finds issues — by the in-stage run (primary) or the navigator catch-up — but only when auto-verify is on for that stage AND preconditions hold (zero unresolved decisions, clean tree, passing re-verify); otherwise a digest is surfaced and the gate is presented.)
74
77
  - `loopRunner` (optional object — the loop runner to drive; **defaults to rauf** when absent, with every command templated. See `references/forge-config-schema.json` and `references/ralph-loop-contract.md`.)
75
78
 
76
79
  ## Feature Directory Resolution
@@ -78,7 +81,7 @@ Extract these config values (use defaults if not present):
78
81
  Before any file I/O against a feature's artifacts, resolve its directory through the deterministic helper rather than hardcoding `{specsDir}/{feature}/`. This makes flat (`{specsDir}/{feature}/`) and nested (`{specsDir}/{epic}/{feature}/`) layouts both resolve from a bare feature name (REQ-DIR-03), with standalone features behaving exactly as today.
79
82
 
80
83
  ```bash
81
- R="$(for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done)"
84
+ R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
82
85
  [ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
83
86
  resolvedFeatureDir=$(python3 "$R/scripts/epic-manifest.py" \
84
87
  resolve "<feature>" --specs-dir "<specsDir>")
@@ -90,6 +93,19 @@ resolvedFeatureDir=$(python3 "$R/scripts/epic-manifest.py" \
90
93
 
91
94
  In both failure cases, do not fall back to a guessed path.
92
95
 
96
+ **On `not-found`, check other branches before stopping.** With `branchPerFeature`, the feature's directory (and its `.pipeline-state.json`) may exist only on its topic branch — invisible from the default branch of a fresh clone. Before concluding the pipeline does not exist, run the read-only cross-branch discovery:
97
+
98
+ ```bash
99
+ R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
100
+ [ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
101
+ python3 "$R/scripts/forge-session.py" discover-feature "<feature>" --specs-dir "<specsDir>" --json
102
+ ```
103
+
104
+ - **Candidates found** (`candidates` and/or `remoteCandidates` non-empty): summarize them as text (branch, recorded stage, whether the state's own `branch` field matches), then use `AskUserQuestion`: **Switch to `{branch}` (recommended)** — run the candidate's `switchCommand` · **Fetch + switch** — for a `needsFetch` remote candidate, run its `fetchCommand` then `switchCommand` (note its contents were matched by name only, not inspected) · **Treat `{feature}` as new on this branch** · **Stop**. A checkout is a mutation inside an otherwise read-only flow: perform it ONLY on the user's explicit accept AND with a clean working tree (`git status --porcelain` prints nothing) — never auto-switch, never with uncommitted changes. After a successful switch, re-run this Feature Directory Resolution block from the top.
105
+ - **Nothing found** (both lists empty): the pipeline genuinely does not exist anywhere discoverable — STOP and surface the original `not-found` stderr line verbatim (or, where the caller offers to start a new pipeline, offer that).
106
+
107
+ **Anti-fabrication guard.** Never describe pipeline state that resolution or discovery did not return: if both come back empty, the pipeline does not exist — say exactly that, and never reconstruct stages, backlogs, or history from conversational memory.
108
+
93
109
  **Resolution algorithm (summary; full spec in `02-manifest-helper-cli.md §4`):**
94
110
  1. Reject the name if unsafe (path separator, `..`, absolute, or failing `SAFE_NAME_RE`) — before any filesystem access.
95
111
  2. If `{specsDir}/{name}/.pipeline-state.json` exists → return that flat path.
@@ -108,7 +124,7 @@ Whenever a stage creates the specs tree for the first time (the first PRD or epi
108
124
  Run this after creating the feature/epic directory, before the stage's git commit:
109
125
 
110
126
  ```bash
111
- R="$(for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done)"
127
+ R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
112
128
  [ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
113
129
  mkdir -p "<specsDir>"
114
130
  [ -f "<specsDir>/AGENTS.md" ] || cp "$R/references/templates/specs-hygiene/AGENTS.md" "<specsDir>/AGENTS.md"
@@ -135,7 +151,7 @@ After resolving the feature directory, check the feature's `.pipeline-state.json
135
151
  To obtain the manifest contracts and the live completion status of each dependency in one deterministic call, run `render-status` and read the per-feature `status` and the `consumes`/`exposes` arrays rather than re-deriving them:
136
152
 
137
153
  ```bash
138
- R="$(for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done)"
154
+ R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
139
155
  [ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
140
156
  python3 "$R/scripts/epic-manifest.py" \
141
157
  render-status "<epic>" --specs-dir "<specsDir>" --json
@@ -180,16 +196,18 @@ Invoke this block at the **very start** of a pipeline entry point — `forge-1-p
180
196
 
181
197
  ## Git Commit Protocol
182
198
 
183
- When `gitCommitAfterStage` is true, follow this exact order to avoid state inconsistency:
199
+ When `gitCommitAfterStage` is true, follow this exact order to avoid state inconsistency.
200
+
201
+ **Why two commits.** The stage's `.pipeline-state.json` is itself part of the staged commit, but the stage's `commitHash` cannot be known until *after* that commit is made. Recording it *inside* the same commit is a chicken-and-egg with no single-commit solution. Resolve it with a **deterministic two-commit sequence**, and **never** with `git commit --amend`: amending rewrites HEAD, so a hash captured before the amend points at an orphaned commit that is not in the final history (the exact defect this protocol exists to prevent).
184
202
 
185
203
  1. **Stage specific files only:** `git add {specsDir}/{feature}/` — never use `git add -A` or `git add .`
186
- 2. **Attempt commit:** `git commit -m "{commitPrefix}({feature}): <action>"`
187
- 3. **If commit succeeds:** capture the commit hash, then update pipeline state with `status: "complete"` and the `commitHash`
188
- 4. **If commit fails:** do NOT update pipeline state to complete. Report the error to the user and leave state as `in-progress` so the stage can be resumed. Common failure causes:
204
+ 2. **Commit 1 — artifacts + state, hash not yet known:** In `.pipeline-state.json`, set this stage's `status: "complete"` and `commitHash: null`, then `git commit -m "{commitPrefix}({feature}): <action>"`. This is the stage's **artifact commit**; its hash is the provenance hash callers rely on.
205
+ 3. **If Commit 1 succeeds — Commit 2 records the hash:** Capture the hash of Commit 1 (`git rev-parse HEAD`). Write it into this stage's `commitHash` in `.pipeline-state.json`, then commit only that one-line change: `git add {specsDir}/{feature}/.pipeline-state.json && git commit -m "{commitPrefix}({feature}): record stage commit hash"`. The stored `commitHash` now points at the artifact commit (Commit 1) — never at Commit 2, and never at an orphaned amend. The working tree is clean afterward, so the next stage's dirty-tree check passes.
206
+ 4. **If Commit 1 fails:** do NOT update pipeline state to complete. Report the error to the user and leave state as `in-progress` so the stage can be resumed. Common failure causes:
189
207
  - **Pre-commit hook failure:** Report the hook output. Never use `--no-verify` to bypass. Help the user fix the underlying issue.
190
208
  - **Merge conflicts:** Report conflicting files. Suggest resolution steps appropriate to the conflict.
191
- - **Nothing to commit:** If all artifacts were already committed, this is fine — proceed with state update but note the absence of a new commit hash.
192
- 5. **Never** use `git add -A`, `--no-verify`, or `--force` flags
209
+ - **Nothing to commit:** If all artifacts were already committed, this is fine — mark the stage `complete`, leave `commitHash` at its existing value (or `null` if there was never an artifact commit), and skip Commit 2. There is no new artifact commit to record.
210
+ 5. **Never** use `git add -A`, `--amend`, `--no-verify`, or `--force` flags
193
211
 
194
212
  ## Crash Recovery
195
213
 
@@ -0,0 +1,206 @@
1
+ # Stage Exit Protocol
2
+
3
+ The single source of truth for how every forge **authoring** stage closes. It
4
+ replaces the old ad-hoc "Next steps:" bullet lists with one fixed, correctly-ordered
5
+ sequence: **verify (if missing or stale) → `/clear` → run the next command.**
6
+
7
+ Two principles this protocol encodes (do not relitigate — they are locked product
8
+ decisions):
9
+
10
+ 1. **Clearing is recommended on its own merits at every stage boundary** — a clean
11
+ start for the next stage — *not* as a proxy for a full context window. Window
12
+ fullness only changes *how emphatically* the clear is recommended, never *whether*
13
+ it is.
14
+ 2. **Verify happens before the clear, never after** — in the authoring session, whether
15
+ manual **or** auto. Verify's clean-room subagent is dispatched from the *current*
16
+ session, so the findings digest and any fix decision land where the context to act on
17
+ them still exists. This holds for auto-verify too: the stage skill dispatches the
18
+ clean-room verify (and any autoFix) at stage end, in-session, before the exit — it is
19
+ **not** deferred to the navigator, which runs *after* the `/clear` with none of the
20
+ authoring context. Clearing first throws that context away.
21
+
22
+ ## How this file is used
23
+
24
+ The five authoring stages (`forge-0-epic` … `forge-4-backlog`) close with the
25
+ **Scripted Stage Exit**: a short stamped block (below) that runs
26
+ `forge-session.py stage-exit`, obeys the DIRECTIVES it prints per the **directive
27
+ contract** in this file, and prints the script-emitted NEXT-STEPS block verbatim as the
28
+ absolute last output. All the conditional logic the old prose blocks asked the model to
29
+ compute (effective auto-verify, freshness collapse, gate selection, host wording) now
30
+ lives in the script, deterministically; only genuinely interactive work (clean-room
31
+ subagent dispatch, `AskUserQuestion` gates) remains prose — specified once here, not
32
+ per stage.
33
+
34
+ The loop (`forge-5-loop`) keeps its bespoke exits: it stamps the **standard block**
35
+ (step-6 epic-member handoff) and the **warm variant** (all-done closing) below,
36
+ verbatim. `forge-6-docs` is **terminal** — it stamps no exit block.
37
+
38
+ A drift-guard test (`tests/test_stage_exit_protocol.py`) asserts each stamp site still
39
+ contains its block, so an edit here must be mirrored into every stamp site (and
40
+ vice-versa).
41
+
42
+ ## Stamp sites
43
+
44
+ | Stamp site | Block |
45
+ |---|---|
46
+ | `forge-0-epic` … `forge-4-backlog` | scripted-stage-exit stamp |
47
+ | `forge-5-loop` (step-6 epic-member handoff) | standard |
48
+ | `forge-5-loop` (all-done closing → docs) | warm |
49
+
50
+ The scripted stamp fills one build-time slot, `{stage-exit-args}` — the per-stage
51
+ argument list (e.g. `--feature "{feature}" --stage forge-2-tech`; the epic stage passes
52
+ `--feature "{epic}" --stage forge-0-epic --next-feature "{first-actionable-feature}"`).
53
+ `{feature}` / `{epic}` / `{specsDir}` / `{first-actionable-feature}` remain runtime
54
+ placeholders the skill resolves before running the command, exactly as elsewhere.
55
+
56
+ <!-- BEGIN: scripted-stage-exit-stamp -->
57
+ **Close this stage with the Scripted Stage Exit** (contract: `references/stage-exit-protocol.md`; do not improvise a "Next steps" list). Run:
58
+
59
+ ```bash
60
+ R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
61
+ [ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
62
+ python3 "$R/scripts/forge-session.py" stage-exit {stage-exit-args} --specs-dir "{specsDir}" --host claude
63
+ ```
64
+
65
+ Obey the DIRECTIVES it prints, in order, per the directive contract: `runInStageVerify: true` → dispatch the in-stage clean-room verify now (honoring `autoFixEligible`); `verifyGate: "standard"` → present the Standard Verify Gate; `verifyGate: "manual-print"` → print the `verifyCommand` for the user; non-empty `invalidAutoVerifyKeys` → print a one-line warning. Then **print the NEXT-STEPS block verbatim as your absolute last output — nothing after its sentinel line.**
66
+ <!-- END: scripted-stage-exit-stamp -->
67
+
68
+ ## Directive contract
69
+
70
+ `stage-exit` emits a DIRECTIVES object and a NEXT-STEPS block. The skill executes the
71
+ directives **in this order**; the script has already computed every conditional, so a
72
+ directive is an instruction, not a question to re-derive.
73
+
74
+ ### `invalidAutoVerifyKeys` (non-empty)
75
+
76
+ Print a one-line warning first (e.g. "⚠️ forge.config.json `autoVerifyStages` has
77
+ unknown keys: … — they are ignored; fix the typo").
78
+
79
+ ### `runInStageVerify: true` — in-stage auto-verify {stageNoun}
80
+
81
+ Auto-verify is effective for this stage and verification is outstanding — verify **now,
82
+ in this session** (principle #2 applied to auto-verify: the digest and any fix decision
83
+ land here, where the authoring context still exists — not deferred to a post-`/clear`
84
+ navigator):
85
+
86
+ 1. **Clean-room verify (require-clean).** Dispatch the clean-room `forge-verifier`
87
+ subagent from this session in require-clean mode — the same path the navigator uses
88
+ (`skills/forge-verify/SKILL.md`). It inherits none of this session's context, so no
89
+ `/clear` is needed and only a compact digest returns. **Clean-room unavailable** (no
90
+ `Agent` tool, `forge-verifier` not dispatchable, or a sentinel returned): do **not**
91
+ run inline — leave verify **pending** so the navigator catch-up fires on a later
92
+ Claude-host `/feature-forge:forge`, print the `verifyCommand` for the user to run,
93
+ and continue to the NEXT-STEPS block.
94
+ 2. **Verify passed / no findings** → the fresh verify state is recorded by the
95
+ clean-room run; continue to the NEXT-STEPS block.
96
+ 3. **Verify found findings** →
97
+ - **`autoFixEligible: true` AND the findings document has zero unresolved decision
98
+ points** → chain `feature-forge:forge-fix` in-session (it owns its own commit +
99
+ step tracking), then run a **mandatory re-verify** in require-clean mode. Continue
100
+ to the NEXT-STEPS block only if the re-verify passes. On any precondition miss, a
101
+ forge-fix early stop, or a red re-verify, fall through to the digest gate below —
102
+ never a silent partial mutation. (`autoFixEligible` already folds in the config
103
+ `autoFix` flag and the clean-tree precondition; a dirty tree or
104
+ `gitCommitAfterStage: false` arrives here as `false`.)
105
+ - **`autoFixEligible: false`, or unresolved decision points** → surface a **compact
106
+ findings digest** as text, then present the gate via `AskUserQuestion`: **Run
107
+ `forge-fix` now** *(recommended — you are in-context and the digest is right
108
+ here)* / **Clear + advance anyway** (leave the findings for later) / **Stop
109
+ here**. Do **not** hard-stop and do **not** silently walk past. Act on the choice,
110
+ then continue to the NEXT-STEPS block.
111
+
112
+ ### `verifyGate: "standard"` — the Standard Verify Gate
113
+
114
+ Auto-verify is off for this stage and verification is outstanding (`verifyState` is
115
+ `never`, `stale`, or `failing`). Verify **now, before clearing**, using
116
+ `AskUserQuestion` with exactly these three options — but only when the host has a
117
+ question mechanism **and** the clean-room path is available (the `Agent` tool plus a
118
+ dispatchable `forge-verifier` subagent); otherwise degrade exactly as `manual-print`
119
+ below:
120
+
121
+ - **Verify {stageNoun} now** *(recommended)* — dispatch the clean-room `forge-verifier`
122
+ subagent from this session in require-clean mode; the digest returns here so any fix
123
+ decision keeps its context. One-time — it does **not** change config.
124
+ - **Verify now + enable auto-verify going forward** — verify now **and** patch
125
+ `"autoVerify": true` into `forge.config.json` in place (preserve formatting and every
126
+ other key) so future stages verify automatically, no prompt. This complements the
127
+ `forge-init` opt-in. **Do not auto-commit this config change** — treat it like
128
+ `notes`: a user-facing edit the user commits on their own cadence, never folded into
129
+ a stage's artifact commit.
130
+ - **Skip for now** — go straight to the NEXT-STEPS block without verifying. Record this
131
+ stage's verify status as `"skipped"` in pipeline state (mirroring the existing skip
132
+ handling) **only** on an explicit skip — a skip does not go stale.
133
+
134
+ If verify runs and finds findings, handle them exactly as in the in-stage flow above
135
+ (digest + `AskUserQuestion` gate; `autoFixEligible` applies unchanged).
136
+
137
+ ### `verifyGate: "manual-print"`
138
+
139
+ Verification is outstanding but the host cannot present the gate or dispatch
140
+ clean-room. Do **not** run verify inline — print the `verifyCommand` for the user to
141
+ run (mirroring `autoInvokeNextStage`), offer the auto-verify enable as plain text only
142
+ if a config write is possible, and continue to the NEXT-STEPS block. Verify state stays
143
+ outstanding, so the navigator catch-up can fire later.
144
+
145
+ ### `verifyGate: "none"`
146
+
147
+ Verification is already resolved (fresh or explicitly skipped) or the in-stage run
148
+ above covers it. Say so in one line and continue to the NEXT-STEPS block.
149
+
150
+ ### The NEXT-STEPS block (always last)
151
+
152
+ Print the script's NEXT-STEPS block **verbatim as your absolute last output**. Nothing
153
+ follows its final sentinel line (`─ forge: end of stage ─`) — no caveats, no summary,
154
+ no sign-off. The block already carries the `/clear` recommendation (host-aware wording
155
+ via `--host`) and the exact next command, so trailing prose can only push the user's
156
+ next action out of view.
157
+
158
+ ---
159
+
160
+ ## Standard block
161
+
162
+ Stamped at the loop's step-6 epic-member handoff (finishing feature A → starting
163
+ feature B's PRD). It self-adapts: step 1's verify gate only fires when verification is
164
+ actually outstanding, so at a boundary where verify already ran (or was explicitly
165
+ skipped, or auto-verify is on) it silently collapses to just the `/clear` →
166
+ next-command steps.
167
+
168
+ Slots: `{stage}` (a lowercase noun phrase), `{verify-command}`, `{next-command}`.
169
+
170
+ <!-- BEGIN: standard-exit-block -->
171
+ **This stage is done — walk the user through the Stage Exit Protocol** before moving on. The order is fixed, and step 2 is something only the user can do:
172
+
173
+ 1. **Verify {stage} first — if it isn't already verified.** If verify already ran in this session — via the in-stage auto-verify on the authoring stages, or the interactive impl-verify offered above on the loop — or is already fresh on record, or the stage was explicitly skipped, say so and go straight to step 2. Only when `autoVerify` is off for this stage **and** verify is **missing or stale** do you present the **Standard Verify Gate**: verify **now, before clearing**, using `AskUserQuestion` with exactly these three options — but only when the host has a question mechanism **and** the clean-room path is available (the `Agent` tool plus a dispatchable `forge-verifier` subagent):
174
+ - **Verify {stage} now** *(recommended)* — dispatch the clean-room `forge-verifier` subagent from this session in require-clean mode; the digest returns here so any fix decision keeps its context. One-time — it does **not** change config.
175
+ - **Verify now + enable auto-verify going forward** — verify now **and** patch `"autoVerify": true` into `forge.config.json` in place (preserve formatting and every other key) so future stages verify automatically, no prompt. This complements the `forge-init` opt-in. **Do not auto-commit this config change** — treat it like `notes`: a user-facing edit the user commits on their own cadence, never folded into a stage's artifact commit.
176
+ - **Skip for now** — go straight to `/clear` and the next command without verifying. Record this stage's verify status as `"skipped"` in pipeline state (mirroring the existing skip handling) **only** on an explicit skip — a skip does not go stale.
177
+
178
+ **Host / clean-room fallback (not a user-selectable option):** if the question mechanism, the `Agent` tool, or the `forge-verifier` subagent is unavailable, do **not** run clean-room — degrade to printing `{verify-command}` for the user to run inline/manually (mirroring `autoInvokeNextStage`), and offer the auto-verify enable as plain text only if a config write is possible.
179
+ 2. **Then `/clear`.** Recommended **unconditionally** at this boundary for a clean start — independent of how full the context window is. Every artifact is on disk, so the work survives the clear. **I can't `/clear` for you — you have to run it yourself.**
180
+ 3. **Then run `{next-command}`** in the fresh session — or re-run `/feature-forge:forge` to let the navigator resume from disk.
181
+ <!-- END: standard-exit-block -->
182
+
183
+ ---
184
+
185
+ ## Warm-acceptable variant
186
+
187
+ Stamp this only at the `forge-5-loop → forge-6-docs` boundary (the all-done result
188
+ report). Here clearing is **optional**: the docs stage benefits from the still-warm
189
+ context of what the loop actually did, and impl-verify is already offered interactively
190
+ by the loop itself, so this block defers rather than re-presenting a gate.
191
+
192
+ > **Note — no literal `/clear` here.** The warm block lives in `result-reporting.md`, a
193
+ > skill-*own* reference that the adapter build copies **verbatim** (unlike skill bodies,
194
+ > it is not host-term translated), so a literal `/clear` would reach non-Claude adapters
195
+ > undegraded. The warm variant says "clearing is optional" anyway, so it is phrased
196
+ > host-neutrally without the token on purpose — do not reintroduce `/clear` here. (The
197
+ > standard block *does* use `/clear`; that is fine because every standard stamp site is a
198
+ > skill **body**, where `scripts/build-adapters.py` degrades it.)
199
+
200
+ <!-- BEGIN: warm-exit-block -->
201
+ **The loop is complete — this is the one boundary where clearing before the next stage is optional.**
202
+
203
+ 1. **Verify is already offered above.** Impl-verify is offered interactively right after this report (Step 5b for a standalone feature, Step 6.1 for an epic member) — run it there rather than as a second gate. It runs clean-room, so it needs no fresh session.
204
+ 2. **Clearing is optional here — warm is fine.** `forge-6-docs` benefits from the still-warm context of what the loop actually did, so continuing in this same session is the easy default. A cold start also works — every artifact is on disk — but there is no need to force it.
205
+ 3. **Then run `{next-command}`** — in this warm session, or a fresh one if you prefer.
206
+ <!-- END: warm-exit-block -->
@@ -312,6 +312,16 @@ def atomic_write(path: Path, data: dict) -> None:
312
312
  handle.flush()
313
313
  os.fsync(handle.fileno())
314
314
  os.replace(tmp_path, path)
315
+ # fsync the parent dir so the rename itself is durable on crash, not just
316
+ # the file bytes (best-effort — some filesystems reject O_RDONLY dir fsync).
317
+ try:
318
+ dir_fd = os.open(parent, os.O_RDONLY)
319
+ try:
320
+ os.fsync(dir_fd)
321
+ finally:
322
+ os.close(dir_fd)
323
+ except OSError:
324
+ pass
315
325
  except OSError as exc:
316
326
  tmp_path.unlink(missing_ok=True)
317
327
  raise UsageError(f"atomic write to {path} failed: {exc}")
@@ -26,6 +26,8 @@ import argparse
26
26
  import json
27
27
  import os
28
28
  import re
29
+ import shlex
30
+ import shutil
29
31
  import subprocess
30
32
  import sys
31
33
  import tempfile
@@ -281,6 +283,32 @@ def _json_text(obj: object) -> str:
281
283
  return json.dumps(obj, indent=2, ensure_ascii=False) + "\n"
282
284
 
283
285
 
286
+ def contained_path(base: Path, *parts: str) -> Path:
287
+ """Join ``parts`` onto ``base`` and assert the result stays within ``base``.
288
+
289
+ Ports epic-manifest.py's ``contained_path`` guard: canonicalizes (symlink-
290
+ resolves) both ends and verifies containment so no scaffold write or verify
291
+ cwd can escape the target repo — defense in depth behind ``_validate_answers``
292
+ should a crafted member path slip through. Containment violations surface only
293
+ as exit-2 usage errors.
294
+
295
+ Args:
296
+ base: The containing directory (the target repo root), already known to exist.
297
+ *parts: Repo-relative segments to append (member path, artifact rel path).
298
+
299
+ Returns:
300
+ The resolved, contained absolute path.
301
+
302
+ Raises:
303
+ UsageError: If the resolved path escapes ``base`` (exit 2).
304
+ """
305
+ base_real = base.resolve()
306
+ resolved = (base_real / Path(*parts)).resolve()
307
+ if resolved != base_real and base_real not in resolved.parents:
308
+ raise UsageError(f"path escapes target dir: {os.path.join(*parts)}")
309
+ return resolved
310
+
311
+
284
312
  def _atomic_write_text(path: Path, text: str) -> None:
285
313
  """Write ``text`` to ``path`` atomically via a same-dir temp file + os.replace.
286
314
 
@@ -381,7 +409,7 @@ def run(
381
409
 
382
410
 
383
411
  # --------------------------------------------------------------------------- #
384
- # Subcommand stubs (filled by later backlog items 003/006/008/009/010)
412
+ # Subcommands: check / scaffold / verify / commit / status (02 §8.2)
385
413
  # --------------------------------------------------------------------------- #
386
414
 
387
415
 
@@ -476,6 +504,7 @@ def _write_artifact(
476
504
  """
477
505
  if rel_path in sentinel["artifactsWritten"]:
478
506
  return
507
+ contained_path(target, rel_path) # never write outside the target repo (exit 2)
479
508
  dest = target / rel_path
480
509
  if dest.exists():
481
510
  return
@@ -529,7 +558,7 @@ def _resolve_commands(member: Member) -> tuple[str, str]:
529
558
 
530
559
 
531
560
  def write_config(answers: Answers, target: Path, sentinel: Sentinel) -> None:
532
- """Write forge.config.json equivalent to forge-init's output (02 §4.3, 00 §7)."""
561
+ """Write forge.config.json == forge-init's field set + loopRunner (02 §4.3, 00 §7)."""
533
562
  config: dict = {
534
563
  "specsDir": "./specs",
535
564
  "docsDir": "./docs/architecture",
@@ -540,6 +569,14 @@ def write_config(answers: Answers, target: Path, sentinel: Sentinel) -> None:
540
569
  "typeCheckCommand": None,
541
570
  "testCommand": None,
542
571
  "loopIterationMultiplier": 1.5,
572
+ # Navigator keys — kept in lockstep with forge-init.sh's emitted field set
573
+ # (00 §7, REQ-CFG-02) so a bootstrapped config == forge-init's + loopRunner.
574
+ "autoInvokeNextStage": True,
575
+ "contextWindowTokens": None,
576
+ "contextWarnThreshold": 0.7,
577
+ "autoVerify": False,
578
+ "autoVerifyStages": {},
579
+ "autoFix": False,
543
580
  "loopRunner": {"name": "rauf", "bin": "rauf"},
544
581
  }
545
582
  if answers["layout"] == "single":
@@ -697,9 +734,9 @@ def scaffold(target: Path, answers: Answers) -> list[str]:
697
734
  def toolchain_present(required: list[str]) -> bool:
698
735
  """Return True iff every required tool is on PATH (REQ-LIFE-03).
699
736
 
700
- Probes each binary with ``command -v`` via the run wrapper (a shell builtin,
701
- invoked through ``sh -c``). A single missing tool yields False, driving the
702
- distinct missing-toolchain outcome (exit 2, 00 §9): the skill then offers
737
+ Probes each binary with :func:`shutil.which` (a pure PATH lookup, no shell
738
+ spawned). A single missing tool yields False, driving the distinct
739
+ missing-toolchain outcome (exit 2, 00 §9): the skill then offers
703
740
  scaffold-anyway-unverified vs abort and marks the baseline unverified
704
741
  (REQ-LIFE-04). Bootstrap NEVER installs a toolchain (tech-spec §9).
705
742
 
@@ -708,17 +745,10 @@ def toolchain_present(required: list[str]) -> bool:
708
745
  already {pm}-substituted.
709
746
 
710
747
  Returns:
711
- True iff ``command -v`` succeeds for every entry.
748
+ True iff every entry resolves on PATH.
712
749
  """
713
750
  for tool in required:
714
- try:
715
- proc = run(["sh", "-c", f"command -v {tool}"], cwd=Path.cwd(), check=False)
716
- except UsageError:
717
- # The probe itself could not be launched (e.g. an empty PATH leaves no
718
- # `sh`): treat that as the tool being absent, the missing-toolchain
719
- # outcome, never an internal error (REQ-LIFE-03/04).
720
- return False
721
- if proc.returncode != 0:
751
+ if shutil.which(tool) is None:
722
752
  return False
723
753
  return True
724
754
 
@@ -763,9 +793,11 @@ def verify(target: Path, answers: Answers) -> VerifyResult:
763
793
  test: list[CommandOutcome] = []
764
794
  for member in answers["members"]:
765
795
  lint_cmd, test_cmd = _resolve_commands(member)
766
- cwd = target / member["path"]
796
+ cwd = contained_path(target, member["path"]) # never run outside the target
767
797
  for bucket, cmd in ((lint, lint_cmd), (test, test_cmd)):
768
- proc = run(["sh", "-c", cmd], cwd=cwd, check=False)
798
+ # STACK_COMMANDS templates with an allow-listed {pm} (see _validate_answers)
799
+ # — split into an argv token list and run without a shell (no `sh -c`).
800
+ proc = run(shlex.split(cmd), cwd=cwd, check=False)
769
801
  bucket.append(
770
802
  {"command": cmd, "ok": proc.returncode == 0, "member": member["path"]}
771
803
  )
@@ -867,9 +899,55 @@ def _parse_answers(raw: str) -> Answers:
867
899
  raise UsageError(f"malformed --answers JSON: {exc}")
868
900
  if not isinstance(parsed, dict):
869
901
  raise UsageError("--answers must be a JSON object")
902
+ _validate_answers(parsed)
870
903
  return parsed
871
904
 
872
905
 
906
+ def _validate_answers(answers: Answers) -> None:
907
+ """Reject non-allowlisted stack/packageManager and unsafe member paths (exit 2).
908
+
909
+ Runs before any ``{pm}`` substitution, ``STACK_COMMANDS[...]`` subscript, or
910
+ artifact write so a malformed ``--answers`` payload fails with a stderr
911
+ diagnostic, never a KeyError traceback or a path escape. Mirrors the
912
+ input-validation rigor epic-manifest.py applies via ``assert_safe_name`` /
913
+ ``contained_path``:
914
+
915
+ - ``stack`` must be a known :data:`Stack` (a ``STACK_COMMANDS`` key).
916
+ - ``packageManager`` must be in ``PACKAGE_MANAGERS[stack]`` for a stack that
917
+ has a choice; stacks with no choice (go/rust/generic) must leave it ``None``.
918
+ - ``path`` must be a repo-relative string — no absolute path, no ``..``
919
+ component, no backslash — so it can never resolve outside the target.
920
+ """
921
+ members = answers.get("members")
922
+ if not isinstance(members, list) or not members:
923
+ raise UsageError("--answers must include a non-empty members[] array")
924
+ for member in members:
925
+ stack = member.get("stack")
926
+ if stack not in STACK_COMMANDS:
927
+ raise UsageError(
928
+ f"unknown stack {stack!r}: expected one of {sorted(STACK_COMMANDS)}"
929
+ )
930
+ pm = member.get("packageManager")
931
+ choices = PACKAGE_MANAGERS.get(stack)
932
+ if choices is not None:
933
+ if pm not in choices:
934
+ raise UsageError(
935
+ f"invalid packageManager {pm!r} for stack {stack!r}: "
936
+ f"expected one of {choices}"
937
+ )
938
+ elif pm is not None:
939
+ raise UsageError(
940
+ f"stack {stack!r} takes no packageManager, got {pm!r}"
941
+ )
942
+ path = member.get("path")
943
+ if not isinstance(path, str) or not path:
944
+ raise UsageError(f"member path must be a non-empty string, got {path!r}")
945
+ if os.path.isabs(path) or "\\" in path or ".." in Path(path).parts:
946
+ raise UsageError(
947
+ f"unsafe member path {path!r}: must be repo-relative with no '..'"
948
+ )
949
+
950
+
873
951
  def _dispatch(args: argparse.Namespace, target: Path) -> int:
874
952
  """Route a parsed command to its handler, translating outcomes into exit codes.
875
953
 
@@ -24,7 +24,10 @@ cat > "$CONFIG_FILE" << 'EOF'
24
24
  "loopIterationMultiplier": 1.5,
25
25
  "autoInvokeNextStage": true,
26
26
  "contextWindowTokens": null,
27
- "contextWarnThreshold": 0.7
27
+ "contextWarnThreshold": 0.7,
28
+ "autoVerify": false,
29
+ "autoVerifyStages": {},
30
+ "autoFix": false
28
31
  }
29
32
  EOF
30
33
 
@@ -43,6 +46,9 @@ echo " loopIterationMultiplier: 1.5 (multiplier for loop iterations)"
43
46
  echo " autoInvokeNextStage: true (navigator auto-starts the next stage after you confirm)"
44
47
  echo " contextWindowTokens: null (infer; set to 1000000 on a 1M-context model)"
45
48
  echo " contextWarnThreshold: 0.7 (suggest a clean session past this fraction of the window)"
49
+ echo " autoVerify: false (set true to run forge-verify automatically after each stage)"
50
+ echo " autoVerifyStages: {} (per-stage overrides for autoVerify)"
51
+ echo " autoFix: false (set true to chain forge-fix after an auto-verify finds issues)"
46
52
  echo ""
47
53
  echo "The loop runner defaults to rauf. To target a different ralph-style runner,"
48
54
  echo "add a \"loopRunner\" block (see references/forge-config-schema.json)."
@@ -31,14 +31,33 @@ if is_root "$root"; then
31
31
  exit 0
32
32
  fi
33
33
 
34
+ # ── Step 2a: Claude marketplace-cache installs — ~/.claude/plugins/cache/<mp>/<plugin>/<ver>/.
35
+ # This is where Claude Code actually installs marketplace plugins (three segments below
36
+ # plugins/, so the single-star plugins/*/feature-forge glob below can never match it). Version
37
+ # dirs can coexist after upgrades, so probe newest plugin.json first — a stale version must
38
+ # never shadow the current install. Deliberately ordered BEFORE the plugins/* glob: that glob
39
+ # can match the marketplace *clone* (~/.claude/plugins/marketplaces/<mp>/ when the marketplace
40
+ # repo root is itself a plugin root), which may sit at a different commit than the installed
41
+ # skills — the versioned cache install must always win to prevent that version skew.
42
+ while IFS= read -r manifest; do
43
+ candidate="${manifest%/.claude-plugin/plugin.json}"
44
+ if is_root "$candidate"; then
45
+ printf '%s\n' "$candidate"
46
+ exit 0
47
+ fi
48
+ done < <(ls -t "$HOME"/.claude/plugins/cache/*/feature-forge/*/.claude-plugin/plugin.json 2>/dev/null || true)
49
+
34
50
  # ── Step 2: candidate-root probe (authoritative multi-agent root list; extend here first). ─
35
51
  # Globs that match nothing expand to themselves; the is_root test rejects such literals. Covers
36
52
  # every supported agent's install destination under BOTH global ($HOME) and project ($PWD) scope,
37
53
  # matching the installer's per-agent layout: claude .claude/skills, codex .agents/skills, copilot
38
- # .github/feature-forge, cursor .cursor/rules, gemini .gemini/extensions.
54
+ # .github/feature-forge, cursor .cursor/rules, gemini .gemini/extensions. The cache glob repeats
55
+ # step 2a's path for a cache install that carries only the neutral bundle sentinel (no
56
+ # plugin.json for ls -t to key on).
39
57
  for candidate in \
40
58
  "$HOME/.claude/skills/feature-forge" \
41
59
  "$PWD/.claude/skills/feature-forge" \
60
+ "$HOME"/.claude/plugins/cache/*/feature-forge/* \
42
61
  "$HOME"/.claude/plugins/*/feature-forge \
43
62
  "$HOME/.agents/skills/feature-forge" \
44
63
  "$PWD/.agents/skills/feature-forge" \