@garygentry/feature-forge 0.2.3 → 0.2.4

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 (149) hide show
  1. package/README.md +6 -1
  2. package/adapters/GENERATION-REPORT.md +5 -1
  3. package/adapters/claude/references/forge-config-schema.json +25 -3
  4. package/adapters/claude/references/pipeline-state-schema.json +3 -2
  5. package/adapters/claude/references/portable-root.md +2 -2
  6. package/adapters/claude/references/process-overview.md +10 -0
  7. package/adapters/claude/references/shared-conventions.md +14 -9
  8. package/adapters/claude/references/stage-exit-protocol.md +99 -0
  9. package/adapters/claude/scripts/epic-manifest.py +10 -0
  10. package/adapters/claude/scripts/forge-bootstrap.py +94 -16
  11. package/adapters/claude/scripts/forge-init.sh +7 -1
  12. package/adapters/claude/scripts/forge-session.py +175 -30
  13. package/adapters/claude/skills/forge/SKILL.md +28 -14
  14. package/adapters/claude/skills/forge-0-epic/SKILL.md +20 -15
  15. package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +6 -4
  16. package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  17. package/adapters/claude/skills/forge-1-prd/SKILL.md +14 -4
  18. package/adapters/claude/skills/forge-2-tech/SKILL.md +14 -3
  19. package/adapters/claude/skills/forge-3-specs/SKILL.md +14 -3
  20. package/adapters/claude/skills/forge-4-backlog/SKILL.md +16 -5
  21. package/adapters/claude/skills/forge-5-loop/SKILL.md +19 -21
  22. package/adapters/claude/skills/forge-5-loop/references/result-reporting.md +10 -5
  23. package/adapters/claude/skills/forge-6-docs/SKILL.md +6 -6
  24. package/adapters/claude/skills/forge-bootstrap/SKILL.md +4 -4
  25. package/adapters/claude/skills/forge-fix/SKILL.md +27 -6
  26. package/adapters/claude/skills/forge-guide/SKILL.md +179 -0
  27. package/adapters/claude/skills/forge-init/SKILL.md +28 -1
  28. package/adapters/claude/skills/forge-verify/SKILL.md +46 -15
  29. package/adapters/claude/skills/forge-verify/references/verification-checklists.md +1 -1
  30. package/adapters/codex/references/forge-config-schema.json +25 -3
  31. package/adapters/codex/references/pipeline-state-schema.json +3 -2
  32. package/adapters/codex/references/portable-root.md +2 -2
  33. package/adapters/codex/references/process-overview.md +10 -0
  34. package/adapters/codex/references/shared-conventions.md +14 -9
  35. package/adapters/codex/references/stage-exit-protocol.md +99 -0
  36. package/adapters/codex/scripts/epic-manifest.py +10 -0
  37. package/adapters/codex/scripts/forge-bootstrap.py +94 -16
  38. package/adapters/codex/scripts/forge-init.sh +7 -1
  39. package/adapters/codex/scripts/forge-session.py +175 -30
  40. package/adapters/codex/skills/forge/SKILL.md +33 -19
  41. package/adapters/codex/skills/forge-0-epic/SKILL.md +21 -16
  42. package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +6 -4
  43. package/adapters/codex/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  44. package/adapters/codex/skills/forge-1-prd/SKILL.md +14 -4
  45. package/adapters/codex/skills/forge-2-tech/SKILL.md +15 -4
  46. package/adapters/codex/skills/forge-3-specs/SKILL.md +14 -3
  47. package/adapters/codex/skills/forge-4-backlog/SKILL.md +16 -5
  48. package/adapters/codex/skills/forge-5-loop/SKILL.md +21 -23
  49. package/adapters/codex/skills/forge-5-loop/references/result-reporting.md +10 -5
  50. package/adapters/codex/skills/forge-6-docs/SKILL.md +6 -6
  51. package/adapters/codex/skills/forge-bootstrap/SKILL.md +4 -4
  52. package/adapters/codex/skills/forge-fix/SKILL.md +27 -6
  53. package/adapters/codex/skills/forge-guide/SKILL.md +188 -0
  54. package/adapters/codex/skills/forge-init/SKILL.md +28 -1
  55. package/adapters/codex/skills/forge-verify/SKILL.md +45 -14
  56. package/adapters/codex/skills/forge-verify/references/verification-checklists.md +1 -1
  57. package/adapters/copilot/references/forge-config-schema.json +25 -3
  58. package/adapters/copilot/references/pipeline-state-schema.json +3 -2
  59. package/adapters/copilot/references/portable-root.md +2 -2
  60. package/adapters/copilot/references/process-overview.md +10 -0
  61. package/adapters/copilot/references/shared-conventions.md +14 -9
  62. package/adapters/copilot/references/stage-exit-protocol.md +99 -0
  63. package/adapters/copilot/scripts/epic-manifest.py +10 -0
  64. package/adapters/copilot/scripts/forge-bootstrap.py +94 -16
  65. package/adapters/copilot/scripts/forge-init.sh +7 -1
  66. package/adapters/copilot/scripts/forge-session.py +175 -30
  67. package/adapters/copilot/skills/forge/forge.md +33 -19
  68. package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +21 -16
  69. package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +6 -4
  70. package/adapters/copilot/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  71. package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +14 -4
  72. package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +15 -4
  73. package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +14 -3
  74. package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +16 -5
  75. package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +21 -23
  76. package/adapters/copilot/skills/forge-5-loop/references/result-reporting.md +10 -5
  77. package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +6 -6
  78. package/adapters/copilot/skills/forge-bootstrap/forge-bootstrap.md +4 -4
  79. package/adapters/copilot/skills/forge-fix/forge-fix.md +27 -6
  80. package/adapters/copilot/skills/forge-guide/forge-guide.md +188 -0
  81. package/adapters/copilot/skills/forge-init/forge-init.md +28 -1
  82. package/adapters/copilot/skills/forge-verify/forge-verify.md +45 -14
  83. package/adapters/copilot/skills/forge-verify/references/verification-checklists.md +1 -1
  84. package/adapters/cursor/references/forge-config-schema.json +25 -3
  85. package/adapters/cursor/references/pipeline-state-schema.json +3 -2
  86. package/adapters/cursor/references/portable-root.md +2 -2
  87. package/adapters/cursor/references/process-overview.md +10 -0
  88. package/adapters/cursor/references/shared-conventions.md +14 -9
  89. package/adapters/cursor/references/stage-exit-protocol.md +99 -0
  90. package/adapters/cursor/scripts/epic-manifest.py +10 -0
  91. package/adapters/cursor/scripts/forge-bootstrap.py +94 -16
  92. package/adapters/cursor/scripts/forge-init.sh +7 -1
  93. package/adapters/cursor/scripts/forge-session.py +175 -30
  94. package/adapters/cursor/skills/forge/forge.mdc +33 -19
  95. package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +21 -16
  96. package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +6 -4
  97. package/adapters/cursor/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  98. package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +14 -4
  99. package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +15 -4
  100. package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +14 -3
  101. package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +16 -5
  102. package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +21 -23
  103. package/adapters/cursor/skills/forge-5-loop/references/result-reporting.md +10 -5
  104. package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +6 -6
  105. package/adapters/cursor/skills/forge-bootstrap/forge-bootstrap.mdc +4 -4
  106. package/adapters/cursor/skills/forge-fix/forge-fix.mdc +27 -6
  107. package/adapters/cursor/skills/forge-guide/forge-guide.mdc +189 -0
  108. package/adapters/cursor/skills/forge-init/forge-init.mdc +28 -1
  109. package/adapters/cursor/skills/forge-verify/forge-verify.mdc +45 -14
  110. package/adapters/cursor/skills/forge-verify/references/verification-checklists.md +1 -1
  111. package/adapters/gemini/gemini-extension.json +4 -0
  112. package/adapters/gemini/references/forge-config-schema.json +25 -3
  113. package/adapters/gemini/references/pipeline-state-schema.json +3 -2
  114. package/adapters/gemini/references/portable-root.md +2 -2
  115. package/adapters/gemini/references/process-overview.md +10 -0
  116. package/adapters/gemini/references/shared-conventions.md +14 -9
  117. package/adapters/gemini/references/stage-exit-protocol.md +99 -0
  118. package/adapters/gemini/scripts/epic-manifest.py +10 -0
  119. package/adapters/gemini/scripts/forge-bootstrap.py +94 -16
  120. package/adapters/gemini/scripts/forge-init.sh +7 -1
  121. package/adapters/gemini/scripts/forge-session.py +175 -30
  122. package/adapters/gemini/skills/forge/forge.md +33 -19
  123. package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +21 -16
  124. package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +6 -4
  125. package/adapters/gemini/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  126. package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +14 -4
  127. package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +15 -4
  128. package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +14 -3
  129. package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +16 -5
  130. package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +21 -23
  131. package/adapters/gemini/skills/forge-5-loop/references/result-reporting.md +10 -5
  132. package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +6 -6
  133. package/adapters/gemini/skills/forge-bootstrap/forge-bootstrap.md +4 -4
  134. package/adapters/gemini/skills/forge-fix/forge-fix.md +27 -6
  135. package/adapters/gemini/skills/forge-guide/forge-guide.md +188 -0
  136. package/adapters/gemini/skills/forge-init/forge-init.md +28 -1
  137. package/adapters/gemini/skills/forge-verify/forge-verify.md +45 -14
  138. package/adapters/gemini/skills/forge-verify/references/verification-checklists.md +1 -1
  139. package/dist/apply.js +34 -8
  140. package/dist/cli.js +40 -4
  141. package/dist/fsutil.d.ts +0 -12
  142. package/dist/fsutil.js +10 -1
  143. package/dist/manifest.d.ts +1 -1
  144. package/dist/plan.js +22 -2
  145. package/dist/rauf.d.ts +4 -4
  146. package/dist/rauf.js +3 -3
  147. package/dist/report.js +1 -1
  148. package/dist/types.d.ts +1 -1
  149. package/package.json +1 -1
