@garygentry/feature-forge 0.3.2 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/adapters/claude/.feature-forge-bundle.json +1 -1
- package/adapters/claude/agents/forge-verifier.md +3 -1
- package/adapters/claude/references/decisions/single-writer-threat-model.md +53 -0
- package/adapters/claude/references/epic-state-schema.json +50 -0
- package/adapters/claude/references/forge-config-schema.json +20 -2
- package/adapters/claude/references/forge-decisions-schema.json +33 -0
- package/adapters/claude/references/pipeline-state-schema.json +34 -2
- package/adapters/claude/references/ralph-loop-contract.md +6 -3
- package/adapters/claude/references/shared-conventions.md +15 -4
- package/adapters/claude/references/stage-exit-protocol.md +55 -11
- package/adapters/claude/scripts/epic-manifest.py +82 -4
- package/adapters/claude/scripts/fix-sweep.py +1180 -0
- package/adapters/claude/scripts/forge-session.py +1151 -32
- package/adapters/claude/skills/forge/SKILL.md +5 -5
- package/adapters/claude/skills/forge/references/pipeline-state-schema.json +34 -2
- package/adapters/claude/skills/forge/references/shared-conventions.md +15 -4
- package/adapters/claude/skills/forge/references/stage-exit-protocol.md +55 -11
- package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +5 -1
- package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +5 -0
- package/adapters/claude/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
- package/adapters/claude/skills/forge-0-epic/references/shared-conventions.md +15 -4
- package/adapters/claude/skills/forge-0-epic/references/stage-exit-protocol.md +55 -11
- package/adapters/claude/skills/forge-1-prd/SKILL.md +3 -1
- package/adapters/claude/skills/forge-1-prd/references/shared-conventions.md +15 -4
- package/adapters/claude/skills/forge-1-prd/references/stage-exit-protocol.md +55 -11
- package/adapters/claude/skills/forge-2-tech/SKILL.md +5 -1
- package/adapters/claude/skills/forge-2-tech/references/shared-conventions.md +15 -4
- package/adapters/claude/skills/forge-2-tech/references/stage-exit-protocol.md +55 -11
- package/adapters/claude/skills/forge-3-specs/SKILL.md +5 -1
- package/adapters/claude/skills/forge-3-specs/references/shared-conventions.md +15 -4
- package/adapters/claude/skills/forge-3-specs/references/stage-exit-protocol.md +55 -11
- package/adapters/claude/skills/forge-4-backlog/SKILL.md +41 -3
- package/adapters/claude/skills/forge-4-backlog/references/shared-conventions.md +15 -4
- package/adapters/claude/skills/forge-4-backlog/references/stage-exit-protocol.md +55 -11
- package/adapters/claude/skills/forge-5-loop/SKILL.md +36 -36
- package/adapters/claude/skills/forge-5-loop/references/agent-selection.md +16 -0
- package/adapters/claude/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
- package/adapters/claude/skills/forge-5-loop/references/recovery-procedure.md +349 -0
- package/adapters/claude/skills/forge-5-loop/references/result-reporting.md +40 -11
- package/adapters/claude/skills/forge-5-loop/references/runner-contract.md +22 -4
- package/adapters/claude/skills/forge-5-loop/references/shared-conventions.md +15 -4
- package/adapters/claude/skills/forge-5-loop/references/stage-exit-protocol.md +55 -11
- package/adapters/claude/skills/forge-6-docs/SKILL.md +29 -6
- package/adapters/claude/skills/forge-6-docs/references/shared-conventions.md +15 -4
- package/adapters/claude/skills/forge-6-docs/references/stage-exit-protocol.md +55 -11
- package/adapters/claude/skills/forge-fix/SKILL.md +34 -0
- package/adapters/claude/skills/forge-fix/references/shared-conventions.md +15 -4
- package/adapters/claude/skills/forge-fix/references/stage-exit-protocol.md +55 -11
- package/adapters/claude/skills/forge-guide/references/forge-config-schema.json +20 -2
- package/adapters/claude/skills/forge-guide/references/ralph-loop-contract.md +6 -3
- package/adapters/claude/skills/forge-guide/references/shared-conventions.md +15 -4
- package/adapters/claude/skills/forge-verify/SKILL.md +10 -11
- package/adapters/claude/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
- package/adapters/claude/skills/forge-verify/references/findings-template.md +30 -0
- package/adapters/claude/skills/forge-verify/references/shared-conventions.md +15 -4
- package/adapters/claude/skills/forge-verify/references/stage-exit-protocol.md +55 -11
- package/adapters/claude/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
- package/adapters/claude/skills/forge-verify/references/verification-checklists/impl.md +85 -0
- package/adapters/claude/skills/forge-verify/references/verification-checklists/specs.md +43 -1
- package/adapters/codex/.feature-forge-bundle.json +1 -1
- package/adapters/codex/agents/forge-verifier.toml +3 -1
- package/adapters/codex/references/decisions/single-writer-threat-model.md +53 -0
- package/adapters/codex/references/epic-state-schema.json +50 -0
- package/adapters/codex/references/forge-config-schema.json +20 -2
- package/adapters/codex/references/forge-decisions-schema.json +33 -0
- package/adapters/codex/references/pipeline-state-schema.json +34 -2
- package/adapters/codex/references/process-overview.md +2 -2
- package/adapters/codex/references/ralph-loop-contract.md +6 -3
- package/adapters/codex/references/shared-conventions.md +44 -33
- package/adapters/codex/references/stage-exit-protocol.md +64 -20
- package/adapters/codex/scripts/epic-manifest.py +82 -4
- package/adapters/codex/scripts/fix-sweep.py +1180 -0
- package/adapters/codex/scripts/forge-session.py +1151 -32
- package/adapters/codex/skills/forge/SKILL.md +8 -8
- package/adapters/codex/skills/forge/references/pipeline-state-schema.json +34 -2
- package/adapters/codex/skills/forge/references/process-overview.md +2 -2
- package/adapters/codex/skills/forge/references/shared-conventions.md +44 -33
- package/adapters/codex/skills/forge/references/stage-exit-protocol.md +64 -20
- package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +14 -10
- package/adapters/codex/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
- package/adapters/codex/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
- package/adapters/codex/skills/forge-0-epic/references/shared-conventions.md +44 -33
- package/adapters/codex/skills/forge-0-epic/references/stage-exit-protocol.md +64 -20
- package/adapters/codex/skills/forge-1-prd/SKILL.md +3 -1
- package/adapters/codex/skills/forge-1-prd/references/shared-conventions.md +44 -33
- package/adapters/codex/skills/forge-1-prd/references/stage-exit-protocol.md +64 -20
- package/adapters/codex/skills/forge-2-tech/SKILL.md +5 -1
- package/adapters/codex/skills/forge-2-tech/references/shared-conventions.md +44 -33
- package/adapters/codex/skills/forge-2-tech/references/stage-exit-protocol.md +64 -20
- package/adapters/codex/skills/forge-3-specs/SKILL.md +5 -1
- package/adapters/codex/skills/forge-3-specs/references/shared-conventions.md +44 -33
- package/adapters/codex/skills/forge-3-specs/references/stage-exit-protocol.md +64 -20
- package/adapters/codex/skills/forge-4-backlog/SKILL.md +41 -3
- package/adapters/codex/skills/forge-4-backlog/references/shared-conventions.md +44 -33
- package/adapters/codex/skills/forge-4-backlog/references/stage-exit-protocol.md +64 -20
- package/adapters/codex/skills/forge-5-loop/SKILL.md +36 -36
- package/adapters/codex/skills/forge-5-loop/references/agent-selection.md +17 -1
- package/adapters/codex/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
- package/adapters/codex/skills/forge-5-loop/references/recovery-procedure.md +349 -0
- package/adapters/codex/skills/forge-5-loop/references/result-reporting.md +40 -11
- package/adapters/codex/skills/forge-5-loop/references/runner-contract.md +26 -8
- package/adapters/codex/skills/forge-5-loop/references/shared-conventions.md +44 -33
- package/adapters/codex/skills/forge-5-loop/references/stage-exit-protocol.md +64 -20
- package/adapters/codex/skills/forge-6-docs/SKILL.md +29 -6
- package/adapters/codex/skills/forge-6-docs/references/shared-conventions.md +44 -33
- package/adapters/codex/skills/forge-6-docs/references/stage-exit-protocol.md +64 -20
- package/adapters/codex/skills/forge-fix/SKILL.md +34 -0
- package/adapters/codex/skills/forge-fix/references/shared-conventions.md +44 -33
- package/adapters/codex/skills/forge-fix/references/stage-exit-protocol.md +64 -20
- package/adapters/codex/skills/forge-guide/SKILL.md +1 -1
- package/adapters/codex/skills/forge-guide/references/forge-config-schema.json +20 -2
- package/adapters/codex/skills/forge-guide/references/process-overview.md +2 -2
- package/adapters/codex/skills/forge-guide/references/ralph-loop-contract.md +6 -3
- package/adapters/codex/skills/forge-guide/references/shared-conventions.md +44 -33
- package/adapters/codex/skills/forge-init/SKILL.md +1 -1
- package/adapters/codex/skills/forge-verify/SKILL.md +11 -12
- package/adapters/codex/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
- package/adapters/codex/skills/forge-verify/references/findings-template.md +32 -2
- package/adapters/codex/skills/forge-verify/references/shared-conventions.md +44 -33
- package/adapters/codex/skills/forge-verify/references/stage-exit-protocol.md +64 -20
- package/adapters/codex/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
- package/adapters/codex/skills/forge-verify/references/verification-checklists/epic.md +1 -1
- package/adapters/codex/skills/forge-verify/references/verification-checklists/impl.md +85 -0
- package/adapters/codex/skills/forge-verify/references/verification-checklists/specs.md +43 -1
- package/adapters/copilot/.feature-forge-bundle.json +1 -1
- package/adapters/copilot/agents/forge-verifier.md +3 -1
- package/adapters/copilot/references/decisions/single-writer-threat-model.md +53 -0
- package/adapters/copilot/references/epic-state-schema.json +50 -0
- package/adapters/copilot/references/forge-config-schema.json +20 -2
- package/adapters/copilot/references/forge-decisions-schema.json +33 -0
- package/adapters/copilot/references/pipeline-state-schema.json +34 -2
- package/adapters/copilot/references/process-overview.md +2 -2
- package/adapters/copilot/references/ralph-loop-contract.md +6 -3
- package/adapters/copilot/references/shared-conventions.md +44 -33
- package/adapters/copilot/references/stage-exit-protocol.md +64 -20
- package/adapters/copilot/scripts/epic-manifest.py +82 -4
- package/adapters/copilot/scripts/fix-sweep.py +1180 -0
- package/adapters/copilot/scripts/forge-session.py +1151 -32
- package/adapters/copilot/skills/forge/forge.md +8 -8
- package/adapters/copilot/skills/forge/references/pipeline-state-schema.json +34 -2
- package/adapters/copilot/skills/forge/references/process-overview.md +2 -2
- package/adapters/copilot/skills/forge/references/shared-conventions.md +44 -33
- package/adapters/copilot/skills/forge/references/stage-exit-protocol.md +64 -20
- package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +14 -10
- package/adapters/copilot/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
- package/adapters/copilot/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
- package/adapters/copilot/skills/forge-0-epic/references/shared-conventions.md +44 -33
- package/adapters/copilot/skills/forge-0-epic/references/stage-exit-protocol.md +64 -20
- package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +3 -1
- package/adapters/copilot/skills/forge-1-prd/references/shared-conventions.md +44 -33
- package/adapters/copilot/skills/forge-1-prd/references/stage-exit-protocol.md +64 -20
- package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +5 -1
- package/adapters/copilot/skills/forge-2-tech/references/shared-conventions.md +44 -33
- package/adapters/copilot/skills/forge-2-tech/references/stage-exit-protocol.md +64 -20
- package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +5 -1
- package/adapters/copilot/skills/forge-3-specs/references/shared-conventions.md +44 -33
- package/adapters/copilot/skills/forge-3-specs/references/stage-exit-protocol.md +64 -20
- package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +41 -3
- package/adapters/copilot/skills/forge-4-backlog/references/shared-conventions.md +44 -33
- package/adapters/copilot/skills/forge-4-backlog/references/stage-exit-protocol.md +64 -20
- package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +36 -36
- package/adapters/copilot/skills/forge-5-loop/references/agent-selection.md +17 -1
- package/adapters/copilot/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
- package/adapters/copilot/skills/forge-5-loop/references/recovery-procedure.md +349 -0
- package/adapters/copilot/skills/forge-5-loop/references/result-reporting.md +40 -11
- package/adapters/copilot/skills/forge-5-loop/references/runner-contract.md +26 -8
- package/adapters/copilot/skills/forge-5-loop/references/shared-conventions.md +44 -33
- package/adapters/copilot/skills/forge-5-loop/references/stage-exit-protocol.md +64 -20
- package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +29 -6
- package/adapters/copilot/skills/forge-6-docs/references/shared-conventions.md +44 -33
- package/adapters/copilot/skills/forge-6-docs/references/stage-exit-protocol.md +64 -20
- package/adapters/copilot/skills/forge-fix/forge-fix.md +34 -0
- package/adapters/copilot/skills/forge-fix/references/shared-conventions.md +44 -33
- package/adapters/copilot/skills/forge-fix/references/stage-exit-protocol.md +64 -20
- package/adapters/copilot/skills/forge-guide/forge-guide.md +1 -1
- package/adapters/copilot/skills/forge-guide/references/forge-config-schema.json +20 -2
- package/adapters/copilot/skills/forge-guide/references/process-overview.md +2 -2
- package/adapters/copilot/skills/forge-guide/references/ralph-loop-contract.md +6 -3
- package/adapters/copilot/skills/forge-guide/references/shared-conventions.md +44 -33
- package/adapters/copilot/skills/forge-init/forge-init.md +1 -1
- package/adapters/copilot/skills/forge-verify/forge-verify.md +11 -12
- package/adapters/copilot/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
- package/adapters/copilot/skills/forge-verify/references/findings-template.md +32 -2
- package/adapters/copilot/skills/forge-verify/references/shared-conventions.md +44 -33
- package/adapters/copilot/skills/forge-verify/references/stage-exit-protocol.md +64 -20
- package/adapters/copilot/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
- package/adapters/copilot/skills/forge-verify/references/verification-checklists/epic.md +1 -1
- package/adapters/copilot/skills/forge-verify/references/verification-checklists/impl.md +85 -0
- package/adapters/copilot/skills/forge-verify/references/verification-checklists/specs.md +43 -1
- package/adapters/cursor/.feature-forge-bundle.json +1 -1
- package/adapters/cursor/agents/forge-verifier.mdc +3 -1
- package/adapters/cursor/references/decisions/single-writer-threat-model.md +53 -0
- package/adapters/cursor/references/epic-state-schema.json +50 -0
- package/adapters/cursor/references/forge-config-schema.json +20 -2
- package/adapters/cursor/references/forge-decisions-schema.json +33 -0
- package/adapters/cursor/references/pipeline-state-schema.json +34 -2
- package/adapters/cursor/references/process-overview.md +2 -2
- package/adapters/cursor/references/ralph-loop-contract.md +6 -3
- package/adapters/cursor/references/shared-conventions.md +44 -33
- package/adapters/cursor/references/stage-exit-protocol.md +64 -20
- package/adapters/cursor/scripts/epic-manifest.py +82 -4
- package/adapters/cursor/scripts/fix-sweep.py +1180 -0
- package/adapters/cursor/scripts/forge-session.py +1151 -32
- package/adapters/cursor/skills/forge/forge.mdc +8 -8
- package/adapters/cursor/skills/forge/references/pipeline-state-schema.json +34 -2
- package/adapters/cursor/skills/forge/references/process-overview.md +2 -2
- package/adapters/cursor/skills/forge/references/shared-conventions.md +44 -33
- package/adapters/cursor/skills/forge/references/stage-exit-protocol.md +64 -20
- package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +14 -10
- package/adapters/cursor/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
- package/adapters/cursor/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
- package/adapters/cursor/skills/forge-0-epic/references/shared-conventions.md +44 -33
- package/adapters/cursor/skills/forge-0-epic/references/stage-exit-protocol.md +64 -20
- package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +3 -1
- package/adapters/cursor/skills/forge-1-prd/references/shared-conventions.md +44 -33
- package/adapters/cursor/skills/forge-1-prd/references/stage-exit-protocol.md +64 -20
- package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +5 -1
- package/adapters/cursor/skills/forge-2-tech/references/shared-conventions.md +44 -33
- package/adapters/cursor/skills/forge-2-tech/references/stage-exit-protocol.md +64 -20
- package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +5 -1
- package/adapters/cursor/skills/forge-3-specs/references/shared-conventions.md +44 -33
- package/adapters/cursor/skills/forge-3-specs/references/stage-exit-protocol.md +64 -20
- package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +41 -3
- package/adapters/cursor/skills/forge-4-backlog/references/shared-conventions.md +44 -33
- package/adapters/cursor/skills/forge-4-backlog/references/stage-exit-protocol.md +64 -20
- package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +36 -36
- package/adapters/cursor/skills/forge-5-loop/references/agent-selection.md +17 -1
- package/adapters/cursor/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
- package/adapters/cursor/skills/forge-5-loop/references/recovery-procedure.md +349 -0
- package/adapters/cursor/skills/forge-5-loop/references/result-reporting.md +40 -11
- package/adapters/cursor/skills/forge-5-loop/references/runner-contract.md +26 -8
- package/adapters/cursor/skills/forge-5-loop/references/shared-conventions.md +44 -33
- package/adapters/cursor/skills/forge-5-loop/references/stage-exit-protocol.md +64 -20
- package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +29 -6
- package/adapters/cursor/skills/forge-6-docs/references/shared-conventions.md +44 -33
- package/adapters/cursor/skills/forge-6-docs/references/stage-exit-protocol.md +64 -20
- package/adapters/cursor/skills/forge-fix/forge-fix.mdc +34 -0
- package/adapters/cursor/skills/forge-fix/references/shared-conventions.md +44 -33
- package/adapters/cursor/skills/forge-fix/references/stage-exit-protocol.md +64 -20
- package/adapters/cursor/skills/forge-guide/forge-guide.mdc +1 -1
- package/adapters/cursor/skills/forge-guide/references/forge-config-schema.json +20 -2
- package/adapters/cursor/skills/forge-guide/references/process-overview.md +2 -2
- package/adapters/cursor/skills/forge-guide/references/ralph-loop-contract.md +6 -3
- package/adapters/cursor/skills/forge-guide/references/shared-conventions.md +44 -33
- package/adapters/cursor/skills/forge-init/forge-init.mdc +1 -1
- package/adapters/cursor/skills/forge-verify/forge-verify.mdc +11 -12
- package/adapters/cursor/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
- package/adapters/cursor/skills/forge-verify/references/findings-template.md +32 -2
- package/adapters/cursor/skills/forge-verify/references/shared-conventions.md +44 -33
- package/adapters/cursor/skills/forge-verify/references/stage-exit-protocol.md +64 -20
- package/adapters/cursor/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
- package/adapters/cursor/skills/forge-verify/references/verification-checklists/epic.md +1 -1
- package/adapters/cursor/skills/forge-verify/references/verification-checklists/impl.md +85 -0
- package/adapters/cursor/skills/forge-verify/references/verification-checklists/specs.md +43 -1
- package/adapters/gemini/.feature-forge-bundle.json +1 -1
- package/adapters/gemini/agents/forge-verifier.md +3 -1
- package/adapters/gemini/gemini-extension.json +1 -1
- package/adapters/gemini/references/decisions/single-writer-threat-model.md +53 -0
- package/adapters/gemini/references/epic-state-schema.json +50 -0
- package/adapters/gemini/references/forge-config-schema.json +20 -2
- package/adapters/gemini/references/forge-decisions-schema.json +33 -0
- package/adapters/gemini/references/pipeline-state-schema.json +34 -2
- package/adapters/gemini/references/process-overview.md +2 -2
- package/adapters/gemini/references/ralph-loop-contract.md +6 -3
- package/adapters/gemini/references/shared-conventions.md +44 -33
- package/adapters/gemini/references/stage-exit-protocol.md +64 -20
- package/adapters/gemini/scripts/epic-manifest.py +82 -4
- package/adapters/gemini/scripts/fix-sweep.py +1180 -0
- package/adapters/gemini/scripts/forge-session.py +1151 -32
- package/adapters/gemini/skills/forge/forge.md +8 -8
- package/adapters/gemini/skills/forge/references/pipeline-state-schema.json +34 -2
- package/adapters/gemini/skills/forge/references/process-overview.md +2 -2
- package/adapters/gemini/skills/forge/references/shared-conventions.md +44 -33
- package/adapters/gemini/skills/forge/references/stage-exit-protocol.md +64 -20
- package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +14 -10
- package/adapters/gemini/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
- package/adapters/gemini/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
- package/adapters/gemini/skills/forge-0-epic/references/shared-conventions.md +44 -33
- package/adapters/gemini/skills/forge-0-epic/references/stage-exit-protocol.md +64 -20
- package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +3 -1
- package/adapters/gemini/skills/forge-1-prd/references/shared-conventions.md +44 -33
- package/adapters/gemini/skills/forge-1-prd/references/stage-exit-protocol.md +64 -20
- package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +5 -1
- package/adapters/gemini/skills/forge-2-tech/references/shared-conventions.md +44 -33
- package/adapters/gemini/skills/forge-2-tech/references/stage-exit-protocol.md +64 -20
- package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +5 -1
- package/adapters/gemini/skills/forge-3-specs/references/shared-conventions.md +44 -33
- package/adapters/gemini/skills/forge-3-specs/references/stage-exit-protocol.md +64 -20
- package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +41 -3
- package/adapters/gemini/skills/forge-4-backlog/references/shared-conventions.md +44 -33
- package/adapters/gemini/skills/forge-4-backlog/references/stage-exit-protocol.md +64 -20
- package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +36 -36
- package/adapters/gemini/skills/forge-5-loop/references/agent-selection.md +17 -1
- package/adapters/gemini/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
- package/adapters/gemini/skills/forge-5-loop/references/recovery-procedure.md +349 -0
- package/adapters/gemini/skills/forge-5-loop/references/result-reporting.md +40 -11
- package/adapters/gemini/skills/forge-5-loop/references/runner-contract.md +26 -8
- package/adapters/gemini/skills/forge-5-loop/references/shared-conventions.md +44 -33
- package/adapters/gemini/skills/forge-5-loop/references/stage-exit-protocol.md +64 -20
- package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +29 -6
- package/adapters/gemini/skills/forge-6-docs/references/shared-conventions.md +44 -33
- package/adapters/gemini/skills/forge-6-docs/references/stage-exit-protocol.md +64 -20
- package/adapters/gemini/skills/forge-fix/forge-fix.md +34 -0
- package/adapters/gemini/skills/forge-fix/references/shared-conventions.md +44 -33
- package/adapters/gemini/skills/forge-fix/references/stage-exit-protocol.md +64 -20
- package/adapters/gemini/skills/forge-guide/forge-guide.md +1 -1
- package/adapters/gemini/skills/forge-guide/references/forge-config-schema.json +20 -2
- package/adapters/gemini/skills/forge-guide/references/process-overview.md +2 -2
- package/adapters/gemini/skills/forge-guide/references/ralph-loop-contract.md +6 -3
- package/adapters/gemini/skills/forge-guide/references/shared-conventions.md +44 -33
- package/adapters/gemini/skills/forge-init/forge-init.md +1 -1
- package/adapters/gemini/skills/forge-verify/forge-verify.md +11 -12
- package/adapters/gemini/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
- package/adapters/gemini/skills/forge-verify/references/findings-template.md +32 -2
- package/adapters/gemini/skills/forge-verify/references/shared-conventions.md +44 -33
- package/adapters/gemini/skills/forge-verify/references/stage-exit-protocol.md +64 -20
- package/adapters/gemini/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
- package/adapters/gemini/skills/forge-verify/references/verification-checklists/epic.md +1 -1
- package/adapters/gemini/skills/forge-verify/references/verification-checklists/impl.md +85 -0
- package/adapters/gemini/skills/forge-verify/references/verification-checklists/specs.md +43 -1
- package/adapters/pi/.feature-forge-bundle.json +1 -1
- package/adapters/pi/agents/forge-verifier.md +3 -1
- package/adapters/pi/references/decisions/single-writer-threat-model.md +53 -0
- package/adapters/pi/references/epic-state-schema.json +50 -0
- package/adapters/pi/references/forge-config-schema.json +20 -2
- package/adapters/pi/references/forge-decisions-schema.json +33 -0
- package/adapters/pi/references/pipeline-state-schema.json +34 -2
- package/adapters/pi/references/process-overview.md +2 -2
- package/adapters/pi/references/ralph-loop-contract.md +6 -3
- package/adapters/pi/references/shared-conventions.md +33 -22
- package/adapters/pi/references/stage-exit-protocol.md +63 -19
- package/adapters/pi/scripts/epic-manifest.py +82 -4
- package/adapters/pi/scripts/fix-sweep.py +1180 -0
- package/adapters/pi/scripts/forge-session.py +1151 -32
- package/adapters/pi/skills/forge/SKILL.md +5 -5
- package/adapters/pi/skills/forge/references/pipeline-state-schema.json +34 -2
- package/adapters/pi/skills/forge/references/process-overview.md +2 -2
- package/adapters/pi/skills/forge/references/shared-conventions.md +33 -22
- package/adapters/pi/skills/forge/references/stage-exit-protocol.md +63 -19
- package/adapters/pi/skills/forge-0-epic/references/edit-mode.md +8 -4
- package/adapters/pi/skills/forge-0-epic/references/epic-manifest-subcommands.md +6 -1
- package/adapters/pi/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
- package/adapters/pi/skills/forge-0-epic/references/shared-conventions.md +33 -22
- package/adapters/pi/skills/forge-0-epic/references/stage-exit-protocol.md +63 -19
- package/adapters/pi/skills/forge-1-prd/SKILL.md +3 -1
- package/adapters/pi/skills/forge-1-prd/references/shared-conventions.md +33 -22
- package/adapters/pi/skills/forge-1-prd/references/stage-exit-protocol.md +63 -19
- package/adapters/pi/skills/forge-2-tech/SKILL.md +5 -1
- package/adapters/pi/skills/forge-2-tech/references/shared-conventions.md +33 -22
- package/adapters/pi/skills/forge-2-tech/references/stage-exit-protocol.md +63 -19
- package/adapters/pi/skills/forge-3-specs/SKILL.md +5 -1
- package/adapters/pi/skills/forge-3-specs/references/shared-conventions.md +33 -22
- package/adapters/pi/skills/forge-3-specs/references/stage-exit-protocol.md +63 -19
- package/adapters/pi/skills/forge-4-backlog/SKILL.md +41 -3
- package/adapters/pi/skills/forge-4-backlog/references/shared-conventions.md +33 -22
- package/adapters/pi/skills/forge-4-backlog/references/stage-exit-protocol.md +63 -19
- package/adapters/pi/skills/forge-5-loop/SKILL.md +36 -36
- package/adapters/pi/skills/forge-5-loop/references/agent-selection.md +16 -0
- package/adapters/pi/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
- package/adapters/pi/skills/forge-5-loop/references/recovery-procedure.md +349 -0
- package/adapters/pi/skills/forge-5-loop/references/result-reporting.md +40 -11
- package/adapters/pi/skills/forge-5-loop/references/runner-contract.md +25 -7
- package/adapters/pi/skills/forge-5-loop/references/shared-conventions.md +33 -22
- package/adapters/pi/skills/forge-5-loop/references/stage-exit-protocol.md +63 -19
- package/adapters/pi/skills/forge-6-docs/SKILL.md +29 -6
- package/adapters/pi/skills/forge-6-docs/references/shared-conventions.md +33 -22
- package/adapters/pi/skills/forge-6-docs/references/stage-exit-protocol.md +63 -19
- package/adapters/pi/skills/forge-fix/SKILL.md +34 -0
- package/adapters/pi/skills/forge-fix/references/shared-conventions.md +33 -22
- package/adapters/pi/skills/forge-fix/references/stage-exit-protocol.md +63 -19
- package/adapters/pi/skills/forge-guide/references/forge-config-schema.json +20 -2
- package/adapters/pi/skills/forge-guide/references/process-overview.md +2 -2
- package/adapters/pi/skills/forge-guide/references/ralph-loop-contract.md +6 -3
- package/adapters/pi/skills/forge-guide/references/shared-conventions.md +33 -22
- package/adapters/pi/skills/forge-verify/SKILL.md +10 -11
- package/adapters/pi/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
- package/adapters/pi/skills/forge-verify/references/findings-template.md +31 -1
- package/adapters/pi/skills/forge-verify/references/shared-conventions.md +33 -22
- package/adapters/pi/skills/forge-verify/references/stage-exit-protocol.md +63 -19
- package/adapters/pi/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
- package/adapters/pi/skills/forge-verify/references/verification-checklists/epic.md +1 -1
- package/adapters/pi/skills/forge-verify/references/verification-checklists/impl.md +85 -0
- package/adapters/pi/skills/forge-verify/references/verification-checklists/specs.md +43 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/rauf.d.ts +3 -3
- package/dist/rauf.js +2 -2
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -14,9 +14,9 @@ root navigator:
|
|
|
14
14
|
python3 forge-session.py check-epic-base --feature F [--specs-dir DIR] \
|
|
15
15
|
[--config FILE] [--epic E] [--json]
|
|
16
16
|
python3 forge-session.py stage-exit --feature F --stage S [--owner direct|nested] \
|
|
17
|
-
[--outcome O] [--
|
|
18
|
-
[--verify-capability interactive|manual] [--specs-dir DIR]
|
|
19
|
-
[--epic E] [--next-feature N] [--host claude|generic|pi] [--json]
|
|
17
|
+
[--outcome O] [--cause dependency-starvation] [--verify-mode M] \
|
|
18
|
+
[--served-stage S] [--verify-capability interactive|manual] [--specs-dir DIR] \
|
|
19
|
+
[--config FILE] [--epic E] [--next-feature N] [--host claude|generic|pi] [--json]
|
|
20
20
|
python3 forge-session.py effective-config [--config FILE] [--schema PATH] [--json]
|
|
21
21
|
|
|
22
22
|
Plus the `state-*` write verbs, which author `.pipeline-state.json` so no stage
|
|
@@ -41,6 +41,15 @@ has to hand-write the JSON (and therefore no stage has to read the state schema)
|
|
|
41
41
|
python3 forge-session.py state-verify --feature F --stage S [--status ST] \
|
|
42
42
|
[--findings-file P] [--findings-count N] [--verified-stage-version N] \
|
|
43
43
|
[--commit-hash H] [--specs-dir DIR] [--epic E] [--json]
|
|
44
|
+
python3 forge-session.py decision-record --backlog-dir DIR --item ID [--item ID ...] \
|
|
45
|
+
--question Q (--answer A | --deferred) [--cluster CID] [--actor LABEL] \
|
|
46
|
+
[--state-dir NAME] [--config PATH] [--json]
|
|
47
|
+
python3 forge-session.py decision-list --backlog-dir DIR [--unapplied] \
|
|
48
|
+
[--state-dir NAME] [--config PATH] [--json]
|
|
49
|
+
python3 forge-session.py decision-apply --backlog-dir DIR --item ID [--actor LABEL] \
|
|
50
|
+
[--state-dir NAME] [--config PATH] [--json]
|
|
51
|
+
python3 forge-session.py backlog-topology (--items-json PATH | --items-stdin) \
|
|
52
|
+
[--cluster] [--json]
|
|
44
53
|
|
|
45
54
|
`rank-features` scans the specs tree for feature-shaped directories (those that
|
|
46
55
|
directly contain a `.pipeline-state.json`, in both the flat
|
|
@@ -158,8 +167,10 @@ from __future__ import annotations
|
|
|
158
167
|
|
|
159
168
|
import argparse
|
|
160
169
|
import json
|
|
170
|
+
import math
|
|
161
171
|
import os
|
|
162
172
|
import re
|
|
173
|
+
import socket
|
|
163
174
|
import subprocess
|
|
164
175
|
import sys
|
|
165
176
|
import tempfile
|
|
@@ -240,8 +251,15 @@ VERIFY_TOKEN_BY_STAGE: Final[dict[str, str]] = {
|
|
|
240
251
|
#: there is no `forge-verify-*` key for it to write.
|
|
241
252
|
VERIFY_STAGES: Final[tuple[str, ...]] = ("forge-0-epic", *VERIFY_TOKEN_BY_STAGE)
|
|
242
253
|
|
|
243
|
-
#:
|
|
254
|
+
#: The terminal status the completion writer records (and the commit-hash
|
|
255
|
+
#: follow-up requires) — NOT the whole "done for selection" set below.
|
|
244
256
|
_DONE_STATUS: Final = "complete"
|
|
257
|
+
#: Production stage statuses that count as "done" for next-stage selection.
|
|
258
|
+
#: `skipped` is legal only on forge-6-docs (schema: `docsStageEntry`) — an
|
|
259
|
+
#: explicitly skipped documentation stage ends the pipeline without claiming
|
|
260
|
+
#: artifacts it never produced (#197). Selection treats the status as done
|
|
261
|
+
#: wherever it appears; the schema is what confines it to the docs stage.
|
|
262
|
+
_DONE_STATUSES: Final = frozenset({_DONE_STATUS, "skipped"})
|
|
245
263
|
#: The authoritative forge-verify status vocabulary. SOURCE OF TRUTH:
|
|
246
264
|
#: references/pipeline-state-schema.json (definitions.verifyEntry.properties.status.enum).
|
|
247
265
|
#: A status outside this set is unrecognized and must not be silently interpreted (#148).
|
|
@@ -261,6 +279,15 @@ KNOWN_VERIFY_STATUSES: Final = frozenset(
|
|
|
261
279
|
#: subset of KNOWN_VERIFY_STATUSES — not collapsible into it (different meaning).
|
|
262
280
|
#: `auto-verify-pending` is deliberately ABSENT: owed-but-unrun debt is not resolved.
|
|
263
281
|
_VERIFY_RESOLVED: Final = frozenset({"passed", "findings-applied", "skipped"})
|
|
282
|
+
#: Prior verify statuses a `skipped` result write may NOT replace (#203). Mirrors
|
|
283
|
+
#: epic-manifest.py's `_VERIFY_ORCH_COMPLETE`: these two statuses make an epic
|
|
284
|
+
#: member complete-for-orchestration, so silently replacing one with `skipped`
|
|
285
|
+
#: demoted the member out of the rollup and fabricated unmetDeps on every
|
|
286
|
+
#: dependent — the observed 5/6 → 1/6 collapse. A deferral over one of these
|
|
287
|
+
#: needs NO write: the recorded result already carries the outstanding state.
|
|
288
|
+
#: NOT the same set as `_VERIFY_RESOLVED` (`skipped` re-writing `skipped` is a
|
|
289
|
+
#: harmless idempotent refresh and stays legal).
|
|
290
|
+
_SKIP_PROTECTED_PRIOR: Final = frozenset({"passed", "findings-applied"})
|
|
264
291
|
#: Per-process dedupe for the unknown-verify-status diagnostic (#148) so a single
|
|
265
292
|
#: bogus status is flagged once, not once per verify_state() call in a command.
|
|
266
293
|
_UNKNOWN_VERIFY_WARNED: set[str] = set()
|
|
@@ -371,8 +398,10 @@ VerifyStatus = Literal[
|
|
|
371
398
|
#: Which gate form a stage exit asks the caller to render.
|
|
372
399
|
VerifyGate = Literal["none", "standard", "manual-print"]
|
|
373
400
|
|
|
374
|
-
LoopOutcome = Literal[
|
|
375
|
-
|
|
401
|
+
LoopOutcome = Literal[
|
|
402
|
+
"complete", "partial", "blocked", "needs-human", "deferred", "resolved"
|
|
403
|
+
]
|
|
404
|
+
DocsOutcome = Literal["complete", "blocked", "skipped"]
|
|
376
405
|
VerifyOutcome = Literal["passed", "findings", "skipped", "failed"]
|
|
377
406
|
FixOutcome = Literal[
|
|
378
407
|
"no-findings",
|
|
@@ -414,6 +443,27 @@ VERIFY_MODE_TO_STAGE: Final[dict[str, str]] = {
|
|
|
414
443
|
"backlog": "forge-4-backlog",
|
|
415
444
|
"impl": "forge-5-loop",
|
|
416
445
|
}
|
|
446
|
+
#: Token-set Jaccard edge threshold for ``cluster_blocked``: two blocked items whose
|
|
447
|
+
#: normalized blockedReason token sets score >= this join one systemic-cause cluster
|
|
448
|
+
#: candidate. Calibrated against a real one-cause-three-phrasings incident — the
|
|
449
|
+
#: binding pair clears 0.5 by only ~0.028, and tests/test_decision_clustering.py
|
|
450
|
+
#: vendors those strings verbatim so a threshold change that would re-split the
|
|
451
|
+
#: incident is caught. Under-clustering is the deliberately chosen failure direction:
|
|
452
|
+
#: the agent holds merge authority, so the scripted floor must never over-merge.
|
|
453
|
+
CLUSTER_JACCARD_THRESHOLD: Final[float] = 0.5
|
|
454
|
+
#: Advisory topology warn triggers for ``compute_topology`` — a single root whose
|
|
455
|
+
#: gated subtree is >= ceil(ratio * itemCount) items trips "single-root-fanout";
|
|
456
|
+
#: a dependsOn chain of >= ceil(ratio * itemCount) nodes trips "chain-depth".
|
|
457
|
+
#: math.ceil keeps the ratios the single source of the thresholds even if a
|
|
458
|
+
#: future ratio is non-half. Advisory only: no consumer blocks on them.
|
|
459
|
+
TOPOLOGY_FANOUT_WARN_RATIO: Final[float] = 0.5
|
|
460
|
+
TOPOLOGY_DEPTH_WARN_RATIO: Final[float] = 0.5
|
|
461
|
+
#: The forge-side capability threshold for the runner's `backlog answer` apply
|
|
462
|
+
#: surface: at or above this rauf version the recovery procedure applies answers
|
|
463
|
+
#: via `rauf backlog answer`; below it, it degrades to `rauf backlog unblock`.
|
|
464
|
+
#: It never hard-fails recovery, and it is NOT ``loopRunner.minRunnerVersion``
|
|
465
|
+
#: (the install floor in references/forge-config-schema.json, which stays 0.6.0).
|
|
466
|
+
RECOVERY_MIN_RUNNER_VERSION: Final[str] = "0.14.0"
|
|
417
467
|
#: The fixed final line of the NEXT-STEPS block. The stamp instructs the skill
|
|
418
468
|
#: to print the block verbatim as its absolute last output — nothing after this.
|
|
419
469
|
NEXT_STEPS_SENTINEL: Final = "─ forge: end of stage ─"
|
|
@@ -746,9 +796,10 @@ def next_stage(state: dict) -> str | None:
|
|
|
746
796
|
"""Return the first production stage that is not yet complete (the next step).
|
|
747
797
|
|
|
748
798
|
Walks ``PRODUCTION_STAGES`` in order and returns the first whose recorded
|
|
749
|
-
status is not ``
|
|
750
|
-
count as "not done"
|
|
751
|
-
|
|
799
|
+
status is not in ``_DONE_STATUSES`` (a missing/pending/in-progress/stale
|
|
800
|
+
stage all count as "not done"; ``complete`` and a forge-6-docs ``skipped``
|
|
801
|
+
both count as done). Returns ``None`` when every production stage is done
|
|
802
|
+
(nothing left to run).
|
|
752
803
|
|
|
753
804
|
This is the derived "what runs next" value — the single source of truth for
|
|
754
805
|
the next stage. It is intentionally distinct from the stored
|
|
@@ -757,7 +808,7 @@ def next_stage(state: dict) -> str | None:
|
|
|
757
808
|
``currentStage``.
|
|
758
809
|
"""
|
|
759
810
|
for stage in PRODUCTION_STAGES:
|
|
760
|
-
if _stage_status(state, stage)
|
|
811
|
+
if _stage_status(state, stage) not in _DONE_STATUSES:
|
|
761
812
|
return stage
|
|
762
813
|
return None
|
|
763
814
|
|
|
@@ -912,7 +963,7 @@ def verify_state(state: dict) -> tuple[str | None, str]:
|
|
|
912
963
|
likewise ``stale``: verify rather than skip.
|
|
913
964
|
"""
|
|
914
965
|
for stage in reversed(PRODUCTION_STAGES):
|
|
915
|
-
if _stage_status(state, stage)
|
|
966
|
+
if _stage_status(state, stage) not in _DONE_STATUSES:
|
|
916
967
|
continue
|
|
917
968
|
token = VERIFY_TOKEN_BY_STAGE.get(stage)
|
|
918
969
|
if token is None:
|
|
@@ -1236,6 +1287,18 @@ def _config_value(config_path: Path, key: str):
|
|
|
1236
1287
|
return _load_config(config_path).get(key)
|
|
1237
1288
|
|
|
1238
1289
|
|
|
1290
|
+
def _config_duplicate_keys(config_path: Path) -> list[str]:
|
|
1291
|
+
"""Duplicate key names in the config file, for doctor's health report.
|
|
1292
|
+
|
|
1293
|
+
Empty on a missing/unreadable/invalid config — those conditions are
|
|
1294
|
+
reported by doctor's ``configExists`` field, not here.
|
|
1295
|
+
"""
|
|
1296
|
+
try:
|
|
1297
|
+
return load_json_with_duplicates(config_path)[1]
|
|
1298
|
+
except (OSError, json.JSONDecodeError):
|
|
1299
|
+
return []
|
|
1300
|
+
|
|
1301
|
+
|
|
1239
1302
|
def auto_verify_for(config: dict, stage: str) -> bool:
|
|
1240
1303
|
"""Return the effective auto-verify setting for ``stage``.
|
|
1241
1304
|
|
|
@@ -1468,6 +1531,7 @@ def doctor_report(specs_dir: Path, config_path: Path) -> dict:
|
|
|
1468
1531
|
"counts": _counts(specs_dir),
|
|
1469
1532
|
"features": features,
|
|
1470
1533
|
"invalidAutoVerifyKeys": invalid_auto_verify_keys(config),
|
|
1534
|
+
"duplicateConfigKeys": _config_duplicate_keys(config_path),
|
|
1471
1535
|
"rootSandbox": _root_sandbox_status(),
|
|
1472
1536
|
}
|
|
1473
1537
|
|
|
@@ -1535,6 +1599,11 @@ def _print_doctor(report: dict) -> None:
|
|
|
1535
1599
|
invalid = report.get("invalidAutoVerifyKeys") or []
|
|
1536
1600
|
if invalid:
|
|
1537
1601
|
print(" ! invalid autoVerifyStages keys (ignored): " + ", ".join(invalid))
|
|
1602
|
+
duplicates = report.get("duplicateConfigKeys") or []
|
|
1603
|
+
if duplicates:
|
|
1604
|
+
print(
|
|
1605
|
+
" ! duplicate config keys (last value wins): " + ", ".join(duplicates)
|
|
1606
|
+
)
|
|
1538
1607
|
rs = report.get("rootSandbox") or {}
|
|
1539
1608
|
if rs.get("isRoot"):
|
|
1540
1609
|
if rs.get("isSandboxSet"):
|
|
@@ -2040,6 +2109,402 @@ def _print_check_epic_base(payload: dict) -> None:
|
|
|
2040
2109
|
print(f" → switch to the epic's home branch: {payload['homeBranch'] or '(unknown)'}")
|
|
2041
2110
|
|
|
2042
2111
|
|
|
2112
|
+
# --------------------------------------------------------------------------- #
|
|
2113
|
+
# Dependency graph & blocked-item clustering
|
|
2114
|
+
# --------------------------------------------------------------------------- #
|
|
2115
|
+
# Pure, stdlib-only flat functions over the loop runner's item array (the
|
|
2116
|
+
# `listCommand` JSON the caller already holds) — same precedent as
|
|
2117
|
+
# rank-features/reconcile-branch, no class. Nothing here reads backlog.json off
|
|
2118
|
+
# disk: single data source, so every derived claim cites the runner's
|
|
2119
|
+
# authoritative counts. All ordering flows through _id_key, never dict/hash
|
|
2120
|
+
# iteration, which is what makes the output deterministic and testable.
|
|
2121
|
+
|
|
2122
|
+
|
|
2123
|
+
def _id_key(item_id: object) -> tuple[int, object]:
|
|
2124
|
+
"""Deterministic sort key for backlog ids.
|
|
2125
|
+
|
|
2126
|
+
All-digit ids sort numerically ("2" before "10"); everything else sorts
|
|
2127
|
+
lexically, after the numeric block. Used everywhere an ordering must not
|
|
2128
|
+
depend on dict/hash iteration.
|
|
2129
|
+
|
|
2130
|
+
Args:
|
|
2131
|
+
item_id: A backlog item id (usually ``str``; coerced defensively).
|
|
2132
|
+
|
|
2133
|
+
Returns:
|
|
2134
|
+
A ``(bucket, value)`` tuple that is a total order across mixed id shapes.
|
|
2135
|
+
"""
|
|
2136
|
+
s = str(item_id)
|
|
2137
|
+
return (0, int(s)) if s.isdigit() else (1, s)
|
|
2138
|
+
|
|
2139
|
+
|
|
2140
|
+
def _build_dep_index(
|
|
2141
|
+
items: list[dict],
|
|
2142
|
+
) -> tuple[dict[str, dict], dict[str, list[str]], dict[str, list[str]]]:
|
|
2143
|
+
"""Build the in-backlog dependency adjacency from ``dependsOn`` edges.
|
|
2144
|
+
|
|
2145
|
+
Edges pointing at ids **not present** in this backlog are dropped (an item
|
|
2146
|
+
whose only ``dependsOn`` targets are external is therefore a root).
|
|
2147
|
+
|
|
2148
|
+
Args:
|
|
2149
|
+
items: The runner's item array (each a dict with at least ``id``; optional
|
|
2150
|
+
``dependsOn``, ``status``, ``blockedReason``).
|
|
2151
|
+
|
|
2152
|
+
Returns:
|
|
2153
|
+
``(by_id, deps, dependents)`` where ``by_id`` maps id → item, ``deps`` maps
|
|
2154
|
+
id → the ids it depends on (in-backlog only), and ``dependents`` maps id →
|
|
2155
|
+
the ids that directly depend on it.
|
|
2156
|
+
"""
|
|
2157
|
+
by_id = {str(it["id"]): it for it in items}
|
|
2158
|
+
deps: dict[str, list[str]] = {
|
|
2159
|
+
i: [str(d) for d in (by_id[i].get("dependsOn") or []) if str(d) in by_id]
|
|
2160
|
+
for i in by_id
|
|
2161
|
+
}
|
|
2162
|
+
dependents: dict[str, list[str]] = {i: [] for i in by_id}
|
|
2163
|
+
for i, ds in deps.items():
|
|
2164
|
+
for d in ds:
|
|
2165
|
+
dependents[d].append(i)
|
|
2166
|
+
return by_id, deps, dependents
|
|
2167
|
+
|
|
2168
|
+
|
|
2169
|
+
def _transitive_dependents(
|
|
2170
|
+
dependents: dict[str, list[str]],
|
|
2171
|
+
) -> dict[str, set[str]]:
|
|
2172
|
+
"""Memoized transitive-dependents (gated-subtree) closure for every node.
|
|
2173
|
+
|
|
2174
|
+
``dependents[x]`` lists items that directly depend on ``x``; the returned map
|
|
2175
|
+
gives, for each item, the set of items that **transitively** depend on it — the
|
|
2176
|
+
gated subtree that item's completion would unblock ("gates").
|
|
2177
|
+
|
|
2178
|
+
Cycle-safe: a node re-encountered on the current DFS path contributes nothing
|
|
2179
|
+
and is not memoized (rauf rejects cycles upstream, so this only hardens against
|
|
2180
|
+
malformed input; it never fires on validated backlogs).
|
|
2181
|
+
|
|
2182
|
+
Args:
|
|
2183
|
+
dependents: The reverse adjacency from :func:`_build_dep_index`.
|
|
2184
|
+
|
|
2185
|
+
Returns:
|
|
2186
|
+
A map id → set of transitively-dependent ids. O(V + E) overall (each edge
|
|
2187
|
+
is walked once thanks to memoization).
|
|
2188
|
+
"""
|
|
2189
|
+
memo: dict[str, set[str]] = {}
|
|
2190
|
+
|
|
2191
|
+
def visit(node: str, on_path: set[str]) -> set[str]:
|
|
2192
|
+
if node in memo:
|
|
2193
|
+
return memo[node]
|
|
2194
|
+
if node in on_path: # cycle guard — unreachable on validated backlogs
|
|
2195
|
+
return set()
|
|
2196
|
+
on_path.add(node)
|
|
2197
|
+
acc: set[str] = set()
|
|
2198
|
+
for child in dependents[node]:
|
|
2199
|
+
acc.add(child)
|
|
2200
|
+
acc |= visit(child, on_path)
|
|
2201
|
+
on_path.discard(node)
|
|
2202
|
+
memo[node] = acc
|
|
2203
|
+
return acc
|
|
2204
|
+
|
|
2205
|
+
for n in dependents:
|
|
2206
|
+
visit(n, set())
|
|
2207
|
+
return memo
|
|
2208
|
+
|
|
2209
|
+
|
|
2210
|
+
#: A token that is a pure number or item-id-shaped (``42``, ``req12``, ``t7``) —
|
|
2211
|
+
#: noise carrying no cause signal, dropped by _normalize_reason.
|
|
2212
|
+
_ID_SHAPED_TOKEN = re.compile(r"^(?:\d+|[a-z]*\d+)$")
|
|
2213
|
+
|
|
2214
|
+
|
|
2215
|
+
def _normalize_reason(text: str | None) -> set[str]:
|
|
2216
|
+
"""Normalize a ``blockedReason`` into its comparison token set.
|
|
2217
|
+
|
|
2218
|
+
Lowercases, splits on any run of non-alphanumeric characters, and drops noise
|
|
2219
|
+
tokens — pure numbers and item-id-shaped tokens (``42``, ``req12``, ``t7``) —
|
|
2220
|
+
which carry no cause signal and would spuriously separate or merge reasons.
|
|
2221
|
+
|
|
2222
|
+
Args:
|
|
2223
|
+
text: The item's ``blockedReason`` (may be ``None``/empty).
|
|
2224
|
+
|
|
2225
|
+
Returns:
|
|
2226
|
+
The set of meaningful lowercased tokens (possibly empty).
|
|
2227
|
+
"""
|
|
2228
|
+
tokens = re.split(r"[^a-z0-9]+", (text or "").lower())
|
|
2229
|
+
return {t for t in tokens if t and not _ID_SHAPED_TOKEN.match(t)}
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
2233
|
+
"""Jaccard similarity |A∩B| / |A∪B| of two token sets.
|
|
2234
|
+
|
|
2235
|
+
Symmetric and order-insensitive. Two empty sets score ``0.0`` — an item with
|
|
2236
|
+
no meaningful reason tokens never clusters with anything.
|
|
2237
|
+
|
|
2238
|
+
Args:
|
|
2239
|
+
a: First token set.
|
|
2240
|
+
b: Second token set.
|
|
2241
|
+
|
|
2242
|
+
Returns:
|
|
2243
|
+
A similarity in ``[0.0, 1.0]``.
|
|
2244
|
+
"""
|
|
2245
|
+
union = a | b
|
|
2246
|
+
return len(a & b) / len(union) if union else 0.0
|
|
2247
|
+
|
|
2248
|
+
|
|
2249
|
+
def cluster_blocked(items: list[dict]) -> list[dict]:
|
|
2250
|
+
"""Cluster blocked items by ``blockedReason`` similarity.
|
|
2251
|
+
|
|
2252
|
+
Union-find over every pair of ``status == "blocked"`` items whose normalized
|
|
2253
|
+
token-set Jaccard is ``>= CLUSTER_JACCARD_THRESHOLD``. Each emitted component
|
|
2254
|
+
carries its member ids, the members' raw reasons, the shared token core, and
|
|
2255
|
+
the **union** of the members' gated subtrees for blast-radius framing.
|
|
2256
|
+
Components of size 1 are emitted too — the recovery procedure consolidates
|
|
2257
|
+
only components of >= 2, prompting singletons per item.
|
|
2258
|
+
|
|
2259
|
+
The result is the deterministic *substrate*: the agent may merge components it
|
|
2260
|
+
judges to share a cause (under-clustering is the deliberately chosen failure
|
|
2261
|
+
direction). It never reads disk; ``items`` is the runner's array.
|
|
2262
|
+
|
|
2263
|
+
Args:
|
|
2264
|
+
items: The runner's ``listCommand`` item array.
|
|
2265
|
+
|
|
2266
|
+
Returns:
|
|
2267
|
+
A list of cluster dicts, sorted by lowest member id:
|
|
2268
|
+
``{clusterId, memberIds, memberReasons, sharedTokens, gatedIds, gatedCount}``.
|
|
2269
|
+
"""
|
|
2270
|
+
by_id, _deps, dependents = _build_dep_index(items)
|
|
2271
|
+
gated = _transitive_dependents(dependents)
|
|
2272
|
+
blocked = sorted(
|
|
2273
|
+
(i for i, it in by_id.items() if it.get("status") == "blocked"),
|
|
2274
|
+
key=_id_key,
|
|
2275
|
+
)
|
|
2276
|
+
tokens = {i: _normalize_reason(by_id[i].get("blockedReason")) for i in blocked}
|
|
2277
|
+
|
|
2278
|
+
parent = {i: i for i in blocked}
|
|
2279
|
+
|
|
2280
|
+
def find(x: str) -> str:
|
|
2281
|
+
while parent[x] != x:
|
|
2282
|
+
parent[x] = parent[parent[x]] # path halving
|
|
2283
|
+
x = parent[x]
|
|
2284
|
+
return x
|
|
2285
|
+
|
|
2286
|
+
def union(a: str, b: str) -> None:
|
|
2287
|
+
ra, rb = find(a), find(b)
|
|
2288
|
+
if ra == rb:
|
|
2289
|
+
return
|
|
2290
|
+
lo, hi = sorted((ra, rb), key=_id_key) # lowest id is the component root
|
|
2291
|
+
parent[hi] = lo
|
|
2292
|
+
|
|
2293
|
+
for idx, a in enumerate(blocked):
|
|
2294
|
+
for b in blocked[idx + 1:]:
|
|
2295
|
+
if _jaccard(tokens[a], tokens[b]) >= CLUSTER_JACCARD_THRESHOLD:
|
|
2296
|
+
union(a, b)
|
|
2297
|
+
|
|
2298
|
+
groups: dict[str, list[str]] = {}
|
|
2299
|
+
for i in blocked:
|
|
2300
|
+
groups.setdefault(find(i), []).append(i)
|
|
2301
|
+
|
|
2302
|
+
clusters: list[dict] = []
|
|
2303
|
+
for root in sorted(groups, key=_id_key):
|
|
2304
|
+
members = sorted(groups[root], key=_id_key)
|
|
2305
|
+
shared = set.intersection(*(tokens[m] for m in members)) if members else set()
|
|
2306
|
+
union_gated: set[str] = set()
|
|
2307
|
+
for m in members:
|
|
2308
|
+
union_gated |= gated[m]
|
|
2309
|
+
union_gated -= set(members) # a member gating a sibling is not its own blast radius
|
|
2310
|
+
clusters.append(
|
|
2311
|
+
{
|
|
2312
|
+
"clusterId": "c" + members[0], # "c" + lowest member id: stable across runs
|
|
2313
|
+
"memberIds": members,
|
|
2314
|
+
"memberReasons": [by_id[m].get("blockedReason") or "" for m in members],
|
|
2315
|
+
"sharedTokens": sorted(shared),
|
|
2316
|
+
"gatedIds": sorted(union_gated, key=_id_key),
|
|
2317
|
+
"gatedCount": len(union_gated),
|
|
2318
|
+
}
|
|
2319
|
+
)
|
|
2320
|
+
return clusters
|
|
2321
|
+
|
|
2322
|
+
|
|
2323
|
+
def _max_chain_depth(by_id: dict[str, dict], deps: dict[str, list[str]]) -> int:
|
|
2324
|
+
"""Longest ``dependsOn`` chain length (node count), memoized and cycle-safe.
|
|
2325
|
+
|
|
2326
|
+
Depth of a node = ``1 + max(depth(dep) …)`` over its in-backlog dependencies;
|
|
2327
|
+
the result is the maximum over all nodes. A node re-seen on the current path
|
|
2328
|
+
contributes ``0`` (cycle guard; unreachable on validated backlogs).
|
|
2329
|
+
|
|
2330
|
+
Args:
|
|
2331
|
+
by_id: id → item, from :func:`_build_dep_index`.
|
|
2332
|
+
deps: id → dependency ids, from :func:`_build_dep_index`.
|
|
2333
|
+
|
|
2334
|
+
Returns:
|
|
2335
|
+
The longest chain length; ``0`` for an empty backlog.
|
|
2336
|
+
"""
|
|
2337
|
+
memo: dict[str, int] = {}
|
|
2338
|
+
|
|
2339
|
+
def depth(node: str, on_path: set[str]) -> int:
|
|
2340
|
+
if node in memo:
|
|
2341
|
+
return memo[node]
|
|
2342
|
+
if node in on_path: # cycle guard
|
|
2343
|
+
return 0
|
|
2344
|
+
on_path.add(node)
|
|
2345
|
+
d = 1 + max((depth(x, on_path) for x in deps[node]), default=0)
|
|
2346
|
+
on_path.discard(node)
|
|
2347
|
+
memo[node] = d
|
|
2348
|
+
return d
|
|
2349
|
+
|
|
2350
|
+
return max((depth(n, set()) for n in by_id), default=0)
|
|
2351
|
+
|
|
2352
|
+
|
|
2353
|
+
def compute_topology(items: list[dict]) -> dict:
|
|
2354
|
+
"""Compute dependency-topology metrics + advisory warnings (REQ-TOPO-01..03).
|
|
2355
|
+
|
|
2356
|
+
Pure function over the runner's item array (single data source, decision
|
|
2357
|
+
V-007) — it never reads ``backlog.json`` off disk, so every derived count
|
|
2358
|
+
cites the runner's authoritative array (REQ-ATTR-01, REQ-OBS-01). Linear via
|
|
2359
|
+
the memoized DFS helpers above (REQ-PERF-01).
|
|
2360
|
+
|
|
2361
|
+
Args:
|
|
2362
|
+
items: The runner's ``listCommand`` item array. Each item may carry
|
|
2363
|
+
``id``, ``dependsOn`` (list of ids), and ``status`` (``pending``/
|
|
2364
|
+
``done``/``blocked``/…).
|
|
2365
|
+
|
|
2366
|
+
Returns:
|
|
2367
|
+
The ``backlog-topology`` output shape (without ``clusters`` — that is
|
|
2368
|
+
appended by the verb under ``--cluster``): ``{itemCount, rootCount,
|
|
2369
|
+
roots, maxChainDepth, selectable, starvation, warnings}``.
|
|
2370
|
+
"""
|
|
2371
|
+
by_id, deps, dependents = _build_dep_index(items)
|
|
2372
|
+
item_count = len(by_id)
|
|
2373
|
+
gated = _transitive_dependents(dependents)
|
|
2374
|
+
|
|
2375
|
+
roots = [i for i in by_id if not deps[i]] # no in-backlog dependsOn edges
|
|
2376
|
+
roots_out = sorted(
|
|
2377
|
+
(
|
|
2378
|
+
{
|
|
2379
|
+
"id": r,
|
|
2380
|
+
"gatedCount": len(gated[r]),
|
|
2381
|
+
"gatedIds": sorted(gated[r], key=_id_key),
|
|
2382
|
+
}
|
|
2383
|
+
for r in roots
|
|
2384
|
+
),
|
|
2385
|
+
key=lambda row: _id_key(row["id"]),
|
|
2386
|
+
)
|
|
2387
|
+
|
|
2388
|
+
max_depth = _max_chain_depth(by_id, deps)
|
|
2389
|
+
|
|
2390
|
+
selectable = sum(
|
|
2391
|
+
1
|
|
2392
|
+
for i, it in by_id.items()
|
|
2393
|
+
if it.get("status") == "pending"
|
|
2394
|
+
and all(by_id[d].get("status") == "done" for d in deps[i])
|
|
2395
|
+
)
|
|
2396
|
+
pending = sum(1 for it in by_id.values() if it.get("status") == "pending")
|
|
2397
|
+
|
|
2398
|
+
fanout_threshold = math.ceil(TOPOLOGY_FANOUT_WARN_RATIO * item_count)
|
|
2399
|
+
depth_threshold = math.ceil(TOPOLOGY_DEPTH_WARN_RATIO * item_count)
|
|
2400
|
+
|
|
2401
|
+
# A trivial graph (0-1 items, or no dependsOn edges at all) has no topology
|
|
2402
|
+
# to warn about — a single node's depth of 1 would otherwise trip the
|
|
2403
|
+
# ceil(0.5 * 1) = 1 depth threshold on every one-item backlog.
|
|
2404
|
+
warnings: list[str] = []
|
|
2405
|
+
if item_count > 1 and any(deps[i] for i in by_id):
|
|
2406
|
+
if any(row["gatedCount"] >= fanout_threshold for row in roots_out):
|
|
2407
|
+
warnings.append("single-root-fanout")
|
|
2408
|
+
if max_depth >= depth_threshold:
|
|
2409
|
+
warnings.append("chain-depth")
|
|
2410
|
+
|
|
2411
|
+
starvation = None
|
|
2412
|
+
if selectable == 0 and pending > 0:
|
|
2413
|
+
starvation = {
|
|
2414
|
+
"starved": True,
|
|
2415
|
+
"blockingRoots": [
|
|
2416
|
+
{"id": row["id"], "gatedCount": row["gatedCount"]}
|
|
2417
|
+
for row in roots_out
|
|
2418
|
+
if row["gatedCount"] > 0 and by_id[row["id"]].get("status") != "done"
|
|
2419
|
+
],
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
return {
|
|
2423
|
+
"itemCount": item_count,
|
|
2424
|
+
"rootCount": len(roots),
|
|
2425
|
+
"roots": roots_out,
|
|
2426
|
+
"maxChainDepth": max_depth,
|
|
2427
|
+
"selectable": selectable,
|
|
2428
|
+
"starvation": starvation,
|
|
2429
|
+
"warnings": warnings,
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
|
|
2433
|
+
def cmd_backlog_topology(items: list[dict], *, with_clusters: bool) -> dict:
|
|
2434
|
+
"""Assemble the ``backlog-topology`` payload.
|
|
2435
|
+
|
|
2436
|
+
Args:
|
|
2437
|
+
items: The runner's ``listCommand`` item array.
|
|
2438
|
+
with_clusters: When true, append the ``clusters`` section.
|
|
2439
|
+
|
|
2440
|
+
Returns:
|
|
2441
|
+
The topology dict; with ``clusters`` appended iff ``with_clusters``.
|
|
2442
|
+
"""
|
|
2443
|
+
result = compute_topology(items)
|
|
2444
|
+
if with_clusters:
|
|
2445
|
+
result["clusters"] = cluster_blocked(items)
|
|
2446
|
+
return result
|
|
2447
|
+
|
|
2448
|
+
|
|
2449
|
+
def _load_topology_items(args: argparse.Namespace) -> list[dict]:
|
|
2450
|
+
"""Read and parse the runner item array for ``backlog-topology``.
|
|
2451
|
+
|
|
2452
|
+
Accepts either a top-level JSON array or an object with an ``items`` array
|
|
2453
|
+
(rauf ``backlog list --json`` emits the array; the object form is tolerated
|
|
2454
|
+
for forward-compatibility). All failures raise ``UsageError`` → exit 2,
|
|
2455
|
+
never a partial/guessed result. This is the ONLY input path for the
|
|
2456
|
+
topology verb — it never opens ``backlog.json`` off disk (single data
|
|
2457
|
+
source, decision V-007).
|
|
2458
|
+
|
|
2459
|
+
Args:
|
|
2460
|
+
args: Parsed namespace with ``items_stdin`` / ``items_json``.
|
|
2461
|
+
|
|
2462
|
+
Returns:
|
|
2463
|
+
The item list.
|
|
2464
|
+
|
|
2465
|
+
Raises:
|
|
2466
|
+
UsageError: unreadable ``--items-json``, invalid JSON, or a shape that is
|
|
2467
|
+
neither an array nor an object carrying an ``items`` array.
|
|
2468
|
+
"""
|
|
2469
|
+
if args.items_stdin:
|
|
2470
|
+
raw = sys.stdin.read()
|
|
2471
|
+
else:
|
|
2472
|
+
try:
|
|
2473
|
+
raw = Path(args.items_json).read_text(encoding="utf-8")
|
|
2474
|
+
except OSError as exc:
|
|
2475
|
+
raise UsageError(f"cannot read --items-json {args.items_json}: {exc}") from exc
|
|
2476
|
+
try:
|
|
2477
|
+
data = json.loads(raw)
|
|
2478
|
+
except json.JSONDecodeError as exc:
|
|
2479
|
+
raise UsageError(f"invalid items JSON: {exc}") from exc
|
|
2480
|
+
items = data.get("items", []) if isinstance(data, dict) else data
|
|
2481
|
+
if not isinstance(items, list):
|
|
2482
|
+
raise UsageError("items JSON must be an array or an object with an 'items' array")
|
|
2483
|
+
return items
|
|
2484
|
+
|
|
2485
|
+
|
|
2486
|
+
def _print_topology(payload: dict) -> None:
|
|
2487
|
+
"""Human-readable topology summary (machine consumers pass ``--json``)."""
|
|
2488
|
+
print(
|
|
2489
|
+
f"Topology: {payload['itemCount']} items, {payload['rootCount']} roots, "
|
|
2490
|
+
f"max chain depth {payload['maxChainDepth']}, selectable {payload['selectable']}"
|
|
2491
|
+
)
|
|
2492
|
+
for row in sorted(payload["roots"], key=lambda r: -r["gatedCount"]):
|
|
2493
|
+
print(f" root {row['id']} gates {row['gatedCount']} item(s)")
|
|
2494
|
+
for warning in payload["warnings"]:
|
|
2495
|
+
print(f" warning: {warning}")
|
|
2496
|
+
starvation = payload.get("starvation")
|
|
2497
|
+
if starvation:
|
|
2498
|
+
blocking = ", ".join(r["id"] for r in starvation["blockingRoots"])
|
|
2499
|
+
print(f" starved: no selectable item; blocking roots: {blocking}")
|
|
2500
|
+
for cluster in payload.get("clusters", []):
|
|
2501
|
+
members = ", ".join(cluster["memberIds"])
|
|
2502
|
+
print(
|
|
2503
|
+
f" cluster {cluster['clusterId']}: members {members} "
|
|
2504
|
+
f"(gates {cluster['gatedCount']} item(s))"
|
|
2505
|
+
)
|
|
2506
|
+
|
|
2507
|
+
|
|
2043
2508
|
# --------------------------------------------------------------------------- #
|
|
2044
2509
|
# Scripted Stage Exit
|
|
2045
2510
|
# --------------------------------------------------------------------------- #
|
|
@@ -2869,6 +3334,36 @@ _DOCS_OUTCOME_TEXT: Final[dict[str, str]] = {
|
|
|
2869
3334
|
"nor epic {epic} is complete. Only valid partial state was persisted. Open "
|
|
2870
3335
|
"the epic dashboard below to see the epic's live state and recover from there."
|
|
2871
3336
|
),
|
|
3337
|
+
# The `skipped` variants (#197): same routes as `complete`, honest wording — a
|
|
3338
|
+
# deliberate skip closes the pipeline without any stage claiming artifacts it
|
|
3339
|
+
# never produced, and the state says `skipped`, not `complete`.
|
|
3340
|
+
"standalone-skipped": (
|
|
3341
|
+
"Documentation was deliberately skipped for {feature} and recorded as "
|
|
3342
|
+
"`skipped` in state, closing the pipeline without claiming docs that were "
|
|
3343
|
+
"never written. The navigator command below is the authoritative completion "
|
|
3344
|
+
"action — it confirms the finished state from disk. Docs can still be "
|
|
3345
|
+
"generated later by re-running `{docs_stage}`. Optionally, you can start a "
|
|
3346
|
+
"new feature with `{new_feature}` or group related work into an epic with "
|
|
3347
|
+
"`{new_epic}`; neither is required to finish here."
|
|
3348
|
+
),
|
|
3349
|
+
"epic-actionable-skipped": (
|
|
3350
|
+
"Documentation was deliberately skipped for {feature} and recorded as "
|
|
3351
|
+
"`skipped` in state. Epic {epic} has more work that can be started now "
|
|
3352
|
+
"({complete}/{total} members complete), so the pipeline continues with the "
|
|
3353
|
+
"next actionable member below."
|
|
3354
|
+
),
|
|
3355
|
+
"epic-blocked-members-skipped": (
|
|
3356
|
+
"Documentation was deliberately skipped for {feature} and recorded as "
|
|
3357
|
+
"`skipped` in state, but no member of epic {epic} is actionable right now "
|
|
3358
|
+
"({complete}/{total} members complete) — the remaining work is blocked by "
|
|
3359
|
+
"unmet dependencies. Open the epic dashboard below to see what is holding "
|
|
3360
|
+
"it up."
|
|
3361
|
+
),
|
|
3362
|
+
"epic-complete-skipped": (
|
|
3363
|
+
"Documentation was deliberately skipped for {feature} and recorded as "
|
|
3364
|
+
"`skipped` in state, and every member of epic {epic} is now complete "
|
|
3365
|
+
"({complete}/{total}). Open the epic dashboard below for its completion view."
|
|
3366
|
+
),
|
|
2872
3367
|
}
|
|
2873
3368
|
|
|
2874
3369
|
|
|
@@ -2882,13 +3377,15 @@ def _docs_route(
|
|
|
2882
3377
|
next member routes to that member's own live command, and anything else (blocked
|
|
2883
3378
|
remaining work, or every member complete) routes to the epic dashboard, which is
|
|
2884
3379
|
also the dashboard's completion view. A ``blocked`` docs outcome routes to
|
|
2885
|
-
recovery and NEVER claims pipeline completion.
|
|
3380
|
+
recovery and NEVER claims pipeline completion. A ``skipped`` outcome (#197)
|
|
3381
|
+
takes exactly the routes ``complete`` takes — the pipeline still ends here —
|
|
3382
|
+
but its wording says the docs were deliberately skipped, never that they exist.
|
|
2886
3383
|
|
|
2887
3384
|
Args:
|
|
2888
3385
|
feature: The feature whose documentation stage is closing.
|
|
2889
3386
|
epic: The owning epic, or None for a standalone feature.
|
|
2890
3387
|
specs_dir: Configured specs directory.
|
|
2891
|
-
outcome: `complete` or `
|
|
3388
|
+
outcome: `complete`, `blocked`, or `skipped`, already validated.
|
|
2892
3389
|
host: Host surface, used only to translate the INLINE secondary mentions —
|
|
2893
3390
|
the primary command is translated by the renderer.
|
|
2894
3391
|
|
|
@@ -2903,12 +3400,11 @@ def _docs_route(
|
|
|
2903
3400
|
rather than converting into a second failure.
|
|
2904
3401
|
"""
|
|
2905
3402
|
if epic is None:
|
|
2906
|
-
text = _DOCS_OUTCOME_TEXT[
|
|
2907
|
-
"standalone-complete" if outcome == "complete" else "standalone-blocked"
|
|
2908
|
-
].format(
|
|
3403
|
+
text = _DOCS_OUTCOME_TEXT[f"standalone-{outcome}"].format(
|
|
2909
3404
|
feature=feature,
|
|
2910
3405
|
new_feature=_host_command("/skill:forge-1-prd <new-feature>", host),
|
|
2911
3406
|
new_epic=_host_command("/skill:forge-0-epic <new-epic>", host),
|
|
3407
|
+
docs_stage=_host_command(f"/skill:forge-6-docs {feature}", host),
|
|
2912
3408
|
)
|
|
2913
3409
|
return f"/skill:forge {feature}", None, text, False
|
|
2914
3410
|
|
|
@@ -2917,6 +3413,7 @@ def _docs_route(
|
|
|
2917
3413
|
text = _DOCS_OUTCOME_TEXT["epic-blocked"].format(feature=feature, epic=epic)
|
|
2918
3414
|
return dashboard, None, text, False
|
|
2919
3415
|
|
|
3416
|
+
skip_suffix = "-skipped" if outcome == "skipped" else ""
|
|
2920
3417
|
status = _render_status(specs_dir, epic)
|
|
2921
3418
|
rollup = status["rollup"]
|
|
2922
3419
|
fields = {
|
|
@@ -2927,7 +3424,12 @@ def _docs_route(
|
|
|
2927
3424
|
}
|
|
2928
3425
|
next_command = status["nextCommand"]
|
|
2929
3426
|
if status["actionable"] and next_command:
|
|
2930
|
-
return
|
|
3427
|
+
return (
|
|
3428
|
+
next_command,
|
|
3429
|
+
None,
|
|
3430
|
+
_DOCS_OUTCOME_TEXT["epic-actionable" + skip_suffix].format(**fields),
|
|
3431
|
+
True,
|
|
3432
|
+
)
|
|
2931
3433
|
# Nothing actionable. Under the current derivation that coincides with "every
|
|
2932
3434
|
# member complete" (a valid graph is acyclic, so an incomplete member always has
|
|
2933
3435
|
# an actionable ancestor), but the two cases are named separately and the
|
|
@@ -2935,7 +3437,7 @@ def _docs_route(
|
|
|
2935
3437
|
# reachable if a future derivation admits an unactionable incomplete member. Both
|
|
2936
3438
|
# route to the same epic command either way; only the explanation differs.
|
|
2937
3439
|
key = "epic-complete" if rollup["complete"] >= rollup["total"] else "epic-blocked-members"
|
|
2938
|
-
return dashboard, None, _DOCS_OUTCOME_TEXT[key].format(**fields), False
|
|
3440
|
+
return dashboard, None, _DOCS_OUTCOME_TEXT[key + skip_suffix].format(**fields), False
|
|
2939
3441
|
|
|
2940
3442
|
|
|
2941
3443
|
#: The route each loop outcome takes. A COMPLETE map over
|
|
@@ -2953,6 +3455,7 @@ _LOOP_ROUTE_KIND: Final[dict[str, str]] = {
|
|
|
2953
3455
|
"complete": "handoff",
|
|
2954
3456
|
"partial": "resume",
|
|
2955
3457
|
"deferred": "resume",
|
|
3458
|
+
"resolved": "resume",
|
|
2956
3459
|
"blocked": "recover",
|
|
2957
3460
|
"needs-human": "recover",
|
|
2958
3461
|
}
|
|
@@ -2986,8 +3489,26 @@ _LOOP_OUTCOME_TEXT: Final[dict[str, str]] = {
|
|
|
2986
3489
|
"navigator below to see the live pipeline state from disk and recover from "
|
|
2987
3490
|
"there."
|
|
2988
3491
|
),
|
|
3492
|
+
"resolved": (
|
|
3493
|
+
"The needs-human stop for {feature} was resolved — the recorded decisions "
|
|
3494
|
+
"were applied and every affected item was verified, per item, to have left "
|
|
3495
|
+
"blocked/needsHuman, with the working tree clean. The recorded state is "
|
|
3496
|
+
"resumable and nothing downstream is ready: run the loop again below to "
|
|
3497
|
+
"continue from where it stopped."
|
|
3498
|
+
),
|
|
2989
3499
|
}
|
|
2990
3500
|
|
|
3501
|
+
#: The starvation variant of the `partial` next-steps sentence (REQ-ATTR-02): names
|
|
3502
|
+
#: the unblock path instead of the iteration limit, which was NOT the binding
|
|
3503
|
+
#: constraint. Selected only by ``--cause dependency-starvation`` (REQ-ATTR-04).
|
|
3504
|
+
_LOOP_PARTIAL_STARVED_TEXT: Final[str] = (
|
|
3505
|
+
"The loop stopped for {feature} with backlog items still pending, but the "
|
|
3506
|
+
"iteration limit was NOT the constraint — no pending item was selectable because "
|
|
3507
|
+
"unblocked root items gate the rest of the backlog. The recorded state is "
|
|
3508
|
+
"resumable and nothing downstream is ready: unblock the roots named in the "
|
|
3509
|
+
"starvation report above, then run the loop again below to continue."
|
|
3510
|
+
)
|
|
3511
|
+
|
|
2991
3512
|
#: The `complete` preamble, selected by where the handoff actually lands. The epic
|
|
2992
3513
|
#: rows name the epic and its live rollup, so the operator can see WHY the handoff is
|
|
2993
3514
|
#: this member's own documentation rather than another member (or vice versa).
|
|
@@ -3043,8 +3564,10 @@ _RECONCILE_FIRST_TEXT: Final[dict[str, str]] = {
|
|
|
3043
3564
|
"the continuation named under it."
|
|
3044
3565
|
),
|
|
3045
3566
|
"forge-6-docs": (
|
|
3046
|
-
"
|
|
3047
|
-
|
|
3567
|
+
# "closed", not "complete": this wording also serves a `skipped` docs
|
|
3568
|
+
# outcome, which must never claim the docs exist (#197).
|
|
3569
|
+
"The documentation stage is closed for {feature}, but {count} blocking epic "
|
|
3570
|
+
"change request{plural} recorded against epic {epic} must be reconciled first. "
|
|
3048
3571
|
"Handing off would build the next member on a decomposition that is about to "
|
|
3049
3572
|
"change, so the reconcile below comes before the continuation named under it."
|
|
3050
3573
|
),
|
|
@@ -3123,6 +3646,7 @@ def _loop_route(
|
|
|
3123
3646
|
resolved: bool,
|
|
3124
3647
|
verify_canonical: str,
|
|
3125
3648
|
fix_canonical: str | None,
|
|
3649
|
+
cause: str | None = None,
|
|
3126
3650
|
) -> tuple[str, str | None, str, bool]:
|
|
3127
3651
|
"""Route one loop result — the outcome table.
|
|
3128
3652
|
|
|
@@ -3147,6 +3671,10 @@ def _loop_route(
|
|
|
3147
3671
|
else None. A live report outranks a fresh verify on the ``complete``
|
|
3148
3672
|
handoff: findings already exist at this exact revision, so the fenced
|
|
3149
3673
|
action is applying them, exactly as on a production re-exit.
|
|
3674
|
+
cause: The already-validated attribution annotation — only
|
|
3675
|
+
``"dependency-starvation"`` with ``outcome == "partial"``, else None.
|
|
3676
|
+
Swaps the partial next-steps sentence for the starvation variant; the
|
|
3677
|
+
route itself is unchanged (partial stays a resume either way).
|
|
3150
3678
|
|
|
3151
3679
|
Returns:
|
|
3152
3680
|
`(primary_canonical, deferred_canonical, outcome_text, advancing)`, matching
|
|
@@ -3171,7 +3699,11 @@ def _loop_route(
|
|
|
3171
3699
|
if kind == "resume"
|
|
3172
3700
|
else f"/skill:forge {feature}"
|
|
3173
3701
|
)
|
|
3174
|
-
|
|
3702
|
+
if outcome == "partial" and cause == "dependency-starvation":
|
|
3703
|
+
text = _LOOP_PARTIAL_STARVED_TEXT.format(feature=feature)
|
|
3704
|
+
else:
|
|
3705
|
+
text = _LOOP_OUTCOME_TEXT[outcome].format(feature=feature)
|
|
3706
|
+
return primary, None, text, False
|
|
3175
3707
|
|
|
3176
3708
|
handoff = successor_command or f"/skill:forge {feature}"
|
|
3177
3709
|
fields: dict[str, object] = {"feature": feature, "epic": epic}
|
|
@@ -3334,6 +3866,7 @@ def stage_exit(
|
|
|
3334
3866
|
outcome: str | None = None,
|
|
3335
3867
|
owner: str | None = None,
|
|
3336
3868
|
verify_capability: str = "manual",
|
|
3869
|
+
cause: str | None = None,
|
|
3337
3870
|
) -> StageExitPayload:
|
|
3338
3871
|
"""Compute a deterministic stage-exit payload.
|
|
3339
3872
|
|
|
@@ -3354,6 +3887,10 @@ def stage_exit(
|
|
|
3354
3887
|
capability is permission, not tool presence: a dispatch permitted
|
|
3355
3888
|
only once the user has asked is still `interactive`, because the
|
|
3356
3889
|
`standard` gate's own prompt supplies that request.
|
|
3890
|
+
cause: Pending-attribution annotation (`dependency-starvation`), valid
|
|
3891
|
+
only with `--stage forge-5-loop --outcome partial` (REQ-ATTR-04).
|
|
3892
|
+
It swaps the partial next-steps sentence for the starvation variant
|
|
3893
|
+
and changes no routing.
|
|
3357
3894
|
|
|
3358
3895
|
Returns:
|
|
3359
3896
|
A JSON-serializable `StageExitPayload` dictionary.
|
|
@@ -3484,6 +4021,14 @@ def stage_exit(
|
|
|
3484
4021
|
f"{', '.join(sorted(allowed_outcomes))}"
|
|
3485
4022
|
)
|
|
3486
4023
|
|
|
4024
|
+
# --cause is a forge-5-loop/partial-only attribution annotation (REQ-ATTR-04).
|
|
4025
|
+
# argparse `choices` already restricts the value; this restricts the combination.
|
|
4026
|
+
if cause is not None and not (stage == "forge-5-loop" and outcome == "partial"):
|
|
4027
|
+
raise UsageError(
|
|
4028
|
+
"--cause dependency-starvation is valid only with "
|
|
4029
|
+
"--stage forge-5-loop --outcome partial"
|
|
4030
|
+
)
|
|
4031
|
+
|
|
3487
4032
|
# 5. Ownership: required for the branch skills, rejected for stages 0-6.
|
|
3488
4033
|
if stage in _BRANCH_STAGES:
|
|
3489
4034
|
if owner is None:
|
|
@@ -3687,12 +4232,33 @@ def stage_exit(
|
|
|
3687
4232
|
# hands off the same way the epic's own exit does. Identical to the previous
|
|
3688
4233
|
# behavior for every production exit, where `route_stage is stage`.
|
|
3689
4234
|
if route_stage == "forge-0-epic" and next_feature is None:
|
|
3690
|
-
#
|
|
3691
|
-
#
|
|
3692
|
-
#
|
|
3693
|
-
#
|
|
3694
|
-
|
|
3695
|
-
|
|
4235
|
+
# A branch exit (verify/fix) that served forge-0-epic cannot carry
|
|
4236
|
+
# --next-feature (the CLI rejects it for non-epic stages), so the member
|
|
4237
|
+
# must be resolved HERE from live epic state. Creation-mode exits pass
|
|
4238
|
+
# --next-feature and skip this block entirely (#230).
|
|
4239
|
+
try:
|
|
4240
|
+
status = _render_status(specs_dir, feature)
|
|
4241
|
+
actionable = status["actionable"]
|
|
4242
|
+
if actionable:
|
|
4243
|
+
resolved_member = actionable[0]
|
|
4244
|
+
ms, mr = _epic_member_state(specs_dir, feature, resolved_member)
|
|
4245
|
+
if mr is not None:
|
|
4246
|
+
next_stage_id = "forge-1-prd"
|
|
4247
|
+
next_command = f"/skill:forge-1-prd {resolved_member}"
|
|
4248
|
+
else:
|
|
4249
|
+
member_next = next_stage(ms)
|
|
4250
|
+
if member_next is None:
|
|
4251
|
+
next_stage_id = None
|
|
4252
|
+
next_command = f"/skill:forge-0-epic {feature}"
|
|
4253
|
+
else:
|
|
4254
|
+
next_stage_id = member_next
|
|
4255
|
+
next_command = f"/skill:{member_next} {resolved_member}"
|
|
4256
|
+
else:
|
|
4257
|
+
next_stage_id = None
|
|
4258
|
+
next_command = f"/skill:forge-0-epic {feature}"
|
|
4259
|
+
except UsageError:
|
|
4260
|
+
next_stage_id = None
|
|
4261
|
+
next_command = f"/skill:forge-0-epic {feature}"
|
|
3696
4262
|
else:
|
|
3697
4263
|
next_arg = next_feature or feature
|
|
3698
4264
|
next_command = (
|
|
@@ -3822,6 +4388,7 @@ def stage_exit(
|
|
|
3822
4388
|
resolved,
|
|
3823
4389
|
verify_canonical,
|
|
3824
4390
|
fix_canonical if live_findings_report else None,
|
|
4391
|
+
cause,
|
|
3825
4392
|
)
|
|
3826
4393
|
if blocking_reconcile:
|
|
3827
4394
|
# Same reconcile-first rule as every other advancing route — but the
|
|
@@ -4102,7 +4669,8 @@ def _write_state(state_path: Path, state: dict) -> None:
|
|
|
4102
4669
|
the temp file onto the target. os.replace is atomic on POSIX within one
|
|
4103
4670
|
filesystem, so an interrupted write never leaves a partial or corrupt state
|
|
4104
4671
|
file. Concurrent multi-session mutation is out of scope (single writer
|
|
4105
|
-
assumed, matching epic-manifest.py
|
|
4672
|
+
assumed, matching epic-manifest.py; decision record:
|
|
4673
|
+
references/decisions/single-writer-threat-model.md, issue #180).
|
|
4106
4674
|
|
|
4107
4675
|
Args:
|
|
4108
4676
|
state_path: Destination path, e.g.
|
|
@@ -4708,6 +5276,62 @@ def cmd_state_complete(
|
|
|
4708
5276
|
return echo
|
|
4709
5277
|
|
|
4710
5278
|
|
|
5279
|
+
def cmd_state_skip(feature: str, stage: str, specs_dir: Path, epic: str | None) -> dict:
|
|
5280
|
+
"""Record a deliberate skip of the documentation stage (#197).
|
|
5281
|
+
|
|
5282
|
+
Writes a REPLACEMENT ``stages.forge-6-docs`` entry ``{"status": "skipped",
|
|
5283
|
+
"skippedAt": …, "commitHash": null}`` — the honest terminal for a feature
|
|
5284
|
+
that ships without architecture docs. ``skipped`` counts as done for
|
|
5285
|
+
next-stage selection (``_DONE_STATUSES``), so the pipeline reads
|
|
5286
|
+
``complete: true`` / ``nextStage: null`` without any stage claiming
|
|
5287
|
+
artifacts it never produced.
|
|
5288
|
+
|
|
5289
|
+
Scoped to ``forge-6-docs`` on purpose: a skipped PRD or specs stage is a
|
|
5290
|
+
different and much worse proposition, so both the CLI (``choices``) and this
|
|
5291
|
+
callable refuse any other stage.
|
|
5292
|
+
|
|
5293
|
+
The skip must not DESTROY a record of docs that exist: a prior entry whose
|
|
5294
|
+
``artifacts`` list is non-empty is refused. A prior ``complete`` entry with
|
|
5295
|
+
no recorded artifacts is exactly the dishonest workaround this verb replaces
|
|
5296
|
+
(``state-complete`` with no ``--artifact``), so it may be corrected to
|
|
5297
|
+
``skipped`` — that is the sanctioned migration path for such state.
|
|
5298
|
+
|
|
5299
|
+
Args:
|
|
5300
|
+
feature: Feature name.
|
|
5301
|
+
stage: Must be ``"forge-6-docs"`` (kept explicit so the scoping shows up
|
|
5302
|
+
in every call site).
|
|
5303
|
+
specs_dir: Specs directory.
|
|
5304
|
+
epic: Owning epic name, or None.
|
|
5305
|
+
|
|
5306
|
+
Returns:
|
|
5307
|
+
The mutated state dict (for the --json echo).
|
|
5308
|
+
|
|
5309
|
+
Raises:
|
|
5310
|
+
UsageError: A stage other than forge-6-docs, a prior entry recording
|
|
5311
|
+
artifacts, an unknown feature directory, an unparseable state file,
|
|
5312
|
+
or a failed atomic write (→ exit 2).
|
|
5313
|
+
"""
|
|
5314
|
+
if stage != "forge-6-docs":
|
|
5315
|
+
raise UsageError(
|
|
5316
|
+
f"state-skip is scoped to forge-6-docs; a skipped {stage} is not a "
|
|
5317
|
+
"representable pipeline state"
|
|
5318
|
+
)
|
|
5319
|
+
state_path, state = _load_state_for_write(specs_dir, feature, epic)
|
|
5320
|
+
prior = state.get("stages", {}).get(stage)
|
|
5321
|
+
if isinstance(prior, dict) and prior.get("artifacts"):
|
|
5322
|
+
raise UsageError(
|
|
5323
|
+
f"{stage} already records {len(prior['artifacts'])} artifact(s) "
|
|
5324
|
+
f"(status: {prior.get('status')!r}); skipping now would erase the "
|
|
5325
|
+
"record that docs exist. Re-run forge-6-docs to refresh them instead."
|
|
5326
|
+
)
|
|
5327
|
+
state.setdefault("stages", {})[stage] = {
|
|
5328
|
+
"status": "skipped",
|
|
5329
|
+
"skippedAt": _now_iso(),
|
|
5330
|
+
"commitHash": None,
|
|
5331
|
+
}
|
|
5332
|
+
return _commit_state(state_path, state)
|
|
5333
|
+
|
|
5334
|
+
|
|
4711
5335
|
def cmd_state_branch(feature: str, branch: str, specs_dir: Path, epic: str | None) -> dict:
|
|
4712
5336
|
"""Set the top-level ``branch`` field.
|
|
4713
5337
|
|
|
@@ -4966,10 +5590,12 @@ def _validated_findings_file(
|
|
|
4966
5590
|
def _current_artifact_version(state: dict, stage: str) -> int:
|
|
4967
5591
|
"""Return the artifact revision a verify result is being recorded against.
|
|
4968
5592
|
|
|
4969
|
-
For a feature target that is the selected production stage's ``version``.
|
|
4970
|
-
|
|
5593
|
+
For a feature target that is the selected production stage's ``version``.
|
|
5594
|
+
Only the statuses that consume it resolve it: `passed` and
|
|
4971
5595
|
`findings-reported` write it into the freshness ledger, and
|
|
4972
5596
|
`auto-verify-pending` writes it as the revision the debt is owed on.
|
|
5597
|
+
`skipped` and `findings-applied` never read it and skip the lookup, so both
|
|
5598
|
+
stay recordable on a completed stage with no recorded ``version``.
|
|
4973
5599
|
|
|
4974
5600
|
Args:
|
|
4975
5601
|
state: The loaded state document.
|
|
@@ -5074,7 +5700,8 @@ def _verify_result_entry(
|
|
|
5074
5700
|
Args:
|
|
5075
5701
|
status: The validated result status.
|
|
5076
5702
|
prior: The existing entry (``{}`` when absent).
|
|
5077
|
-
current: The current artifact revision, or None for ``skipped
|
|
5703
|
+
current: The current artifact revision, or None for ``skipped`` and
|
|
5704
|
+
``findings-applied`` (which never consume it).
|
|
5078
5705
|
findings_file: Validated relative report path, when supplied.
|
|
5079
5706
|
findings_count: Validated non-negative count, when supplied.
|
|
5080
5707
|
now: The shared ISO-8601 timestamp for this write.
|
|
@@ -5343,7 +5970,12 @@ def cmd_state_verify(
|
|
|
5343
5970
|
if findings_file is not None:
|
|
5344
5971
|
_validated_findings_file(findings_file, target_dir)
|
|
5345
5972
|
|
|
5346
|
-
if status
|
|
5973
|
+
if status in ("skipped", "findings-applied"):
|
|
5974
|
+
# Neither status consumes the artifact revision: `skipped` records no
|
|
5975
|
+
# freshness, and `findings-applied` deliberately clears it (the entry is
|
|
5976
|
+
# built from the prior report plus `fixedAt`). Resolving it anyway would
|
|
5977
|
+
# make both unrecordable on a completed stage whose `version` was never
|
|
5978
|
+
# written — exactly the state that needs the recovery path (#202).
|
|
5347
5979
|
current = None
|
|
5348
5980
|
elif is_epic_target:
|
|
5349
5981
|
# The epic's artifact revision is the manifest revision — never a member's
|
|
@@ -5363,6 +5995,20 @@ def cmd_state_verify(
|
|
|
5363
5995
|
)
|
|
5364
5996
|
|
|
5365
5997
|
prior = _verify_entry(state, verify_key)
|
|
5998
|
+
if status == "skipped" and prior.get("status") in _SKIP_PROTECTED_PRIOR:
|
|
5999
|
+
# The #203 demotion trap: `skipped` over a complete-for-orchestration
|
|
6000
|
+
# status silently dropped the member from its epic rollup and re-blocked
|
|
6001
|
+
# every dependent. Fail closed; a deferral needs no write at all.
|
|
6002
|
+
raise UsageError(
|
|
6003
|
+
f"--status skipped would demote {verify_key} from "
|
|
6004
|
+
f"{prior.get('status')!r}: that status counts as resolved (and, for an "
|
|
6005
|
+
f"epic member, complete-for-orchestration), so replacing it with "
|
|
6006
|
+
f"skipped would drop the member from its epic rollup and re-block its "
|
|
6007
|
+
f"dependents. A deferral needs no write — the recorded result already "
|
|
6008
|
+
f"stands. Re-run verification to refresh it, or record --status passed "
|
|
6009
|
+
f"(with the report attached) to accept residual findings. "
|
|
6010
|
+
f"Nothing was written."
|
|
6011
|
+
)
|
|
5366
6012
|
if status == "auto-verify-pending" and prior.get("status") == "findings-reported":
|
|
5367
6013
|
# `_verify_result_entry` REPLACES the entry, so scheduling over a report
|
|
5368
6014
|
# for the current revision would delete its `findingsFile`/`findingsCount`
|
|
@@ -5453,6 +6099,14 @@ def _print_state_complete(
|
|
|
5453
6099
|
)
|
|
5454
6100
|
|
|
5455
6101
|
|
|
6102
|
+
def _print_state_skip(state: dict, stage: str) -> None:
|
|
6103
|
+
"""Print the one-line human summary for `state-skip`."""
|
|
6104
|
+
print(
|
|
6105
|
+
f"recorded {stage} as skipped for {state['feature']} "
|
|
6106
|
+
"(deliberate — no docs claimed)"
|
|
6107
|
+
)
|
|
6108
|
+
|
|
6109
|
+
|
|
5456
6110
|
def _print_state_branch(state: dict) -> None:
|
|
5457
6111
|
"""Print the one-line human summary for `state-branch`."""
|
|
5458
6112
|
print(f"recorded branch for {state['feature']}: {state['branch']}")
|
|
@@ -5506,6 +6160,338 @@ def _print_state_ecr(state: dict) -> None:
|
|
|
5506
6160
|
)
|
|
5507
6161
|
|
|
5508
6162
|
|
|
6163
|
+
# --------------------------------------------------------------------------- #
|
|
6164
|
+
# Decision record (forge-decisions.json) — the decision-* verbs
|
|
6165
|
+
# --------------------------------------------------------------------------- #
|
|
6166
|
+
|
|
6167
|
+
#: The one persistent artifact this feature adds; only decision-* verbs write it.
|
|
6168
|
+
DECISIONS_FILENAME: Final[str] = "forge-decisions.json"
|
|
6169
|
+
#: Enum-locked at references/forge-decisions-schema.json; a bump is a breaking change.
|
|
6170
|
+
DECISIONS_SCHEMA_VERSION: Final[str] = "1"
|
|
6171
|
+
|
|
6172
|
+
|
|
6173
|
+
def _resolve_decisions_path(
|
|
6174
|
+
backlog_dir: Path,
|
|
6175
|
+
state_dir: str | None,
|
|
6176
|
+
config_path: Path,
|
|
6177
|
+
schema_path: Path,
|
|
6178
|
+
) -> Path:
|
|
6179
|
+
"""Resolve `{backlog_dir}/{stateDir}/forge-decisions.json`.
|
|
6180
|
+
|
|
6181
|
+
When ``state_dir`` is None, ``stateDir`` is taken from the effective loopRunner
|
|
6182
|
+
config (schema default ``.rauf``) via ``resolve_loop_runner`` — the same resolver
|
|
6183
|
+
the loop itself uses — so the record lands beside the runner's own state and is
|
|
6184
|
+
covered by the ``**/.rauf/*`` ignore rule with zero ``.gitignore`` edits.
|
|
6185
|
+
|
|
6186
|
+
Args:
|
|
6187
|
+
backlog_dir: The resolved backlog directory (e.g. ``specs/loop-recovery``).
|
|
6188
|
+
state_dir: An explicit state-dir name, or None to resolve from config.
|
|
6189
|
+
config_path: ``forge.config.json`` path (``_load_config`` tolerates absent).
|
|
6190
|
+
schema_path: ``forge-config-schema.json`` path (source of the default).
|
|
6191
|
+
|
|
6192
|
+
Returns:
|
|
6193
|
+
The resolved path to the decision record (its parent may not yet exist).
|
|
6194
|
+
"""
|
|
6195
|
+
if state_dir is None:
|
|
6196
|
+
resolved = resolve_loop_runner(config_path, schema_path)
|
|
6197
|
+
state_dir = str(resolved["stateDir"])
|
|
6198
|
+
return backlog_dir / state_dir / DECISIONS_FILENAME
|
|
6199
|
+
|
|
6200
|
+
|
|
6201
|
+
def _read_decisions_for_write(path: Path, feature: str) -> dict:
|
|
6202
|
+
"""Load the decisions document for mutation, or seed a fresh one on first write.
|
|
6203
|
+
|
|
6204
|
+
A MISSING file is the first-write case → return a fresh skeleton whose parent
|
|
6205
|
+
dir is created on commit. An UNPARSEABLE or non-object existing file is a HARD
|
|
6206
|
+
failure (exit 2) — a write path must not inherit ``_read_state``'s corrupt→{}
|
|
6207
|
+
tolerance, which would atomically replace a recoverable record with a
|
|
6208
|
+
near-empty one.
|
|
6209
|
+
|
|
6210
|
+
Args:
|
|
6211
|
+
path: The resolved decision-record path.
|
|
6212
|
+
feature: The feature label to stamp on a first write (backlog dir basename).
|
|
6213
|
+
|
|
6214
|
+
Returns:
|
|
6215
|
+
The loaded (or freshly-seeded) decisions document, ready to mutate.
|
|
6216
|
+
|
|
6217
|
+
Raises:
|
|
6218
|
+
UsageError: The existing file is unreadable/unparseable or not a JSON object.
|
|
6219
|
+
"""
|
|
6220
|
+
if not path.exists():
|
|
6221
|
+
return {
|
|
6222
|
+
"schemaVersion": DECISIONS_SCHEMA_VERSION,
|
|
6223
|
+
"feature": feature,
|
|
6224
|
+
"createdAt": _now_iso(),
|
|
6225
|
+
"decisions": [],
|
|
6226
|
+
}
|
|
6227
|
+
try:
|
|
6228
|
+
parsed = json.loads(path.read_text(encoding="utf-8"))
|
|
6229
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
6230
|
+
raise UsageError(f"unparseable decision record at {path}: {exc}") from exc
|
|
6231
|
+
if not isinstance(parsed, dict):
|
|
6232
|
+
raise UsageError(f"decision record at {path} is not a JSON object")
|
|
6233
|
+
return parsed
|
|
6234
|
+
|
|
6235
|
+
|
|
6236
|
+
def _new_decision_entry(
|
|
6237
|
+
item_id: str,
|
|
6238
|
+
question: str,
|
|
6239
|
+
answer: str | None,
|
|
6240
|
+
deferred: bool,
|
|
6241
|
+
cluster_id: str | None,
|
|
6242
|
+
actor: str,
|
|
6243
|
+
) -> dict:
|
|
6244
|
+
"""Build one decision entry conforming to references/forge-decisions-schema.json.
|
|
6245
|
+
|
|
6246
|
+
Args:
|
|
6247
|
+
item_id: The backlog item the decision answers.
|
|
6248
|
+
question: The needs-human question text (original text on a deferral).
|
|
6249
|
+
answer: The operator's answer, or None for a deferral.
|
|
6250
|
+
deferred: True iff this is a deferral / cancel-early entry.
|
|
6251
|
+
cluster_id: Shared clusterId for a consolidated decision, or None.
|
|
6252
|
+
actor: The session/actor label for ``recordedBy`` (never user identity).
|
|
6253
|
+
|
|
6254
|
+
Returns:
|
|
6255
|
+
A dict carrying all eight required fields (``appliedAt``/``appliedBy`` null),
|
|
6256
|
+
plus ``clusterId`` when supplied.
|
|
6257
|
+
"""
|
|
6258
|
+
entry: dict = {
|
|
6259
|
+
"itemId": item_id,
|
|
6260
|
+
"question": question,
|
|
6261
|
+
"answer": answer,
|
|
6262
|
+
"deferred": deferred,
|
|
6263
|
+
"decidedAt": _now_iso(),
|
|
6264
|
+
"recordedBy": actor,
|
|
6265
|
+
"appliedAt": None,
|
|
6266
|
+
"appliedBy": None,
|
|
6267
|
+
}
|
|
6268
|
+
if cluster_id is not None:
|
|
6269
|
+
entry["clusterId"] = cluster_id
|
|
6270
|
+
return entry
|
|
6271
|
+
|
|
6272
|
+
|
|
6273
|
+
def _default_actor() -> str:
|
|
6274
|
+
"""Return the default recordedBy/appliedBy label: ``forge-5-loop@<host>``.
|
|
6275
|
+
|
|
6276
|
+
The host segment is a machine label, not a user identity (REQ-SEC-01).
|
|
6277
|
+
"""
|
|
6278
|
+
return f"forge-5-loop@{socket.gethostname()}"
|
|
6279
|
+
|
|
6280
|
+
|
|
6281
|
+
def _unapplied_decisions(decisions: list[dict]) -> list[dict]:
|
|
6282
|
+
"""Return the latest entry per itemId whose ``appliedAt`` is None (REQ-DEC-05).
|
|
6283
|
+
|
|
6284
|
+
Walks entries in stored (append) order keeping the LAST entry seen per itemId,
|
|
6285
|
+
then keeps only those still unapplied. Deferrals (never applied) are included
|
|
6286
|
+
(REQ-DEC-06); an item whose latest entry is applied drops out; a later
|
|
6287
|
+
per-item entry supersedes an earlier consolidated (clusterId) one for that item
|
|
6288
|
+
only (REQ-DEC-07). Output is sorted by itemId for deterministic reporting.
|
|
6289
|
+
|
|
6290
|
+
Args:
|
|
6291
|
+
decisions: The document's ``decisions`` array, in stored order.
|
|
6292
|
+
|
|
6293
|
+
Returns:
|
|
6294
|
+
The unapplied entries, one per item, sorted by ``itemId``.
|
|
6295
|
+
"""
|
|
6296
|
+
latest: dict[str, dict] = {}
|
|
6297
|
+
for entry in decisions:
|
|
6298
|
+
latest[entry["itemId"]] = entry
|
|
6299
|
+
return [
|
|
6300
|
+
entry for _item_id, entry in sorted(latest.items())
|
|
6301
|
+
if entry.get("appliedAt") is None
|
|
6302
|
+
]
|
|
6303
|
+
|
|
6304
|
+
|
|
6305
|
+
def cmd_decision_record(
|
|
6306
|
+
backlog_dir: Path,
|
|
6307
|
+
item_ids: list[str],
|
|
6308
|
+
question: str,
|
|
6309
|
+
answer: str | None,
|
|
6310
|
+
deferred: bool,
|
|
6311
|
+
cluster_id: str | None,
|
|
6312
|
+
actor: str,
|
|
6313
|
+
state_dir: str | None,
|
|
6314
|
+
config_path: Path,
|
|
6315
|
+
schema_path: Path,
|
|
6316
|
+
) -> dict:
|
|
6317
|
+
"""Append one needs-human decision entry per ``--item`` (append-only).
|
|
6318
|
+
|
|
6319
|
+
Records a decision at the moment it is collected (REQ-DEC-01), on EVERY branch:
|
|
6320
|
+
an answered decision (``--answer``), and a deferral or cancel-early
|
|
6321
|
+
(``--deferred`` → ``answer: null``, REQ-DEC-06). With ``--cluster`` the per-item
|
|
6322
|
+
entries of ONE consolidated decision share a ``clusterId`` (REQ-CLU-04) yet stay
|
|
6323
|
+
independently re-decidable (REQ-DEC-07). The file and its
|
|
6324
|
+
``schemaVersion``/``feature``/``createdAt`` stamp are created on first write.
|
|
6325
|
+
Existing entries are never mutated (append-only).
|
|
6326
|
+
|
|
6327
|
+
Args:
|
|
6328
|
+
backlog_dir: The resolved backlog directory; its basename stamps ``feature``.
|
|
6329
|
+
item_ids: One or more backlog item ids; one entry is appended per id.
|
|
6330
|
+
question: The needs-human question text (original text on a deferral).
|
|
6331
|
+
answer: The operator's answer, or None for a deferral.
|
|
6332
|
+
deferred: True iff this is a deferral / cancel-early entry.
|
|
6333
|
+
cluster_id: Shared ``clusterId`` for a consolidated decision, or None.
|
|
6334
|
+
actor: Session/actor label for ``recordedBy`` (never user identity).
|
|
6335
|
+
state_dir: State-dir name override, or None to resolve from config.
|
|
6336
|
+
config_path: ``forge.config.json`` path (for the stateDir default).
|
|
6337
|
+
schema_path: ``forge-config-schema.json`` path (source of the default).
|
|
6338
|
+
|
|
6339
|
+
Returns:
|
|
6340
|
+
The mutated decisions document (for the ``--json`` echo).
|
|
6341
|
+
|
|
6342
|
+
Raises:
|
|
6343
|
+
UsageError: Missing backlog dir; both/neither of ``--answer``/``--deferred``;
|
|
6344
|
+
an unparseable existing record; or a failed atomic write (→ exit 2).
|
|
6345
|
+
"""
|
|
6346
|
+
# Defense in depth: the argparse mutually-exclusive group rejects both/neither
|
|
6347
|
+
# first, but a direct call must fail the same way. Valid states are exactly
|
|
6348
|
+
# (answered, not deferred) or (deferred, no answer).
|
|
6349
|
+
if deferred == (answer is not None):
|
|
6350
|
+
raise UsageError("exactly one of --answer or --deferred is required")
|
|
6351
|
+
if not backlog_dir.is_dir():
|
|
6352
|
+
raise UsageError(f"no backlog directory at {backlog_dir}")
|
|
6353
|
+
|
|
6354
|
+
path = _resolve_decisions_path(backlog_dir, state_dir, config_path, schema_path)
|
|
6355
|
+
doc = _read_decisions_for_write(path, backlog_dir.resolve().name)
|
|
6356
|
+
for item_id in item_ids:
|
|
6357
|
+
doc["decisions"].append(
|
|
6358
|
+
_new_decision_entry(item_id, question, answer, deferred, cluster_id, actor)
|
|
6359
|
+
)
|
|
6360
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
6361
|
+
return _commit_state(path, doc)
|
|
6362
|
+
|
|
6363
|
+
|
|
6364
|
+
def cmd_decision_list(
|
|
6365
|
+
backlog_dir: Path,
|
|
6366
|
+
unapplied: bool,
|
|
6367
|
+
state_dir: str | None,
|
|
6368
|
+
config_path: Path,
|
|
6369
|
+
schema_path: Path,
|
|
6370
|
+
) -> dict:
|
|
6371
|
+
"""Read the decision record back — the full log, or the unapplied set.
|
|
6372
|
+
|
|
6373
|
+
With ``--unapplied`` returns the REQ-DEC-05 set (``_unapplied_decisions``).
|
|
6374
|
+
Without it, echoes the full on-disk document. A missing record returns an
|
|
6375
|
+
empty result at exit 0 (nothing recorded yet is not a failure). This verb
|
|
6376
|
+
never mutates the file; it parses an existing record **strictly** (exit 2 on
|
|
6377
|
+
corruption) for both the plain and ``--unapplied`` forms — it never
|
|
6378
|
+
downgrades a corrupt record to ``{}``.
|
|
6379
|
+
|
|
6380
|
+
Args:
|
|
6381
|
+
backlog_dir: The resolved backlog directory.
|
|
6382
|
+
unapplied: Return only the latest-unapplied-per-item set.
|
|
6383
|
+
state_dir: State-dir name override, or None to resolve from config.
|
|
6384
|
+
config_path: ``forge.config.json`` path (for the stateDir default).
|
|
6385
|
+
schema_path: ``forge-config-schema.json`` path (source of the default).
|
|
6386
|
+
|
|
6387
|
+
Returns:
|
|
6388
|
+
On a plain read: the full document ``{schemaVersion, feature, createdAt,
|
|
6389
|
+
updatedAt, decisions}`` (or ``{"decisions": []}`` when none recorded).
|
|
6390
|
+
On ``--unapplied``: a report view ``{"feature", "unapplied": [...],
|
|
6391
|
+
"count": N}`` (NOT the on-disk shape; it is never written).
|
|
6392
|
+
|
|
6393
|
+
Raises:
|
|
6394
|
+
UsageError: Missing backlog dir, or an unparseable existing record (→ exit 2).
|
|
6395
|
+
"""
|
|
6396
|
+
if not backlog_dir.is_dir():
|
|
6397
|
+
raise UsageError(f"no backlog directory at {backlog_dir}")
|
|
6398
|
+
path = _resolve_decisions_path(backlog_dir, state_dir, config_path, schema_path)
|
|
6399
|
+
|
|
6400
|
+
if not path.exists():
|
|
6401
|
+
return {"feature": backlog_dir.resolve().name, "unapplied": [], "count": 0} \
|
|
6402
|
+
if unapplied else {"decisions": []}
|
|
6403
|
+
|
|
6404
|
+
try:
|
|
6405
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
6406
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
6407
|
+
raise UsageError(f"unparseable decision record at {path}: {exc}") from exc
|
|
6408
|
+
|
|
6409
|
+
if not unapplied:
|
|
6410
|
+
return doc
|
|
6411
|
+
pending = _unapplied_decisions(doc.get("decisions", []))
|
|
6412
|
+
return {"feature": doc.get("feature"), "unapplied": pending, "count": len(pending)}
|
|
6413
|
+
|
|
6414
|
+
|
|
6415
|
+
def cmd_decision_apply(
|
|
6416
|
+
backlog_dir: Path,
|
|
6417
|
+
item_id: str,
|
|
6418
|
+
actor: str,
|
|
6419
|
+
state_dir: str | None,
|
|
6420
|
+
config_path: Path,
|
|
6421
|
+
schema_path: Path,
|
|
6422
|
+
) -> dict:
|
|
6423
|
+
"""Stamp ``appliedAt``/``appliedBy`` on the LATEST entry for ``item_id``.
|
|
6424
|
+
|
|
6425
|
+
Append-only mutation (REQ-DEC-07): only the most recent entry for the item is
|
|
6426
|
+
touched, and only its ``appliedAt`` (→ ``_now_iso()``) and ``appliedBy``
|
|
6427
|
+
(→ ``actor``) fields. Called by the Post-Run Recovery Procedure only AFTER
|
|
6428
|
+
the runner apply for the item succeeded, so the record's applied state
|
|
6429
|
+
tracks the runner's (REQ-UNB-01).
|
|
6430
|
+
|
|
6431
|
+
Args:
|
|
6432
|
+
backlog_dir: The resolved backlog directory.
|
|
6433
|
+
item_id: The backlog item whose latest decision to stamp applied.
|
|
6434
|
+
actor: The session/actor label for ``appliedBy``.
|
|
6435
|
+
state_dir: State-dir name override, or None to resolve from config.
|
|
6436
|
+
config_path: ``forge.config.json`` path (for the stateDir default).
|
|
6437
|
+
schema_path: ``forge-config-schema.json`` path (source of the default).
|
|
6438
|
+
|
|
6439
|
+
Returns:
|
|
6440
|
+
The mutated decisions document (for the ``--json`` echo).
|
|
6441
|
+
|
|
6442
|
+
Raises:
|
|
6443
|
+
UsageError: Missing backlog dir; no decision recorded for the item; the
|
|
6444
|
+
item's latest entry is already applied (nothing unapplied); an
|
|
6445
|
+
unparseable record; or a failed atomic write (→ exit 2).
|
|
6446
|
+
"""
|
|
6447
|
+
if not backlog_dir.is_dir():
|
|
6448
|
+
raise UsageError(f"no backlog directory at {backlog_dir}")
|
|
6449
|
+
path = _resolve_decisions_path(backlog_dir, state_dir, config_path, schema_path)
|
|
6450
|
+
doc = _read_decisions_for_write(path, backlog_dir.resolve().name)
|
|
6451
|
+
|
|
6452
|
+
latest_index: int | None = None
|
|
6453
|
+
for index, entry in enumerate(doc["decisions"]):
|
|
6454
|
+
if entry["itemId"] == item_id:
|
|
6455
|
+
latest_index = index # keep the LAST match — stored order is chronological
|
|
6456
|
+
if latest_index is None:
|
|
6457
|
+
raise UsageError(f"no decision recorded for item {item_id!r}")
|
|
6458
|
+
entry = doc["decisions"][latest_index]
|
|
6459
|
+
if entry["appliedAt"] is not None:
|
|
6460
|
+
raise UsageError(
|
|
6461
|
+
f"latest decision for item {item_id!r} is already applied "
|
|
6462
|
+
f"(at {entry['appliedAt']}) — nothing unapplied"
|
|
6463
|
+
)
|
|
6464
|
+
|
|
6465
|
+
entry["appliedAt"] = _now_iso()
|
|
6466
|
+
entry["appliedBy"] = actor
|
|
6467
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
6468
|
+
return _commit_state(path, doc)
|
|
6469
|
+
|
|
6470
|
+
|
|
6471
|
+
def _print_decision_record(doc: dict) -> None:
|
|
6472
|
+
"""One-line human summary for ``decision-record``."""
|
|
6473
|
+
print(f"decision recorded — {len(doc['decisions'])} entr"
|
|
6474
|
+
f"{'y' if len(doc['decisions']) == 1 else 'ies'} on record for {doc['feature']}")
|
|
6475
|
+
|
|
6476
|
+
|
|
6477
|
+
def _print_decision_list(view: dict) -> None:
|
|
6478
|
+
"""One-line-per-entry human summary for ``decision-list``."""
|
|
6479
|
+
if "unapplied" in view:
|
|
6480
|
+
print(f"{view['count']} unapplied decision(s)")
|
|
6481
|
+
for entry in view["unapplied"]:
|
|
6482
|
+
kind = "deferred" if entry["deferred"] else "answered"
|
|
6483
|
+
print(f" {entry['itemId']}: {kind} — {entry['question']}")
|
|
6484
|
+
else:
|
|
6485
|
+
print(f"{len(view.get('decisions', []))} decision(s) on record")
|
|
6486
|
+
|
|
6487
|
+
|
|
6488
|
+
def _print_decision_apply(doc: dict) -> None:
|
|
6489
|
+
"""One-line human summary naming the just-applied entry (max appliedAt)."""
|
|
6490
|
+
applied = [d for d in doc["decisions"] if d["appliedAt"] is not None]
|
|
6491
|
+
entry = max(applied, key=lambda d: d["appliedAt"])
|
|
6492
|
+
print(f"applied decision for item {entry['itemId']} ({entry['appliedBy']})")
|
|
6493
|
+
|
|
6494
|
+
|
|
5509
6495
|
# --------------------------------------------------------------------------- #
|
|
5510
6496
|
# CLI dispatch
|
|
5511
6497
|
# --------------------------------------------------------------------------- #
|
|
@@ -5652,6 +6638,11 @@ def main() -> int:
|
|
|
5652
6638
|
# argparse cannot express. `stage_exit` validates it against EXIT_OUTCOMES.
|
|
5653
6639
|
p_exit.add_argument("--outcome", default=None,
|
|
5654
6640
|
help="Stage-specific outcome (loop/docs/verify/fix only)")
|
|
6641
|
+
p_exit.add_argument(
|
|
6642
|
+
"--cause", default=None, dest="cause", choices=("dependency-starvation",),
|
|
6643
|
+
help="Pending-attribution cause; valid only with "
|
|
6644
|
+
"--stage forge-5-loop --outcome partial",
|
|
6645
|
+
)
|
|
5655
6646
|
p_exit.add_argument("--owner", default=None, choices=get_args(ExitOwner),
|
|
5656
6647
|
help="Branch terminal ownership (forge-verify/forge-fix only)")
|
|
5657
6648
|
p_exit.add_argument("--verify-capability", default="manual",
|
|
@@ -5736,6 +6727,16 @@ def main() -> int:
|
|
|
5736
6727
|
p_comp.add_argument("--epic", default=None, help="Epic name for a nested member")
|
|
5737
6728
|
p_comp.add_argument("--json", action="store_true", dest="json_output")
|
|
5738
6729
|
|
|
6730
|
+
p_skip = sub.add_parser(
|
|
6731
|
+
"state-skip", help="Record forge-6-docs as deliberately skipped (#197)"
|
|
6732
|
+
)
|
|
6733
|
+
p_skip.add_argument("--feature", required=True, help="Feature name")
|
|
6734
|
+
p_skip.add_argument("--stage", required=True, choices=("forge-6-docs",),
|
|
6735
|
+
help="The stage being skipped (only forge-6-docs is skippable)")
|
|
6736
|
+
p_skip.add_argument("--specs-dir", default="./specs", help="Specs directory")
|
|
6737
|
+
p_skip.add_argument("--epic", default=None, help="Epic name for a nested member")
|
|
6738
|
+
p_skip.add_argument("--json", action="store_true", dest="json_output")
|
|
6739
|
+
|
|
5739
6740
|
p_br = sub.add_parser("state-branch", help="Set the top-level branch field")
|
|
5740
6741
|
p_br.add_argument("--feature", required=True, help="Feature name")
|
|
5741
6742
|
p_br.add_argument("--branch", required=True, help="Branch name to record")
|
|
@@ -5815,6 +6816,74 @@ def main() -> int:
|
|
|
5815
6816
|
p_ver.add_argument("--epic", default=None, help="Epic name for a nested member")
|
|
5816
6817
|
p_ver.add_argument("--json", action="store_true", dest="json_output")
|
|
5817
6818
|
|
|
6819
|
+
p_drec = sub.add_parser(
|
|
6820
|
+
"decision-record", help="Append a needs-human decision entry (append-only)"
|
|
6821
|
+
)
|
|
6822
|
+
p_drec.add_argument("--backlog-dir", required=True, dest="backlog_dir",
|
|
6823
|
+
help="Resolved backlog directory (e.g. specs/loop-recovery)")
|
|
6824
|
+
p_drec.add_argument("--item", required=True, action="append", dest="item_ids",
|
|
6825
|
+
metavar="ID", help="Backlog item id (repeatable — one entry per id)")
|
|
6826
|
+
p_drec.add_argument("--question", required=True, help="The needs-human question text")
|
|
6827
|
+
_ans = p_drec.add_mutually_exclusive_group(required=True)
|
|
6828
|
+
_ans.add_argument("--answer", default=None, help="The operator's answer")
|
|
6829
|
+
_ans.add_argument("--deferred", action="store_true",
|
|
6830
|
+
help="Record a deferral / cancel-early (answer: null)")
|
|
6831
|
+
p_drec.add_argument("--cluster", default=None, dest="cluster_id", metavar="CID",
|
|
6832
|
+
help="Shared clusterId for one consolidated decision (REQ-CLU-04)")
|
|
6833
|
+
p_drec.add_argument("--actor", default=None,
|
|
6834
|
+
help="Session/actor label for recordedBy (default forge-5-loop@<host>)")
|
|
6835
|
+
p_drec.add_argument("--state-dir", default=None, dest="state_dir",
|
|
6836
|
+
help="State-dir name (default: effective loopRunner.stateDir)")
|
|
6837
|
+
p_drec.add_argument("--config", default="./forge.config.json",
|
|
6838
|
+
help="forge.config.json path")
|
|
6839
|
+
p_drec.add_argument("--json", action="store_true", dest="json_output")
|
|
6840
|
+
|
|
6841
|
+
p_dlist = sub.add_parser(
|
|
6842
|
+
"decision-list", help="Read the decision record (or the unapplied set)"
|
|
6843
|
+
)
|
|
6844
|
+
p_dlist.add_argument("--backlog-dir", required=True, dest="backlog_dir",
|
|
6845
|
+
help="Resolved backlog directory")
|
|
6846
|
+
p_dlist.add_argument("--unapplied", action="store_true",
|
|
6847
|
+
help="Return only the latest-unapplied-per-item set (REQ-DEC-05)")
|
|
6848
|
+
p_dlist.add_argument("--state-dir", default=None, dest="state_dir",
|
|
6849
|
+
help="State-dir name (default: effective loopRunner.stateDir)")
|
|
6850
|
+
p_dlist.add_argument("--config", default="./forge.config.json",
|
|
6851
|
+
help="forge.config.json path")
|
|
6852
|
+
p_dlist.add_argument("--json", action="store_true", dest="json_output")
|
|
6853
|
+
|
|
6854
|
+
p_dapply = sub.add_parser(
|
|
6855
|
+
"decision-apply", help="Mark the latest decision for an item applied"
|
|
6856
|
+
)
|
|
6857
|
+
p_dapply.add_argument("--backlog-dir", required=True, dest="backlog_dir",
|
|
6858
|
+
help="Resolved backlog directory")
|
|
6859
|
+
p_dapply.add_argument("--item", required=True, dest="item_id", metavar="ID",
|
|
6860
|
+
help="Backlog item whose latest decision to stamp applied")
|
|
6861
|
+
p_dapply.add_argument("--actor", default=None,
|
|
6862
|
+
help="Session/actor label for appliedBy (default forge-5-loop@<host>)")
|
|
6863
|
+
p_dapply.add_argument("--state-dir", default=None, dest="state_dir",
|
|
6864
|
+
help="State-dir name (default: effective loopRunner.stateDir)")
|
|
6865
|
+
p_dapply.add_argument("--config", default="./forge.config.json",
|
|
6866
|
+
help="forge.config.json path")
|
|
6867
|
+
p_dapply.add_argument("--json", action="store_true", dest="json_output")
|
|
6868
|
+
|
|
6869
|
+
p_topo = sub.add_parser(
|
|
6870
|
+
"backlog-topology",
|
|
6871
|
+
help="Dependency-topology metrics + advisory warnings over a runner item array",
|
|
6872
|
+
)
|
|
6873
|
+
topo_src = p_topo.add_mutually_exclusive_group(required=True)
|
|
6874
|
+
topo_src.add_argument(
|
|
6875
|
+
"--items-json", help="Path to the loopRunner listCommand JSON output"
|
|
6876
|
+
)
|
|
6877
|
+
topo_src.add_argument(
|
|
6878
|
+
"--items-stdin", action="store_true",
|
|
6879
|
+
help="Read the listCommand JSON from stdin",
|
|
6880
|
+
)
|
|
6881
|
+
p_topo.add_argument(
|
|
6882
|
+
"--cluster", action="store_true", dest="with_clusters",
|
|
6883
|
+
help="Append blocked-item clusters for consolidated prompts",
|
|
6884
|
+
)
|
|
6885
|
+
p_topo.add_argument("--json", action="store_true", dest="json_output")
|
|
6886
|
+
|
|
5818
6887
|
args = parser.parse_args()
|
|
5819
6888
|
|
|
5820
6889
|
try:
|
|
@@ -5903,6 +6972,7 @@ def main() -> int:
|
|
|
5903
6972
|
args.outcome,
|
|
5904
6973
|
args.owner,
|
|
5905
6974
|
args.verify_capability,
|
|
6975
|
+
args.cause,
|
|
5906
6976
|
)
|
|
5907
6977
|
if args.json_output:
|
|
5908
6978
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
@@ -5960,6 +7030,17 @@ def main() -> int:
|
|
|
5960
7030
|
)
|
|
5961
7031
|
return 0
|
|
5962
7032
|
|
|
7033
|
+
if args.cmd == "state-skip":
|
|
7034
|
+
payload = cmd_state_skip(
|
|
7035
|
+
args.feature, args.stage, Path(args.specs_dir), args.epic
|
|
7036
|
+
)
|
|
7037
|
+
_emit(
|
|
7038
|
+
payload,
|
|
7039
|
+
args.json_output,
|
|
7040
|
+
lambda state: _print_state_skip(state, args.stage),
|
|
7041
|
+
)
|
|
7042
|
+
return 0
|
|
7043
|
+
|
|
5963
7044
|
if args.cmd == "state-branch":
|
|
5964
7045
|
payload = cmd_state_branch(
|
|
5965
7046
|
args.feature, args.branch, Path(args.specs_dir), args.epic
|
|
@@ -6020,6 +7101,44 @@ def main() -> int:
|
|
|
6020
7101
|
)
|
|
6021
7102
|
return 0
|
|
6022
7103
|
|
|
7104
|
+
if args.cmd == "decision-record":
|
|
7105
|
+
payload = cmd_decision_record(
|
|
7106
|
+
Path(args.backlog_dir),
|
|
7107
|
+
args.item_ids,
|
|
7108
|
+
args.question,
|
|
7109
|
+
args.answer,
|
|
7110
|
+
args.deferred,
|
|
7111
|
+
args.cluster_id,
|
|
7112
|
+
args.actor or _default_actor(),
|
|
7113
|
+
args.state_dir,
|
|
7114
|
+
Path(args.config),
|
|
7115
|
+
_default_schema_path(),
|
|
7116
|
+
)
|
|
7117
|
+
_emit(payload, args.json_output, _print_decision_record)
|
|
7118
|
+
return 0
|
|
7119
|
+
|
|
7120
|
+
if args.cmd == "decision-list":
|
|
7121
|
+
payload = cmd_decision_list(
|
|
7122
|
+
Path(args.backlog_dir), args.unapplied, args.state_dir,
|
|
7123
|
+
Path(args.config), _default_schema_path(),
|
|
7124
|
+
)
|
|
7125
|
+
_emit(payload, args.json_output, _print_decision_list)
|
|
7126
|
+
return 0
|
|
7127
|
+
|
|
7128
|
+
if args.cmd == "decision-apply":
|
|
7129
|
+
payload = cmd_decision_apply(
|
|
7130
|
+
Path(args.backlog_dir), args.item_id, args.actor or _default_actor(),
|
|
7131
|
+
args.state_dir, Path(args.config), _default_schema_path(),
|
|
7132
|
+
)
|
|
7133
|
+
_emit(payload, args.json_output, _print_decision_apply)
|
|
7134
|
+
return 0
|
|
7135
|
+
|
|
7136
|
+
if args.cmd == "backlog-topology":
|
|
7137
|
+
items = _load_topology_items(args)
|
|
7138
|
+
payload = cmd_backlog_topology(items, with_clusters=args.with_clusters)
|
|
7139
|
+
_emit(payload, args.json_output, _print_topology)
|
|
7140
|
+
return 0
|
|
7141
|
+
|
|
6023
7142
|
raise UsageError(f"unknown command: {args.cmd}")
|
|
6024
7143
|
except UsageError as exc:
|
|
6025
7144
|
print(f"Error: {exc}", file=sys.stderr)
|