@garygentry/feature-forge 0.2.14 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/README.md +6 -3
  2. package/adapters/GENERATION-REPORT.md +20 -0
  3. package/adapters/claude/.feature-forge-bundle.json +1 -1
  4. package/adapters/claude/references/forge-config-schema.json +2 -2
  5. package/adapters/claude/scripts/forge-root.sh +47 -3
  6. package/adapters/claude/scripts/forge-session.py +30 -8
  7. package/adapters/claude/skills/forge-4-backlog/references/forge-config-schema.json +2 -2
  8. package/adapters/claude/skills/forge-5-loop/references/forge-config-schema.json +2 -2
  9. package/adapters/claude/skills/forge-guide/references/forge-config-schema.json +2 -2
  10. package/adapters/codex/.feature-forge-bundle.json +1 -1
  11. package/adapters/codex/references/forge-config-schema.json +2 -2
  12. package/adapters/codex/scripts/forge-root.sh +47 -3
  13. package/adapters/codex/scripts/forge-session.py +30 -8
  14. package/adapters/codex/skills/forge-4-backlog/references/forge-config-schema.json +2 -2
  15. package/adapters/codex/skills/forge-5-loop/references/forge-config-schema.json +2 -2
  16. package/adapters/codex/skills/forge-guide/references/forge-config-schema.json +2 -2
  17. package/adapters/copilot/.feature-forge-bundle.json +1 -1
  18. package/adapters/copilot/references/forge-config-schema.json +2 -2
  19. package/adapters/copilot/scripts/forge-root.sh +47 -3
  20. package/adapters/copilot/scripts/forge-session.py +30 -8
  21. package/adapters/copilot/skills/forge-4-backlog/references/forge-config-schema.json +2 -2
  22. package/adapters/copilot/skills/forge-5-loop/references/forge-config-schema.json +2 -2
  23. package/adapters/copilot/skills/forge-guide/references/forge-config-schema.json +2 -2
  24. package/adapters/cursor/.feature-forge-bundle.json +1 -1
  25. package/adapters/cursor/references/forge-config-schema.json +2 -2
  26. package/adapters/cursor/scripts/forge-root.sh +47 -3
  27. package/adapters/cursor/scripts/forge-session.py +30 -8
  28. package/adapters/cursor/skills/forge-4-backlog/references/forge-config-schema.json +2 -2
  29. package/adapters/cursor/skills/forge-5-loop/references/forge-config-schema.json +2 -2
  30. package/adapters/cursor/skills/forge-guide/references/forge-config-schema.json +2 -2
  31. package/adapters/gemini/.feature-forge-bundle.json +1 -1
  32. package/adapters/gemini/gemini-extension.json +1 -1
  33. package/adapters/gemini/references/forge-config-schema.json +2 -2
  34. package/adapters/gemini/scripts/forge-root.sh +47 -3
  35. package/adapters/gemini/scripts/forge-session.py +30 -8
  36. package/adapters/gemini/skills/forge-4-backlog/references/forge-config-schema.json +2 -2
  37. package/adapters/gemini/skills/forge-5-loop/references/forge-config-schema.json +2 -2
  38. package/adapters/gemini/skills/forge-guide/references/forge-config-schema.json +2 -2
  39. package/adapters/pi/.feature-forge-bundle.json +6 -0
  40. package/adapters/pi/agents/forge-researcher.md +139 -0
  41. package/adapters/pi/agents/forge-spec-writer.md +116 -0
  42. package/adapters/pi/agents/forge-verifier.md +126 -0
  43. package/adapters/pi/extensions/ask-user-question/LICENSE +21 -0
  44. package/adapters/pi/extensions/ask-user-question/README.md +91 -0
  45. package/adapters/pi/extensions/ask-user-question/ask-user-question.ts +298 -0
  46. package/adapters/pi/extensions/ask-user-question/config.ts +78 -0
  47. package/adapters/pi/extensions/ask-user-question/events.ts +57 -0
  48. package/adapters/pi/extensions/ask-user-question/index.ts +61 -0
  49. package/adapters/pi/extensions/ask-user-question/locales/de.json +27 -0
  50. package/adapters/pi/extensions/ask-user-question/locales/en.json +27 -0
  51. package/adapters/pi/extensions/ask-user-question/locales/es.json +27 -0
  52. package/adapters/pi/extensions/ask-user-question/locales/fr.json +27 -0
  53. package/adapters/pi/extensions/ask-user-question/locales/pt-BR.json +27 -0
  54. package/adapters/pi/extensions/ask-user-question/locales/pt.json +27 -0
  55. package/adapters/pi/extensions/ask-user-question/locales/ru.json +27 -0
  56. package/adapters/pi/extensions/ask-user-question/locales/uk.json +27 -0
  57. package/adapters/pi/extensions/ask-user-question/locales/zh.json +29 -0
  58. package/adapters/pi/extensions/ask-user-question/reconcile.ts +49 -0
  59. package/adapters/pi/extensions/ask-user-question/rpc-fallback.ts +168 -0
  60. package/adapters/pi/extensions/ask-user-question/state/build-questionnaire.ts +302 -0
  61. package/adapters/pi/extensions/ask-user-question/state/i18n-bridge.ts +53 -0
  62. package/adapters/pi/extensions/ask-user-question/state/key-router.ts +277 -0
  63. package/adapters/pi/extensions/ask-user-question/state/questionnaire-session.ts +234 -0
  64. package/adapters/pi/extensions/ask-user-question/state/row-intent.ts +145 -0
  65. package/adapters/pi/extensions/ask-user-question/state/selectors/contract.ts +26 -0
  66. package/adapters/pi/extensions/ask-user-question/state/selectors/derivations.ts +42 -0
  67. package/adapters/pi/extensions/ask-user-question/state/selectors/focus.ts +19 -0
  68. package/adapters/pi/extensions/ask-user-question/state/selectors/projections.ts +101 -0
  69. package/adapters/pi/extensions/ask-user-question/state/state-reducer.ts +292 -0
  70. package/adapters/pi/extensions/ask-user-question/state/state.ts +55 -0
  71. package/adapters/pi/extensions/ask-user-question/tool/format-answer.ts +31 -0
  72. package/adapters/pi/extensions/ask-user-question/tool/response-envelope.ts +49 -0
  73. package/adapters/pi/extensions/ask-user-question/tool/types.ts +147 -0
  74. package/adapters/pi/extensions/ask-user-question/tool/validate-questionnaire.ts +58 -0
  75. package/adapters/pi/extensions/ask-user-question/vendor-config-shim.ts +65 -0
  76. package/adapters/pi/extensions/ask-user-question/view/component-binding.ts +47 -0
  77. package/adapters/pi/extensions/ask-user-question/view/components/inline-input.ts +98 -0
  78. package/adapters/pi/extensions/ask-user-question/view/components/multi-select-view.ts +193 -0
  79. package/adapters/pi/extensions/ask-user-question/view/components/option-list-view.ts +70 -0
  80. package/adapters/pi/extensions/ask-user-question/view/components/preview/markdown-content-cache.ts +79 -0
  81. package/adapters/pi/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +111 -0
  82. package/adapters/pi/extensions/ask-user-question/view/components/preview/preview-box-renderer.ts +88 -0
  83. package/adapters/pi/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +202 -0
  84. package/adapters/pi/extensions/ask-user-question/view/components/preview/preview-pane.ts +228 -0
  85. package/adapters/pi/extensions/ask-user-question/view/components/submit-picker.ts +67 -0
  86. package/adapters/pi/extensions/ask-user-question/view/components/tab-bar.ts +59 -0
  87. package/adapters/pi/extensions/ask-user-question/view/components/wrapping-select.ts +293 -0
  88. package/adapters/pi/extensions/ask-user-question/view/dialog-builder.ts +224 -0
  89. package/adapters/pi/extensions/ask-user-question/view/props-adapter.ts +125 -0
  90. package/adapters/pi/extensions/ask-user-question/view/stateful-view.ts +26 -0
  91. package/adapters/pi/extensions/ask-user-question/view/tab-components.ts +18 -0
  92. package/adapters/pi/extensions/ask-user-question/view/tab-content-strategy.ts +252 -0
  93. package/adapters/pi/package.json +26 -0
  94. package/adapters/pi/references/epic-manifest-schema.json +125 -0
  95. package/adapters/pi/references/forge-config-schema.json +236 -0
  96. package/adapters/pi/references/pipeline-state-schema.json +191 -0
  97. package/adapters/pi/references/portable-root.md +71 -0
  98. package/adapters/pi/references/process-overview.md +143 -0
  99. package/adapters/pi/references/ralph-loop-contract.md +221 -0
  100. package/adapters/pi/references/shared-conventions.md +295 -0
  101. package/adapters/pi/references/skill-frontmatter.schema.json +17 -0
  102. package/adapters/pi/references/stack-resolution.md +54 -0
  103. package/adapters/pi/references/stacks/_generic.md +111 -0
  104. package/adapters/pi/references/stacks/go.md +157 -0
  105. package/adapters/pi/references/stacks/python.md +184 -0
  106. package/adapters/pi/references/stacks/rust.md +170 -0
  107. package/adapters/pi/references/stacks/typescript.md +134 -0
  108. package/adapters/pi/references/stage-exit-protocol.md +258 -0
  109. package/adapters/pi/references/templates/specs-hygiene/AGENTS.md +32 -0
  110. package/adapters/pi/references/templates/specs-hygiene/CLAUDE.md +31 -0
  111. package/adapters/pi/references/vendor-construct-inventory.md +50 -0
  112. package/adapters/pi/scripts/epic-manifest.py +1694 -0
  113. package/adapters/pi/scripts/forge-bootstrap.py +1070 -0
  114. package/adapters/pi/scripts/forge-init.sh +58 -0
  115. package/adapters/pi/scripts/forge-root.sh +179 -0
  116. package/adapters/pi/scripts/forge-session.py +1888 -0
  117. package/adapters/pi/scripts/validate-traceability.py +150 -0
  118. package/adapters/pi/skills/forge/SKILL.md +243 -0
  119. package/adapters/pi/skills/forge/references/pipeline-state-schema.json +191 -0
  120. package/adapters/pi/skills/forge/references/process-overview.md +143 -0
  121. package/adapters/pi/skills/forge/references/shared-conventions.md +295 -0
  122. package/adapters/pi/skills/forge/references/stage-exit-protocol.md +258 -0
  123. package/adapters/pi/skills/forge-0-epic/SKILL.md +308 -0
  124. package/adapters/pi/skills/forge-0-epic/references/edit-mode.md +266 -0
  125. package/adapters/pi/skills/forge-0-epic/references/epic-manifest-subcommands.md +75 -0
  126. package/adapters/pi/skills/forge-0-epic/references/pipeline-state-schema.json +191 -0
  127. package/adapters/pi/skills/forge-0-epic/references/portable-root.md +71 -0
  128. package/adapters/pi/skills/forge-0-epic/references/shared-conventions.md +295 -0
  129. package/adapters/pi/skills/forge-0-epic/references/stage-exit-protocol.md +258 -0
  130. package/adapters/pi/skills/forge-1-prd/SKILL.md +164 -0
  131. package/adapters/pi/skills/forge-1-prd/references/pipeline-state-schema.json +191 -0
  132. package/adapters/pi/skills/forge-1-prd/references/prd-template.md +106 -0
  133. package/adapters/pi/skills/forge-1-prd/references/shared-conventions.md +295 -0
  134. package/adapters/pi/skills/forge-1-prd/references/stage-exit-protocol.md +258 -0
  135. package/adapters/pi/skills/forge-2-tech/SKILL.md +225 -0
  136. package/adapters/pi/skills/forge-2-tech/references/pipeline-state-schema.json +191 -0
  137. package/adapters/pi/skills/forge-2-tech/references/shared-conventions.md +295 -0
  138. package/adapters/pi/skills/forge-2-tech/references/stack-discovery-checklist.md +95 -0
  139. package/adapters/pi/skills/forge-2-tech/references/stack-resolution.md +54 -0
  140. package/adapters/pi/skills/forge-2-tech/references/stacks/_generic.md +111 -0
  141. package/adapters/pi/skills/forge-2-tech/references/stacks/go.md +157 -0
  142. package/adapters/pi/skills/forge-2-tech/references/stacks/python.md +184 -0
  143. package/adapters/pi/skills/forge-2-tech/references/stacks/rust.md +170 -0
  144. package/adapters/pi/skills/forge-2-tech/references/stacks/typescript.md +134 -0
  145. package/adapters/pi/skills/forge-2-tech/references/stage-exit-protocol.md +258 -0
  146. package/adapters/pi/skills/forge-3-specs/SKILL.md +178 -0
  147. package/adapters/pi/skills/forge-3-specs/references/pipeline-state-schema.json +191 -0
  148. package/adapters/pi/skills/forge-3-specs/references/shared-conventions.md +295 -0
  149. package/adapters/pi/skills/forge-3-specs/references/spec-archetypes.md +106 -0
  150. package/adapters/pi/skills/forge-3-specs/references/spec-examples.md +71 -0
  151. package/adapters/pi/skills/forge-3-specs/references/stacks/_generic.md +111 -0
  152. package/adapters/pi/skills/forge-3-specs/references/stacks/go.md +157 -0
  153. package/adapters/pi/skills/forge-3-specs/references/stacks/python.md +184 -0
  154. package/adapters/pi/skills/forge-3-specs/references/stacks/rust.md +170 -0
  155. package/adapters/pi/skills/forge-3-specs/references/stacks/typescript.md +134 -0
  156. package/adapters/pi/skills/forge-3-specs/references/stage-exit-protocol.md +258 -0
  157. package/adapters/pi/skills/forge-4-backlog/SKILL.md +175 -0
  158. package/adapters/pi/skills/forge-4-backlog/references/forge-config-schema.json +236 -0
  159. package/adapters/pi/skills/forge-4-backlog/references/pipeline-state-schema.json +191 -0
  160. package/adapters/pi/skills/forge-4-backlog/references/shared-conventions.md +295 -0
  161. package/adapters/pi/skills/forge-4-backlog/references/stage-exit-protocol.md +258 -0
  162. package/adapters/pi/skills/forge-5-loop/SKILL.md +314 -0
  163. package/adapters/pi/skills/forge-5-loop/references/forge-config-schema.json +236 -0
  164. package/adapters/pi/skills/forge-5-loop/references/ralph-loop-contract.md +221 -0
  165. package/adapters/pi/skills/forge-5-loop/references/result-reporting.md +85 -0
  166. package/adapters/pi/skills/forge-5-loop/references/runner-contract.md +341 -0
  167. package/adapters/pi/skills/forge-5-loop/references/shared-conventions.md +295 -0
  168. package/adapters/pi/skills/forge-5-loop/references/stage-exit-protocol.md +258 -0
  169. package/adapters/pi/skills/forge-6-docs/SKILL.md +202 -0
  170. package/adapters/pi/skills/forge-6-docs/references/doc-conventions.md +126 -0
  171. package/adapters/pi/skills/forge-6-docs/references/pipeline-state-schema.json +191 -0
  172. package/adapters/pi/skills/forge-6-docs/references/shared-conventions.md +295 -0
  173. package/adapters/pi/skills/forge-bootstrap/SKILL.md +250 -0
  174. package/adapters/pi/skills/forge-bootstrap/references/templates/ci/github-actions.yml +12 -0
  175. package/adapters/pi/skills/forge-bootstrap/references/templates/generic/run.sh +3 -0
  176. package/adapters/pi/skills/forge-bootstrap/references/templates/generic/test.sh +13 -0
  177. package/adapters/pi/skills/forge-bootstrap/references/templates/go/go.mod +3 -0
  178. package/adapters/pi/skills/forge-bootstrap/references/templates/go/main.go +12 -0
  179. package/adapters/pi/skills/forge-bootstrap/references/templates/go/main_test.go +11 -0
  180. package/adapters/pi/skills/forge-bootstrap/references/templates/hygiene/AGENTS.md +35 -0
  181. package/adapters/pi/skills/forge-bootstrap/references/templates/hygiene/CLAUDE.md +36 -0
  182. package/adapters/pi/skills/forge-bootstrap/references/templates/hygiene/README.md +11 -0
  183. package/adapters/pi/skills/forge-bootstrap/references/templates/licenses/Apache-2.0/LICENSE +198 -0
  184. package/adapters/pi/skills/forge-bootstrap/references/templates/licenses/MIT/LICENSE +21 -0
  185. package/adapters/pi/skills/forge-bootstrap/references/templates/python/pyproject.toml +24 -0
  186. package/adapters/pi/skills/forge-bootstrap/references/templates/python/src/{{PKG}}/__init__.py +5 -0
  187. package/adapters/pi/skills/forge-bootstrap/references/templates/python/src/{{PKG}}/main.py +13 -0
  188. package/adapters/pi/skills/forge-bootstrap/references/templates/python/tests/test_smoke.py +8 -0
  189. package/adapters/pi/skills/forge-bootstrap/references/templates/rust/Cargo.toml +15 -0
  190. package/adapters/pi/skills/forge-bootstrap/references/templates/rust/src/lib.rs +7 -0
  191. package/adapters/pi/skills/forge-bootstrap/references/templates/rust/src/main.rs +5 -0
  192. package/adapters/pi/skills/forge-bootstrap/references/templates/rust/tests/smoke.rs +6 -0
  193. package/adapters/pi/skills/forge-bootstrap/references/templates/typescript/package.json +15 -0
  194. package/adapters/pi/skills/forge-bootstrap/references/templates/typescript/src/index.ts +4 -0
  195. package/adapters/pi/skills/forge-bootstrap/references/templates/typescript/test/smoke.test.ts +6 -0
  196. package/adapters/pi/skills/forge-bootstrap/references/templates/typescript/tsconfig.json +14 -0
  197. package/adapters/pi/skills/forge-fix/SKILL.md +98 -0
  198. package/adapters/pi/skills/forge-fix/references/shared-conventions.md +295 -0
  199. package/adapters/pi/skills/forge-fix/references/stage-exit-protocol.md +258 -0
  200. package/adapters/pi/skills/forge-guide/SKILL.md +192 -0
  201. package/adapters/pi/skills/forge-guide/references/forge-config-schema.json +236 -0
  202. package/adapters/pi/skills/forge-guide/references/process-overview.md +143 -0
  203. package/adapters/pi/skills/forge-guide/references/ralph-loop-contract.md +221 -0
  204. package/adapters/pi/skills/forge-guide/references/shared-conventions.md +295 -0
  205. package/adapters/pi/skills/forge-guide/references/stack-resolution.md +54 -0
  206. package/adapters/pi/skills/forge-guide/references/stacks/_generic.md +111 -0
  207. package/adapters/pi/skills/forge-guide/references/stacks/go.md +157 -0
  208. package/adapters/pi/skills/forge-guide/references/stacks/python.md +184 -0
  209. package/adapters/pi/skills/forge-guide/references/stacks/rust.md +170 -0
  210. package/adapters/pi/skills/forge-guide/references/stacks/typescript.md +134 -0
  211. package/adapters/pi/skills/forge-init/SKILL.md +72 -0
  212. package/adapters/pi/skills/forge-verify/SKILL.md +273 -0
  213. package/adapters/pi/skills/forge-verify/references/pipeline-state-schema.json +191 -0
  214. package/adapters/pi/skills/forge-verify/references/shared-conventions.md +295 -0
  215. package/adapters/pi/skills/forge-verify/references/verification-checklists.md +477 -0
  216. package/dist/agent-targets.d.ts +1 -1
  217. package/dist/agent-targets.js +23 -3
  218. package/dist/detect.d.ts +1 -1
  219. package/dist/detect.js +2 -1
  220. package/dist/manifest.d.ts +1 -1
  221. package/dist/manifest.js +2 -2
  222. package/dist/placements.js +5 -1
  223. package/dist/rauf.d.ts +4 -4
  224. package/dist/rauf.js +3 -3
  225. package/dist/types.d.ts +31 -6
  226. package/dist/types.js +6 -3
  227. package/package.json +14 -3
