@garygentry/feature-forge 0.3.1 → 0.3.2
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.
- package/adapters/claude/.feature-forge-bundle.json +1 -1
- package/adapters/claude/references/epic-manifest-schema.json +6 -1
- package/adapters/claude/references/forge-config-schema.json +1 -1
- package/adapters/claude/references/pipeline-state-schema.json +4 -2
- package/adapters/claude/references/shared-conventions.md +63 -0
- package/adapters/claude/references/stage-exit-protocol.md +344 -140
- package/adapters/claude/scripts/epic-manifest.py +413 -87
- package/adapters/claude/scripts/forge-bootstrap.py +57 -5
- package/adapters/claude/scripts/forge-session.py +3136 -139
- package/adapters/claude/scripts/validate-traceability.py +86 -5
- package/adapters/claude/skills/forge/SKILL.md +7 -6
- package/adapters/claude/skills/forge/references/pipeline-state-schema.json +4 -2
- package/adapters/claude/skills/forge/references/shared-conventions.md +63 -0
- package/adapters/claude/skills/forge/references/stage-exit-protocol.md +344 -140
- package/adapters/claude/skills/forge-0-epic/SKILL.md +7 -2
- package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +24 -24
- package/adapters/claude/skills/forge-0-epic/references/pipeline-state-schema.json +4 -2
- package/adapters/claude/skills/forge-0-epic/references/shared-conventions.md +63 -0
- package/adapters/claude/skills/forge-0-epic/references/stage-exit-protocol.md +344 -140
- package/adapters/claude/skills/forge-1-prd/SKILL.md +14 -3
- package/adapters/claude/skills/forge-1-prd/references/shared-conventions.md +63 -0
- package/adapters/claude/skills/forge-1-prd/references/stage-exit-protocol.md +344 -140
- package/adapters/claude/skills/forge-2-tech/SKILL.md +13 -3
- package/adapters/claude/skills/forge-2-tech/references/shared-conventions.md +63 -0
- package/adapters/claude/skills/forge-2-tech/references/stage-exit-protocol.md +344 -140
- package/adapters/claude/skills/forge-3-specs/SKILL.md +4 -2
- package/adapters/claude/skills/forge-3-specs/references/shared-conventions.md +63 -0
- package/adapters/claude/skills/forge-3-specs/references/spec-archetypes.md +9 -0
- package/adapters/claude/skills/forge-3-specs/references/stage-exit-protocol.md +344 -140
- package/adapters/claude/skills/forge-4-backlog/SKILL.md +16 -3
- package/adapters/claude/skills/forge-4-backlog/references/shared-conventions.md +63 -0
- package/adapters/claude/skills/forge-4-backlog/references/stage-exit-protocol.md +344 -140
- package/adapters/claude/skills/forge-5-loop/SKILL.md +40 -42
- package/adapters/claude/skills/forge-5-loop/references/result-reporting.md +70 -31
- package/adapters/claude/skills/forge-5-loop/references/runner-contract.md +4 -2
- package/adapters/claude/skills/forge-5-loop/references/shared-conventions.md +63 -0
- package/adapters/claude/skills/forge-5-loop/references/stage-exit-protocol.md +344 -140
- package/adapters/claude/skills/forge-6-docs/SKILL.md +48 -4
- package/adapters/claude/skills/forge-6-docs/references/shared-conventions.md +63 -0
- package/adapters/claude/skills/forge-6-docs/references/stage-exit-protocol.md +472 -0
- package/adapters/claude/skills/forge-fix/SKILL.md +85 -33
- package/adapters/claude/skills/forge-fix/references/shared-conventions.md +63 -0
- package/adapters/claude/skills/forge-fix/references/stage-exit-protocol.md +344 -140
- package/adapters/claude/skills/forge-guide/references/forge-config-schema.json +1 -1
- package/adapters/claude/skills/forge-guide/references/shared-conventions.md +63 -0
- package/adapters/claude/skills/forge-verify/SKILL.md +73 -41
- package/adapters/claude/skills/forge-verify/references/findings-template.md +39 -49
- package/adapters/claude/skills/forge-verify/references/shared-conventions.md +63 -0
- package/adapters/claude/skills/forge-verify/references/stage-exit-protocol.md +472 -0
- package/adapters/claude/skills/forge-verify/references/verification-checklists/epic.md +4 -3
- package/adapters/claude/skills/forge-verify/references/verification-checklists/specs.md +2 -0
- package/adapters/codex/.feature-forge-bundle.json +1 -1
- package/adapters/codex/references/epic-manifest-schema.json +6 -1
- package/adapters/codex/references/forge-config-schema.json +1 -1
- package/adapters/codex/references/pipeline-state-schema.json +4 -2
- package/adapters/codex/references/shared-conventions.md +63 -0
- package/adapters/codex/references/stage-exit-protocol.md +344 -140
- package/adapters/codex/scripts/epic-manifest.py +413 -87
- package/adapters/codex/scripts/forge-bootstrap.py +57 -5
- package/adapters/codex/scripts/forge-session.py +3136 -139
- package/adapters/codex/scripts/validate-traceability.py +86 -5
- package/adapters/codex/skills/forge/SKILL.md +7 -6
- package/adapters/codex/skills/forge/references/pipeline-state-schema.json +4 -2
- package/adapters/codex/skills/forge/references/shared-conventions.md +63 -0
- package/adapters/codex/skills/forge/references/stage-exit-protocol.md +344 -140
- package/adapters/codex/skills/forge-0-epic/SKILL.md +7 -2
- package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +24 -24
- package/adapters/codex/skills/forge-0-epic/references/pipeline-state-schema.json +4 -2
- package/adapters/codex/skills/forge-0-epic/references/shared-conventions.md +63 -0
- package/adapters/codex/skills/forge-0-epic/references/stage-exit-protocol.md +344 -140
- package/adapters/codex/skills/forge-1-prd/SKILL.md +14 -3
- package/adapters/codex/skills/forge-1-prd/references/shared-conventions.md +63 -0
- package/adapters/codex/skills/forge-1-prd/references/stage-exit-protocol.md +344 -140
- package/adapters/codex/skills/forge-2-tech/SKILL.md +13 -3
- package/adapters/codex/skills/forge-2-tech/references/shared-conventions.md +63 -0
- package/adapters/codex/skills/forge-2-tech/references/stage-exit-protocol.md +344 -140
- package/adapters/codex/skills/forge-3-specs/SKILL.md +4 -2
- package/adapters/codex/skills/forge-3-specs/references/shared-conventions.md +63 -0
- package/adapters/codex/skills/forge-3-specs/references/spec-archetypes.md +9 -0
- package/adapters/codex/skills/forge-3-specs/references/stage-exit-protocol.md +344 -140
- package/adapters/codex/skills/forge-4-backlog/SKILL.md +16 -3
- package/adapters/codex/skills/forge-4-backlog/references/shared-conventions.md +63 -0
- package/adapters/codex/skills/forge-4-backlog/references/stage-exit-protocol.md +344 -140
- package/adapters/codex/skills/forge-5-loop/SKILL.md +40 -42
- package/adapters/codex/skills/forge-5-loop/references/result-reporting.md +70 -31
- package/adapters/codex/skills/forge-5-loop/references/runner-contract.md +4 -2
- package/adapters/codex/skills/forge-5-loop/references/shared-conventions.md +63 -0
- package/adapters/codex/skills/forge-5-loop/references/stage-exit-protocol.md +344 -140
- package/adapters/codex/skills/forge-6-docs/SKILL.md +48 -4
- package/adapters/codex/skills/forge-6-docs/references/shared-conventions.md +63 -0
- package/adapters/codex/skills/forge-6-docs/references/stage-exit-protocol.md +472 -0
- package/adapters/codex/skills/forge-fix/SKILL.md +84 -32
- package/adapters/codex/skills/forge-fix/references/shared-conventions.md +63 -0
- package/adapters/codex/skills/forge-fix/references/stage-exit-protocol.md +344 -140
- package/adapters/codex/skills/forge-guide/references/forge-config-schema.json +1 -1
- package/adapters/codex/skills/forge-guide/references/shared-conventions.md +63 -0
- package/adapters/codex/skills/forge-verify/SKILL.md +72 -40
- package/adapters/codex/skills/forge-verify/references/findings-template.md +39 -49
- package/adapters/codex/skills/forge-verify/references/shared-conventions.md +63 -0
- package/adapters/codex/skills/forge-verify/references/stage-exit-protocol.md +472 -0
- package/adapters/codex/skills/forge-verify/references/verification-checklists/epic.md +4 -3
- package/adapters/codex/skills/forge-verify/references/verification-checklists/specs.md +2 -0
- package/adapters/copilot/.feature-forge-bundle.json +1 -1
- package/adapters/copilot/references/epic-manifest-schema.json +6 -1
- package/adapters/copilot/references/forge-config-schema.json +1 -1
- package/adapters/copilot/references/pipeline-state-schema.json +4 -2
- package/adapters/copilot/references/shared-conventions.md +63 -0
- package/adapters/copilot/references/stage-exit-protocol.md +344 -140
- package/adapters/copilot/scripts/epic-manifest.py +413 -87
- package/adapters/copilot/scripts/forge-bootstrap.py +57 -5
- package/adapters/copilot/scripts/forge-session.py +3136 -139
- package/adapters/copilot/scripts/validate-traceability.py +86 -5
- package/adapters/copilot/skills/forge/forge.md +7 -6
- package/adapters/copilot/skills/forge/references/pipeline-state-schema.json +4 -2
- package/adapters/copilot/skills/forge/references/shared-conventions.md +63 -0
- package/adapters/copilot/skills/forge/references/stage-exit-protocol.md +344 -140
- package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +7 -2
- package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +24 -24
- package/adapters/copilot/skills/forge-0-epic/references/pipeline-state-schema.json +4 -2
- package/adapters/copilot/skills/forge-0-epic/references/shared-conventions.md +63 -0
- package/adapters/copilot/skills/forge-0-epic/references/stage-exit-protocol.md +344 -140
- package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +14 -3
- package/adapters/copilot/skills/forge-1-prd/references/shared-conventions.md +63 -0
- package/adapters/copilot/skills/forge-1-prd/references/stage-exit-protocol.md +344 -140
- package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +13 -3
- package/adapters/copilot/skills/forge-2-tech/references/shared-conventions.md +63 -0
- package/adapters/copilot/skills/forge-2-tech/references/stage-exit-protocol.md +344 -140
- package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +4 -2
- package/adapters/copilot/skills/forge-3-specs/references/shared-conventions.md +63 -0
- package/adapters/copilot/skills/forge-3-specs/references/spec-archetypes.md +9 -0
- package/adapters/copilot/skills/forge-3-specs/references/stage-exit-protocol.md +344 -140
- package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +16 -3
- package/adapters/copilot/skills/forge-4-backlog/references/shared-conventions.md +63 -0
- package/adapters/copilot/skills/forge-4-backlog/references/stage-exit-protocol.md +344 -140
- package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +40 -42
- package/adapters/copilot/skills/forge-5-loop/references/result-reporting.md +70 -31
- package/adapters/copilot/skills/forge-5-loop/references/runner-contract.md +4 -2
- package/adapters/copilot/skills/forge-5-loop/references/shared-conventions.md +63 -0
- package/adapters/copilot/skills/forge-5-loop/references/stage-exit-protocol.md +344 -140
- package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +48 -4
- package/adapters/copilot/skills/forge-6-docs/references/shared-conventions.md +63 -0
- package/adapters/copilot/skills/forge-6-docs/references/stage-exit-protocol.md +472 -0
- package/adapters/copilot/skills/forge-fix/forge-fix.md +84 -32
- package/adapters/copilot/skills/forge-fix/references/shared-conventions.md +63 -0
- package/adapters/copilot/skills/forge-fix/references/stage-exit-protocol.md +344 -140
- package/adapters/copilot/skills/forge-guide/references/forge-config-schema.json +1 -1
- package/adapters/copilot/skills/forge-guide/references/shared-conventions.md +63 -0
- package/adapters/copilot/skills/forge-verify/forge-verify.md +72 -40
- package/adapters/copilot/skills/forge-verify/references/findings-template.md +39 -49
- package/adapters/copilot/skills/forge-verify/references/shared-conventions.md +63 -0
- package/adapters/copilot/skills/forge-verify/references/stage-exit-protocol.md +472 -0
- package/adapters/copilot/skills/forge-verify/references/verification-checklists/epic.md +4 -3
- package/adapters/copilot/skills/forge-verify/references/verification-checklists/specs.md +2 -0
- package/adapters/cursor/.feature-forge-bundle.json +1 -1
- package/adapters/cursor/references/epic-manifest-schema.json +6 -1
- package/adapters/cursor/references/forge-config-schema.json +1 -1
- package/adapters/cursor/references/pipeline-state-schema.json +4 -2
- package/adapters/cursor/references/shared-conventions.md +63 -0
- package/adapters/cursor/references/stage-exit-protocol.md +344 -140
- package/adapters/cursor/scripts/epic-manifest.py +413 -87
- package/adapters/cursor/scripts/forge-bootstrap.py +57 -5
- package/adapters/cursor/scripts/forge-session.py +3136 -139
- package/adapters/cursor/scripts/validate-traceability.py +86 -5
- package/adapters/cursor/skills/forge/forge.mdc +7 -6
- package/adapters/cursor/skills/forge/references/pipeline-state-schema.json +4 -2
- package/adapters/cursor/skills/forge/references/shared-conventions.md +63 -0
- package/adapters/cursor/skills/forge/references/stage-exit-protocol.md +344 -140
- package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +7 -2
- package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +24 -24
- package/adapters/cursor/skills/forge-0-epic/references/pipeline-state-schema.json +4 -2
- package/adapters/cursor/skills/forge-0-epic/references/shared-conventions.md +63 -0
- package/adapters/cursor/skills/forge-0-epic/references/stage-exit-protocol.md +344 -140
- package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +14 -3
- package/adapters/cursor/skills/forge-1-prd/references/shared-conventions.md +63 -0
- package/adapters/cursor/skills/forge-1-prd/references/stage-exit-protocol.md +344 -140
- package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +13 -3
- package/adapters/cursor/skills/forge-2-tech/references/shared-conventions.md +63 -0
- package/adapters/cursor/skills/forge-2-tech/references/stage-exit-protocol.md +344 -140
- package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +4 -2
- package/adapters/cursor/skills/forge-3-specs/references/shared-conventions.md +63 -0
- package/adapters/cursor/skills/forge-3-specs/references/spec-archetypes.md +9 -0
- package/adapters/cursor/skills/forge-3-specs/references/stage-exit-protocol.md +344 -140
- package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +16 -3
- package/adapters/cursor/skills/forge-4-backlog/references/shared-conventions.md +63 -0
- package/adapters/cursor/skills/forge-4-backlog/references/stage-exit-protocol.md +344 -140
- package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +40 -42
- package/adapters/cursor/skills/forge-5-loop/references/result-reporting.md +70 -31
- package/adapters/cursor/skills/forge-5-loop/references/runner-contract.md +4 -2
- package/adapters/cursor/skills/forge-5-loop/references/shared-conventions.md +63 -0
- package/adapters/cursor/skills/forge-5-loop/references/stage-exit-protocol.md +344 -140
- package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +48 -4
- package/adapters/cursor/skills/forge-6-docs/references/shared-conventions.md +63 -0
- package/adapters/cursor/skills/forge-6-docs/references/stage-exit-protocol.md +472 -0
- package/adapters/cursor/skills/forge-fix/forge-fix.mdc +84 -32
- package/adapters/cursor/skills/forge-fix/references/shared-conventions.md +63 -0
- package/adapters/cursor/skills/forge-fix/references/stage-exit-protocol.md +344 -140
- package/adapters/cursor/skills/forge-guide/references/forge-config-schema.json +1 -1
- package/adapters/cursor/skills/forge-guide/references/shared-conventions.md +63 -0
- package/adapters/cursor/skills/forge-verify/forge-verify.mdc +72 -40
- package/adapters/cursor/skills/forge-verify/references/findings-template.md +39 -49
- package/adapters/cursor/skills/forge-verify/references/shared-conventions.md +63 -0
- package/adapters/cursor/skills/forge-verify/references/stage-exit-protocol.md +472 -0
- package/adapters/cursor/skills/forge-verify/references/verification-checklists/epic.md +4 -3
- package/adapters/cursor/skills/forge-verify/references/verification-checklists/specs.md +2 -0
- package/adapters/gemini/.feature-forge-bundle.json +1 -1
- package/adapters/gemini/gemini-extension.json +1 -1
- package/adapters/gemini/references/epic-manifest-schema.json +6 -1
- package/adapters/gemini/references/forge-config-schema.json +1 -1
- package/adapters/gemini/references/pipeline-state-schema.json +4 -2
- package/adapters/gemini/references/shared-conventions.md +63 -0
- package/adapters/gemini/references/stage-exit-protocol.md +344 -140
- package/adapters/gemini/scripts/epic-manifest.py +413 -87
- package/adapters/gemini/scripts/forge-bootstrap.py +57 -5
- package/adapters/gemini/scripts/forge-session.py +3136 -139
- package/adapters/gemini/scripts/validate-traceability.py +86 -5
- package/adapters/gemini/skills/forge/forge.md +7 -6
- package/adapters/gemini/skills/forge/references/pipeline-state-schema.json +4 -2
- package/adapters/gemini/skills/forge/references/shared-conventions.md +63 -0
- package/adapters/gemini/skills/forge/references/stage-exit-protocol.md +344 -140
- package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +7 -2
- package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +24 -24
- package/adapters/gemini/skills/forge-0-epic/references/pipeline-state-schema.json +4 -2
- package/adapters/gemini/skills/forge-0-epic/references/shared-conventions.md +63 -0
- package/adapters/gemini/skills/forge-0-epic/references/stage-exit-protocol.md +344 -140
- package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +14 -3
- package/adapters/gemini/skills/forge-1-prd/references/shared-conventions.md +63 -0
- package/adapters/gemini/skills/forge-1-prd/references/stage-exit-protocol.md +344 -140
- package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +13 -3
- package/adapters/gemini/skills/forge-2-tech/references/shared-conventions.md +63 -0
- package/adapters/gemini/skills/forge-2-tech/references/stage-exit-protocol.md +344 -140
- package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +4 -2
- package/adapters/gemini/skills/forge-3-specs/references/shared-conventions.md +63 -0
- package/adapters/gemini/skills/forge-3-specs/references/spec-archetypes.md +9 -0
- package/adapters/gemini/skills/forge-3-specs/references/stage-exit-protocol.md +344 -140
- package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +16 -3
- package/adapters/gemini/skills/forge-4-backlog/references/shared-conventions.md +63 -0
- package/adapters/gemini/skills/forge-4-backlog/references/stage-exit-protocol.md +344 -140
- package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +40 -42
- package/adapters/gemini/skills/forge-5-loop/references/result-reporting.md +70 -31
- package/adapters/gemini/skills/forge-5-loop/references/runner-contract.md +4 -2
- package/adapters/gemini/skills/forge-5-loop/references/shared-conventions.md +63 -0
- package/adapters/gemini/skills/forge-5-loop/references/stage-exit-protocol.md +344 -140
- package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +48 -4
- package/adapters/gemini/skills/forge-6-docs/references/shared-conventions.md +63 -0
- package/adapters/gemini/skills/forge-6-docs/references/stage-exit-protocol.md +472 -0
- package/adapters/gemini/skills/forge-fix/forge-fix.md +84 -32
- package/adapters/gemini/skills/forge-fix/references/shared-conventions.md +63 -0
- package/adapters/gemini/skills/forge-fix/references/stage-exit-protocol.md +344 -140
- package/adapters/gemini/skills/forge-guide/references/forge-config-schema.json +1 -1
- package/adapters/gemini/skills/forge-guide/references/shared-conventions.md +63 -0
- package/adapters/gemini/skills/forge-verify/forge-verify.md +72 -40
- package/adapters/gemini/skills/forge-verify/references/findings-template.md +39 -49
- package/adapters/gemini/skills/forge-verify/references/shared-conventions.md +63 -0
- package/adapters/gemini/skills/forge-verify/references/stage-exit-protocol.md +472 -0
- package/adapters/gemini/skills/forge-verify/references/verification-checklists/epic.md +4 -3
- package/adapters/gemini/skills/forge-verify/references/verification-checklists/specs.md +2 -0
- package/adapters/pi/.feature-forge-bundle.json +1 -1
- package/adapters/pi/references/epic-manifest-schema.json +6 -1
- package/adapters/pi/references/forge-config-schema.json +1 -1
- package/adapters/pi/references/pipeline-state-schema.json +4 -2
- package/adapters/pi/references/shared-conventions.md +63 -0
- package/adapters/pi/references/stage-exit-protocol.md +344 -140
- package/adapters/pi/scripts/epic-manifest.py +413 -87
- package/adapters/pi/scripts/forge-bootstrap.py +57 -5
- package/adapters/pi/scripts/forge-session.py +3136 -139
- package/adapters/pi/scripts/validate-traceability.py +86 -5
- package/adapters/pi/skills/forge/SKILL.md +7 -6
- package/adapters/pi/skills/forge/references/pipeline-state-schema.json +4 -2
- package/adapters/pi/skills/forge/references/shared-conventions.md +63 -0
- package/adapters/pi/skills/forge/references/stage-exit-protocol.md +344 -140
- package/adapters/pi/skills/forge-0-epic/SKILL.md +7 -2
- package/adapters/pi/skills/forge-0-epic/references/edit-mode.md +24 -24
- package/adapters/pi/skills/forge-0-epic/references/pipeline-state-schema.json +4 -2
- package/adapters/pi/skills/forge-0-epic/references/shared-conventions.md +63 -0
- package/adapters/pi/skills/forge-0-epic/references/stage-exit-protocol.md +344 -140
- package/adapters/pi/skills/forge-1-prd/SKILL.md +14 -3
- package/adapters/pi/skills/forge-1-prd/references/shared-conventions.md +63 -0
- package/adapters/pi/skills/forge-1-prd/references/stage-exit-protocol.md +344 -140
- package/adapters/pi/skills/forge-2-tech/SKILL.md +13 -3
- package/adapters/pi/skills/forge-2-tech/references/shared-conventions.md +63 -0
- package/adapters/pi/skills/forge-2-tech/references/stage-exit-protocol.md +344 -140
- package/adapters/pi/skills/forge-3-specs/SKILL.md +4 -2
- package/adapters/pi/skills/forge-3-specs/references/shared-conventions.md +63 -0
- package/adapters/pi/skills/forge-3-specs/references/spec-archetypes.md +9 -0
- package/adapters/pi/skills/forge-3-specs/references/stage-exit-protocol.md +344 -140
- package/adapters/pi/skills/forge-4-backlog/SKILL.md +16 -3
- package/adapters/pi/skills/forge-4-backlog/references/shared-conventions.md +63 -0
- package/adapters/pi/skills/forge-4-backlog/references/stage-exit-protocol.md +344 -140
- package/adapters/pi/skills/forge-5-loop/SKILL.md +40 -42
- package/adapters/pi/skills/forge-5-loop/references/result-reporting.md +70 -31
- package/adapters/pi/skills/forge-5-loop/references/runner-contract.md +4 -2
- package/adapters/pi/skills/forge-5-loop/references/shared-conventions.md +63 -0
- package/adapters/pi/skills/forge-5-loop/references/stage-exit-protocol.md +344 -140
- package/adapters/pi/skills/forge-6-docs/SKILL.md +48 -4
- package/adapters/pi/skills/forge-6-docs/references/shared-conventions.md +63 -0
- package/adapters/pi/skills/forge-6-docs/references/stage-exit-protocol.md +472 -0
- package/adapters/pi/skills/forge-fix/SKILL.md +84 -32
- package/adapters/pi/skills/forge-fix/references/shared-conventions.md +63 -0
- package/adapters/pi/skills/forge-fix/references/stage-exit-protocol.md +344 -140
- package/adapters/pi/skills/forge-guide/references/forge-config-schema.json +1 -1
- package/adapters/pi/skills/forge-guide/references/shared-conventions.md +63 -0
- package/adapters/pi/skills/forge-verify/SKILL.md +72 -40
- package/adapters/pi/skills/forge-verify/references/findings-template.md +39 -49
- package/adapters/pi/skills/forge-verify/references/shared-conventions.md +63 -0
- package/adapters/pi/skills/forge-verify/references/stage-exit-protocol.md +472 -0
- package/adapters/pi/skills/forge-verify/references/verification-checklists/epic.md +4 -3
- package/adapters/pi/skills/forge-verify/references/verification-checklists/specs.md +2 -0
- package/package.json +1 -1
- package/adapters/claude/skills/forge-verify/references/pipeline-state-schema.json +0 -191
- package/adapters/codex/skills/forge-verify/references/pipeline-state-schema.json +0 -191
- package/adapters/copilot/skills/forge-verify/references/pipeline-state-schema.json +0 -191
- package/adapters/cursor/skills/forge-verify/references/pipeline-state-schema.json +0 -191
- package/adapters/gemini/skills/forge-verify/references/pipeline-state-schema.json +0 -191
- package/adapters/pi/skills/forge-verify/references/pipeline-state-schema.json +0 -191
|
@@ -13,8 +13,10 @@ root navigator:
|
|
|
13
13
|
[--config FILE] [--epic E] [--json]
|
|
14
14
|
python3 forge-session.py check-epic-base --feature F [--specs-dir DIR] \
|
|
15
15
|
[--config FILE] [--epic E] [--json]
|
|
16
|
-
python3 forge-session.py stage-exit --feature F --stage S [--
|
|
17
|
-
[--
|
|
16
|
+
python3 forge-session.py stage-exit --feature F --stage S [--owner direct|nested] \
|
|
17
|
+
[--outcome O] [--verify-mode M] [--served-stage S] \
|
|
18
|
+
[--verify-capability interactive|manual] [--specs-dir DIR] [--config FILE] \
|
|
19
|
+
[--epic E] [--next-feature N] [--host claude|generic|pi] [--json]
|
|
18
20
|
python3 forge-session.py effective-config [--config FILE] [--schema PATH] [--json]
|
|
19
21
|
|
|
20
22
|
Plus the `state-*` write verbs, which author `.pipeline-state.json` so no stage
|
|
@@ -36,6 +38,9 @@ has to hand-write the JSON (and therefore no stage has to read the state schema)
|
|
|
36
38
|
[--rationale R] [--target-stage S] [--specs-dir DIR] [--epic E] [--json]
|
|
37
39
|
python3 forge-session.py state-ecr --feature F --kind K --target T --rationale R \
|
|
38
40
|
--raised-by S --blocks-current true|false [--specs-dir DIR] [--epic E] [--json]
|
|
41
|
+
python3 forge-session.py state-verify --feature F --stage S [--status ST] \
|
|
42
|
+
[--findings-file P] [--findings-count N] [--verified-stage-version N] \
|
|
43
|
+
[--commit-hash H] [--specs-dir DIR] [--epic E] [--json]
|
|
39
44
|
|
|
40
45
|
`rank-features` scans the specs tree for feature-shaped directories (those that
|
|
41
46
|
directly contain a `.pipeline-state.json`, in both the flat
|
|
@@ -125,6 +130,22 @@ record `status: "open"` — resolving an item is the target stage's job, never t
|
|
|
125
130
|
recorder's — and both emit exactly the schema keys, because those two array item
|
|
126
131
|
shapes set `additionalProperties: false`.
|
|
127
132
|
|
|
133
|
+
`state-verify` is the eighth verb and the one that stops forge-verify/forge-fix
|
|
134
|
+
hand-authoring a `forge-verify-*` entry. It writes exactly one transition of the
|
|
135
|
+
verification matrix — `auto-verify-pending` (durable automatic-verify debt),
|
|
136
|
+
`passed`, `findings-reported`, `findings-applied`, or `skipped` — against the
|
|
137
|
+
`forge-verify-{token}` key the `--stage` selects, and touches nothing else in the
|
|
138
|
+
document. A terminal result DELETES the scheduling keys rather than nulling them,
|
|
139
|
+
and `findings-applied` deliberately drops `verifiedStageVersion`: fixes landed but
|
|
140
|
+
nothing has re-verified them, so freshness stays unresolved until a later `passed`
|
|
141
|
+
write. Its second mode, `--commit-hash`, is the Commit-2 provenance follow-up for
|
|
142
|
+
an entry that already exists: it changes only that entry's `commitHash`, and the
|
|
143
|
+
hash must be a full 40 hex characters — an abbreviation is rejected rather than
|
|
144
|
+
expanded, and no path amends a commit. Legacy short hashes already recorded in
|
|
145
|
+
state keep loading unmigrated; nothing constrains `commitHash` in the schema.
|
|
146
|
+
Unlike the other verbs its `--json` echo is the written entry plus the
|
|
147
|
+
resolved state path, not the whole document, so a caller never re-reads state.
|
|
148
|
+
|
|
128
149
|
3.10 baseline, Google-style docstrings, full type annotations, stdlib only —
|
|
129
150
|
matching the conventions of `scripts/epic-manifest.py`.
|
|
130
151
|
|
|
@@ -138,12 +159,13 @@ from __future__ import annotations
|
|
|
138
159
|
import argparse
|
|
139
160
|
import json
|
|
140
161
|
import os
|
|
162
|
+
import re
|
|
141
163
|
import subprocess
|
|
142
164
|
import sys
|
|
143
165
|
import tempfile
|
|
144
166
|
from datetime import datetime, timezone
|
|
145
167
|
from pathlib import Path
|
|
146
|
-
from typing import Callable, Final, TypedDict
|
|
168
|
+
from typing import Callable, Final, Literal, NoReturn, TypedDict, get_args
|
|
147
169
|
|
|
148
170
|
|
|
149
171
|
# --------------------------------------------------------------------------- #
|
|
@@ -154,6 +176,15 @@ from typing import Callable, Final, TypedDict
|
|
|
154
176
|
PIPELINE_STATE_FILENAME: Final = ".pipeline-state.json"
|
|
155
177
|
#: Epic roots hold this (and no .pipeline-state.json) — never a feature.
|
|
156
178
|
MANIFEST_FILENAME: Final = "epic-manifest.json"
|
|
179
|
+
#: Epic-scoped verification state, sibling to the manifest. NEVER a member's
|
|
180
|
+
#: .pipeline-state.json: epic verification is epic-scoped (REQ-SEC-01).
|
|
181
|
+
EPIC_STATE_FILENAME: Final = ".epic-state.json"
|
|
182
|
+
|
|
183
|
+
#: A safe bare name: one kebab-case token, no separator, no traversal. Same pattern
|
|
184
|
+
#: epic-manifest.py applies (the flat scripts share no import module), so the epic
|
|
185
|
+
#: target of a state write fails closed exactly where the canonical resolver does
|
|
186
|
+
#: (REQ-SEC-01).
|
|
187
|
+
SAFE_NAME_RE: Final = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
157
188
|
|
|
158
189
|
#: The ordered production stages. This is the ONE place stage order lives.
|
|
159
190
|
PRODUCTION_STAGES: Final[tuple[str, ...]] = (
|
|
@@ -203,6 +234,12 @@ VERIFY_TOKEN_BY_STAGE: Final[dict[str, str]] = {
|
|
|
203
234
|
"forge-5-loop": "impl",
|
|
204
235
|
}
|
|
205
236
|
|
|
237
|
+
#: The `--stage` domain for `state-verify`: forge-0-epic (whose verification lives
|
|
238
|
+
#: in the epic's own `.epic-state.json`) plus the five stages that carry a verify
|
|
239
|
+
#: token. forge-6-docs is excluded on purpose — it has no verification token, so
|
|
240
|
+
#: there is no `forge-verify-*` key for it to write.
|
|
241
|
+
VERIFY_STAGES: Final[tuple[str, ...]] = ("forge-0-epic", *VERIFY_TOKEN_BY_STAGE)
|
|
242
|
+
|
|
206
243
|
#: A production stage status that counts as "done" for next-stage selection.
|
|
207
244
|
_DONE_STATUS: Final = "complete"
|
|
208
245
|
#: The authoritative forge-verify status vocabulary. SOURCE OF TRUTH:
|
|
@@ -211,14 +248,178 @@ _DONE_STATUS: Final = "complete"
|
|
|
211
248
|
#: NOTE: epic-manifest.py keeps a byte-identical copy — flat, self-contained scripts have
|
|
212
249
|
#: no shared import module (each is copied verbatim into per-agent adapter bundles).
|
|
213
250
|
KNOWN_VERIFY_STATUSES: Final = frozenset(
|
|
214
|
-
{
|
|
251
|
+
{
|
|
252
|
+
"pending",
|
|
253
|
+
"auto-verify-pending",
|
|
254
|
+
"passed",
|
|
255
|
+
"findings-reported",
|
|
256
|
+
"findings-applied",
|
|
257
|
+
"skipped",
|
|
258
|
+
}
|
|
215
259
|
)
|
|
216
260
|
#: Verify statuses that count as "resolved" (no outstanding verify needed). A STRICT
|
|
217
261
|
#: subset of KNOWN_VERIFY_STATUSES — not collapsible into it (different meaning).
|
|
262
|
+
#: `auto-verify-pending` is deliberately ABSENT: owed-but-unrun debt is not resolved.
|
|
218
263
|
_VERIFY_RESOLVED: Final = frozenset({"passed", "findings-applied", "skipped"})
|
|
219
264
|
#: Per-process dedupe for the unknown-verify-status diagnostic (#148) so a single
|
|
220
265
|
#: bogus status is flagged once, not once per verify_state() call in a command.
|
|
221
266
|
_UNKNOWN_VERIFY_WARNED: set[str] = set()
|
|
267
|
+
#: Per-process dedupe for the auto-verify debt-metadata diagnostic, same reason.
|
|
268
|
+
_AUTO_VERIFY_DEBT_WARNED: set[str] = set()
|
|
269
|
+
#: The single normative sentence every read-side emitter uses for owed-but-unrun
|
|
270
|
+
#: automatic verification. One line naming the
|
|
271
|
+
#: subject, the served stage, and the retry command — never a state-file dump.
|
|
272
|
+
AUTO_PENDING_DIAGNOSTIC: Final = (
|
|
273
|
+
"{subject}: automatic verification is still pending for {stage}; "
|
|
274
|
+
"run {command} to resolve it."
|
|
275
|
+
)
|
|
276
|
+
#: The directive-facing form of the debt-metadata advisory (`warnings` entry 2).
|
|
277
|
+
#: The stderr twin lives in `_warn_auto_verify_debt_metadata`; this one also
|
|
278
|
+
#: names the subject and the host-translated retry command, because a `warnings` entry
|
|
279
|
+
#: must carry both the affected feature/stage/key AND the recovery action (REQ-OBS-02).
|
|
280
|
+
AUTO_VERIFY_DEBT_METADATA_DIAGNOSTIC: Final = (
|
|
281
|
+
"{subject}: {verify_key} is auto-verify-pending but its scheduledStageVersion "
|
|
282
|
+
"is missing or malformed (legacy or hand-edited state); the debt stays "
|
|
283
|
+
"outstanding — run {command} to resolve it and record a usable schedule."
|
|
284
|
+
)
|
|
285
|
+
#: The exact template for an `autoVerifyStages` key that names no
|
|
286
|
+
#: verify-capable stage. A typo there silently never takes effect, so the exit
|
|
287
|
+
#: says so — once per offending key, in sorted key order, on stderr, and
|
|
288
|
+
#: WITHOUT failing the exit: an ignored config key is an advisory, not a usage
|
|
289
|
+
#: error. `{valid}` is derived from `VERIFY_TOKEN_BY_STAGE` so the sentence cannot
|
|
290
|
+
#: drift from the domain it describes.
|
|
291
|
+
INVALID_AUTO_VERIFY_KEY_WARNING: Final = (
|
|
292
|
+
'Warning: autoVerifyStages key "{key}" names no verify-capable stage; it is '
|
|
293
|
+
"ignored. Valid keys are {valid}."
|
|
294
|
+
)
|
|
295
|
+
#: The exact template for an epic edit-mode member whose live pipeline state
|
|
296
|
+
#: cannot be resolved. It is `warnings` entry 1 and the router's
|
|
297
|
+
#: ONE tolerant new case: the exit degrades DOWN to `forge-1-prd <member>` rather
|
|
298
|
+
#: than fabricating progress it could not read (REQ-PROD-06). The trailing sentence
|
|
299
|
+
#: is what makes the warning name both the affected feature and the recovery action
|
|
300
|
+
#: (REQ-OBS-02); `{reason}` is one of `EPIC_MEMBER_FALLBACK_REASONS`.
|
|
301
|
+
EPIC_MEMBER_FALLBACK_WARNING: Final = (
|
|
302
|
+
"Warning: {member}: pipeline state could not be resolved under epic {epic} "
|
|
303
|
+
"({reason}); routing to forge-1-prd. Run /feature-forge:forge {member} to "
|
|
304
|
+
"inspect its state."
|
|
305
|
+
)
|
|
306
|
+
#: The closed reason domain for `EPIC_MEMBER_FALLBACK_WARNING`. No other
|
|
307
|
+
#: value may be substituted — `tests/test_stage_exit.py` asserts the literal.
|
|
308
|
+
EPIC_MEMBER_FALLBACK_REASONS: Final[tuple[str, ...]] = (
|
|
309
|
+
"missing",
|
|
310
|
+
"unreadable",
|
|
311
|
+
"malformed",
|
|
312
|
+
"not a member of this epic",
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# --------------------------------------------------------------------------- #
|
|
317
|
+
# Stage-exit and verification domains
|
|
318
|
+
#
|
|
319
|
+
# The `Literal` aliases below are the SINGLE place each domain is written. The
|
|
320
|
+
# `Final` constants underneath are DERIVED from them with `get_args`, never
|
|
321
|
+
# hand-listed: `ruff check` does not verify Literal conformance, so a hand-copied
|
|
322
|
+
# second list would drift silently — the failure this repository has already been
|
|
323
|
+
# bitten by twice (tests/test_stage_constants_parity.py,
|
|
324
|
+
# tests/test_agent_targets_parity.py). Deriving removes the second list entirely.
|
|
325
|
+
# --------------------------------------------------------------------------- #
|
|
326
|
+
|
|
327
|
+
#: The seven stages that produce a pipeline artifact. forge-0-epic participates in
|
|
328
|
+
#: exit and verify routing but not the member production walk (PRODUCTION_STAGES).
|
|
329
|
+
ProductionStage = Literal[
|
|
330
|
+
"forge-0-epic",
|
|
331
|
+
"forge-1-prd",
|
|
332
|
+
"forge-2-tech",
|
|
333
|
+
"forge-3-specs",
|
|
334
|
+
"forge-4-backlog",
|
|
335
|
+
"forge-5-loop",
|
|
336
|
+
"forge-6-docs",
|
|
337
|
+
]
|
|
338
|
+
#: Every skill that closes a stage through `stage-exit` — the seven production
|
|
339
|
+
#: stages plus the two branch skills.
|
|
340
|
+
ExitStage = Literal[
|
|
341
|
+
"forge-0-epic",
|
|
342
|
+
"forge-1-prd",
|
|
343
|
+
"forge-2-tech",
|
|
344
|
+
"forge-3-specs",
|
|
345
|
+
"forge-4-backlog",
|
|
346
|
+
"forge-5-loop",
|
|
347
|
+
"forge-6-docs",
|
|
348
|
+
"forge-verify",
|
|
349
|
+
"forge-fix",
|
|
350
|
+
]
|
|
351
|
+
#: forge-verify's mode, which selects the production stage a diversion served.
|
|
352
|
+
VerifyMode = Literal["epic", "prd", "tech", "specs", "backlog", "impl"]
|
|
353
|
+
#: Who prints the terminal block for a branch exit.
|
|
354
|
+
ExitOwner = Literal["direct", "nested"]
|
|
355
|
+
#: Whether the host may run an interactive verify gate + clean-room dispatch.
|
|
356
|
+
VerifyCapability = Literal["interactive", "manual"]
|
|
357
|
+
#: The navigator/stage-exit freshness label for an artifact's verification.
|
|
358
|
+
VerifyStateLabel = Literal[
|
|
359
|
+
"fresh", "stale", "failing", "never", "auto-pending", "skipped", "none"
|
|
360
|
+
]
|
|
361
|
+
#: The persisted verify-entry status vocabulary; mirrors KNOWN_VERIFY_STATUSES and
|
|
362
|
+
#: references/pipeline-state-schema.json's verifyEntry.status.enum.
|
|
363
|
+
VerifyStatus = Literal[
|
|
364
|
+
"pending",
|
|
365
|
+
"auto-verify-pending",
|
|
366
|
+
"passed",
|
|
367
|
+
"findings-reported",
|
|
368
|
+
"findings-applied",
|
|
369
|
+
"skipped",
|
|
370
|
+
]
|
|
371
|
+
#: Which gate form a stage exit asks the caller to render.
|
|
372
|
+
VerifyGate = Literal["none", "standard", "manual-print"]
|
|
373
|
+
|
|
374
|
+
LoopOutcome = Literal["complete", "partial", "blocked", "needs-human", "deferred"]
|
|
375
|
+
DocsOutcome = Literal["complete", "blocked"]
|
|
376
|
+
VerifyOutcome = Literal["passed", "findings", "skipped", "failed"]
|
|
377
|
+
FixOutcome = Literal[
|
|
378
|
+
"no-findings",
|
|
379
|
+
"decisions",
|
|
380
|
+
"failed",
|
|
381
|
+
"applied",
|
|
382
|
+
"reverified",
|
|
383
|
+
"reverify-findings",
|
|
384
|
+
"deferred",
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
#: Derived, never hand-listed — see the block comment above.
|
|
388
|
+
EXIT_STAGES: Final[tuple[str, ...]] = get_args(ExitStage)
|
|
389
|
+
#: The `state-verify --status` domain: every VerifyStatus a result write may record.
|
|
390
|
+
#: `pending` is excluded — it is the pre-existing generic/manual pending marker, not
|
|
391
|
+
#: a verification RESULT, and `auto-verify-pending` is the value that carries owed
|
|
392
|
+
#: automatic debt. Derived so the two lists cannot drift.
|
|
393
|
+
VERIFY_RESULT_STATUSES: Final[tuple[str, ...]] = tuple(
|
|
394
|
+
status for status in get_args(VerifyStatus) if status != "pending"
|
|
395
|
+
)
|
|
396
|
+
#: The stages whose exit carries a multi-way outcome, and each one's legal values.
|
|
397
|
+
#: Stages absent from this table take no `--outcome` at all.
|
|
398
|
+
EXIT_OUTCOMES: Final[dict[str, frozenset[str]]] = {
|
|
399
|
+
"forge-5-loop": frozenset(get_args(LoopOutcome)),
|
|
400
|
+
"forge-6-docs": frozenset(get_args(DocsOutcome)),
|
|
401
|
+
"forge-verify": frozenset(get_args(VerifyOutcome)),
|
|
402
|
+
"forge-fix": frozenset(get_args(FixOutcome)),
|
|
403
|
+
}
|
|
404
|
+
#: The one domain still written twice, because neither side is a subset of the
|
|
405
|
+
#: other: its keys MUST equal set(get_args(VerifyMode)) and its values MUST be a
|
|
406
|
+
#: subset of get_args(ProductionStage). tests/test_stage_constants_parity.py
|
|
407
|
+
#: asserts both. NOT collapsible into VERIFY_TOKEN_BY_STAGE's inverse — that map
|
|
408
|
+
#: has no `epic` mode and exists to name state keys, not to route stages.
|
|
409
|
+
VERIFY_MODE_TO_STAGE: Final[dict[str, str]] = {
|
|
410
|
+
"epic": "forge-0-epic",
|
|
411
|
+
"prd": "forge-1-prd",
|
|
412
|
+
"tech": "forge-2-tech",
|
|
413
|
+
"specs": "forge-3-specs",
|
|
414
|
+
"backlog": "forge-4-backlog",
|
|
415
|
+
"impl": "forge-5-loop",
|
|
416
|
+
}
|
|
417
|
+
#: The fixed final line of the NEXT-STEPS block. The stamp instructs the skill
|
|
418
|
+
#: to print the block verbatim as its absolute last output — nothing after this.
|
|
419
|
+
NEXT_STEPS_SENTINEL: Final = "─ forge: end of stage ─"
|
|
420
|
+
#: New non-null commit hashes are full 40-hex only. Loaded legacy short hashes stay
|
|
421
|
+
#: readable — this validates WRITES, and no schema constrains commitHash.
|
|
422
|
+
FULL_GIT_HASH_RE: Final = re.compile(r"[0-9a-fA-F]{40}")
|
|
222
423
|
|
|
223
424
|
#: Default context window when the model can't be inferred and config is silent.
|
|
224
425
|
_DEFAULT_WINDOW: Final = 200_000
|
|
@@ -253,6 +454,231 @@ class FeatureRow(TypedDict):
|
|
|
253
454
|
verifyGate: str
|
|
254
455
|
|
|
255
456
|
|
|
457
|
+
class EpicReconcile(TypedDict, total=False):
|
|
458
|
+
"""Existing epic backflow directive retained in expanded exits.
|
|
459
|
+
|
|
460
|
+
Present only for epic members; absent entirely for a standalone feature.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
# True when backflow must run before the member may advance; False when it is
|
|
464
|
+
# merely advisable. Drives whether the exit blocks or only mentions it.
|
|
465
|
+
required: bool
|
|
466
|
+
# True to surface the reminder text in the rendered block. Independent of
|
|
467
|
+
# `required`: a required reconcile with `reminder: False` still blocks silently
|
|
468
|
+
# in `--json` consumers.
|
|
469
|
+
reminder: bool
|
|
470
|
+
# Host-rendered command that performs the reconcile. Already passed through
|
|
471
|
+
# `_host_command`; consumers print it verbatim and never re-translate it.
|
|
472
|
+
command: str
|
|
473
|
+
# Number of member changes awaiting backflow. 0 is meaningful — it means
|
|
474
|
+
# reconcile was evaluated and found nothing, distinct from the key being absent
|
|
475
|
+
# because the feature is not an epic member.
|
|
476
|
+
count: int
|
|
477
|
+
# Canonical (untranslated) production command demoted behind a blocking
|
|
478
|
+
# reconcile — rendered as the unfenced "After reconciling, continue the
|
|
479
|
+
# pipeline with: …" line and passed through `_host_command` at render time.
|
|
480
|
+
# Present only when `required: True`; None/absent otherwise. It is a COMMAND,
|
|
481
|
+
# never a user-supplied reason: the live writer sets it to `next_command`
|
|
482
|
+
# (scripts/forge-session.py) and `_next_steps_block` translates it for the
|
|
483
|
+
# host. Repurposing it to carry prose would send free text through
|
|
484
|
+
# `_host_command` and strip the blocking follow-up line of its source
|
|
485
|
+
# (REQ-COMPAT-01).
|
|
486
|
+
deferred: str | None
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class StageExitDirectives(TypedDict, total=False):
|
|
490
|
+
"""Machine-readable decisions emitted by `stage_exit`.
|
|
491
|
+
|
|
492
|
+
`total=False` throughout: a key's ABSENCE means "not applicable to this exit",
|
|
493
|
+
which is never the same as a present-but-null value. `servedStage: None` says
|
|
494
|
+
the exit resolved no served stage; a missing `servedStage` says the concept does
|
|
495
|
+
not apply. Consumers must distinguish the two.
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
# The stage whose exit this is — always one of EXIT_STAGES. Always present.
|
|
499
|
+
stage: str
|
|
500
|
+
# Human-readable noun for this stage's artifact, used by
|
|
501
|
+
# references/stage-exit-protocol.md's "{stageNoun}" slots (the auto-verify
|
|
502
|
+
# heading and the "Verify {stageNoun} now" gate label). Always present;
|
|
503
|
+
# STAGE_NOUN.get(stage, stage), so it defaults to the stage id when unmapped.
|
|
504
|
+
# Pre-existing key, retained verbatim for REQ-COMPAT-01.
|
|
505
|
+
stageNoun: str
|
|
506
|
+
# For a verify/fix branch exit, the production stage the diversion served and
|
|
507
|
+
# rejoins. None on a production-stage exit, which serves only itself.
|
|
508
|
+
servedStage: str | None
|
|
509
|
+
# Verify mode in play (`prd`, `tech`, `specs`, `backlog`, `impl`, `epic`), keyed
|
|
510
|
+
# by VERIFY_MODE_TO_STAGE. None when this exit is not a verify/fix exit.
|
|
511
|
+
verifyMode: str | None
|
|
512
|
+
# Terminal outcome for stages with a multi-way result. Must be a member of
|
|
513
|
+
# EXIT_OUTCOMES[stage] — consult that table rather than this comment,
|
|
514
|
+
# which is deliberately not a second copy of the domain. None for stages
|
|
515
|
+
# whose exit has a single outcome.
|
|
516
|
+
outcome: str | None
|
|
517
|
+
# Branch ownership for a verify/fix exit — ExitOwner, i.e. exactly "direct"
|
|
518
|
+
# (this call owns and prints the terminal block) or "nested" (an outer
|
|
519
|
+
# authoring stage owns it). REQUIRED for forge-verify/forge-fix and REJECTED
|
|
520
|
+
# for stages 0–6, which are always direct owners.
|
|
521
|
+
# None only on a production-stage exit, where the concept does not apply.
|
|
522
|
+
owner: str | None
|
|
523
|
+
# Who prints the terminal block. "self" — this caller renders exactly one
|
|
524
|
+
# sentinel-terminated block. "outer" — a nested invocation that must print
|
|
525
|
+
# nothing terminal, leaving ownership with the outermost authoring stage.
|
|
526
|
+
terminalOwnedBy: Literal["self", "outer"]
|
|
527
|
+
# Feature (or epic) name this exit concerns. Always present.
|
|
528
|
+
feature: str
|
|
529
|
+
# Resolved host: "claude", "pi", or "generic". Selects command syntax and
|
|
530
|
+
# fresh-session wording; never inferred downstream, always decided here.
|
|
531
|
+
host: str
|
|
532
|
+
# Whether the host may dispatch a clean-room verifier subagent —
|
|
533
|
+
# VerifyCapability, i.e. exactly "interactive" or "manual". A manual host
|
|
534
|
+
# receives verify-first ordering with copy-paste commands instead of an
|
|
535
|
+
# interactive gate; capable Pi is interactive, not manual (REQ-EXIT-07).
|
|
536
|
+
# "May", not "has the tool": a session that bars unsolicited dispatch but
|
|
537
|
+
# offers a question tool is interactive, since the gate's prompt makes the
|
|
538
|
+
# dispatch solicited. Only no-question-tool-and-no-dispatch is manual.
|
|
539
|
+
verifyCapability: str
|
|
540
|
+
# Current verification state of the served artifact, as classified by
|
|
541
|
+
# `verify_state` — including "auto-pending" for unrun scheduled verification.
|
|
542
|
+
verifyState: str
|
|
543
|
+
# Production stage the outstanding/owed verification belongs to — the value
|
|
544
|
+
# `pending_verify()` returns; mirrors FeatureRow.verifyStage so navigator rows
|
|
545
|
+
# and stage-exit JSON report the same thing. None when nothing is outstanding.
|
|
546
|
+
# DISTINCT from `servedStage`, which is branch-exit-only: on a production-stage
|
|
547
|
+
# exit `servedStage` is None while `verifyStage` names the stage the debt is
|
|
548
|
+
# owed on (REQ-OBS-01, REQ-DEBT-05).
|
|
549
|
+
verifyStage: str | None
|
|
550
|
+
# Which gate form to render, derived from verifyState and verifyCapability.
|
|
551
|
+
verifyGate: str
|
|
552
|
+
# Host-rendered verify command. Present whenever verification is reachable,
|
|
553
|
+
# even if it is not the primary action.
|
|
554
|
+
verifyCommand: str
|
|
555
|
+
# True when the caller must run in-stage verification before returning control.
|
|
556
|
+
# When True, the auto-verify-pending debt write has already been attempted —
|
|
557
|
+
# see `autoVerifyDebtRecorded` for whether it landed.
|
|
558
|
+
runInStageVerify: bool
|
|
559
|
+
# Effective autoVerify for THIS stage after applying autoVerifyStages overrides
|
|
560
|
+
# over the autoVerify default. Not the raw config value.
|
|
561
|
+
autoVerifyEffective: bool
|
|
562
|
+
# True whenever `runInStageVerify` is True — the scheduling boundary
|
|
563
|
+
# persists the auto-verify-pending marker BEFORE this payload exists, and a
|
|
564
|
+
# failed debt write raises UsageError with no payload at all. So
|
|
565
|
+
# `runInStageVerify: True` with `autoVerifyDebtRecorded: False` is UNREACHABLE;
|
|
566
|
+
# the field is carried so tests and downstream tools can assert that invariant
|
|
567
|
+
# rather than infer it. False with `runInStageVerify: False` simply means no
|
|
568
|
+
# debt was owed (REQ-DEBT-01/04, REQ-REL-02).
|
|
569
|
+
autoVerifyDebtRecorded: bool
|
|
570
|
+
# True when an autoFix chain may run unattended: autoFix configured, zero
|
|
571
|
+
# unresolved decision points, and a clean tree at the pre-scheduling snapshot.
|
|
572
|
+
autoFixEligible: bool
|
|
573
|
+
# Next production stage in pipeline order, or None at the end of the pipeline.
|
|
574
|
+
# Routing introspection only — never promote it over `primaryCommand`.
|
|
575
|
+
nextStage: str | None
|
|
576
|
+
# Host-rendered command for `nextStage`. Retained for compatibility; see the
|
|
577
|
+
# promotion rule below. None when `nextStage` is None.
|
|
578
|
+
nextCommand: str | None
|
|
579
|
+
# THE authoritative single action. While verification is unresolved this is the
|
|
580
|
+
# verify command — or the forge-fix command when a findings report is live at
|
|
581
|
+
# the current revision — never the downstream stage. The one fenced command in
|
|
582
|
+
# the rendered block. None only when the pipeline has no further action.
|
|
583
|
+
primaryCommand: str | None
|
|
584
|
+
# Post-verification guidance shown as prose, never fenced, so it cannot be
|
|
585
|
+
# mistaken for the primary action. None when there is nothing deferred.
|
|
586
|
+
deferredCommand: str | None
|
|
587
|
+
# Keys in autoVerifyStages that name no verify-capable stage — a config typo.
|
|
588
|
+
# Empty list means the config was checked and clean; the key is always present
|
|
589
|
+
# when config was read at all, so [] and absent differ. Each key renders as
|
|
590
|
+
# exactly:
|
|
591
|
+
# Warning: autoVerifyStages key "{key}" names no verify-capable stage; it is
|
|
592
|
+
# ignored. Valid keys are forge-1-prd, forge-2-tech, forge-3-specs,
|
|
593
|
+
# forge-4-backlog, forge-5-loop.
|
|
594
|
+
# Keys are rendered in sorted order, per the determinism rule
|
|
595
|
+
# (REQ-OBS-02, REQ-REL-01).
|
|
596
|
+
invalidAutoVerifyKeys: list[str]
|
|
597
|
+
# Whether the working directory is a git repository at all.
|
|
598
|
+
gitRepo: bool
|
|
599
|
+
# Clean-tree snapshot taken BEFORE the pending-debt write, so the sanctioned
|
|
600
|
+
# state mutation does not dirty its own precondition. None when `gitRepo` is
|
|
601
|
+
# False — unknown, not clean.
|
|
602
|
+
cleanTree: bool | None
|
|
603
|
+
# Human-readable non-fatal advisories, in a fixed deterministic order:
|
|
604
|
+
# (1) the epic-member unreadable-state fallback, (2) the legacy/malformed
|
|
605
|
+
# scheduledStageVersion metadata warning, (3) the scheduled-vs-current
|
|
606
|
+
# revision mismatch note. A LIST,
|
|
607
|
+
# not a string, because these are independently triggerable and can co-occur
|
|
608
|
+
# on one call; a single string would force an implementer to drop or
|
|
609
|
+
# concatenate them, and REQ-REL-01's byte-identical-output requirement needs a
|
|
610
|
+
# defined order to assert against. Mirrors RenderStatus.warnings,
|
|
611
|
+
# which is already a list. Empty list means checked and clean; the key is
|
|
612
|
+
# always present, so [] and absent differ. Each entry names its affected
|
|
613
|
+
# feature/stage/key AND the recovery action (REQ-OBS-02).
|
|
614
|
+
warnings: list[str]
|
|
615
|
+
# Epic backflow directive; see EpicReconcile. Absent for standalone features.
|
|
616
|
+
epicReconcile: EpicReconcile
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
class StageExitPayload(TypedDict):
|
|
620
|
+
"""Serialized direct or nested exit result.
|
|
621
|
+
|
|
622
|
+
Total (not `total=False`): all three keys are always present, and a nested
|
|
623
|
+
exit carries explicit nulls rather than omitting them.
|
|
624
|
+
"""
|
|
625
|
+
|
|
626
|
+
# Always populated, for both direct and nested exits.
|
|
627
|
+
directives: StageExitDirectives
|
|
628
|
+
# The rendered terminal block for a direct owner. MUST be None when
|
|
629
|
+
# `terminalOwnedBy == "outer"` — a nested caller has nothing to print.
|
|
630
|
+
nextSteps: str | None
|
|
631
|
+
# NEXT_STEPS_SENTINEL when this payload owns the terminal block, else None.
|
|
632
|
+
# When non-None, `nextSteps` ends with exactly this string and nothing follows
|
|
633
|
+
# it (REQ-EXIT-03). Carried explicitly so a consumer can verify termination
|
|
634
|
+
# without importing the constant.
|
|
635
|
+
sentinel: str | None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
class VerifyEntry(TypedDict, total=False):
|
|
639
|
+
"""Feature or epic verification state persisted by `state-verify`.
|
|
640
|
+
|
|
641
|
+
`total=False` is load-bearing: terminal writes DELETE the scheduling keys rather
|
|
642
|
+
than nulling them, so an absent `scheduledAt` means "not scheduled"
|
|
643
|
+
while a present-but-null one would be a malformed entry. Legacy entries written
|
|
644
|
+
before this feature simply lack the newer keys and load unmigrated
|
|
645
|
+
(REQ-DEBT-06).
|
|
646
|
+
"""
|
|
647
|
+
|
|
648
|
+
# The entry's state. Always present on a written entry; a wholly absent entry
|
|
649
|
+
# means never verified, which is distinct from every value here.
|
|
650
|
+
status: VerifyStatus
|
|
651
|
+
# Path to the findings document, relative to the feature directory. Non-empty
|
|
652
|
+
# for `findings-reported`/`findings-applied`; absent otherwise.
|
|
653
|
+
findingsFile: str | None
|
|
654
|
+
# Findings count. 0 is legal and meaningful for `findings-reported` — verified
|
|
655
|
+
# with nothing found — and is not the same as the key being absent.
|
|
656
|
+
findingsCount: int | None
|
|
657
|
+
# UTC ISO-8601 timestamp of the terminal verification result. Absent while
|
|
658
|
+
# scheduling is pending.
|
|
659
|
+
verifiedAt: str | None
|
|
660
|
+
# UTC ISO-8601 timestamp set by `findings-applied`. Its presence alongside a
|
|
661
|
+
# deleted `verifiedStageVersion` is exactly what marks fixes-landed-but-
|
|
662
|
+
# unconfirmed.
|
|
663
|
+
fixedAt: str | None
|
|
664
|
+
# Full 40-character hash of the artifact commit for this entry, or null between
|
|
665
|
+
# commit 1 and commit 2 of the two-commit protocol. Never a short hash on a new
|
|
666
|
+
# write; legacy short hashes still READ (REQ-STATE-01/02).
|
|
667
|
+
commitHash: str | None
|
|
668
|
+
# Artifact revision this result verified — the production stage's `version` for
|
|
669
|
+
# a feature, the manifest `revision` for an epic. Deleted by `findings-applied`
|
|
670
|
+
# on purpose, so freshness stays unresolved until a later `passed` write.
|
|
671
|
+
verifiedStageVersion: int | None
|
|
672
|
+
# UTC ISO-8601 timestamp of the auto-verify schedule. Deleted (not nulled) by
|
|
673
|
+
# any terminal result.
|
|
674
|
+
scheduledAt: str | None
|
|
675
|
+
# Artifact revision current when verification was scheduled. Makes rescheduling
|
|
676
|
+
# idempotent — an identical revision does not rewrite the entry (REQ-REL-01) —
|
|
677
|
+
# and lets a read distinguish debt owed on the current artifact from debt
|
|
678
|
+
# stranded on an older one. Deleted by any terminal result.
|
|
679
|
+
scheduledStageVersion: int | None
|
|
680
|
+
|
|
681
|
+
|
|
256
682
|
class UsageError(Exception):
|
|
257
683
|
"""A usage or I/O failure that must exit 2."""
|
|
258
684
|
|
|
@@ -376,18 +802,100 @@ def _warn_unknown_verify_status(stage_name: str, status: object) -> None:
|
|
|
376
802
|
)
|
|
377
803
|
|
|
378
804
|
|
|
805
|
+
def _scheduled_stage_version(entry: dict) -> int | None:
|
|
806
|
+
"""Return an ``auto-verify-pending`` entry's usable ``scheduledStageVersion``.
|
|
807
|
+
|
|
808
|
+
``None`` when the field is absent, a bool, a non-integer, or below 1 — i.e.
|
|
809
|
+
legacy state written before the scheduling fields existed, or hand-edited
|
|
810
|
+
state. The caller stays ``auto-pending`` either way: unusable metadata is a
|
|
811
|
+
reason to warn, never a reason to forget the debt.
|
|
812
|
+
"""
|
|
813
|
+
version = entry.get("scheduledStageVersion")
|
|
814
|
+
if isinstance(version, bool) or not isinstance(version, int) or version < 1:
|
|
815
|
+
return None
|
|
816
|
+
return version
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _warn_auto_verify_debt_metadata(verify_key: str) -> None:
|
|
820
|
+
"""Flag an ``auto-verify-pending`` entry whose scheduled revision is unusable.
|
|
821
|
+
|
|
822
|
+
Without a recorded revision the debt cannot be compared against the current
|
|
823
|
+
artifact, so it can be neither discharged as fresh nor described as advanced.
|
|
824
|
+
It REMAINS outstanding — the alternative (degrading to ``never``) is exactly
|
|
825
|
+
the conflation REQ-DEBT-02 forbids — but the operator needs to know why the
|
|
826
|
+
row carries no revision detail, so say it once per process.
|
|
827
|
+
"""
|
|
828
|
+
if verify_key in _AUTO_VERIFY_DEBT_WARNED:
|
|
829
|
+
return
|
|
830
|
+
_AUTO_VERIFY_DEBT_WARNED.add(verify_key)
|
|
831
|
+
print(
|
|
832
|
+
f"feature-forge: {verify_key} is auto-verify-pending but its "
|
|
833
|
+
"scheduledStageVersion is missing or malformed (legacy or hand-edited "
|
|
834
|
+
"state); the debt stays outstanding — re-run forge-verify to resolve it "
|
|
835
|
+
"and record a usable schedule",
|
|
836
|
+
file=sys.stderr,
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def auto_pending_message(
|
|
841
|
+
subject: str,
|
|
842
|
+
stage: str,
|
|
843
|
+
command: str,
|
|
844
|
+
scheduled_version: int | None = None,
|
|
845
|
+
current_version: int | None = None,
|
|
846
|
+
) -> str:
|
|
847
|
+
"""Render the diagnostic for owed-but-unrun automatic verification.
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
subject: The feature or epic the debt belongs to.
|
|
851
|
+
stage: The served production stage the debt is owed on.
|
|
852
|
+
command: The host-translated forge-verify retry command.
|
|
853
|
+
scheduled_version: Revision the debt was recorded against, if usable.
|
|
854
|
+
current_version: The artifact's current revision, if known.
|
|
855
|
+
|
|
856
|
+
Returns:
|
|
857
|
+
One sentence, with both revision numbers appended when the recorded
|
|
858
|
+
schedule predates the current artifact. Never a state-file dump.
|
|
859
|
+
"""
|
|
860
|
+
message = AUTO_PENDING_DIAGNOSTIC.format(
|
|
861
|
+
subject=subject, stage=stage, command=command
|
|
862
|
+
)
|
|
863
|
+
if (
|
|
864
|
+
scheduled_version is not None
|
|
865
|
+
and current_version is not None
|
|
866
|
+
and scheduled_version != current_version
|
|
867
|
+
):
|
|
868
|
+
message += (
|
|
869
|
+
f" The artifact has advanced since it was scheduled "
|
|
870
|
+
f"(scheduled at revision {scheduled_version}, now at revision "
|
|
871
|
+
f"{current_version})."
|
|
872
|
+
)
|
|
873
|
+
return message
|
|
874
|
+
|
|
875
|
+
|
|
379
876
|
def verify_state(state: dict) -> tuple[str | None, str]:
|
|
380
877
|
"""Classify verify freshness for the most-recently-completed stage.
|
|
381
878
|
|
|
382
879
|
Returns ``(stage, state_label)`` where ``state_label`` is one of:
|
|
383
880
|
|
|
384
|
-
- ``fresh`` —
|
|
385
|
-
stage's current ``version`` (so no re-verify is needed).
|
|
881
|
+
- ``fresh`` — the entry is ``passed`` AND its ``verifiedStageVersion`` matches
|
|
882
|
+
the stage's current ``version`` (so no re-verify is needed). ``passed`` is the
|
|
883
|
+
ONLY status that reaches ``fresh``: ``findings-applied`` and ``skipped`` are
|
|
884
|
+
resolved but never fresh, for the reasons given below.
|
|
386
885
|
- ``stale`` — verify was resolved once, but the stage version has since moved
|
|
387
886
|
(artifact revised) OR the entry predates the freshness ledger (no
|
|
388
|
-
``verifiedStageVersion``)
|
|
887
|
+
``verifiedStageVersion``), OR the entry is ``findings-applied``, which never
|
|
888
|
+
classifies ``fresh`` regardless of any version it carries (§4.2 step 4).
|
|
889
|
+
A revised artifact must be re-verified.
|
|
389
890
|
- ``failing`` — verify ran and reported findings that are not yet applied
|
|
390
891
|
(``findings-reported``).
|
|
892
|
+
- ``auto-pending`` — effective configuration scheduled unattended in-stage
|
|
893
|
+
verification and nothing has discharged it: the obligation is RECORDED and
|
|
894
|
+
owed. Deliberately distinct from ``never`` (nobody ever asked for it), from
|
|
895
|
+
manual ``pending`` work, and from every resolved label — a dropped
|
|
896
|
+
``runInStageVerify`` directive is precisely what this makes visible (#163,
|
|
897
|
+
REQ-DEBT-02). Classified BEFORE the generic unresolved handling below, and
|
|
898
|
+
never downgraded when its scheduling metadata is missing or malformed.
|
|
391
899
|
- ``never`` — the stage completed but verify has not run at all.
|
|
392
900
|
- ``skipped`` — the user explicitly chose to proceed without verifying. A
|
|
393
901
|
resolved, non-pending state: it is deliberately NOT re-offered or
|
|
@@ -398,9 +906,10 @@ def verify_state(state: dict) -> tuple[str | None, str]:
|
|
|
398
906
|
is ``None``.
|
|
399
907
|
|
|
400
908
|
Only the most-recent completed production stage is considered, matching the
|
|
401
|
-
navigator's "verify before continuing" gate.
|
|
402
|
-
|
|
403
|
-
|
|
909
|
+
navigator's "verify before continuing" gate. A ``findings-applied`` entry is
|
|
910
|
+
treated as ``stale`` UNCONDITIONALLY — applying fixes is not verifying them —
|
|
911
|
+
and an absent ``verifiedStageVersion`` on a ``passed`` entry (legacy state) is
|
|
912
|
+
likewise ``stale``: verify rather than skip.
|
|
404
913
|
"""
|
|
405
914
|
for stage in reversed(PRODUCTION_STAGES):
|
|
406
915
|
if _stage_status(state, stage) != _DONE_STATUS:
|
|
@@ -410,11 +919,26 @@ def verify_state(state: dict) -> tuple[str | None, str]:
|
|
|
410
919
|
continue # forge-6-docs has no verify step
|
|
411
920
|
entry = _verify_entry(state, f"forge-verify-{token}")
|
|
412
921
|
status = entry.get("status")
|
|
922
|
+
if status is not None and not isinstance(status, str):
|
|
923
|
+
# A torn or hand-edited entry can carry any JSON type here; an
|
|
924
|
+
# unhashable one would raise TypeError at the frozenset membership
|
|
925
|
+
# below, crashing the navigator on one bad file. Same answer as an
|
|
926
|
+
# absent entry — and the same #148 diagnostic as an unknown string,
|
|
927
|
+
# so the degradation is never silent.
|
|
928
|
+
_warn_unknown_verify_status(f"forge-verify-{token}", status)
|
|
929
|
+
return stage, "never"
|
|
413
930
|
if status == "skipped":
|
|
414
931
|
# An explicit skip is resolved and non-pending — preserve the user's
|
|
415
932
|
# decision. It never goes stale (no recorded version to compare), so
|
|
416
933
|
# the freshness check below deliberately does not apply.
|
|
417
934
|
return stage, "skipped"
|
|
935
|
+
if status == "auto-verify-pending":
|
|
936
|
+
# Ordered ahead of the generic unresolved branch so recorded debt can
|
|
937
|
+
# never fall through to "never". Unusable metadata warns and stays
|
|
938
|
+
# owed; a superseded revision stays owed too.
|
|
939
|
+
if _scheduled_stage_version(entry) is None:
|
|
940
|
+
_warn_auto_verify_debt_metadata(f"forge-verify-{token}")
|
|
941
|
+
return stage, "auto-pending"
|
|
418
942
|
if status not in _VERIFY_RESOLVED:
|
|
419
943
|
if status == "findings-reported":
|
|
420
944
|
return stage, "failing"
|
|
@@ -425,6 +949,15 @@ def verify_state(state: dict) -> tuple[str | None, str]:
|
|
|
425
949
|
if status is not None and status not in KNOWN_VERIFY_STATUSES:
|
|
426
950
|
_warn_unknown_verify_status(f"forge-verify-{token}", status)
|
|
427
951
|
return stage, "never"
|
|
952
|
+
if status == "findings-applied":
|
|
953
|
+
# Applying fixes is not verifying them: §4.2 step 4 says `findings-applied`
|
|
954
|
+
# CLEARS freshness, and only a later `passed` restores it. The writer builds
|
|
955
|
+
# the entry without `verifiedStageVersion`, but the read side may not rely on
|
|
956
|
+
# that — REQ-DEBT-06 requires loading legacy state without migration, and a
|
|
957
|
+
# pre-writer entry can still carry the key. Without this guard such an entry
|
|
958
|
+
# reads `fresh`, `pending_verify` returns None, and the verification debt for
|
|
959
|
+
# a fixed-but-never-re-verified stage disappears silently.
|
|
960
|
+
return stage, "stale"
|
|
428
961
|
verified_version = entry.get("verifiedStageVersion")
|
|
429
962
|
stage_version = _stage_version(state, stage)
|
|
430
963
|
if (
|
|
@@ -441,8 +974,11 @@ def pending_verify(state: dict) -> str | None:
|
|
|
441
974
|
"""Return the production stage whose verify is outstanding, if any.
|
|
442
975
|
|
|
443
976
|
Outstanding means the most-recently-completed production stage's verify is not
|
|
444
|
-
``fresh`` (never run,
|
|
445
|
-
revision). An
|
|
977
|
+
``fresh`` (never run, scheduled-but-unrun automatic verification, reported
|
|
978
|
+
findings, or gone stale after an artifact revision). An ``auto-pending`` stage
|
|
979
|
+
is returned like any other outstanding one — recorded debt is owed work, and
|
|
980
|
+
``_VERIFY_RESOLVED`` deliberately excludes it.
|
|
981
|
+
An explicit ``skipped`` is treated as resolved (never outstanding).
|
|
446
982
|
Surfaced so the navigator can offer "verify before continuing" as an
|
|
447
983
|
alternative to advancing. Returns ``None`` when the latest stage is fresh,
|
|
448
984
|
skipped, or there is nothing to verify.
|
|
@@ -474,6 +1010,13 @@ def build_rows(specs_dir: Path, config: dict | None = None) -> list[FeatureRow]:
|
|
|
474
1010
|
``config`` is the loaded forge.config.json (or ``{}``); it drives the effective
|
|
475
1011
|
``autoVerify``/``autoFix`` per stage so the navigator can branch without
|
|
476
1012
|
re-reading config.
|
|
1013
|
+
|
|
1014
|
+
A row whose verify classifies ``auto-pending`` carries recorded-but-undischarged
|
|
1015
|
+
automatic verification: ``verifyPending`` is True, ``verifyState`` is
|
|
1016
|
+
``auto-pending``, and ``verifyCommand`` is non-null, so no consumer can read it
|
|
1017
|
+
as verification-complete. The named sentence goes to stderr (this is the
|
|
1018
|
+
one emitter that knows the feature name); stdout keeps the three stable JSON
|
|
1019
|
+
keys — ``verifyState``, ``verifyStage``, ``verifyCommand`` — and no prose.
|
|
477
1020
|
"""
|
|
478
1021
|
config = config or {}
|
|
479
1022
|
# Fail closed: only a literal JSON ``true`` enables artifact-mutating autoFix.
|
|
@@ -487,6 +1030,20 @@ def build_rows(specs_dir: Path, config: dict | None = None) -> list[FeatureRow]:
|
|
|
487
1030
|
vstage, vlabel = verify_state(state)
|
|
488
1031
|
verify_pending = vstage is not None and vlabel not in ("fresh", "none", "skipped")
|
|
489
1032
|
effective_auto_verify = auto_verify_for(config, vstage) if vstage else False
|
|
1033
|
+
verify_command = f"/feature-forge:forge-verify {name}" if verify_pending else None
|
|
1034
|
+
if vlabel == "auto-pending" and vstage is not None and verify_command:
|
|
1035
|
+
token = VERIFY_TOKEN_BY_STAGE.get(vstage)
|
|
1036
|
+
entry = _verify_entry(state, f"forge-verify-{token}") if token else {}
|
|
1037
|
+
print(
|
|
1038
|
+
auto_pending_message(
|
|
1039
|
+
name,
|
|
1040
|
+
vstage,
|
|
1041
|
+
verify_command,
|
|
1042
|
+
_scheduled_stage_version(entry),
|
|
1043
|
+
_stage_version(state, vstage),
|
|
1044
|
+
),
|
|
1045
|
+
file=sys.stderr,
|
|
1046
|
+
)
|
|
490
1047
|
branch = state.get("branch")
|
|
491
1048
|
updated = state.get("updatedAt")
|
|
492
1049
|
rows.append({
|
|
@@ -502,7 +1059,7 @@ def build_rows(specs_dir: Path, config: dict | None = None) -> list[FeatureRow]:
|
|
|
502
1059
|
"nextStage": nxt,
|
|
503
1060
|
"nextCommand": f"/feature-forge:{nxt} {name}" if nxt else None,
|
|
504
1061
|
"verifyPending": verify_pending,
|
|
505
|
-
"verifyCommand":
|
|
1062
|
+
"verifyCommand": verify_command,
|
|
506
1063
|
"verifyStage": vstage,
|
|
507
1064
|
"verifyState": vlabel,
|
|
508
1065
|
"autoVerify": effective_auto_verify,
|
|
@@ -611,17 +1168,67 @@ def _infer_window(model: str | None) -> int:
|
|
|
611
1168
|
return _DEFAULT_WINDOW
|
|
612
1169
|
|
|
613
1170
|
|
|
614
|
-
|
|
615
|
-
|
|
1171
|
+
#: mirrors ``load_json_with_duplicates``/``warn_duplicate_keys`` in scripts/forge-bootstrap.py
|
|
1172
|
+
def load_json_with_duplicates(path: Path) -> tuple[object, list[str]]:
|
|
1173
|
+
"""Load JSON with last-key-wins values and ordered duplicate key names.
|
|
1174
|
+
|
|
1175
|
+
Args:
|
|
1176
|
+
path: UTF-8 JSON file to read.
|
|
1177
|
+
|
|
1178
|
+
Returns:
|
|
1179
|
+
The parsed JSON value and duplicate key names in deterministic decoder-hook
|
|
1180
|
+
order. A repeated occurrence is appended whenever its key was already seen
|
|
1181
|
+
in that same object. Objects at every nesting depth use the hook.
|
|
1182
|
+
|
|
1183
|
+
Raises:
|
|
1184
|
+
OSError: The path cannot be read as UTF-8 text.
|
|
1185
|
+
json.JSONDecodeError: The file is not valid JSON.
|
|
1186
|
+
"""
|
|
1187
|
+
duplicate_keys: list[str] = []
|
|
1188
|
+
|
|
1189
|
+
def object_from_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
1190
|
+
result: dict[str, object] = {}
|
|
1191
|
+
for key, value in pairs:
|
|
1192
|
+
if key in result:
|
|
1193
|
+
duplicate_keys.append(key)
|
|
1194
|
+
result[key] = value
|
|
1195
|
+
return result
|
|
1196
|
+
|
|
1197
|
+
text = path.read_text(encoding="utf-8")
|
|
1198
|
+
value = json.loads(text, object_pairs_hook=object_from_pairs)
|
|
1199
|
+
return value, duplicate_keys
|
|
1200
|
+
|
|
616
1201
|
|
|
617
|
-
|
|
618
|
-
|
|
1202
|
+
def warn_duplicate_keys(path: Path, duplicate_keys: list[str]) -> None:
|
|
1203
|
+
"""Write one deterministic warning for each reported duplicate occurrence.
|
|
1204
|
+
|
|
1205
|
+
Args:
|
|
1206
|
+
path: Source file whose duplicate key was accepted.
|
|
1207
|
+
duplicate_keys: Ordered names returned by `load_json_with_duplicates`.
|
|
1208
|
+
|
|
1209
|
+
Raises:
|
|
1210
|
+
OSError: The process cannot write to stderr.
|
|
619
1211
|
"""
|
|
1212
|
+
for key in duplicate_keys:
|
|
1213
|
+
rendered_key = json.dumps(key, ensure_ascii=False)
|
|
1214
|
+
print(
|
|
1215
|
+
f"Warning: duplicate JSON key {rendered_key} in {path}; "
|
|
1216
|
+
"using the last value.",
|
|
1217
|
+
file=sys.stderr,
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _load_config(config_path: Path) -> dict:
|
|
1222
|
+
"""Read config into a dict, warning on duplicates and tolerating bad input."""
|
|
620
1223
|
try:
|
|
621
|
-
|
|
1224
|
+
value, duplicate_keys = load_json_with_duplicates(config_path)
|
|
622
1225
|
except (OSError, json.JSONDecodeError):
|
|
623
1226
|
return {}
|
|
624
|
-
|
|
1227
|
+
try:
|
|
1228
|
+
warn_duplicate_keys(config_path, duplicate_keys)
|
|
1229
|
+
except OSError:
|
|
1230
|
+
pass # a diagnostic write failure must not break a total read path
|
|
1231
|
+
return value if isinstance(value, dict) else {}
|
|
625
1232
|
|
|
626
1233
|
|
|
627
1234
|
def _config_value(config_path: Path, key: str):
|
|
@@ -653,11 +1260,15 @@ def invalid_auto_verify_keys(config: dict) -> list[str]:
|
|
|
653
1260
|
An unknown/typo key (e.g. ``forge-1-prod``) would silently never take effect,
|
|
654
1261
|
turning an intended off-switch into a no-op. Surfacing it lets the navigator
|
|
655
1262
|
warn instead of failing quietly. Mirrors the schema's ``propertyNames.enum``.
|
|
1263
|
+
|
|
1264
|
+
Sorted, not insertion-ordered: every diagnostic list must be
|
|
1265
|
+
sorted before rendering, so two configs that differ only in key order produce
|
|
1266
|
+
byte-identical output.
|
|
656
1267
|
"""
|
|
657
1268
|
stages = config.get("autoVerifyStages")
|
|
658
1269
|
if not isinstance(stages, dict):
|
|
659
1270
|
return []
|
|
660
|
-
return
|
|
1271
|
+
return sorted(key for key in stages if key not in VERIFY_TOKEN_BY_STAGE)
|
|
661
1272
|
|
|
662
1273
|
|
|
663
1274
|
def context_usage(
|
|
@@ -1433,15 +2044,28 @@ def _print_check_epic_base(payload: dict) -> None:
|
|
|
1433
2044
|
# Scripted Stage Exit
|
|
1434
2045
|
# --------------------------------------------------------------------------- #
|
|
1435
2046
|
|
|
1436
|
-
#:
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
2047
|
+
#: The seven production stages as a ROUTING domain. Deliberately not
|
|
2048
|
+
#: ``PRODUCTION_STAGES``, which is the six-stage member walk that excludes
|
|
2049
|
+
#: ``forge-0-epic``; derived from the shared ``ProductionStage`` alias so the two
|
|
2050
|
+
#: cannot drift.
|
|
2051
|
+
_EXIT_PRODUCTION_STAGES: Final[tuple[str, ...]] = get_args(ProductionStage)
|
|
2052
|
+
|
|
2053
|
+
#: The two direct branch skills — every exit stage that is not a production stage.
|
|
2054
|
+
#: Derived, so adding a branch skill to ``ExitStage`` lands here automatically.
|
|
2055
|
+
_BRANCH_STAGES: Final[tuple[str, ...]] = tuple(
|
|
2056
|
+
stage for stage in EXIT_STAGES if stage not in _EXIT_PRODUCTION_STAGES
|
|
1443
2057
|
)
|
|
1444
2058
|
|
|
2059
|
+
#: Inverse of ``VERIFY_MODE_TO_STAGE``. The mapping is injective, so the inverse is
|
|
2060
|
+
#: total over its values; stages with no mode (``forge-6-docs``) are simply absent.
|
|
2061
|
+
_STAGE_TO_VERIFY_MODE: Final[dict[str, str]] = {
|
|
2062
|
+
stage: mode for mode, stage in VERIFY_MODE_TO_STAGE.items()
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
#: The `--host` domain: command syntax and fresh-session wording only. A host NEVER
|
|
2066
|
+
#: implies a verification capability (REQ-EXIT-07).
|
|
2067
|
+
EXIT_HOSTS: Final[tuple[str, ...]] = ("claude", "generic", "pi")
|
|
2068
|
+
|
|
1445
2069
|
#: Stage id -> the noun phrase gate wording uses (the old {stage} stamp slot).
|
|
1446
2070
|
STAGE_NOUN: Final[dict[str, str]] = {
|
|
1447
2071
|
"forge-0-epic": "the epic decomposition",
|
|
@@ -1458,49 +2082,225 @@ _EXIT_VERIFY_TOKEN: Final[dict[str, str]] = {
|
|
|
1458
2082
|
"forge-0-epic": "epic",
|
|
1459
2083
|
}
|
|
1460
2084
|
|
|
1461
|
-
#: The stage each exit hands off to when pipeline state cannot say better.
|
|
2085
|
+
#: The stage each exit hands off to when pipeline state cannot say better. Also the
|
|
2086
|
+
#: production-successor table a branch exit walks from its RESOLVED SERVED stage:
|
|
2087
|
+
#: ``forge-6-docs`` is absent because the pipeline ends there — a docs exit routes to
|
|
2088
|
+
#: a completion action, never to a nonexistent stage 7.
|
|
1462
2089
|
_EXIT_NEXT_STAGE: Final[dict[str, str]] = {
|
|
1463
2090
|
"forge-0-epic": "forge-1-prd",
|
|
1464
2091
|
"forge-1-prd": "forge-2-tech",
|
|
1465
2092
|
"forge-2-tech": "forge-3-specs",
|
|
1466
2093
|
"forge-3-specs": "forge-4-backlog",
|
|
1467
2094
|
"forge-4-backlog": "forge-5-loop",
|
|
2095
|
+
"forge-5-loop": "forge-6-docs",
|
|
1468
2096
|
}
|
|
1469
2097
|
|
|
1470
|
-
#: The
|
|
1471
|
-
#:
|
|
1472
|
-
|
|
2098
|
+
#: The route each branch outcome takes. Every value is a COMPLETE map over
|
|
2099
|
+
#: ``EXIT_OUTCOMES[stage]``: REQ-ROUTE-05/06 require a terminus for every outcome and
|
|
2100
|
+
#: forbid a fall-through, so a missing key is a bug, not a default. The four kinds:
|
|
2101
|
+
#:
|
|
2102
|
+
#: ``successor`` rejoin the live production position after the served stage
|
|
2103
|
+
#: ``fix`` ``/feature-forge:forge-fix FEATURE --served-stage SERVED``
|
|
2104
|
+
#: ``verify`` ``/feature-forge:forge-verify FEATURE --served-stage SERVED``
|
|
2105
|
+
#: ``verify-if-owed`` ``verify`` while verification is still owed, else ``successor``
|
|
2106
|
+
#:
|
|
2107
|
+
#: Only ``successor`` advances. ``decisions``, ``failed``, ``deferred``, and
|
|
2108
|
+
#: ``reverify-findings`` are deliberately absent from it: unresolved work never
|
|
2109
|
+
#: reaches a production stage.
|
|
2110
|
+
_BRANCH_ROUTE_KIND: Final[dict[str, dict[str, str]]] = {
|
|
2111
|
+
"forge-verify": {
|
|
2112
|
+
"passed": "successor",
|
|
2113
|
+
"findings": "fix",
|
|
2114
|
+
"skipped": "successor",
|
|
2115
|
+
"failed": "verify",
|
|
2116
|
+
},
|
|
2117
|
+
"forge-fix": {
|
|
2118
|
+
"no-findings": "verify-if-owed",
|
|
2119
|
+
"decisions": "fix",
|
|
2120
|
+
"failed": "fix",
|
|
2121
|
+
# `applied` is NOT `reverified`: the writer clears `verifiedStageVersion`, so
|
|
2122
|
+
# re-verification is mandatory and this may never route to production.
|
|
2123
|
+
"applied": "verify",
|
|
2124
|
+
"reverified": "successor",
|
|
2125
|
+
"reverify-findings": "fix",
|
|
2126
|
+
"deferred": "fix",
|
|
2127
|
+
},
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
#: The deterministic sentence each branch outcome renders inside its NEXT-STEPS block
|
|
2131
|
+
#: (``_next_steps_block(..., outcome_text=...)``). Every non-advancing outcome names
|
|
2132
|
+
#: the unresolved work explicitly, which is what is required of `decisions`,
|
|
2133
|
+
#: `failed`, and `deferred`, and what is required of a `failed` verification.
|
|
2134
|
+
_BRANCH_OUTCOME_TEXT: Final[dict[str, dict[str, str]]] = {
|
|
2135
|
+
"forge-verify": {
|
|
2136
|
+
"passed": (
|
|
2137
|
+
"Verification passed for {served} — the pipeline rejoins where the "
|
|
2138
|
+
"diversion left it."
|
|
2139
|
+
),
|
|
2140
|
+
"findings": (
|
|
2141
|
+
"Verification reported findings for {served}. They are recorded and "
|
|
2142
|
+
"remain unresolved, so the pipeline does not advance until they are "
|
|
2143
|
+
"fixed and re-verification passes."
|
|
2144
|
+
),
|
|
2145
|
+
"skipped": (
|
|
2146
|
+
"Verification for {served} was explicitly skipped and the skip is "
|
|
2147
|
+
"recorded, so the pipeline may continue."
|
|
2148
|
+
),
|
|
2149
|
+
"failed": (
|
|
2150
|
+
"Verification for {served} could not run to a result — the dispatch, the "
|
|
2151
|
+
"check, or the state write failed. Nothing advances until it does: "
|
|
2152
|
+
"resolve the failure, then re-run the verification below."
|
|
2153
|
+
),
|
|
2154
|
+
},
|
|
2155
|
+
"forge-fix": {
|
|
2156
|
+
"no-findings": (
|
|
2157
|
+
"No applicable findings were found for {served}, but its verification is "
|
|
2158
|
+
"still owed — the absence of applicable findings is not a pass, so "
|
|
2159
|
+
"verification runs before the pipeline advances."
|
|
2160
|
+
),
|
|
2161
|
+
"decisions": (
|
|
2162
|
+
"The fix stopped on unresolved decisions for {served}. Answer them and "
|
|
2163
|
+
"re-run the fix below; the pipeline does not advance while they are open."
|
|
2164
|
+
),
|
|
2165
|
+
"failed": (
|
|
2166
|
+
"The fix for {served} failed — a fix step, a validation, a commit, or a "
|
|
2167
|
+
"state write did not complete. The findings remain unresolved, so the "
|
|
2168
|
+
"pipeline does not advance; address the failure and re-run the fix below."
|
|
2169
|
+
),
|
|
2170
|
+
"applied": (
|
|
2171
|
+
"Fixes were applied for {served}, but applied is not verified: the "
|
|
2172
|
+
"recorded freshness was cleared, so re-verification is mandatory before "
|
|
2173
|
+
"the pipeline advances."
|
|
2174
|
+
),
|
|
2175
|
+
"reverified": (
|
|
2176
|
+
"Re-verification passed for {served} — the findings are resolved and the "
|
|
2177
|
+
"pipeline rejoins where the diversion left it."
|
|
2178
|
+
),
|
|
2179
|
+
"reverify-findings": (
|
|
2180
|
+
"Re-verification reported further findings for {served}. They remain "
|
|
2181
|
+
"unresolved, so the pipeline does not advance."
|
|
2182
|
+
),
|
|
2183
|
+
"deferred": (
|
|
2184
|
+
"Fix work for {served} was explicitly deferred. The findings remain "
|
|
2185
|
+
"UNRESOLVED — the pipeline does not advance until they are fixed and "
|
|
2186
|
+
"re-verification passes."
|
|
2187
|
+
),
|
|
2188
|
+
},
|
|
2189
|
+
}
|
|
1473
2190
|
|
|
2191
|
+
#: `no-findings` is the one outcome whose terminus depends on live state, so it has a
|
|
2192
|
+
#: second sentence for the already-resolved case.
|
|
2193
|
+
_NO_FINDINGS_RESOLVED_TEXT: Final[str] = (
|
|
2194
|
+
"No applicable findings were found for {served}, and its verification is already "
|
|
2195
|
+
"resolved — the pipeline rejoins where the diversion left it."
|
|
2196
|
+
)
|
|
1474
2197
|
|
|
1475
|
-
def _verify_state_for(state: dict, stage: str) -> str:
|
|
1476
|
-
"""Classify THIS stage's verify freshness (stage-scoped ``verify_state``).
|
|
1477
2198
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
2199
|
+
def _classify_verify_entry(entry: dict, verify_key: str, current: int | None) -> str:
|
|
2200
|
+
"""Label one ``forge-verify-*`` entry against the artifact revision it serves.
|
|
2201
|
+
|
|
2202
|
+
The revision-agnostic half of ``_verify_state_for``, factored out because an
|
|
2203
|
+
EPIC-scoped exit compares against the epic manifest's ``revision`` held in
|
|
2204
|
+
``.epic-state.json``, not against a member production-stage ``version``
|
|
2205
|
+
(REQ-SEC-01). Both callers must apply identical rules, so there is
|
|
2206
|
+
one implementation rather than two that can drift.
|
|
2207
|
+
|
|
2208
|
+
Args:
|
|
2209
|
+
entry: The verify entry (``{}`` when absent).
|
|
2210
|
+
verify_key: The ``forge-verify-*`` key, named in the metadata diagnostic.
|
|
2211
|
+
current: The artifact's current revision, or None when it is unknown.
|
|
2212
|
+
|
|
2213
|
+
Returns:
|
|
2214
|
+
One of fresh / stale / failing / auto-pending / never / skipped.
|
|
1481
2215
|
"""
|
|
1482
|
-
token = _EXIT_VERIFY_TOKEN.get(stage)
|
|
1483
|
-
if token is None:
|
|
1484
|
-
return "none"
|
|
1485
|
-
entry = _verify_entry(state, f"forge-verify-{token}")
|
|
1486
2216
|
status = entry.get("status")
|
|
2217
|
+
if status is not None and not isinstance(status, str):
|
|
2218
|
+
# Same guard as `verify_state`: an unhashable status from a torn or
|
|
2219
|
+
# hand-edited entry must classify, not raise at the frozenset
|
|
2220
|
+
# membership below — this label is read while closing a stage.
|
|
2221
|
+
return "never"
|
|
1487
2222
|
if status == "skipped":
|
|
1488
2223
|
return "skipped"
|
|
1489
2224
|
if status == "findings-reported":
|
|
1490
2225
|
return "failing"
|
|
2226
|
+
if status == "auto-verify-pending":
|
|
2227
|
+
# Ahead of the generic unresolved branch, exactly as in verify_state.
|
|
2228
|
+
if _scheduled_stage_version(entry) is None:
|
|
2229
|
+
_warn_auto_verify_debt_metadata(verify_key)
|
|
2230
|
+
return "auto-pending"
|
|
1491
2231
|
if status not in _VERIFY_RESOLVED:
|
|
1492
2232
|
return "never"
|
|
2233
|
+
if status == "findings-applied":
|
|
2234
|
+
# §4.2 step 4: applying fixes CLEARS freshness; only a later `passed` restores
|
|
2235
|
+
# it. The writer omits `verifiedStageVersion` on this status, but REQ-DEBT-06
|
|
2236
|
+
# requires loading legacy state without migration, so a pre-writer entry can
|
|
2237
|
+
# still carry the key — and would otherwise read `fresh` here. Mirrors the
|
|
2238
|
+
# identical guard in `verify_state` (§5.1).
|
|
2239
|
+
return "stale"
|
|
1493
2240
|
verified_version = entry.get("verifiedStageVersion")
|
|
1494
|
-
stage_version = _stage_version(state, stage)
|
|
1495
2241
|
if (
|
|
1496
2242
|
isinstance(verified_version, int)
|
|
1497
|
-
and
|
|
1498
|
-
and verified_version ==
|
|
2243
|
+
and current is not None
|
|
2244
|
+
and verified_version == current
|
|
1499
2245
|
):
|
|
1500
2246
|
return "fresh"
|
|
1501
2247
|
return "stale"
|
|
1502
2248
|
|
|
1503
2249
|
|
|
2250
|
+
def _verify_state_for(state: dict, stage: str) -> str:
|
|
2251
|
+
"""Classify THIS stage's verify freshness (stage-scoped ``verify_state``).
|
|
2252
|
+
|
|
2253
|
+
Same labels as ``verify_state`` — fresh / stale / failing / auto-pending /
|
|
2254
|
+
never / skipped / none — but for the given stage rather than the
|
|
2255
|
+
most-recently completed one, because stage-exit runs inside the stage that
|
|
2256
|
+
just closed. ``auto-pending`` is classified identically here so stage-exit
|
|
2257
|
+
routing and the navigator ledger never disagree about owed debt (REQ-DEBT-05).
|
|
2258
|
+
"""
|
|
2259
|
+
token = _EXIT_VERIFY_TOKEN.get(stage)
|
|
2260
|
+
if token is None:
|
|
2261
|
+
return "none"
|
|
2262
|
+
return _classify_verify_entry(
|
|
2263
|
+
_verify_entry(state, f"forge-verify-{token}"),
|
|
2264
|
+
f"forge-verify-{token}",
|
|
2265
|
+
_stage_version(state, stage),
|
|
2266
|
+
)
|
|
2267
|
+
|
|
2268
|
+
|
|
2269
|
+
def _epic_verify_context(specs_dir: Path, epic_name: str) -> tuple[dict, int | None]:
|
|
2270
|
+
"""Read an epic's verification entry and manifest revision — tolerantly.
|
|
2271
|
+
|
|
2272
|
+
An epic's verification state lives in ``{specsDir}/{epic}/.epic-state.json``
|
|
2273
|
+
and its artifact revision is the sibling manifest's ``revision``. Neither ever
|
|
2274
|
+
comes from a member ``.pipeline-state.json``, and a member production-stage
|
|
2275
|
+
``version`` is never the epic's revision (REQ-SEC-01). This
|
|
2276
|
+
is the READ half: it degrades to ``({}, None)`` on anything missing or
|
|
2277
|
+
malformed, matching stage-exit's "never crash a stage closing" posture. The
|
|
2278
|
+
strict, fail-closed resolution lives on the WRITE path
|
|
2279
|
+
(``_load_epic_state_for_write``).
|
|
2280
|
+
|
|
2281
|
+
A legacy manifest with no ``revision`` reads as logical ``1``, matching
|
|
2282
|
+
``epic-manifest.py::load_manifest``; its bytes are not rewritten
|
|
2283
|
+
(REQ-DEBT-06).
|
|
2284
|
+
|
|
2285
|
+
Args:
|
|
2286
|
+
specs_dir: The configured specs directory.
|
|
2287
|
+
epic_name: The epic — what ``--feature`` carries on an epic-scoped exit.
|
|
2288
|
+
|
|
2289
|
+
Returns:
|
|
2290
|
+
``(verify_entry, revision)``; ``revision`` is None when the manifest is
|
|
2291
|
+
missing or its revision unusable.
|
|
2292
|
+
"""
|
|
2293
|
+
epic_dir = specs_dir / epic_name
|
|
2294
|
+
revision: int | None = None
|
|
2295
|
+
manifest = _read_state(epic_dir / MANIFEST_FILENAME)
|
|
2296
|
+
if manifest:
|
|
2297
|
+
raw = manifest.get("revision", 1)
|
|
2298
|
+
if isinstance(raw, int) and not isinstance(raw, bool) and raw >= 1:
|
|
2299
|
+
revision = raw
|
|
2300
|
+
entry = _verify_entry(_read_state(epic_dir / EPIC_STATE_FILENAME), "forge-verify-epic")
|
|
2301
|
+
return entry, revision
|
|
2302
|
+
|
|
2303
|
+
|
|
1504
2304
|
def _resolve_feature_dir(specs_dir: Path, feature: str, epic: str | None) -> Path:
|
|
1505
2305
|
"""Best-effort feature dir (flat, else unique nested, else flat literal).
|
|
1506
2306
|
|
|
@@ -1522,6 +2322,97 @@ def _resolve_feature_dir(specs_dir: Path, feature: str, epic: str | None) -> Pat
|
|
|
1522
2322
|
return flat
|
|
1523
2323
|
|
|
1524
2324
|
|
|
2325
|
+
def _same_named_candidates(specs_dir: Path, epic: str, member: str) -> list[Path]:
|
|
2326
|
+
"""Directories OTHER than ``{specsDir}/{epic}/{member}`` carrying that name's state.
|
|
2327
|
+
|
|
2328
|
+
Read-only, and used only to tell "no such feature anywhere" apart from "that
|
|
2329
|
+
name belongs to someone else" when the selected epic does not contain the
|
|
2330
|
+
member. Sorted, so the ambiguity error it feeds is deterministic.
|
|
2331
|
+
"""
|
|
2332
|
+
contained = specs_dir / epic / member
|
|
2333
|
+
out: list[Path] = []
|
|
2334
|
+
flat = specs_dir / member
|
|
2335
|
+
if flat != contained and (flat / PIPELINE_STATE_FILENAME).is_file():
|
|
2336
|
+
out.append(flat)
|
|
2337
|
+
if specs_dir.is_dir():
|
|
2338
|
+
out.extend(
|
|
2339
|
+
sorted(
|
|
2340
|
+
p
|
|
2341
|
+
for p in specs_dir.glob(f"*/{member}")
|
|
2342
|
+
if p != contained and (p / PIPELINE_STATE_FILENAME).is_file()
|
|
2343
|
+
)
|
|
2344
|
+
)
|
|
2345
|
+
return out
|
|
2346
|
+
|
|
2347
|
+
|
|
2348
|
+
def _epic_member_state(specs_dir: Path, epic: str, member: str) -> tuple[dict, str | None]:
|
|
2349
|
+
"""Resolve ONE epic member's live pipeline state for edit-mode routing.
|
|
2350
|
+
|
|
2351
|
+
Identity containment comes first: the member is read from
|
|
2352
|
+
``{specsDir}/{epic}/{member}`` and nowhere else, so a same-named flat feature
|
|
2353
|
+
or a member of a different epic can never be substituted for it (REQ-SEC-01).
|
|
2354
|
+
``_assert_safe_name`` has already rejected traversal by the time this runs.
|
|
2355
|
+
|
|
2356
|
+
Progress is then TOLERATED rather than demanded: an absent, unreadable,
|
|
2357
|
+
malformed, or foreign-epic state yields a reason instead of an exception, so
|
|
2358
|
+
the caller can degrade DOWN to ``forge-1-prd`` with a named warning rather
|
|
2359
|
+
than crash a stage closing or infer progress it could not read (REQ-PROD-06).
|
|
2360
|
+
This is the one documented new tolerant case.
|
|
2361
|
+
|
|
2362
|
+
Identity itself still fails closed: a member that is not under the selected
|
|
2363
|
+
epic at all and whose bare name matches more than one other candidate cannot
|
|
2364
|
+
be pinned to a single feature, and guessing would route a DIFFERENT feature's
|
|
2365
|
+
pipeline (REQ-REL-02).
|
|
2366
|
+
|
|
2367
|
+
Args:
|
|
2368
|
+
specs_dir: The configured specs directory.
|
|
2369
|
+
epic: The selected epic — what ``--feature`` carries on an epic exit.
|
|
2370
|
+
member: The selected member (``--next-feature``), already name-checked.
|
|
2371
|
+
|
|
2372
|
+
Returns:
|
|
2373
|
+
``(state, None)`` when the member's state resolved, else ``({}, reason)``
|
|
2374
|
+
where ``reason`` is a member of ``EPIC_MEMBER_FALLBACK_REASONS``.
|
|
2375
|
+
|
|
2376
|
+
Raises:
|
|
2377
|
+
UsageError: The member is not under the selected epic and its bare name is
|
|
2378
|
+
ambiguous across the specs tree (→ exit 2, no route guessed).
|
|
2379
|
+
"""
|
|
2380
|
+
member_dir = specs_dir / epic / member
|
|
2381
|
+
state_path = member_dir / PIPELINE_STATE_FILENAME
|
|
2382
|
+
# ``exists`` rather than ``is_file``: something occupying the state file's name
|
|
2383
|
+
# that cannot be read as one is `unreadable`, not absent.
|
|
2384
|
+
if not state_path.exists():
|
|
2385
|
+
if member_dir.is_dir():
|
|
2386
|
+
# Contained, just not started yet — the creation-mode case.
|
|
2387
|
+
return {}, "missing"
|
|
2388
|
+
elsewhere = _same_named_candidates(specs_dir, epic, member)
|
|
2389
|
+
if len(elsewhere) > 1:
|
|
2390
|
+
listed = ", ".join(str(p) for p in elsewhere)
|
|
2391
|
+
raise UsageError(
|
|
2392
|
+
f"ambiguous member {member!r} for epic {epic}: it is not under "
|
|
2393
|
+
f"{member_dir} and {len(elsewhere)} other directories carry a state "
|
|
2394
|
+
f"file for that name ({listed}) — refusing to guess which feature to "
|
|
2395
|
+
f"route to. Re-run naming the epic that owns it."
|
|
2396
|
+
)
|
|
2397
|
+
return {}, "not a member of this epic" if elsewhere else "missing"
|
|
2398
|
+
try:
|
|
2399
|
+
raw = state_path.read_text(encoding="utf-8")
|
|
2400
|
+
except OSError:
|
|
2401
|
+
return {}, "unreadable"
|
|
2402
|
+
try:
|
|
2403
|
+
parsed = json.loads(raw)
|
|
2404
|
+
except json.JSONDecodeError:
|
|
2405
|
+
return {}, "malformed"
|
|
2406
|
+
if not isinstance(parsed, dict):
|
|
2407
|
+
return {}, "malformed"
|
|
2408
|
+
back_pointer = parsed.get("epic")
|
|
2409
|
+
if isinstance(back_pointer, str) and back_pointer != epic:
|
|
2410
|
+
# The file sits under this epic but claims another one. Trusting either
|
|
2411
|
+
# side would assert progress for a feature we cannot identify.
|
|
2412
|
+
return {}, "not a member of this epic"
|
|
2413
|
+
return parsed, None
|
|
2414
|
+
|
|
2415
|
+
|
|
1525
2416
|
def _host_command(command: str, host: str) -> str:
|
|
1526
2417
|
"""Rewrite a `/feature-forge:` slash command to the host's surface.
|
|
1527
2418
|
|
|
@@ -1534,34 +2425,57 @@ def _host_command(command: str, host: str) -> str:
|
|
|
1534
2425
|
|
|
1535
2426
|
|
|
1536
2427
|
def _next_steps_block(
|
|
1537
|
-
|
|
2428
|
+
primary_command: str,
|
|
2429
|
+
host: str,
|
|
2430
|
+
reconcile: dict | None = None,
|
|
2431
|
+
deferred_command: str | None = None,
|
|
2432
|
+
outcome_text: str | None = None,
|
|
1538
2433
|
) -> str:
|
|
1539
|
-
"""Render
|
|
2434
|
+
"""Render one sentinel-terminated terminal block.
|
|
2435
|
+
|
|
2436
|
+
Args:
|
|
2437
|
+
primary_command: The sole fenced action.
|
|
2438
|
+
host: Command and fresh-session wording target.
|
|
2439
|
+
reconcile: Existing epic-backflow override metadata.
|
|
2440
|
+
deferred_command: Optional production action allowed only after the primary
|
|
2441
|
+
verification/recovery action succeeds.
|
|
2442
|
+
outcome_text: Optional deterministic loop/docs/branch outcome explanation.
|
|
2443
|
+
|
|
2444
|
+
Returns:
|
|
2445
|
+
A string whose final line is exactly `NEXT_STEPS_SENTINEL`.
|
|
1540
2446
|
|
|
1541
2447
|
The Claude wording uses the literal ``/clear`` slash-command; the generic
|
|
1542
2448
|
wording is host-neutral (matching the adapter build's host-term table, so
|
|
1543
2449
|
a non-Claude bundle invoking ``--host generic`` never instructs a fake
|
|
1544
2450
|
slash-command).
|
|
1545
2451
|
|
|
2452
|
+
``deferred_command`` is the caller's signal that ``primary_command`` is a
|
|
2453
|
+
verification/recovery action standing in front of a production successor: it
|
|
2454
|
+
is rendered only as unfenced conditional prose, and the fresh-session wording
|
|
2455
|
+
follows the primary action instead of promising "the next stage below"
|
|
2456
|
+
(REQ-EXIT-06). It is NEVER fenced, so it cannot be mistaken for the
|
|
2457
|
+
primary action.
|
|
2458
|
+
|
|
1546
2459
|
``reconcile`` carries the epic-backflow routing (§Epic backflow in
|
|
1547
2460
|
``references/stage-exit-protocol.md``). When it marks a **blocking** request
|
|
1548
|
-
(``required: true``)
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
2461
|
+
(``required: true``) AND the caller made the reconcile command primary, the
|
|
2462
|
+
fence carries it and the normal next stage is demoted to a follow-up line.
|
|
2463
|
+
When verification is still outstanding the caller keeps the verify command
|
|
2464
|
+
primary instead; the reconcile then becomes the FIRST deferred action, ahead
|
|
2465
|
+
of the ordinary production successor. When only **non-blocking**
|
|
2466
|
+
requests are present (``reminder: true``), a reminder line is appended.
|
|
2467
|
+
Either way the added prose is host-neutral (no literal ``/clear``) so it
|
|
2468
|
+
survives verbatim into a generic bundle.
|
|
1554
2469
|
"""
|
|
2470
|
+
verify_first = deferred_command is not None
|
|
1555
2471
|
if host == "claude":
|
|
1556
2472
|
clear_line = (
|
|
1557
2473
|
"1. `/clear` — recommended unconditionally at this stage boundary; "
|
|
1558
2474
|
"every artifact is on disk, so the work survives the clear. "
|
|
1559
2475
|
"I can't `/clear` for you — you have to run it yourself."
|
|
1560
2476
|
)
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
"re-run `/feature-forge:forge` to let the navigator resume from disk."
|
|
1564
|
-
)
|
|
2477
|
+
navigator = "`/feature-forge:forge`"
|
|
2478
|
+
fresh_prefix = "2. Then start a fresh session and run"
|
|
1565
2479
|
elif host == "pi":
|
|
1566
2480
|
# Pi's fresh-session command is `/new` (not `/clear`); its slash-command
|
|
1567
2481
|
# surface is `/skill:` (the fenced command below is rewritten to match).
|
|
@@ -1570,29 +2484,50 @@ def _next_steps_block(
|
|
|
1570
2484
|
"artifact is on disk, so the work survives starting a fresh session. "
|
|
1571
2485
|
"I can't run `/new` for you — you have to run it yourself."
|
|
1572
2486
|
)
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
"`/skill:forge` to let the navigator resume from disk."
|
|
1576
|
-
)
|
|
2487
|
+
navigator = "`/skill:forge`"
|
|
2488
|
+
fresh_prefix = "2. Then, in the new session, run"
|
|
1577
2489
|
else:
|
|
1578
2490
|
clear_line = (
|
|
1579
2491
|
"1. Clear your session / start a fresh session — recommended "
|
|
1580
2492
|
"unconditionally at this stage boundary; every artifact is on "
|
|
1581
2493
|
"disk, so the work survives it."
|
|
1582
2494
|
)
|
|
2495
|
+
navigator = None
|
|
2496
|
+
fresh_prefix = "2. Then start a fresh session and run"
|
|
2497
|
+
resume = (
|
|
2498
|
+
f"re-run {navigator} to let the navigator resume from disk."
|
|
2499
|
+
if navigator
|
|
2500
|
+
else "re-run the forge navigator skill to resume from disk."
|
|
2501
|
+
)
|
|
2502
|
+
# The primary actionable command goes in a fenced block so mobile/remote hosts
|
|
2503
|
+
# get a native copy button (inline code is not tap-to-copy). The CALLER decides
|
|
2504
|
+
# which command is primary (the renderer fences exactly what it
|
|
2505
|
+
# is given); the fence sits before the sentinel, so the sentinel remains the
|
|
2506
|
+
# absolute last line.
|
|
2507
|
+
fenced_command = _host_command(primary_command, host)
|
|
2508
|
+
if verify_first:
|
|
2509
|
+
# REQ-EXIT-06: the fresh-session guidance follows the PRIMARY action, and
|
|
2510
|
+
# must never tell the user to clear and run the production successor first.
|
|
2511
|
+
# It names what is actually FENCED: on a branch exit that is the fix standing
|
|
2512
|
+
# between recorded findings and the re-verification, not a verify
|
|
2513
|
+
# command. Every other case keeps the wording verbatim.
|
|
2514
|
+
action_noun = "fix" if "forge-fix " in fenced_command else "verification"
|
|
1583
2515
|
next_line = (
|
|
1584
|
-
"
|
|
1585
|
-
"
|
|
2516
|
+
f"{fresh_prefix} the {action_noun} below — verification is still "
|
|
2517
|
+
"outstanding for this stage, so it comes before the next production "
|
|
2518
|
+
f"stage. Or {resume}"
|
|
1586
2519
|
)
|
|
2520
|
+
else:
|
|
2521
|
+
next_line = f"{fresh_prefix} the next stage below — or {resume}"
|
|
1587
2522
|
blocking = bool(reconcile and reconcile.get("required"))
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
lines
|
|
1595
|
-
if
|
|
2523
|
+
reconcile_is_primary = bool(
|
|
2524
|
+
blocking and _host_command(reconcile["command"], host) == fenced_command
|
|
2525
|
+
)
|
|
2526
|
+
lines = ["**Next steps**"]
|
|
2527
|
+
if outcome_text:
|
|
2528
|
+
lines.append(outcome_text)
|
|
2529
|
+
lines.append(clear_line)
|
|
2530
|
+
if reconcile_is_primary:
|
|
1596
2531
|
count = reconcile["count"]
|
|
1597
2532
|
plural = "s" if count != 1 else ""
|
|
1598
2533
|
lines.append(
|
|
@@ -1605,6 +2540,16 @@ def _next_steps_block(
|
|
|
1605
2540
|
lines.append(next_line)
|
|
1606
2541
|
lines.append("")
|
|
1607
2542
|
lines.append(f"```\n{fenced_command}\n```")
|
|
2543
|
+
if blocking and not reconcile_is_primary:
|
|
2544
|
+
# Verification outranked the reconcile, so the reconcile is the FIRST
|
|
2545
|
+
# deferred action and the production successor stays subordinate to it.
|
|
2546
|
+
count = reconcile["count"]
|
|
2547
|
+
plural = "s" if count != 1 else ""
|
|
2548
|
+
lines.append(
|
|
2549
|
+
f"After verification passes, reconcile the epic first — {count} "
|
|
2550
|
+
f"blocking epic change request{plural} flagged: "
|
|
2551
|
+
f"`{_host_command(reconcile['command'], host)}`"
|
|
2552
|
+
)
|
|
1608
2553
|
if blocking and reconcile.get("deferred"):
|
|
1609
2554
|
deferred_cmd = _host_command(reconcile["deferred"], host)
|
|
1610
2555
|
lines.append(f"After reconciling, continue the pipeline with: `{deferred_cmd}`")
|
|
@@ -1615,91 +2560,1181 @@ def _next_steps_block(
|
|
|
1615
2560
|
f"You also flagged {count} epic change{plural} to reconcile when "
|
|
1616
2561
|
f"convenient: `{_host_command(reconcile['command'], host)}`"
|
|
1617
2562
|
)
|
|
2563
|
+
if verify_first and _host_command(deferred_command, host) != _host_command(
|
|
2564
|
+
(reconcile or {}).get("deferred") or "", host
|
|
2565
|
+
):
|
|
2566
|
+
# Unfenced, conditional prose only. Suppressed when the
|
|
2567
|
+
# blocking reconcile above already demoted this same command, so one
|
|
2568
|
+
# command never appears twice in the deferred chain.
|
|
2569
|
+
lines.append(
|
|
2570
|
+
"After verification passes, continue with: "
|
|
2571
|
+
f"`{_host_command(deferred_command, host)}`"
|
|
2572
|
+
)
|
|
1618
2573
|
lines.append(NEXT_STEPS_SENTINEL)
|
|
1619
2574
|
return "\n".join(lines)
|
|
1620
2575
|
|
|
1621
2576
|
|
|
1622
|
-
def
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
epic: str | None,
|
|
1628
|
-
host: str,
|
|
1629
|
-
next_feature: str | None,
|
|
1630
|
-
) -> dict:
|
|
1631
|
-
"""Compute the Scripted Stage Exit payload: DIRECTIVES + NEXT-STEPS block.
|
|
2577
|
+
def resolve_served_stage(
|
|
2578
|
+
served_stage: str | None,
|
|
2579
|
+
verify_mode: str | None,
|
|
2580
|
+
) -> str:
|
|
2581
|
+
"""Resolve one unambiguous production stage for a branch exit.
|
|
1632
2582
|
|
|
1633
|
-
|
|
2583
|
+
Args:
|
|
2584
|
+
served_stage: Explicit production stage supplied by the branch caller.
|
|
2585
|
+
verify_mode: Optional authoritative mode mapped by VERIFY_MODE_TO_STAGE.
|
|
1634
2586
|
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
resolved (fresh/skipped). The skill then dispatches the clean-room
|
|
1638
|
-
verify in-session (principle #2: verify before the clear).
|
|
1639
|
-
- ``autoFixEligible`` — ``autoFix`` is strict-true AND the in-stage verify
|
|
1640
|
-
runs AND the working tree is clean. Findings-level preconditions (zero
|
|
1641
|
-
unresolved decisions) remain the skill's runtime check.
|
|
1642
|
-
- ``verifyGate`` — ``none`` when verify is resolved or the in-stage run
|
|
1643
|
-
covers it; ``standard`` when auto-verify is off and verification is
|
|
1644
|
-
outstanding on a host with a question mechanism + clean-room path
|
|
1645
|
-
(``--host claude``); ``manual-print`` for the same state on a generic
|
|
1646
|
-
host (print ``verifyCommand`` instead of presenting the gate).
|
|
1647
|
-
- ``nextStage``/``nextCommand`` — from pipeline state when it already
|
|
1648
|
-
records this stage complete (first non-complete production stage), else
|
|
1649
|
-
the fixed successor. ``--next-feature`` names the first actionable
|
|
1650
|
-
feature for the epic handoff; without it the runtime placeholder
|
|
1651
|
-
``{first-actionable-feature}`` passes through for the skill to resolve.
|
|
1652
|
-
- ``epicReconcile`` — present only when the exiting member carries
|
|
1653
|
-
``open`` ``epicChangeRequests`` (epic-backflow). ``required: true`` (any
|
|
1654
|
-
``blocksCurrent: true`` request) interposes a reconcile-first exit: the
|
|
1655
|
-
NEXT-STEPS primary command becomes ``/feature-forge:forge-0-epic {epic}``
|
|
1656
|
-
and the normal next stage is deferred. Only non-blocking requests set
|
|
1657
|
-
``reminder: true`` and append a non-blocking reminder line. Absent when
|
|
1658
|
-
there are no open requests (common path) or the epic name is unresolvable.
|
|
2587
|
+
Returns:
|
|
2588
|
+
A member of the shared ProductionStage domain.
|
|
1659
2589
|
|
|
1660
|
-
|
|
1661
|
-
|
|
2590
|
+
Raises:
|
|
2591
|
+
UsageError: The explicit stage is invalid, mode is invalid, both inputs
|
|
2592
|
+
disagree, or neither input identifies a stage.
|
|
1662
2593
|
"""
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
2594
|
+
if served_stage is not None and served_stage not in _EXIT_PRODUCTION_STAGES:
|
|
2595
|
+
raise UsageError(
|
|
2596
|
+
f"--served-stage {served_stage!r} is not a production stage; expected "
|
|
2597
|
+
f"one of {', '.join(_EXIT_PRODUCTION_STAGES)}"
|
|
2598
|
+
)
|
|
2599
|
+
if verify_mode is not None and verify_mode not in VERIFY_MODE_TO_STAGE:
|
|
2600
|
+
raise UsageError(
|
|
2601
|
+
f"--verify-mode {verify_mode!r} is not a known verify mode; expected "
|
|
2602
|
+
f"one of {', '.join(VERIFY_MODE_TO_STAGE)}"
|
|
2603
|
+
)
|
|
2604
|
+
if served_stage is not None and verify_mode is not None:
|
|
2605
|
+
mapped = VERIFY_MODE_TO_STAGE[verify_mode]
|
|
2606
|
+
if mapped != served_stage:
|
|
2607
|
+
# Both were supplied and they disagree. Name both flags and both
|
|
2608
|
+
# resolutions — picking one silently is exactly the guess REQ-ROUTE-03
|
|
2609
|
+
# forbids.
|
|
2610
|
+
raise UsageError(
|
|
2611
|
+
f"--served-stage {served_stage} conflicts with --verify-mode "
|
|
2612
|
+
f"{verify_mode} (which maps to {mapped}); supply one, or supply "
|
|
2613
|
+
"values that agree"
|
|
2614
|
+
)
|
|
2615
|
+
return served_stage
|
|
2616
|
+
if served_stage is not None:
|
|
2617
|
+
# Explicit stage takes precedence, and accepts any ProductionStage —
|
|
2618
|
+
# including forge-6-docs, which no verify mode maps to.
|
|
2619
|
+
return served_stage
|
|
2620
|
+
if verify_mode is not None:
|
|
2621
|
+
return VERIFY_MODE_TO_STAGE[verify_mode]
|
|
2622
|
+
raise UsageError(
|
|
2623
|
+
"forge-verify requires --served-stage or an unambiguous --verify-mode; "
|
|
2624
|
+
"rerun with the production stage this verification served"
|
|
2625
|
+
)
|
|
1666
2626
|
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
2627
|
+
|
|
2628
|
+
def _branch_route(
|
|
2629
|
+
stage: str,
|
|
2630
|
+
outcome: str,
|
|
2631
|
+
feature: str,
|
|
2632
|
+
served: str,
|
|
2633
|
+
successor_command: str | None,
|
|
2634
|
+
resolved: bool,
|
|
2635
|
+
) -> tuple[str, str | None, str, bool]:
|
|
2636
|
+
"""Route one verify/fix outcome back into the pipeline — the rejoin tables.
|
|
2637
|
+
|
|
2638
|
+
A verify or fix diversion must rejoin the production stage it SERVED rather than
|
|
2639
|
+
dropping the pipeline thread (issue #176), so the served stage is carried forward
|
|
2640
|
+
in every branch command this returns. Commands are canonical, pre-`_host_command`
|
|
2641
|
+
forms; the renderer translates them.
|
|
2642
|
+
|
|
2643
|
+
"Live successor" is the current production position, never a conversational
|
|
2644
|
+
assumption: `successor_command` is already the state-aware next production action
|
|
2645
|
+
after the served artifact, and it is None only at the end of the pipeline — a
|
|
2646
|
+
completed stage 6, which routes to the navigator completion action rather than a
|
|
2647
|
+
nonexistent stage 7.
|
|
2648
|
+
|
|
2649
|
+
Args:
|
|
2650
|
+
stage: `forge-verify` or `forge-fix`.
|
|
2651
|
+
outcome: A member of `EXIT_OUTCOMES[stage]`, already validated.
|
|
2652
|
+
feature: The feature (or epic) the diversion served.
|
|
2653
|
+
served: The resolved served production stage.
|
|
2654
|
+
successor_command: Canonical live-successor command, or None at pipeline end.
|
|
2655
|
+
resolved: Whether the served stage's verification is settled. Consulted only
|
|
2656
|
+
by `no-findings`, the one outcome whose terminus depends on live state.
|
|
2657
|
+
|
|
2658
|
+
Returns:
|
|
2659
|
+
`(primary_canonical, deferred_canonical, outcome_text, advancing)`.
|
|
2660
|
+
`deferred_canonical` is the demoted production successor, rendered only as
|
|
2661
|
+
unfenced prose, and is None whenever the primary command already advances.
|
|
2662
|
+
|
|
2663
|
+
Two rows carry a precondition this router does NOT re-check: `skipped` is valid
|
|
2664
|
+
only after the skip is persisted and `reverified` only after a passing state is
|
|
2665
|
+
recorded. Both are the CALLER's obligation — the branch skills
|
|
2666
|
+
write through `state-verify` before invoking this exit, and a fix
|
|
2667
|
+
that merely skips re-verification reports `deferred`, not `reverified`. Rejecting
|
|
2668
|
+
the outcome here would make a valid member of `EXIT_OUTCOMES[stage]` exit 2, which
|
|
2669
|
+
is a different contract from the one `stage_exit` validates.
|
|
2670
|
+
"""
|
|
2671
|
+
kind = _BRANCH_ROUTE_KIND[stage][outcome]
|
|
2672
|
+
if kind == "verify-if-owed":
|
|
2673
|
+
template = (
|
|
2674
|
+
_NO_FINDINGS_RESOLVED_TEXT if resolved else _BRANCH_OUTCOME_TEXT[stage][outcome]
|
|
2675
|
+
)
|
|
2676
|
+
kind = "successor" if resolved else "verify"
|
|
2677
|
+
else:
|
|
2678
|
+
template = _BRANCH_OUTCOME_TEXT[stage][outcome]
|
|
2679
|
+
text = template.format(served=served)
|
|
2680
|
+
|
|
2681
|
+
if kind == "successor":
|
|
2682
|
+
return successor_command or f"/feature-forge:forge {feature}", None, text, True
|
|
2683
|
+
|
|
2684
|
+
branch = "forge-fix" if kind == "fix" else "forge-verify"
|
|
2685
|
+
return (
|
|
2686
|
+
f"/feature-forge:{branch} {feature} --served-stage {served}",
|
|
2687
|
+
successor_command,
|
|
2688
|
+
text,
|
|
2689
|
+
False,
|
|
2690
|
+
)
|
|
2691
|
+
|
|
2692
|
+
|
|
2693
|
+
#: Keys `_render_status` requires before it will route on a `render-status --json`
|
|
2694
|
+
#: payload. `RenderStatus` is TOTAL: every key is always present, so an
|
|
2695
|
+
#: empty `actionable` list is the answer "nothing is actionable" and a MISSING key
|
|
2696
|
+
#: means the helper is not the contract this router was built against — an
|
|
2697
|
+
#: actionable routing failure, never a silently-skipped check.
|
|
2698
|
+
_RENDER_STATUS_REQUIRED: Final[tuple[str, ...]] = (
|
|
2699
|
+
"epic",
|
|
2700
|
+
"features",
|
|
2701
|
+
"actionable",
|
|
2702
|
+
"rollup",
|
|
2703
|
+
"nextCommand",
|
|
2704
|
+
)
|
|
2705
|
+
|
|
2706
|
+
#: The bound on the one subprocess the docs exit path makes. Matches every other
|
|
2707
|
+
#: `subprocess.run` in this file (the git reads and the `forge-root.sh` resolver);
|
|
2708
|
+
#: without it a hung or pathological epic would stall stage closure with no
|
|
2709
|
+
#: diagnostic, defeating REQ-PERF-01.
|
|
2710
|
+
_RENDER_STATUS_TIMEOUT: Final = 10
|
|
2711
|
+
|
|
2712
|
+
|
|
2713
|
+
def _render_status_failure_detail(proc: subprocess.CompletedProcess) -> str:
|
|
2714
|
+
"""Name WHY a nonzero ``render-status --json`` failed, in one deterministic line.
|
|
2715
|
+
|
|
2716
|
+
Its first stderr line when it wrote one (a missing/unreadable manifest exits 2
|
|
2717
|
+
that way), else the first validation finding from the JSON on stdout — which is
|
|
2718
|
+
the only place an invalid graph reports itself (it exits 1 with
|
|
2719
|
+
``{"valid": false, "findings": [...]}`` and a silent stderr).
|
|
2720
|
+
|
|
2721
|
+
Args:
|
|
2722
|
+
proc: The completed ``render-status`` process.
|
|
2723
|
+
|
|
2724
|
+
Returns:
|
|
2725
|
+
A single-line detail, or ``""`` when the helper said nothing usable.
|
|
2726
|
+
"""
|
|
2727
|
+
lines = proc.stderr.strip().splitlines()
|
|
2728
|
+
if lines:
|
|
2729
|
+
return lines[0]
|
|
2730
|
+
try:
|
|
2731
|
+
payload = json.loads(proc.stdout)
|
|
2732
|
+
except json.JSONDecodeError:
|
|
2733
|
+
return ""
|
|
2734
|
+
findings = payload.get("findings") if isinstance(payload, dict) else None
|
|
2735
|
+
if isinstance(findings, list) and findings and isinstance(findings[0], dict):
|
|
2736
|
+
message = findings[0].get("message")
|
|
2737
|
+
if isinstance(message, str):
|
|
2738
|
+
return f"first finding: {message}"
|
|
2739
|
+
return ""
|
|
2740
|
+
|
|
2741
|
+
|
|
2742
|
+
def _render_status(specs_dir: Path, epic: str) -> dict:
|
|
2743
|
+
"""Read LIVE epic status from the sibling ``epic-manifest.py``.
|
|
2744
|
+
|
|
2745
|
+
The docs exit routes on the epic's real dependency/completion graph rather than
|
|
2746
|
+
re-deriving it here: dependency and completion derivation belong to
|
|
2747
|
+
``epic-manifest.py``; duplicating them in this file is forbidden.
|
|
2748
|
+
|
|
2749
|
+
``<bundle-root>`` is NOT a path this router may guess. ``forge-session.py`` is
|
|
2750
|
+
copied verbatim into six adapter bundles and runs from an arbitrary cwd, so the
|
|
2751
|
+
helper is resolved as a SIBLING of this file — the ``RUNTIME_HELPERS`` guarantee
|
|
2752
|
+
that ships them together, matching the existing ``_resolve_plugin_root``
|
|
2753
|
+
convention — and invoked with ``sys.executable`` rather than a bare ``python3``,
|
|
2754
|
+
which may be absent or a different interpreter than the one running this script.
|
|
2755
|
+
|
|
2756
|
+
Args:
|
|
2757
|
+
specs_dir: Configured specs directory, passed through to the helper.
|
|
2758
|
+
epic: The epic name; also the subject of every failure message.
|
|
2759
|
+
|
|
2760
|
+
Returns:
|
|
2761
|
+
The parsed ``RenderStatus`` dict.
|
|
2762
|
+
|
|
2763
|
+
Raises:
|
|
2764
|
+
UsageError: A missing sibling helper, a non-zero exit (which covers an
|
|
2765
|
+
invalid graph — ``render-status`` refuses to render one), a spawn
|
|
2766
|
+
failure, a timeout at the bound, unparseable stdout, or a missing or
|
|
2767
|
+
malformed required field. Every one is an actionable exit-2 routing
|
|
2768
|
+
failure that names the epic and the recovery command, so the caller
|
|
2769
|
+
emits no guessed member route and no sentinel (REQ-REL-02).
|
|
2770
|
+
|
|
2771
|
+
Reads only the bounded local manifest/member-state set — no network call and no
|
|
2772
|
+
repository-history scan (REQ-PERF-01).
|
|
2773
|
+
"""
|
|
2774
|
+
helper = Path(__file__).resolve().parent / "epic-manifest.py"
|
|
2775
|
+
|
|
2776
|
+
def fail(reason: str) -> NoReturn:
|
|
2777
|
+
raise UsageError(
|
|
2778
|
+
f"cannot route the documentation exit for epic {epic!r}: {reason}. "
|
|
2779
|
+
f"Run /feature-forge:forge-0-epic {epic} to inspect the epic and "
|
|
2780
|
+
"resolve it, then re-run this exit."
|
|
2781
|
+
)
|
|
2782
|
+
|
|
2783
|
+
if not helper.is_file():
|
|
2784
|
+
fail(f"the sibling epic-manifest.py is missing at {helper}")
|
|
2785
|
+
try:
|
|
2786
|
+
proc = subprocess.run(
|
|
2787
|
+
[
|
|
2788
|
+
sys.executable,
|
|
2789
|
+
str(helper),
|
|
2790
|
+
"render-status",
|
|
2791
|
+
epic,
|
|
2792
|
+
"--specs-dir",
|
|
2793
|
+
str(specs_dir),
|
|
2794
|
+
"--json",
|
|
2795
|
+
],
|
|
2796
|
+
capture_output=True,
|
|
2797
|
+
text=True,
|
|
2798
|
+
timeout=_RENDER_STATUS_TIMEOUT,
|
|
2799
|
+
check=False,
|
|
2800
|
+
)
|
|
2801
|
+
except subprocess.TimeoutExpired:
|
|
2802
|
+
fail(f"render-status did not finish within {_RENDER_STATUS_TIMEOUT} seconds")
|
|
2803
|
+
except OSError as exc:
|
|
2804
|
+
fail(f"render-status could not be started ({exc})")
|
|
2805
|
+
if proc.returncode != 0:
|
|
2806
|
+
# An INVALID GRAPH exits 1 with its findings as JSON on stdout and nothing on
|
|
2807
|
+
# stderr, so quoting stderr alone would report a bare exit code for the one
|
|
2808
|
+
# failure the operator most needs named (REQ-OBS-02).
|
|
2809
|
+
detail = _render_status_failure_detail(proc)
|
|
2810
|
+
fail(f"render-status exited {proc.returncode}{f' ({detail})' if detail else ''}")
|
|
2811
|
+
try:
|
|
2812
|
+
status = json.loads(proc.stdout)
|
|
2813
|
+
except json.JSONDecodeError as exc:
|
|
2814
|
+
fail(f"render-status did not emit parseable JSON ({exc})")
|
|
2815
|
+
if not isinstance(status, dict):
|
|
2816
|
+
fail("render-status emitted a non-object JSON payload")
|
|
2817
|
+
missing = [key for key in _RENDER_STATUS_REQUIRED if key not in status]
|
|
2818
|
+
if missing:
|
|
2819
|
+
fail(f"render-status omitted required field(s): {', '.join(missing)}")
|
|
2820
|
+
rollup = status["rollup"]
|
|
2821
|
+
if not isinstance(rollup, dict) or any(
|
|
2822
|
+
not isinstance(rollup.get(key), int) or isinstance(rollup.get(key), bool)
|
|
2823
|
+
for key in ("complete", "total")
|
|
2824
|
+
):
|
|
2825
|
+
fail("render-status emitted a malformed rollup")
|
|
2826
|
+
if not isinstance(status["actionable"], list):
|
|
2827
|
+
fail("render-status emitted a malformed actionable list")
|
|
2828
|
+
if status["nextCommand"] is not None and not isinstance(status["nextCommand"], str):
|
|
2829
|
+
fail("render-status emitted a malformed nextCommand")
|
|
2830
|
+
return status
|
|
2831
|
+
|
|
2832
|
+
|
|
2833
|
+
#: The deterministic sentence each documentation terminus renders inside its
|
|
2834
|
+
#: NEXT-STEPS block. Every epic route names the epic; no `blocked` route claims the
|
|
2835
|
+
#: pipeline is complete. `{new_feature}`/`{new_epic}` are host-translated INLINE
|
|
2836
|
+
#: mentions: starting a new feature is allowed only as secondary unfenced text, and
|
|
2837
|
+
#: `_next_steps_block` fences exactly the primary command and nothing else.
|
|
2838
|
+
_DOCS_OUTCOME_TEXT: Final[dict[str, str]] = {
|
|
2839
|
+
"standalone-complete": (
|
|
2840
|
+
"Documentation is complete for {feature}, and with it the pipeline. The "
|
|
2841
|
+
"navigator command below is the authoritative completion action — it "
|
|
2842
|
+
"confirms the finished state from disk. Optionally, you can start a new "
|
|
2843
|
+
"feature with `{new_feature}` or group related work into an epic with "
|
|
2844
|
+
"`{new_epic}`; neither is required to finish here."
|
|
2845
|
+
),
|
|
2846
|
+
"standalone-blocked": (
|
|
2847
|
+
"Documentation could not be completed for {feature}, so the pipeline is NOT "
|
|
2848
|
+
"complete. Only valid partial state was persisted. Run the navigator below "
|
|
2849
|
+
"to see what remains and recover from there."
|
|
2850
|
+
),
|
|
2851
|
+
"epic-actionable": (
|
|
2852
|
+
"Documentation is complete for {feature}. Epic {epic} has more work that can "
|
|
2853
|
+
"be started now ({complete}/{total} members complete), so the pipeline "
|
|
2854
|
+
"continues with the next actionable member below."
|
|
2855
|
+
),
|
|
2856
|
+
"epic-blocked-members": (
|
|
2857
|
+
"Documentation is complete for {feature}, but no member of epic {epic} is "
|
|
2858
|
+
"actionable right now ({complete}/{total} members complete) — the remaining "
|
|
2859
|
+
"work is blocked by unmet dependencies. Open the epic dashboard below to see "
|
|
2860
|
+
"what is holding it up."
|
|
2861
|
+
),
|
|
2862
|
+
"epic-complete": (
|
|
2863
|
+
"Documentation is complete for {feature}, and every member of epic {epic} is "
|
|
2864
|
+
"now complete ({complete}/{total}). Open the epic dashboard below for its "
|
|
2865
|
+
"completion view."
|
|
2866
|
+
),
|
|
2867
|
+
"epic-blocked": (
|
|
2868
|
+
"Documentation could not be completed for {feature}, so neither this feature "
|
|
2869
|
+
"nor epic {epic} is complete. Only valid partial state was persisted. Open "
|
|
2870
|
+
"the epic dashboard below to see the epic's live state and recover from there."
|
|
2871
|
+
),
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
|
|
2875
|
+
def _docs_route(
|
|
2876
|
+
feature: str, epic: str | None, specs_dir: Path, outcome: str, host: str
|
|
2877
|
+
) -> tuple[str, str | None, str, bool]:
|
|
2878
|
+
"""Route the documentation exit — the live-state table.
|
|
2879
|
+
|
|
2880
|
+
For an epic member the route comes from the live ``render-status`` payload, so a
|
|
2881
|
+
Step-1 snapshot taken before docs state changed is never trusted: an actionable
|
|
2882
|
+
next member routes to that member's own live command, and anything else (blocked
|
|
2883
|
+
remaining work, or every member complete) routes to the epic dashboard, which is
|
|
2884
|
+
also the dashboard's completion view. A ``blocked`` docs outcome routes to
|
|
2885
|
+
recovery and NEVER claims pipeline completion.
|
|
2886
|
+
|
|
2887
|
+
Args:
|
|
2888
|
+
feature: The feature whose documentation stage is closing.
|
|
2889
|
+
epic: The owning epic, or None for a standalone feature.
|
|
2890
|
+
specs_dir: Configured specs directory.
|
|
2891
|
+
outcome: `complete` or `blocked`, already validated.
|
|
2892
|
+
host: Host surface, used only to translate the INLINE secondary mentions —
|
|
2893
|
+
the primary command is translated by the renderer.
|
|
2894
|
+
|
|
2895
|
+
Returns:
|
|
2896
|
+
`(primary_canonical, deferred_canonical, outcome_text, advancing)`, matching
|
|
2897
|
+
`_branch_route`. `deferred_canonical` is always None: a docs terminus has no
|
|
2898
|
+
production successor to demote, because the pipeline ends here.
|
|
2899
|
+
|
|
2900
|
+
A ``blocked`` epic exit deliberately does NOT call ``render-status``: its route is
|
|
2901
|
+
fixed at the epic dashboard regardless of what the live graph says, and a broken
|
|
2902
|
+
epic graph is precisely the state in which the recovery route must stay reachable
|
|
2903
|
+
rather than converting into a second failure.
|
|
2904
|
+
"""
|
|
2905
|
+
if epic is None:
|
|
2906
|
+
text = _DOCS_OUTCOME_TEXT[
|
|
2907
|
+
"standalone-complete" if outcome == "complete" else "standalone-blocked"
|
|
2908
|
+
].format(
|
|
2909
|
+
feature=feature,
|
|
2910
|
+
new_feature=_host_command("/feature-forge:forge-1-prd <new-feature>", host),
|
|
2911
|
+
new_epic=_host_command("/feature-forge:forge-0-epic <new-epic>", host),
|
|
2912
|
+
)
|
|
2913
|
+
return f"/feature-forge:forge {feature}", None, text, False
|
|
2914
|
+
|
|
2915
|
+
dashboard = f"/feature-forge:forge-0-epic {epic}"
|
|
2916
|
+
if outcome == "blocked":
|
|
2917
|
+
text = _DOCS_OUTCOME_TEXT["epic-blocked"].format(feature=feature, epic=epic)
|
|
2918
|
+
return dashboard, None, text, False
|
|
2919
|
+
|
|
2920
|
+
status = _render_status(specs_dir, epic)
|
|
2921
|
+
rollup = status["rollup"]
|
|
2922
|
+
fields = {
|
|
2923
|
+
"feature": feature,
|
|
2924
|
+
"epic": epic,
|
|
2925
|
+
"complete": rollup["complete"],
|
|
2926
|
+
"total": rollup["total"],
|
|
2927
|
+
}
|
|
2928
|
+
next_command = status["nextCommand"]
|
|
2929
|
+
if status["actionable"] and next_command:
|
|
2930
|
+
return next_command, None, _DOCS_OUTCOME_TEXT["epic-actionable"].format(**fields), True
|
|
2931
|
+
# Nothing actionable. Under the current derivation that coincides with "every
|
|
2932
|
+
# member complete" (a valid graph is acyclic, so an incomplete member always has
|
|
2933
|
+
# an actionable ancestor), but the two cases are named separately and the
|
|
2934
|
+
# rollup is the observable that tells them apart — so the blocked wording stays
|
|
2935
|
+
# reachable if a future derivation admits an unactionable incomplete member. Both
|
|
2936
|
+
# route to the same epic command either way; only the explanation differs.
|
|
2937
|
+
key = "epic-complete" if rollup["complete"] >= rollup["total"] else "epic-blocked-members"
|
|
2938
|
+
return dashboard, None, _DOCS_OUTCOME_TEXT[key].format(**fields), False
|
|
2939
|
+
|
|
2940
|
+
|
|
2941
|
+
#: The route each loop outcome takes. A COMPLETE map over
|
|
2942
|
+
#: ``EXIT_OUTCOMES["forge-5-loop"]``: REQ-PROD-01/02 require a deterministic resume or
|
|
2943
|
+
#: recovery action for every result, so a missing key is a bug, not a default.
|
|
2944
|
+
#:
|
|
2945
|
+
#: ``handoff`` verify-first implementation routing, then the live docs/epic handoff
|
|
2946
|
+
#: ``resume`` ``/feature-forge:forge-5-loop FEATURE`` — state remains resumable
|
|
2947
|
+
#: ``recover`` ``/feature-forge:forge FEATURE`` — the deterministic diagnostic action
|
|
2948
|
+
#:
|
|
2949
|
+
#: Only ``handoff`` (i.e. ``complete``) may reach a production stage. A runner's
|
|
2950
|
+
#: successful process exit is NOT by itself ``complete``: the final backlog state
|
|
2951
|
+
#: selects the outcome, and that selection is the skill's job.
|
|
2952
|
+
_LOOP_ROUTE_KIND: Final[dict[str, str]] = {
|
|
2953
|
+
"complete": "handoff",
|
|
2954
|
+
"partial": "resume",
|
|
2955
|
+
"deferred": "resume",
|
|
2956
|
+
"blocked": "recover",
|
|
2957
|
+
"needs-human": "recover",
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
#: The deterministic sentence each NON-complete loop outcome renders inside its
|
|
2961
|
+
#: NEXT-STEPS block. Every one names the resume or recovery action and states that
|
|
2962
|
+
#: nothing downstream is ready — no wording here may imply that documentation, or any
|
|
2963
|
+
#: other downstream production stage, can start (REQ-PROD-02).
|
|
2964
|
+
_LOOP_OUTCOME_TEXT: Final[dict[str, str]] = {
|
|
2965
|
+
"partial": (
|
|
2966
|
+
"The loop stopped for {feature} with backlog items still pending — the "
|
|
2967
|
+
"iteration limit was reached before every item was done. The recorded state "
|
|
2968
|
+
"is resumable and nothing downstream is ready: run the loop again below to "
|
|
2969
|
+
"continue from where it stopped."
|
|
2970
|
+
),
|
|
2971
|
+
"deferred": (
|
|
2972
|
+
"The loop explicitly deferred items for {feature} — the runner gave up on "
|
|
2973
|
+
"them after retries rather than finishing them, so they were left for "
|
|
2974
|
+
"another pass. The recorded state is resumable and nothing downstream is "
|
|
2975
|
+
"ready: run the loop again below to pick the deferred items back up."
|
|
2976
|
+
),
|
|
2977
|
+
"blocked": (
|
|
2978
|
+
"The loop is blocked for {feature} — one or more backlog items could not be "
|
|
2979
|
+
"completed. Nothing downstream is ready. Run the navigator below to see the "
|
|
2980
|
+
"live pipeline state from disk and choose how to recover."
|
|
2981
|
+
),
|
|
2982
|
+
"needs-human": (
|
|
2983
|
+
"The loop stopped for {feature} on a decision only a human can make — one or "
|
|
2984
|
+
"more items asked a question it could not answer, and they were set aside. "
|
|
2985
|
+
"Nothing downstream is ready until those decisions are made. Run the "
|
|
2986
|
+
"navigator below to see the live pipeline state from disk and recover from "
|
|
2987
|
+
"there."
|
|
2988
|
+
),
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
#: The `complete` preamble, selected by where the handoff actually lands. The epic
|
|
2992
|
+
#: rows name the epic and its live rollup, so the operator can see WHY the handoff is
|
|
2993
|
+
#: this member's own documentation rather than another member (or vice versa).
|
|
2994
|
+
_LOOP_COMPLETE_TEXT: Final[dict[str, str]] = {
|
|
2995
|
+
"standalone": "Every backlog item is done for {feature}.",
|
|
2996
|
+
"epic-next-member": (
|
|
2997
|
+
"Every backlog item is done for {feature}, and the live status of epic "
|
|
2998
|
+
"{epic} ({complete}/{total} members complete) puts the next actionable work "
|
|
2999
|
+
"below."
|
|
3000
|
+
),
|
|
3001
|
+
"epic-complete-docs": (
|
|
3002
|
+
"Every backlog item is done for {feature}, and every member of epic {epic} "
|
|
3003
|
+
"is now complete ({complete}/{total}) — documentation is the next step below."
|
|
3004
|
+
),
|
|
3005
|
+
"epic-dashboard": (
|
|
3006
|
+
"Every backlog item is done for {feature}, and no member of epic {epic} is "
|
|
3007
|
+
"actionable right now ({complete}/{total} members complete). Open the epic "
|
|
3008
|
+
"dashboard below for its live state."
|
|
3009
|
+
),
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
#: Appended to the `complete` preamble. REQ-EXIT-06/REQ-PROD-02: while implementation
|
|
3013
|
+
#: verification is unresolved it is THE action and the handoff is demoted to unfenced
|
|
3014
|
+
#: prose, so documentation never becomes primary before a pass or an explicit skip.
|
|
3015
|
+
_LOOP_COMPLETE_OUTSTANDING: Final[str] = (
|
|
3016
|
+
" Implementation verification is still outstanding, so it comes first — nothing "
|
|
3017
|
+
"downstream becomes the primary action until it passes or is explicitly skipped."
|
|
3018
|
+
)
|
|
3019
|
+
_LOOP_COMPLETE_SETTLED: Final[str] = (
|
|
3020
|
+
" Its implementation verification is settled, so the pipeline continues with the "
|
|
3021
|
+
"action below."
|
|
3022
|
+
)
|
|
3023
|
+
_LOOP_COMPLETE_FINDINGS: Final[str] = (
|
|
3024
|
+
" Implementation verification already ran at this revision and reported findings, "
|
|
3025
|
+
"so applying them comes first — nothing downstream becomes the primary action "
|
|
3026
|
+
"until a re-verify passes or the verification is explicitly skipped."
|
|
3027
|
+
)
|
|
3028
|
+
|
|
3029
|
+
#: The outcome sentence a loop or documentation exit renders when a blocking
|
|
3030
|
+
#: epic change request DISPLACES its live continuation. Both route tables above name
|
|
3031
|
+
#: that continuation "below"; once the fence carries the reconcile instead, the claim is
|
|
3032
|
+
#: false, so the sentence is REPLACED rather than corrected after the fact. The displaced
|
|
3033
|
+
#: command is deliberately not named here — the block's own "After reconciling, continue
|
|
3034
|
+
#: the pipeline with" line is its single authoritative mention, so the two can never
|
|
3035
|
+
#: disagree. Only used when the displaced command actually differs from the reconcile:
|
|
3036
|
+
#: a route that already lands on the epic keeps its own accurate wording.
|
|
3037
|
+
_RECONCILE_FIRST_TEXT: Final[dict[str, str]] = {
|
|
3038
|
+
"forge-5-loop": (
|
|
3039
|
+
"Every backlog item is done for {feature} and its implementation verification "
|
|
3040
|
+
"is settled, but {count} blocking epic change request{plural} recorded against "
|
|
3041
|
+
"epic {epic} must be reconciled first. Proceeding would build on a "
|
|
3042
|
+
"decomposition that is about to change, so the reconcile below comes before "
|
|
3043
|
+
"the continuation named under it."
|
|
3044
|
+
),
|
|
3045
|
+
"forge-6-docs": (
|
|
3046
|
+
"Documentation is complete for {feature}, but {count} blocking epic change "
|
|
3047
|
+
"request{plural} recorded against epic {epic} must be reconciled first. "
|
|
3048
|
+
"Handing off would build the next member on a decomposition that is about to "
|
|
3049
|
+
"change, so the reconcile below comes before the continuation named under it."
|
|
3050
|
+
),
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
|
|
3054
|
+
def _promote_reconcile(
|
|
3055
|
+
stage: str,
|
|
3056
|
+
epic_reconcile: dict,
|
|
3057
|
+
feature: str,
|
|
3058
|
+
epic_name: object,
|
|
3059
|
+
primary_canonical: str,
|
|
3060
|
+
deferred_canonical: str | None,
|
|
3061
|
+
outcome_text: str | None,
|
|
3062
|
+
advancing: bool,
|
|
3063
|
+
) -> tuple[str, str | None, str | None]:
|
|
3064
|
+
"""Reconcile-first promotion for the loop and documentation routes.
|
|
3065
|
+
|
|
3066
|
+
Both routes compute their real primary from LIVE state (``render-status``), long
|
|
3067
|
+
after ``epicReconcile["deferred"]`` was seeded from the successor table. That seed
|
|
3068
|
+
is the wrong continuation for these two stages — for the loop it names this
|
|
3069
|
+
feature's own documentation, which the route deliberately did not choose, and for
|
|
3070
|
+
documentation it is None because the pipeline has no stage after it. So the
|
|
3071
|
+
continuation is re-derived here from the route's own result, never from the
|
|
3072
|
+
successor table (REQ-ROUTE-05/06: the live thread is what must survive).
|
|
3073
|
+
|
|
3074
|
+
Args:
|
|
3075
|
+
stage: `forge-5-loop` or `forge-6-docs` — selects the replacement wording.
|
|
3076
|
+
epic_reconcile: The blocking reconcile directive, MUTATED in place.
|
|
3077
|
+
feature: The exiting feature.
|
|
3078
|
+
epic_name: The epic the reconcile is recorded against.
|
|
3079
|
+
primary_canonical: The route's own primary command.
|
|
3080
|
+
deferred_canonical: The route's own deferred continuation, if any.
|
|
3081
|
+
outcome_text: The route's own outcome sentence.
|
|
3082
|
+
advancing: Whether the route's primary advances the pipeline.
|
|
3083
|
+
|
|
3084
|
+
Returns:
|
|
3085
|
+
`(primary_canonical, deferred_canonical, outcome_text)` after promotion.
|
|
3086
|
+
|
|
3087
|
+
A route whose primary IS the epic command (the dashboard handoffs) is not
|
|
3088
|
+
displaced by a reconcile that names the same command: promoting it would leave a
|
|
3089
|
+
"continue the pipeline with" line pointing back at the fence, so its own accurate
|
|
3090
|
+
wording and an absent continuation are kept instead.
|
|
3091
|
+
"""
|
|
3092
|
+
reconcile_command = epic_reconcile["command"]
|
|
3093
|
+
if not advancing:
|
|
3094
|
+
# Verification (or a recovery action) outranks the reconcile, so the reconcile
|
|
3095
|
+
# is the FIRST deferred action and the route's own continuation follows it.
|
|
3096
|
+
# Handing the renderer the same command the caller deferred is what collapses
|
|
3097
|
+
# the two conditional lines into one.
|
|
3098
|
+
epic_reconcile["deferred"] = deferred_canonical
|
|
3099
|
+
return primary_canonical, deferred_canonical, outcome_text
|
|
3100
|
+
if primary_canonical == reconcile_command:
|
|
3101
|
+
epic_reconcile["deferred"] = None
|
|
3102
|
+
return primary_canonical, None, outcome_text
|
|
3103
|
+
epic_reconcile["deferred"] = primary_canonical
|
|
3104
|
+
count = epic_reconcile["count"]
|
|
3105
|
+
return (
|
|
3106
|
+
reconcile_command,
|
|
3107
|
+
None,
|
|
3108
|
+
_RECONCILE_FIRST_TEXT[stage].format(
|
|
3109
|
+
feature=feature,
|
|
3110
|
+
epic=epic_name,
|
|
3111
|
+
count=count,
|
|
3112
|
+
plural="s" if count != 1 else "",
|
|
3113
|
+
),
|
|
3114
|
+
)
|
|
3115
|
+
|
|
3116
|
+
|
|
3117
|
+
def _loop_route(
|
|
3118
|
+
outcome: str,
|
|
3119
|
+
feature: str,
|
|
3120
|
+
epic: str | None,
|
|
3121
|
+
specs_dir: Path,
|
|
3122
|
+
successor_command: str | None,
|
|
3123
|
+
resolved: bool,
|
|
3124
|
+
verify_canonical: str,
|
|
3125
|
+
fix_canonical: str | None,
|
|
3126
|
+
) -> tuple[str, str | None, str, bool]:
|
|
3127
|
+
"""Route one loop result — the outcome table.
|
|
3128
|
+
|
|
3129
|
+
Every outcome lands on a deterministic action. Only ``complete`` may reach a
|
|
3130
|
+
production stage, and even then documentation is not primary until implementation
|
|
3131
|
+
verification passes or is explicitly skipped (REQ-PROD-02, REQ-EXIT-06). The four
|
|
3132
|
+
non-complete outcomes route to the loop resume (``partial``/``deferred``) or to
|
|
3133
|
+
the navigator (``blocked``/``needs-human``); their caller has already stripped the
|
|
3134
|
+
production successor, so no directive and no rendered line can imply that
|
|
3135
|
+
documentation is ready.
|
|
3136
|
+
|
|
3137
|
+
Args:
|
|
3138
|
+
outcome: A member of `EXIT_OUTCOMES["forge-5-loop"]`, already validated.
|
|
3139
|
+
feature: The feature whose loop stage is closing.
|
|
3140
|
+
epic: The owning epic, or None for a standalone feature.
|
|
3141
|
+
specs_dir: Configured specs directory.
|
|
3142
|
+
successor_command: Canonical live-successor command (documentation), or None.
|
|
3143
|
+
resolved: Whether the implementation verification is settled.
|
|
3144
|
+
verify_canonical: Canonical implementation-verify command.
|
|
3145
|
+
fix_canonical: Canonical forge-fix command when a findings report is live
|
|
3146
|
+
at the current revision (see ``live_findings_report`` in ``stage_exit``),
|
|
3147
|
+
else None. A live report outranks a fresh verify on the ``complete``
|
|
3148
|
+
handoff: findings already exist at this exact revision, so the fenced
|
|
3149
|
+
action is applying them, exactly as on a production re-exit.
|
|
3150
|
+
|
|
3151
|
+
Returns:
|
|
3152
|
+
`(primary_canonical, deferred_canonical, outcome_text, advancing)`, matching
|
|
3153
|
+
`_branch_route` and `_docs_route`.
|
|
3154
|
+
|
|
3155
|
+
For a completed EPIC MEMBER the handoff is delegated to the live
|
|
3156
|
+
``render-status`` payload rather than re-deriving dependency or completion logic
|
|
3157
|
+
here, preserving the epic handoff this stage already performed:
|
|
3158
|
+
an actionable member routes to the epic's own live next command; nothing
|
|
3159
|
+
actionable with every member complete routes to this member's documentation; and
|
|
3160
|
+
anything else opens the epic dashboard. The ``total > 0`` guard is what stops an
|
|
3161
|
+
EMPTY epic's ``0/0`` from reading as complete. A helper failure is the same
|
|
3162
|
+
actionable ``UsageError`` the documentation exit raises, so a broken epic graph
|
|
3163
|
+
surfaces instead of being guessed around. A NON-complete outcome never calls the
|
|
3164
|
+
helper: a resume or recovery action must stay reachable exactly when the epic's
|
|
3165
|
+
own state is the thing that is broken.
|
|
3166
|
+
"""
|
|
3167
|
+
kind = _LOOP_ROUTE_KIND[outcome]
|
|
3168
|
+
if kind != "handoff":
|
|
3169
|
+
primary = (
|
|
3170
|
+
f"/feature-forge:forge-5-loop {feature}"
|
|
3171
|
+
if kind == "resume"
|
|
3172
|
+
else f"/feature-forge:forge {feature}"
|
|
3173
|
+
)
|
|
3174
|
+
return primary, None, _LOOP_OUTCOME_TEXT[outcome].format(feature=feature), False
|
|
3175
|
+
|
|
3176
|
+
handoff = successor_command or f"/feature-forge:forge {feature}"
|
|
3177
|
+
fields: dict[str, object] = {"feature": feature, "epic": epic}
|
|
3178
|
+
key = "standalone"
|
|
3179
|
+
if epic is not None:
|
|
3180
|
+
status = _render_status(specs_dir, epic)
|
|
3181
|
+
rollup = status["rollup"]
|
|
3182
|
+
fields["complete"] = rollup["complete"]
|
|
3183
|
+
fields["total"] = rollup["total"]
|
|
3184
|
+
next_command = status["nextCommand"]
|
|
3185
|
+
if status["actionable"] and next_command:
|
|
3186
|
+
handoff, key = next_command, "epic-next-member"
|
|
3187
|
+
elif rollup["total"] > 0 and rollup["complete"] >= rollup["total"]:
|
|
3188
|
+
# Nothing left to start and every member complete: the epic's remaining
|
|
3189
|
+
# work is this member's documentation, which `handoff` already names.
|
|
3190
|
+
key = "epic-complete-docs"
|
|
3191
|
+
else:
|
|
3192
|
+
handoff, key = f"/feature-forge:forge-0-epic {epic}", "epic-dashboard"
|
|
3193
|
+
|
|
3194
|
+
if resolved:
|
|
3195
|
+
tail = _LOOP_COMPLETE_SETTLED
|
|
3196
|
+
elif fix_canonical is not None:
|
|
3197
|
+
tail = _LOOP_COMPLETE_FINDINGS
|
|
3198
|
+
else:
|
|
3199
|
+
tail = _LOOP_COMPLETE_OUTSTANDING
|
|
3200
|
+
text = _LOOP_COMPLETE_TEXT[key].format(**fields) + tail
|
|
3201
|
+
if resolved:
|
|
3202
|
+
return handoff, None, text, True
|
|
3203
|
+
if fix_canonical is not None:
|
|
3204
|
+
# A live findings report outranks a fresh verify, exactly as on a
|
|
3205
|
+
# production re-exit: the fenced action is the fix, the handoff is demoted.
|
|
3206
|
+
return fix_canonical, handoff, text, False
|
|
3207
|
+
# Verify-first ordering, applied to the loop's own handoff rather than to
|
|
3208
|
+
# the fixed successor: the verification is fenced and the handoff is demoted.
|
|
3209
|
+
return verify_canonical, handoff, text, False
|
|
3210
|
+
|
|
3211
|
+
|
|
3212
|
+
def _debt_metadata_warnings(
|
|
3213
|
+
entry: dict,
|
|
3214
|
+
verify_key: str | None,
|
|
3215
|
+
stage: str,
|
|
3216
|
+
subject: str,
|
|
3217
|
+
verify_command: str,
|
|
3218
|
+
current: int | None,
|
|
3219
|
+
) -> list[str]:
|
|
3220
|
+
"""Entries 2 and 3 of the ``warnings`` order, for owed automatic verification.
|
|
3221
|
+
|
|
3222
|
+
Entry 2 is the legacy/malformed ``scheduledStageVersion`` advisory;
|
|
3223
|
+
entry 3 is the scheduled-vs-current revision mismatch note. They are
|
|
3224
|
+
mutually exclusive by construction — a mismatch is only detectable once the
|
|
3225
|
+
recorded revision is usable — but the order is fixed regardless so a later
|
|
3226
|
+
entry can be added without re-deriving it.
|
|
3227
|
+
|
|
3228
|
+
Takes the already-resolved entry and revision rather than re-deriving them
|
|
3229
|
+
from a member state document: on an epic-scoped exit both come from
|
|
3230
|
+
``.epic-state.json`` and the manifest revision, which a member state cannot
|
|
3231
|
+
supply (REQ-SEC-01).
|
|
3232
|
+
|
|
3233
|
+
Args:
|
|
3234
|
+
entry: The verify entry the exit routed from (``{}`` when absent).
|
|
3235
|
+
verify_key: Its ``forge-verify-*`` key, or None for a tokenless stage.
|
|
3236
|
+
stage: The production stage the debt is owed on.
|
|
3237
|
+
subject: The feature or epic to name.
|
|
3238
|
+
verify_command: The host-translated retry command.
|
|
3239
|
+
current: The artifact's current revision, or None when unknown.
|
|
3240
|
+
"""
|
|
3241
|
+
if verify_key is None or entry.get("status") != "auto-verify-pending":
|
|
3242
|
+
return []
|
|
3243
|
+
scheduled = _scheduled_stage_version(entry)
|
|
3244
|
+
if scheduled is None:
|
|
3245
|
+
return [
|
|
3246
|
+
AUTO_VERIFY_DEBT_METADATA_DIAGNOSTIC.format(
|
|
3247
|
+
subject=subject, verify_key=verify_key, command=verify_command
|
|
3248
|
+
)
|
|
3249
|
+
]
|
|
3250
|
+
if current is not None and scheduled != current:
|
|
3251
|
+
return [
|
|
3252
|
+
auto_pending_message(subject, stage, verify_command, scheduled, current)
|
|
3253
|
+
]
|
|
3254
|
+
return []
|
|
3255
|
+
|
|
3256
|
+
|
|
3257
|
+
def _schedule_auto_verify_debt(
|
|
3258
|
+
specs_dir: Path, feature: str, epic: str | None, stage: str, verify_key: str
|
|
3259
|
+
) -> None:
|
|
3260
|
+
"""Persist `auto-verify-pending` for `stage` — the scheduling boundary.
|
|
3261
|
+
|
|
3262
|
+
Called immediately BEFORE `stage_exit` returns a payload carrying
|
|
3263
|
+
``runInStageVerify: true``, never after, so there is no window in which the
|
|
3264
|
+
model is told to verify while nothing on disk records that it was owed
|
|
3265
|
+
(REQ-DEBT-01, REQ-REL-03). The transition itself is `cmd_state_verify`'s —
|
|
3266
|
+
`_load_verify_target` selects the target and `_verify_result_entry` builds the
|
|
3267
|
+
entry — so a scheduled marker is byte-identical to one written through the CLI.
|
|
3268
|
+
|
|
3269
|
+
Idempotent by target revision (REQ-REL-01): an entry already
|
|
3270
|
+
`auto-verify-pending` at the current revision returns without calling
|
|
3271
|
+
`_commit_state`, so `scheduledAt`, top-level `updatedAt`, and the file bytes
|
|
3272
|
+
are all untouched. A newer revision supersedes the older marker with exactly
|
|
3273
|
+
one write. The caller's `resolved` and live-report checks are what keep a
|
|
3274
|
+
fresh terminal entry, an explicit `skipped`, or a `findings-reported` entry
|
|
3275
|
+
at the current revision from ever reaching this function — the last because
|
|
3276
|
+
a write here REPLACES the entry and would delete its report metadata
|
|
3277
|
+
(REQ-EXIT-04).
|
|
3278
|
+
|
|
3279
|
+
Unlike the `state-verify` CLI, a target whose artifact revision is unknown
|
|
3280
|
+
(no recorded `version`, or an epic with no readable manifest) records the debt
|
|
3281
|
+
with a null `scheduledStageVersion` rather than refusing: the obligation is
|
|
3282
|
+
real either way, and an unusable schedule is already classified as
|
|
3283
|
+
`auto-pending` plus a warning. Forgetting the debt because its revision is
|
|
3284
|
+
unknown is the REQ-DEBT-02 conflation, and refusing would turn a routine stage
|
|
3285
|
+
closing into an exit 2.
|
|
3286
|
+
|
|
3287
|
+
Args:
|
|
3288
|
+
specs_dir: The configured specs directory.
|
|
3289
|
+
feature: The feature name, or the EPIC name for an epic-scoped exit.
|
|
3290
|
+
epic: The owning epic for a member, else None.
|
|
3291
|
+
stage: The production stage the debt is owed on (`forge-0-epic` for an
|
|
3292
|
+
epic-scoped exit).
|
|
3293
|
+
verify_key: The `forge-verify-*` key to write.
|
|
3294
|
+
|
|
3295
|
+
Raises:
|
|
3296
|
+
UsageError: Unsafe/ambiguous/unresolvable target, corrupt state, or an
|
|
3297
|
+
atomic-write failure (→ exit 2, no payload and no dispatch directive).
|
|
3298
|
+
"""
|
|
3299
|
+
is_epic_target = stage == "forge-0-epic"
|
|
3300
|
+
state_path, state, epic_revision = _load_verify_target(
|
|
3301
|
+
specs_dir, feature, epic, is_epic_target
|
|
3302
|
+
)
|
|
3303
|
+
if is_epic_target:
|
|
3304
|
+
current = epic_revision
|
|
3305
|
+
else:
|
|
3306
|
+
version = _stage_version(state, stage)
|
|
3307
|
+
current = (
|
|
3308
|
+
version
|
|
3309
|
+
if isinstance(version, int) and not isinstance(version, bool) and version >= 1
|
|
3310
|
+
else None
|
|
3311
|
+
)
|
|
3312
|
+
prior = _verify_entry(state, verify_key)
|
|
3313
|
+
if (
|
|
3314
|
+
prior.get("status") == "auto-verify-pending"
|
|
3315
|
+
and _scheduled_stage_version(prior) == current
|
|
3316
|
+
):
|
|
3317
|
+
return
|
|
3318
|
+
state.setdefault("stages", {})[verify_key] = _verify_result_entry(
|
|
3319
|
+
"auto-verify-pending", prior, current, None, None, _now_iso()
|
|
3320
|
+
)
|
|
3321
|
+
_commit_state(state_path, state)
|
|
3322
|
+
|
|
3323
|
+
|
|
3324
|
+
def stage_exit(
|
|
3325
|
+
feature: str,
|
|
3326
|
+
stage: str,
|
|
3327
|
+
specs_dir: Path,
|
|
3328
|
+
config_path: Path,
|
|
3329
|
+
epic: str | None,
|
|
3330
|
+
host: str,
|
|
3331
|
+
next_feature: str | None,
|
|
3332
|
+
served_stage: str | None = None,
|
|
3333
|
+
verify_mode: str | None = None,
|
|
3334
|
+
outcome: str | None = None,
|
|
3335
|
+
owner: str | None = None,
|
|
3336
|
+
verify_capability: str = "manual",
|
|
3337
|
+
) -> StageExitPayload:
|
|
3338
|
+
"""Compute a deterministic stage-exit payload.
|
|
3339
|
+
|
|
3340
|
+
Args:
|
|
3341
|
+
feature: Safe feature name, or epic name for an epic-scoped exit.
|
|
3342
|
+
stage: One member of `EXIT_STAGES`.
|
|
3343
|
+
specs_dir: Configured specs directory.
|
|
3344
|
+
config_path: Path to `forge.config.json`.
|
|
3345
|
+
epic: Owning epic for a nested member, otherwise None.
|
|
3346
|
+
host: Command-rendering host: `claude`, `pi`, or `generic`.
|
|
3347
|
+
next_feature: Explicit epic handoff member, when applicable.
|
|
3348
|
+
served_stage: Production stage served by direct verify/fix.
|
|
3349
|
+
verify_mode: Verify mode used to infer `served_stage` when unique.
|
|
3350
|
+
outcome: Required stage-specific outcome for loop/docs/verify/fix.
|
|
3351
|
+
owner: Required for verify/fix: `direct` or `nested`.
|
|
3352
|
+
verify_capability: `interactive` only when both question and clean-room
|
|
3353
|
+
verifier dispatch capabilities exist; otherwise `manual`. Dispatch
|
|
3354
|
+
capability is permission, not tool presence: a dispatch permitted
|
|
3355
|
+
only once the user has asked is still `interactive`, because the
|
|
3356
|
+
`standard` gate's own prompt supplies that request.
|
|
3357
|
+
|
|
3358
|
+
Returns:
|
|
3359
|
+
A JSON-serializable `StageExitPayload` dictionary.
|
|
3360
|
+
|
|
3361
|
+
Raises:
|
|
3362
|
+
UsageError: Unsafe or ambiguous identity, unsupported stage/outcome,
|
|
3363
|
+
missing ownership/served-stage metadata, or conflicting inference.
|
|
3364
|
+
|
|
3365
|
+
Directive semantics (the contract in ``references/stage-exit-protocol.md``):
|
|
3366
|
+
|
|
3367
|
+
- ``runInStageVerify`` — the effective auto-verify (per-stage override,
|
|
3368
|
+
else global; strict-true) is on AND this stage's verify is not already
|
|
3369
|
+
resolved (fresh/skipped) AND no findings report exists at the current
|
|
3370
|
+
revision (that state routes to forge-fix instead — scheduling over the
|
|
3371
|
+
report would delete its metadata, REQ-EXIT-04). The skill then dispatches
|
|
3372
|
+
the clean-room verify in-session (principle #2: verify before the clear).
|
|
3373
|
+
- ``autoVerifyDebtRecorded`` — the ``auto-verify-pending`` marker for this
|
|
3374
|
+
stage is durably on disk. Written BEFORE this payload exists, so
|
|
3375
|
+
a failed write raises ``UsageError`` and returns no payload at all and
|
|
3376
|
+
``runInStageVerify: True`` with ``autoVerifyDebtRecorded: False`` is
|
|
3377
|
+
unreachable. Scheduling is idempotent by target revision: a repeat at the
|
|
3378
|
+
same revision touches neither ``scheduledAt`` nor top-level ``updatedAt``.
|
|
3379
|
+
- ``autoFixEligible`` — ``autoFix`` is strict-true AND the in-stage verify
|
|
3380
|
+
runs AND the working tree is clean. Findings-level preconditions (zero
|
|
3381
|
+
unresolved decisions) remain the skill's runtime check. Its clean-tree
|
|
3382
|
+
snapshot is taken BEFORE the debt write, so that sanctioned control-plane
|
|
3383
|
+
mutation cannot dirty its own precondition.
|
|
3384
|
+
- ``verifyState``/``warnings``/``cleanTree`` — all PRE-mutation snapshots:
|
|
3385
|
+
they describe the state the routing decision was made from, which is why a
|
|
3386
|
+
first exit reports ``never`` while the debt it just recorded reads
|
|
3387
|
+
``auto-pending`` on the next one. Only ``autoVerifyDebtRecorded`` reports
|
|
3388
|
+
the write.
|
|
3389
|
+
- ``verifyGate`` — ``none`` when verify is resolved (including a tokenless
|
|
3390
|
+
stage), the in-stage run covers it, or a live findings report routes to
|
|
3391
|
+
forge-fix (the fenced fix IS the one action — a "verify now?" prompt
|
|
3392
|
+
beside it would be a second, contradictory ask); ``standard`` when
|
|
3393
|
+
auto-verify is off, verification is outstanding, and the CALLER declared
|
|
3394
|
+
``--verify-capability interactive``; ``manual-print`` for the same state
|
|
3395
|
+
under ``manual`` (print ``verifyCommand`` instead of presenting the gate).
|
|
3396
|
+
Never a function of ``--host``: capable Pi is ``standard`` and incapable
|
|
3397
|
+
Claude is ``manual-print`` (REQ-EXIT-07).
|
|
3398
|
+
- ``primaryCommand``/``deferredCommand`` — the verify-first pair. While
|
|
3399
|
+
verification is unresolved ``primaryCommand`` is the verify command — or
|
|
3400
|
+
the forge-fix command when a findings report is live at the current
|
|
3401
|
+
revision — and is the ONLY fenced command; ``deferredCommand`` names the
|
|
3402
|
+
production successor as unfenced conditional prose. ``nextCommand`` stays
|
|
3403
|
+
compatibility/routing metadata and never overrides ``primaryCommand``
|
|
3404
|
+
(REQ-EXIT-06).
|
|
3405
|
+
- ``nextStage``/``nextCommand`` — from pipeline state when it already
|
|
3406
|
+
records this stage complete (first non-complete production stage), else
|
|
3407
|
+
the fixed successor. ``--next-feature`` names the first actionable
|
|
3408
|
+
feature for the epic handoff; without it an epic exit hands back to the
|
|
3409
|
+
epic dashboard rather than naming a member it cannot resolve.
|
|
3410
|
+
With it, the handoff is derived from THAT member's live state via
|
|
3411
|
+
``next_stage``: a progressed member resumes where it actually is,
|
|
3412
|
+
a fully complete member hands back to the epic dashboard, and a member
|
|
3413
|
+
whose state cannot be resolved falls back to ``forge-1-prd`` with
|
|
3414
|
+
``warnings`` entry 1 naming it.
|
|
3415
|
+
- ``epicReconcile`` — present only when the exiting member carries
|
|
3416
|
+
``open`` ``epicChangeRequests`` (epic-backflow). ``required: true`` (any
|
|
3417
|
+
``blocksCurrent: true`` request) interposes a reconcile-first exit: the
|
|
3418
|
+
NEXT-STEPS primary command becomes ``/feature-forge:forge-0-epic {epic}``
|
|
3419
|
+
and the normal next stage is deferred. Only non-blocking requests set
|
|
3420
|
+
``reminder: true`` and append a non-blocking reminder line. Absent when
|
|
3421
|
+
there are no open requests (common path) or the epic name is unresolvable.
|
|
3422
|
+
- ``servedStage``/``verifyMode``/``outcome``/``owner``/``terminalOwnedBy`` —
|
|
3423
|
+
branch metadata. A production exit serves only itself, so ``servedStage``
|
|
3424
|
+
is None there; ``verifyStage`` is the DISTINCT value ``pending_verify``
|
|
3425
|
+
returns, naming the stage outstanding verification is owed on.
|
|
3426
|
+
On a branch exit the outcome table in ``_branch_route`` — not
|
|
3427
|
+
verify-first ordering — supplies ``primaryCommand``: a diversion rejoins
|
|
3428
|
+
the production stage it served, and every recovery/defer route carries
|
|
3429
|
+
``--served-stage`` forward so the thread is never dropped (issue #176).
|
|
3430
|
+
- ``forge-5-loop`` — routed by its required ``--outcome``. ``complete``
|
|
3431
|
+
keeps verify-first ordering in front of the documentation/epic-member handoff,
|
|
3432
|
+
which for a member is delegated to the live ``render-status`` payload rather
|
|
3433
|
+
than re-derived here. The other four outcomes route to the loop resume
|
|
3434
|
+
(``partial``/``deferred``) or the navigator (``blocked``/``needs-human``) and
|
|
3435
|
+
suppress every downstream signal: ``nextStage``/``nextCommand`` are None,
|
|
3436
|
+
``runInStageVerify`` is False, no debt is scheduled, and ``verifyGate`` is
|
|
3437
|
+
``none`` — a loop still in flight has no finished implementation to verify and
|
|
3438
|
+
nothing downstream may read as ready (REQ-PROD-02).
|
|
3439
|
+
- ``forge-6-docs`` — the documentation terminus is decided by LIVE epic
|
|
3440
|
+
state, never by the successor table: for an epic member the adjacent
|
|
3441
|
+
``epic-manifest.py render-status`` supplies the next actionable member's own
|
|
3442
|
+
command, and anything else routes to the epic dashboard. A ``blocked``
|
|
3443
|
+
outcome routes to recovery and never claims completion. Any helper failure
|
|
3444
|
+
is an actionable ``UsageError`` — exit 2 with no payload, so no guessed
|
|
3445
|
+
member command and no sentinel can escape (REQ-REL-02).
|
|
3446
|
+
- ``warnings`` — non-fatal advisories in the documented fixed order. Always
|
|
3447
|
+
present; ``[]`` means checked-and-clean, which is not the same as absent.
|
|
3448
|
+
|
|
3449
|
+
Read-only and deterministic. Syntactic validation fails closed with
|
|
3450
|
+
``UsageError`` (exit 2, no payload and no sentinel); everything after it
|
|
3451
|
+
degrades to defaults rather than crashing a stage closing.
|
|
3452
|
+
"""
|
|
3453
|
+
# ---- Deterministic validation order ----------------------------------- #
|
|
3454
|
+
# 1. Safe names and containment, before any strict filesystem access.
|
|
3455
|
+
_assert_safe_name(feature, "--feature")
|
|
3456
|
+
if epic is not None:
|
|
3457
|
+
_assert_safe_name(epic, "--epic")
|
|
3458
|
+
if next_feature is not None:
|
|
3459
|
+
_assert_safe_name(next_feature, "--next-feature")
|
|
3460
|
+
|
|
3461
|
+
# 2. The stage domain itself.
|
|
3462
|
+
if stage not in EXIT_STAGES:
|
|
3463
|
+
raise UsageError(
|
|
3464
|
+
f"unsupported --stage {stage!r}; expected one of {', '.join(EXIT_STAGES)}"
|
|
3465
|
+
)
|
|
3466
|
+
|
|
3467
|
+
# 3./4. Stages 0-4 reject an outcome; loop/docs/verify/fix require their own.
|
|
3468
|
+
# argparse cannot express a different enum per stage, so the domain check is here.
|
|
3469
|
+
allowed_outcomes = EXIT_OUTCOMES.get(stage)
|
|
3470
|
+
if allowed_outcomes is None:
|
|
3471
|
+
if outcome is not None:
|
|
3472
|
+
raise UsageError(
|
|
3473
|
+
f"--outcome is not accepted for {stage}; its exit is state-driven "
|
|
3474
|
+
"and has a single outcome"
|
|
3475
|
+
)
|
|
3476
|
+
elif outcome is None:
|
|
3477
|
+
raise UsageError(
|
|
3478
|
+
f"{stage} requires --outcome; expected one of "
|
|
3479
|
+
f"{', '.join(sorted(allowed_outcomes))}"
|
|
3480
|
+
)
|
|
3481
|
+
elif outcome not in allowed_outcomes:
|
|
3482
|
+
raise UsageError(
|
|
3483
|
+
f"--outcome {outcome!r} is not valid for {stage}; expected one of "
|
|
3484
|
+
f"{', '.join(sorted(allowed_outcomes))}"
|
|
3485
|
+
)
|
|
3486
|
+
|
|
3487
|
+
# 5. Ownership: required for the branch skills, rejected for stages 0-6.
|
|
3488
|
+
if stage in _BRANCH_STAGES:
|
|
3489
|
+
if owner is None:
|
|
3490
|
+
raise UsageError(
|
|
3491
|
+
f"{stage} requires --owner direct (this call prints the terminal "
|
|
3492
|
+
"block) or --owner nested (an outer stage owns it)"
|
|
3493
|
+
)
|
|
3494
|
+
if owner not in get_args(ExitOwner):
|
|
3495
|
+
raise UsageError(
|
|
3496
|
+
f"--owner {owner!r} is not valid; expected direct or nested"
|
|
3497
|
+
)
|
|
3498
|
+
elif owner is not None:
|
|
3499
|
+
raise UsageError(
|
|
3500
|
+
f"--owner is not accepted for {stage}; only forge-verify and forge-fix "
|
|
3501
|
+
"carry branch ownership, and stages 0-6 are always direct owners"
|
|
3502
|
+
)
|
|
3503
|
+
|
|
3504
|
+
# 6. Host and capability, independently. A host NEVER implies a capability.
|
|
3505
|
+
if host not in EXIT_HOSTS:
|
|
3506
|
+
raise UsageError(
|
|
3507
|
+
f"unknown --host {host!r}; expected one of {', '.join(EXIT_HOSTS)}"
|
|
3508
|
+
)
|
|
3509
|
+
if verify_capability not in get_args(VerifyCapability):
|
|
3510
|
+
raise UsageError(
|
|
3511
|
+
f"unknown --verify-capability {verify_capability!r}; expected "
|
|
3512
|
+
f"{' or '.join(get_args(VerifyCapability))}"
|
|
3513
|
+
)
|
|
3514
|
+
|
|
3515
|
+
# 7./8. Served stage for branch exits; branch-only flags rejected elsewhere.
|
|
3516
|
+
if stage in _BRANCH_STAGES:
|
|
3517
|
+
resolved_served: str | None = resolve_served_stage(served_stage, verify_mode)
|
|
3518
|
+
else:
|
|
3519
|
+
if served_stage is not None or verify_mode is not None:
|
|
3520
|
+
raise UsageError(
|
|
3521
|
+
"--served-stage and --verify-mode are branch-only; "
|
|
3522
|
+
f"{stage} is a production stage and serves only itself"
|
|
3523
|
+
)
|
|
3524
|
+
resolved_served = None
|
|
3525
|
+
if next_feature is not None and stage != "forge-0-epic":
|
|
3526
|
+
raise UsageError(
|
|
3527
|
+
f"--next-feature is accepted only for forge-0-epic, not {stage}"
|
|
3528
|
+
)
|
|
3529
|
+
|
|
3530
|
+
# A loop that did not complete has NO production successor and owes no
|
|
3531
|
+
# implementation verification yet. Everything downstream is suppressed below —
|
|
3532
|
+
# `nextStage`/`nextCommand`, the epic-reconcile deferred line, the in-stage
|
|
3533
|
+
# verify chain, its debt write, and the verify gate — because each of them
|
|
3534
|
+
# would assert that the implementation is finished enough to move on, which is
|
|
3535
|
+
# exactly the readiness claim REQ-PROD-02 forbids.
|
|
3536
|
+
loop_incomplete = stage == "forge-5-loop" and outcome != "complete"
|
|
3537
|
+
|
|
3538
|
+
config = _load_config(config_path)
|
|
3539
|
+
invalid_keys = invalid_auto_verify_keys(config)
|
|
3540
|
+
for key in invalid_keys: # already sorted; advisory, never fatal
|
|
3541
|
+
print(
|
|
3542
|
+
INVALID_AUTO_VERIFY_KEY_WARNING.format(
|
|
3543
|
+
key=key, valid=", ".join(VERIFY_TOKEN_BY_STAGE)
|
|
3544
|
+
),
|
|
3545
|
+
file=sys.stderr,
|
|
3546
|
+
)
|
|
3547
|
+
feature_dir = _resolve_feature_dir(specs_dir, feature, epic)
|
|
3548
|
+
state = _read_state(feature_dir / PIPELINE_STATE_FILENAME)
|
|
3549
|
+
|
|
3550
|
+
# Epic edit-mode: resolve the SELECTED member's live progress here, before
|
|
3551
|
+
# the scheduling boundary below, so an ambiguous identity exits 2 without having
|
|
3552
|
+
# mutated anything. `--next-feature` is accepted only for `forge-0-epic` (step 1),
|
|
3553
|
+
# so this is exactly the epic edit-mode selection. Read-only: no candidate state
|
|
3554
|
+
# file is opened for writing on this path.
|
|
3555
|
+
member_state: dict = {}
|
|
3556
|
+
member_reason: str | None = None
|
|
3557
|
+
if next_feature is not None:
|
|
3558
|
+
member_state, member_reason = _epic_member_state(specs_dir, feature, next_feature)
|
|
3559
|
+
|
|
3560
|
+
# The clean-tree snapshot is taken HERE, before the sanctioned debt write
|
|
3561
|
+
# below, so the pending marker cannot dirty its own precondition.
|
|
3562
|
+
# Every other directive is likewise a pre-mutation snapshot; only
|
|
3563
|
+
# `autoVerifyDebtRecorded` describes what the write did.
|
|
3564
|
+
git_repo = _git_output(["rev-parse", "--git-dir"]) is not None
|
|
3565
|
+
clean_tree: bool | None = None
|
|
3566
|
+
if git_repo:
|
|
3567
|
+
porcelain = _git_output(["status", "--porcelain"])
|
|
1671
3568
|
clean_tree = porcelain is None or porcelain == ""
|
|
1672
3569
|
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
3570
|
+
# A branch exit routes from the production stage it SERVED, never from itself:
|
|
3571
|
+
# `forge-verify` has no artifact, no verify token, and no successor of its own.
|
|
3572
|
+
# For a production exit the two are the same stage, so stages 0-4 are unchanged.
|
|
3573
|
+
route_stage = resolved_served if resolved_served is not None else stage
|
|
3574
|
+
|
|
3575
|
+
# Verification context for the routed stage. An epic-scoped route reads
|
|
3576
|
+
# `.epic-state.json` and the manifest revision DIRECTLY — never
|
|
3577
|
+
# `_resolve_feature_dir`, never a member stage version (REQ-SEC-01).
|
|
3578
|
+
verify_token = _EXIT_VERIFY_TOKEN.get(route_stage)
|
|
3579
|
+
verify_key = f"forge-verify-{verify_token}" if verify_token else None
|
|
3580
|
+
if route_stage == "forge-0-epic":
|
|
3581
|
+
# EPIC-scoped: the entry and the revision come from `.epic-state.json` and the
|
|
3582
|
+
# manifest, never from a member stage version, so this branch cannot route
|
|
3583
|
+
# through the stage-scoped helper below. `forge-0-epic` always has a token.
|
|
3584
|
+
verify_entry, verify_current = _epic_verify_context(specs_dir, feature)
|
|
3585
|
+
verify_label = _classify_verify_entry(verify_entry, verify_key, verify_current)
|
|
3586
|
+
else:
|
|
3587
|
+
verify_entry = _verify_entry(state, verify_key) if verify_key else {}
|
|
3588
|
+
verify_current = _stage_version(state, route_stage) if verify_key else None
|
|
3589
|
+
# Classify through `_verify_state_for`, the designated stage-exit routing
|
|
3590
|
+
# classifier, rather than re-deriving its two steps inline. The inline copy
|
|
3591
|
+
# left `_verify_state_for` with no runtime
|
|
3592
|
+
# caller, so `tests/test_auto_verify.py` could pin routing labels through a
|
|
3593
|
+
# function the CLI never executed. It repeats the `_EXIT_VERIFY_TOKEN` lookup
|
|
3594
|
+
# and `_classify_verify_entry` call above and returns "none" for a tokenless
|
|
3595
|
+
# stage (forge-6-docs), where there is no verification to owe.
|
|
3596
|
+
verify_label = _verify_state_for(state, route_stage)
|
|
3597
|
+
# ``none`` is resolved for routing purposes: no verify command is promoted.
|
|
3598
|
+
resolved = verify_label in ("fresh", "skipped", "none")
|
|
3599
|
+
# A findings report AT THE CURRENT revision is live evidence, not owed debt.
|
|
3600
|
+
# Scheduling over it would REPLACE the entry (`_verify_result_entry` builds
|
|
3601
|
+
# replacements, not patches) and delete `findingsFile`/`findingsCount` —
|
|
3602
|
+
# the same REQ-EXIT-04 clobber the branch-exit guard below forbids, reached
|
|
3603
|
+
# instead from a production re-exit. The outstanding obligation is the FIX,
|
|
3604
|
+
# so this exit routes to forge-fix and never re-schedules; a report left
|
|
3605
|
+
# behind by a since-revised artifact is superseded normally.
|
|
3606
|
+
reported_version = verify_entry.get("verifiedStageVersion")
|
|
3607
|
+
live_findings_report = (
|
|
3608
|
+
verify_label == "failing"
|
|
3609
|
+
and isinstance(reported_version, int)
|
|
3610
|
+
and not isinstance(reported_version, bool)
|
|
3611
|
+
and verify_current is not None
|
|
3612
|
+
and reported_version == verify_current
|
|
3613
|
+
)
|
|
3614
|
+
effective_auto_verify = auto_verify_for(config, route_stage)
|
|
3615
|
+
# A BRANCH exit is already inside the verification diversion, so it never owes
|
|
3616
|
+
# an in-stage verify chain and never schedules debt. Without this a
|
|
3617
|
+
# `forge-verify --outcome findings` exit would both direct a re-dispatch of
|
|
3618
|
+
# itself and overwrite the `findings-reported` entry it had just written with
|
|
3619
|
+
# a fresh `auto-verify-pending` marker, losing the report (REQ-EXIT-04).
|
|
3620
|
+
# Branch rejoin routing belongs to the outcome tables, not this boundary.
|
|
3621
|
+
run_in_stage = (
|
|
3622
|
+
effective_auto_verify
|
|
3623
|
+
and not resolved
|
|
3624
|
+
and not live_findings_report
|
|
3625
|
+
and stage not in _BRANCH_STAGES
|
|
3626
|
+
and not loop_incomplete
|
|
3627
|
+
)
|
|
1677
3628
|
auto_fix_eligible = (
|
|
1678
3629
|
config.get("autoFix") is True and run_in_stage and clean_tree is True
|
|
1679
3630
|
)
|
|
1680
|
-
|
|
3631
|
+
|
|
3632
|
+
# ---- Scheduling boundary ---------------------------------------------- #
|
|
3633
|
+
# The debt lands BEFORE the payload exists, so a crash between here and the
|
|
3634
|
+
# dispatch leaves durable state exposing the obligation, and a failed write
|
|
3635
|
+
# raises UsageError with no payload at all — `runInStageVerify: True` with
|
|
3636
|
+
# `autoVerifyDebtRecorded: False` is therefore unreachable.
|
|
3637
|
+
auto_verify_debt_recorded = False
|
|
3638
|
+
if run_in_stage and verify_key is not None:
|
|
3639
|
+
_schedule_auto_verify_debt(specs_dir, feature, epic, route_stage, verify_key)
|
|
3640
|
+
auto_verify_debt_recorded = True
|
|
3641
|
+
# Priority table. The gate is a pure function of the verification state
|
|
3642
|
+
# and the caller's declared capability: `--host` selects command syntax and
|
|
3643
|
+
# fresh-session wording ONLY. A capable Pi session gets `standard`; an
|
|
3644
|
+
# incapable Claude session gets `manual-print` (REQ-EXIT-07). Whether the
|
|
3645
|
+
# caller needed user consent to dispatch is the CALLER's determination
|
|
3646
|
+
# and is invisible here — a consent-required caller sends
|
|
3647
|
+
# `interactive` and gets `standard`, which is the intended path.
|
|
3648
|
+
#
|
|
3649
|
+
# A BRANCH exit is already inside the diversion and its outcome table
|
|
3650
|
+
# names the one action to take, so there is nothing left to gate: offering
|
|
3651
|
+
# "verify now?" beside a fenced fix command would be a second, contradictory
|
|
3652
|
+
# ask. The table's `verify` routes ARE the verification prompt.
|
|
3653
|
+
#
|
|
3654
|
+
# A non-complete loop outcome is gateless for the same reason it never
|
|
3655
|
+
# schedules debt: there is no finished implementation to verify, so offering
|
|
3656
|
+
# "verify now?" beside a fenced loop resume would ask for a verification of
|
|
3657
|
+
# work that is still in flight.
|
|
3658
|
+
# A live findings report is likewise gateless: the fenced forge-fix route IS
|
|
3659
|
+
# the one action, and a "verify now?" prompt beside it would be a second,
|
|
3660
|
+
# contradictory ask for a verification that already ran at this revision.
|
|
3661
|
+
if (
|
|
3662
|
+
resolved
|
|
3663
|
+
or run_in_stage
|
|
3664
|
+
or live_findings_report
|
|
3665
|
+
or stage in _BRANCH_STAGES
|
|
3666
|
+
or loop_incomplete
|
|
3667
|
+
):
|
|
1681
3668
|
verify_gate = "none"
|
|
1682
|
-
elif
|
|
3669
|
+
elif verify_capability == "interactive":
|
|
1683
3670
|
verify_gate = "standard"
|
|
1684
3671
|
else:
|
|
1685
3672
|
verify_gate = "manual-print"
|
|
1686
3673
|
|
|
1687
|
-
next_stage_id = _EXIT_NEXT_STAGE.get(
|
|
3674
|
+
next_stage_id = _EXIT_NEXT_STAGE.get(route_stage)
|
|
1688
3675
|
state_next = next_stage(state)
|
|
1689
3676
|
if (
|
|
1690
|
-
|
|
3677
|
+
route_stage in PRODUCTION_STAGES
|
|
1691
3678
|
and state_next is not None
|
|
1692
|
-
and PRODUCTION_STAGES.index(state_next) > PRODUCTION_STAGES.index(
|
|
3679
|
+
and PRODUCTION_STAGES.index(state_next) > PRODUCTION_STAGES.index(route_stage)
|
|
1693
3680
|
):
|
|
1694
3681
|
# State records this stage complete AND its walk lands beyond it —
|
|
1695
3682
|
# trust it (it skips stages already completed out of order). A missing
|
|
1696
3683
|
# or behind-the-stage walk (state not yet flushed, corrupt file) falls
|
|
1697
3684
|
# back to the fixed successor, never to an earlier stage.
|
|
1698
3685
|
next_stage_id = state_next
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
3686
|
+
# Keyed off the ROUTED stage, so a branch exit that served the epic decomposition
|
|
3687
|
+
# hands off the same way the epic's own exit does. Identical to the previous
|
|
3688
|
+
# behavior for every production exit, where `route_stage is stage`.
|
|
3689
|
+
if route_stage == "forge-0-epic" and next_feature is None:
|
|
3690
|
+
# An epic exit that names no concrete member has nothing to hand off to.
|
|
3691
|
+
# The dashboard is the same non-fabrication answer given to a named member
|
|
3692
|
+
# that has finished every production stage: never invent a member, and
|
|
3693
|
+
# never print a template the user cannot run.
|
|
3694
|
+
next_stage_id = None
|
|
3695
|
+
next_command = f"/feature-forge:forge-0-epic {feature}"
|
|
3696
|
+
else:
|
|
3697
|
+
next_arg = next_feature or feature
|
|
3698
|
+
next_command = (
|
|
3699
|
+
f"/feature-forge:{next_stage_id} {next_arg}" if next_stage_id else None
|
|
3700
|
+
)
|
|
3701
|
+
|
|
3702
|
+
# ---- Epic edit-mode live member routing (issue #175) -------------------- #
|
|
3703
|
+
# The fixed `forge-0-epic -> forge-1-prd` successor above is a CREATION-mode
|
|
3704
|
+
# answer: a member that has just been decomposed has no completed production
|
|
3705
|
+
# stage, so PRD is right. In edit mode the selected member may be anywhere in
|
|
3706
|
+
# the pipeline, and sending it back to PRD would ask for work already done.
|
|
3707
|
+
# The live position comes from the member's own state via `next_stage`, never
|
|
3708
|
+
# from the epic's state, the successor table, or conversational context.
|
|
3709
|
+
epic_member_warning: str | None = None
|
|
3710
|
+
if next_feature is not None:
|
|
3711
|
+
if member_reason is not None:
|
|
3712
|
+
# Degrade DOWN, never up: an unreadable member cannot be assumed to
|
|
3713
|
+
# have progressed, and inferring a later stage would fabricate the
|
|
3714
|
+
# very progress this exit failed to read (REQ-PROD-06).
|
|
3715
|
+
epic_member_warning = EPIC_MEMBER_FALLBACK_WARNING.format(
|
|
3716
|
+
member=next_feature, epic=feature, reason=member_reason
|
|
3717
|
+
)
|
|
3718
|
+
next_stage_id = "forge-1-prd"
|
|
3719
|
+
next_command = f"/feature-forge:forge-1-prd {next_feature}"
|
|
3720
|
+
else:
|
|
3721
|
+
member_next = next_stage(member_state)
|
|
3722
|
+
if member_next is None:
|
|
3723
|
+
# Every production stage is complete. There is no stage 7 to
|
|
3724
|
+
# fabricate, so the handoff is the epic dashboard itself.
|
|
3725
|
+
next_stage_id = None
|
|
3726
|
+
next_command = f"/feature-forge:forge-0-epic {feature}"
|
|
3727
|
+
else:
|
|
3728
|
+
next_stage_id = member_next
|
|
3729
|
+
next_command = f"/feature-forge:{member_next} {next_feature}"
|
|
3730
|
+
|
|
3731
|
+
if loop_incomplete:
|
|
3732
|
+
# The pipeline has no next production stage from here, exactly as it has
|
|
3733
|
+
# none after `forge-6-docs`. Cleared BEFORE the epic-backflow block below,
|
|
3734
|
+
# so a blocking reconcile's `deferred` line cannot re-introduce
|
|
3735
|
+
# `/feature-forge:forge-6-docs` as text the loop resume did not earn.
|
|
3736
|
+
next_stage_id = None
|
|
3737
|
+
next_command = None
|
|
1703
3738
|
|
|
1704
3739
|
# Epic backflow routing: an exiting member may carry epic-level change requests
|
|
1705
3740
|
# (recorded by forge-1-prd/forge-2-tech). A `blocksCurrent: true` request means
|
|
@@ -1709,6 +3744,15 @@ def stage_exit(
|
|
|
1709
3744
|
# The epic name comes from the `--epic` arg or the state's `epic` back-pointer.
|
|
1710
3745
|
epic_reconcile: dict | None = None
|
|
1711
3746
|
epic_name = epic or state.get("epic")
|
|
3747
|
+
# The epic a documentation or completed-loop exit routes against:
|
|
3748
|
+
# the explicit `--epic`, else the state's back-pointer. A back-pointer is
|
|
3749
|
+
# untrusted on-disk data, so it is name-checked here rather than reaching the
|
|
3750
|
+
# helper's argv (REQ-SEC-01); an unusable value degrades to the standalone route
|
|
3751
|
+
# rather than crashing a stage closing. `--epic` itself was already validated in
|
|
3752
|
+
# step 1.
|
|
3753
|
+
route_epic = (
|
|
3754
|
+
epic_name if isinstance(epic_name, str) and SAFE_NAME_RE.match(epic_name) else None
|
|
3755
|
+
)
|
|
1712
3756
|
open_requests = [
|
|
1713
3757
|
r
|
|
1714
3758
|
for r in state.get("epicChangeRequests", [])
|
|
@@ -1732,38 +3776,184 @@ def stage_exit(
|
|
|
1732
3776
|
"count": len(open_requests),
|
|
1733
3777
|
}
|
|
1734
3778
|
|
|
3779
|
+
# ---- Verify-first primary routing ------------------------------------- #
|
|
3780
|
+
# While verification is unresolved the verify command is THE action — except
|
|
3781
|
+
# under a live findings report, whose one action is the forge-fix that applies
|
|
3782
|
+
# it. Either way the production successor is demoted to unfenced conditional
|
|
3783
|
+
# prose. No path may fence or recommend the deferred production command first
|
|
3784
|
+
# (REQ-EXIT-06).
|
|
3785
|
+
verify_canonical = f"/feature-forge:forge-verify {feature}"
|
|
3786
|
+
# The one action a live findings report promotes, on every route that can
|
|
3787
|
+
# reach it: findings already exist at this exact revision, so re-dispatching
|
|
3788
|
+
# verify would only restate them. The served stage is carried so the fix
|
|
3789
|
+
# rejoins this production thread.
|
|
3790
|
+
fix_canonical = f"/feature-forge:forge-fix {feature} --served-stage {route_stage}"
|
|
3791
|
+
verify_command = _host_command(verify_canonical, host)
|
|
3792
|
+
blocking_reconcile = bool(epic_reconcile and epic_reconcile.get("required"))
|
|
3793
|
+
primary_canonical: str | None
|
|
3794
|
+
deferred_canonical: str | None
|
|
3795
|
+
outcome_text: str | None = None
|
|
3796
|
+
if stage in _BRANCH_STAGES:
|
|
3797
|
+
# The outcome table alone decides a branch terminus — verify-first
|
|
3798
|
+
# ordering does not apply, because the branch IS the verification work.
|
|
3799
|
+
primary_canonical, deferred_canonical, outcome_text, advancing = _branch_route(
|
|
3800
|
+
stage,
|
|
3801
|
+
outcome,
|
|
3802
|
+
feature,
|
|
3803
|
+
resolved_served,
|
|
3804
|
+
next_command,
|
|
3805
|
+
resolved,
|
|
3806
|
+
)
|
|
3807
|
+
if advancing and blocking_reconcile:
|
|
3808
|
+
# An advancing rejoin is subject to the same reconcile-first rule as a
|
|
3809
|
+
# production exit; a non-advancing one already outranks the reconcile.
|
|
3810
|
+
primary_canonical = epic_reconcile["command"]
|
|
3811
|
+
deferred_canonical = None
|
|
3812
|
+
elif stage == "forge-5-loop":
|
|
3813
|
+
# Every loop result gets a deterministic resume or recovery action.
|
|
3814
|
+
# `complete` keeps verify-first ordering (the table applies it to its own
|
|
3815
|
+
# handoff); the other four never reach a production stage at all.
|
|
3816
|
+
primary_canonical, deferred_canonical, outcome_text, advancing = _loop_route(
|
|
3817
|
+
outcome,
|
|
3818
|
+
feature,
|
|
3819
|
+
route_epic,
|
|
3820
|
+
specs_dir,
|
|
3821
|
+
next_command,
|
|
3822
|
+
resolved,
|
|
3823
|
+
verify_canonical,
|
|
3824
|
+
fix_canonical if live_findings_report else None,
|
|
3825
|
+
)
|
|
3826
|
+
if blocking_reconcile:
|
|
3827
|
+
# Same reconcile-first rule as every other advancing route — but the
|
|
3828
|
+
# continuation carried forward is the LOOP's, not the successor table's
|
|
3829
|
+
# documentation stage, which this route deliberately did not choose.
|
|
3830
|
+
primary_canonical, deferred_canonical, outcome_text = _promote_reconcile(
|
|
3831
|
+
stage,
|
|
3832
|
+
epic_reconcile,
|
|
3833
|
+
feature,
|
|
3834
|
+
epic_name,
|
|
3835
|
+
primary_canonical,
|
|
3836
|
+
deferred_canonical,
|
|
3837
|
+
outcome_text,
|
|
3838
|
+
advancing,
|
|
3839
|
+
)
|
|
3840
|
+
elif stage == "forge-6-docs":
|
|
3841
|
+
# The documentation terminus is decided by LIVE epic state, not by
|
|
3842
|
+
# the successor table — the pipeline ends here, so there is no next stage
|
|
3843
|
+
# to fence and no verification to put first (docs is tokenless).
|
|
3844
|
+
primary_canonical, deferred_canonical, outcome_text, advancing = _docs_route(
|
|
3845
|
+
feature,
|
|
3846
|
+
route_epic,
|
|
3847
|
+
specs_dir,
|
|
3848
|
+
outcome,
|
|
3849
|
+
host,
|
|
3850
|
+
)
|
|
3851
|
+
if blocking_reconcile:
|
|
3852
|
+
# Same reconcile-first rule as an advancing branch rejoin: handing off to
|
|
3853
|
+
# the next member would build it on a decomposition that is about to
|
|
3854
|
+
# change. A non-advancing docs route already lands on the epic itself. The
|
|
3855
|
+
# successor table has no entry for this stage, so its seeded continuation
|
|
3856
|
+
# is None — the live route's own primary is what must be carried forward.
|
|
3857
|
+
primary_canonical, deferred_canonical, outcome_text = _promote_reconcile(
|
|
3858
|
+
stage,
|
|
3859
|
+
epic_reconcile,
|
|
3860
|
+
feature,
|
|
3861
|
+
epic_name,
|
|
3862
|
+
primary_canonical,
|
|
3863
|
+
deferred_canonical,
|
|
3864
|
+
outcome_text,
|
|
3865
|
+
advancing,
|
|
3866
|
+
)
|
|
3867
|
+
elif live_findings_report:
|
|
3868
|
+
primary_canonical = fix_canonical
|
|
3869
|
+
deferred_canonical = next_command
|
|
3870
|
+
elif not resolved:
|
|
3871
|
+
primary_canonical = verify_canonical
|
|
3872
|
+
deferred_canonical = next_command
|
|
3873
|
+
elif blocking_reconcile:
|
|
3874
|
+
# Verification is settled, so the blocking reconcile is the primary
|
|
3875
|
+
# action and `epicReconcile["deferred"]` carries the demoted successor.
|
|
3876
|
+
primary_canonical = epic_reconcile["command"]
|
|
3877
|
+
deferred_canonical = None
|
|
3878
|
+
else:
|
|
3879
|
+
primary_canonical = next_command or "/feature-forge:forge"
|
|
3880
|
+
deferred_canonical = None
|
|
3881
|
+
|
|
3882
|
+
# Fixed order: entry 1 is the epic-member unreadable-state fallback,
|
|
3883
|
+
# then the debt-metadata and revision-mismatch entries.
|
|
3884
|
+
warnings: list[str] = []
|
|
3885
|
+
if epic_member_warning is not None:
|
|
3886
|
+
warnings.append(epic_member_warning)
|
|
3887
|
+
warnings.extend(
|
|
3888
|
+
_debt_metadata_warnings(
|
|
3889
|
+
verify_entry, verify_key, route_stage, feature, verify_command, verify_current
|
|
3890
|
+
)
|
|
3891
|
+
)
|
|
3892
|
+
|
|
3893
|
+
# `owner == "nested"` means an outer authoring stage prints the terminal block.
|
|
3894
|
+
# The routing directives survive; the human-facing block does not exist at all,
|
|
3895
|
+
# so a nested chain can never emit a second sentinel (REQ-EXIT-03/04).
|
|
3896
|
+
nested = owner == "nested"
|
|
3897
|
+
|
|
1735
3898
|
directives = {
|
|
1736
3899
|
"stage": stage,
|
|
1737
3900
|
"stageNoun": STAGE_NOUN.get(stage, stage),
|
|
3901
|
+
"servedStage": resolved_served,
|
|
3902
|
+
"verifyMode": _STAGE_TO_VERIFY_MODE.get(resolved_served or ""),
|
|
3903
|
+
"outcome": outcome,
|
|
3904
|
+
"owner": owner,
|
|
3905
|
+
"terminalOwnedBy": "outer" if nested else "self",
|
|
1738
3906
|
"feature": feature,
|
|
1739
3907
|
"runInStageVerify": run_in_stage,
|
|
1740
3908
|
"verifyGate": verify_gate,
|
|
3909
|
+
"verifyCapability": verify_capability,
|
|
1741
3910
|
"autoFixEligible": auto_fix_eligible,
|
|
1742
3911
|
"verifyState": verify_label,
|
|
1743
|
-
"
|
|
3912
|
+
"verifyStage": pending_verify(state),
|
|
3913
|
+
"verifyCommand": verify_command,
|
|
1744
3914
|
"autoVerifyEffective": effective_auto_verify,
|
|
3915
|
+
"autoVerifyDebtRecorded": auto_verify_debt_recorded,
|
|
1745
3916
|
"nextStage": next_stage_id,
|
|
1746
3917
|
"nextCommand": _host_command(next_command, host) if next_command else next_command,
|
|
1747
|
-
"
|
|
3918
|
+
"primaryCommand": _host_command(primary_canonical, host),
|
|
3919
|
+
"deferredCommand": (
|
|
3920
|
+
_host_command(deferred_canonical, host) if deferred_canonical else None
|
|
3921
|
+
),
|
|
3922
|
+
"invalidAutoVerifyKeys": invalid_keys,
|
|
3923
|
+
"warnings": warnings,
|
|
1748
3924
|
"gitRepo": git_repo,
|
|
1749
3925
|
"cleanTree": clean_tree,
|
|
1750
3926
|
"host": host,
|
|
1751
3927
|
}
|
|
1752
3928
|
if epic_reconcile is not None:
|
|
1753
3929
|
directives["epicReconcile"] = epic_reconcile
|
|
3930
|
+
if nested:
|
|
3931
|
+
return {"directives": directives, "nextSteps": None, "sentinel": None}
|
|
1754
3932
|
return {
|
|
1755
3933
|
"directives": directives,
|
|
1756
3934
|
"nextSteps": _next_steps_block(
|
|
1757
|
-
|
|
3935
|
+
primary_canonical,
|
|
3936
|
+
host,
|
|
3937
|
+
epic_reconcile,
|
|
3938
|
+
deferred_command=deferred_canonical,
|
|
3939
|
+
outcome_text=outcome_text,
|
|
1758
3940
|
),
|
|
1759
3941
|
"sentinel": NEXT_STEPS_SENTINEL,
|
|
1760
3942
|
}
|
|
1761
3943
|
|
|
1762
3944
|
|
|
1763
3945
|
def _print_stage_exit(payload: dict) -> None:
|
|
1764
|
-
"""Print DIRECTIVES then the NEXT-STEPS block (the skill-facing form).
|
|
3946
|
+
"""Print DIRECTIVES then the NEXT-STEPS block (the skill-facing form).
|
|
3947
|
+
|
|
3948
|
+
A NESTED branch payload carries ``nextSteps is None``: an outer authoring stage
|
|
3949
|
+
owns the terminal block, so this printer emits the directives and stops. Printing
|
|
3950
|
+
a terminal section here — even an empty one — is the ownership leak REQ-EXIT-04
|
|
3951
|
+
forbids.
|
|
3952
|
+
"""
|
|
1765
3953
|
print("DIRECTIVES:")
|
|
1766
3954
|
print(json.dumps(payload["directives"], indent=2, ensure_ascii=False))
|
|
3955
|
+
if payload.get("nextSteps") is None:
|
|
3956
|
+
return
|
|
1767
3957
|
print(
|
|
1768
3958
|
"NEXT-STEPS (print this block verbatim as your absolute last output — "
|
|
1769
3959
|
"nothing after the sentinel):"
|
|
@@ -2066,6 +4256,135 @@ def _load_state_for_write(
|
|
|
2066
4256
|
return state_path, state
|
|
2067
4257
|
|
|
2068
4258
|
|
|
4259
|
+
def _assert_safe_name(name: str, label: str) -> None:
|
|
4260
|
+
"""Reject a name that could steer a write outside ``{specsDir}/{name}``.
|
|
4261
|
+
|
|
4262
|
+
Args:
|
|
4263
|
+
name: The bare name supplied on the command line.
|
|
4264
|
+
label: The flag to name in the error (e.g. ``--feature``).
|
|
4265
|
+
|
|
4266
|
+
Raises:
|
|
4267
|
+
UsageError: Empty, absolute, separator-bearing, ``..``, or not a single
|
|
4268
|
+
kebab-case token (→ exit 2, nothing read or written).
|
|
4269
|
+
"""
|
|
4270
|
+
if (
|
|
4271
|
+
not name
|
|
4272
|
+
or name == ".."
|
|
4273
|
+
or "/" in name
|
|
4274
|
+
or "\\" in name
|
|
4275
|
+
or os.path.isabs(name)
|
|
4276
|
+
or not SAFE_NAME_RE.match(name)
|
|
4277
|
+
):
|
|
4278
|
+
raise UsageError(f"unsafe name {name!r} for {label}")
|
|
4279
|
+
|
|
4280
|
+
|
|
4281
|
+
def _load_epic_state_for_write(
|
|
4282
|
+
specs_dir: Path, epic_name: str, epic: str | None
|
|
4283
|
+
) -> tuple[Path, dict, int]:
|
|
4284
|
+
"""Resolve an EPIC's ``.epic-state.json`` and its manifest revision, for mutation.
|
|
4285
|
+
|
|
4286
|
+
The epic counterpart of ``_load_state_for_write``, and deliberately NOT a
|
|
4287
|
+
variant of it: epic verification is epic-scoped and must never resolve, read,
|
|
4288
|
+
create, or write a member's ``.pipeline-state.json`` (REQ-SEC-01). There is no
|
|
4289
|
+
fallback in either direction — an epic whose manifest is missing or whose
|
|
4290
|
+
identity disagrees is an error, not a feature lookup.
|
|
4291
|
+
|
|
4292
|
+
Resolution is strict where the member resolver is tolerant: the name must be a
|
|
4293
|
+
safe single token, the joined path must stay inside ``specs_dir`` after symlink
|
|
4294
|
+
resolution, ``epic-manifest.json`` must exist, and the manifest's own ``epic``
|
|
4295
|
+
value must equal ``epic_name``. The revision comes from the manifest, which is
|
|
4296
|
+
the canonical artifact version for epic freshness — never a member's
|
|
4297
|
+
production-stage version. A legacy manifest with no ``revision`` is
|
|
4298
|
+
presented as logical ``1`` here, matching ``epic-manifest.py::load_manifest``,
|
|
4299
|
+
and its bytes are not rewritten.
|
|
4300
|
+
|
|
4301
|
+
Args:
|
|
4302
|
+
specs_dir: The configured specs directory (``--specs-dir``).
|
|
4303
|
+
epic_name: The epic name — what ``--feature`` carries for this stage.
|
|
4304
|
+
epic: The ``--epic`` value, which must be absent or equal to ``epic_name``.
|
|
4305
|
+
|
|
4306
|
+
Returns:
|
|
4307
|
+
A ``(state_path, state, revision)`` tuple. ``state`` is the lazily created
|
|
4308
|
+
minimal shell (``epic`` + ``stages``) when no epic state exists yet.
|
|
4309
|
+
|
|
4310
|
+
Raises:
|
|
4311
|
+
UsageError: Conflicting ``--feature``/``--epic``, unsafe name, containment
|
|
4312
|
+
escape, missing/unparseable/non-object/identity-mismatched manifest,
|
|
4313
|
+
invalid manifest revision, or an unparseable/non-object epic state or
|
|
4314
|
+
``stages`` value (→ exit 2, nothing written).
|
|
4315
|
+
"""
|
|
4316
|
+
if epic is not None and epic != epic_name:
|
|
4317
|
+
raise UsageError(
|
|
4318
|
+
f"--stage forge-0-epic writes epic-scoped state, so --feature names the "
|
|
4319
|
+
f"epic: --feature {epic_name!r} and --epic {epic!r} disagree. Drop --epic "
|
|
4320
|
+
f"or make it match."
|
|
4321
|
+
)
|
|
4322
|
+
_assert_safe_name(epic_name, "--feature")
|
|
4323
|
+
base_real = specs_dir.resolve()
|
|
4324
|
+
epic_dir = (base_real / epic_name).resolve()
|
|
4325
|
+
if epic_dir != base_real and base_real not in epic_dir.parents:
|
|
4326
|
+
raise UsageError(
|
|
4327
|
+
f"resolved epic path escapes the specs dir: {specs_dir / epic_name}"
|
|
4328
|
+
)
|
|
4329
|
+
|
|
4330
|
+
manifest_path = epic_dir / MANIFEST_FILENAME
|
|
4331
|
+
if not manifest_path.is_file():
|
|
4332
|
+
raise UsageError(
|
|
4333
|
+
f"no epic manifest at {manifest_path} — --stage forge-0-epic verifies an "
|
|
4334
|
+
f"epic, and {epic_name!r} is not one. Nothing was written."
|
|
4335
|
+
)
|
|
4336
|
+
try:
|
|
4337
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
4338
|
+
except json.JSONDecodeError as exc:
|
|
4339
|
+
raise UsageError(f"{manifest_path} is not valid JSON ({exc})") from exc
|
|
4340
|
+
if not isinstance(manifest, dict):
|
|
4341
|
+
raise UsageError(f"{manifest_path} is not a JSON object")
|
|
4342
|
+
if manifest.get("epic") != epic_name:
|
|
4343
|
+
raise UsageError(
|
|
4344
|
+
f"{manifest_path} declares epic {manifest.get('epic')!r}, not "
|
|
4345
|
+
f"{epic_name!r}; refusing to write verification state for a mismatched "
|
|
4346
|
+
f"epic identity"
|
|
4347
|
+
)
|
|
4348
|
+
revision = _require_positive_int(
|
|
4349
|
+
manifest.get("revision", 1), f"{epic_name}/{MANIFEST_FILENAME} revision"
|
|
4350
|
+
)
|
|
4351
|
+
|
|
4352
|
+
state_path = epic_dir / EPIC_STATE_FILENAME
|
|
4353
|
+
if state_path.exists():
|
|
4354
|
+
try:
|
|
4355
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
4356
|
+
except json.JSONDecodeError as exc:
|
|
4357
|
+
raise UsageError(
|
|
4358
|
+
f"{state_path} exists but is not valid JSON ({exc}); refusing to "
|
|
4359
|
+
f"overwrite it. Fix or move the file, then re-run."
|
|
4360
|
+
) from exc
|
|
4361
|
+
if not isinstance(state, dict):
|
|
4362
|
+
raise UsageError(
|
|
4363
|
+
f"{state_path} is not a JSON object; refusing to overwrite it."
|
|
4364
|
+
)
|
|
4365
|
+
recorded = state.get("epic")
|
|
4366
|
+
if recorded is not None and recorded != epic_name:
|
|
4367
|
+
raise UsageError(
|
|
4368
|
+
f"{state_path} records epic {recorded!r}, not {epic_name!r}; "
|
|
4369
|
+
f"refusing to overwrite it."
|
|
4370
|
+
)
|
|
4371
|
+
stages = state.get("stages")
|
|
4372
|
+
if stages is not None and not isinstance(stages, dict):
|
|
4373
|
+
raise UsageError(
|
|
4374
|
+
f"{state_path} has a non-object 'stages' value ({type(stages).__name__}); "
|
|
4375
|
+
f"refusing to overwrite it."
|
|
4376
|
+
)
|
|
4377
|
+
else:
|
|
4378
|
+
state = {}
|
|
4379
|
+
# Seed the minimal state shape in its documented key order. ``updatedAt`` is
|
|
4380
|
+
# a placeholder: every caller stamps it through ``_commit_state`` immediately
|
|
4381
|
+
# before the single atomic replacement, so the null never reaches disk.
|
|
4382
|
+
state.setdefault("epic", epic_name)
|
|
4383
|
+
state.setdefault("updatedAt", None)
|
|
4384
|
+
state.setdefault("stages", {})
|
|
4385
|
+
return state_path, state, revision
|
|
4386
|
+
|
|
4387
|
+
|
|
2069
4388
|
def _commit_state(state_path: Path, state: dict) -> dict:
|
|
2070
4389
|
"""Refresh ``updatedAt`` and write ``state`` atomically; return it for echo.
|
|
2071
4390
|
|
|
@@ -2073,7 +4392,11 @@ def _commit_state(state_path: Path, state: dict) -> dict:
|
|
|
2073
4392
|
always refreshed on a successful write and the write is atomic.
|
|
2074
4393
|
|
|
2075
4394
|
Args:
|
|
2076
|
-
state_path: The resolved
|
|
4395
|
+
state_path: The resolved state-file path — a feature's
|
|
4396
|
+
``.pipeline-state.json``, or an epic's ``.epic-state.json``. The helper
|
|
4397
|
+
is target-agnostic: it stamps and writes whatever document it is given,
|
|
4398
|
+
so an epic write reuses the same atomic mechanism without
|
|
4399
|
+
going anywhere near the member resolver.
|
|
2077
4400
|
state: The mutated state dict.
|
|
2078
4401
|
|
|
2079
4402
|
Returns:
|
|
@@ -2166,10 +4489,18 @@ def cmd_state_artifact(
|
|
|
2166
4489
|
The mutated state dict (for the --json echo).
|
|
2167
4490
|
|
|
2168
4491
|
Raises:
|
|
2169
|
-
UsageError:
|
|
2170
|
-
|
|
4492
|
+
UsageError: A ``--path`` that is empty, absolute, ``..``-bearing,
|
|
4493
|
+
control-character-bearing, or escaping the feature directory; an
|
|
4494
|
+
unknown feature directory, an unparseable state file, or a failed
|
|
4495
|
+
atomic write (→ exit 2).
|
|
2171
4496
|
"""
|
|
2172
4497
|
state_path, state = _load_state_for_write(specs_dir, feature, epic)
|
|
4498
|
+
# Containment is checked against the resolved feature dir, which only the load
|
|
4499
|
+
# produces; every path is validated before any of them is appended, so a
|
|
4500
|
+
# rejected value in a repeated --path list leaves the file untouched.
|
|
4501
|
+
target_dir = state_path.parent
|
|
4502
|
+
for path in paths:
|
|
4503
|
+
_validated_findings_file(path, target_dir, label="--path")
|
|
2173
4504
|
entry = _stage_entry(state, stage)
|
|
2174
4505
|
artifacts = entry.setdefault("artifacts", [])
|
|
2175
4506
|
for path in paths:
|
|
@@ -2282,7 +4613,8 @@ def cmd_state_complete(
|
|
|
2282
4613
|
Sets ONLY ``commitHash``, leaving status/version/artifacts intact. Guarded
|
|
2283
4614
|
on the stage already being ``complete``, so a typo'd ``--stage`` cannot
|
|
2284
4615
|
write a lone ``{"commitHash": …}`` entry (which would violate
|
|
2285
|
-
``stageEntry``'s ``required: ["status"]``) at exit 0.
|
|
4616
|
+
``stageEntry``'s ``required: ["status"]``) at exit 0. The value must be a
|
|
4617
|
+
full 40-hex object hash (REQ-STATE-01), checked before anything is loaded.
|
|
2286
4618
|
2. ``resumable`` — the failed-Commit-1 revert (`references/shared-conventions.md`
|
|
2287
4619
|
L245). Records ONLY ``status = "in-progress"`` plus the ``updatedAt``
|
|
2288
4620
|
refresh: no completedAt, no version bump, no basedOnVersions/artifacts
|
|
@@ -2308,7 +4640,8 @@ def cmd_state_complete(
|
|
|
2308
4640
|
based_on: Parsed ``{upstreamStage: version}`` provenance map.
|
|
2309
4641
|
artifacts: Final canonical artifact path list for this stage.
|
|
2310
4642
|
commit_hash: If given, record it as the stage's commitHash (Commit 2);
|
|
2311
|
-
else set commitHash to None (Commit 1).
|
|
4643
|
+
else set commitHash to None (Commit 1). Full 40-hex only on a new
|
|
4644
|
+
write — an abbreviation is rejected rather than expanded.
|
|
2312
4645
|
specs_dir: Specs directory.
|
|
2313
4646
|
epic: Owning epic name, or None.
|
|
2314
4647
|
status: Terminal status to record — "complete" (the default when the flag
|
|
@@ -2325,6 +4658,7 @@ def cmd_state_complete(
|
|
|
2325
4658
|
|
|
2326
4659
|
Raises:
|
|
2327
4660
|
UsageError: Contradictory ``--resumable --status complete``, a
|
|
4661
|
+
``--version`` below 1, a short or non-hex ``--commit-hash``, a
|
|
2328
4662
|
``--commit-hash`` follow-up against a stage that is not complete, an
|
|
2329
4663
|
unknown feature directory, an unparseable state file, or a failed
|
|
2330
4664
|
atomic write (→ exit 2).
|
|
@@ -2333,6 +4667,14 @@ def cmd_state_complete(
|
|
|
2333
4667
|
raise UsageError(
|
|
2334
4668
|
"--resumable implies --status in-progress; do not pass --status complete"
|
|
2335
4669
|
)
|
|
4670
|
+
# The write path must not accept a version the read path refuses; checked before
|
|
4671
|
+
# the state file is loaded for mutation, so a rejection touches nothing.
|
|
4672
|
+
_require_positive_int(version, "--version")
|
|
4673
|
+
if commit_hash is not None:
|
|
4674
|
+
# Branch 1's first act: full 40-hex only, validated BEFORE the
|
|
4675
|
+
# state file is loaded for mutation and long before _commit_state. Legacy
|
|
4676
|
+
# short hashes already recorded in state keep loading unmigrated.
|
|
4677
|
+
_assert_full_commit_hash(commit_hash)
|
|
2336
4678
|
state_path, state = _load_state_for_write(specs_dir, feature, epic)
|
|
2337
4679
|
entry = _stage_entry(state, stage)
|
|
2338
4680
|
cascaded: list[str] = []
|
|
@@ -2542,6 +4884,539 @@ def cmd_state_ecr(
|
|
|
2542
4884
|
return _commit_state(state_path, state)
|
|
2543
4885
|
|
|
2544
4886
|
|
|
4887
|
+
def _require_positive_int(value: object, label: str) -> int:
|
|
4888
|
+
"""Return ``value`` as a positive int, or raise ``UsageError``.
|
|
4889
|
+
|
|
4890
|
+
``bool`` is rejected explicitly: it is an ``int`` subclass, so ``True`` would
|
|
4891
|
+
otherwise sail through as version 1 and record a freshness ledger entry for an
|
|
4892
|
+
artifact revision that never existed.
|
|
4893
|
+
|
|
4894
|
+
Args:
|
|
4895
|
+
value: The candidate revision/version.
|
|
4896
|
+
label: The flag or field name to name in the error.
|
|
4897
|
+
|
|
4898
|
+
Returns:
|
|
4899
|
+
The validated positive integer.
|
|
4900
|
+
|
|
4901
|
+
Raises:
|
|
4902
|
+
UsageError: Not an int, a bool, or below 1 (→ exit 2).
|
|
4903
|
+
"""
|
|
4904
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
4905
|
+
raise UsageError(f"{label} must be a positive integer; got {value!r}")
|
|
4906
|
+
return value
|
|
4907
|
+
|
|
4908
|
+
|
|
4909
|
+
def _validated_findings_file(
|
|
4910
|
+
value: str, target_dir: Path, label: str = "--findings-file"
|
|
4911
|
+
) -> str:
|
|
4912
|
+
"""Return ``value`` if it is a safe relative path inside ``target_dir``.
|
|
4913
|
+
|
|
4914
|
+
``findingsFile`` is defined as relative to the
|
|
4915
|
+
feature directory, and downstream consumers (forge-fix selecting the report)
|
|
4916
|
+
follow the stored value verbatim. So it gets the same fail-closed containment
|
|
4917
|
+
treatment as the write target itself (REQ-SEC-01): an absolute path, a ``..``
|
|
4918
|
+
segment, a NUL/control character, or a symlinked escape is rejected BEFORE any
|
|
4919
|
+
mutation rather than persisted for a later reader to resolve.
|
|
4920
|
+
|
|
4921
|
+
The same containment contract governs every stored path a caller asserts is
|
|
4922
|
+
inside the feature directory, so the flag being validated is a parameter: the
|
|
4923
|
+
diagnostic must name the flag the user actually passed.
|
|
4924
|
+
|
|
4925
|
+
Args:
|
|
4926
|
+
value: The candidate path, as supplied on the command line.
|
|
4927
|
+
target_dir: The resolved feature (or epic) directory it must sit inside.
|
|
4928
|
+
label: The flag to name in the error.
|
|
4929
|
+
|
|
4930
|
+
Returns:
|
|
4931
|
+
The value unchanged, once validated.
|
|
4932
|
+
|
|
4933
|
+
Raises:
|
|
4934
|
+
UsageError: Empty, absolute, ``..``-bearing, control-character-bearing, or
|
|
4935
|
+
escaping the target directory (→ exit 2).
|
|
4936
|
+
"""
|
|
4937
|
+
if not value:
|
|
4938
|
+
raise UsageError(f"{label} must not be empty")
|
|
4939
|
+
bad = next((ch for ch in value if ord(ch) < 32 or ord(ch) == 127), None)
|
|
4940
|
+
if bad is not None:
|
|
4941
|
+
raise UsageError(
|
|
4942
|
+
f"{label} contains a control character ({bad!r}); "
|
|
4943
|
+
f"expected a plain relative path"
|
|
4944
|
+
)
|
|
4945
|
+
candidate = Path(value)
|
|
4946
|
+
if candidate.is_absolute():
|
|
4947
|
+
raise UsageError(
|
|
4948
|
+
f"{label} {value!r} is absolute; it must be relative to the "
|
|
4949
|
+
f"feature directory ({target_dir})"
|
|
4950
|
+
)
|
|
4951
|
+
if ".." in candidate.parts:
|
|
4952
|
+
raise UsageError(
|
|
4953
|
+
f"{label} {value!r} contains a '..' segment; it must stay inside "
|
|
4954
|
+
f"the feature directory ({target_dir})"
|
|
4955
|
+
)
|
|
4956
|
+
root = target_dir.resolve()
|
|
4957
|
+
resolved = (target_dir / candidate).resolve()
|
|
4958
|
+
if resolved == root or root not in resolved.parents:
|
|
4959
|
+
raise UsageError(
|
|
4960
|
+
f"{label} {value!r} escapes the feature directory ({target_dir}); "
|
|
4961
|
+
f"refusing to record it"
|
|
4962
|
+
)
|
|
4963
|
+
return value
|
|
4964
|
+
|
|
4965
|
+
|
|
4966
|
+
def _current_artifact_version(state: dict, stage: str) -> int:
|
|
4967
|
+
"""Return the artifact revision a verify result is being recorded against.
|
|
4968
|
+
|
|
4969
|
+
For a feature target that is the selected production stage's ``version``. A
|
|
4970
|
+
result other than ``skipped`` cannot be recorded without it: `passed` and
|
|
4971
|
+
`findings-reported` write it into the freshness ledger, and
|
|
4972
|
+
`auto-verify-pending` writes it as the revision the debt is owed on.
|
|
4973
|
+
|
|
4974
|
+
Args:
|
|
4975
|
+
state: The loaded state document.
|
|
4976
|
+
stage: The production stage the verify entry serves.
|
|
4977
|
+
|
|
4978
|
+
Returns:
|
|
4979
|
+
The stage's current positive-integer version.
|
|
4980
|
+
|
|
4981
|
+
Raises:
|
|
4982
|
+
UsageError: The stage has no recorded (or no valid) ``version`` (→ exit 2).
|
|
4983
|
+
"""
|
|
4984
|
+
version = _stage_version(state, stage)
|
|
4985
|
+
if version is None:
|
|
4986
|
+
raise UsageError(
|
|
4987
|
+
f"{stage} has no recorded version in this feature's state, so there is no "
|
|
4988
|
+
f"artifact revision to verify against; run state-complete for {stage} first"
|
|
4989
|
+
)
|
|
4990
|
+
return _require_positive_int(version, f"{stage}.version")
|
|
4991
|
+
|
|
4992
|
+
|
|
4993
|
+
def _assert_full_commit_hash(commit_hash: object) -> None:
|
|
4994
|
+
"""Reject a ``--commit-hash`` that is not exactly 40 hexadecimal characters.
|
|
4995
|
+
|
|
4996
|
+
REQ-STATE-01 constrains WRITES, not reads. New provenance is a
|
|
4997
|
+
full ``git rev-parse HEAD`` object hash; an abbreviation is rejected rather than
|
|
4998
|
+
expanded, because expanding one would mean shelling out to Git from a script
|
|
4999
|
+
whose whole contract is bounded local file reads. Caller case is preserved —
|
|
5000
|
+
the regex accepts either case and nothing normalizes it.
|
|
5001
|
+
|
|
5002
|
+
Nothing constrains the schema, so a legacy short hash already recorded in state
|
|
5003
|
+
keeps loading through ``_read_state``, ``_load_state_for_write``, the manifest
|
|
5004
|
+
status readers, the navigator, and stage exit unmigrated (REQ-STATE-02).
|
|
5005
|
+
|
|
5006
|
+
Args:
|
|
5007
|
+
commit_hash: The supplied value, typed loosely so a non-string reaching the
|
|
5008
|
+
callable in-process is refused here rather than at serialization time.
|
|
5009
|
+
|
|
5010
|
+
Raises:
|
|
5011
|
+
UsageError: The value is not a 40-character hex string (→ exit 2, before
|
|
5012
|
+
any load-for-mutation and always before ``_commit_state``).
|
|
5013
|
+
"""
|
|
5014
|
+
if isinstance(commit_hash, str) and FULL_GIT_HASH_RE.fullmatch(commit_hash):
|
|
5015
|
+
return
|
|
5016
|
+
raise UsageError(
|
|
5017
|
+
f"--commit-hash must be the full 40-character Git object hash "
|
|
5018
|
+
f"(`git rev-parse HEAD`); got {commit_hash!r}. An abbreviation is rejected "
|
|
5019
|
+
f"rather than expanded. Nothing was written."
|
|
5020
|
+
)
|
|
5021
|
+
|
|
5022
|
+
|
|
5023
|
+
def _load_verify_target(
|
|
5024
|
+
specs_dir: Path, feature: str, epic: str | None, is_epic_target: bool
|
|
5025
|
+
) -> tuple[Path, dict, int | None]:
|
|
5026
|
+
"""Resolve the state document ``state-verify`` will mutate — epic or feature.
|
|
5027
|
+
|
|
5028
|
+
An epic target NEVER falls back to the member writer, and a member target never
|
|
5029
|
+
reaches the epic root: the two resolvers are disjoint (REQ-SEC-01). Both result
|
|
5030
|
+
mode and commit-2 mode go through here, so neither can drift onto the other's
|
|
5031
|
+
resolver.
|
|
5032
|
+
|
|
5033
|
+
Args:
|
|
5034
|
+
specs_dir: The configured specs directory.
|
|
5035
|
+
feature: The feature name, or the epic name for an epic target.
|
|
5036
|
+
epic: The owning epic for a member, else None.
|
|
5037
|
+
is_epic_target: True when ``--stage forge-0-epic`` selected the epic root.
|
|
5038
|
+
|
|
5039
|
+
Returns:
|
|
5040
|
+
``(state_path, state, revision)``. ``revision`` is the epic's manifest
|
|
5041
|
+
revision for an epic target, and None for a feature target (whose artifact
|
|
5042
|
+
version is read per-stage out of its own state).
|
|
5043
|
+
|
|
5044
|
+
Raises:
|
|
5045
|
+
UsageError: Any resolution or load failure (→ exit 2, nothing written).
|
|
5046
|
+
"""
|
|
5047
|
+
if is_epic_target:
|
|
5048
|
+
return _load_epic_state_for_write(specs_dir, feature, epic)
|
|
5049
|
+
state_path, state = _load_state_for_write(specs_dir, feature, epic)
|
|
5050
|
+
return state_path, state, None
|
|
5051
|
+
|
|
5052
|
+
|
|
5053
|
+
def _verify_result_entry(
|
|
5054
|
+
status: str,
|
|
5055
|
+
prior: dict,
|
|
5056
|
+
current: int | None,
|
|
5057
|
+
findings_file: str | None,
|
|
5058
|
+
findings_count: int | None,
|
|
5059
|
+
now: str,
|
|
5060
|
+
) -> dict:
|
|
5061
|
+
"""Build the replacement ``forge-verify-*`` entry for one result transition.
|
|
5062
|
+
|
|
5063
|
+
Each status REPLACES the entry rather than patching it, which is what makes the
|
|
5064
|
+
the "clear …" rules exact: a terminal write cannot leave a stale
|
|
5065
|
+
``scheduledAt``/``scheduledStageVersion`` behind, and the keys are DELETED
|
|
5066
|
+
rather than nulled (``VerifyEntry`` is ``total=False``, so absent means "not
|
|
5067
|
+
scheduled" while present-but-null would be malformed). ``findings-applied`` is
|
|
5068
|
+
the one status that carries prior state forward — the report metadata — and it
|
|
5069
|
+
deliberately writes no ``verifiedStageVersion``: fixes landed, nothing
|
|
5070
|
+
re-verified them, so freshness stays unresolved until a later ``passed``.
|
|
5071
|
+
``passed`` may record NEW attached-report metadata of its own (the
|
|
5072
|
+
advisory-only and escalation-acceptance rules in ``cmd_state_verify``).
|
|
5073
|
+
|
|
5074
|
+
Args:
|
|
5075
|
+
status: The validated result status.
|
|
5076
|
+
prior: The existing entry (``{}`` when absent).
|
|
5077
|
+
current: The current artifact revision, or None for ``skipped``.
|
|
5078
|
+
findings_file: Validated relative report path, when supplied.
|
|
5079
|
+
findings_count: Validated non-negative count, when supplied.
|
|
5080
|
+
now: The shared ISO-8601 timestamp for this write.
|
|
5081
|
+
|
|
5082
|
+
Returns:
|
|
5083
|
+
The complete new entry dict.
|
|
5084
|
+
"""
|
|
5085
|
+
if status == "auto-verify-pending":
|
|
5086
|
+
return {
|
|
5087
|
+
"status": status,
|
|
5088
|
+
"scheduledAt": now,
|
|
5089
|
+
"scheduledStageVersion": current,
|
|
5090
|
+
"commitHash": None,
|
|
5091
|
+
}
|
|
5092
|
+
if status == "passed":
|
|
5093
|
+
entry: dict = {"status": status}
|
|
5094
|
+
if findings_file is not None:
|
|
5095
|
+
# An attached report — advisory-only, or residual findings the user
|
|
5096
|
+
# explicitly accepted at the escalation gate — resolves as `passed`
|
|
5097
|
+
# so it never routes to forge-fix, while the report stays attached
|
|
5098
|
+
# for later pickup. A bare zero count records no report keys, keeping
|
|
5099
|
+
# the plain "verified clean" shape byte-identical to before.
|
|
5100
|
+
entry["findingsFile"] = findings_file
|
|
5101
|
+
entry["findingsCount"] = findings_count
|
|
5102
|
+
entry["verifiedAt"] = now
|
|
5103
|
+
entry["verifiedStageVersion"] = current
|
|
5104
|
+
entry["commitHash"] = None
|
|
5105
|
+
return entry
|
|
5106
|
+
if status == "findings-reported":
|
|
5107
|
+
return {
|
|
5108
|
+
"status": status,
|
|
5109
|
+
"findingsFile": findings_file,
|
|
5110
|
+
"findingsCount": findings_count,
|
|
5111
|
+
"verifiedAt": now,
|
|
5112
|
+
"verifiedStageVersion": current,
|
|
5113
|
+
"commitHash": None,
|
|
5114
|
+
}
|
|
5115
|
+
if status == "findings-applied":
|
|
5116
|
+
entry: dict = {"status": status}
|
|
5117
|
+
for key in ("findingsFile", "findingsCount"):
|
|
5118
|
+
if key in prior:
|
|
5119
|
+
entry[key] = prior[key]
|
|
5120
|
+
entry["fixedAt"] = now
|
|
5121
|
+
entry["commitHash"] = None
|
|
5122
|
+
return entry
|
|
5123
|
+
return {"status": status, "commitHash": None} # skipped
|
|
5124
|
+
|
|
5125
|
+
|
|
5126
|
+
def cmd_state_verify(
|
|
5127
|
+
feature: str,
|
|
5128
|
+
stage: str,
|
|
5129
|
+
specs_dir: Path,
|
|
5130
|
+
epic: str | None,
|
|
5131
|
+
status: str | None = None,
|
|
5132
|
+
findings_file: str | None = None,
|
|
5133
|
+
findings_count: int | None = None,
|
|
5134
|
+
verified_stage_version: int | None = None,
|
|
5135
|
+
commit_hash: str | None = None,
|
|
5136
|
+
) -> dict:
|
|
5137
|
+
"""Write one verify result transition or one provenance follow-up.
|
|
5138
|
+
|
|
5139
|
+
Args:
|
|
5140
|
+
feature: The feature name, or the EPIC name when `stage == "forge-0-epic"`.
|
|
5141
|
+
Resolved through the same path-safety and containment rules as every
|
|
5142
|
+
other state write.
|
|
5143
|
+
stage: The production stage this verify entry serves — one of
|
|
5144
|
+
`VERIFY_MODE_TO_STAGE`'s values, or `"forge-0-epic"` for an epic-target
|
|
5145
|
+
write. Selects `stages["forge-verify-{suffix}"]`.
|
|
5146
|
+
specs_dir: Root of the specs tree, as configured by `specsDir`.
|
|
5147
|
+
epic: Epic name when `feature` is a member, else None. REQUIRED for members
|
|
5148
|
+
so the bare name is never resolved ambiguously. For
|
|
5149
|
+
`stage == "forge-0-epic"` it must be absent or equal to `feature`.
|
|
5150
|
+
status: Result mode. Mutually exclusive with `commit_hash`. Each status
|
|
5151
|
+
admits only the metadata below; everything else is refused before any
|
|
5152
|
+
write, so a contradictory call never lands a partial entry:
|
|
5153
|
+
|
|
5154
|
+
- `passed` — REQUIRES `verified_stage_version`. MAY carry an attached
|
|
5155
|
+
report (`findings_file` + `findings_count` together, count >= 1) in
|
|
5156
|
+
two protocol cases: an ADVISORY-ONLY report (no blocking
|
|
5157
|
+
`error`/`gap` findings), and residual findings the user explicitly
|
|
5158
|
+
ACCEPTED at the round-ledger escalation (recorded first as a
|
|
5159
|
+
`state-decision`; see "Escalation" in stage-exit-protocol.md).
|
|
5160
|
+
Either way the stage resolves without routing to forge-fix and the
|
|
5161
|
+
report stays attached. Half a pairing is refused: a file without a
|
|
5162
|
+
count, a positive count without a file, or a file with a zero
|
|
5163
|
+
count. Unaccepted blocking findings belong to `findings-reported`.
|
|
5164
|
+
- `findings-reported` — REQUIRES all three of `verified_stage_version`,
|
|
5165
|
+
`findings_file`, and a non-negative `findings_count`.
|
|
5166
|
+
- `findings-applied` — REFUSES `verified_stage_version`. Applying fixes
|
|
5167
|
+
is not verifying them, so this status deliberately CLEARS the recorded
|
|
5168
|
+
freshness and leaves the stage's verification outstanding until a later
|
|
5169
|
+
`passed` records a revision.
|
|
5170
|
+
- `skipped` and `auto-verify-pending` — accept none of the three.
|
|
5171
|
+
|
|
5172
|
+
`passed` and `findings-reported` additionally refuse a
|
|
5173
|
+
`verified_stage_version` that is stale against the served stage's current
|
|
5174
|
+
version. The persisted shape is `references/pipeline-state-schema.json`.
|
|
5175
|
+
findings_file: Path to the findings document, relative to and contained by
|
|
5176
|
+
the resolved feature/epic directory. Required by `findings-reported`,
|
|
5177
|
+
optional on `passed` (the attached-report cases above); rejected when
|
|
5178
|
+
absolute, containing `..`, or carrying NUL/control characters
|
|
5179
|
+
(REQ-SEC-01).
|
|
5180
|
+
findings_count: Number of findings in `findings_file`. Required alongside it.
|
|
5181
|
+
verified_stage_version: The served stage's `version` at verification time,
|
|
5182
|
+
feeding the navigator's freshness ledger. Cleared by
|
|
5183
|
+
`findings-applied`, which deliberately does not claim freshness.
|
|
5184
|
+
commit_hash: Commit-2 mode. Full 40-hex only, validated by
|
|
5185
|
+
`FULL_GIT_HASH_RE.fullmatch`; abbreviations are rejected rather than
|
|
5186
|
+
expanded. Mutually exclusive with `status`.
|
|
5187
|
+
|
|
5188
|
+
Returns:
|
|
5189
|
+
The emitted JSON result: the written verify entry plus the resolved target
|
|
5190
|
+
path, so the caller can report what landed without re-reading state.
|
|
5191
|
+
|
|
5192
|
+
Raises:
|
|
5193
|
+
UsageError: Mixed modes, invalid metadata, invalid hash, missing entry,
|
|
5194
|
+
unsafe/ambiguous target, or atomic write failure.
|
|
5195
|
+
"""
|
|
5196
|
+
# --- Mode exclusivity, before anything is resolved or loaded. -------------
|
|
5197
|
+
if status is None and commit_hash is None:
|
|
5198
|
+
raise UsageError(
|
|
5199
|
+
"state-verify needs exactly one mode: --status <result> to record a "
|
|
5200
|
+
"verification transition, or --commit-hash <40-hex> to record Commit-2 "
|
|
5201
|
+
"provenance for an existing entry"
|
|
5202
|
+
)
|
|
5203
|
+
if status is not None and commit_hash is not None:
|
|
5204
|
+
raise UsageError(
|
|
5205
|
+
"--status and --commit-hash are mutually exclusive: a result write "
|
|
5206
|
+
"records commitHash null (Commit 1), and the hash lands in a separate "
|
|
5207
|
+
"commit-2 call"
|
|
5208
|
+
)
|
|
5209
|
+
if commit_hash is not None:
|
|
5210
|
+
# Commit-2 carries provenance for an entry that ALREADY exists, so every
|
|
5211
|
+
# result field must be absent: a hash arriving next to findings metadata
|
|
5212
|
+
# means the caller conflated the two writes.
|
|
5213
|
+
for label, value in (
|
|
5214
|
+
("--findings-file", findings_file),
|
|
5215
|
+
("--findings-count", findings_count),
|
|
5216
|
+
("--verified-stage-version", verified_stage_version),
|
|
5217
|
+
):
|
|
5218
|
+
if value is not None:
|
|
5219
|
+
raise UsageError(
|
|
5220
|
+
f"--commit-hash records provenance for an existing entry and "
|
|
5221
|
+
f"changes only its commitHash, so it does not accept {label}. "
|
|
5222
|
+
f"Record the result with --status first, commit, then re-run "
|
|
5223
|
+
f"with --commit-hash alone."
|
|
5224
|
+
)
|
|
5225
|
+
_assert_full_commit_hash(commit_hash)
|
|
5226
|
+
elif status not in VERIFY_RESULT_STATUSES:
|
|
5227
|
+
known = ", ".join(VERIFY_RESULT_STATUSES)
|
|
5228
|
+
raise UsageError(f"unknown --status {status!r}; expected one of {known}")
|
|
5229
|
+
|
|
5230
|
+
# --- Target selection: epic before the token map. --------------
|
|
5231
|
+
is_epic_target = stage == "forge-0-epic"
|
|
5232
|
+
if is_epic_target:
|
|
5233
|
+
verify_key = "forge-verify-epic"
|
|
5234
|
+
else:
|
|
5235
|
+
token = VERIFY_TOKEN_BY_STAGE.get(stage)
|
|
5236
|
+
if token is None:
|
|
5237
|
+
raise UsageError(
|
|
5238
|
+
f"{stage} has no verification token, so it has no forge-verify-* entry "
|
|
5239
|
+
f"to write; expected one of {', '.join(VERIFY_STAGES)}"
|
|
5240
|
+
)
|
|
5241
|
+
verify_key = f"forge-verify-{token}"
|
|
5242
|
+
|
|
5243
|
+
# --- Commit-2 provenance mode. ---------------------------------
|
|
5244
|
+
# Commit 1 recorded the result with `commitHash: null`; this second, targeted
|
|
5245
|
+
# write records the hash of THAT commit. Nothing here invokes Git, rewrites
|
|
5246
|
+
# history, or amends — the two commits stay two commits (REQ-STATE-04).
|
|
5247
|
+
if commit_hash is not None:
|
|
5248
|
+
state_path, state, _ = _load_verify_target(
|
|
5249
|
+
specs_dir, feature, epic, is_epic_target
|
|
5250
|
+
)
|
|
5251
|
+
entry = _verify_entry(state, verify_key)
|
|
5252
|
+
if not entry:
|
|
5253
|
+
raise UsageError(
|
|
5254
|
+
f"--commit-hash records provenance for an existing {verify_key} "
|
|
5255
|
+
f"entry, and {feature} has none. Record the verification result "
|
|
5256
|
+
f"with --status first, commit it, then re-run with --commit-hash."
|
|
5257
|
+
)
|
|
5258
|
+
# In place, so status, findings metadata, scheduling metadata, timestamps
|
|
5259
|
+
# and versions are all left exactly as Commit 1 wrote them.
|
|
5260
|
+
entry["commitHash"] = commit_hash
|
|
5261
|
+
written = _commit_state(state_path, state)
|
|
5262
|
+
return {
|
|
5263
|
+
"feature": feature,
|
|
5264
|
+
"stage": stage,
|
|
5265
|
+
"verifyKey": verify_key,
|
|
5266
|
+
"statePath": str(state_path),
|
|
5267
|
+
"entry": entry,
|
|
5268
|
+
"updatedAt": written["updatedAt"],
|
|
5269
|
+
}
|
|
5270
|
+
|
|
5271
|
+
# --- Metadata validation that needs no state. ------------------
|
|
5272
|
+
if verified_stage_version is not None:
|
|
5273
|
+
_require_positive_int(verified_stage_version, "--verified-stage-version")
|
|
5274
|
+
if findings_count is not None and (
|
|
5275
|
+
isinstance(findings_count, bool) or not isinstance(findings_count, int)
|
|
5276
|
+
):
|
|
5277
|
+
raise UsageError(f"--findings-count must be an integer; got {findings_count!r}")
|
|
5278
|
+
|
|
5279
|
+
if status in ("auto-verify-pending", "skipped"):
|
|
5280
|
+
for label, value in (
|
|
5281
|
+
("--findings-file", findings_file),
|
|
5282
|
+
("--findings-count", findings_count),
|
|
5283
|
+
("--verified-stage-version", verified_stage_version),
|
|
5284
|
+
):
|
|
5285
|
+
if value is not None:
|
|
5286
|
+
raise UsageError(f"--status {status} does not accept {label}")
|
|
5287
|
+
elif status == "passed":
|
|
5288
|
+
if findings_file is not None and findings_count is None:
|
|
5289
|
+
raise UsageError(
|
|
5290
|
+
"--status passed with an advisory --findings-file requires "
|
|
5291
|
+
"--findings-count N (the number of advisory findings it lists)"
|
|
5292
|
+
)
|
|
5293
|
+
if findings_count is not None:
|
|
5294
|
+
if findings_count < 0:
|
|
5295
|
+
raise UsageError(
|
|
5296
|
+
f"--findings-count must not be negative; got {findings_count!r}"
|
|
5297
|
+
)
|
|
5298
|
+
if findings_count > 0 and findings_file is None:
|
|
5299
|
+
raise UsageError(
|
|
5300
|
+
f"--status passed with --findings-count {findings_count} requires "
|
|
5301
|
+
f"--findings-file <advisory report>: a positive count with no "
|
|
5302
|
+
f"report to read is unrecoverable. Blocking findings belong to "
|
|
5303
|
+
f"--status findings-reported instead."
|
|
5304
|
+
)
|
|
5305
|
+
if findings_count == 0 and findings_file is not None:
|
|
5306
|
+
raise UsageError(
|
|
5307
|
+
"--status passed with --findings-file requires --findings-count "
|
|
5308
|
+
">= 1: an attached report claiming zero findings is "
|
|
5309
|
+
"self-contradictory — omit both for a clean pass"
|
|
5310
|
+
)
|
|
5311
|
+
if verified_stage_version is None:
|
|
5312
|
+
raise UsageError(
|
|
5313
|
+
"--status passed requires --verified-stage-version <current version>"
|
|
5314
|
+
)
|
|
5315
|
+
elif status == "findings-reported":
|
|
5316
|
+
if verified_stage_version is None:
|
|
5317
|
+
raise UsageError(
|
|
5318
|
+
"--status findings-reported requires --verified-stage-version "
|
|
5319
|
+
"<current version>"
|
|
5320
|
+
)
|
|
5321
|
+
if findings_file is None:
|
|
5322
|
+
raise UsageError(
|
|
5323
|
+
"--status findings-reported requires --findings-file <path relative "
|
|
5324
|
+
"to the feature directory>"
|
|
5325
|
+
)
|
|
5326
|
+
if findings_count is None:
|
|
5327
|
+
raise UsageError("--status findings-reported requires --findings-count N")
|
|
5328
|
+
if findings_count < 0:
|
|
5329
|
+
raise UsageError(
|
|
5330
|
+
f"--findings-count must not be negative; got {findings_count!r}"
|
|
5331
|
+
)
|
|
5332
|
+
elif verified_stage_version is not None: # findings-applied
|
|
5333
|
+
raise UsageError(
|
|
5334
|
+
"--status findings-applied does not accept --verified-stage-version: "
|
|
5335
|
+
"applying fixes deliberately CLEARS freshness, so only a later "
|
|
5336
|
+
"--status passed may record a verified revision"
|
|
5337
|
+
)
|
|
5338
|
+
|
|
5339
|
+
state_path, state, epic_revision = _load_verify_target(
|
|
5340
|
+
specs_dir, feature, epic, is_epic_target
|
|
5341
|
+
)
|
|
5342
|
+
target_dir = state_path.parent
|
|
5343
|
+
if findings_file is not None:
|
|
5344
|
+
_validated_findings_file(findings_file, target_dir)
|
|
5345
|
+
|
|
5346
|
+
if status == "skipped":
|
|
5347
|
+
current = None
|
|
5348
|
+
elif is_epic_target:
|
|
5349
|
+
# The epic's artifact revision is the manifest revision — never a member's
|
|
5350
|
+
# production-stage version.
|
|
5351
|
+
current = epic_revision
|
|
5352
|
+
else:
|
|
5353
|
+
current = _current_artifact_version(state, stage)
|
|
5354
|
+
if status in ("passed", "findings-reported") and verified_stage_version != current:
|
|
5355
|
+
at = (
|
|
5356
|
+
f"{feature}'s manifest is at revision {current}"
|
|
5357
|
+
if is_epic_target
|
|
5358
|
+
else f"{stage} is at version {current}"
|
|
5359
|
+
)
|
|
5360
|
+
raise UsageError(
|
|
5361
|
+
f"--verified-stage-version {verified_stage_version} is stale: {at}. "
|
|
5362
|
+
f"Re-run verification against the current artifact."
|
|
5363
|
+
)
|
|
5364
|
+
|
|
5365
|
+
prior = _verify_entry(state, verify_key)
|
|
5366
|
+
if status == "auto-verify-pending" and prior.get("status") == "findings-reported":
|
|
5367
|
+
# `_verify_result_entry` REPLACES the entry, so scheduling over a report
|
|
5368
|
+
# for the current revision would delete its `findingsFile`/`findingsCount`
|
|
5369
|
+
# and break the later `findings-applied` precondition (REQ-EXIT-04's
|
|
5370
|
+
# forbidden clobber, reached through the CLI instead of a branch exit).
|
|
5371
|
+
# A report against a since-revised artifact is superseded normally.
|
|
5372
|
+
reported = prior.get("verifiedStageVersion")
|
|
5373
|
+
if (
|
|
5374
|
+
isinstance(reported, int)
|
|
5375
|
+
and not isinstance(reported, bool)
|
|
5376
|
+
and current is not None
|
|
5377
|
+
and reported == current
|
|
5378
|
+
):
|
|
5379
|
+
raise UsageError(
|
|
5380
|
+
f"--status auto-verify-pending would replace {verify_key}'s "
|
|
5381
|
+
f"findings-reported entry for the current revision and delete its "
|
|
5382
|
+
f"report metadata ({prior.get('findingsFile')!r}, "
|
|
5383
|
+
f"findingsCount {prior.get('findingsCount')!r}). Apply the report "
|
|
5384
|
+
f"via forge-fix (--status findings-applied) or re-verify to a "
|
|
5385
|
+
f"terminal status; scheduling is valid only after the artifact "
|
|
5386
|
+
f"is revised."
|
|
5387
|
+
)
|
|
5388
|
+
if status == "findings-applied":
|
|
5389
|
+
if prior.get("status") not in ("findings-reported", "findings-applied"):
|
|
5390
|
+
raise UsageError(
|
|
5391
|
+
f"--status findings-applied requires an existing {verify_key} entry "
|
|
5392
|
+
f"with status findings-reported (or findings-applied); found "
|
|
5393
|
+
f"{prior.get('status')!r}"
|
|
5394
|
+
)
|
|
5395
|
+
for label, key, supplied in (
|
|
5396
|
+
("--findings-file", "findingsFile", findings_file),
|
|
5397
|
+
("--findings-count", "findingsCount", findings_count),
|
|
5398
|
+
):
|
|
5399
|
+
if supplied is not None and supplied != prior.get(key):
|
|
5400
|
+
raise UsageError(
|
|
5401
|
+
f"{label} {supplied!r} does not match the recorded report "
|
|
5402
|
+
f"({key}: {prior.get(key)!r}); fix the value or omit the flag"
|
|
5403
|
+
)
|
|
5404
|
+
|
|
5405
|
+
entry = _verify_result_entry(
|
|
5406
|
+
status, prior, current, findings_file, findings_count, _now_iso()
|
|
5407
|
+
)
|
|
5408
|
+
state.setdefault("stages", {})[verify_key] = entry
|
|
5409
|
+
written = _commit_state(state_path, state)
|
|
5410
|
+
return {
|
|
5411
|
+
"feature": feature,
|
|
5412
|
+
"stage": stage,
|
|
5413
|
+
"verifyKey": verify_key,
|
|
5414
|
+
"statePath": str(state_path),
|
|
5415
|
+
"entry": entry,
|
|
5416
|
+
"updatedAt": written["updatedAt"],
|
|
5417
|
+
}
|
|
5418
|
+
|
|
5419
|
+
|
|
2545
5420
|
def _print_state_enter(state: dict) -> None:
|
|
2546
5421
|
"""Print the one-line human summary for `state-enter`."""
|
|
2547
5422
|
print(f"entered {state['currentStage']} (in-progress) for {state['feature']}")
|
|
@@ -2596,6 +5471,31 @@ def _print_state_decision(state: dict) -> None:
|
|
|
2596
5471
|
print(f"deferred decision recorded (raisedBy {routing})")
|
|
2597
5472
|
|
|
2598
5473
|
|
|
5474
|
+
def _print_state_verify(result: dict, commit_hash: str | None = None) -> None:
|
|
5475
|
+
"""Print the one-line human summary for `state-verify` (one per mode).
|
|
5476
|
+
|
|
5477
|
+
Takes the verb's RESULT dict (entry + resolved path), not a state document —
|
|
5478
|
+
`state-verify` is the one verb whose echo is the written entry rather than the
|
|
5479
|
+
whole file. Commit-2 mode gets its own line: reporting the untouched status
|
|
5480
|
+
would read as if the result had just been re-written.
|
|
5481
|
+
"""
|
|
5482
|
+
entry = result["entry"]
|
|
5483
|
+
if commit_hash is not None:
|
|
5484
|
+
print(f"recorded {result['verifyKey']} commitHash: {commit_hash}")
|
|
5485
|
+
return
|
|
5486
|
+
detail = ""
|
|
5487
|
+
if entry.get("findingsFile"):
|
|
5488
|
+
detail = f" ({entry.get('findingsCount')} in {entry['findingsFile']})"
|
|
5489
|
+
elif entry.get("scheduledStageVersion") is not None:
|
|
5490
|
+
detail = f" (scheduled at v{entry['scheduledStageVersion']})"
|
|
5491
|
+
elif entry.get("verifiedStageVersion") is not None:
|
|
5492
|
+
detail = f" (v{entry['verifiedStageVersion']})"
|
|
5493
|
+
print(
|
|
5494
|
+
f"recorded {result['verifyKey']} = {entry['status']} for "
|
|
5495
|
+
f"{result['feature']}{detail}"
|
|
5496
|
+
)
|
|
5497
|
+
|
|
5498
|
+
|
|
2599
5499
|
def _print_state_ecr(state: dict) -> None:
|
|
2600
5500
|
"""Print the one-line human summary for `state-ecr` (the item appended)."""
|
|
2601
5501
|
item = state["epicChangeRequests"][-1]
|
|
@@ -2640,7 +5540,15 @@ def _print_rank_table(rows: list[FeatureRow], counts: dict[str, int]) -> None:
|
|
|
2640
5540
|
nxt = row["nextCommand"] or "complete"
|
|
2641
5541
|
print(f" {marker} {label}: {row['currentStage']} — next: {nxt}")
|
|
2642
5542
|
if row["verifyPending"]:
|
|
2643
|
-
|
|
5543
|
+
# Owed automatic verification is an obligation, not an offer — the
|
|
5544
|
+
# full diagnostic sentence went to stderr, so keep this line honest
|
|
5545
|
+
# rather than repeating it (REQ-DEBT-02).
|
|
5546
|
+
offer = (
|
|
5547
|
+
"automatic verification owed"
|
|
5548
|
+
if row["verifyState"] == "auto-pending"
|
|
5549
|
+
else "verify available"
|
|
5550
|
+
)
|
|
5551
|
+
print(f" ({offer}: {row['verifyCommand']})")
|
|
2644
5552
|
|
|
2645
5553
|
|
|
2646
5554
|
def _print_context(usage: dict) -> None:
|
|
@@ -2656,8 +5564,29 @@ def _print_context(usage: dict) -> None:
|
|
|
2656
5564
|
)
|
|
2657
5565
|
|
|
2658
5566
|
|
|
5567
|
+
class _ErrorPrefixParser(argparse.ArgumentParser):
|
|
5568
|
+
"""An argparse parser whose failures use this CLI's ``Error: ...`` exit-2 form.
|
|
5569
|
+
|
|
5570
|
+
``stage-exit`` carries two contracts that argparse cannot satisfy together out
|
|
5571
|
+
of the box: its enum flags MUST be registered with typed ``choices`` drawn from
|
|
5572
|
+
the shared literal domains, AND any invalid input must print
|
|
5573
|
+
``Error: <actionable message>`` to stderr and return exit 2 with no payload and
|
|
5574
|
+
no sentinel. Stock argparse leads with ``usage:``, so the reconciliation lives
|
|
5575
|
+
here rather than in a hand-rolled second validation pass that would drift from
|
|
5576
|
+
the ``choices`` it duplicates.
|
|
5577
|
+
|
|
5578
|
+
``parse_args`` runs before ``main``'s ``UsageError`` handler, so this exits
|
|
5579
|
+
directly instead of raising. ``add_subparsers`` defaults ``parser_class`` to
|
|
5580
|
+
``type(self)``, so every subcommand inherits the same form — matching the
|
|
5581
|
+
``UsageError`` path they already share.
|
|
5582
|
+
"""
|
|
5583
|
+
|
|
5584
|
+
def error(self, message: str) -> NoReturn: # noqa: D102 - argparse override
|
|
5585
|
+
self.exit(2, f"Error: {message}\nTry '{self.prog} --help' for usage.\n")
|
|
5586
|
+
|
|
5587
|
+
|
|
2659
5588
|
def main() -> int:
|
|
2660
|
-
parser =
|
|
5589
|
+
parser = _ErrorPrefixParser(prog="forge-session.py", description=__doc__)
|
|
2661
5590
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
2662
5591
|
|
|
2663
5592
|
p_rank = sub.add_parser("rank-features", help="Rank active features by recency")
|
|
@@ -2712,13 +5641,29 @@ def main() -> int:
|
|
|
2712
5641
|
p_exit.add_argument("--feature", required=True,
|
|
2713
5642
|
help="Feature name (the epic name for forge-0-epic)")
|
|
2714
5643
|
p_exit.add_argument("--stage", required=True, choices=EXIT_STAGES,
|
|
2715
|
-
help="The just-completed
|
|
5644
|
+
help="The just-completed stage (or branch skill)")
|
|
5645
|
+
p_exit.add_argument("--served-stage", default=None, dest="served_stage",
|
|
5646
|
+
choices=_EXIT_PRODUCTION_STAGES,
|
|
5647
|
+
help="Production stage a verify/fix diversion served")
|
|
5648
|
+
p_exit.add_argument("--verify-mode", default=None, dest="verify_mode",
|
|
5649
|
+
choices=tuple(VERIFY_MODE_TO_STAGE),
|
|
5650
|
+
help="Verify mode; maps to --served-stage when unique")
|
|
5651
|
+
# No argparse `choices`: the accepted outcome domain differs per stage, which
|
|
5652
|
+
# argparse cannot express. `stage_exit` validates it against EXIT_OUTCOMES.
|
|
5653
|
+
p_exit.add_argument("--outcome", default=None,
|
|
5654
|
+
help="Stage-specific outcome (loop/docs/verify/fix only)")
|
|
5655
|
+
p_exit.add_argument("--owner", default=None, choices=get_args(ExitOwner),
|
|
5656
|
+
help="Branch terminal ownership (forge-verify/forge-fix only)")
|
|
5657
|
+
p_exit.add_argument("--verify-capability", default="manual",
|
|
5658
|
+
dest="verify_capability", choices=get_args(VerifyCapability),
|
|
5659
|
+
help="interactive only with BOTH a question mechanism and "
|
|
5660
|
+
"permitted clean-room verifier dispatch")
|
|
2716
5661
|
p_exit.add_argument("--specs-dir", default="./specs", help="Specs directory")
|
|
2717
5662
|
p_exit.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
|
|
2718
5663
|
p_exit.add_argument("--epic", default=None, help="Epic name for a nested member")
|
|
2719
5664
|
p_exit.add_argument("--next-feature", default=None, dest="next_feature",
|
|
2720
5665
|
help="First actionable feature (epic handoff next-command arg)")
|
|
2721
|
-
p_exit.add_argument("--host", default="claude", choices=
|
|
5666
|
+
p_exit.add_argument("--host", default="claude", choices=EXIT_HOSTS,
|
|
2722
5667
|
help="Host wording for the NEXT-STEPS block")
|
|
2723
5668
|
p_exit.add_argument("--json", action="store_true", dest="json_output")
|
|
2724
5669
|
|
|
@@ -2842,6 +5787,34 @@ def main() -> int:
|
|
|
2842
5787
|
p_ecr.add_argument("--epic", default=None, help="Epic name for a nested member")
|
|
2843
5788
|
p_ecr.add_argument("--json", action="store_true", dest="json_output")
|
|
2844
5789
|
|
|
5790
|
+
p_ver = sub.add_parser(
|
|
5791
|
+
"state-verify", help="Write one forge-verify-* transition (result or provenance)"
|
|
5792
|
+
)
|
|
5793
|
+
p_ver.add_argument("--feature", required=True,
|
|
5794
|
+
help="Feature name (the EPIC name for --stage forge-0-epic)")
|
|
5795
|
+
p_ver.add_argument("--stage", required=True, choices=VERIFY_STAGES,
|
|
5796
|
+
help="The production stage this verify entry serves "
|
|
5797
|
+
"(forge-6-docs has no verification token)")
|
|
5798
|
+
p_ver.add_argument("--status", default=None, choices=VERIFY_RESULT_STATUSES,
|
|
5799
|
+
help="Result mode: the transition to record "
|
|
5800
|
+
"(mutually exclusive with --commit-hash)")
|
|
5801
|
+
p_ver.add_argument("--findings-file", default=None, dest="findings_file",
|
|
5802
|
+
metavar="PATH",
|
|
5803
|
+
help="Findings document, relative to and contained by the "
|
|
5804
|
+
"feature directory (required by findings-reported)")
|
|
5805
|
+
p_ver.add_argument("--findings-count", type=int, default=None,
|
|
5806
|
+
dest="findings_count", metavar="N",
|
|
5807
|
+
help="Number of findings in --findings-file (0 is meaningful)")
|
|
5808
|
+
p_ver.add_argument("--verified-stage-version", type=int, default=None,
|
|
5809
|
+
dest="verified_stage_version", metavar="N",
|
|
5810
|
+
help="The served stage's current version, for the freshness "
|
|
5811
|
+
"ledger (rejected by findings-applied)")
|
|
5812
|
+
p_ver.add_argument("--commit-hash", default=None, dest="commit_hash",
|
|
5813
|
+
help="Commit-2 provenance mode: the full 40-hex Commit-1 hash")
|
|
5814
|
+
p_ver.add_argument("--specs-dir", default="./specs", help="Specs directory")
|
|
5815
|
+
p_ver.add_argument("--epic", default=None, help="Epic name for a nested member")
|
|
5816
|
+
p_ver.add_argument("--json", action="store_true", dest="json_output")
|
|
5817
|
+
|
|
2845
5818
|
args = parser.parse_args()
|
|
2846
5819
|
|
|
2847
5820
|
try:
|
|
@@ -2925,6 +5898,11 @@ def main() -> int:
|
|
|
2925
5898
|
args.epic,
|
|
2926
5899
|
args.host,
|
|
2927
5900
|
args.next_feature,
|
|
5901
|
+
args.served_stage,
|
|
5902
|
+
args.verify_mode,
|
|
5903
|
+
args.outcome,
|
|
5904
|
+
args.owner,
|
|
5905
|
+
args.verify_capability,
|
|
2928
5906
|
)
|
|
2929
5907
|
if args.json_output:
|
|
2930
5908
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
@@ -3023,6 +6001,25 @@ def main() -> int:
|
|
|
3023
6001
|
_emit(payload, args.json_output, _print_state_ecr)
|
|
3024
6002
|
return 0
|
|
3025
6003
|
|
|
6004
|
+
if args.cmd == "state-verify":
|
|
6005
|
+
payload = cmd_state_verify(
|
|
6006
|
+
args.feature,
|
|
6007
|
+
args.stage,
|
|
6008
|
+
Path(args.specs_dir),
|
|
6009
|
+
args.epic,
|
|
6010
|
+
status=args.status,
|
|
6011
|
+
findings_file=args.findings_file,
|
|
6012
|
+
findings_count=args.findings_count,
|
|
6013
|
+
verified_stage_version=args.verified_stage_version,
|
|
6014
|
+
commit_hash=args.commit_hash,
|
|
6015
|
+
)
|
|
6016
|
+
_emit(
|
|
6017
|
+
payload,
|
|
6018
|
+
args.json_output,
|
|
6019
|
+
lambda result: _print_state_verify(result, args.commit_hash),
|
|
6020
|
+
)
|
|
6021
|
+
return 0
|
|
6022
|
+
|
|
3026
6023
|
raise UsageError(f"unknown command: {args.cmd}")
|
|
3027
6024
|
except UsageError as exc:
|
|
3028
6025
|
print(f"Error: {exc}", file=sys.stderr)
|