@@ -0,0 +1,99 @@
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 block 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.** Verify's clean-room subagent is
15
+ dispatched from the *current* session, so the findings digest and any fix decision
16
+ land where the context to act on them still exists. Clearing first throws that
17
+ context away.
18
+
19
+ ## How this file is used
20
+
21
+ The blocks below are **stamped verbatim** into each stage skill's closing (a runtime
22
+ `references/` include would not survive the adapter build, which flattens skills into
23
+ `adapters/<agent>/`). A drift-guard test (`tests/test_stage_exit_protocol.py`) asserts
24
+ each stamp site still contains its block, so an edit here must be mirrored into every
25
+ stamp site (and vice-versa).
26
+
27
+ Each stamp site fills three slots; everything else is identical across sites:
28
+
29
+ - `{stage}` — the just-completed stage as a lowercase noun phrase (e.g. `the PRD`,
30
+ `the tech spec`, `the backlog`). Always used mid-sentence, never sentence-initial.
31
+ - `{verify-command}` — the verify command for that stage (e.g.
32
+ `` `/feature-forge:forge-verify {feature}` ``).
33
+ - `{next-command}` — the command that starts the next stage (e.g.
34
+ `` `/feature-forge:forge-2-tech {feature}` ``).
35
+
36
+ `{feature}` / `{epic}` and similar remain runtime placeholders that pass through
37
+ untouched — they are resolved by the skill at run time, not by this template.
38
+
39
+ ## Stamp sites
40
+
41
+ | Stamp site | Block | `{stage}` |
42
+ |---|---|---|
43
+ | `forge-0-epic` (epic → first PRD) | standard | the epic decomposition |
44
+ | `forge-1-prd` | standard | the PRD |
45
+ | `forge-2-tech` | standard | the tech spec |
46
+ | `forge-3-specs` | standard | the implementation specs |
47
+ | `forge-4-backlog` | standard | the backlog |
48
+ | `forge-5-loop` (step-6 epic-member handoff) | standard | feature `{feature}`'s loop |
49
+ | `forge-5-loop` (all-done closing → docs) | warm | — |
50
+
51
+ `forge-6-docs` is **terminal** — it stamps no exit block. It is the *target* of the
52
+ warm variant, not a stamp site.
53
+
54
+ ---
55
+
56
+ ## Standard block
57
+
58
+ Stamp this at every authoring-stage boundary. It self-adapts: step 1's verify gate
59
+ only fires when verification is actually outstanding, so at a boundary where verify
60
+ already ran (or was explicitly skipped, or auto-verify is on) it silently collapses to
61
+ just the `/clear` → next-command steps.
62
+
63
+ <!-- BEGIN: standard-exit-block -->
64
+ **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:
65
+
66
+ 1. **Verify {stage} first — if it isn't already verified.** When this stage has no fresh verification on record (`verifyState` is **missing or stale**) **and** `autoVerify` is off for it, verify **now, before clearing**. If verify already ran, is pending under auto-verify, or the stage was explicitly skipped, say so and go straight to step 2. Present the **Standard Verify Gate** 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):
67
+ - **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.
68
+ - **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.
69
+ - **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.
70
+
71
+ **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.
72
+ 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.**
73
+ 3. **Then run `{next-command}`** in the fresh session — or re-run `/feature-forge:forge` to let the navigator resume from disk.
74
+ <!-- END: standard-exit-block -->
75
+
76
+ ---
77
+
78
+ ## Warm-acceptable variant
79
+
80
+ Stamp this only at the `forge-5-loop → forge-6-docs` boundary (the all-done result
81
+ report). Here clearing is **optional**: the docs stage benefits from the still-warm
82
+ context of what the loop actually did, and impl-verify is already offered interactively
83
+ by the loop itself, so this block defers rather than re-presenting a gate.
84
+
85
+ > **Note — no literal `/clear` here.** The warm block lives in `result-reporting.md`, a
86
+ > skill-*own* reference that the adapter build copies **verbatim** (unlike skill bodies,
87
+ > it is not host-term translated), so a literal `/clear` would reach non-Claude adapters
88
+ > undegraded. The warm variant says "clearing is optional" anyway, so it is phrased
89
+ > host-neutrally without the token on purpose — do not reintroduce `/clear` here. (The
90
+ > standard block *does* use `/clear`; that is fine because every standard stamp site is a
91
+ > skill **body**, where `scripts/build-adapters.py` degrades it.)
92
+
93
+ <!-- BEGIN: warm-exit-block -->
94
+ **The loop is complete — this is the one boundary where clearing before the next stage is optional.**
95
+
96
+ 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.
97
+ 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.
98
+ 3. **Then run `{next-command}`** — in this warm session, or a fresh one if you prefer.
99
+ <!-- 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)."
@@ -37,7 +37,7 @@ from __future__ import annotations
37
37
  import argparse