@@ -0,0 +1,1888 @@
1
+ #!/usr/bin/env python3
2
+ """Session-aware navigation helpers for the feature-forge pipeline navigator.
3
+
4
+ Read-only subcommands that drive the usability features of the `/forge`
5
+ root navigator:
6
+
7
+ python3 forge-session.py rank-features [--specs-dir DIR] [--json]
8
+ python3 forge-session.py context-usage [--config FILE] [--window N] \
9
+ [--threshold F] [--json]
10
+ python3 forge-session.py doctor [--specs-dir DIR] [--config FILE] [--json]
11
+ python3 forge-session.py discover-feature [NAME | --all] [--specs-dir DIR] [--json]
12
+ python3 forge-session.py reconcile-branch --feature F [--specs-dir DIR] \
13
+ [--config FILE] [--epic E] [--json]
14
+ python3 forge-session.py check-epic-base --feature F [--specs-dir DIR] \
15
+ [--config FILE] [--epic E] [--json]
16
+ python3 forge-session.py stage-exit --feature F --stage S [--specs-dir DIR] \
17
+ [--config FILE] [--epic E] [--next-feature N] [--host claude|generic] [--json]
18
+
19
+ `rank-features` scans the specs tree for feature-shaped directories (those that
20
+ directly contain a `.pipeline-state.json`, in both the flat
21
+ `{specsDir}/{feature}/` and nested `{specsDir}/{epic}/{feature}/` layouts) and
22
+ reports the **active** ones ordered by `updatedAt` descending, so the navigator
23
+ can offer the most-recently-touched feature as the recency default. Each row
24
+ carries the next actionable stage + its slash command, derived from the single
25
+ ordered stage map below.
26
+
27
+ `context-usage` reads the live Claude Code session transcript (the most-recently
28
+ modified `*.jsonl` under `~/.claude/projects/<cwd-slug>/`), sums the last
29
+ assistant message's token usage, and compares it to the context window so the
30
+ navigator can recommend a clean session before the next stage. It is best-effort
31
+ and degrades gracefully: when no transcript or usage is found (a non-Claude host,
32
+ or a fresh session) it reports `{"available": false}` and still exits 0, so the
33
+ caller simply omits the context advice.
34
+
35
+ `doctor` captures pipeline ground truth in one shot for debugging a confused
36
+ session or a broken install: the plugin root the sibling `forge-root.sh`
37
+ actually resolves (plus its version and commit), the current git branch vs.
38
+ each feature's recorded state branch, the recency-ranked feature summary, and
39
+ whether each feature's composed backlog path exists on disk. Every probe is
40
+ best-effort — a failure is reported as data, never as a crash — and the
41
+ command always exits 0 so it can run in any half-broken environment.
42
+
43
+ `discover-feature` looks for a feature's `.pipeline-state.json` across ALL
44
+ git branches (local heads and remote-tracking refs), so a session on the
45
+ default branch can learn that a pipeline exists on a topic branch instead of
46
+ concluding it was never started. When nothing is found locally it also asks
47
+ `git ls-remote --heads origin` about branches a single-branch clone never
48
+ fetched, and emits the exact `git fetch`/`git switch` commands a caller could
49
+ run. It is strictly read-only — it never checks anything out itself — and
50
+ like `doctor` it always exits 0 and degrades to data. Each candidate also
51
+ carries `epic`/`isEpicMember`, so a caller minting a new standalone feature can
52
+ refuse when the name is a known epic member discoverable on another branch
53
+ (the split-brain-epic guard, Issue #125).
54
+
55
+ `check-epic-base` is the defense-in-depth companion: given a feature that
56
+ resolves to a nested epic member on the current branch, it confirms the epic's
57
+ `epic-manifest.json` is actually present on HEAD. When it is absent, the member
58
+ was reached from a branch that predates or lacks the manifest commit (a detached
59
+ base) and the command emits `warn-detached-base` with the member's recorded home
60
+ branch. Read-only; always exits 0.
61
+
62
+ `stage-exit` computes everything an authoring stage's closing used to derive
63
+ in prose (the Scripted Stage Exit, `references/stage-exit-protocol.md`):
64
+ the DIRECTIVES (whether the in-stage auto-verify runs, which verify gate to
65
+ present, autoFix eligibility, the verify and next-stage commands) plus the
66
+ exact sentinel-terminated NEXT-STEPS block the skill must print verbatim as
67
+ its absolute last output. Deterministic and read-only; always exits 0.
68
+
69
+ 3.10 baseline, Google-style docstrings, full type annotations, stdlib only —
70
+ matching the conventions of `scripts/epic-manifest.py`.
71
+
72
+ Exit codes:
73
+ 0 = ok (including an empty feature list or unavailable context usage)
74
+ 2 = usage error or unreadable I/O
75
+ """
76
+
77
+ from __future__ import annotations
78
+
79
+ import argparse
80
+ import json
81
+ import os
82
+ import subprocess
83
+ import sys
84
+ from datetime import datetime, timezone
85
+ from pathlib import Path
86
+ from typing import Final, TypedDict
87
+
88
+
89
+ # --------------------------------------------------------------------------- #
90
+ # Constants
91
+ # --------------------------------------------------------------------------- #
92
+
93
+ #: A directory is "feature-shaped" iff it directly contains this file.
94
+ PIPELINE_STATE_FILENAME: Final = ".pipeline-state.json"
95
+ #: Epic roots hold this (and no .pipeline-state.json) — never a feature.
96
+ MANIFEST_FILENAME: Final = "epic-manifest.json"
97
+
98
+ #: The ordered production stages. This is the ONE place stage order lives.
99
+ PRODUCTION_STAGES: Final[tuple[str, ...]] = (
100
+ "forge-1-prd",
101
+ "forge-2-tech",
102
+ "forge-3-specs",
103
+ "forge-4-backlog",
104
+ "forge-5-loop",
105
+ "forge-6-docs",
106
+ )
107
+
108
+ #: Production stage -> the verify token its findings file uses, and the
109
+ #: `forge-verify-<token>` key its state lives under. forge-6-docs has no verify.
110
+ VERIFY_TOKEN_BY_STAGE: Final[dict[str, str]] = {
111
+ "forge-1-prd": "prd",
112
+ "forge-2-tech": "tech",
113
+ "forge-3-specs": "specs",
114
+ "forge-4-backlog": "backlog",
115
+ "forge-5-loop": "impl",
116
+ }
117
+
118
+ #: A production stage status that counts as "done" for next-stage selection.
119
+ _DONE_STATUS: Final = "complete"
120
+ #: The authoritative forge-verify status vocabulary. SOURCE OF TRUTH:
121
+ #: references/pipeline-state-schema.json (definitions.verifyEntry.properties.status.enum).
122
+ #: A status outside this set is unrecognized and must not be silently interpreted (#148).
123
+ #: NOTE: epic-manifest.py keeps a byte-identical copy — flat, self-contained scripts have
124
+ #: no shared import module (each is copied verbatim into per-agent adapter bundles).
125
+ KNOWN_VERIFY_STATUSES: Final = frozenset(
126
+ {"pending", "passed", "findings-reported", "findings-applied", "skipped"}
127
+ )
128
+ #: Verify statuses that count as "resolved" (no outstanding verify needed). A STRICT
129
+ #: subset of KNOWN_VERIFY_STATUSES — not collapsible into it (different meaning).
130
+ _VERIFY_RESOLVED: Final = frozenset({"passed", "findings-applied", "skipped"})
131
+ #: Per-process dedupe for the unknown-verify-status diagnostic (#148) so a single
132
+ #: bogus status is flagged once, not once per verify_state() call in a command.
133
+ _UNKNOWN_VERIFY_WARNED: set[str] = set()
134
+
135
+ #: Default context window when the model can't be inferred and config is silent.
136
+ _DEFAULT_WINDOW: Final = 200_000
137
+ #: Window for 1M-context models (model id carries a `[1m]` / `-1m` marker).
138
+ _WIDE_WINDOW: Final = 1_000_000
139
+ #: Default fraction of the window past which a clean session is recommended.
140
+ _DEFAULT_THRESHOLD: Final = 0.7
141
+
142
+
143
+ # --------------------------------------------------------------------------- #
144
+ # Types
145
+ # --------------------------------------------------------------------------- #
146
+
147
+
148
+ class FeatureRow(TypedDict):
149
+ """One active feature, ranked by recency, with its next actionable step."""
150
+
151
+ name: str
152
+ epic: str | None
153
+ currentStage: str
154
+ branch: str | None
155
+ updatedAt: str | None
156
+ complete: bool
157
+ nextStage: str | None
158
+ nextCommand: str | None
159
+ verifyPending: bool
160
+ verifyCommand: str | None
161
+ verifyStage: str | None
162
+ verifyState: str
163
+ autoVerify: bool
164
+ autoFix: bool
165
+ verifyGate: str
166
+
167
+
168
+ class UsageError(Exception):
169
+ """A usage or I/O failure that must exit 2."""
170
+
171
+
172
+ # --------------------------------------------------------------------------- #
173
+ # Feature scanning & ranking
174
+ # --------------------------------------------------------------------------- #
175
+
176
+
177
+ def _read_state(state_path: Path) -> dict:
178
+ """Read a `.pipeline-state.json`, tolerating missing/corrupt files.
179
+
180
+ A missing, unreadable, or unparseable state downgrades to ``{}`` rather than
181
+ crashing the scan — the navigator simply treats that feature as not-started.
182
+ """
183
+ try:
184
+ parsed = json.loads(state_path.read_text(encoding="utf-8"))
185
+ except (OSError, json.JSONDecodeError):
186
+ return {}
187
+ return parsed if isinstance(parsed, dict) else {}
188
+
189
+
190
+ def _scan_features(specs_dir: Path) -> list[tuple[str, str | None, dict]]:
191
+ """Find every feature-shaped dir under the specs tree (flat + nested).
192
+
193
+ Descends exactly one level below each top-level dir (never deeper), matching
194
+ ``epic-manifest.py``'s feature-shaped-dir bound.
195
+
196
+ Args:
197
+ specs_dir: The configured specs directory.
198
+
199
+ Returns:
200
+ A list of ``(feature_name, epic_name_or_None, state_dict)`` tuples. The
201
+ epic name is the parent dir name for a nested member, ``None`` for a flat
202
+ feature.
203
+ """
204
+ if not specs_dir.is_dir():
205
+ return []
206
+ out: list[tuple[str, str | None, dict]] = []
207
+ for top in sorted(p for p in specs_dir.iterdir() if p.is_dir()):
208
+ flat_state = top / PIPELINE_STATE_FILENAME
209
+ if flat_state.is_file():
210
+ out.append((top.name, None, _read_state(flat_state)))
211
+ # Descend one level for nested epic members (skip the epic root itself).
212
+ for child in sorted(p for p in top.iterdir() if p.is_dir()):
213
+ nested_state = child / PIPELINE_STATE_FILENAME
214
+ if nested_state.is_file():
215
+ out.append((child.name, top.name, _read_state(nested_state)))
216
+ return out
217
+
218
+
219
+ def _stage_status(state: dict, stage: str) -> str | None:
220
+ """Return the recorded status of a stage, or None if absent."""
221
+ stages = state.get("stages")
222
+ if not isinstance(stages, dict):
223
+ return None
224
+ entry = stages.get(stage)
225
+ if not isinstance(entry, dict):
226
+ return None
227
+ status = entry.get("status")
228
+ return status if isinstance(status, str) else None
229
+
230
+
231
+ def next_stage(state: dict) -> str | None:
232
+ """Return the first production stage that is not yet complete (the next step).
233
+
234
+ Walks ``PRODUCTION_STAGES`` in order and returns the first whose recorded
235
+ status is not ``complete`` (a missing/pending/in-progress/stale stage all
236
+ count as "not done"). Returns ``None`` when every production stage is
237
+ complete (nothing left to run).
238
+
239
+ This is the derived "what runs next" value — the single source of truth for
240
+ the next stage. It is intentionally distinct from the stored
241
+ ``currentStage`` field ("where the pipeline IS"; see the schema): the next
242
+ stage is computed from ``stages[].status`` here, never read from
243
+ ``currentStage``.
244
+ """
245
+ for stage in PRODUCTION_STAGES:
246
+ if _stage_status(state, stage) != _DONE_STATUS:
247
+ return stage
248
+ return None
249
+
250
+
251
+ def _stage_version(state: dict, stage: str) -> int | None:
252
+ """Return the recorded ``version`` of a stage entry, or None if absent."""
253
+ stages = state.get("stages")
254
+ if not isinstance(stages, dict):
255
+ return None
256
+ entry = stages.get(stage)
257
+ if not isinstance(entry, dict):
258
+ return None
259
+ version = entry.get("version")
260
+ return version if isinstance(version, int) else None
261
+
262
+
263
+ def _verify_entry(state: dict, verify_key: str) -> dict:
264
+ """Return the ``forge-verify-*`` entry dict, or ``{}`` if absent."""
265
+ stages = state.get("stages")
266
+ if not isinstance(stages, dict):
267
+ return {}
268
+ entry = stages.get(verify_key)
269
+ return entry if isinstance(entry, dict) else {}
270
+
271
+
272
+ def _warn_unknown_verify_status(stage_name: str, status: object) -> None:
273
+ """Emit a one-time stderr diagnostic for an out-of-vocabulary verify status (#148).
274
+
275
+ The freshness classifier maps an unrecognized status to "never verified" — correct,
276
+ but silent, so a typo poisons the downstream gate (e.g. forge-5-loop's dependency
277
+ check) with no clue. Flagging it here makes the bad value visible where it is read.
278
+ """
279
+ key = f"{stage_name}={status!r}"
280
+ if key in _UNKNOWN_VERIFY_WARNED:
281
+ return
282
+ _UNKNOWN_VERIFY_WARNED.add(key)
283
+ known = ", ".join(sorted(KNOWN_VERIFY_STATUSES))
284
+ print(
285
+ f"feature-forge: unknown {stage_name} status {status!r} "
286
+ f"(treated as unverified; expected one of {known})",
287
+ file=sys.stderr,
288
+ )
289
+
290
+
291
+ def verify_state(state: dict) -> tuple[str | None, str]:
292
+ """Classify verify freshness for the most-recently-completed stage.
293
+
294
+ Returns ``(stage, state_label)`` where ``state_label`` is one of:
295
+
296
+ - ``fresh`` — verify is resolved AND its ``verifiedStageVersion`` matches the
297
+ stage's current ``version`` (so no re-verify is needed).
298
+ - ``stale`` — verify was resolved once, but the stage version has since moved
299
+ (artifact revised) OR the entry predates the freshness ledger (no
300
+ ``verifiedStageVersion``). A revised artifact must be re-verified.
301
+ - ``failing`` — verify ran and reported findings that are not yet applied
302
+ (``findings-reported``).
303
+ - ``never`` — the stage completed but verify has not run at all.
304
+ - ``skipped`` — the user explicitly chose to proceed without verifying. A
305
+ resolved, non-pending state: it is deliberately NOT re-offered or
306
+ auto-verified, and (unlike a genuine verification result) it does not go
307
+ stale on an artifact revision — skip writers record no version to compare
308
+ against, and re-surfacing would override an explicit human decision.
309
+ - ``none`` — no completed verify-capable stage (nothing to verify), stage
310
+ is ``None``.
311
+
312
+ Only the most-recent completed production stage is considered, matching the
313
+ navigator's "verify before continuing" gate. Absent ``verifiedStageVersion``
314
+ on a ``passed``/``findings-applied`` entry (legacy state) is deliberately
315
+ treated as ``stale`` — verify rather than skip.
316
+ """
317
+ for stage in reversed(PRODUCTION_STAGES):
318
+ if _stage_status(state, stage) != _DONE_STATUS:
319
+ continue
320
+ token = VERIFY_TOKEN_BY_STAGE.get(stage)
321
+ if token is None:
322
+ continue # forge-6-docs has no verify step
323
+ entry = _verify_entry(state, f"forge-verify-{token}")
324
+ status = entry.get("status")
325
+ if status == "skipped":
326
+ # An explicit skip is resolved and non-pending — preserve the user's
327
+ # decision. It never goes stale (no recorded version to compare), so
328
+ # the freshness check below deliberately does not apply.
329
+ return stage, "skipped"
330
+ if status not in _VERIFY_RESOLVED:
331
+ if status == "findings-reported":
332
+ return stage, "failing"
333
+ # An unrecognized status (outside KNOWN_VERIFY_STATUSES) is treated as
334
+ # "never verified" — defensible, but flag it once so a typo (e.g. the
335
+ # eye-slip 'findings-resolved') doesn't silently poison the gate that
336
+ # reads this label (#148). ``pending``/``None`` are known/absent → quiet.
337
+ if status is not None and status not in KNOWN_VERIFY_STATUSES:
338
+ _warn_unknown_verify_status(f"forge-verify-{token}", status)
339
+ return stage, "never"
340
+ verified_version = entry.get("verifiedStageVersion")
341
+ stage_version = _stage_version(state, stage)
342
+ if (
343
+ isinstance(verified_version, int)
344
+ and stage_version is not None
345
+ and verified_version == stage_version
346
+ ):
347
+ return stage, "fresh"
348
+ return stage, "stale"
349
+ return None, "none"
350
+
351
+
352
+ def pending_verify(state: dict) -> str | None:
353
+ """Return the production stage whose verify is outstanding, if any.
354
+
355
+ Outstanding means the most-recently-completed production stage's verify is not
356
+ ``fresh`` (never run, reported findings, or gone stale after an artifact
357
+ revision). An explicit ``skipped`` is treated as resolved (never outstanding).
358
+ Surfaced so the navigator can offer "verify before continuing" as an
359
+ alternative to advancing. Returns ``None`` when the latest stage is fresh,
360
+ skipped, or there is nothing to verify.
361
+ """
362
+ stage, label = verify_state(state)
363
+ return stage if label not in ("fresh", "none", "skipped") else None
364
+
365
+
366
+ def _parse_ts(value: str | None) -> datetime | None:
367
+ """Parse an ISO-8601 timestamp (tolerating a trailing 'Z'), else None."""
368
+ if not isinstance(value, str):
369
+ return None
370
+ try:
371
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
372
+ except ValueError:
373
+ return None
374
+ if dt.tzinfo is None:
375
+ dt = dt.replace(tzinfo=timezone.utc)
376
+ return dt
377
+
378
+
379
+ def build_rows(specs_dir: Path, config: dict | None = None) -> list[FeatureRow]:
380
+ """Build the recency-ranked active-feature rows (the rank-features payload).
381
+
382
+ Active features (``pipelineStatus == "active"``, the default when absent) are
383
+ sorted by ``updatedAt`` descending — most recently touched first — so the
384
+ navigator's recency default is row 0.
385
+
386
+ ``config`` is the loaded forge.config.json (or ``{}``); it drives the effective
387
+ ``autoVerify``/``autoFix`` per stage so the navigator can branch without
388
+ re-reading config.
389
+ """
390
+ config = config or {}
391
+ # Fail closed: only a literal JSON ``true`` enables artifact-mutating autoFix.
392
+ global_auto_fix = config.get("autoFix") is True
393
+ rows: list[FeatureRow] = []
394
+ for name, epic, state in _scan_features(specs_dir):
395
+ status = state.get("pipelineStatus", "active")
396
+ if status != "active":
397
+ continue
398
+ nxt = next_stage(state)
399
+ vstage, vlabel = verify_state(state)
400
+ verify_pending = vstage is not None and vlabel not in ("fresh", "none", "skipped")
401
+ effective_auto_verify = auto_verify_for(config, vstage) if vstage else False
402
+ branch = state.get("branch")
403
+ updated = state.get("updatedAt")
404
+ rows.append({
405
+ "name": name,
406
+ "epic": epic,
407
+ # currentStage = "where the pipeline IS" (the recorded field). When a
408
+ # legacy/absent state omits it, fall back to the DERIVED next stage
409
+ # for display only — never conflate the two elsewhere (schema O1).
410
+ "currentStage": state.get("currentStage") or (nxt or "complete"),
411
+ "branch": branch if isinstance(branch, str) else None,
412
+ "updatedAt": updated if isinstance(updated, str) else None,
413
+ "complete": nxt is None,
414
+ "nextStage": nxt,
415
+ "nextCommand": f"/skill:{nxt} {name}" if nxt else None,
416
+ "verifyPending": verify_pending,
417
+ "verifyCommand": f"/skill:forge-verify {name}" if verify_pending else None,
418
+ "verifyStage": vstage,
419
+ "verifyState": vlabel,
420
+ "autoVerify": effective_auto_verify,
421
+ "autoFix": global_auto_fix and effective_auto_verify,
422
+ # Single resolved verify-gate classification (5b — one exit computation,
423
+ # mirroring stage-exit's `verifyGate`): the navigator reads this instead of
424
+ # re-deriving from verifyPending + autoVerify in prose. `auto` = the §2b
425
+ # catch-up runs it unattended; `standard` = the §3 gate (degrades to
426
+ # manual-print on a non-Claude host); `none` = nothing outstanding.
427
+ "verifyGate": (
428
+ "none" if not verify_pending
429
+ else "auto" if effective_auto_verify
430
+ else "standard"
431
+ ),
432
+ })
433
+ # Sort by updatedAt desc; rows without a parseable timestamp sort last.
434
+ rows.sort(
435
+ key=lambda r: (_parse_ts(r["updatedAt"]) or datetime.min.replace(tzinfo=timezone.utc)),
436
+ reverse=True,
437
+ )
438
+ return rows
439
+
440
+
441
+ def _counts(specs_dir: Path) -> dict[str, int]:
442
+ """Tally active/paused/abandoned pipelines across the specs tree."""
443
+ tally = {"active": 0, "paused": 0, "abandoned": 0}
444
+ for _name, _epic, state in _scan_features(specs_dir):
445
+ status = state.get("pipelineStatus", "active")
446
+ if status in tally:
447
+ tally[status] += 1
448
+ return tally
449
+
450
+
451
+ # --------------------------------------------------------------------------- #
452
+ # Context-window usage
453
+ # --------------------------------------------------------------------------- #
454
+
455
+
456
+ def _cwd_slug(cwd: Path) -> str:
457
+ """Map a working directory to its Claude Code project-dir slug.
458
+
459
+ Claude Code names the per-project transcript dir by replacing path
460
+ separators (and dots) in the absolute cwd with hyphens, e.g.
461
+ ``/home/u/proj`` -> ``-home-u-proj``.
462
+ """
463
+ return str(cwd.resolve()).replace("/", "-").replace(".", "-")
464
+
465
+
466
+ def _latest_transcript(cwd: Path) -> Path | None:
467
+ """Return the most-recently-modified transcript JSONL for this cwd, if any."""
468
+ project_dir = Path.home() / ".claude" / "projects" / _cwd_slug(cwd)
469
+ if not project_dir.is_dir():
470
+ return None
471
+ transcripts = [p for p in project_dir.glob("*.jsonl") if p.is_file()]
472
+ if not transcripts:
473
+ return None
474
+ return max(transcripts, key=lambda p: p.stat().st_mtime)
475
+
476
+
477
+ def _last_usage(transcript: Path) -> tuple[int, str | None] | None:
478
+ """Scan a transcript from the end for the last `usage` record.
479
+
480
+ Returns ``(token_total, model_id)`` where the total sums
481
+ ``input_tokens + cache_creation_input_tokens + cache_read_input_tokens +
482
+ output_tokens`` of the most recent message carrying a usage object — i.e. the
483
+ current context occupancy. Returns ``None`` if no usable record is found.
484
+ """
485
+ try:
486
+ lines = transcript.read_text(encoding="utf-8").splitlines()
487
+ except OSError:
488
+ return None
489
+ for line in reversed(lines):
490
+ line = line.strip()
491
+ if not line or '"usage"' not in line:
492
+ continue
493
+ try:
494
+ record = json.loads(line)
495
+ except json.JSONDecodeError:
496
+ continue
497
+ message = record.get("message")
498
+ usage = message.get("usage") if isinstance(message, dict) else record.get("usage")
499
+ if not isinstance(usage, dict):
500
+ continue
501
+ # A malformed transcript may carry a non-numeric usage field; skip that
502
+ # record rather than crash the whole context-usage read (ValueError/TypeError).
503
+ try:
504
+ total = (
505
+ int(usage.get("input_tokens", 0) or 0)
506
+ + int(usage.get("cache_creation_input_tokens", 0) or 0)
507
+ + int(usage.get("cache_read_input_tokens", 0) or 0)
508
+ + int(usage.get("output_tokens", 0) or 0)
509
+ )
510
+ except (TypeError, ValueError):
511
+ continue
512
+ if total <= 0:
513
+ continue
514
+ model = message.get("model") if isinstance(message, dict) else record.get("model")
515
+ return total, (model if isinstance(model, str) else None)
516
+ return None
517
+
518
+
519
+ def _infer_window(model: str | None) -> int:
520
+ """Infer the context window from a model id (1M-context markers -> wide)."""
521
+ if model and ("[1m]" in model.lower() or "-1m" in model.lower()):
522
+ return _WIDE_WINDOW
523
+ return _DEFAULT_WINDOW
524
+
525
+
526
+ def _load_config(config_path: Path) -> dict:
527
+ """Read forge.config.json into a dict, tolerating missing/corrupt files.
528
+
529
+ A missing, unreadable, or non-object config downgrades to ``{}`` so callers
530
+ read every key through absent-safe ``.get`` defaults.
531
+ """
532
+ try:
533
+ config = json.loads(config_path.read_text(encoding="utf-8"))
534
+ except (OSError, json.JSONDecodeError):
535
+ return {}
536
+ return config if isinstance(config, dict) else {}
537
+
538
+
539
+ def _config_value(config_path: Path, key: str):
540
+ """Read a single key from forge.config.json, or None if absent/unreadable."""
541
+ return _load_config(config_path).get(key)
542
+
543
+
544
+ def auto_verify_for(config: dict, stage: str) -> bool:
545
+ """Return the effective auto-verify setting for ``stage``.
546
+
547
+ Per-stage override in ``autoVerifyStages`` wins over the global ``autoVerify``;
548
+ both default to off, so a config with neither key means "no auto-verify".
549
+
550
+ Parsing is strict and **fails closed**: only a literal JSON ``true`` enables
551
+ auto-verify. A non-boolean value (e.g. the string ``"false"``, which is truthy
552
+ in Python) is treated as off, not on. The schema already rejects non-booleans
553
+ at author time; this guards a hand-edited config from silently enabling
554
+ automation.
555
+ """
556
+ stages = config.get("autoVerifyStages")
557
+ if isinstance(stages, dict) and stage in stages:
558
+ return stages[stage] is True
559
+ return config.get("autoVerify") is True
560
+
561
+
562
+ def invalid_auto_verify_keys(config: dict) -> list[str]:
563
+ """Return ``autoVerifyStages`` keys outside the verify-capable stage ids.
564
+
565
+ An unknown/typo key (e.g. ``forge-1-prod``) would silently never take effect,
566
+ turning an intended off-switch into a no-op. Surfacing it lets the navigator
567
+ warn instead of failing quietly. Mirrors the schema's ``propertyNames.enum``.
568
+ """
569
+ stages = config.get("autoVerifyStages")
570
+ if not isinstance(stages, dict):
571
+ return []
572
+ return [key for key in stages if key not in VERIFY_TOKEN_BY_STAGE]
573
+
574
+
575
+ def context_usage(
576
+ config_path: Path,
577
+ window_override: int | None,
578
+ threshold_override: float | None,
579
+ ) -> dict:
580
+ """Compute live context-window occupancy for the current session.
581
+
582
+ Window precedence: ``--window`` > config ``contextWindowTokens`` > inferred
583
+ from the transcript's model id > ``_DEFAULT_WINDOW``. When inferring (no
584
+ override, no config) and the observed token total already exceeds the default
585
+ window, the window is auto-bumped to ``_WIDE_WINDOW`` — observed tokens above
586
+ 200k prove a wider (1M-beta) window is active, so this corrects the reading
587
+ without ever under-reporting a genuine 200k session. Threshold precedence:
588
+ ``--threshold`` > config ``contextWarnThreshold`` > ``_DEFAULT_THRESHOLD``.
589
+
590
+ Returns a dict with ``available: True`` and ``{tokens, windowTokens, pct,
591
+ overThreshold, recommendation, model}`` when usage is found, or
592
+ ``{available: False, reason}`` otherwise. Never raises for a missing
593
+ transcript — that is the expected non-Claude / fresh-session path.
594
+ """
595
+ threshold = threshold_override
596
+ if threshold is None:
597
+ cfg_threshold = _config_value(config_path, "contextWarnThreshold")
598
+ threshold = (
599
+ float(cfg_threshold)
600
+ if isinstance(cfg_threshold, (int, float))
601
+ else _DEFAULT_THRESHOLD
602
+ )
603
+
604
+ transcript = _latest_transcript(Path.cwd())
605
+ if transcript is None:
606
+ return {"available": False, "reason": "no session transcript found"}
607
+ found = _last_usage(transcript)
608
+ if found is None:
609
+ return {"available": False, "reason": "no usage record in transcript"}
610
+ tokens, model = found
611
+
612
+ window = window_override
613
+ if window is None or window <= 0:
614
+ cfg_window = _config_value(config_path, "contextWindowTokens")
615
+ if isinstance(cfg_window, int) and cfg_window > 0:
616
+ window = cfg_window
617
+ else:
618
+ # Inferring (no override, no config). Start from the model marker /
619
+ # conservative default, then auto-bump: observed tokens above the
620
+ # default window PROVE a wider window is active (a 200k session can
621
+ # never exceed 200k), so widen to 1M rather than report a nonsensical
622
+ # >100%. Never under-reports a real 200k session, which can't trip it.
623
+ window = _infer_window(model)
624
+ if tokens > window:
625
+ window = _WIDE_WINDOW
626
+
627
+ pct = round(tokens / window, 4)
628
+ over = pct >= threshold
629
+ if over:
630
+ recommendation = "clean-session"
631
+ else:
632
+ recommendation = "continue"
633
+ return {
634
+ "available": True,
635
+ "tokens": tokens,
636
+ "windowTokens": window,
637
+ "pct": pct,
638
+ "threshold": threshold,
639
+ "overThreshold": over,
640
+ "recommendation": recommendation,
641
+ "model": model,
642
+ }
643
+
644
+
645
+ # --------------------------------------------------------------------------- #
646
+ # Doctor
647
+ # --------------------------------------------------------------------------- #
648
+
649
+
650
+ def _git_output(args: list[str]) -> str | None:
651
+ """Run a read-only git command and return stripped stdout, or None.
652
+
653
+ Any failure (git missing, not a repo, nonzero exit, timeout) degrades to
654
+ ``None`` — doctor reports absence rather than crashing.
655
+ """
656
+ try:
657
+ proc = subprocess.run(
658
+ ["git", *args], capture_output=True, text=True, timeout=10,
659
+ )
660
+ except (OSError, subprocess.TimeoutExpired):
661
+ return None
662
+ if proc.returncode != 0:
663
+ return None
664
+ out = proc.stdout.strip()
665
+ return out or None
666
+
667
+
668
+ def _resolve_plugin_root() -> dict:
669
+ """Resolve the plugin root by running the sibling ``forge-root.sh``.
670
+
671
+ Uses the resolver that ships next to this script, so the answer reflects
672
+ the install this helper actually belongs to — exactly what a skill's
673
+ bootstrap prelude would find (or fail to find). On success the dict also
674
+ carries the root's ``version`` (from ``.claude-plugin/plugin.json`` or the
675
+ neutral ``.feature-forge-bundle.json``) and, when the root is a git
676
+ checkout, its short ``commit`` — enough to spot version skew between the
677
+ resolved root and the skills a session loaded.
678
+ """
679
+ resolver = Path(__file__).resolve().parent / "forge-root.sh"
680
+ if not resolver.is_file():
681
+ return {"resolved": False, "error": f"resolver not found: {resolver}"}
682
+ try:
683
+ proc = subprocess.run(
684
+ ["bash", str(resolver)], capture_output=True, text=True, timeout=10,
685
+ )
686
+ except (OSError, subprocess.TimeoutExpired) as exc:
687
+ return {"resolved": False, "error": str(exc)}
688
+ if proc.returncode != 0:
689
+ return {
690
+ "resolved": False,
691
+ "error": proc.stderr.strip() or f"resolver exited {proc.returncode}",
692
+ }
693
+ root = proc.stdout.strip()
694
+ info: dict = {"resolved": True, "root": root}
695
+ for rel in (".claude-plugin/plugin.json", ".feature-forge-bundle.json"):
696
+ manifest = Path(root) / rel
697
+ if manifest.is_file():
698
+ version = _load_config(manifest).get("version")
699
+ if isinstance(version, str):
700
+ info["version"] = version
701
+ info["manifest"] = rel
702
+ break
703
+ commit = _git_output(["-C", root, "rev-parse", "--short", "HEAD"])
704
+ if commit:
705
+ info["commit"] = commit
706
+ return info
707
+
708
+
709
+ def _backlog_path(config: dict, name: str, epic: str | None, specs_dir: Path) -> Path:
710
+ """Compose a feature's backlog.json path per the forge-4-backlog rule.
711
+
712
+ ``{backlogDir}/{feature}/backlog.json`` when ``backlogDir`` is configured,
713
+ else ``{resolvedFeatureDir}/backlog.json`` (flat or nested under the epic).
714
+ """
715
+ backlog_dir = config.get("backlogDir")
716
+ if isinstance(backlog_dir, str) and backlog_dir:
717
+ return Path(backlog_dir) / name / "backlog.json"
718
+ feature_dir = specs_dir / epic / name if epic else specs_dir / name
719
+ return feature_dir / "backlog.json"
720
+
721
+
722
+ def doctor_report(specs_dir: Path, config_path: Path) -> dict:
723
+ """Assemble the ground-truth diagnostic payload (always succeeds).
724
+
725
+ One snapshot of everything a confused session needs checked: resolved
726
+ plugin root + version/commit, current git branch vs. each feature's
727
+ recorded state branch, the recency-ranked feature summary, and whether
728
+ each feature's composed backlog path exists on disk.
729
+ """
730
+ config = _load_config(config_path)
731
+ # --show-current (not rev-parse HEAD) so an unborn branch (fresh repo,
732
+ # no commits yet) still reports its name instead of failing.
733
+ current_branch = _git_output(["branch", "--show-current"])
734
+ default_branch = _default_branch()
735
+ rows = build_rows(specs_dir, config)
736
+ features = []
737
+ for row in rows:
738
+ backlog = _backlog_path(config, row["name"], row["epic"], specs_dir)
739
+ state_branch = row["branch"]
740
+ mismatch = bool(state_branch and current_branch and state_branch != current_branch)
741
+ # Classify a mismatch: on a topic branch it is adoptable (imposed/session-branch
742
+ # drift, Chunk 6); on the default branch it is real drift-back, only a warning.
743
+ branch_reconcile = None
744
+ if mismatch:
745
+ branch_reconcile = "warn-drift" if current_branch == default_branch else "adopt-current"
746
+ features.append({
747
+ "name": row["name"],
748
+ "epic": row["epic"],
749
+ "currentStage": row["currentStage"],
750
+ "nextStage": row["nextStage"],
751
+ "verifyState": row["verifyState"],
752
+ "stateBranch": state_branch,
753
+ "branchMatchesState": (
754
+ state_branch == current_branch
755
+ if state_branch and current_branch
756
+ else None
757
+ ),
758
+ "branchReconcile": branch_reconcile,
759
+ "backlogPath": str(backlog),
760
+ "backlogExists": backlog.is_file(),
761
+ })
762
+ return {
763
+ "pluginRoot": _resolve_plugin_root(),
764
+ "currentBranch": current_branch,
765
+ "specsDir": str(specs_dir),
766
+ "specsDirExists": specs_dir.is_dir(),
767
+ "configPath": str(config_path),
768
+ "configExists": config_path.is_file(),
769
+ "counts": _counts(specs_dir),
770
+ "features": features,
771
+ "invalidAutoVerifyKeys": invalid_auto_verify_keys(config),
772
+ "rootSandbox": _root_sandbox_status(),
773
+ }
774
+
775
+
776
+ def _root_sandbox_status() -> dict:
777
+ """Report the root/sandbox launch condition for forge-5-loop (issue #99).
778
+
779
+ On a hosted remote (e.g. Claude.ai) the loop runs as root, where rauf's
780
+ ``claude --dangerously-skip-permissions`` is refused unless ``IS_SANDBOX``
781
+ is set. forge-5-loop exports ``IS_SANDBOX=${IS_SANDBOX:-1}`` at launch when
782
+ root; this surfaces the same condition as a diagnosable check. ``geteuid``
783
+ is absent on Windows — treat that as non-root.
784
+ """
785
+ geteuid = getattr(os, "geteuid", None)
786
+ is_root = geteuid() == 0 if geteuid is not None else False
787
+ is_sandbox_set = os.environ.get("IS_SANDBOX") not in (None, "")
788
+ return {
789
+ "isRoot": is_root,
790
+ "isSandboxSet": is_sandbox_set,
791
+ # True only when the loop would need to supply the default at launch.
792
+ "loopWillSetSandbox": is_root and not is_sandbox_set,
793
+ }
794
+
795
+
796
+ def _print_doctor(report: dict) -> None:
797
+ """Print the human-readable doctor report."""
798
+ root = report["pluginRoot"]
799
+ if root.get("resolved"):
800
+ detail = " ".join(
801
+ f"{key}={root[key]}" for key in ("version", "commit") if key in root
802
+ )
803
+ print(f"plugin root: {root['root']}" + (f" ({detail})" if detail else ""))
804
+ else:
805
+ print(f"plugin root: UNRESOLVED — {root.get('error', 'unknown')}")
806
+ print(f"current branch: {report['currentBranch'] or '(not a git repo)'}")
807
+ print(
808
+ f"specs dir: {report['specsDir']}"
809
+ + ("" if report["specsDirExists"] else " (MISSING)")
810
+ )
811
+ print(
812
+ f"config: {report['configPath']}"
813
+ + ("" if report["configExists"] else " (MISSING)")
814
+ )
815
+ counts = report["counts"]
816
+ print(
817
+ f"features: {counts['active']} active "
818
+ f"(paused: {counts['paused']}, abandoned: {counts['abandoned']})"
819
+ )
820
+ for feat in report["features"]:
821
+ label = feat["name"] + (f" [{feat['epic']}]" if feat["epic"] else "")
822
+ branch = feat["stateBranch"] or "?"
823
+ if feat["branchMatchesState"] is False:
824
+ if feat.get("branchReconcile") == "adopt-current":
825
+ branch += " (MISMATCH — reconcile: adopt current branch)"
826
+ elif feat.get("branchReconcile") == "warn-drift":
827
+ branch += " (MISMATCH — on default branch; create a topic branch)"
828
+ else:
829
+ branch += " (MISMATCH vs current)"
830
+ backlog = "exists" if feat["backlogExists"] else "MISSING"
831
+ print(
832
+ f" - {label}: stage={feat['currentStage']} "
833
+ f"verify={feat['verifyState']} branch={branch} "
834
+ f"backlog={backlog} ({feat['backlogPath']})"
835
+ )
836
+ invalid = report.get("invalidAutoVerifyKeys") or []
837
+ if invalid:
838
+ print(" ! invalid autoVerifyStages keys (ignored): " + ", ".join(invalid))
839
+ rs = report.get("rootSandbox") or {}
840
+ if rs.get("isRoot"):
841
+ if rs.get("isSandboxSet"):
842
+ print("root/sandbox: running as root; IS_SANDBOX already set — loop launch OK")
843
+ else:
844
+ print(
845
+ "root/sandbox: running as root; IS_SANDBOX not set — forge-5-loop will "
846
+ "export IS_SANDBOX=1 at launch so rauf's "
847
+ "--dangerously-skip-permissions is not refused"
848
+ )
849
+
850
+
851
+ # --------------------------------------------------------------------------- #
852
+ # Cross-branch feature discovery
853
+ # --------------------------------------------------------------------------- #
854
+
855
+
856
+ def _specs_rel(specs_dir: str) -> str:
857
+ """Normalize a specs dir to the repo-relative POSIX form git ls-tree uses."""
858
+ rel = specs_dir.replace("\\", "/")
859
+ while rel.startswith("./"):
860
+ rel = rel[2:]
861
+ return rel.rstrip("/")
862
+
863
+
864
+ def _state_paths_in_ref(ref: str, specs_rel: str, name: str) -> list[str]:
865
+ """Feature-shaped ``.pipeline-state.json`` paths for ``name`` in one ref.
866
+
867
+ Mirrors the ``_scan_features`` flat/nested bound: exactly
868
+ ``{specsDir}/{name}/.pipeline-state.json`` or
869
+ ``{specsDir}/{epic}/{name}/.pipeline-state.json`` — never deeper.
870
+ """
871
+ listing = _git_output(["ls-tree", "-r", "--name-only", ref, "--", specs_rel])
872
+ if not listing:
873
+ return []
874
+ hits: list[str] = []
875
+ prefix = specs_rel + "/"
876
+ for path in listing.splitlines():
877
+ if not path.startswith(prefix) or not path.endswith("/" + PIPELINE_STATE_FILENAME):
878
+ continue
879
+ segments = path[len(prefix):].split("/")
880
+ # [name, state-file] (flat) or [epic, name, state-file] (nested).
881
+ if len(segments) == 2 and segments[0] == name:
882
+ hits.append(path)
883
+ elif len(segments) == 3 and segments[1] == name:
884
+ hits.append(path)
885
+ return hits
886
+
887
+
888
+ def _read_state_at_ref(ref: str, path: str) -> dict:
889
+ """Parse ``git show ref:path`` as pipeline state, downgrading failures to {}."""
890
+ raw = _git_output(["show", f"{ref}:{path}"])
891
+ if raw is None:
892
+ return {}
893
+ try:
894
+ parsed = json.loads(raw)
895
+ except json.JSONDecodeError:
896
+ return {}
897
+ return parsed if isinstance(parsed, dict) else {}
898
+
899
+
900
+ def _epic_membership(path: str, specs_rel: str, state: dict) -> tuple[str | None, bool]:
901
+ """Derive ``(epic, isEpicMember)`` for a discovered candidate.
902
+
903
+ A candidate is an epic member when its state carries an ``epic`` back-pointer
904
+ **or** its path is nested (``{specsDir}/{epic}/{name}/.pipeline-state.json``).
905
+ Nested-ness is structurally authoritative; the ``epic`` field is the recorded
906
+ back-pointer. When the state lacks the field, the nested directory name is used
907
+ so the signal is never "member of epic None".
908
+ """
909
+ prefix = specs_rel + "/"
910
+ nested_epic: str | None = None
911
+ if path.startswith(prefix):
912
+ segments = path[len(prefix):].split("/")
913
+ if len(segments) == 3: # [epic, name, state-file]
914
+ nested_epic = segments[0]
915
+ epic = state.get("epic")
916
+ epic = epic if isinstance(epic, str) and epic else nested_epic
917
+ return epic, bool(nested_epic) or bool(epic)
918
+
919
+
920
+ def _list_refs(pattern: str) -> list[tuple[str, str]]:
921
+ """Return ``(short_ref, committer_date)`` pairs under a ref namespace."""
922
+ raw = _git_output([
923
+ "for-each-ref",
924
+ "--format=%(refname:short)\t%(committerdate:iso-strict)",
925
+ pattern,
926
+ ])
927
+ if not raw:
928
+ return []
929
+ out: list[tuple[str, str]] = []
930
+ for line in raw.splitlines():
931
+ ref, _, date = line.partition("\t")
932
+ if ref:
933
+ out.append((ref, date))
934
+ return out
935
+
936
+
937
+ def discover_feature(name: str, specs_dir: str) -> dict:
938
+ """Find a feature's pipeline state across all branches (strictly read-only).
939
+
940
+ Scans every local head and remote-tracking ref for a feature-shaped
941
+ ``.pipeline-state.json``, parses each hit via ``git show``, and ranks
942
+ candidates by (state's own ``branch`` field matches the ref) first, then
943
+ local-before-remote-tracking, then newest commit. When no candidate exists
944
+ locally, ``git ls-remote --heads origin`` surfaces plausibly-named
945
+ branches a single-branch clone never fetched, as ``needsFetch`` entries
946
+ with the exact fetch/switch commands.
947
+
948
+ Never mutates anything: checkout is the caller's decision (and requires
949
+ the user's explicit accept plus a clean tree — see shared-conventions).
950
+ """
951
+ if _git_output(["rev-parse", "--git-dir"]) is None:
952
+ return {
953
+ "feature": name,
954
+ "gitRepo": False,
955
+ "currentBranch": None,
956
+ "candidates": [],
957
+ "remoteCandidates": [],
958
+ }
959
+ current_branch = _git_output(["branch", "--show-current"])
960
+ specs_rel = _specs_rel(specs_dir)
961
+
962
+ refs = [(ref, date, False) for ref, date in _list_refs("refs/heads")]
963
+ refs += [(ref, date, True) for ref, date in _list_refs("refs/remotes")]
964
+
965
+ candidates: list[dict] = []
966
+ matched_branches: set[str] = set()
967
+ known_branches: set[str] = set()
968
+ for ref, commit_date, is_remote in refs:
969
+ branch = ref.split("/", 1)[1] if is_remote else ref
970
+ if is_remote and (not branch or branch == "HEAD"):
971
+ continue
972
+ known_branches.add(branch)
973
+ if branch in matched_branches:
974
+ continue # the local head already yielded this branch's state
975
+ for path in _state_paths_in_ref(ref, specs_rel, name):
976
+ state = _read_state_at_ref(ref, path)
977
+ state_branch = state.get("branch")
978
+ state_branch = state_branch if isinstance(state_branch, str) else None
979
+ updated = state.get("updatedAt")
980
+ epic, is_epic_member = _epic_membership(path, specs_rel, state)
981
+ matched_branches.add(branch)
982
+ candidates.append({
983
+ "branch": branch,
984
+ "ref": ref,
985
+ "remoteTracking": is_remote,
986
+ "path": path,
987
+ "stateBranch": state_branch,
988
+ "stateBranchMatches": state_branch == branch,
989
+ "currentStage": state.get("currentStage"),
990
+ "pipelineStatus": state.get("pipelineStatus", "active"),
991
+ "epic": epic,
992
+ "isEpicMember": is_epic_member,
993
+ "updatedAt": updated if isinstance(updated, str) else None,
994
+ "commitDate": commit_date or None,
995
+ "isCurrentBranch": branch == current_branch,
996
+ "switchCommand": f"git switch {branch}",
997
+ })
998
+
999
+ def _rank(cand: dict) -> tuple:
1000
+ ts = _parse_ts(cand["commitDate"]) or datetime.min.replace(tzinfo=timezone.utc)
1001
+ return (
1002
+ not cand["stateBranchMatches"],
1003
+ cand["remoteTracking"],
1004
+ -ts.timestamp(),
1005
+ )
1006
+
1007
+ candidates.sort(key=_rank)
1008
+
1009
+ # Single-branch clones: the branch holding the state may never have been
1010
+ # fetched. Only when nothing was found locally, ask the remote for heads we
1011
+ # do not know and surface the plausibly-named ones (the feature name appears
1012
+ # in the branch name — e.g. forge/<feature>). These are name-based hints
1013
+ # only; their contents were NOT inspected.
1014
+ remote_candidates: list[dict] = []
1015
+ if not candidates:
1016
+ ls_remote = _git_output(["ls-remote", "--heads", "origin"])
1017
+ for line in (ls_remote or "").splitlines():
1018
+ _, _, refname = line.partition("\t")
1019
+ if not refname.startswith("refs/heads/"):
1020
+ continue
1021
+ branch = refname[len("refs/heads/"):]
1022
+ if branch in known_branches or name not in branch:
1023
+ continue
1024
+ remote_candidates.append({
1025
+ "branch": branch,
1026
+ "needsFetch": True,
1027
+ "fetchCommand": f"git fetch origin {branch}:refs/remotes/origin/{branch}",
1028
+ "switchCommand": f"git switch {branch}",
1029
+ })
1030
+
1031
+ return {
1032
+ "feature": name,
1033
+ "gitRepo": True,
1034
+ "currentBranch": current_branch,
1035
+ "specsDir": specs_rel,
1036
+ "candidates": candidates,
1037
+ "remoteCandidates": remote_candidates,
1038
+ }
1039
+
1040
+
1041
+ def _print_discover(payload: dict) -> None:
1042
+ """Print the human-readable discovery report."""
1043
+ name = payload["feature"]
1044
+ if not payload["gitRepo"]:
1045
+ print(f"discover-feature {name}: not a git repository — nothing to scan")
1046
+ return
1047
+ candidates = payload["candidates"]
1048
+ remote = payload["remoteCandidates"]
1049
+ if not candidates and not remote:
1050
+ print(
1051
+ f"discover-feature {name}: no pipeline state found on any local or "
1052
+ "remote-tracking branch"
1053
+ )
1054
+ return
1055
+ for cand in candidates:
1056
+ marks = []
1057
+ if cand["isCurrentBranch"]:
1058
+ marks.append("current branch")
1059
+ if cand["remoteTracking"]:
1060
+ marks.append("remote-tracking")
1061
+ if not cand["stateBranchMatches"] and cand["stateBranch"]:
1062
+ marks.append(f"state records branch {cand['stateBranch']}")
1063
+ if cand.get("isEpicMember"):
1064
+ marks.append(f"member of epic {cand.get('epic') or '?'}")
1065
+ suffix = f" ({'; '.join(marks)})" if marks else ""
1066
+ print(
1067
+ f" {cand['branch']}: stage={cand['currentStage'] or '?'} "
1068
+ f"status={cand['pipelineStatus']} path={cand['path']}{suffix}"
1069
+ )
1070
+ if not cand["isCurrentBranch"]:
1071
+ print(f" switch: {cand['switchCommand']}")
1072
+ for cand in remote:
1073
+ print(
1074
+ f" {cand['branch']}: on origin only (never fetched; contents not "
1075
+ "inspected — name matches)"
1076
+ )
1077
+ print(f" fetch: {cand['fetchCommand']}")
1078
+ print(f" switch: {cand['switchCommand']}")
1079
+
1080
+
1081
+ def _all_state_paths_in_ref(ref: str, specs_rel: str) -> list[tuple[str, str]]:
1082
+ """Every feature-shaped ``.pipeline-state.json`` in one ref as ``(path, feature)``.
1083
+
1084
+ The ``--all`` counterpart to ``_state_paths_in_ref``: same flat/nested bound
1085
+ (``{specsDir}/{name}/…`` or ``{specsDir}/{epic}/{name}/…``) but for every
1086
+ feature, not one named one.
1087
+ """
1088
+ listing = _git_output(["ls-tree", "-r", "--name-only", ref, "--", specs_rel])
1089
+ if not listing:
1090
+ return []
1091
+ hits: list[tuple[str, str]] = []
1092
+ prefix = specs_rel + "/"
1093
+ for path in listing.splitlines():
1094
+ if not path.startswith(prefix) or not path.endswith("/" + PIPELINE_STATE_FILENAME):
1095
+ continue
1096
+ segments = path[len(prefix):].split("/")
1097
+ if len(segments) == 2: # [name, state-file] (flat)
1098
+ hits.append((path, segments[0]))
1099
+ elif len(segments) == 3: # [epic, name, state-file] (nested)
1100
+ hits.append((path, segments[1]))
1101
+ return hits
1102
+
1103
+
1104
+ def discover_all(specs_dir: str) -> dict:
1105
+ """Discover EVERY feature's pipeline state across all branches (read-only, Chunk 5c).
1106
+
1107
+ The empty-dashboard counterpart to ``discover-feature <name>``: enumerates every
1108
+ feature-shaped state across local heads + remote-tracking refs and groups the
1109
+ candidates by feature, so a fresh clone / default-branch session can see the whole
1110
+ branch-scattered pipeline set instead of nothing. Never mutates anything.
1111
+ """
1112
+ if _git_output(["rev-parse", "--git-dir"]) is None:
1113
+ return {"gitRepo": False, "currentBranch": None, "features": []}
1114
+ current_branch = _git_output(["branch", "--show-current"])
1115
+ specs_rel = _specs_rel(specs_dir)
1116
+ refs = [(ref, date, False) for ref, date in _list_refs("refs/heads")]
1117
+ refs += [(ref, date, True) for ref, date in _list_refs("refs/remotes")]
1118
+
1119
+ by_feature: dict[str, list[dict]] = {}
1120
+ for ref, commit_date, is_remote in refs:
1121
+ branch = ref.split("/", 1)[1] if is_remote else ref
1122
+ if is_remote and (not branch or branch == "HEAD"):
1123
+ continue
1124
+ for path, feature in _all_state_paths_in_ref(ref, specs_rel):
1125
+ seen = by_feature.setdefault(feature, [])
1126
+ if any(c["branch"] == branch for c in seen):
1127
+ continue # a local head already yielded this branch's state
1128
+ state = _read_state_at_ref(ref, path)
1129
+ state_branch = state.get("branch")
1130
+ state_branch = state_branch if isinstance(state_branch, str) else None
1131
+ epic, is_epic_member = _epic_membership(path, specs_rel, state)
1132
+ seen.append({
1133
+ "branch": branch,
1134
+ "remoteTracking": is_remote,
1135
+ "path": path,
1136
+ "stateBranch": state_branch,
1137
+ "stateBranchMatches": state_branch == branch,
1138
+ "currentStage": state.get("currentStage"),
1139
+ "pipelineStatus": state.get("pipelineStatus", "active"),
1140
+ "epic": epic,
1141
+ "isEpicMember": is_epic_member,
1142
+ "commitDate": commit_date or None,
1143
+ "isCurrentBranch": branch == current_branch,
1144
+ "switchCommand": f"git switch {branch}",
1145
+ })
1146
+
1147
+ def _rank(cand: dict) -> tuple:
1148
+ ts = _parse_ts(cand["commitDate"]) or datetime.min.replace(tzinfo=timezone.utc)
1149
+ return (not cand["stateBranchMatches"], cand["remoteTracking"], -ts.timestamp())
1150
+
1151
+ features = []
1152
+ for feature in sorted(by_feature):
1153
+ cands = sorted(by_feature[feature], key=_rank)
1154
+ features.append({"feature": feature, "candidates": cands})
1155
+ return {"gitRepo": True, "currentBranch": current_branch, "features": features}
1156
+
1157
+
1158
+ def _print_discover_all(payload: dict) -> None:
1159
+ """Human-readable ``discover-feature --all`` report."""
1160
+ if not payload["gitRepo"]:
1161
+ print("discover-feature --all: not a git repository — nothing to scan")
1162
+ return
1163
+ if not payload["features"]:
1164
+ print("discover-feature --all: no pipeline state found on any local or "
1165
+ "remote-tracking branch")
1166
+ return
1167
+ for feat in payload["features"]:
1168
+ print(f"{feat['feature']}:")
1169
+ for cand in feat["candidates"]:
1170
+ marks = []
1171
+ if cand["isCurrentBranch"]:
1172
+ marks.append("current branch")
1173
+ if cand["remoteTracking"]:
1174
+ marks.append("remote-tracking")
1175
+ if not cand["stateBranchMatches"] and cand["stateBranch"]:
1176
+ marks.append(f"state records branch {cand['stateBranch']}")
1177
+ if cand.get("isEpicMember"):
1178
+ marks.append(f"member of epic {cand.get('epic') or '?'}")
1179
+ suffix = f" ({'; '.join(marks)})" if marks else ""
1180
+ print(f" {cand['branch']}: stage={cand['currentStage'] or '?'} "
1181
+ f"status={cand['pipelineStatus']}{suffix}")
1182
+ if not cand["isCurrentBranch"]:
1183
+ print(f" switch: {cand['switchCommand']}")
1184
+
1185
+
1186
+ # --------------------------------------------------------------------------- #
1187
+ # Branch reconciliation (Chunk 6) — imposed/session-branch drift
1188
+ # --------------------------------------------------------------------------- #
1189
+
1190
+
1191
+ def _default_branch() -> str | None:
1192
+ """The repo's default branch: origin/HEAD target, else `main`/`master` if present."""
1193
+ ref = _git_output(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"])
1194
+ if ref:
1195
+ return ref.rsplit("/", 1)[-1]
1196
+ for cand in ("main", "master"):
1197
+ if _git_output(["rev-parse", "--verify", "--quiet", f"refs/heads/{cand}"]) is not None:
1198
+ return cand
1199
+ return None
1200
+
1201
+
1202
+ def reconcile_branch(
1203
+ name: str, specs_dir: Path, config_path: Path, epic: str | None = None
1204
+ ) -> dict:
1205
+ """Decide whether a feature's recorded ``branch`` should adopt the current branch.
1206
+
1207
+ Read-only: it emits a decision; the caller performs any state write. A hosted
1208
+ environment (Claude.ai remote, cloud agents) imposes an arbitrary session branch
1209
+ that Branch Setup silently records; when the user moves to the intended branch the
1210
+ recorded ``branch`` goes stale and every branch-aware mechanism keys off it. This
1211
+ reconciler treats *where the state actually resolves* as the source of truth, with a
1212
+ default-branch guardrail so genuine drift-back-to-default is still surfaced, not
1213
+ silently adopted.
1214
+ """
1215
+ if _git_output(["rev-parse", "--git-dir"]) is None:
1216
+ return {"feature": name, "gitRepo": False, "reconcile": False,
1217
+ "action": "none", "reason": "not a git repository"}
1218
+ current = _git_output(["branch", "--show-current"])
1219
+ default = _default_branch()
1220
+ config = _load_config(config_path)
1221
+ row = next(
1222
+ (r for r in build_rows(specs_dir, config)
1223
+ if r["name"] == name and (epic is None or r["epic"] == epic)),
1224
+ None,
1225
+ )
1226
+ state_path = None
1227
+ if row is not None:
1228
+ parent = specs_dir / row["epic"] / name if row["epic"] else specs_dir / name
1229
+ state_path = str(parent / PIPELINE_STATE_FILENAME)
1230
+ base = {
1231
+ "feature": name,
1232
+ "gitRepo": True,
1233
+ "currentBranch": current,
1234
+ "defaultBranch": default,
1235
+ "stateBranch": row["branch"] if row else None,
1236
+ "resolvesOnCurrentBranch": row is not None,
1237
+ "statePath": state_path,
1238
+ "newBranch": None,
1239
+ }
1240
+ if current is None:
1241
+ return {**base, "reconcile": False, "action": "none",
1242
+ "reason": "no current branch (detached HEAD or unborn branch)"}
1243
+ if row is None:
1244
+ return {**base, "reconcile": False, "action": "not-resolved",
1245
+ "reason": "feature state does not resolve on the current branch — "
1246
+ "use discover-feature to locate it"}
1247
+ state_branch = base["stateBranch"]
1248
+ if state_branch == current:
1249
+ return {**base, "reconcile": False, "action": "none",
1250
+ "reason": "recorded branch already matches the current branch"}
1251
+ if current == default:
1252
+ return {**base, "reconcile": False, "action": "warn-drift",
1253
+ "reason": f"on the default branch ({default}); recording it would commit "
1254
+ "here — create/switch to a topic branch instead of reconciling"}
1255
+ detail = (f"recorded branch {state_branch!r} differs from the current topic branch"
1256
+ if state_branch else "no branch recorded")
1257
+ return {**base, "reconcile": True, "action": "adopt-current", "newBranch": current,
1258
+ "reason": f"{detail}; the feature state resolves here, so adopt the current branch"}
1259
+
1260
+
1261
+ def _print_reconcile(payload: dict) -> None:
1262
+ """Human-readable reconcile-branch report."""
1263
+ if not payload["gitRepo"]:
1264
+ print(f"reconcile-branch {payload['feature']}: not a git repository")
1265
+ return
1266
+ print(f"reconcile-branch {payload['feature']}: {payload['action']} — {payload['reason']}")
1267
+ print(f" current={payload['currentBranch']} recorded={payload['stateBranch'] or '(none)'} "
1268
+ f"default={payload['defaultBranch']}")
1269
+ if payload["reconcile"]:
1270
+ print(f" → write state branch := {payload['newBranch']} ({payload['statePath']})")
1271
+
1272
+
1273
+ # --------------------------------------------------------------------------- #
1274
+ # Epic-member base guard (Issue #125) — detached-base detection
1275
+ # --------------------------------------------------------------------------- #
1276
+
1277
+
1278
+ def check_epic_base(
1279
+ name: str, specs_dir: Path, config_path: Path, epic: str | None = None
1280
+ ) -> dict:
1281
+ """Verify the current HEAD actually contains the epic manifest for a nested member.
1282
+
1283
+ Defense-in-depth for the split-brain-epic failure (Issue #125): when a feature
1284
+ resolves to a nested epic-member directory but the epic's ``epic-manifest.json``
1285
+ is absent from the current checkout, the member stub was reached from a branch
1286
+ that predates (or otherwise lacks) the manifest commit — a detached base. This
1287
+ is read-only: it emits a decision; the caller stops or warns.
1288
+
1289
+ Actions:
1290
+ - ``none`` — not a git repo, a standalone feature (no epic to check), or the
1291
+ manifest is present on HEAD. Nothing to do.
1292
+ - ``not-resolved`` — the feature does not resolve on the current branch.
1293
+ - ``warn-detached-base`` — nested member resolves here but the manifest is
1294
+ missing on HEAD; ``homeBranch`` is the member stub's recorded ``branch``.
1295
+ """
1296
+ base = {
1297
+ "feature": name,
1298
+ "gitRepo": True,
1299
+ "epic": epic,
1300
+ "isEpicMember": False,
1301
+ "manifestOnHead": None,
1302
+ "homeBranch": None,
1303
+ }
1304
+ if _git_output(["rev-parse", "--git-dir"]) is None:
1305
+ return {**base, "gitRepo": False, "action": "none",
1306
+ "reason": "not a git repository"}
1307
+ config = _load_config(config_path)
1308
+ row = next(
1309
+ (r for r in build_rows(specs_dir, config)
1310
+ if r["name"] == name and (epic is None or r["epic"] == epic)),
1311
+ None,
1312
+ )
1313
+ if row is None:
1314
+ return {**base, "action": "not-resolved",
1315
+ "reason": "feature state does not resolve on the current branch — "
1316
+ "use discover-feature to locate it"}
1317
+ member_epic = row["epic"]
1318
+ if not member_epic:
1319
+ return {**base, "action": "none",
1320
+ "reason": "standalone feature — no epic base to check"}
1321
+ base = {**base, "epic": member_epic, "isEpicMember": True,
1322
+ "homeBranch": row["branch"]}
1323
+ manifest = specs_dir / member_epic / MANIFEST_FILENAME
1324
+ if manifest.is_file():
1325
+ return {**base, "manifestOnHead": True, "action": "none",
1326
+ "reason": f"epic manifest present on the current branch "
1327
+ f"({member_epic}/{MANIFEST_FILENAME})"}
1328
+ return {**base, "manifestOnHead": False, "action": "warn-detached-base",
1329
+ "reason": f"member of epic {member_epic!r} resolves here, but "
1330
+ f"{member_epic}/{MANIFEST_FILENAME} is absent on the current "
1331
+ f"branch — this base predates or lacks the epic manifest"}
1332
+
1333
+
1334
+ def _print_check_epic_base(payload: dict) -> None:
1335
+ """Human-readable check-epic-base report."""
1336
+ if not payload["gitRepo"]:
1337
+ print(f"check-epic-base {payload['feature']}: not a git repository")
1338
+ return
1339
+ print(f"check-epic-base {payload['feature']}: {payload['action']} — {payload['reason']}")
1340
+ if payload["action"] == "warn-detached-base":
1341
+ print(f" → switch to the epic's home branch: {payload['homeBranch'] or '(unknown)'}")
1342
+
1343
+
1344
+ # --------------------------------------------------------------------------- #
1345
+ # Scripted Stage Exit
1346
+ # --------------------------------------------------------------------------- #
1347
+
1348
+ #: Authoring stages whose closing runs stage-exit (the loop keeps bespoke exits).
1349
+ EXIT_STAGES: Final[tuple[str, ...]] = (
1350
+ "forge-0-epic",
1351
+ "forge-1-prd",
1352
+ "forge-2-tech",
1353
+ "forge-3-specs",
1354
+ "forge-4-backlog",
1355
+ )
1356
+
1357
+ #: Stage id -> the noun phrase gate wording uses (the old {stage} stamp slot).
1358
+ STAGE_NOUN: Final[dict[str, str]] = {
1359
+ "forge-0-epic": "the epic decomposition",
1360
+ "forge-1-prd": "the PRD",
1361
+ "forge-2-tech": "the tech spec",
1362
+ "forge-3-specs": "the implementation specs",
1363
+ "forge-4-backlog": "the backlog",
1364
+ }
1365
+
1366
+ #: Verify token per exit stage. Extends the production map with the epic stage,
1367
+ #: whose verify entry is recorded under ``forge-verify-epic``.
1368
+ _EXIT_VERIFY_TOKEN: Final[dict[str, str]] = {
1369
+ **VERIFY_TOKEN_BY_STAGE,
1370
+ "forge-0-epic": "epic",
1371
+ }
1372
+
1373
+ #: The stage each exit hands off to when pipeline state cannot say better.
1374
+ _EXIT_NEXT_STAGE: Final[dict[str, str]] = {
1375
+ "forge-0-epic": "forge-1-prd",
1376
+ "forge-1-prd": "forge-2-tech",
1377
+ "forge-2-tech": "forge-3-specs",
1378
+ "forge-3-specs": "forge-4-backlog",
1379
+ "forge-4-backlog": "forge-5-loop",
1380
+ }
1381
+
1382
+ #: The fixed final line of the NEXT-STEPS block. The stamp instructs the skill
1383
+ #: to print the block verbatim as its absolute last output — nothing after this.
1384
+ NEXT_STEPS_SENTINEL: Final = "─ forge: end of stage ─"
1385
+
1386
+
1387
+ def _verify_state_for(state: dict, stage: str) -> str:
1388
+ """Classify THIS stage's verify freshness (stage-scoped ``verify_state``).
1389
+
1390
+ Same labels as ``verify_state`` — fresh / stale / failing / never /
1391
+ skipped / none — but for the given stage rather than the most-recently
1392
+ completed one, because stage-exit runs inside the stage that just closed.
1393
+ """
1394
+ token = _EXIT_VERIFY_TOKEN.get(stage)
1395
+ if token is None:
1396
+ return "none"
1397
+ entry = _verify_entry(state, f"forge-verify-{token}")
1398
+ status = entry.get("status")
1399
+ if status == "skipped":
1400
+ return "skipped"
1401
+ if status == "findings-reported":
1402
+ return "failing"
1403
+ if status not in _VERIFY_RESOLVED:
1404
+ return "never"
1405
+ verified_version = entry.get("verifiedStageVersion")
1406
+ stage_version = _stage_version(state, stage)
1407
+ if (
1408
+ isinstance(verified_version, int)
1409
+ and stage_version is not None
1410
+ and verified_version == stage_version
1411
+ ):
1412
+ return "fresh"
1413
+ return "stale"
1414
+
1415
+
1416
+ def _resolve_feature_dir(specs_dir: Path, feature: str, epic: str | None) -> Path:
1417
+ """Best-effort feature dir (flat, else unique nested, else flat literal).
1418
+
1419
+ stage-exit tolerates an unresolvable dir — the state read downgrades to
1420
+ ``{}`` and every directive still computes from defaults.
1421
+ """
1422
+ if epic:
1423
+ return specs_dir / epic / feature
1424
+ flat = specs_dir / feature
1425
+ if (flat / PIPELINE_STATE_FILENAME).is_file():
1426
+ return flat
1427
+ if specs_dir.is_dir():
1428
+ nested = [
1429
+ p for p in specs_dir.glob(f"*/{feature}")
1430
+ if (p / PIPELINE_STATE_FILENAME).is_file()
1431
+ ]
1432
+ if len(nested) == 1:
1433
+ return nested[0]
1434
+ return flat
1435
+
1436
+
1437
+ def _host_command(command: str, host: str) -> str:
1438
+ """Rewrite a `/skill:` slash command to the host's surface.
1439
+
1440
+ Pi's slash-command surface is `/skill:` (matching the adapter body's
1441
+ `/skill:` -> `/skill:` translation). The scripted stage-exit output bypasses
1442
+ that body translation, so it rewrites the commands it emits here. No-op for
1443
+ claude/generic, which keep the canonical `/skill:` form.
1444
+ """
1445
+ return command.replace("/skill:", "/skill:") if host == "pi" else command
1446
+
1447
+
1448
+ def _next_steps_block(
1449
+ next_command: str, host: str, reconcile: dict | None = None
1450
+ ) -> str:
1451
+ """Render the sentinel-terminated NEXT-STEPS block for the given host.
1452
+
1453
+ The Claude wording uses the literal ``/clear`` slash-command; the generic
1454
+ wording is host-neutral (matching the adapter build's host-term table, so
1455
+ a non-Claude bundle invoking ``--host generic`` never instructs a fake
1456
+ slash-command).
1457
+
1458
+ ``reconcile`` carries the epic-backflow routing (§Epic backflow in
1459
+ ``references/stage-exit-protocol.md``). When it marks a **blocking** request
1460
+ (``required: true``), the fenced primary command becomes the epic reconcile
1461
+ command and the normal next stage is demoted to a follow-up line. When it
1462
+ marks only **non-blocking** requests (``reminder: true``), the fenced command
1463
+ stays the normal next stage and a reminder line is appended. Either way the
1464
+ added prose is host-neutral (no literal ``/clear``) so it survives verbatim
1465
+ into a generic bundle.
1466
+ """
1467
+ if host == "claude":
1468
+ clear_line = (
1469
+ "1. `/clear` — recommended unconditionally at this stage boundary; "
1470
+ "every artifact is on disk, so the work survives the clear. "
1471
+ "I can't `/clear` for you — you have to run it yourself."
1472
+ )
1473
+ next_line = (
1474
+ "2. Then start a fresh session and run the next stage below — or "
1475
+ "re-run `/skill:forge` to let the navigator resume from disk."
1476
+ )
1477
+ elif host == "pi":
1478
+ # Pi's fresh-session command is `/new` (not `/clear`); its slash-command
1479
+ # surface is `/skill:` (the fenced command below is rewritten to match).
1480
+ clear_line = (
1481
+ "1. `/new` — recommended unconditionally at this stage boundary; every "
1482
+ "artifact is on disk, so the work survives starting a fresh session. "
1483
+ "I can't run `/new` for you — you have to run it yourself."
1484
+ )
1485
+ next_line = (
1486
+ "2. Then, in the new session, run the next stage below — or re-run "
1487
+ "`/skill:forge` to let the navigator resume from disk."
1488
+ )
1489
+ else:
1490
+ clear_line = (
1491
+ "1. Clear your session / start a fresh session — recommended "
1492
+ "unconditionally at this stage boundary; every artifact is on "
1493
+ "disk, so the work survives it."
1494
+ )
1495
+ next_line = (
1496
+ "2. Then start a fresh session and run the next stage below — or "
1497
+ "re-run the forge navigator skill to resume from disk."
1498
+ )
1499
+ blocking = bool(reconcile and reconcile.get("required"))
1500
+ # The primary actionable command goes in a fenced block so mobile/remote hosts
1501
+ # get a native copy button (inline code is not tap-to-copy). For a blocking
1502
+ # epic-change request the primary is the reconcile command; otherwise it is the
1503
+ # normal next-stage command. The fence sits before the sentinel, so the
1504
+ # sentinel remains the absolute last line.
1505
+ fenced_command = _host_command(reconcile["command"] if blocking else next_command, host)
1506
+ lines = ["**Next steps**", clear_line]
1507
+ if blocking:
1508
+ count = reconcile["count"]
1509
+ plural = "s" if count != 1 else ""
1510
+ lines.append(
1511
+ f"2. Then reconcile the epic **before** the next stage — {count} "
1512
+ f"blocking epic change request{plural} flagged, and proceeding would "
1513
+ "build this feature's artifacts on a decomposition that is about to "
1514
+ "change. Run the reconcile command below first."
1515
+ )
1516
+ else:
1517
+ lines.append(next_line)
1518
+ lines.append("")
1519
+ lines.append(f"```\n{fenced_command}\n```")
1520
+ if blocking and reconcile.get("deferred"):
1521
+ deferred_cmd = _host_command(reconcile["deferred"], host)
1522
+ lines.append(f"After reconciling, continue the pipeline with: `{deferred_cmd}`")
1523
+ elif reconcile and reconcile.get("reminder"):
1524
+ count = reconcile["count"]
1525
+ plural = "s" if count != 1 else ""
1526
+ lines.append(
1527
+ f"You also flagged {count} epic change{plural} to reconcile when "
1528
+ f"convenient: `{_host_command(reconcile['command'], host)}`"
1529
+ )
1530
+ lines.append(NEXT_STEPS_SENTINEL)
1531
+ return "\n".join(lines)
1532
+
1533
+
1534
+ def stage_exit(
1535
+ feature: str,
1536
+ stage: str,
1537
+ specs_dir: Path,
1538
+ config_path: Path,
1539
+ epic: str | None,
1540
+ host: str,
1541
+ next_feature: str | None,
1542
+ ) -> dict:
1543
+ """Compute the Scripted Stage Exit payload: DIRECTIVES + NEXT-STEPS block.
1544
+
1545
+ Directive semantics (the contract in ``references/stage-exit-protocol.md``):
1546
+
1547
+ - ``runInStageVerify`` — the effective auto-verify (per-stage override,
1548
+ else global; strict-true) is on AND this stage's verify is not already
1549
+ resolved (fresh/skipped). The skill then dispatches the clean-room
1550
+ verify in-session (principle #2: verify before the clear).
1551
+ - ``autoFixEligible`` — ``autoFix`` is strict-true AND the in-stage verify
1552
+ runs AND the working tree is clean. Findings-level preconditions (zero
1553
+ unresolved decisions) remain the skill's runtime check.
1554
+ - ``verifyGate`` — ``none`` when verify is resolved or the in-stage run
1555
+ covers it; ``standard`` when auto-verify is off and verification is
1556
+ outstanding on a host with a question mechanism + clean-room path
1557
+ (``--host claude``); ``manual-print`` for the same state on a generic
1558
+ host (print ``verifyCommand`` instead of presenting the gate).
1559
+ - ``nextStage``/``nextCommand`` — from pipeline state when it already
1560
+ records this stage complete (first non-complete production stage), else
1561
+ the fixed successor. ``--next-feature`` names the first actionable
1562
+ feature for the epic handoff; without it the runtime placeholder
1563
+ ``{first-actionable-feature}`` passes through for the skill to resolve.
1564
+ - ``epicReconcile`` — present only when the exiting member carries
1565
+ ``open`` ``epicChangeRequests`` (epic-backflow). ``required: true`` (any
1566
+ ``blocksCurrent: true`` request) interposes a reconcile-first exit: the
1567
+ NEXT-STEPS primary command becomes ``/skill:forge-0-epic {epic}``
1568
+ and the normal next stage is deferred. Only non-blocking requests set
1569
+ ``reminder: true`` and append a non-blocking reminder line. Absent when
1570
+ there are no open requests (common path) or the epic name is unresolvable.
1571
+
1572
+ Read-only, deterministic, exit 0 — errors degrade to defaults, never
1573
+ crash a stage closing.
1574
+ """
1575
+ config = _load_config(config_path)
1576
+ feature_dir = _resolve_feature_dir(specs_dir, feature, epic)
1577
+ state = _read_state(feature_dir / PIPELINE_STATE_FILENAME)
1578
+
1579
+ git_repo = _git_output(["rev-parse", "--git-dir"]) is not None
1580
+ clean_tree: bool | None = None
1581
+ if git_repo:
1582
+ porcelain = _git_output(["status", "--porcelain"])
1583
+ clean_tree = porcelain is None or porcelain == ""
1584
+
1585
+ verify_label = _verify_state_for(state, stage)
1586
+ resolved = verify_label in ("fresh", "skipped")
1587
+ effective_auto_verify = auto_verify_for(config, stage)
1588
+ run_in_stage = effective_auto_verify and not resolved
1589
+ auto_fix_eligible = (
1590
+ config.get("autoFix") is True and run_in_stage and clean_tree is True
1591
+ )
1592
+ if resolved or effective_auto_verify:
1593
+ verify_gate = "none"
1594
+ elif host == "claude":
1595
+ verify_gate = "standard"
1596
+ else:
1597
+ verify_gate = "manual-print"
1598
+
1599
+ next_stage_id = _EXIT_NEXT_STAGE.get(stage)
1600
+ state_next = next_stage(state)
1601
+ if (
1602
+ stage in PRODUCTION_STAGES
1603
+ and state_next is not None
1604
+ and PRODUCTION_STAGES.index(state_next) > PRODUCTION_STAGES.index(stage)
1605
+ ):
1606
+ # State records this stage complete AND its walk lands beyond it —
1607
+ # trust it (it skips stages already completed out of order). A missing
1608
+ # or behind-the-stage walk (state not yet flushed, corrupt file) falls
1609
+ # back to the fixed successor, never to an earlier stage.
1610
+ next_stage_id = state_next
1611
+ next_arg = next_feature or (
1612
+ "{first-actionable-feature}" if stage == "forge-0-epic" else feature
1613
+ )
1614
+ next_command = f"/skill:{next_stage_id} {next_arg}" if next_stage_id else None
1615
+
1616
+ # Epic backflow routing: an exiting member may carry epic-level change requests
1617
+ # (recorded by forge-1-prd/forge-2-tech). A `blocksCurrent: true` request means
1618
+ # the current feature's next stage would build on a soon-to-change decomposition,
1619
+ # so the exit interposes a reconcile-first step; only-`false` requests append a
1620
+ # non-blocking reminder. Read-only; the common path (no open requests) is a no-op.
1621
+ # The epic name comes from the `--epic` arg or the state's `epic` back-pointer.
1622
+ epic_reconcile: dict | None = None
1623
+ epic_name = epic or state.get("epic")
1624
+ open_requests = [
1625
+ r
1626
+ for r in state.get("epicChangeRequests", [])
1627
+ if isinstance(r, dict) and r.get("status") == "open"
1628
+ ]
1629
+ if open_requests and epic_name:
1630
+ reconcile_command = f"/skill:forge-0-epic {epic_name}"
1631
+ blocking = [r for r in open_requests if r.get("blocksCurrent") is True]
1632
+ if blocking:
1633
+ epic_reconcile = {
1634
+ "required": True,
1635
+ "command": reconcile_command,
1636
+ "count": len(blocking),
1637
+ "deferred": next_command,
1638
+ }
1639
+ else:
1640
+ epic_reconcile = {
1641
+ "required": False,
1642
+ "reminder": True,
1643
+ "command": reconcile_command,
1644
+ "count": len(open_requests),
1645
+ }
1646
+
1647
+ directives = {
1648
+ "stage": stage,
1649
+ "stageNoun": STAGE_NOUN.get(stage, stage),
1650
+ "feature": feature,
1651
+ "runInStageVerify": run_in_stage,
1652
+ "verifyGate": verify_gate,
1653
+ "autoFixEligible": auto_fix_eligible,
1654
+ "verifyState": verify_label,
1655
+ "verifyCommand": _host_command(f"/skill:forge-verify {feature}", host),
1656
+ "autoVerifyEffective": effective_auto_verify,
1657
+ "nextStage": next_stage_id,
1658
+ "nextCommand": _host_command(next_command, host) if next_command else next_command,
1659
+ "invalidAutoVerifyKeys": invalid_auto_verify_keys(config),
1660
+ "gitRepo": git_repo,
1661
+ "cleanTree": clean_tree,
1662
+ "host": host,
1663
+ }
1664
+ if epic_reconcile is not None:
1665
+ directives["epicReconcile"] = epic_reconcile
1666
+ return {
1667
+ "directives": directives,
1668
+ "nextSteps": _next_steps_block(
1669
+ next_command or "/skill:forge", host, epic_reconcile
1670
+ ),
1671
+ "sentinel": NEXT_STEPS_SENTINEL,
1672
+ }
1673
+
1674
+
1675
+ def _print_stage_exit(payload: dict) -> None:
1676
+ """Print DIRECTIVES then the NEXT-STEPS block (the skill-facing form)."""
1677
+ print("DIRECTIVES:")
1678
+ print(json.dumps(payload["directives"], indent=2, ensure_ascii=False))
1679
+ print(
1680
+ "NEXT-STEPS (print this block verbatim as your absolute last output — "
1681
+ "nothing after the sentinel):"
1682
+ )
1683
+ print(payload["nextSteps"])
1684
+
1685
+
1686
+ # --------------------------------------------------------------------------- #
1687
+ # CLI dispatch
1688
+ # --------------------------------------------------------------------------- #
1689
+
1690
+
1691
+ def _print_rank_table(rows: list[FeatureRow], counts: dict[str, int]) -> None:
1692
+ """Print a human-readable recency-ranked feature list."""
1693
+ print(
1694
+ f"Active: {counts['active']} "
1695
+ f"(paused: {counts['paused']}, abandoned: {counts['abandoned']})"
1696
+ )
1697
+ if not rows:
1698
+ print(" (no active feature pipelines)")
1699
+ return
1700
+ for idx, row in enumerate(rows):
1701
+ marker = "→" if idx == 0 else " "
1702
+ label = row["name"] + (f" [{row['epic']}]" if row["epic"] else "")
1703
+ nxt = row["nextCommand"] or "complete"
1704
+ print(f" {marker} {label}: {row['currentStage']} — next: {nxt}")
1705
+ if row["verifyPending"]:
1706
+ print(f" (verify available: {row['verifyCommand']})")
1707
+
1708
+
1709
+ def _print_context(usage: dict) -> None:
1710
+ """Print a one-line human-readable context-usage summary."""
1711
+ if not usage.get("available"):
1712
+ print(f"context usage: unavailable ({usage.get('reason', 'unknown')})")
1713
+ return
1714
+ pct = round(usage["pct"] * 100, 1)
1715
+ flag = " — over threshold, clean session recommended" if usage["overThreshold"] else ""
1716
+ print(
1717
+ f"context: {usage['tokens']:,} / {usage['windowTokens']:,} tokens "
1718
+ f"(~{pct}%){flag}"
1719
+ )
1720
+
1721
+
1722
+ def main() -> int:
1723
+ parser = argparse.ArgumentParser(prog="forge-session.py", description=__doc__)
1724
+ sub = parser.add_subparsers(dest="cmd", required=True)
1725
+
1726
+ p_rank = sub.add_parser("rank-features", help="Rank active features by recency")
1727
+ p_rank.add_argument("--specs-dir", default="./specs", help="Specs directory")
1728
+ p_rank.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
1729
+ p_rank.add_argument("--json", action="store_true", dest="json_output")
1730
+
1731
+ p_ctx = sub.add_parser("context-usage", help="Report live context-window usage")
1732
+ p_ctx.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
1733
+ p_ctx.add_argument("--window", type=int, default=None, help="Override context window size")
1734
+ p_ctx.add_argument("--threshold", type=float, default=None, help="Override warn fraction (0-1)")
1735
+ p_ctx.add_argument("--json", action="store_true", dest="json_output")
1736
+
1737
+ p_doc = sub.add_parser("doctor", help="Capture pipeline ground truth for debugging")
1738
+ p_doc.add_argument("--specs-dir", default="./specs", help="Specs directory")
1739
+ p_doc.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
1740
+ p_doc.add_argument("--json", action="store_true", dest="json_output")
1741
+
1742
+ p_disc = sub.add_parser(
1743
+ "discover-feature", help="Find a feature's pipeline state across all branches"
1744
+ )
1745
+ p_disc.add_argument("name", nargs="?", default=None,
1746
+ help="Feature name to discover (omit with --all)")
1747
+ p_disc.add_argument("--all", action="store_true", dest="discover_all",
1748
+ help="Discover every feature across all branches (empty-dashboard)")
1749
+ p_disc.add_argument("--specs-dir", default="./specs", help="Specs directory")
1750
+ p_disc.add_argument("--json", action="store_true", dest="json_output")
1751
+
1752
+ p_recon = sub.add_parser(
1753
+ "reconcile-branch",
1754
+ help="Decide whether a feature's recorded branch should adopt the current branch",
1755
+ )
1756
+ p_recon.add_argument("--feature", required=True, help="Feature name")
1757
+ p_recon.add_argument("--specs-dir", default="./specs", help="Specs directory")
1758
+ p_recon.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
1759
+ p_recon.add_argument("--epic", default=None, help="Epic name for a nested member")
1760
+ p_recon.add_argument("--json", action="store_true", dest="json_output")
1761
+
1762
+ p_base = sub.add_parser(
1763
+ "check-epic-base",
1764
+ help="Verify HEAD contains the epic manifest for a resolved nested member",
1765
+ )
1766
+ p_base.add_argument("--feature", required=True, help="Feature name")
1767
+ p_base.add_argument("--specs-dir", default="./specs", help="Specs directory")
1768
+ p_base.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
1769
+ p_base.add_argument("--epic", default=None, help="Epic name for a nested member")
1770
+ p_base.add_argument("--json", action="store_true", dest="json_output")
1771
+
1772
+ p_exit = sub.add_parser(
1773
+ "stage-exit", help="Emit the Scripted Stage Exit directives + NEXT-STEPS block"
1774
+ )
1775
+ p_exit.add_argument("--feature", required=True,
1776
+ help="Feature name (the epic name for forge-0-epic)")
1777
+ p_exit.add_argument("--stage", required=True, choices=EXIT_STAGES,
1778
+ help="The just-completed authoring stage")
1779
+ p_exit.add_argument("--specs-dir", default="./specs", help="Specs directory")
1780
+ p_exit.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
1781
+ p_exit.add_argument("--epic", default=None, help="Epic name for a nested member")
1782
+ p_exit.add_argument("--next-feature", default=None, dest="next_feature",
1783
+ help="First actionable feature (epic handoff next-command arg)")
1784
+ p_exit.add_argument("--host", default="claude", choices=("claude", "generic", "pi"),
1785
+ help="Host wording for the NEXT-STEPS block")
1786
+ p_exit.add_argument("--json", action="store_true", dest="json_output")
1787
+
1788
+ args = parser.parse_args()
1789
+
1790
+ try:
1791
+ if args.cmd == "rank-features":
1792
+ specs_dir = Path(args.specs_dir)
1793
+ config = _load_config(Path(args.config))
1794
+ rows = build_rows(specs_dir, config)
1795
+ counts = _counts(specs_dir)
1796
+ invalid_keys = invalid_auto_verify_keys(config)
1797
+ if args.json_output:
1798
+ payload = {"active": rows, "counts": counts}
1799
+ if invalid_keys:
1800
+ payload["invalidAutoVerifyKeys"] = invalid_keys
1801
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1802
+ else:
1803
+ _print_rank_table(rows, counts)
1804
+ if invalid_keys:
1805
+ print(
1806
+ " ! invalid autoVerifyStages keys (ignored): "
1807
+ + ", ".join(invalid_keys)
1808
+ )
1809
+ return 0
1810
+
1811
+ if args.cmd == "context-usage":
1812
+ usage = context_usage(Path(args.config), args.window, args.threshold)
1813
+ if args.json_output:
1814
+ print(json.dumps(usage, indent=2, ensure_ascii=False))
1815
+ else:
1816
+ _print_context(usage)
1817
+ return 0
1818
+
1819
+ if args.cmd == "doctor":
1820
+ report = doctor_report(Path(args.specs_dir), Path(args.config))
1821
+ if args.json_output:
1822
+ print(json.dumps(report, indent=2, ensure_ascii=False))
1823
+ else:
1824
+ _print_doctor(report)
1825
+ return 0
1826
+
1827
+ if args.cmd == "discover-feature":
1828
+ if args.discover_all:
1829
+ payload = discover_all(args.specs_dir)
1830
+ printer = _print_discover_all
1831
+ elif args.name:
1832
+ payload = discover_feature(args.name, args.specs_dir)
1833
+ printer = _print_discover
1834
+ else:
1835
+ parser.error("discover-feature requires a NAME or --all")
1836
+ if args.json_output:
1837
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1838
+ else:
1839
+ printer(payload)
1840
+ return 0
1841
+
1842
+ if args.cmd == "reconcile-branch":
1843
+ payload = reconcile_branch(
1844
+ args.feature, Path(args.specs_dir), Path(args.config), args.epic
1845
+ )
1846
+ if args.json_output:
1847
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1848
+ else:
1849
+ _print_reconcile(payload)
1850
+ return 0
1851
+
1852
+ if args.cmd == "check-epic-base":
1853
+ payload = check_epic_base(
1854
+ args.feature, Path(args.specs_dir), Path(args.config), args.epic
1855
+ )
1856
+ if args.json_output:
1857
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1858
+ else:
1859
+ _print_check_epic_base(payload)
1860
+ return 0
1861
+
1862
+ if args.cmd == "stage-exit":
1863
+ payload = stage_exit(
1864
+ args.feature,
1865
+ args.stage,
1866
+ Path(args.specs_dir),
1867
+ Path(args.config),
1868
+ args.epic,
1869
+ args.host,
1870
+ args.next_feature,
1871
+ )
1872
+ if args.json_output:
1873
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1874
+ else:
1875
+ _print_stage_exit(payload)
1876
+ return 0
1877
+
1878
+ raise UsageError(f"unknown command: {args.cmd}")
1879
+ except UsageError as exc:
1880
+ print(f"Error: {exc}", file=sys.stderr)
1881
+ return 2
1882
+ except OSError as exc:
1883
+ print(f"Error: {exc}", file=sys.stderr)
1884
+ return 2
1885
+
1886
+
1887
+ if __name__ == "__main__":
1888
+ sys.exit(main())