38
38
  import json
39
39
  import sys
40
- from datetime import datetime
40
+ from datetime import datetime, timezone
41
41
  from pathlib import Path
42
42
  from typing import Final, TypedDict
43
43
 
@@ -102,6 +102,10 @@ class FeatureRow(TypedDict):
102
102
  nextCommand: str | None
103
103
  verifyPending: bool
104
104
  verifyCommand: str | None
105
+ verifyStage: str | None
106
+ verifyState: str
107
+ autoVerify: bool
108
+ autoFix: bool
105
109
 
106
110
 
107
111
  class UsageError(Exception):
@@ -181,13 +185,52 @@ def next_stage(state: dict) -> str | None:
181
185
  return None
182
186
 
183
187
 
184
- def pending_verify(state: dict) -> str | None:
185
- """Return the production stage whose verify is outstanding, if any.
188
+ def _stage_version(state: dict, stage: str) -> int | None:
189
+ """Return the recorded ``version`` of a stage entry, or None if absent."""
190
+ stages = state.get("stages")
191
+ if not isinstance(stages, dict):
192
+ return None
193
+ entry = stages.get(stage)
194
+ if not isinstance(entry, dict):
195
+ return None
196
+ version = entry.get("version")
197
+ return version if isinstance(version, int) else None
198
+
186
199
 
187
- The most recently completed production stage whose corresponding
188
- ``forge-verify-*`` is neither resolved (passed/findings-applied/skipped) nor
189
- already run. Surfaced so the navigator can offer "verify before continuing"
190
- as an alternative to advancing. Returns ``None`` when nothing needs verify.
200
+ def _verify_entry(state: dict, verify_key: str) -> dict:
201
+ """Return the ``forge-verify-*`` entry dict, or ``{}`` if absent."""
202
+ stages = state.get("stages")
203
+ if not isinstance(stages, dict):
204
+ return {}
205
+ entry = stages.get(verify_key)
206
+ return entry if isinstance(entry, dict) else {}
207
+
208
+
209
+ def verify_state(state: dict) -> tuple[str | None, str]:
210
+ """Classify verify freshness for the most-recently-completed stage.
211
+
212
+ Returns ``(stage, state_label)`` where ``state_label`` is one of:
213
+
214
+ - ``fresh`` — verify is resolved AND its ``verifiedStageVersion`` matches the
215
+ stage's current ``version`` (so no re-verify is needed).
216
+ - ``stale`` — verify was resolved once, but the stage version has since moved
217
+ (artifact revised) OR the entry predates the freshness ledger (no
218
+ ``verifiedStageVersion``). A revised artifact must be re-verified.
219
+ - ``failing`` — verify ran and reported findings that are not yet applied
220
+ (``findings-reported``).
221
+ - ``never`` — the stage completed but verify has not run at all.
222
+ - ``skipped`` — the user explicitly chose to proceed without verifying. A
223
+ resolved, non-pending state: it is deliberately NOT re-offered or
224
+ auto-verified, and (unlike a genuine verification result) it does not go
225
+ stale on an artifact revision — skip writers record no version to compare
226
+ against, and re-surfacing would override an explicit human decision.
227
+ - ``none`` — no completed verify-capable stage (nothing to verify), stage
228
+ is ``None``.
229
+
230
+ Only the most-recent completed production stage is considered, matching the
231
+ navigator's "verify before continuing" gate. Absent ``verifiedStageVersion``
232
+ on a ``passed``/``findings-applied`` entry (legacy state) is deliberately
233
+ treated as ``stale`` — verify rather than skip.
191
234
  """
192
235
  for stage in reversed(PRODUCTION_STAGES):
193
236
  if _stage_status(state, stage) != _DONE_STATUS:
@@ -195,11 +238,41 @@ def pending_verify(state: dict) -> str | None:
195
238
  token = VERIFY_TOKEN_BY_STAGE.get(stage)
196
239
  if token is None:
197
240
  continue # forge-6-docs has no verify step
198
- verify_status = _stage_status(state, f"forge-verify-{token}")
199
- if verify_status not in _VERIFY_RESOLVED:
200
- return stage
201
- return None # most-recent complete stage is already verified
202
- return None
241
+ entry = _verify_entry(state, f"forge-verify-{token}")
242
+ status = entry.get("status")
243
+ if status == "skipped":
244
+ # An explicit skip is resolved and non-pending — preserve the user's
245
+ # decision. It never goes stale (no recorded version to compare), so
246
+ # the freshness check below deliberately does not apply.
247
+ return stage, "skipped"
248
+ if status not in _VERIFY_RESOLVED:
249
+ if status == "findings-reported":
250
+ return stage, "failing"
251
+ return stage, "never"
252
+ verified_version = entry.get("verifiedStageVersion")
253
+ stage_version = _stage_version(state, stage)
254
+ if (
255
+ isinstance(verified_version, int)
256
+ and stage_version is not None
257
+ and verified_version == stage_version
258
+ ):
259
+ return stage, "fresh"
260
+ return stage, "stale"
261
+ return None, "none"
262
+
263
+
264
+ def pending_verify(state: dict) -> str | None:
265
+ """Return the production stage whose verify is outstanding, if any.
266
+
267
+ Outstanding means the most-recently-completed production stage's verify is not
268
+ ``fresh`` (never run, reported findings, or gone stale after an artifact
269
+ revision). An explicit ``skipped`` is treated as resolved (never outstanding).
270
+ Surfaced so the navigator can offer "verify before continuing" as an
271
+ alternative to advancing. Returns ``None`` when the latest stage is fresh,
272
+ skipped, or there is nothing to verify.
273
+ """
274
+ stage, label = verify_state(state)
275
+ return stage if label not in ("fresh", "none", "skipped") else None
203
276
 
204
277
 
205
278
  def _parse_ts(value: str | None) -> datetime | None:
@@ -207,25 +280,37 @@ def _parse_ts(value: str | None) -> datetime | None:
207
280
  if not isinstance(value, str):
208
281
  return None
209
282
  try:
210
- return datetime.fromisoformat(value.replace("Z", "+00:00"))
283
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
211
284
  except ValueError:
212
285
  return None
286
+ if dt.tzinfo is None:
287
+ dt = dt.replace(tzinfo=timezone.utc)
288
+ return dt
213
289
 
214
290
 
215
- def build_rows(specs_dir: Path) -> list[FeatureRow]:
291
+ def build_rows(specs_dir: Path, config: dict | None = None) -> list[FeatureRow]:
216
292
  """Build the recency-ranked active-feature rows (the rank-features payload).
217
293
 
218
294
  Active features (``pipelineStatus == "active"``, the default when absent) are
219
295
  sorted by ``updatedAt`` descending — most recently touched first — so the
220
296
  navigator's recency default is row 0.
297
+
298
+ ``config`` is the loaded forge.config.json (or ``{}``); it drives the effective
299
+ ``autoVerify``/``autoFix`` per stage so the navigator can branch without
300
+ re-reading config.
221
301
  """
302
+ config = config or {}
303
+ # Fail closed: only a literal JSON ``true`` enables artifact-mutating autoFix.
304
+ global_auto_fix = config.get("autoFix") is True
222
305
  rows: list[FeatureRow] = []
223
306
  for name, epic, state in _scan_features(specs_dir):
224
307
  status = state.get("pipelineStatus", "active")
225
308
  if status != "active":
226
309
  continue
227
310
  nxt = next_stage(state)
228
- verify_stage = pending_verify(state)
311
+ vstage, vlabel = verify_state(state)
312
+ verify_pending = vstage is not None and vlabel not in ("fresh", "none", "skipped")
313
+ effective_auto_verify = auto_verify_for(config, vstage) if vstage else False
229
314
  branch = state.get("branch")
230
315
  updated = state.get("updatedAt")
231
316
  rows.append({
@@ -237,12 +322,16 @@ def build_rows(specs_dir: Path) -> list[FeatureRow]:
237
322
  "complete": nxt is None,
238
323
  "nextStage": nxt,
239
324
  "nextCommand": f"/feature-forge:{nxt} {name}" if nxt else None,
240
- "verifyPending": verify_stage is not None,
241
- "verifyCommand": f"/feature-forge:forge-verify {name}" if verify_stage else None,
325
+ "verifyPending": verify_pending,
326
+ "verifyCommand": f"/feature-forge:forge-verify {name}" if verify_pending else None,
327
+ "verifyStage": vstage,
328
+ "verifyState": vlabel,
329
+ "autoVerify": effective_auto_verify,
330
+ "autoFix": global_auto_fix and effective_auto_verify,
242
331
  })
243
332
  # Sort by updatedAt desc; rows without a parseable timestamp sort last.
244
333
  rows.sort(
245
- key=lambda r: (_parse_ts(r["updatedAt"]) or datetime.min.replace(tzinfo=None)),
334
+ key=lambda r: (_parse_ts(r["updatedAt"]) or datetime.min.replace(tzinfo=timezone.utc)),
246
335
  reverse=True,
247
336
  )
248
337
  return rows
@@ -308,12 +397,17 @@ def _last_usage(transcript: Path) -> tuple[int, str | None] | None:
308
397
  usage = message.get("usage") if isinstance(message, dict) else record.get("usage")
309
398
  if not isinstance(usage, dict):
310
399
  continue
311
- total = (
312
- int(usage.get("input_tokens", 0) or 0)
313
- + int(usage.get("cache_creation_input_tokens", 0) or 0)
314
- + int(usage.get("cache_read_input_tokens", 0) or 0)
315
- + int(usage.get("output_tokens", 0) or 0)
316
- )
400
+ # A malformed transcript may carry a non-numeric usage field; skip that
401
+ # record rather than crash the whole context-usage read (ValueError/TypeError).
402
+ try:
403
+ total = (
404
+ int(usage.get("input_tokens", 0) or 0)
405
+ + int(usage.get("cache_creation_input_tokens", 0) or 0)
406
+ + int(usage.get("cache_read_input_tokens", 0) or 0)
407
+ + int(usage.get("output_tokens", 0) or 0)
408
+ )
409
+ except (TypeError, ValueError):
410
+ continue
317
411
  if total <= 0:
318
412
  continue
319
413
  model = message.get("model") if isinstance(message, dict) else record.get("model")
@@ -328,13 +422,53 @@ def _infer_window(model: str | None) -> int:
328
422
  return _DEFAULT_WINDOW
329
423
 
330
424
 
331
- def _config_value(config_path: Path, key: str):
332
- """Read a single key from forge.config.json, or None if absent/unreadable."""
425
+ def _load_config(config_path: Path) -> dict:
426
+ """Read forge.config.json into a dict, tolerating missing/corrupt files.
427
+
428
+ A missing, unreadable, or non-object config downgrades to ``{}`` so callers
429
+ read every key through absent-safe ``.get`` defaults.
430
+ """
333
431
  try:
334
432
  config = json.loads(config_path.read_text(encoding="utf-8"))
335
433
  except (OSError, json.JSONDecodeError):
336
- return None
337
- return config.get(key) if isinstance(config, dict) else None
434
+ return {}
435
+ return config if isinstance(config, dict) else {}
436
+
437
+
438
+ def _config_value(config_path: Path, key: str):
439
+ """Read a single key from forge.config.json, or None if absent/unreadable."""
440
+ return _load_config(config_path).get(key)
441
+
442
+
443
+ def auto_verify_for(config: dict, stage: str) -> bool:
444
+ """Return the effective auto-verify setting for ``stage``.
445
+
446
+ Per-stage override in ``autoVerifyStages`` wins over the global ``autoVerify``;
447
+ both default to off, so a config with neither key means "no auto-verify".
448
+
449
+ Parsing is strict and **fails closed**: only a literal JSON ``true`` enables
450
+ auto-verify. A non-boolean value (e.g. the string ``"false"``, which is truthy
451
+ in Python) is treated as off, not on. The schema already rejects non-booleans
452
+ at author time; this guards a hand-edited config from silently enabling
453
+ automation.
454
+ """
455
+ stages = config.get("autoVerifyStages")
456
+ if isinstance(stages, dict) and stage in stages:
457
+ return stages[stage] is True
458
+ return config.get("autoVerify") is True
459
+
460
+
461
+ def invalid_auto_verify_keys(config: dict) -> list[str]:
462
+ """Return ``autoVerifyStages`` keys outside the verify-capable stage ids.
463
+
464
+ An unknown/typo key (e.g. ``forge-1-prod``) would silently never take effect,
465
+ turning an intended off-switch into a no-op. Surfacing it lets the navigator
466
+ warn instead of failing quietly. Mirrors the schema's ``propertyNames.enum``.
467
+ """
468
+ stages = config.get("autoVerifyStages")
469
+ if not isinstance(stages, dict):
470
+ return []
471
+ return [key for key in stages if key not in VERIFY_TOKEN_BY_STAGE]
338
472
 
339
473
 
340
474
  def context_usage(
@@ -449,6 +583,7 @@ def main() -> int:
449
583
 
450
584
  p_rank = sub.add_parser("rank-features", help="Rank active features by recency")
451
585
  p_rank.add_argument("--specs-dir", default="./specs", help="Specs directory")
586
+ p_rank.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
452
587
  p_rank.add_argument("--json", action="store_true", dest="json_output")
453
588
 
454
589
  p_ctx = sub.add_parser("context-usage", help="Report live context-window usage")
@@ -462,12 +597,22 @@ def main() -> int:
462
597
  try:
463
598
  if args.cmd == "rank-features":
464
599
  specs_dir = Path(args.specs_dir)
465
- rows = build_rows(specs_dir)
600
+ config = _load_config(Path(args.config))
601
+ rows = build_rows(specs_dir, config)
466
602
  counts = _counts(specs_dir)
603
+ invalid_keys = invalid_auto_verify_keys(config)
467
604
  if args.json_output:
468
- print(json.dumps({"active": rows, "counts": counts}, indent=2, ensure_ascii=False))
605
+ payload = {"active": rows, "counts": counts}
606
+ if invalid_keys:
607
+ payload["invalidAutoVerifyKeys"] = invalid_keys
608
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
469
609
  else:
470
610
  _print_rank_table(rows, counts)
611
+ if invalid_keys:
612
+ print(
613
+ " ! invalid autoVerifyStages keys (ignored): "
614
+ + ", ".join(invalid_keys)
615
+ )
471
616
  return 0
472
617
 
473
618
  if args.cmd == "context-usage":