@garygentry/feature-forge 0.3.6 → 0.3.8
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/GENERATION-REPORT.md +20 -0
- package/adapters/claude/.claude-plugin/plugin.json +16 -0
- package/adapters/claude/.feature-forge-bundle.json +1 -1
- package/adapters/claude/agents/forge-verifier.md +16 -5
- package/adapters/claude/references/forge-config-schema.json +3 -3
- package/adapters/claude/references/portable-root.md +20 -16
- package/adapters/claude/references/preflight-and-self-heal.md +183 -0
- package/adapters/claude/references/process-overview.md +1 -1
- package/adapters/claude/references/select-outcome.md +138 -0
- package/adapters/claude/references/shared-conventions.md +175 -22
- package/adapters/claude/references/skill-frontmatter.schema.json +3 -1
- package/adapters/claude/references/stack-resolution.md +1 -1
- package/adapters/claude/references/stage-exit-protocol.md +19 -4
- package/adapters/claude/references/templates/root-hygiene/AGENTS.md +14 -0
- package/adapters/claude/references/templates/root-hygiene/CLAUDE.md +14 -0
- package/adapters/claude/references/templates/specs-hygiene/AGENTS.md +3 -1
- package/adapters/claude/references/templates/specs-hygiene/CLAUDE.md +2 -1
- package/adapters/claude/references/verifier-patterns/MEMORY.md +35 -0
- package/adapters/claude/references/verifier-patterns/pattern_absence_claims.md +36 -0
- package/adapters/claude/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
- package/adapters/claude/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
- package/adapters/claude/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
- package/adapters/claude/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
- package/adapters/claude/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
- package/adapters/claude/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
- package/adapters/claude/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
- package/adapters/claude/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
- package/adapters/claude/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
- package/adapters/claude/references/verifier-patterns/pattern_self_referential_control.md +26 -0
- package/adapters/claude/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
- package/adapters/claude/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
- package/adapters/claude/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
- package/adapters/claude/references/verifier-patterns/pattern_test_count_units.md +20 -0
- package/adapters/claude/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
- package/adapters/claude/references/verify-state.md +98 -0
- package/adapters/claude/scripts/forge-bootstrap.py +23 -2
- package/adapters/claude/scripts/forge-root.sh +104 -21
- package/adapters/claude/scripts/forge-session.py +620 -6973
- package/adapters/claude/scripts/forge_session/__init__.py +11 -0
- package/adapters/claude/scripts/forge_session/_common.py +1566 -0
- package/adapters/claude/scripts/forge_session/_doctor_util.py +218 -0
- package/adapters/claude/scripts/forge_session/cli.py +1067 -0
- package/adapters/claude/scripts/forge_session/decisions.py +355 -0
- package/adapters/claude/scripts/forge_session/discover.py +472 -0
- package/adapters/claude/scripts/forge_session/doctor.py +2144 -0
- package/adapters/claude/scripts/forge_session/exit.py +1081 -0
- package/adapters/claude/scripts/forge_session/outcomes.py +574 -0
- package/adapters/claude/scripts/forge_session/routes.py +1162 -0
- package/adapters/claude/scripts/forge_session/state.py +1479 -0
- package/adapters/claude/scripts/forge_session/topology.py +443 -0
- package/adapters/claude/skills/forge/SKILL.md +7 -7
- package/adapters/claude/skills/forge/references/process-overview.md +1 -1
- package/adapters/claude/skills/forge/references/shared-conventions.md +175 -22
- package/adapters/claude/skills/forge/references/stage-exit-protocol.md +19 -4
- package/adapters/claude/skills/forge-0-epic/SKILL.md +7 -7
- package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +9 -5
- package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +6 -1
- package/adapters/claude/skills/forge-0-epic/references/portable-root.md +20 -16
- package/adapters/claude/skills/forge-0-epic/references/shared-conventions.md +175 -22
- package/adapters/claude/skills/forge-0-epic/references/stage-exit-protocol.md +19 -4
- package/adapters/claude/skills/forge-1-prd/SKILL.md +7 -7
- package/adapters/claude/skills/forge-1-prd/references/shared-conventions.md +175 -22
- package/adapters/claude/skills/forge-1-prd/references/stage-exit-protocol.md +19 -4
- package/adapters/claude/skills/forge-2-tech/SKILL.md +7 -7
- package/adapters/claude/skills/forge-2-tech/references/shared-conventions.md +175 -22
- package/adapters/claude/skills/forge-2-tech/references/stack-resolution.md +1 -1
- package/adapters/claude/skills/forge-2-tech/references/stage-exit-protocol.md +19 -4
- package/adapters/claude/skills/forge-3-specs/SKILL.md +5 -5
- package/adapters/claude/skills/forge-3-specs/references/shared-conventions.md +175 -22
- package/adapters/claude/skills/forge-3-specs/references/stage-exit-protocol.md +19 -4
- package/adapters/claude/skills/forge-4-backlog/SKILL.md +16 -8
- package/adapters/claude/skills/forge-4-backlog/references/shared-conventions.md +175 -22
- package/adapters/claude/skills/forge-4-backlog/references/stage-exit-protocol.md +19 -4
- package/adapters/claude/skills/forge-4-backlog/references/verify-state.md +98 -0
- package/adapters/claude/skills/forge-5-loop/SKILL.md +36 -41
- package/adapters/claude/skills/forge-5-loop/references/agent-selection.md +4 -0
- package/adapters/claude/skills/forge-5-loop/references/preflight-and-self-heal.md +183 -0
- package/adapters/claude/skills/forge-5-loop/references/recovery-procedure.md +10 -0
- package/adapters/claude/skills/forge-5-loop/references/runner-contract.md +15 -5
- package/adapters/claude/skills/forge-5-loop/references/shared-conventions.md +175 -22
- package/adapters/claude/skills/forge-5-loop/references/stage-exit-protocol.md +19 -4
- package/adapters/claude/skills/forge-5-loop/references/verify-state.md +98 -0
- package/adapters/claude/skills/forge-6-docs/SKILL.md +21 -14
- package/adapters/claude/skills/forge-6-docs/references/shared-conventions.md +175 -22
- package/adapters/claude/skills/forge-6-docs/references/stage-exit-protocol.md +19 -4
- package/adapters/claude/skills/forge-6-docs/references/verify-state.md +98 -0
- package/adapters/claude/skills/forge-bootstrap/SKILL.md +19 -10
- package/adapters/claude/skills/forge-bootstrap/references/shared-conventions.md +572 -0
- package/adapters/claude/skills/forge-fix/SKILL.md +15 -18
- package/adapters/claude/skills/forge-fix/references/select-outcome.md +138 -0
- package/adapters/claude/skills/forge-fix/references/shared-conventions.md +175 -22
- package/adapters/claude/skills/forge-fix/references/stage-exit-protocol.md +19 -4
- package/adapters/claude/skills/forge-guide/SKILL.md +87 -1
- package/adapters/claude/skills/forge-guide/references/forge-config-schema.json +3 -3
- package/adapters/claude/skills/forge-guide/references/preflight-and-self-heal.md +183 -0
- package/adapters/claude/skills/forge-guide/references/process-overview.md +1 -1
- package/adapters/claude/skills/forge-guide/references/shared-conventions.md +175 -22
- package/adapters/claude/skills/forge-guide/references/stack-resolution.md +1 -1
- package/adapters/claude/skills/forge-init/SKILL.md +58 -7
- package/adapters/claude/skills/forge-init/references/preflight-and-self-heal.md +183 -0
- package/adapters/claude/skills/forge-init/references/shared-conventions.md +572 -0
- package/adapters/claude/skills/forge-verify/SKILL.md +20 -23
- package/adapters/claude/skills/forge-verify/references/findings-template.md +2 -2
- package/adapters/claude/skills/forge-verify/references/select-outcome.md +138 -0
- package/adapters/claude/skills/forge-verify/references/shared-conventions.md +175 -22
- package/adapters/claude/skills/forge-verify/references/stage-exit-protocol.md +19 -4
- package/adapters/claude/skills/forge-verify/references/verification-checklists/epic.md +1 -1
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/MEMORY.md +35 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_absence_claims.md +36 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_self_referential_control.md +26 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_test_count_units.md +20 -0
- package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
- package/adapters/codex/.feature-forge-bundle.json +1 -1
- package/adapters/codex/agents/forge-researcher.toml +1 -1
- package/adapters/codex/agents/forge-verifier.toml +17 -6
- package/adapters/codex/references/forge-config-schema.json +5 -5
- package/adapters/codex/references/portable-root.md +20 -16
- package/adapters/codex/references/preflight-and-self-heal.md +183 -0
- package/adapters/codex/references/process-overview.md +10 -10
- package/adapters/codex/references/select-outcome.md +138 -0
- package/adapters/codex/references/shared-conventions.md +188 -35
- package/adapters/codex/references/skill-frontmatter.schema.json +3 -1
- package/adapters/codex/references/stack-resolution.md +1 -1
- package/adapters/codex/references/stage-exit-protocol.md +22 -7
- package/adapters/codex/references/templates/root-hygiene/AGENTS.md +14 -0
- package/adapters/codex/references/templates/root-hygiene/CLAUDE.md +14 -0
- package/adapters/codex/references/templates/specs-hygiene/AGENTS.md +3 -1
- package/adapters/codex/references/templates/specs-hygiene/CLAUDE.md +2 -1
- package/adapters/codex/references/verifier-patterns/MEMORY.md +35 -0
- package/adapters/codex/references/verifier-patterns/pattern_absence_claims.md +36 -0
- package/adapters/codex/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
- package/adapters/codex/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
- package/adapters/codex/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
- package/adapters/codex/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
- package/adapters/codex/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
- package/adapters/codex/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
- package/adapters/codex/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
- package/adapters/codex/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
- package/adapters/codex/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
- package/adapters/codex/references/verifier-patterns/pattern_self_referential_control.md +26 -0
- package/adapters/codex/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
- package/adapters/codex/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
- package/adapters/codex/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
- package/adapters/codex/references/verifier-patterns/pattern_test_count_units.md +20 -0
- package/adapters/codex/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
- package/adapters/codex/references/verify-state.md +98 -0
- package/adapters/codex/scripts/forge-bootstrap.py +23 -2
- package/adapters/codex/scripts/forge-root.sh +104 -21
- package/adapters/codex/scripts/forge-session.py +620 -6973
- package/adapters/codex/scripts/forge_session/__init__.py +11 -0
- package/adapters/codex/scripts/forge_session/_common.py +1566 -0
- package/adapters/codex/scripts/forge_session/_doctor_util.py +218 -0
- package/adapters/codex/scripts/forge_session/cli.py +1067 -0
- package/adapters/codex/scripts/forge_session/decisions.py +355 -0
- package/adapters/codex/scripts/forge_session/discover.py +472 -0
- package/adapters/codex/scripts/forge_session/doctor.py +2144 -0
- package/adapters/codex/scripts/forge_session/exit.py +1081 -0
- package/adapters/codex/scripts/forge_session/outcomes.py +574 -0
- package/adapters/codex/scripts/forge_session/routes.py +1162 -0
- package/adapters/codex/scripts/forge_session/state.py +1479 -0
- package/adapters/codex/scripts/forge_session/topology.py +443 -0
- package/adapters/codex/skills/forge/SKILL.md +40 -40
- package/adapters/codex/skills/forge/references/process-overview.md +10 -10
- package/adapters/codex/skills/forge/references/shared-conventions.md +188 -35
- package/adapters/codex/skills/forge/references/stage-exit-protocol.md +22 -7
- package/adapters/codex/skills/forge-0-epic/SKILL.md +15 -15
- 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/portable-root.md +20 -16
- package/adapters/codex/skills/forge-0-epic/references/shared-conventions.md +188 -35
- package/adapters/codex/skills/forge-0-epic/references/stage-exit-protocol.md +22 -7
- package/adapters/codex/skills/forge-1-prd/SKILL.md +15 -15
- package/adapters/codex/skills/forge-1-prd/references/shared-conventions.md +188 -35
- package/adapters/codex/skills/forge-1-prd/references/stage-exit-protocol.md +22 -7
- package/adapters/codex/skills/forge-2-tech/SKILL.md +15 -15
- package/adapters/codex/skills/forge-2-tech/references/shared-conventions.md +188 -35
- package/adapters/codex/skills/forge-2-tech/references/stack-resolution.md +1 -1
- package/adapters/codex/skills/forge-2-tech/references/stage-exit-protocol.md +22 -7
- package/adapters/codex/skills/forge-3-specs/SKILL.md +10 -10
- package/adapters/codex/skills/forge-3-specs/references/shared-conventions.md +188 -35
- package/adapters/codex/skills/forge-3-specs/references/stage-exit-protocol.md +22 -7
- package/adapters/codex/skills/forge-4-backlog/SKILL.md +19 -11
- package/adapters/codex/skills/forge-4-backlog/references/shared-conventions.md +188 -35
- package/adapters/codex/skills/forge-4-backlog/references/stage-exit-protocol.md +22 -7
- package/adapters/codex/skills/forge-4-backlog/references/verify-state.md +98 -0
- package/adapters/codex/skills/forge-5-loop/SKILL.md +48 -53
- package/adapters/codex/skills/forge-5-loop/references/agent-selection.md +5 -1
- package/adapters/codex/skills/forge-5-loop/references/preflight-and-self-heal.md +183 -0
- package/adapters/codex/skills/forge-5-loop/references/recovery-procedure.md +14 -4
- package/adapters/codex/skills/forge-5-loop/references/runner-contract.md +18 -8
- package/adapters/codex/skills/forge-5-loop/references/shared-conventions.md +188 -35
- package/adapters/codex/skills/forge-5-loop/references/stage-exit-protocol.md +22 -7
- package/adapters/codex/skills/forge-5-loop/references/verify-state.md +98 -0
- package/adapters/codex/skills/forge-6-docs/SKILL.md +27 -20
- package/adapters/codex/skills/forge-6-docs/references/shared-conventions.md +188 -35
- package/adapters/codex/skills/forge-6-docs/references/stage-exit-protocol.md +22 -7
- package/adapters/codex/skills/forge-6-docs/references/verify-state.md +98 -0
- package/adapters/codex/skills/forge-bootstrap/SKILL.md +23 -15
- package/adapters/codex/skills/forge-bootstrap/references/shared-conventions.md +572 -0
- package/adapters/codex/skills/forge-bootstrap/references/templates/hygiene/CLAUDE.md +2 -2
- package/adapters/codex/skills/forge-fix/SKILL.md +27 -30
- package/adapters/codex/skills/forge-fix/references/select-outcome.md +138 -0
- package/adapters/codex/skills/forge-fix/references/shared-conventions.md +188 -35
- package/adapters/codex/skills/forge-fix/references/stage-exit-protocol.md +22 -7
- package/adapters/codex/skills/forge-guide/SKILL.md +84 -7
- package/adapters/codex/skills/forge-guide/references/forge-config-schema.json +5 -5
- package/adapters/codex/skills/forge-guide/references/preflight-and-self-heal.md +183 -0
- package/adapters/codex/skills/forge-guide/references/process-overview.md +10 -10
- package/adapters/codex/skills/forge-guide/references/shared-conventions.md +188 -35
- package/adapters/codex/skills/forge-guide/references/stack-resolution.md +1 -1
- package/adapters/codex/skills/forge-init/SKILL.md +62 -12
- package/adapters/codex/skills/forge-init/references/preflight-and-self-heal.md +183 -0
- package/adapters/codex/skills/forge-init/references/shared-conventions.md +572 -0
- package/adapters/codex/skills/forge-verify/SKILL.md +27 -30
- package/adapters/codex/skills/forge-verify/references/findings-template.md +2 -2
- package/adapters/codex/skills/forge-verify/references/select-outcome.md +138 -0
- package/adapters/codex/skills/forge-verify/references/shared-conventions.md +188 -35
- package/adapters/codex/skills/forge-verify/references/stage-exit-protocol.md +22 -7
- package/adapters/codex/skills/forge-verify/references/verification-checklists/epic.md +2 -2
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/MEMORY.md +35 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_absence_claims.md +36 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_self_referential_control.md +26 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_test_count_units.md +20 -0
- package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
- package/adapters/copilot/.feature-forge-bundle.json +1 -1
- package/adapters/copilot/agents/forge-researcher.md +1 -1
- package/adapters/copilot/agents/forge-verifier.md +17 -6
- package/adapters/copilot/references/forge-config-schema.json +5 -5
- package/adapters/copilot/references/portable-root.md +20 -16
- package/adapters/copilot/references/preflight-and-self-heal.md +183 -0
- package/adapters/copilot/references/process-overview.md +10 -10
- package/adapters/copilot/references/select-outcome.md +138 -0
- package/adapters/copilot/references/shared-conventions.md +188 -35
- package/adapters/copilot/references/skill-frontmatter.schema.json +3 -1
- package/adapters/copilot/references/stack-resolution.md +1 -1
- package/adapters/copilot/references/stage-exit-protocol.md +22 -7
- package/adapters/copilot/references/templates/root-hygiene/AGENTS.md +14 -0
- package/adapters/copilot/references/templates/root-hygiene/CLAUDE.md +14 -0
- package/adapters/copilot/references/templates/specs-hygiene/AGENTS.md +3 -1
- package/adapters/copilot/references/templates/specs-hygiene/CLAUDE.md +2 -1
- package/adapters/copilot/references/verifier-patterns/MEMORY.md +35 -0
- package/adapters/copilot/references/verifier-patterns/pattern_absence_claims.md +36 -0
- package/adapters/copilot/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
- package/adapters/copilot/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
- package/adapters/copilot/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
- package/adapters/copilot/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
- package/adapters/copilot/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
- package/adapters/copilot/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
- package/adapters/copilot/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
- package/adapters/copilot/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
- package/adapters/copilot/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
- package/adapters/copilot/references/verifier-patterns/pattern_self_referential_control.md +26 -0
- package/adapters/copilot/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
- package/adapters/copilot/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
- package/adapters/copilot/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
- package/adapters/copilot/references/verifier-patterns/pattern_test_count_units.md +20 -0
- package/adapters/copilot/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
- package/adapters/copilot/references/verify-state.md +98 -0
- package/adapters/copilot/scripts/forge-bootstrap.py +23 -2
- package/adapters/copilot/scripts/forge-root.sh +104 -21
- package/adapters/copilot/scripts/forge-session.py +620 -6973
- package/adapters/copilot/scripts/forge_session/__init__.py +11 -0
- package/adapters/copilot/scripts/forge_session/_common.py +1566 -0
- package/adapters/copilot/scripts/forge_session/_doctor_util.py +218 -0
- package/adapters/copilot/scripts/forge_session/cli.py +1067 -0
- package/adapters/copilot/scripts/forge_session/decisions.py +355 -0
- package/adapters/copilot/scripts/forge_session/discover.py +472 -0
- package/adapters/copilot/scripts/forge_session/doctor.py +2144 -0
- package/adapters/copilot/scripts/forge_session/exit.py +1081 -0
- package/adapters/copilot/scripts/forge_session/outcomes.py +574 -0
- package/adapters/copilot/scripts/forge_session/routes.py +1162 -0
- package/adapters/copilot/scripts/forge_session/state.py +1479 -0
- package/adapters/copilot/scripts/forge_session/topology.py +443 -0
- package/adapters/copilot/skills/forge/forge.md +40 -40
- package/adapters/copilot/skills/forge/references/process-overview.md +10 -10
- package/adapters/copilot/skills/forge/references/shared-conventions.md +188 -35
- package/adapters/copilot/skills/forge/references/stage-exit-protocol.md +22 -7
- package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +15 -15
- 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/portable-root.md +20 -16
- package/adapters/copilot/skills/forge-0-epic/references/shared-conventions.md +188 -35
- package/adapters/copilot/skills/forge-0-epic/references/stage-exit-protocol.md +22 -7
- package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +15 -15
- package/adapters/copilot/skills/forge-1-prd/references/shared-conventions.md +188 -35
- package/adapters/copilot/skills/forge-1-prd/references/stage-exit-protocol.md +22 -7
- package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +15 -15
- package/adapters/copilot/skills/forge-2-tech/references/shared-conventions.md +188 -35
- package/adapters/copilot/skills/forge-2-tech/references/stack-resolution.md +1 -1
- package/adapters/copilot/skills/forge-2-tech/references/stage-exit-protocol.md +22 -7
- package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +10 -10
- package/adapters/copilot/skills/forge-3-specs/references/shared-conventions.md +188 -35
- package/adapters/copilot/skills/forge-3-specs/references/stage-exit-protocol.md +22 -7
- package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +19 -11
- package/adapters/copilot/skills/forge-4-backlog/references/shared-conventions.md +188 -35
- package/adapters/copilot/skills/forge-4-backlog/references/stage-exit-protocol.md +22 -7
- package/adapters/copilot/skills/forge-4-backlog/references/verify-state.md +98 -0
- package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +48 -53
- package/adapters/copilot/skills/forge-5-loop/references/agent-selection.md +5 -1
- package/adapters/copilot/skills/forge-5-loop/references/preflight-and-self-heal.md +183 -0
- package/adapters/copilot/skills/forge-5-loop/references/recovery-procedure.md +14 -4
- package/adapters/copilot/skills/forge-5-loop/references/runner-contract.md +18 -8
- package/adapters/copilot/skills/forge-5-loop/references/shared-conventions.md +188 -35
- package/adapters/copilot/skills/forge-5-loop/references/stage-exit-protocol.md +22 -7
- package/adapters/copilot/skills/forge-5-loop/references/verify-state.md +98 -0
- package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +27 -20
- package/adapters/copilot/skills/forge-6-docs/references/shared-conventions.md +188 -35
- package/adapters/copilot/skills/forge-6-docs/references/stage-exit-protocol.md +22 -7
- package/adapters/copilot/skills/forge-6-docs/references/verify-state.md +98 -0
- package/adapters/copilot/skills/forge-bootstrap/forge-bootstrap.md +23 -15
- package/adapters/copilot/skills/forge-bootstrap/references/shared-conventions.md +572 -0
- package/adapters/copilot/skills/forge-bootstrap/references/templates/hygiene/CLAUDE.md +2 -2
- package/adapters/copilot/skills/forge-fix/forge-fix.md +27 -30
- package/adapters/copilot/skills/forge-fix/references/select-outcome.md +138 -0
- package/adapters/copilot/skills/forge-fix/references/shared-conventions.md +188 -35
- package/adapters/copilot/skills/forge-fix/references/stage-exit-protocol.md +22 -7
- package/adapters/copilot/skills/forge-guide/forge-guide.md +84 -7
- package/adapters/copilot/skills/forge-guide/references/forge-config-schema.json +5 -5
- package/adapters/copilot/skills/forge-guide/references/preflight-and-self-heal.md +183 -0
- package/adapters/copilot/skills/forge-guide/references/process-overview.md +10 -10
- package/adapters/copilot/skills/forge-guide/references/shared-conventions.md +188 -35
- package/adapters/copilot/skills/forge-guide/references/stack-resolution.md +1 -1
- package/adapters/copilot/skills/forge-init/forge-init.md +62 -12
- package/adapters/copilot/skills/forge-init/references/preflight-and-self-heal.md +183 -0
- package/adapters/copilot/skills/forge-init/references/shared-conventions.md +572 -0
- package/adapters/copilot/skills/forge-verify/forge-verify.md +27 -30
- package/adapters/copilot/skills/forge-verify/references/findings-template.md +2 -2
- package/adapters/copilot/skills/forge-verify/references/select-outcome.md +138 -0
- package/adapters/copilot/skills/forge-verify/references/shared-conventions.md +188 -35
- package/adapters/copilot/skills/forge-verify/references/stage-exit-protocol.md +22 -7
- package/adapters/copilot/skills/forge-verify/references/verification-checklists/epic.md +2 -2
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/MEMORY.md +35 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_absence_claims.md +36 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_self_referential_control.md +26 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_test_count_units.md +20 -0
- package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
- package/adapters/cursor/.feature-forge-bundle.json +1 -1
- package/adapters/cursor/agents/forge-researcher.mdc +1 -1
- package/adapters/cursor/agents/forge-verifier.mdc +17 -6
- package/adapters/cursor/references/forge-config-schema.json +5 -5
- package/adapters/cursor/references/portable-root.md +20 -16
- package/adapters/cursor/references/preflight-and-self-heal.md +183 -0
- package/adapters/cursor/references/process-overview.md +10 -10
- package/adapters/cursor/references/select-outcome.md +138 -0
- package/adapters/cursor/references/shared-conventions.md +188 -35
- package/adapters/cursor/references/skill-frontmatter.schema.json +3 -1
- package/adapters/cursor/references/stack-resolution.md +1 -1
- package/adapters/cursor/references/stage-exit-protocol.md +22 -7
- package/adapters/cursor/references/templates/root-hygiene/AGENTS.md +14 -0
- package/adapters/cursor/references/templates/root-hygiene/CLAUDE.md +14 -0
- package/adapters/cursor/references/templates/specs-hygiene/AGENTS.md +3 -1
- package/adapters/cursor/references/templates/specs-hygiene/CLAUDE.md +2 -1
- package/adapters/cursor/references/verifier-patterns/MEMORY.md +35 -0
- package/adapters/cursor/references/verifier-patterns/pattern_absence_claims.md +36 -0
- package/adapters/cursor/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
- package/adapters/cursor/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
- package/adapters/cursor/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
- package/adapters/cursor/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
- package/adapters/cursor/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
- package/adapters/cursor/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
- package/adapters/cursor/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
- package/adapters/cursor/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
- package/adapters/cursor/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
- package/adapters/cursor/references/verifier-patterns/pattern_self_referential_control.md +26 -0
- package/adapters/cursor/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
- package/adapters/cursor/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
- package/adapters/cursor/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
- package/adapters/cursor/references/verifier-patterns/pattern_test_count_units.md +20 -0
- package/adapters/cursor/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
- package/adapters/cursor/references/verify-state.md +98 -0
- package/adapters/cursor/scripts/forge-bootstrap.py +23 -2
- package/adapters/cursor/scripts/forge-root.sh +104 -21
- package/adapters/cursor/scripts/forge-session.py +620 -6973
- package/adapters/cursor/scripts/forge_session/__init__.py +11 -0
- package/adapters/cursor/scripts/forge_session/_common.py +1566 -0
- package/adapters/cursor/scripts/forge_session/_doctor_util.py +218 -0
- package/adapters/cursor/scripts/forge_session/cli.py +1067 -0
- package/adapters/cursor/scripts/forge_session/decisions.py +355 -0
- package/adapters/cursor/scripts/forge_session/discover.py +472 -0
- package/adapters/cursor/scripts/forge_session/doctor.py +2144 -0
- package/adapters/cursor/scripts/forge_session/exit.py +1081 -0
- package/adapters/cursor/scripts/forge_session/outcomes.py +574 -0
- package/adapters/cursor/scripts/forge_session/routes.py +1162 -0
- package/adapters/cursor/scripts/forge_session/state.py +1479 -0
- package/adapters/cursor/scripts/forge_session/topology.py +443 -0
- package/adapters/cursor/skills/forge/forge.mdc +40 -40
- package/adapters/cursor/skills/forge/references/process-overview.md +10 -10
- package/adapters/cursor/skills/forge/references/shared-conventions.md +188 -35
- package/adapters/cursor/skills/forge/references/stage-exit-protocol.md +22 -7
- package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +15 -15
- 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/portable-root.md +20 -16
- package/adapters/cursor/skills/forge-0-epic/references/shared-conventions.md +188 -35
- package/adapters/cursor/skills/forge-0-epic/references/stage-exit-protocol.md +22 -7
- package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +15 -15
- package/adapters/cursor/skills/forge-1-prd/references/shared-conventions.md +188 -35
- package/adapters/cursor/skills/forge-1-prd/references/stage-exit-protocol.md +22 -7
- package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +15 -15
- package/adapters/cursor/skills/forge-2-tech/references/shared-conventions.md +188 -35
- package/adapters/cursor/skills/forge-2-tech/references/stack-resolution.md +1 -1
- package/adapters/cursor/skills/forge-2-tech/references/stage-exit-protocol.md +22 -7
- package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +10 -10
- package/adapters/cursor/skills/forge-3-specs/references/shared-conventions.md +188 -35
- package/adapters/cursor/skills/forge-3-specs/references/stage-exit-protocol.md +22 -7
- package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +19 -11
- package/adapters/cursor/skills/forge-4-backlog/references/shared-conventions.md +188 -35
- package/adapters/cursor/skills/forge-4-backlog/references/stage-exit-protocol.md +22 -7
- package/adapters/cursor/skills/forge-4-backlog/references/verify-state.md +98 -0
- package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +48 -53
- package/adapters/cursor/skills/forge-5-loop/references/agent-selection.md +5 -1
- package/adapters/cursor/skills/forge-5-loop/references/preflight-and-self-heal.md +183 -0
- package/adapters/cursor/skills/forge-5-loop/references/recovery-procedure.md +14 -4
- package/adapters/cursor/skills/forge-5-loop/references/runner-contract.md +18 -8
- package/adapters/cursor/skills/forge-5-loop/references/shared-conventions.md +188 -35
- package/adapters/cursor/skills/forge-5-loop/references/stage-exit-protocol.md +22 -7
- package/adapters/cursor/skills/forge-5-loop/references/verify-state.md +98 -0
- package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +27 -20
- package/adapters/cursor/skills/forge-6-docs/references/shared-conventions.md +188 -35
- package/adapters/cursor/skills/forge-6-docs/references/stage-exit-protocol.md +22 -7
- package/adapters/cursor/skills/forge-6-docs/references/verify-state.md +98 -0
- package/adapters/cursor/skills/forge-bootstrap/forge-bootstrap.mdc +23 -15
- package/adapters/cursor/skills/forge-bootstrap/references/shared-conventions.md +572 -0
- package/adapters/cursor/skills/forge-bootstrap/references/templates/hygiene/CLAUDE.md +2 -2
- package/adapters/cursor/skills/forge-fix/forge-fix.mdc +27 -30
- package/adapters/cursor/skills/forge-fix/references/select-outcome.md +138 -0
- package/adapters/cursor/skills/forge-fix/references/shared-conventions.md +188 -35
- package/adapters/cursor/skills/forge-fix/references/stage-exit-protocol.md +22 -7
- package/adapters/cursor/skills/forge-guide/forge-guide.mdc +84 -7
- package/adapters/cursor/skills/forge-guide/references/forge-config-schema.json +5 -5
- package/adapters/cursor/skills/forge-guide/references/preflight-and-self-heal.md +183 -0
- package/adapters/cursor/skills/forge-guide/references/process-overview.md +10 -10
- package/adapters/cursor/skills/forge-guide/references/shared-conventions.md +188 -35
- package/adapters/cursor/skills/forge-guide/references/stack-resolution.md +1 -1
- package/adapters/cursor/skills/forge-init/forge-init.mdc +62 -12
- package/adapters/cursor/skills/forge-init/references/preflight-and-self-heal.md +183 -0
- package/adapters/cursor/skills/forge-init/references/shared-conventions.md +572 -0
- package/adapters/cursor/skills/forge-verify/forge-verify.mdc +27 -30
- package/adapters/cursor/skills/forge-verify/references/findings-template.md +2 -2
- package/adapters/cursor/skills/forge-verify/references/select-outcome.md +138 -0
- package/adapters/cursor/skills/forge-verify/references/shared-conventions.md +188 -35
- package/adapters/cursor/skills/forge-verify/references/stage-exit-protocol.md +22 -7
- package/adapters/cursor/skills/forge-verify/references/verification-checklists/epic.md +2 -2
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/MEMORY.md +35 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_absence_claims.md +36 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_self_referential_control.md +26 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_test_count_units.md +20 -0
- package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
- package/adapters/gemini/.feature-forge-bundle.json +1 -1
- package/adapters/gemini/agents/forge-researcher.md +1 -1
- package/adapters/gemini/agents/forge-verifier.md +17 -6
- package/adapters/gemini/gemini-extension.json +13 -13
- package/adapters/gemini/references/forge-config-schema.json +5 -5
- package/adapters/gemini/references/portable-root.md +20 -16
- package/adapters/gemini/references/preflight-and-self-heal.md +183 -0
- package/adapters/gemini/references/process-overview.md +10 -10
- package/adapters/gemini/references/select-outcome.md +138 -0
- package/adapters/gemini/references/shared-conventions.md +188 -35
- package/adapters/gemini/references/skill-frontmatter.schema.json +3 -1
- package/adapters/gemini/references/stack-resolution.md +1 -1
- package/adapters/gemini/references/stage-exit-protocol.md +22 -7
- package/adapters/gemini/references/templates/root-hygiene/AGENTS.md +14 -0
- package/adapters/gemini/references/templates/root-hygiene/CLAUDE.md +14 -0
- package/adapters/gemini/references/templates/specs-hygiene/AGENTS.md +3 -1
- package/adapters/gemini/references/templates/specs-hygiene/CLAUDE.md +2 -1
- package/adapters/gemini/references/verifier-patterns/MEMORY.md +35 -0
- package/adapters/gemini/references/verifier-patterns/pattern_absence_claims.md +36 -0
- package/adapters/gemini/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
- package/adapters/gemini/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
- package/adapters/gemini/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
- package/adapters/gemini/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
- package/adapters/gemini/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
- package/adapters/gemini/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
- package/adapters/gemini/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
- package/adapters/gemini/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
- package/adapters/gemini/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
- package/adapters/gemini/references/verifier-patterns/pattern_self_referential_control.md +26 -0
- package/adapters/gemini/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
- package/adapters/gemini/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
- package/adapters/gemini/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
- package/adapters/gemini/references/verifier-patterns/pattern_test_count_units.md +20 -0
- package/adapters/gemini/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
- package/adapters/gemini/references/verify-state.md +98 -0
- package/adapters/gemini/scripts/forge-bootstrap.py +23 -2
- package/adapters/gemini/scripts/forge-root.sh +104 -21
- package/adapters/gemini/scripts/forge-session.py +620 -6973
- package/adapters/gemini/scripts/forge_session/__init__.py +11 -0
- package/adapters/gemini/scripts/forge_session/_common.py +1566 -0
- package/adapters/gemini/scripts/forge_session/_doctor_util.py +218 -0
- package/adapters/gemini/scripts/forge_session/cli.py +1067 -0
- package/adapters/gemini/scripts/forge_session/decisions.py +355 -0
- package/adapters/gemini/scripts/forge_session/discover.py +472 -0
- package/adapters/gemini/scripts/forge_session/doctor.py +2144 -0
- package/adapters/gemini/scripts/forge_session/exit.py +1081 -0
- package/adapters/gemini/scripts/forge_session/outcomes.py +574 -0
- package/adapters/gemini/scripts/forge_session/routes.py +1162 -0
- package/adapters/gemini/scripts/forge_session/state.py +1479 -0
- package/adapters/gemini/scripts/forge_session/topology.py +443 -0
- package/adapters/gemini/skills/forge/forge.md +40 -40
- package/adapters/gemini/skills/forge/references/process-overview.md +10 -10
- package/adapters/gemini/skills/forge/references/shared-conventions.md +188 -35
- package/adapters/gemini/skills/forge/references/stage-exit-protocol.md +22 -7
- package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +15 -15
- 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/portable-root.md +20 -16
- package/adapters/gemini/skills/forge-0-epic/references/shared-conventions.md +188 -35
- package/adapters/gemini/skills/forge-0-epic/references/stage-exit-protocol.md +22 -7
- package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +15 -15
- package/adapters/gemini/skills/forge-1-prd/references/shared-conventions.md +188 -35
- package/adapters/gemini/skills/forge-1-prd/references/stage-exit-protocol.md +22 -7
- package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +15 -15
- package/adapters/gemini/skills/forge-2-tech/references/shared-conventions.md +188 -35
- package/adapters/gemini/skills/forge-2-tech/references/stack-resolution.md +1 -1
- package/adapters/gemini/skills/forge-2-tech/references/stage-exit-protocol.md +22 -7
- package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +10 -10
- package/adapters/gemini/skills/forge-3-specs/references/shared-conventions.md +188 -35
- package/adapters/gemini/skills/forge-3-specs/references/stage-exit-protocol.md +22 -7
- package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +19 -11
- package/adapters/gemini/skills/forge-4-backlog/references/shared-conventions.md +188 -35
- package/adapters/gemini/skills/forge-4-backlog/references/stage-exit-protocol.md +22 -7
- package/adapters/gemini/skills/forge-4-backlog/references/verify-state.md +98 -0
- package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +48 -53
- package/adapters/gemini/skills/forge-5-loop/references/agent-selection.md +5 -1
- package/adapters/gemini/skills/forge-5-loop/references/preflight-and-self-heal.md +183 -0
- package/adapters/gemini/skills/forge-5-loop/references/recovery-procedure.md +14 -4
- package/adapters/gemini/skills/forge-5-loop/references/runner-contract.md +18 -8
- package/adapters/gemini/skills/forge-5-loop/references/shared-conventions.md +188 -35
- package/adapters/gemini/skills/forge-5-loop/references/stage-exit-protocol.md +22 -7
- package/adapters/gemini/skills/forge-5-loop/references/verify-state.md +98 -0
- package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +27 -20
- package/adapters/gemini/skills/forge-6-docs/references/shared-conventions.md +188 -35
- package/adapters/gemini/skills/forge-6-docs/references/stage-exit-protocol.md +22 -7
- package/adapters/gemini/skills/forge-6-docs/references/verify-state.md +98 -0
- package/adapters/gemini/skills/forge-bootstrap/forge-bootstrap.md +23 -15
- package/adapters/gemini/skills/forge-bootstrap/references/shared-conventions.md +572 -0
- package/adapters/gemini/skills/forge-bootstrap/references/templates/hygiene/CLAUDE.md +2 -2
- package/adapters/gemini/skills/forge-fix/forge-fix.md +27 -30
- package/adapters/gemini/skills/forge-fix/references/select-outcome.md +138 -0
- package/adapters/gemini/skills/forge-fix/references/shared-conventions.md +188 -35
- package/adapters/gemini/skills/forge-fix/references/stage-exit-protocol.md +22 -7
- package/adapters/gemini/skills/forge-guide/forge-guide.md +84 -7
- package/adapters/gemini/skills/forge-guide/references/forge-config-schema.json +5 -5
- package/adapters/gemini/skills/forge-guide/references/preflight-and-self-heal.md +183 -0
- package/adapters/gemini/skills/forge-guide/references/process-overview.md +10 -10
- package/adapters/gemini/skills/forge-guide/references/shared-conventions.md +188 -35
- package/adapters/gemini/skills/forge-guide/references/stack-resolution.md +1 -1
- package/adapters/gemini/skills/forge-init/forge-init.md +62 -12
- package/adapters/gemini/skills/forge-init/references/preflight-and-self-heal.md +183 -0
- package/adapters/gemini/skills/forge-init/references/shared-conventions.md +572 -0
- package/adapters/gemini/skills/forge-verify/forge-verify.md +27 -30
- package/adapters/gemini/skills/forge-verify/references/findings-template.md +2 -2
- package/adapters/gemini/skills/forge-verify/references/select-outcome.md +138 -0
- package/adapters/gemini/skills/forge-verify/references/shared-conventions.md +188 -35
- package/adapters/gemini/skills/forge-verify/references/stage-exit-protocol.md +22 -7
- package/adapters/gemini/skills/forge-verify/references/verification-checklists/epic.md +2 -2
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/MEMORY.md +35 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_absence_claims.md +36 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_self_referential_control.md +26 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_test_count_units.md +20 -0
- package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
- package/adapters/pi/.feature-forge-bundle.json +1 -1
- package/adapters/pi/agents/forge-verifier.md +16 -5
- package/adapters/pi/extensions/forge-invocation-args/README.md +106 -0
- package/adapters/pi/extensions/forge-invocation-args/index.ts +31 -0
- package/adapters/pi/extensions/forge-invocation-args/wiring.ts +172 -0
- package/adapters/pi/package.json +1 -0
- package/adapters/pi/references/forge-config-schema.json +3 -3
- package/adapters/pi/references/portable-root.md +20 -16
- package/adapters/pi/references/preflight-and-self-heal.md +183 -0
- package/adapters/pi/references/select-outcome.md +138 -0
- package/adapters/pi/references/shared-conventions.md +175 -22
- package/adapters/pi/references/skill-frontmatter.schema.json +3 -1
- package/adapters/pi/references/stack-resolution.md +1 -1
- package/adapters/pi/references/stage-exit-protocol.md +19 -4
- package/adapters/pi/references/templates/root-hygiene/AGENTS.md +14 -0
- package/adapters/pi/references/templates/root-hygiene/CLAUDE.md +14 -0
- package/adapters/pi/references/templates/specs-hygiene/AGENTS.md +3 -1
- package/adapters/pi/references/templates/specs-hygiene/CLAUDE.md +2 -1
- package/adapters/pi/references/verifier-patterns/MEMORY.md +35 -0
- package/adapters/pi/references/verifier-patterns/pattern_absence_claims.md +36 -0
- package/adapters/pi/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
- package/adapters/pi/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
- package/adapters/pi/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
- package/adapters/pi/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
- package/adapters/pi/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
- package/adapters/pi/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
- package/adapters/pi/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
- package/adapters/pi/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
- package/adapters/pi/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
- package/adapters/pi/references/verifier-patterns/pattern_self_referential_control.md +26 -0
- package/adapters/pi/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
- package/adapters/pi/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
- package/adapters/pi/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
- package/adapters/pi/references/verifier-patterns/pattern_test_count_units.md +20 -0
- package/adapters/pi/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
- package/adapters/pi/references/verify-state.md +98 -0
- package/adapters/pi/scripts/forge-bootstrap.py +23 -2
- package/adapters/pi/scripts/forge-root.sh +104 -21
- package/adapters/pi/scripts/forge-session.py +620 -6973
- package/adapters/pi/scripts/forge_session/__init__.py +11 -0
- package/adapters/pi/scripts/forge_session/_common.py +1566 -0
- package/adapters/pi/scripts/forge_session/_doctor_util.py +218 -0
- package/adapters/pi/scripts/forge_session/cli.py +1067 -0
- package/adapters/pi/scripts/forge_session/decisions.py +355 -0
- package/adapters/pi/scripts/forge_session/discover.py +472 -0
- package/adapters/pi/scripts/forge_session/doctor.py +2144 -0
- package/adapters/pi/scripts/forge_session/exit.py +1081 -0
- package/adapters/pi/scripts/forge_session/outcomes.py +574 -0
- package/adapters/pi/scripts/forge_session/routes.py +1162 -0
- package/adapters/pi/scripts/forge_session/state.py +1479 -0
- package/adapters/pi/scripts/forge_session/topology.py +443 -0
- package/adapters/pi/skills/forge/SKILL.md +9 -8
- package/adapters/pi/skills/forge/references/shared-conventions.md +175 -22
- package/adapters/pi/skills/forge/references/stage-exit-protocol.md +19 -4
- package/adapters/pi/skills/forge-0-epic/SKILL.md +9 -8
- package/adapters/pi/skills/forge-0-epic/references/edit-mode.md +9 -5
- package/adapters/pi/skills/forge-0-epic/references/epic-manifest-subcommands.md +6 -1
- package/adapters/pi/skills/forge-0-epic/references/portable-root.md +20 -16
- package/adapters/pi/skills/forge-0-epic/references/shared-conventions.md +175 -22
- package/adapters/pi/skills/forge-0-epic/references/stage-exit-protocol.md +19 -4
- package/adapters/pi/skills/forge-1-prd/SKILL.md +9 -8
- package/adapters/pi/skills/forge-1-prd/references/shared-conventions.md +175 -22
- package/adapters/pi/skills/forge-1-prd/references/stage-exit-protocol.md +19 -4
- package/adapters/pi/skills/forge-2-tech/SKILL.md +8 -7
- package/adapters/pi/skills/forge-2-tech/references/shared-conventions.md +175 -22
- package/adapters/pi/skills/forge-2-tech/references/stack-resolution.md +1 -1
- package/adapters/pi/skills/forge-2-tech/references/stage-exit-protocol.md +19 -4
- package/adapters/pi/skills/forge-3-specs/SKILL.md +7 -6
- package/adapters/pi/skills/forge-3-specs/references/shared-conventions.md +175 -22
- package/adapters/pi/skills/forge-3-specs/references/stage-exit-protocol.md +19 -4
- package/adapters/pi/skills/forge-4-backlog/SKILL.md +18 -9
- package/adapters/pi/skills/forge-4-backlog/references/shared-conventions.md +175 -22
- package/adapters/pi/skills/forge-4-backlog/references/stage-exit-protocol.md +19 -4
- package/adapters/pi/skills/forge-4-backlog/references/verify-state.md +98 -0
- package/adapters/pi/skills/forge-5-loop/SKILL.md +38 -42
- package/adapters/pi/skills/forge-5-loop/references/agent-selection.md +4 -0
- package/adapters/pi/skills/forge-5-loop/references/preflight-and-self-heal.md +183 -0
- package/adapters/pi/skills/forge-5-loop/references/recovery-procedure.md +10 -0
- package/adapters/pi/skills/forge-5-loop/references/runner-contract.md +15 -5
- package/adapters/pi/skills/forge-5-loop/references/shared-conventions.md +175 -22
- package/adapters/pi/skills/forge-5-loop/references/stage-exit-protocol.md +19 -4
- package/adapters/pi/skills/forge-5-loop/references/verify-state.md +98 -0
- package/adapters/pi/skills/forge-6-docs/SKILL.md +23 -15
- package/adapters/pi/skills/forge-6-docs/references/shared-conventions.md +175 -22
- package/adapters/pi/skills/forge-6-docs/references/stage-exit-protocol.md +19 -4
- package/adapters/pi/skills/forge-6-docs/references/verify-state.md +98 -0
- package/adapters/pi/skills/forge-bootstrap/SKILL.md +20 -11
- package/adapters/pi/skills/forge-bootstrap/references/shared-conventions.md +572 -0
- package/adapters/pi/skills/forge-fix/SKILL.md +17 -19
- package/adapters/pi/skills/forge-fix/references/select-outcome.md +138 -0
- package/adapters/pi/skills/forge-fix/references/shared-conventions.md +175 -22
- package/adapters/pi/skills/forge-fix/references/stage-exit-protocol.md +19 -4
- package/adapters/pi/skills/forge-guide/SKILL.md +79 -1
- package/adapters/pi/skills/forge-guide/references/forge-config-schema.json +3 -3
- package/adapters/pi/skills/forge-guide/references/preflight-and-self-heal.md +183 -0
- package/adapters/pi/skills/forge-guide/references/shared-conventions.md +175 -22
- package/adapters/pi/skills/forge-guide/references/stack-resolution.md +1 -1
- package/adapters/pi/skills/forge-init/SKILL.md +59 -8
- package/adapters/pi/skills/forge-init/references/preflight-and-self-heal.md +183 -0
- package/adapters/pi/skills/forge-init/references/shared-conventions.md +572 -0
- package/adapters/pi/skills/forge-verify/SKILL.md +17 -19
- package/adapters/pi/skills/forge-verify/references/findings-template.md +1 -1
- package/adapters/pi/skills/forge-verify/references/select-outcome.md +138 -0
- package/adapters/pi/skills/forge-verify/references/shared-conventions.md +175 -22
- package/adapters/pi/skills/forge-verify/references/stage-exit-protocol.md +19 -4
- package/adapters/pi/skills/forge-verify/references/verification-checklists/epic.md +1 -1
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/MEMORY.md +35 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_absence_claims.md +36 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_self_referential_control.md +26 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_test_count_units.md +20 -0
- package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
- package/dist/manifest.d.ts +1 -1
- package/dist/rauf.d.ts +3 -3
- package/dist/rauf.js +2 -2
- package/dist/source.d.ts +5 -2
- package/dist/source.js +5 -2
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,2144 @@
|
|
|
1
|
+
"""Doctor: pipeline ground-truth capture and the structured health-check registry.
|
|
2
|
+
|
|
3
|
+
The ``doctor`` verb (``forge-session.py doctor [--json] [--check ID ...]``) and
|
|
4
|
+
its registry of best-effort checks — the CLI's self-verifying smoke command
|
|
5
|
+
(CHECK-I21). Extracted from the monolith by #279 P4.1 as a PURE MOVE: the verb,
|
|
6
|
+
its ``--check`` IDs, its ``--json`` shape, and CHECK-I21 output are FROZEN.
|
|
7
|
+
|
|
8
|
+
Every shared primitive it needs (readers, config helpers, git access) is imported
|
|
9
|
+
FROM ``forge_session._common`` — never from the hyphen-named shim, which would be a
|
|
10
|
+
circular import. Stdlib only, 3.10 baseline, Google-style docstrings.
|
|
11
|
+
|
|
12
|
+
Path anchors: this module ships at ``<scripts>/forge_session/doctor.py`` while the
|
|
13
|
+
CLI shim (``forge-session.py``) and its ``forge-root.sh`` resolver sit one level up
|
|
14
|
+
in ``<scripts>/``, and the bundle root is one level above that. The legacy report
|
|
15
|
+
resolved those anchors from ``forge-session.py``'s own location, so they are
|
|
16
|
+
recomputed here relative to this module to keep doctor's output byte-identical.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shlex
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path, PurePosixPath
|
|
29
|
+
from typing import Callable, Final, NamedTuple
|
|
30
|
+
|
|
31
|
+
from forge_session._common import (
|
|
32
|
+
_config_duplicate_keys,
|
|
33
|
+
_counts,
|
|
34
|
+
_default_branch,
|
|
35
|
+
_default_schema_path,
|
|
36
|
+
_git_output,
|
|
37
|
+
_load_config,
|
|
38
|
+
load_effective_config,
|
|
39
|
+
_loop_runner_defaults,
|
|
40
|
+
resolve_loop_runner_layers,
|
|
41
|
+
_local_config_path,
|
|
42
|
+
build_rows,
|
|
43
|
+
invalid_auto_verify_keys,
|
|
44
|
+
load_json_with_duplicates,
|
|
45
|
+
)
|
|
46
|
+
from forge_session._doctor_util import (
|
|
47
|
+
CHECK_SEVERITIES,
|
|
48
|
+
CHECK_STATUSES,
|
|
49
|
+
REMEDY_SAFETY_TIERS,
|
|
50
|
+
_INSTALLED_BY_RE,
|
|
51
|
+
_JSON_SCHEMA_TYPES,
|
|
52
|
+
_PROBE_OUTPUT_CAP,
|
|
53
|
+
_SEMVER_NUM,
|
|
54
|
+
_SEMVER_RE,
|
|
55
|
+
_check_record,
|
|
56
|
+
_exc_text,
|
|
57
|
+
_first_backticked,
|
|
58
|
+
_fmt_semver,
|
|
59
|
+
_head,
|
|
60
|
+
_parse_installed_by,
|
|
61
|
+
_parse_semver,
|
|
62
|
+
_remedy,
|
|
63
|
+
_result,
|
|
64
|
+
_schema_violations,
|
|
65
|
+
_stage_at_or_after,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
#: This module lives at ``<scripts>/forge_session/doctor.py``; ``_SCRIPTS_DIR`` is
|
|
69
|
+
#: ``<scripts>/`` (where the CLI shim + ``forge-root.sh`` live), ``_SHIM_PATH`` is
|
|
70
|
+
#: the CLI entry point an operator remedy shells out to, and ``_BUNDLE_ROOT`` is the
|
|
71
|
+
#: install root the plugin resolver compares against. Recomputed from this module's
|
|
72
|
+
#: location so every legacy ``Path(__file__)`` anchor keeps its original target.
|
|
73
|
+
_SCRIPTS_DIR: Final = Path(__file__).resolve().parent.parent
|
|
74
|
+
_SHIM_PATH: Final = _SCRIPTS_DIR / "forge-session.py"
|
|
75
|
+
_BUNDLE_ROOT: Final = _SCRIPTS_DIR.parent
|
|
76
|
+
|
|
77
|
+
#: The runner version that first supports recovery-answer injection — the apply
|
|
78
|
+
#: surface doctor reports. Distinct from ``loopRunner.minRunnerVersion`` (the launch
|
|
79
|
+
#: floor): this one only DEGRADES the reported surface, never hard-stops.
|
|
80
|
+
RECOVERY_MIN_RUNNER_VERSION: Final[str] = "0.14.0"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve_plugin_root() -> dict:
|
|
84
|
+
"""Resolve the plugin root by running the sibling ``forge-root.sh``.
|
|
85
|
+
|
|
86
|
+
Uses the resolver that ships next to this script, so the answer reflects
|
|
87
|
+
the install this helper actually belongs to — exactly what a skill's
|
|
88
|
+
bootstrap prelude would find (or fail to find). On success the dict also
|
|
89
|
+
carries the root's ``version`` (from ``.claude-plugin/plugin.json`` or the
|
|
90
|
+
neutral ``.feature-forge-bundle.json``) and, when the root is a git
|
|
91
|
+
checkout, its short ``commit`` — enough to spot version skew between the
|
|
92
|
+
resolved root and the skills a session loaded.
|
|
93
|
+
"""
|
|
94
|
+
resolver = _SCRIPTS_DIR / "forge-root.sh"
|
|
95
|
+
if not resolver.is_file():
|
|
96
|
+
return {"resolved": False, "error": f"resolver not found: {resolver}"}
|
|
97
|
+
try:
|
|
98
|
+
proc = subprocess.run(
|
|
99
|
+
["bash", str(resolver), "--explain"], capture_output=True, text=True, timeout=10,
|
|
100
|
+
)
|
|
101
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
102
|
+
return {"resolved": False, "error": str(exc)}
|
|
103
|
+
if proc.returncode != 0:
|
|
104
|
+
return {
|
|
105
|
+
"resolved": False,
|
|
106
|
+
"error": proc.stderr.strip() or f"resolver exited {proc.returncode}",
|
|
107
|
+
}
|
|
108
|
+
# --explain prints `channel<TAB>path`; a resolver predating it prints the bare path (#323).
|
|
109
|
+
channel, _, path = proc.stdout.strip().partition("\t")
|
|
110
|
+
if not path:
|
|
111
|
+
channel, path = None, channel
|
|
112
|
+
root = path
|
|
113
|
+
info: dict = {"resolved": True, "root": root, "channel": channel or None}
|
|
114
|
+
bundle = _bundle_version(Path(root))
|
|
115
|
+
if bundle["version"] is not None:
|
|
116
|
+
info["version"] = bundle["version"]
|
|
117
|
+
if bundle["manifest"] is not None:
|
|
118
|
+
info["manifest"] = bundle["manifest"]
|
|
119
|
+
commit = _git_output(["-C", root, "rev-parse", "--short", "HEAD"])
|
|
120
|
+
if commit:
|
|
121
|
+
info["commit"] = commit
|
|
122
|
+
return info
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolve_all_roots() -> list[dict]:
|
|
126
|
+
"""Every candidate root the resolver probed, via ``forge-root.sh --explain --all`` (#323).
|
|
127
|
+
|
|
128
|
+
Each entry is ``{"channel", "root", "version"}``; a ``(degraded)`` channel suffix marks a
|
|
129
|
+
sentinel-bearing but asset-incomplete candidate. Returns ``[]`` when the resolver is absent,
|
|
130
|
+
predates ``--explain --all``, or errors — this is diagnostic enrichment, never a failure path.
|
|
131
|
+
"""
|
|
132
|
+
resolver = _SCRIPTS_DIR / "forge-root.sh"
|
|
133
|
+
if not resolver.is_file():
|
|
134
|
+
return []
|
|
135
|
+
try:
|
|
136
|
+
proc = subprocess.run(
|
|
137
|
+
["bash", str(resolver), "--explain", "--all"],
|
|
138
|
+
capture_output=True, text=True, timeout=10,
|
|
139
|
+
)
|
|
140
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
141
|
+
return []
|
|
142
|
+
roots: list[dict] = []
|
|
143
|
+
seen: set[str] = set()
|
|
144
|
+
for line in proc.stdout.splitlines():
|
|
145
|
+
channel, _, path = line.partition("\t")
|
|
146
|
+
if not path:
|
|
147
|
+
continue # a resolver predating --explain --all printed a bare path; skip it
|
|
148
|
+
# A single install reachable by more than one step (e.g. FEATURE_FORGE_ROOT == the
|
|
149
|
+
# self-located root) is emitted once per matching step; keep only the first (highest-
|
|
150
|
+
# precedence channel) so the "which install is live" list has one row per path.
|
|
151
|
+
if path in seen:
|
|
152
|
+
continue
|
|
153
|
+
seen.add(path)
|
|
154
|
+
roots.append(
|
|
155
|
+
{"channel": channel, "root": path, "version": _bundle_version(Path(path))["version"]}
|
|
156
|
+
)
|
|
157
|
+
return roots
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _bundle_version(root: Path) -> dict:
|
|
161
|
+
"""Read a candidate root's bundle manifest for its declared version.
|
|
162
|
+
|
|
163
|
+
Probes ``.claude-plugin/plugin.json`` then the neutral
|
|
164
|
+
``.feature-forge-bundle.json`` (the same two sentinels ``forge-root.sh``
|
|
165
|
+
accepts) and returns ``{"version", "manifest"}`` — either may be ``None``
|
|
166
|
+
when no manifest exists or it declares no string version.
|
|
167
|
+
"""
|
|
168
|
+
for rel in (".claude-plugin/plugin.json", ".feature-forge-bundle.json"):
|
|
169
|
+
manifest = root / rel
|
|
170
|
+
if manifest.is_file():
|
|
171
|
+
version = _load_config(manifest).get("version")
|
|
172
|
+
return {
|
|
173
|
+
"version": version if isinstance(version, str) else None,
|
|
174
|
+
"manifest": rel,
|
|
175
|
+
}
|
|
176
|
+
return {"version": None, "manifest": None}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _backlog_path(config: dict, name: str, epic: str | None, specs_dir: Path) -> Path:
|
|
180
|
+
"""Compose a feature's backlog.json path per the forge-4-backlog rule.
|
|
181
|
+
|
|
182
|
+
``{backlogDir}/{feature}/backlog.json`` when ``backlogDir`` is configured,
|
|
183
|
+
else ``{resolvedFeatureDir}/backlog.json`` (flat or nested under the epic).
|
|
184
|
+
"""
|
|
185
|
+
backlog_dir = config.get("backlogDir")
|
|
186
|
+
if isinstance(backlog_dir, str) and backlog_dir:
|
|
187
|
+
return Path(backlog_dir) / name / "backlog.json"
|
|
188
|
+
feature_dir = specs_dir / epic / name if epic else specs_dir / name
|
|
189
|
+
return feature_dir / "backlog.json"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def doctor_report(
|
|
193
|
+
specs_dir: Path,
|
|
194
|
+
config_path: Path,
|
|
195
|
+
*,
|
|
196
|
+
schema_path: Path | None = None,
|
|
197
|
+
only: frozenset[str] | None = None,
|
|
198
|
+
) -> dict:
|
|
199
|
+
"""Assemble the ground-truth diagnostic payload (always succeeds).
|
|
200
|
+
|
|
201
|
+
One snapshot of everything a confused session needs checked: resolved
|
|
202
|
+
plugin root + version/commit, current git branch vs. each feature's
|
|
203
|
+
recorded state branch, the recency-ranked feature summary, and whether
|
|
204
|
+
each feature's composed backlog path exists on disk.
|
|
205
|
+
|
|
206
|
+
The eleven legacy keys come first and are unchanged; after them the
|
|
207
|
+
structured ``checks[]`` (one record per ``DOCTOR_CHECKS`` entry, or the
|
|
208
|
+
``only`` subset), their ``checksSummary`` counts, and the ``remedyClusters``
|
|
209
|
+
grouped by remedy command. A crash anywhere in the check driver degrades
|
|
210
|
+
every requested check to ``na`` — the legacy payload is never lost.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
specs_dir: The specs directory to scan.
|
|
214
|
+
config_path: Path to ``forge.config.json``.
|
|
215
|
+
schema_path: Override for the bundled ``forge-config-schema.json``
|
|
216
|
+
(defaults to the sibling ``references/`` copy).
|
|
217
|
+
only: When given, run just these check ids (others are omitted).
|
|
218
|
+
"""
|
|
219
|
+
config = load_effective_config(config_path)
|
|
220
|
+
# --show-current (not rev-parse HEAD) so an unborn branch (fresh repo,
|
|
221
|
+
# no commits yet) still reports its name instead of failing.
|
|
222
|
+
current_branch = _git_output(["branch", "--show-current"])
|
|
223
|
+
default_branch = _default_branch()
|
|
224
|
+
specs_error: str | None = None
|
|
225
|
+
try:
|
|
226
|
+
rows = build_rows(specs_dir, config)
|
|
227
|
+
counts = _counts(specs_dir)
|
|
228
|
+
except (OSError, ValueError, RecursionError) as exc: # unlistable dir, absurd nesting
|
|
229
|
+
rows, counts = [], {"active": 0, "paused": 0, "abandoned": 0}
|
|
230
|
+
specs_error = _exc_text(exc)
|
|
231
|
+
features = []
|
|
232
|
+
for row in rows:
|
|
233
|
+
backlog = _backlog_path(config, row["name"], row["epic"], specs_dir)
|
|
234
|
+
state_branch = row["branch"]
|
|
235
|
+
mismatch = bool(state_branch and current_branch and state_branch != current_branch)
|
|
236
|
+
# Classify a mismatch: on a topic branch it is adoptable (imposed/session-branch
|
|
237
|
+
# drift, Chunk 6); on the default branch it is real drift-back, only a warning.
|
|
238
|
+
branch_reconcile = None
|
|
239
|
+
if mismatch:
|
|
240
|
+
branch_reconcile = "warn-drift" if current_branch == default_branch else "adopt-current"
|
|
241
|
+
features.append({
|
|
242
|
+
"name": row["name"],
|
|
243
|
+
"epic": row["epic"],
|
|
244
|
+
"currentStage": row["currentStage"],
|
|
245
|
+
"nextStage": row["nextStage"],
|
|
246
|
+
"verifyState": row["verifyState"],
|
|
247
|
+
"stateBranch": state_branch,
|
|
248
|
+
"branchMatchesState": (
|
|
249
|
+
state_branch == current_branch
|
|
250
|
+
if state_branch and current_branch
|
|
251
|
+
else None
|
|
252
|
+
),
|
|
253
|
+
"branchReconcile": branch_reconcile,
|
|
254
|
+
"backlogPath": str(backlog),
|
|
255
|
+
"backlogExists": backlog.is_file(),
|
|
256
|
+
})
|
|
257
|
+
plugin_root = _resolve_plugin_root()
|
|
258
|
+
report = {
|
|
259
|
+
"pluginRoot": plugin_root,
|
|
260
|
+
"currentBranch": current_branch,
|
|
261
|
+
"specsDir": str(specs_dir),
|
|
262
|
+
"specsDirExists": specs_dir.is_dir(),
|
|
263
|
+
"configPath": str(config_path),
|
|
264
|
+
"configExists": config_path.is_file(),
|
|
265
|
+
"counts": counts,
|
|
266
|
+
"features": features,
|
|
267
|
+
"invalidAutoVerifyKeys": invalid_auto_verify_keys(config),
|
|
268
|
+
"duplicateConfigKeys": _config_duplicate_keys(config_path),
|
|
269
|
+
"rootSandbox": _root_sandbox_status(),
|
|
270
|
+
}
|
|
271
|
+
specs = [spec for spec in DOCTOR_CHECKS if only is None or spec.id in only]
|
|
272
|
+
try:
|
|
273
|
+
ctx = _build_check_context(
|
|
274
|
+
specs_dir,
|
|
275
|
+
config_path,
|
|
276
|
+
schema_path or _default_schema_path(),
|
|
277
|
+
config=config,
|
|
278
|
+
current_branch=current_branch,
|
|
279
|
+
default_branch=default_branch,
|
|
280
|
+
rows=rows,
|
|
281
|
+
features=features,
|
|
282
|
+
plugin_root=plugin_root,
|
|
283
|
+
)
|
|
284
|
+
checks = _run_checks(ctx, specs)
|
|
285
|
+
except Exception as exc: # INV-3: doctor never crashes
|
|
286
|
+
checks = [
|
|
287
|
+
_check_record(
|
|
288
|
+
spec.id, "na", spec.severity, f"check driver crashed: {_exc_text(exc)}",
|
|
289
|
+
)
|
|
290
|
+
for spec in specs
|
|
291
|
+
]
|
|
292
|
+
if specs_error is not None: # only on the failure path: happy-path keys unchanged
|
|
293
|
+
report["specsDirError"] = specs_error
|
|
294
|
+
report["checks"] = checks
|
|
295
|
+
report["checksSummary"] = _checks_summary(checks)
|
|
296
|
+
report["remedyClusters"] = cluster_checks(checks)
|
|
297
|
+
return report
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _root_sandbox_status() -> dict:
|
|
301
|
+
"""Report the root/sandbox launch condition for forge-5-loop (issue #99).
|
|
302
|
+
|
|
303
|
+
On a hosted remote (e.g. Claude.ai) the loop runs as root, where rauf's
|
|
304
|
+
``claude --dangerously-skip-permissions`` is refused unless ``IS_SANDBOX``
|
|
305
|
+
is set. forge-5-loop exports ``IS_SANDBOX=${IS_SANDBOX:-1}`` at launch when
|
|
306
|
+
root; this surfaces the same condition as a diagnosable check. ``geteuid``
|
|
307
|
+
is absent on Windows — treat that as non-root.
|
|
308
|
+
"""
|
|
309
|
+
geteuid = getattr(os, "geteuid", None)
|
|
310
|
+
is_root = geteuid() == 0 if geteuid is not None else False
|
|
311
|
+
is_sandbox_set = os.environ.get("IS_SANDBOX") not in (None, "")
|
|
312
|
+
return {
|
|
313
|
+
"isRoot": is_root,
|
|
314
|
+
"isSandboxSet": is_sandbox_set,
|
|
315
|
+
# True only when the loop would need to supply the default at launch.
|
|
316
|
+
"loopWillSetSandbox": is_root and not is_sandbox_set,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# --------------------------------------------------------------------------- #
|
|
321
|
+
# Doctor checks (roadmap/self-healing-resilience.md §5; catalog: docs/doctor-checks.md)
|
|
322
|
+
# --------------------------------------------------------------------------- #
|
|
323
|
+
#
|
|
324
|
+
# Each check is a pure function ``(ctx) -> dict`` registered in ``DOCTOR_CHECKS``.
|
|
325
|
+
# The driver stamps ``id``/``severity``, isolates crashes (a raising check becomes
|
|
326
|
+
# ``na`` with the exception in ``detail`` — it never takes doctor down), demotes any
|
|
327
|
+
# ``fail`` from a check outside ``FAIL_PROMOTED_CHECK_IDS`` to ``warn`` (INV-1:
|
|
328
|
+
# warn-only until the promotion pass) and clusters remedies by command. Doctor
|
|
329
|
+
# still always exits 0 (INV-3); remedies are DATA (INV-4) — doctor never runs one.
|
|
330
|
+
#
|
|
331
|
+
# Subprocess allowlist — the only commands a check may spawn, all read-only:
|
|
332
|
+
# * ``git`` queries and ``bash forge-root.sh`` (already part of the legacy report),
|
|
333
|
+
# * ``{bin} version --json`` (once, memoised on the context),
|
|
334
|
+
# * ``{bin} backlog validate …`` (at most once per distinct backlog dir),
|
|
335
|
+
# * ``gh --version`` and ``gh auth token`` (stdout discarded, never logged).
|
|
336
|
+
# Never ``agentsProbeCommand``, ``gh auth status``, ``rauf update --check``, nor any
|
|
337
|
+
# ``remedy.command``.
|
|
338
|
+
|
|
339
|
+
#: Check ids allowed to emit ``fail``. Empty until the promotion pass; the driver
|
|
340
|
+
#: demotes every other ``fail`` to ``warn`` so no check can block by accident.
|
|
341
|
+
FAIL_PROMOTED_CHECK_IDS: Final[frozenset[str]] = frozenset()
|
|
342
|
+
#: Check ids whose result is never ``na`` — they hold in every environment.
|
|
343
|
+
NO_NA_CHECKS: Final[frozenset[str]] = frozenset({"plugin-root", "gh-available"})
|
|
344
|
+
_CHECK_ID_RE: Final = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
|
|
345
|
+
#: Wall-clock cap per probe; a stuck runner degrades to ``warn``, never a hang.
|
|
346
|
+
_PROBE_TIMEOUT_S: Final[int] = 10
|
|
347
|
+
|
|
348
|
+
#: The launcher-stamped interaction contract (#244 P3.5, roadmap D2a). A launcher
|
|
349
|
+
#: that KNOWS the session it is spawning has no reply channel states it here;
|
|
350
|
+
#: feature-forge only ever READS this variable, never sets or exports it. Values
|
|
351
|
+
#: are ``INTERACTION_MODES``; anything else is reported as ``unknown`` with the
|
|
352
|
+
#: raw value as evidence. Named without ``KEY``/``SECRET``/``TOKEN`` so no host's
|
|
353
|
+
#: environment policy strips it before the session sees it (verified on Codex).
|
|
354
|
+
INTERACTION_ENV_VAR: Final[str] = "FORGE_INTERACTION"
|
|
355
|
+
#: Every adapter id ``scripts/build-adapters.py`` emits; the bundle sentinel's
|
|
356
|
+
#: ``agent`` must be one of these to be trusted. Mirrored (not imported — this
|
|
357
|
+
#: file is copied verbatim into each bundle) and pinned by a parity test.
|
|
358
|
+
_ADAPTER_AGENT_IDS: Final[frozenset[str]] = frozenset(
|
|
359
|
+
{"claude", "codex", "copilot", "cursor", "gemini", "pi"}
|
|
360
|
+
)
|
|
361
|
+
INTERACTION_MODES: Final[tuple[str, ...]] = ("interactive", "non-interactive")
|
|
362
|
+
#: Harness binaries whose argv in process ancestry is a VERIFIED mode signal.
|
|
363
|
+
#: Deliberately excludes Codex: its shell tool executes under a long-lived,
|
|
364
|
+
#: init-parented ``app-server`` daemon that predates the session, so the nearest
|
|
365
|
+
#: ``codex`` ancestor describes the DAEMON's launch mode, not this session's
|
|
366
|
+
#: (#261 spike). Inferring from it would confidently report the wrong mode — the
|
|
367
|
+
#: one failure the ``unknown``-never-``non-interactive`` rule exists to prevent.
|
|
368
|
+
_ANCESTRY_MODE_HARNESSES: Final[frozenset[str]] = frozenset({"claude", "pi"})
|
|
369
|
+
#: Harness binaries recognised in ancestry for the HOST axis only. A ``codex``
|
|
370
|
+
#: ancestor does identify the host (the daemon is Codex's); only its mode is
|
|
371
|
+
#: unreadable. ``cursor-agent`` is the binary; ``cursor`` is the adapter id.
|
|
372
|
+
_ANCESTRY_HOSTS: Final[tuple[tuple[str, str], ...]] = (
|
|
373
|
+
("claude", "claude"), ("pi", "pi"), ("codex", "codex"),
|
|
374
|
+
("cursor-agent", "cursor"), ("copilot", "copilot"), ("gemini", "gemini"),
|
|
375
|
+
)
|
|
376
|
+
#: argv flags that put a recognised harness into print/headless mode.
|
|
377
|
+
_HEADLESS_FLAGS: Final[frozenset[str]] = frozenset({"-p", "--print"})
|
|
378
|
+
#: argv flags that RESTORE a reply channel to an otherwise-headless invocation.
|
|
379
|
+
#: Claude's SDK streaming mode (``--print --input-format stream-json``) is
|
|
380
|
+
#: bidirectional and its host CAN answer, so ``--print`` alone does not prove
|
|
381
|
+
#: "nobody is there". Presence of any of these withdraws the headless claim.
|
|
382
|
+
_REPLY_CHANNEL_FLAGS: Final[frozenset[str]] = frozenset(
|
|
383
|
+
{"--input-format", "--permission-prompt-tool"}
|
|
384
|
+
)
|
|
385
|
+
#: Depth cap on the ancestry walk — a guard, not a tuning knob.
|
|
386
|
+
_ANCESTRY_MAX_DEPTH: Final[int] = 16
|
|
387
|
+
#: A plausible executable basename. Anything else is redacted to ``?`` before it
|
|
388
|
+
#: reaches evidence: a process whose ``argv[0]`` is descriptive text rather than
|
|
389
|
+
#: a path (``sshd: gary@pts/6``, kernel threads) can carry a username, and every
|
|
390
|
+
#: evidence field lands in ``doctor --json`` output that gets pasted into issues.
|
|
391
|
+
_EXEC_NAME_RE: Final = re.compile(r"^[A-Za-z0-9._+-]{1,64}$")
|
|
392
|
+
#: Adapter ids that keep their own ``--host`` value; every other id is generic.
|
|
393
|
+
_NAMED_HOST_ARGS: Final[frozenset[str]] = frozenset({"claude", "pi"})
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _run_probe(
|
|
397
|
+
argv: list[str], *, cwd: Path | None = None, discard_stdout: bool = False,
|
|
398
|
+
) -> dict:
|
|
399
|
+
"""Run one allowlisted read-only probe with a timeout; never raises.
|
|
400
|
+
|
|
401
|
+
Returns a dict ``{ok, returncode, stdout, stderr, error, timedOut}``.
|
|
402
|
+
``discard_stdout`` sends the child's stdout to ``/dev/null`` so it never
|
|
403
|
+
enters this process — used for ``gh auth token``, whose output is a
|
|
404
|
+
credential.
|
|
405
|
+
"""
|
|
406
|
+
result: dict = {
|
|
407
|
+
"ok": False, "returncode": None, "stdout": "", "stderr": "",
|
|
408
|
+
"error": None, "timedOut": False,
|
|
409
|
+
}
|
|
410
|
+
try:
|
|
411
|
+
proc = subprocess.run(
|
|
412
|
+
argv,
|
|
413
|
+
stdout=subprocess.DEVNULL if discard_stdout else subprocess.PIPE,
|
|
414
|
+
stderr=subprocess.PIPE,
|
|
415
|
+
text=True,
|
|
416
|
+
timeout=_PROBE_TIMEOUT_S,
|
|
417
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
418
|
+
)
|
|
419
|
+
except subprocess.TimeoutExpired:
|
|
420
|
+
result["timedOut"] = True
|
|
421
|
+
result["error"] = f"timed out after {_PROBE_TIMEOUT_S}s"
|
|
422
|
+
return result
|
|
423
|
+
except (OSError, ValueError) as exc:
|
|
424
|
+
result["error"] = str(exc)
|
|
425
|
+
return result
|
|
426
|
+
result["ok"] = proc.returncode == 0
|
|
427
|
+
result["returncode"] = proc.returncode
|
|
428
|
+
result["stdout"] = proc.stdout or ""
|
|
429
|
+
result["stderr"] = proc.stderr
|
|
430
|
+
return result
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _render_runner_command(template: str, loop_runner: dict, **tokens: object) -> list[str] | None:
|
|
434
|
+
"""Render a ``loopRunner`` command template into argv (``{bin}`` + ``tokens``).
|
|
435
|
+
|
|
436
|
+
Every substituted value is ``shlex.quote``d so a path with spaces survives
|
|
437
|
+
the split. Returns ``None`` when the template does not start with ``{bin}``
|
|
438
|
+
(doctor only ever invokes the configured runner binary — a template whose
|
|
439
|
+
first word is anything else is reported as data, never run), when a
|
|
440
|
+
``{token}`` remains unrendered, or when the result does not split.
|
|
441
|
+
"""
|
|
442
|
+
if not template.lstrip().startswith("{bin}"):
|
|
443
|
+
return None
|
|
444
|
+
values: dict[str, object] = {"bin": loop_runner.get("bin") or "", **tokens}
|
|
445
|
+
unrendered = False
|
|
446
|
+
|
|
447
|
+
def substitute(match: re.Match) -> str:
|
|
448
|
+
nonlocal unrendered
|
|
449
|
+
key = match.group(1)
|
|
450
|
+
if key not in values:
|
|
451
|
+
unrendered = True
|
|
452
|
+
return match.group(0)
|
|
453
|
+
return shlex.quote(str(values[key]))
|
|
454
|
+
|
|
455
|
+
# One pass over the template: a substituted value is opaque and is never
|
|
456
|
+
# re-scanned for a later token (``bin = "run-{backlogDir}"`` stays literal).
|
|
457
|
+
rendered = re.sub(r"\{([A-Za-z][A-Za-z0-9_]*)\}", substitute, template)
|
|
458
|
+
if unrendered:
|
|
459
|
+
return None
|
|
460
|
+
try:
|
|
461
|
+
argv = shlex.split(rendered)
|
|
462
|
+
except ValueError:
|
|
463
|
+
return None
|
|
464
|
+
# argv[0] must be the configured runner binary *exactly*: ``{bin}5.34`` or
|
|
465
|
+
# ``{bin}/../x`` would otherwise swap in a different executable.
|
|
466
|
+
if not argv or argv[0] != str(values["bin"]):
|
|
467
|
+
return None
|
|
468
|
+
return argv
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class _CheckContext:
|
|
472
|
+
"""Everything a check may read. Probes are memoised so each runs at most once.
|
|
473
|
+
|
|
474
|
+
A plain class (not a dataclass): the test suite imports this file through
|
|
475
|
+
``importlib`` without registering it in ``sys.modules``, which breaks
|
|
476
|
+
dataclass annotation resolution under ``from __future__ import annotations``.
|
|
477
|
+
"""
|
|
478
|
+
|
|
479
|
+
def __init__(
|
|
480
|
+
self,
|
|
481
|
+
*,
|
|
482
|
+
cwd: Path,
|
|
483
|
+
specs_dir: Path,
|
|
484
|
+
config_path: Path,
|
|
485
|
+
config_exists: bool,
|
|
486
|
+
config: dict,
|
|
487
|
+
schema_path: Path,
|
|
488
|
+
schema: dict | None,
|
|
489
|
+
schema_error: str | None,
|
|
490
|
+
loop_runner: dict | None,
|
|
491
|
+
loop_runner_defaults: dict | None,
|
|
492
|
+
loop_runner_layers: dict | None,
|
|
493
|
+
loop_runner_error: str | None,
|
|
494
|
+
plugin_root: dict,
|
|
495
|
+
current_branch: str | None,
|
|
496
|
+
default_branch: str | None,
|
|
497
|
+
rows: list,
|
|
498
|
+
features: list,
|
|
499
|
+
) -> None:
|
|
500
|
+
self.cwd = cwd
|
|
501
|
+
self.specs_dir = specs_dir
|
|
502
|
+
self.config_path = config_path
|
|
503
|
+
self.config_exists = config_exists
|
|
504
|
+
self.config = config
|
|
505
|
+
self.schema_path = schema_path
|
|
506
|
+
self.schema = schema
|
|
507
|
+
self.schema_error = schema_error
|
|
508
|
+
self.loop_runner = loop_runner
|
|
509
|
+
self.loop_runner_defaults = loop_runner_defaults
|
|
510
|
+
self.loop_runner_layers = loop_runner_layers
|
|
511
|
+
self.loop_runner_error = loop_runner_error
|
|
512
|
+
self.plugin_root = plugin_root
|
|
513
|
+
self.current_branch = current_branch
|
|
514
|
+
self.default_branch = default_branch
|
|
515
|
+
self.rows = rows
|
|
516
|
+
self.features = features
|
|
517
|
+
self._memo: dict = {}
|
|
518
|
+
|
|
519
|
+
def runner_bin(self) -> str:
|
|
520
|
+
"""The configured ``loopRunner.bin`` (``""`` when unavailable)."""
|
|
521
|
+
value = (self.loop_runner or {}).get("bin")
|
|
522
|
+
return value if isinstance(value, str) else ""
|
|
523
|
+
|
|
524
|
+
def runner_bin_layer(self) -> str | None:
|
|
525
|
+
"""Which layer set the effective ``bin`` (env/local/committed/default), or ``None``."""
|
|
526
|
+
return (self.loop_runner_layers or {}).get("bin")
|
|
527
|
+
|
|
528
|
+
def runner_bin_path(self) -> str | None:
|
|
529
|
+
"""``shutil.which`` of the runner binary, memoised."""
|
|
530
|
+
if "bin_path" not in self._memo:
|
|
531
|
+
name = self.runner_bin()
|
|
532
|
+
self._memo["bin_path"] = shutil.which(name) if name else None
|
|
533
|
+
return self._memo["bin_path"]
|
|
534
|
+
|
|
535
|
+
def runner_version_probe(self) -> dict | None:
|
|
536
|
+
"""Run ``versionCommand`` once; ``None`` when it cannot be rendered/run."""
|
|
537
|
+
if "version_probe" not in self._memo:
|
|
538
|
+
probe = None
|
|
539
|
+
if self.loop_runner and self.runner_bin_path():
|
|
540
|
+
template = self.loop_runner.get("versionCommand")
|
|
541
|
+
argv = (
|
|
542
|
+
_render_runner_command(template, self.loop_runner)
|
|
543
|
+
if isinstance(template, str) else None
|
|
544
|
+
)
|
|
545
|
+
if argv:
|
|
546
|
+
probe = _run_probe(argv, cwd=self.cwd)
|
|
547
|
+
self._memo["version_probe"] = probe
|
|
548
|
+
return self._memo["version_probe"]
|
|
549
|
+
|
|
550
|
+
def runner_version(self) -> tuple[int, int, int] | None:
|
|
551
|
+
"""The live runner's parsed semver from the version probe, or ``None``."""
|
|
552
|
+
probe = self.runner_version_probe()
|
|
553
|
+
if not probe or not probe["ok"]:
|
|
554
|
+
return None
|
|
555
|
+
try:
|
|
556
|
+
payload = json.loads(probe["stdout"])
|
|
557
|
+
except ValueError:
|
|
558
|
+
return None
|
|
559
|
+
return _parse_semver(payload.get("version")) if isinstance(payload, dict) else None
|
|
560
|
+
|
|
561
|
+
def precondition_path(self) -> Path | None:
|
|
562
|
+
"""``cwd / loopRunner.preconditionFile`` (e.g. ``.rauf.json``), or ``None``."""
|
|
563
|
+
name = (self.loop_runner or {}).get("preconditionFile")
|
|
564
|
+
return self.cwd / name if isinstance(name, str) and name else None
|
|
565
|
+
|
|
566
|
+
def precondition(self) -> dict | None:
|
|
567
|
+
"""The parsed precondition file: ``None`` when absent, ``{}`` when unreadable."""
|
|
568
|
+
if "precondition" not in self._memo:
|
|
569
|
+
path = self.precondition_path()
|
|
570
|
+
parsed: dict | None = None
|
|
571
|
+
if path is not None and path.is_file():
|
|
572
|
+
try:
|
|
573
|
+
value, _dupes = load_json_with_duplicates(path)
|
|
574
|
+
except (OSError, ValueError, RecursionError): # JSON *and* UTF-8 decode errors
|
|
575
|
+
value = {}
|
|
576
|
+
parsed = value if isinstance(value, dict) else {}
|
|
577
|
+
self._memo["precondition"] = parsed
|
|
578
|
+
return self._memo["precondition"]
|
|
579
|
+
|
|
580
|
+
def runner_relevant(self) -> bool:
|
|
581
|
+
"""True once any active feature's next stage is forge-4-backlog or later."""
|
|
582
|
+
return any(_stage_at_or_after(row["nextStage"], "forge-4-backlog") for row in self.rows)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _build_check_context(
|
|
586
|
+
specs_dir: Path,
|
|
587
|
+
config_path: Path,
|
|
588
|
+
schema_path: Path,
|
|
589
|
+
*,
|
|
590
|
+
config: dict,
|
|
591
|
+
current_branch: str | None,
|
|
592
|
+
default_branch: str | None,
|
|
593
|
+
rows: list,
|
|
594
|
+
features: list,
|
|
595
|
+
plugin_root: dict,
|
|
596
|
+
) -> _CheckContext:
|
|
597
|
+
"""Assemble the shared check context from values the legacy report computed.
|
|
598
|
+
|
|
599
|
+
The config schema and the resolved ``loopRunner`` block are loaded here
|
|
600
|
+
with every failure captured as data (``schema_error`` /
|
|
601
|
+
``loop_runner_error``) — ``_loop_runner_defaults`` raises ``UsageError``
|
|
602
|
+
on a damaged schema, which would otherwise map to exit 2.
|
|
603
|
+
"""
|
|
604
|
+
schema: dict | None = None
|
|
605
|
+
schema_error: str | None = None
|
|
606
|
+
try:
|
|
607
|
+
loaded = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
608
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
609
|
+
schema_error = _exc_text(exc)
|
|
610
|
+
else:
|
|
611
|
+
if isinstance(loaded, dict):
|
|
612
|
+
schema = loaded
|
|
613
|
+
else:
|
|
614
|
+
schema_error = "schema top level is not an object"
|
|
615
|
+
defaults: dict | None = None
|
|
616
|
+
loop_runner: dict | None = None
|
|
617
|
+
loop_runner_layers: dict | None = None
|
|
618
|
+
loop_runner_error: str | None = None
|
|
619
|
+
try:
|
|
620
|
+
defaults = dict(_loop_runner_defaults(schema_path))
|
|
621
|
+
except Exception as exc: # captured as data (INV-3)
|
|
622
|
+
loop_runner_error = _exc_text(exc)
|
|
623
|
+
else:
|
|
624
|
+
# Resolve with per-field provenance (#324) so the runner checks can report which
|
|
625
|
+
# layer — env / machine-local / committed / default — set the effective value.
|
|
626
|
+
# warn=False: doctor_report already loaded (and warned about) these files once.
|
|
627
|
+
layered = resolve_loop_runner_layers(config_path, schema_path, warn=False)
|
|
628
|
+
loop_runner = {field: info["value"] for field, info in layered.items()}
|
|
629
|
+
loop_runner_layers = {field: info["layer"] for field, info in layered.items()}
|
|
630
|
+
return _CheckContext(
|
|
631
|
+
cwd=Path.cwd(),
|
|
632
|
+
specs_dir=specs_dir,
|
|
633
|
+
config_path=config_path,
|
|
634
|
+
config_exists=config_path.is_file(),
|
|
635
|
+
config=config,
|
|
636
|
+
schema_path=schema_path,
|
|
637
|
+
schema=schema,
|
|
638
|
+
schema_error=schema_error,
|
|
639
|
+
loop_runner=loop_runner,
|
|
640
|
+
loop_runner_defaults=defaults,
|
|
641
|
+
loop_runner_layers=loop_runner_layers,
|
|
642
|
+
loop_runner_error=loop_runner_error,
|
|
643
|
+
plugin_root=plugin_root,
|
|
644
|
+
current_branch=current_branch,
|
|
645
|
+
default_branch=default_branch,
|
|
646
|
+
rows=rows,
|
|
647
|
+
features=features,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
class _CheckSpec(NamedTuple):
|
|
652
|
+
"""One registry entry: a stable id, its severity, and the check function."""
|
|
653
|
+
|
|
654
|
+
id: str
|
|
655
|
+
severity: str
|
|
656
|
+
run: Callable[[_CheckContext], dict]
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _make_spec(check_id: str, severity: str, run: Callable[[_CheckContext], dict]) -> _CheckSpec:
|
|
660
|
+
"""Validate and build a ``_CheckSpec`` (ids are kebab-case; severity enumerated)."""
|
|
661
|
+
if not _CHECK_ID_RE.match(check_id):
|
|
662
|
+
raise ValueError(f"invalid check id: {check_id!r}")
|
|
663
|
+
if severity not in CHECK_SEVERITIES:
|
|
664
|
+
raise ValueError(f"invalid severity for {check_id}: {severity!r}")
|
|
665
|
+
return _CheckSpec(check_id, severity, run)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _per_feature_remedy(rows: list[dict]) -> dict | None:
|
|
669
|
+
"""Top-level remedy for a per-feature check from its ``features[]`` rows.
|
|
670
|
+
|
|
671
|
+
The rows' remedy when every affected row agrees (typically one affected
|
|
672
|
+
feature), else a "per-feature" pointer carrying the most conservative tier
|
|
673
|
+
so a consumer never runs one feature's command for another.
|
|
674
|
+
"""
|
|
675
|
+
remedies = [row["remedy"] for row in rows if row.get("remedy")]
|
|
676
|
+
if not remedies:
|
|
677
|
+
return None
|
|
678
|
+
if len({json.dumps(r, sort_keys=True) for r in remedies}) == 1:
|
|
679
|
+
return dict(remedies[0])
|
|
680
|
+
tier = max(remedies, key=lambda r: REMEDY_SAFETY_TIERS.index(r["safety"]))["safety"]
|
|
681
|
+
return _remedy("per-feature — see evidence.features[].remedy", None, tier)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _feature_label(feat: dict) -> str:
|
|
685
|
+
"""``name`` or ``name [epic]`` for detail strings."""
|
|
686
|
+
return feat["name"] + (f" [{feat['epic']}]" if feat.get("epic") else "")
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _check_plugin_root(ctx: _CheckContext) -> dict:
|
|
690
|
+
"""The sibling ``forge-root.sh`` resolves an install root (never ``na``)."""
|
|
691
|
+
root = ctx.plugin_root
|
|
692
|
+
evidence = dict(root)
|
|
693
|
+
if root.get("resolved"):
|
|
694
|
+
version = root.get("version")
|
|
695
|
+
channel = root.get("channel")
|
|
696
|
+
suffix = f" (version {version})" if version else " (no version manifest)"
|
|
697
|
+
chan = f" via {channel}" if channel else ""
|
|
698
|
+
return _result("ok", f"resolved {root.get('root')}{chan}{suffix}", evidence)
|
|
699
|
+
return _result(
|
|
700
|
+
"warn",
|
|
701
|
+
f"plugin root unresolved: {root.get('error', 'unknown')}",
|
|
702
|
+
evidence,
|
|
703
|
+
_remedy(
|
|
704
|
+
"Reinstall feature-forge for this host (setup guide: "
|
|
705
|
+
"https://raw.githubusercontent.com/garygentry/feature-forge/main/AGENTS-SETUP.md) "
|
|
706
|
+
"or set FEATURE_FORGE_ROOT to the bundle directory",
|
|
707
|
+
None,
|
|
708
|
+
"global-install",
|
|
709
|
+
),
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _runner_unavailable(ctx: _CheckContext) -> dict | None:
|
|
714
|
+
"""The shared ``na`` result when the resolved ``loopRunner`` block is missing."""
|
|
715
|
+
if ctx.loop_runner is None:
|
|
716
|
+
return _result(
|
|
717
|
+
"na", f"loopRunner config unavailable: {ctx.loop_runner_error}",
|
|
718
|
+
{"schemaPath": str(ctx.schema_path), "error": ctx.loop_runner_error},
|
|
719
|
+
)
|
|
720
|
+
return None
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
_NETWORK_FETCH_RE: Final = re.compile(
|
|
724
|
+
r"\b(npx|npm|pnpm|yarn|pip|pipx|cargo|brew|apt(-get)?|curl|wget)\b|https?://"
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _install_remedy(ctx: _CheckContext) -> dict:
|
|
729
|
+
"""The schema's ``installHint`` as a remedy (command = its first backticked span).
|
|
730
|
+
|
|
731
|
+
Tier is the most conservative that applies: ``network`` when the hint
|
|
732
|
+
fetches from a registry or URL (the default ``npx …`` hint does), else
|
|
733
|
+
``global-install``.
|
|
734
|
+
"""
|
|
735
|
+
hint = (ctx.loop_runner or {}).get("installHint")
|
|
736
|
+
description = hint if isinstance(hint, str) and hint else "Install the loop runner"
|
|
737
|
+
tier = "network" if _NETWORK_FETCH_RE.search(description) else "global-install"
|
|
738
|
+
return _remedy(description, _first_backticked(hint), tier)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _check_runner_binary(ctx: _CheckContext) -> dict:
|
|
742
|
+
"""``loopRunner.bin`` resolves on PATH.
|
|
743
|
+
|
|
744
|
+
A customised ``bin`` that is missing while the schema default *is* on PATH
|
|
745
|
+
points at the config, not the install: that remedy is a config edit
|
|
746
|
+
(``local-write``), never the install hint (G4).
|
|
747
|
+
"""
|
|
748
|
+
if (na := _runner_unavailable(ctx)) is not None:
|
|
749
|
+
return na
|
|
750
|
+
name = ctx.runner_bin()
|
|
751
|
+
default_bin = (ctx.loop_runner_defaults or {}).get("bin")
|
|
752
|
+
customized = name != default_bin
|
|
753
|
+
path = ctx.runner_bin_path()
|
|
754
|
+
default_path = (
|
|
755
|
+
shutil.which(default_bin) if customized and isinstance(default_bin, str) else None
|
|
756
|
+
)
|
|
757
|
+
evidence = {
|
|
758
|
+
"bin": name,
|
|
759
|
+
"layer": ctx.runner_bin_layer(),
|
|
760
|
+
"path": path,
|
|
761
|
+
"defaultBin": default_bin,
|
|
762
|
+
"customized": customized,
|
|
763
|
+
"defaultOnPath": default_path,
|
|
764
|
+
"pathEntries": len([p for p in os.environ.get("PATH", "").split(os.pathsep) if p]),
|
|
765
|
+
}
|
|
766
|
+
if path:
|
|
767
|
+
return _result("ok", f"'{name}' found at {path}", evidence)
|
|
768
|
+
if not name:
|
|
769
|
+
return _result(
|
|
770
|
+
"warn", "loopRunner.bin is empty — no runner binary to look for", evidence,
|
|
771
|
+
_remedy("Set loopRunner.bin in forge.config.json to the runner binary", None,
|
|
772
|
+
"local-write"),
|
|
773
|
+
)
|
|
774
|
+
if customized and default_path:
|
|
775
|
+
return _result(
|
|
776
|
+
"warn",
|
|
777
|
+
f"configured runner '{name}' is not on PATH, but the default '{default_bin}' is "
|
|
778
|
+
f"({default_path})",
|
|
779
|
+
evidence,
|
|
780
|
+
_remedy(
|
|
781
|
+
f"Set loopRunner.bin back to '{default_bin}' in forge.config.json, or install "
|
|
782
|
+
f"'{name}' on PATH",
|
|
783
|
+
None,
|
|
784
|
+
"local-write",
|
|
785
|
+
),
|
|
786
|
+
)
|
|
787
|
+
if customized:
|
|
788
|
+
return _result(
|
|
789
|
+
"warn", f"configured runner '{name}' is not on PATH", evidence,
|
|
790
|
+
_remedy(
|
|
791
|
+
f"Install '{name}' on PATH, or point loopRunner.bin at a binary that is",
|
|
792
|
+
None, "global-install",
|
|
793
|
+
),
|
|
794
|
+
)
|
|
795
|
+
return _result("warn", f"runner '{name}' is not installed (not on PATH)", evidence,
|
|
796
|
+
_install_remedy(ctx))
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _check_runner_version(ctx: _CheckContext) -> dict:
|
|
800
|
+
"""The live runner's ``version --json`` meets ``minRunnerVersion``."""
|
|
801
|
+
if (na := _runner_unavailable(ctx)) is not None:
|
|
802
|
+
return na
|
|
803
|
+
if not ctx.runner_bin_path():
|
|
804
|
+
return _result("na", "runner binary not on PATH (see runner-binary)",
|
|
805
|
+
{"bin": ctx.runner_bin()})
|
|
806
|
+
required_text = ctx.loop_runner.get("minRunnerVersion")
|
|
807
|
+
template = ctx.loop_runner.get("versionCommand")
|
|
808
|
+
argv = _render_runner_command(template, ctx.loop_runner) if isinstance(template, str) else None
|
|
809
|
+
evidence: dict = {
|
|
810
|
+
"bin": ctx.runner_bin(),
|
|
811
|
+
"layer": ctx.runner_bin_layer(),
|
|
812
|
+
"command": shlex.join(argv) if argv else template,
|
|
813
|
+
"required": required_text,
|
|
814
|
+
"reported": None,
|
|
815
|
+
"exitCode": None,
|
|
816
|
+
"timedOut": False,
|
|
817
|
+
"stdoutHead": "",
|
|
818
|
+
"stderrHead": "",
|
|
819
|
+
"recoveryApplySurface": RECOVERY_MIN_RUNNER_VERSION,
|
|
820
|
+
}
|
|
821
|
+
if argv is None:
|
|
822
|
+
return _result("na", f"versionCommand cannot be rendered: {template!r}", evidence)
|
|
823
|
+
probe = ctx.runner_version_probe()
|
|
824
|
+
if probe is None:
|
|
825
|
+
return _result("na", "version probe did not run", evidence)
|
|
826
|
+
evidence.update(
|
|
827
|
+
exitCode=probe["returncode"], timedOut=probe["timedOut"],
|
|
828
|
+
stdoutHead=_head(probe["stdout"]), stderrHead=_head(probe["stderr"] or probe["error"]),
|
|
829
|
+
)
|
|
830
|
+
if not probe["ok"]:
|
|
831
|
+
why = probe["error"] or f"exit {probe['returncode']}"
|
|
832
|
+
return _result(
|
|
833
|
+
"warn", f"'{evidence['command']}' failed ({why})", evidence,
|
|
834
|
+
_remedy("Reinstall the loop runner so its version command runs", None,
|
|
835
|
+
"global-install"),
|
|
836
|
+
)
|
|
837
|
+
try:
|
|
838
|
+
payload = json.loads(probe["stdout"])
|
|
839
|
+
except ValueError:
|
|
840
|
+
payload = None
|
|
841
|
+
reported_text = payload.get("version") if isinstance(payload, dict) else None
|
|
842
|
+
evidence["reported"] = reported_text
|
|
843
|
+
# rauf >= 0.16.0 (#123) reports its own resolution provenance in `version --json`; surface it
|
|
844
|
+
# when present so doctor shows which runner binary/channel is live. Absent on older runners
|
|
845
|
+
# (the floor stays 0.14.0) ⇒ simply omitted, never a failure (#323).
|
|
846
|
+
if isinstance(payload, dict):
|
|
847
|
+
for key in ("channel", "path"):
|
|
848
|
+
value = payload.get(key)
|
|
849
|
+
if isinstance(value, str) and value:
|
|
850
|
+
evidence[f"runner{key.capitalize()}"] = value
|
|
851
|
+
reported = _parse_semver(reported_text)
|
|
852
|
+
if reported is None:
|
|
853
|
+
return _result(
|
|
854
|
+
"warn",
|
|
855
|
+
f"could not parse a semver from '{evidence['command']}' output "
|
|
856
|
+
f"(version={reported_text!r})",
|
|
857
|
+
evidence,
|
|
858
|
+
_install_remedy(ctx),
|
|
859
|
+
)
|
|
860
|
+
required = _parse_semver(required_text)
|
|
861
|
+
if required is None:
|
|
862
|
+
return _result(
|
|
863
|
+
"warn", f"minRunnerVersion {required_text!r} is not a plain semver", evidence,
|
|
864
|
+
_remedy("Fix loopRunner.minRunnerVersion in forge.config.json (X.Y.Z)", None,
|
|
865
|
+
"local-write"),
|
|
866
|
+
)
|
|
867
|
+
if reported < required:
|
|
868
|
+
return _result(
|
|
869
|
+
"warn",
|
|
870
|
+
f"runner {_fmt_semver(reported)} is below minRunnerVersion {_fmt_semver(required)}",
|
|
871
|
+
evidence,
|
|
872
|
+
_install_remedy(ctx),
|
|
873
|
+
)
|
|
874
|
+
return _result(
|
|
875
|
+
"ok", f"runner {_fmt_semver(reported)} >= minRunnerVersion {_fmt_semver(required)}",
|
|
876
|
+
evidence,
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _check_runner_wired(ctx: _CheckContext) -> dict:
|
|
881
|
+
"""``loopRunner.preconditionFile`` exists once a feature needs the runner."""
|
|
882
|
+
if (na := _runner_unavailable(ctx)) is not None:
|
|
883
|
+
return na
|
|
884
|
+
path = ctx.precondition_path()
|
|
885
|
+
relevant = ctx.runner_relevant()
|
|
886
|
+
evidence = {
|
|
887
|
+
"preconditionFile": str(path) if path else None,
|
|
888
|
+
"exists": bool(path and path.is_file()),
|
|
889
|
+
"runnerRelevant": relevant,
|
|
890
|
+
}
|
|
891
|
+
if path is None:
|
|
892
|
+
return _result("na", "loopRunner.preconditionFile unset", evidence)
|
|
893
|
+
if path.is_file():
|
|
894
|
+
return _result("ok", f"{path.name} present", evidence)
|
|
895
|
+
if not relevant:
|
|
896
|
+
return _result(
|
|
897
|
+
"na", f"{path.name} absent, but no feature has reached forge-4-backlog yet", evidence,
|
|
898
|
+
)
|
|
899
|
+
hint = ctx.loop_runner.get("setupHint")
|
|
900
|
+
return _result(
|
|
901
|
+
"warn", f"{path.name} absent — the runner is not wired into this project", evidence,
|
|
902
|
+
_remedy(
|
|
903
|
+
hint if isinstance(hint, str) and hint else "Wire the loop runner into the project",
|
|
904
|
+
shlex.join([ctx.runner_bin(), "install", "."]),
|
|
905
|
+
"local-write",
|
|
906
|
+
),
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _check_runner_legacy_layout(ctx: _CheckContext) -> dict:
|
|
911
|
+
"""No legacy Ralph artefacts (``.ralph.json`` / ``.ralph/``) beside a rauf project."""
|
|
912
|
+
if (na := _runner_unavailable(ctx)) is not None:
|
|
913
|
+
return na
|
|
914
|
+
runner_name = ctx.loop_runner.get("name")
|
|
915
|
+
if runner_name != "rauf":
|
|
916
|
+
return _result("na", f"runner is {runner_name!r}, not rauf — no legacy layout to detect",
|
|
917
|
+
{"runnerName": runner_name})
|
|
918
|
+
ralph_json = ctx.cwd / ".ralph.json"
|
|
919
|
+
ralph_dir = ctx.cwd / ".ralph"
|
|
920
|
+
evidence = {
|
|
921
|
+
"runnerName": runner_name,
|
|
922
|
+
"ralphJson": ralph_json.is_file(),
|
|
923
|
+
"ralphDir": ralph_dir.is_dir(),
|
|
924
|
+
"runnerOnPath": ctx.runner_bin_path(),
|
|
925
|
+
}
|
|
926
|
+
found = [name for name, present in (("`.ralph.json`", evidence["ralphJson"]),
|
|
927
|
+
("`.ralph/`", evidence["ralphDir"])) if present]
|
|
928
|
+
if not found:
|
|
929
|
+
return _result("ok", "no legacy Ralph layout", evidence)
|
|
930
|
+
return _result(
|
|
931
|
+
"warn", "legacy Ralph layout present: " + ", ".join(found), evidence,
|
|
932
|
+
_remedy(
|
|
933
|
+
"Migrate the legacy Ralph layout to rauf's per-project artifacts",
|
|
934
|
+
shlex.join([ctx.runner_bin(), "migrate", "."]),
|
|
935
|
+
"local-write",
|
|
936
|
+
),
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _refresh_artifacts_remedy(ctx: _CheckContext) -> dict:
|
|
941
|
+
"""Shared remedy for the stale-artifacts warn branches: ``<runner> update .``.
|
|
942
|
+
|
|
943
|
+
The description is the consent surface, so it names every project file the
|
|
944
|
+
command rewrites — including ``.gitignore``, which the project owns (#283).
|
|
945
|
+
"""
|
|
946
|
+
return _remedy(
|
|
947
|
+
"Refresh the runner's per-project artifacts (`.rauf.json`, `.rauf/` "
|
|
948
|
+
"schema) and add its runtime files to `.gitignore`",
|
|
949
|
+
shlex.join([ctx.runner_bin(), "update", "."]),
|
|
950
|
+
"local-write",
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _check_runner_artifacts_stale(ctx: _CheckContext) -> dict:
|
|
955
|
+
"""The precondition file's ``installedBy`` version matches the live runner."""
|
|
956
|
+
if (na := _runner_unavailable(ctx)) is not None:
|
|
957
|
+
return na
|
|
958
|
+
precondition = ctx.precondition()
|
|
959
|
+
path = ctx.precondition_path()
|
|
960
|
+
if precondition is None:
|
|
961
|
+
return _result("na", "precondition file absent (see runner-wired)",
|
|
962
|
+
{"preconditionFile": str(path) if path else None})
|
|
963
|
+
live = ctx.runner_version()
|
|
964
|
+
installed_by = precondition.get("installedBy")
|
|
965
|
+
parsed = _parse_installed_by(installed_by)
|
|
966
|
+
evidence = {
|
|
967
|
+
"preconditionFile": str(path),
|
|
968
|
+
"installedBy": installed_by if isinstance(installed_by, str) else None,
|
|
969
|
+
"installedVersion": _fmt_semver(parsed[1]) if parsed else None,
|
|
970
|
+
"liveVersion": _fmt_semver(live) if live else None,
|
|
971
|
+
}
|
|
972
|
+
if live is None:
|
|
973
|
+
return _result("na", "live runner version unknown (see runner-version)", evidence)
|
|
974
|
+
if parsed is None:
|
|
975
|
+
return _result(
|
|
976
|
+
"warn", f"{path.name} has no parseable installedBy ({installed_by!r})", evidence,
|
|
977
|
+
_refresh_artifacts_remedy(ctx),
|
|
978
|
+
)
|
|
979
|
+
if parsed[1] < live:
|
|
980
|
+
return _result(
|
|
981
|
+
"warn",
|
|
982
|
+
f"{path.name} was written by {installed_by}; the live runner is "
|
|
983
|
+
f"{_fmt_semver(live)}",
|
|
984
|
+
evidence,
|
|
985
|
+
_refresh_artifacts_remedy(ctx),
|
|
986
|
+
)
|
|
987
|
+
if parsed[1] > live:
|
|
988
|
+
# The artifacts are newer than the live binary: the fix is to obtain/upgrade
|
|
989
|
+
# the CLI itself, so this routes to _install_remedy, whose description is the
|
|
990
|
+
# loopRunner.installHint verbatim. The shipped default hint is a binary-
|
|
991
|
+
# provisioning path (the cross-agent installer — whose sources write no project
|
|
992
|
+
# file — or the rauf-CLI one-liner); none of those rewrite the project-owned
|
|
993
|
+
# `.gitignore`, so the default description names no project file. That is the
|
|
994
|
+
# opposite of `<runner> update .` (_refresh_artifacts_remedy), whose description
|
|
995
|
+
# MUST name `.gitignore` because that command does rewrite it (#283). If an
|
|
996
|
+
# operator customises installHint, that authored string is its own consent
|
|
997
|
+
# surface — feature-forge surfaces it faithfully, it does not edit it. The
|
|
998
|
+
# `> live` test row locks the shipped-default behaviour (#317).
|
|
999
|
+
return _result(
|
|
1000
|
+
"warn",
|
|
1001
|
+
f"{path.name} was written by {installed_by}, newer than the live runner "
|
|
1002
|
+
f"{_fmt_semver(live)}",
|
|
1003
|
+
evidence,
|
|
1004
|
+
_install_remedy(ctx),
|
|
1005
|
+
)
|
|
1006
|
+
return _result("ok", f"{path.name} matches the live runner ({_fmt_semver(live)})", evidence)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _check_runner_profile_drift(ctx: _CheckContext) -> dict:
|
|
1010
|
+
"""``testCommand`` agrees with the runner profile's test/verify command.
|
|
1011
|
+
|
|
1012
|
+
Divergence may be deliberate (the profile can run a broader gate), so this
|
|
1013
|
+
is advisory with no remedy — evidence shows both sides.
|
|
1014
|
+
"""
|
|
1015
|
+
if (na := _runner_unavailable(ctx)) is not None:
|
|
1016
|
+
return na
|
|
1017
|
+
test_command = ctx.config.get("testCommand")
|
|
1018
|
+
if not isinstance(test_command, str) or not test_command.strip():
|
|
1019
|
+
return _result("na", "testCommand unset (see config-completeness)",
|
|
1020
|
+
{"testCommand": None})
|
|
1021
|
+
precondition = ctx.precondition()
|
|
1022
|
+
path = ctx.precondition_path()
|
|
1023
|
+
if precondition is None:
|
|
1024
|
+
return _result("na", "precondition file absent (see runner-wired)",
|
|
1025
|
+
{"testCommand": test_command})
|
|
1026
|
+
profile = precondition.get("profile")
|
|
1027
|
+
if not isinstance(profile, dict):
|
|
1028
|
+
return _result("na", f"{path.name} declares no profile", {"testCommand": test_command})
|
|
1029
|
+
commands = profile.get("commands")
|
|
1030
|
+
profile_test = commands.get("test") if isinstance(commands, dict) else None
|
|
1031
|
+
profile_verify = profile.get("verify")
|
|
1032
|
+
if not isinstance(profile_test, str):
|
|
1033
|
+
profile_test = None
|
|
1034
|
+
if not isinstance(profile_verify, str):
|
|
1035
|
+
profile_verify = None
|
|
1036
|
+
norm = " ".join(test_command.split())
|
|
1037
|
+
matches = [
|
|
1038
|
+
label for label, value in (("commands.test", profile_test), ("verify", profile_verify))
|
|
1039
|
+
if value is not None and " ".join(value.split()) == norm
|
|
1040
|
+
]
|
|
1041
|
+
evidence = {
|
|
1042
|
+
"testCommand": test_command,
|
|
1043
|
+
"profileTest": profile_test,
|
|
1044
|
+
"profileVerify": profile_verify,
|
|
1045
|
+
"matches": matches,
|
|
1046
|
+
}
|
|
1047
|
+
if profile_test is None and profile_verify is None:
|
|
1048
|
+
return _result("na", f"{path.name} profile declares no test or verify command", evidence)
|
|
1049
|
+
if matches:
|
|
1050
|
+
return _result("ok", f"testCommand matches profile {' and '.join(matches)}", evidence)
|
|
1051
|
+
return _result(
|
|
1052
|
+
"warn",
|
|
1053
|
+
f"testCommand {test_command!r} matches neither profile.commands.test "
|
|
1054
|
+
f"{profile_test!r} nor profile.verify {profile_verify!r} — divergence may be deliberate",
|
|
1055
|
+
evidence,
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _check_config_schema(ctx: _CheckContext) -> dict:
|
|
1060
|
+
"""``forge.config.json`` parses and conforms to the bundled schema."""
|
|
1061
|
+
if not ctx.config_exists:
|
|
1062
|
+
return _result(
|
|
1063
|
+
"na", "forge.config.json absent — schema defaults apply",
|
|
1064
|
+
{"configPath": str(ctx.config_path)},
|
|
1065
|
+
)
|
|
1066
|
+
evidence: dict = {
|
|
1067
|
+
"configPath": str(ctx.config_path),
|
|
1068
|
+
"schemaPath": str(ctx.schema_path),
|
|
1069
|
+
"schemaError": ctx.schema_error,
|
|
1070
|
+
"parseError": None,
|
|
1071
|
+
"duplicateKeys": [],
|
|
1072
|
+
"invalidAutoVerifyKeys": [],
|
|
1073
|
+
"violations": [],
|
|
1074
|
+
"unknownKeys": [],
|
|
1075
|
+
}
|
|
1076
|
+
fix = _remedy("Fix forge.config.json so it parses as a JSON object", None, "local-write")
|
|
1077
|
+
try:
|
|
1078
|
+
value, duplicates = load_json_with_duplicates(ctx.config_path)
|
|
1079
|
+
except (OSError, ValueError, RecursionError) as exc: # JSON *and* UTF-8 decode errors
|
|
1080
|
+
evidence["parseError"] = _exc_text(exc)
|
|
1081
|
+
return _result(
|
|
1082
|
+
"warn", f"forge.config.json unreadable or invalid JSON: {evidence['parseError']}",
|
|
1083
|
+
evidence, fix,
|
|
1084
|
+
)
|
|
1085
|
+
if not isinstance(value, dict):
|
|
1086
|
+
evidence["parseError"] = f"top level is {type(value).__name__}, expected object"
|
|
1087
|
+
return _result("warn", f"forge.config.json: {evidence['parseError']}", evidence, fix)
|
|
1088
|
+
evidence["duplicateKeys"] = list(duplicates)
|
|
1089
|
+
evidence["invalidAutoVerifyKeys"] = invalid_auto_verify_keys(value)
|
|
1090
|
+
findings: list[str] = []
|
|
1091
|
+
if duplicates:
|
|
1092
|
+
findings.append("duplicate keys (last value wins): " + ", ".join(duplicates))
|
|
1093
|
+
if evidence["invalidAutoVerifyKeys"]:
|
|
1094
|
+
findings.append(
|
|
1095
|
+
"invalid autoVerifyStages keys (ignored): "
|
|
1096
|
+
+ ", ".join(evidence["invalidAutoVerifyKeys"])
|
|
1097
|
+
)
|
|
1098
|
+
if ctx.schema is None:
|
|
1099
|
+
return _result(
|
|
1100
|
+
"warn",
|
|
1101
|
+
f"config schema unreadable ({ctx.schema_error}); structural validation skipped",
|
|
1102
|
+
evidence,
|
|
1103
|
+
_remedy(
|
|
1104
|
+
"Reinstall or update feature-forge — the bundled forge-config-schema.json "
|
|
1105
|
+
"is missing or damaged",
|
|
1106
|
+
None,
|
|
1107
|
+
"global-install",
|
|
1108
|
+
),
|
|
1109
|
+
)
|
|
1110
|
+
try:
|
|
1111
|
+
evidence["violations"] = _schema_violations(value, ctx.schema, ctx.schema, "$")
|
|
1112
|
+
except Exception as exc: # a malformed schema node (e.g. ``"type": 5``)
|
|
1113
|
+
evidence["schemaError"] = f"schema malformed: {_exc_text(exc)}"
|
|
1114
|
+
return _result(
|
|
1115
|
+
"warn",
|
|
1116
|
+
f"config schema malformed ({_exc_text(exc)}); structural validation skipped",
|
|
1117
|
+
evidence,
|
|
1118
|
+
_remedy(
|
|
1119
|
+
"Reinstall or update feature-forge — the bundled forge-config-schema.json "
|
|
1120
|
+
"is damaged",
|
|
1121
|
+
None,
|
|
1122
|
+
"global-install",
|
|
1123
|
+
),
|
|
1124
|
+
)
|
|
1125
|
+
props = ctx.schema.get("properties")
|
|
1126
|
+
known = set(props) if isinstance(props, dict) else set()
|
|
1127
|
+
evidence["unknownKeys"] = sorted(key for key in value if key not in known)
|
|
1128
|
+
findings += evidence["violations"]
|
|
1129
|
+
|
|
1130
|
+
# The machine-local overlay (#324) is validated against the same schema, so an invalid
|
|
1131
|
+
# forge.config.local.json is caught with the file named — not silently merged. It is
|
|
1132
|
+
# OPTIONAL (absent → nothing to check) and only its own keys are validated; unknown-key
|
|
1133
|
+
# noise is left to the committed file's report above.
|
|
1134
|
+
local_path = _local_config_path(ctx.config_path)
|
|
1135
|
+
evidence["localConfigPath"] = str(local_path)
|
|
1136
|
+
evidence["localParseError"] = None
|
|
1137
|
+
evidence["localViolations"] = []
|
|
1138
|
+
local_findings: list[str] = []
|
|
1139
|
+
if local_path.is_file():
|
|
1140
|
+
try:
|
|
1141
|
+
local_value, _ = load_json_with_duplicates(local_path)
|
|
1142
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
1143
|
+
evidence["localParseError"] = _exc_text(exc)
|
|
1144
|
+
local_findings.append(f"unreadable or invalid JSON: {evidence['localParseError']}")
|
|
1145
|
+
else:
|
|
1146
|
+
if not isinstance(local_value, dict):
|
|
1147
|
+
evidence["localParseError"] = (
|
|
1148
|
+
f"top level is {type(local_value).__name__}, expected object"
|
|
1149
|
+
)
|
|
1150
|
+
local_findings.append(evidence["localParseError"])
|
|
1151
|
+
else:
|
|
1152
|
+
try:
|
|
1153
|
+
evidence["localViolations"] = _schema_violations(
|
|
1154
|
+
local_value, ctx.schema, ctx.schema, "$"
|
|
1155
|
+
)
|
|
1156
|
+
except Exception as exc: # malformed schema node reached only via a local key
|
|
1157
|
+
# Same INV-3 guard as the committed call above: a damaged bundled schema is
|
|
1158
|
+
# captured as data (a warn finding), never propagated as an exit-2 crash.
|
|
1159
|
+
evidence["schemaError"] = f"schema malformed: {_exc_text(exc)}"
|
|
1160
|
+
local_findings.append(
|
|
1161
|
+
f"config schema malformed ({_exc_text(exc)}); "
|
|
1162
|
+
"local structural validation skipped"
|
|
1163
|
+
)
|
|
1164
|
+
else:
|
|
1165
|
+
local_findings += evidence["localViolations"]
|
|
1166
|
+
|
|
1167
|
+
if findings:
|
|
1168
|
+
return _result(
|
|
1169
|
+
"warn",
|
|
1170
|
+
f"forge.config.json has {len(findings)} finding(s): {findings[0]}",
|
|
1171
|
+
evidence,
|
|
1172
|
+
_remedy(f"Fix forge.config.json: {findings[0]}", None, "local-write"),
|
|
1173
|
+
)
|
|
1174
|
+
if local_findings:
|
|
1175
|
+
return _result(
|
|
1176
|
+
"warn",
|
|
1177
|
+
f"forge.config.local.json has {len(local_findings)} finding(s): {local_findings[0]}",
|
|
1178
|
+
evidence,
|
|
1179
|
+
_remedy(f"Fix forge.config.local.json: {local_findings[0]}", None, "local-write"),
|
|
1180
|
+
)
|
|
1181
|
+
detail = "forge.config.json conforms to the schema"
|
|
1182
|
+
if evidence["localConfigPath"] and local_path.is_file():
|
|
1183
|
+
detail += " (with forge.config.local.json overlay)"
|
|
1184
|
+
if evidence["unknownKeys"]:
|
|
1185
|
+
detail += "; unknown top-level key(s) ignored: " + ", ".join(evidence["unknownKeys"])
|
|
1186
|
+
return _result("ok", detail, evidence)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _git_check_ignored(path: Path) -> bool | None:
|
|
1190
|
+
"""Whether git ignores ``path``: ``True`` ignored, ``False`` tracked, ``None`` undecidable.
|
|
1191
|
+
|
|
1192
|
+
``git check-ignore -q`` exits 0 when the path is ignored, 1 when it is not, and 128 outside
|
|
1193
|
+
a work tree (or git missing) — the last collapses to ``None`` so the caller reports ``na``
|
|
1194
|
+
rather than a false "not ignored". Run from the file's own directory so any nested work tree
|
|
1195
|
+
is honoured.
|
|
1196
|
+
"""
|
|
1197
|
+
try:
|
|
1198
|
+
proc = subprocess.run(
|
|
1199
|
+
["git", "check-ignore", "-q", path.name],
|
|
1200
|
+
capture_output=True, text=True, timeout=10, cwd=path.parent,
|
|
1201
|
+
)
|
|
1202
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1203
|
+
return None
|
|
1204
|
+
if proc.returncode == 0:
|
|
1205
|
+
return True
|
|
1206
|
+
if proc.returncode == 1:
|
|
1207
|
+
return False
|
|
1208
|
+
return None
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _check_config_local_ignored(ctx: _CheckContext) -> dict:
|
|
1212
|
+
"""A present ``forge.config.local.json`` is git-ignored (#324).
|
|
1213
|
+
|
|
1214
|
+
The machine-local overlay is a per-machine dev fact (a runner-binary choice, a
|
|
1215
|
+
machine-specific ``contextWindowTokens``) and must never be committed. This advisory check
|
|
1216
|
+
warns when the file exists but git would track it, so the override cannot leak into the repo.
|
|
1217
|
+
It never fails: an absent file, an already-ignored file, or a non-git / git-less environment
|
|
1218
|
+
are all ``ok``/``na`` — doctor reports, it does not rewrite. The remedy NAMES ``.gitignore``
|
|
1219
|
+
(the file it would touch), per the #283/#317 consent rule.
|
|
1220
|
+
"""
|
|
1221
|
+
local_path = _local_config_path(ctx.config_path)
|
|
1222
|
+
evidence: dict = {
|
|
1223
|
+
"localConfigPath": str(local_path),
|
|
1224
|
+
"exists": local_path.is_file(),
|
|
1225
|
+
"ignored": None,
|
|
1226
|
+
}
|
|
1227
|
+
if not local_path.is_file():
|
|
1228
|
+
return _result("ok", "no forge.config.local.json present", evidence)
|
|
1229
|
+
ignored = _git_check_ignored(local_path)
|
|
1230
|
+
evidence["ignored"] = ignored
|
|
1231
|
+
if ignored is None:
|
|
1232
|
+
return _result(
|
|
1233
|
+
"na", "cannot determine git-ignore status (not a git work tree?)", evidence
|
|
1234
|
+
)
|
|
1235
|
+
if ignored:
|
|
1236
|
+
return _result("ok", "forge.config.local.json is git-ignored", evidence)
|
|
1237
|
+
return _result(
|
|
1238
|
+
"warn",
|
|
1239
|
+
"forge.config.local.json exists but is not git-ignored — a machine-local override "
|
|
1240
|
+
"could be committed by accident",
|
|
1241
|
+
evidence,
|
|
1242
|
+
_remedy(
|
|
1243
|
+
"Add 'forge.config.local.json' to the project's .gitignore so the machine-local "
|
|
1244
|
+
"override is never committed",
|
|
1245
|
+
None,
|
|
1246
|
+
"local-write",
|
|
1247
|
+
),
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def _check_root_version_skew(ctx: _CheckContext) -> dict:
|
|
1252
|
+
"""The resolved root, this script's own bundle and any env override agree.
|
|
1253
|
+
|
|
1254
|
+
Two roots agree when they are the same real path, or both declare a
|
|
1255
|
+
version and the versions match. A root without a version manifest at a
|
|
1256
|
+
different path cannot be proven equal and counts as skew.
|
|
1257
|
+
"""
|
|
1258
|
+
if not ctx.plugin_root.get("resolved"):
|
|
1259
|
+
# Still enumerate every candidate root: this is exactly the multi-install triage moment
|
|
1260
|
+
# (e.g. a bad FEATURE_FORGE_ROOT override hard-failed Step 0 on a host that also has a
|
|
1261
|
+
# good install) where the operator most needs to see what a working install would be.
|
|
1262
|
+
return _result(
|
|
1263
|
+
"na", "plugin root unresolved (see plugin-root)",
|
|
1264
|
+
{"resolved": False, "candidates": _resolve_all_roots()},
|
|
1265
|
+
)
|
|
1266
|
+
own_root = _BUNDLE_ROOT
|
|
1267
|
+
resolved_root = Path(str(ctx.plugin_root.get("root")))
|
|
1268
|
+
env_var = next(
|
|
1269
|
+
(name for name in ("FEATURE_FORGE_ROOT", "CLAUDE_PLUGIN_ROOT") if os.environ.get(name)),
|
|
1270
|
+
None,
|
|
1271
|
+
)
|
|
1272
|
+
roots = {
|
|
1273
|
+
"own": (own_root, _bundle_version(own_root)["version"]),
|
|
1274
|
+
"resolved": (resolved_root, ctx.plugin_root.get("version")),
|
|
1275
|
+
}
|
|
1276
|
+
if env_var:
|
|
1277
|
+
env_root = Path(os.environ[env_var])
|
|
1278
|
+
roots["env"] = (env_root, _bundle_version(env_root)["version"])
|
|
1279
|
+
|
|
1280
|
+
def real(path: Path) -> str:
|
|
1281
|
+
try:
|
|
1282
|
+
return str(path.resolve())
|
|
1283
|
+
except OSError:
|
|
1284
|
+
return str(path)
|
|
1285
|
+
|
|
1286
|
+
def agree(a: tuple[Path, str | None], b: tuple[Path, str | None]) -> bool:
|
|
1287
|
+
if real(a[0]) == real(b[0]):
|
|
1288
|
+
return True
|
|
1289
|
+
return a[1] is not None and b[1] is not None and a[1] == b[1]
|
|
1290
|
+
|
|
1291
|
+
# For the pairwise skew check, drop `env` when it IS the resolved root — a FEATURE_FORGE_ROOT
|
|
1292
|
+
# override makes resolved == env post-#323, and comparing both would print the same skew twice
|
|
1293
|
+
# (own-vs-resolved and own-vs-env). The env evidence fields below are kept for transparency.
|
|
1294
|
+
compare = dict(roots)
|
|
1295
|
+
if "env" in compare and real(compare["env"][0]) == real(resolved_root):
|
|
1296
|
+
del compare["env"]
|
|
1297
|
+
labels = list(compare)
|
|
1298
|
+
disagreements = [
|
|
1299
|
+
f"{x} ({compare[x][1] or 'no version'} at {compare[x][0]}) vs "
|
|
1300
|
+
f"{y} ({compare[y][1] or 'no version'} at {compare[y][0]})"
|
|
1301
|
+
for i, x in enumerate(labels) for y in labels[i + 1:]
|
|
1302
|
+
if not agree(compare[x], compare[y])
|
|
1303
|
+
]
|
|
1304
|
+
evidence = {
|
|
1305
|
+
"ownRoot": str(own_root),
|
|
1306
|
+
"ownVersion": roots["own"][1],
|
|
1307
|
+
"resolvedRoot": str(resolved_root),
|
|
1308
|
+
"resolvedVersion": roots["resolved"][1],
|
|
1309
|
+
"envVar": env_var,
|
|
1310
|
+
"envRoot": str(roots["env"][0]) if "env" in roots else None,
|
|
1311
|
+
"envVersion": roots["env"][1] if "env" in roots else None,
|
|
1312
|
+
# Every candidate root the resolver saw, with its channel + version (#323) — the estate's
|
|
1313
|
+
# "which install is live on this host" answer in one command, without duplicating the
|
|
1314
|
+
# candidate list in Python (forge-root.sh --explain --all owns it).
|
|
1315
|
+
"candidates": _resolve_all_roots(),
|
|
1316
|
+
"agree": not disagreements,
|
|
1317
|
+
}
|
|
1318
|
+
if not disagreements:
|
|
1319
|
+
return _result(
|
|
1320
|
+
"ok",
|
|
1321
|
+
f"one install in play ({roots['resolved'][1] or 'unversioned'} at {resolved_root})",
|
|
1322
|
+
evidence,
|
|
1323
|
+
)
|
|
1324
|
+
return _result(
|
|
1325
|
+
"warn", "install roots disagree: " + "; ".join(disagreements), evidence,
|
|
1326
|
+
_remedy(
|
|
1327
|
+
"Reinstall or update feature-forge so a single bundle is loaded (setup guide: "
|
|
1328
|
+
"https://raw.githubusercontent.com/garygentry/feature-forge/main/AGENTS-SETUP.md), "
|
|
1329
|
+
"or unset the stale root override",
|
|
1330
|
+
None,
|
|
1331
|
+
"global-install",
|
|
1332
|
+
),
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
#: Config keys forge-2-tech records, in the order forge-2-tech writes them;
|
|
1337
|
+
#: ``smokeCommand`` is optional everywhere (evidence-only when absent).
|
|
1338
|
+
_CONFIG_KEYS_BY_STAGE: Final[tuple[tuple[str, str], ...]] = (
|
|
1339
|
+
("stack", "forge-3-specs"),
|
|
1340
|
+
("typeCheckCommand", "forge-4-backlog"),
|
|
1341
|
+
("testCommand", "forge-4-backlog"),
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def _check_config_completeness(ctx: _CheckContext) -> dict:
|
|
1346
|
+
"""The keys forge-2-tech records exist once a feature is far enough along.
|
|
1347
|
+
|
|
1348
|
+
A feature whose next stage is forge-3-specs needs ``stack``; from
|
|
1349
|
+
forge-4-backlog on (or complete) it also needs ``typeCheckCommand`` and
|
|
1350
|
+
``testCommand``. Features that have not reached forge-3-specs are skipped.
|
|
1351
|
+
"""
|
|
1352
|
+
def present(key: str) -> bool:
|
|
1353
|
+
value = ctx.config.get(key)
|
|
1354
|
+
return isinstance(value, str) and bool(value.strip())
|
|
1355
|
+
|
|
1356
|
+
rows = []
|
|
1357
|
+
for feat in ctx.features:
|
|
1358
|
+
required = [
|
|
1359
|
+
key for key, floor in _CONFIG_KEYS_BY_STAGE
|
|
1360
|
+
if _stage_at_or_after(feat["nextStage"], floor)
|
|
1361
|
+
]
|
|
1362
|
+
if not required:
|
|
1363
|
+
continue
|
|
1364
|
+
missing = [key for key in required if not present(key)]
|
|
1365
|
+
rows.append({
|
|
1366
|
+
"name": feat["name"],
|
|
1367
|
+
"epic": feat["epic"],
|
|
1368
|
+
"nextStage": feat["nextStage"],
|
|
1369
|
+
"required": list(required),
|
|
1370
|
+
"missing": missing,
|
|
1371
|
+
"remedy": _remedy(
|
|
1372
|
+
"Record the missing keys in forge.config.json (forge-2-tech Step 3 writes "
|
|
1373
|
+
"stack/typeCheckCommand/testCommand): " + ", ".join(missing),
|
|
1374
|
+
None,
|
|
1375
|
+
"local-write",
|
|
1376
|
+
) if missing else None,
|
|
1377
|
+
})
|
|
1378
|
+
optional_missing = [key for key in ("smokeCommand",) if not present(key)]
|
|
1379
|
+
evidence = {
|
|
1380
|
+
"configExists": ctx.config_exists,
|
|
1381
|
+
"features": rows,
|
|
1382
|
+
"missing": [
|
|
1383
|
+
key for key, _floor in _CONFIG_KEYS_BY_STAGE
|
|
1384
|
+
if any(key in row["missing"] for row in rows)
|
|
1385
|
+
],
|
|
1386
|
+
"optionalMissing": optional_missing,
|
|
1387
|
+
}
|
|
1388
|
+
if not rows:
|
|
1389
|
+
return _result("na", "no active feature has reached forge-3-specs", evidence)
|
|
1390
|
+
affected = [row for row in rows if row["missing"]]
|
|
1391
|
+
if not affected:
|
|
1392
|
+
detail = f"required config present for {len(rows)} feature(s)"
|
|
1393
|
+
if optional_missing:
|
|
1394
|
+
detail += "; optional " + ", ".join(optional_missing) + " unset"
|
|
1395
|
+
return _result("ok", detail, evidence)
|
|
1396
|
+
return _result(
|
|
1397
|
+
"warn",
|
|
1398
|
+
f"{len(affected)} feature(s) missing required config: "
|
|
1399
|
+
+ ", ".join(evidence["missing"]),
|
|
1400
|
+
evidence,
|
|
1401
|
+
_per_feature_remedy(rows),
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _check_backlog_valid(ctx: _CheckContext) -> dict:
|
|
1406
|
+
"""Each backlog the loop is about to consume passes ``{bin} backlog validate``.
|
|
1407
|
+
|
|
1408
|
+
Scoped to features whose next stage is forge-5-loop (a validated-and-done
|
|
1409
|
+
loop's backlog is history), at most one probe per distinct backlog dir.
|
|
1410
|
+
"""
|
|
1411
|
+
if (na := _runner_unavailable(ctx)) is not None:
|
|
1412
|
+
return na
|
|
1413
|
+
if not ctx.runner_bin_path():
|
|
1414
|
+
return _result("na", "runner binary not on PATH (see runner-binary)",
|
|
1415
|
+
{"bin": ctx.runner_bin()})
|
|
1416
|
+
template = ctx.loop_runner.get("validateCommand")
|
|
1417
|
+
if not isinstance(template, str) or not template.strip():
|
|
1418
|
+
return _result("na", "loopRunner.validateCommand unset", {"command": template})
|
|
1419
|
+
eligible = [
|
|
1420
|
+
feat for feat in ctx.features
|
|
1421
|
+
if feat["nextStage"] == "forge-5-loop" and feat["backlogExists"]
|
|
1422
|
+
]
|
|
1423
|
+
evidence: dict = {"command": template, "features": []}
|
|
1424
|
+
if not eligible:
|
|
1425
|
+
return _result(
|
|
1426
|
+
"na", "no active feature is about to run forge-5-loop with a backlog on disk",
|
|
1427
|
+
evidence,
|
|
1428
|
+
)
|
|
1429
|
+
probes: dict[str, dict] = {}
|
|
1430
|
+
rows = []
|
|
1431
|
+
for feat in eligible:
|
|
1432
|
+
backlog_dir = str(Path(feat["backlogPath"]).parent)
|
|
1433
|
+
if backlog_dir not in probes:
|
|
1434
|
+
argv = _render_runner_command(
|
|
1435
|
+
template, ctx.loop_runner, backlogDir=backlog_dir, specsDir=str(ctx.specs_dir),
|
|
1436
|
+
)
|
|
1437
|
+
probes[backlog_dir] = (
|
|
1438
|
+
_run_probe(argv, cwd=ctx.cwd) if argv
|
|
1439
|
+
else {"ok": False, "returncode": None, "stdout": "", "stderr": "",
|
|
1440
|
+
"error": "validateCommand cannot be rendered", "timedOut": False}
|
|
1441
|
+
)
|
|
1442
|
+
probe = probes[backlog_dir]
|
|
1443
|
+
findings: list = []
|
|
1444
|
+
findings_parsed = False
|
|
1445
|
+
if probe["returncode"] == 1:
|
|
1446
|
+
try:
|
|
1447
|
+
payload = json.loads(probe["stdout"])
|
|
1448
|
+
except ValueError:
|
|
1449
|
+
payload = None
|
|
1450
|
+
raw = payload.get("findings") if isinstance(payload, dict) else payload
|
|
1451
|
+
if isinstance(raw, list):
|
|
1452
|
+
findings = raw
|
|
1453
|
+
findings_parsed = True
|
|
1454
|
+
rows.append({
|
|
1455
|
+
"name": feat["name"],
|
|
1456
|
+
"epic": feat["epic"],
|
|
1457
|
+
"backlogDir": backlog_dir,
|
|
1458
|
+
"exitCode": probe["returncode"],
|
|
1459
|
+
"valid": probe["ok"],
|
|
1460
|
+
"timedOut": probe["timedOut"],
|
|
1461
|
+
"error": probe["error"],
|
|
1462
|
+
"findingsCount": len(findings),
|
|
1463
|
+
"findingsParsed": findings_parsed,
|
|
1464
|
+
"findings": [_head(json.dumps(f, ensure_ascii=False)) for f in findings[:5]],
|
|
1465
|
+
"stderrHead": _head(probe["stderr"]),
|
|
1466
|
+
"remedy": None,
|
|
1467
|
+
})
|
|
1468
|
+
evidence["features"] = rows
|
|
1469
|
+
bad = [row for row in rows if not row["valid"]]
|
|
1470
|
+
if not bad:
|
|
1471
|
+
return _result("ok", f"{len(rows)} backlog(s) validate", evidence)
|
|
1472
|
+
parts = []
|
|
1473
|
+
for row in bad:
|
|
1474
|
+
if row["exitCode"] == 1 and row["findingsParsed"]:
|
|
1475
|
+
parts.append(f"{_feature_label(row)} ({row['findingsCount']} finding(s))")
|
|
1476
|
+
elif row["exitCode"] == 1:
|
|
1477
|
+
parts.append(
|
|
1478
|
+
f"{_feature_label(row)} (exit 1 but the validator output is not findings JSON"
|
|
1479
|
+
+ (f": {row['stderrHead']}" if row["stderrHead"] else "")
|
|
1480
|
+
+ ")"
|
|
1481
|
+
)
|
|
1482
|
+
else:
|
|
1483
|
+
why = row["error"] or f"exit {row['exitCode']}"
|
|
1484
|
+
parts.append(f"{_feature_label(row)} (validator {why})")
|
|
1485
|
+
return _result(
|
|
1486
|
+
"warn",
|
|
1487
|
+
f"{len(bad)} backlog(s) fail validation: " + "; ".join(parts)
|
|
1488
|
+
+ " — fix the findings or re-run /feature-forge:forge-4-backlog <feature>",
|
|
1489
|
+
evidence,
|
|
1490
|
+
)
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def _check_gh_available(ctx: _CheckContext) -> dict:
|
|
1494
|
+
"""GitHub CLI on PATH with credentials (``gh auth token``; never ``auth status``)."""
|
|
1495
|
+
path = shutil.which("gh")
|
|
1496
|
+
evidence: dict = {
|
|
1497
|
+
"path": path,
|
|
1498
|
+
"version": None,
|
|
1499
|
+
"authenticated": False,
|
|
1500
|
+
"tokenFromEnv": any(os.environ.get(name) for name in ("GH_TOKEN", "GITHUB_TOKEN")),
|
|
1501
|
+
}
|
|
1502
|
+
install = _remedy(
|
|
1503
|
+
"Install the GitHub CLI (https://cli.github.com) — needed only for PR/issue "
|
|
1504
|
+
"automation; every stage degrades to manual steps without it",
|
|
1505
|
+
None,
|
|
1506
|
+
"global-install",
|
|
1507
|
+
)
|
|
1508
|
+
if path is None:
|
|
1509
|
+
return _result("warn", "GitHub CLI (gh) not on PATH", evidence, install)
|
|
1510
|
+
version = _run_probe([path, "--version"])
|
|
1511
|
+
if not version["ok"]:
|
|
1512
|
+
evidence["versionError"] = version["error"] or _head(version["stderr"])
|
|
1513
|
+
return _result(
|
|
1514
|
+
"warn", f"gh present at {path} but `gh --version` failed", evidence,
|
|
1515
|
+
_remedy("Reinstall the GitHub CLI", None, "global-install"),
|
|
1516
|
+
)
|
|
1517
|
+
evidence["version"] = _head(version["stdout"].splitlines()[0] if version["stdout"] else "")
|
|
1518
|
+
# stdout is a credential: discarded before it can reach evidence.
|
|
1519
|
+
token = _run_probe([path, "auth", "token"], discard_stdout=True)
|
|
1520
|
+
if not token["ok"]:
|
|
1521
|
+
return _result(
|
|
1522
|
+
"warn", f"gh present ({evidence['version']}) but has no credentials", evidence,
|
|
1523
|
+
_remedy("Authenticate the GitHub CLI", "gh auth login", "network"),
|
|
1524
|
+
)
|
|
1525
|
+
evidence["authenticated"] = True
|
|
1526
|
+
return _result("ok", f"gh present and authenticated ({evidence['version']})", evidence)
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _check_backlog_present(ctx: _CheckContext) -> dict:
|
|
1530
|
+
"""Every feature past forge-4-backlog has its composed ``backlog.json`` on disk."""
|
|
1531
|
+
eligible = [f for f in ctx.features if _stage_at_or_after(f["nextStage"], "forge-5-loop")]
|
|
1532
|
+
if not eligible:
|
|
1533
|
+
return _result(
|
|
1534
|
+
"na", "no active feature has completed forge-4-backlog",
|
|
1535
|
+
{"features": [], "skipped": len(ctx.features)},
|
|
1536
|
+
)
|
|
1537
|
+
rows = [
|
|
1538
|
+
{
|
|
1539
|
+
"name": feat["name"],
|
|
1540
|
+
"epic": feat["epic"],
|
|
1541
|
+
"nextStage": feat["nextStage"],
|
|
1542
|
+
"backlogPath": feat["backlogPath"],
|
|
1543
|
+
"exists": feat["backlogExists"],
|
|
1544
|
+
# The fix is a slash command, not a shell command: remedy stays null.
|
|
1545
|
+
"remedy": None,
|
|
1546
|
+
}
|
|
1547
|
+
for feat in eligible
|
|
1548
|
+
]
|
|
1549
|
+
missing = [row for row in rows if not row["exists"]]
|
|
1550
|
+
evidence = {"features": rows, "skipped": len(ctx.features) - len(eligible)}
|
|
1551
|
+
if not missing:
|
|
1552
|
+
return _result("ok", f"{len(rows)} backlog(s) present", evidence)
|
|
1553
|
+
names = ", ".join(_feature_label(row) for row in missing)
|
|
1554
|
+
return _result(
|
|
1555
|
+
"warn",
|
|
1556
|
+
f"backlog missing for {len(missing)} feature(s): {names} — re-run "
|
|
1557
|
+
"/feature-forge:forge-4-backlog <feature> to regenerate it",
|
|
1558
|
+
evidence,
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def _check_branch_state(ctx: _CheckContext) -> dict:
|
|
1563
|
+
"""The current branch matches each pending feature's recorded state branch.
|
|
1564
|
+
|
|
1565
|
+
Complete features are skipped: their branch is history, not a place the
|
|
1566
|
+
next stage will run. Reuses the legacy ``branchReconcile`` classification
|
|
1567
|
+
(``adopt-current`` on a topic branch, ``warn-drift`` on the default branch).
|
|
1568
|
+
"""
|
|
1569
|
+
base = {"currentBranch": ctx.current_branch, "defaultBranch": ctx.default_branch}
|
|
1570
|
+
if ctx.current_branch is None:
|
|
1571
|
+
return _result("na", "not a git repository (no current branch)", base)
|
|
1572
|
+
pending = [f for f in ctx.features if f["nextStage"] is not None]
|
|
1573
|
+
skipped = len(ctx.features) - len(pending)
|
|
1574
|
+
if not pending:
|
|
1575
|
+
return _result(
|
|
1576
|
+
"na", "no active feature has a pending stage",
|
|
1577
|
+
{**base, "features": [], "skippedComplete": skipped},
|
|
1578
|
+
)
|
|
1579
|
+
script = str(_SHIM_PATH)
|
|
1580
|
+
rows = []
|
|
1581
|
+
for feat in pending:
|
|
1582
|
+
reconcile = feat.get("branchReconcile")
|
|
1583
|
+
remedy = None
|
|
1584
|
+
if reconcile == "adopt-current":
|
|
1585
|
+
argv = [
|
|
1586
|
+
"python3", script, "state-branch", "--feature", feat["name"],
|
|
1587
|
+
"--branch", ctx.current_branch, "--specs-dir", str(ctx.specs_dir),
|
|
1588
|
+
]
|
|
1589
|
+
if feat["epic"]:
|
|
1590
|
+
argv += ["--epic", feat["epic"]]
|
|
1591
|
+
remedy = _remedy(
|
|
1592
|
+
f"Record the current branch '{ctx.current_branch}' in the feature's state "
|
|
1593
|
+
f"(recorded: '{feat['stateBranch']}')",
|
|
1594
|
+
shlex.join(argv),
|
|
1595
|
+
"local-write",
|
|
1596
|
+
)
|
|
1597
|
+
elif reconcile == "warn-drift":
|
|
1598
|
+
remedy = _remedy(
|
|
1599
|
+
f"Switch to the feature's recorded branch before running {feat['nextStage']} "
|
|
1600
|
+
"(create it with `git switch -c` if it no longer exists)",
|
|
1601
|
+
shlex.join(["git", "switch", feat["stateBranch"]]),
|
|
1602
|
+
"local-write",
|
|
1603
|
+
)
|
|
1604
|
+
rows.append({
|
|
1605
|
+
"name": feat["name"],
|
|
1606
|
+
"epic": feat["epic"],
|
|
1607
|
+
"nextStage": feat["nextStage"],
|
|
1608
|
+
"stateBranch": feat["stateBranch"],
|
|
1609
|
+
"reconcile": reconcile,
|
|
1610
|
+
"remedy": remedy,
|
|
1611
|
+
})
|
|
1612
|
+
evidence = {**base, "features": rows, "skippedComplete": skipped}
|
|
1613
|
+
drifted = [row for row in rows if row["reconcile"]]
|
|
1614
|
+
if not drifted:
|
|
1615
|
+
return _result(
|
|
1616
|
+
"ok", f"{len(rows)} pending feature(s) consistent with branch '{ctx.current_branch}'",
|
|
1617
|
+
evidence,
|
|
1618
|
+
)
|
|
1619
|
+
parts = [
|
|
1620
|
+
f"{_feature_label(row)} ({row['reconcile']}: on '{ctx.current_branch}', "
|
|
1621
|
+
f"state records '{row['stateBranch']}')"
|
|
1622
|
+
for row in drifted
|
|
1623
|
+
]
|
|
1624
|
+
return _result(
|
|
1625
|
+
"warn",
|
|
1626
|
+
f"{len(drifted)} feature(s) with branch drift: " + "; ".join(parts),
|
|
1627
|
+
evidence,
|
|
1628
|
+
_per_feature_remedy(rows),
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def _check_sandbox_root(ctx: _CheckContext) -> dict:
|
|
1633
|
+
"""Root without ``IS_SANDBOX`` — the forge-5-loop launch condition (#99)."""
|
|
1634
|
+
if getattr(os, "geteuid", None) is None:
|
|
1635
|
+
return _result(
|
|
1636
|
+
"na", "os.geteuid unavailable on this platform — root/sandbox gate not applicable",
|
|
1637
|
+
{"platform": sys.platform},
|
|
1638
|
+
)
|
|
1639
|
+
status = _root_sandbox_status()
|
|
1640
|
+
if status["loopWillSetSandbox"]:
|
|
1641
|
+
return _result(
|
|
1642
|
+
"warn",
|
|
1643
|
+
"running as root without IS_SANDBOX — forge-5-loop will export IS_SANDBOX=1 at "
|
|
1644
|
+
"launch so the runner's --dangerously-skip-permissions is not refused",
|
|
1645
|
+
status,
|
|
1646
|
+
_remedy(
|
|
1647
|
+
"Export IS_SANDBOX=1 in the launching shell (forge-5-loop supplies it "
|
|
1648
|
+
"automatically)",
|
|
1649
|
+
"export IS_SANDBOX=1",
|
|
1650
|
+
"read-only",
|
|
1651
|
+
),
|
|
1652
|
+
)
|
|
1653
|
+
detail = (
|
|
1654
|
+
"running as root; IS_SANDBOX already set" if status["isRoot"] else "not running as root"
|
|
1655
|
+
)
|
|
1656
|
+
return _result("ok", detail, status)
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def _process_ancestry(
|
|
1660
|
+
start_pid: int, *, proc_root: Path = Path("/proc"), max_depth: int = _ANCESTRY_MAX_DEPTH,
|
|
1661
|
+
) -> list[dict]:
|
|
1662
|
+
"""Walk ``/proc`` from ``start_pid`` upward; never raises, never blocks.
|
|
1663
|
+
|
|
1664
|
+
Returns one entry per readable ancestor, nearest first:
|
|
1665
|
+
``{"pid", "name", "flags"}`` where ``name`` is the basename of ``argv[0]``
|
|
1666
|
+
and ``flags`` are the ``_HEADLESS_FLAGS`` present in that argv.
|
|
1667
|
+
|
|
1668
|
+
**Only** those two fields are extracted, and ``name`` is redacted to ``?``
|
|
1669
|
+
unless it looks like an executable basename (``_EXEC_NAME_RE``). Raw argv is
|
|
1670
|
+
never returned: a parent's command line can carry credentials or a username,
|
|
1671
|
+
and every field here reaches ``doctor --json`` output that gets pasted into
|
|
1672
|
+
issues and PRs.
|
|
1673
|
+
"""
|
|
1674
|
+
chain: list[dict] = []
|
|
1675
|
+
pid = start_pid
|
|
1676
|
+
seen: set[int] = set()
|
|
1677
|
+
for _ in range(max_depth):
|
|
1678
|
+
if pid <= 0 or pid in seen:
|
|
1679
|
+
break
|
|
1680
|
+
seen.add(pid)
|
|
1681
|
+
try:
|
|
1682
|
+
# `stat` first: it carries the ppid, so a frame whose `cmdline` is
|
|
1683
|
+
# unreadable (hidepid, a uid boundary, a pid that just exited) costs
|
|
1684
|
+
# us its NAME but not the rest of the walk.
|
|
1685
|
+
stat = (proc_root / str(pid) / "stat").read_text(encoding="utf-8", errors="replace")
|
|
1686
|
+
ppid = int(stat.rsplit(") ", 1)[1].split()[1])
|
|
1687
|
+
except (OSError, ValueError, IndexError):
|
|
1688
|
+
break
|
|
1689
|
+
try:
|
|
1690
|
+
raw = (proc_root / str(pid) / "cmdline").read_bytes()
|
|
1691
|
+
argv = [part for part in raw.decode("utf-8", "replace").split("\0") if part]
|
|
1692
|
+
except OSError:
|
|
1693
|
+
argv = []
|
|
1694
|
+
if argv:
|
|
1695
|
+
# Basename only a real path: `sshd: gary@pts/6` would otherwise
|
|
1696
|
+
# basename to `6`, which is both meaningless and a hint at a real user.
|
|
1697
|
+
argv0 = argv[0]
|
|
1698
|
+
name = (
|
|
1699
|
+
PurePosixPath(argv0).name.lstrip("-")
|
|
1700
|
+
if not (set(argv0) & {" ", ":", "\t"}) else "?"
|
|
1701
|
+
)
|
|
1702
|
+
chain.append({
|
|
1703
|
+
"pid": pid,
|
|
1704
|
+
"name": name if _EXEC_NAME_RE.match(name) else "?",
|
|
1705
|
+
"flags": sorted(set(argv[1:]) & _HEADLESS_FLAGS),
|
|
1706
|
+
"replyFlags": sorted(set(argv[1:]) & _REPLY_CHANNEL_FLAGS),
|
|
1707
|
+
# False when the process exposes no arguments at all — either it
|
|
1708
|
+
# genuinely had none, or it overwrote its argv (see _classify_ancestry).
|
|
1709
|
+
"hasArgs": len(argv) > 1,
|
|
1710
|
+
})
|
|
1711
|
+
if pid == 1:
|
|
1712
|
+
break
|
|
1713
|
+
pid = ppid
|
|
1714
|
+
return chain
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def _classify_ancestry(chain: list[dict]) -> dict:
|
|
1718
|
+
"""Derive ``{mode, host, harness}`` from an ancestry chain. Pure.
|
|
1719
|
+
|
|
1720
|
+
The **nearest** recognised harness ancestor decides both axes, so a nested
|
|
1721
|
+
session is read as itself rather than as whatever launched its launcher.
|
|
1722
|
+
|
|
1723
|
+
Asymmetric by design. Claiming ``non-interactive`` makes a skill take silent
|
|
1724
|
+
defaults, so it is claimed only from a verified harness carrying an explicit
|
|
1725
|
+
headless flag. Claiming ``interactive`` only risks a question nobody answers
|
|
1726
|
+
— today's behavior — so it is claimed from a verified harness that exposes
|
|
1727
|
+
arguments and carries no headless flag. Everything else is ``None``
|
|
1728
|
+
(reported as ``unknown``), never a guess either way.
|
|
1729
|
+
|
|
1730
|
+
A harness exposing **no arguments at all** yields no mode. Measured: Pi
|
|
1731
|
+
overwrites its own argv with its process title, so ``/proc/<pid>/cmdline``
|
|
1732
|
+
for a ``pi -p --mode json`` session reads exactly ``pi`` with the flags
|
|
1733
|
+
erased. Without this rule that session is read as *interactive* — a
|
|
1734
|
+
confidently wrong claim in the dangerous direction. The rule is written
|
|
1735
|
+
against the argv, not against Pi, so it covers any harness that sets a
|
|
1736
|
+
process title, and Pi starts detecting again for free if it ever stops.
|
|
1737
|
+
"""
|
|
1738
|
+
for entry in chain:
|
|
1739
|
+
host = next((hid for name, hid in _ANCESTRY_HOSTS if entry["name"] == name), None)
|
|
1740
|
+
if host is None:
|
|
1741
|
+
continue
|
|
1742
|
+
if entry["name"] not in _ANCESTRY_MODE_HARNESSES or not entry["hasArgs"]:
|
|
1743
|
+
return {"mode": None, "host": host, "harness": entry["name"]}
|
|
1744
|
+
if entry["flags"] and entry["replyFlags"]:
|
|
1745
|
+
# Headless-looking but a reply channel is wired back in — refuse both
|
|
1746
|
+
# claims rather than pick the wrong one.
|
|
1747
|
+
return {"mode": None, "host": host, "harness": entry["name"]}
|
|
1748
|
+
mode = "non-interactive" if entry["flags"] else "interactive"
|
|
1749
|
+
return {"mode": mode, "host": host, "harness": entry["name"]}
|
|
1750
|
+
return {"mode": None, "host": None, "harness": None}
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
def _bundle_agent(root: Path) -> str | None:
|
|
1754
|
+
"""The adapter id this script was installed as, from its bundle sentinel.
|
|
1755
|
+
|
|
1756
|
+
``scripts/forge-session.py`` is copied byte-identically into every
|
|
1757
|
+
``adapters/<agent>/scripts/``, so the sentinel beside it names the host
|
|
1758
|
+
exactly — the one host fact that is neither guessed nor inferred. Absent at
|
|
1759
|
+
the mainline repo root (a dogfood checkout is not a bundle), which is why
|
|
1760
|
+
``_check_interaction_mode`` falls back to ancestry rather than failing.
|
|
1761
|
+
"""
|
|
1762
|
+
try:
|
|
1763
|
+
manifest = root / ".feature-forge-bundle.json"
|
|
1764
|
+
if not manifest.is_file():
|
|
1765
|
+
return None
|
|
1766
|
+
agent = _load_config(manifest).get("agent")
|
|
1767
|
+
except (OSError, ValueError, RecursionError):
|
|
1768
|
+
return None
|
|
1769
|
+
return agent if isinstance(agent, str) and agent in _ADAPTER_AGENT_IDS else None
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def _check_interaction_mode(ctx: _CheckContext) -> dict:
|
|
1773
|
+
"""Report the session's interaction mode and host as DATA a skill reads.
|
|
1774
|
+
|
|
1775
|
+
Closes the gap #261 measured: a model has no observable signal for "this
|
|
1776
|
+
invocation has no reply channel", so off-Claude headless runs self-assess
|
|
1777
|
+
rung 2, emit a prose question and stall — including every rauf loop
|
|
1778
|
+
iteration, which runs non-interactively by construction.
|
|
1779
|
+
|
|
1780
|
+
The split this check is built on: a model CAN observe rung 1 vs rung 2 (does
|
|
1781
|
+
it have a structured question tool) and CANNOT observe rung 2 vs rung 3 (is
|
|
1782
|
+
there a reply channel). This supplies exactly the half the model cannot see,
|
|
1783
|
+
and never the half it can — so it informs the ladder without becoming the
|
|
1784
|
+
host-implies-capability proxy INV-5 forbids.
|
|
1785
|
+
|
|
1786
|
+
Precedence: the launcher's explicit stamp, then verified process ancestry,
|
|
1787
|
+
then ``unknown``. ``unknown`` means "self-assess as today" and is reported
|
|
1788
|
+
as ``na``; it is NEVER reported as non-interactive, because guessing
|
|
1789
|
+
headless would make an interactive session silently skip its questions and
|
|
1790
|
+
take no-write defaults — a silent behavior change traded for a visible
|
|
1791
|
+
stall, which is worse than the bug.
|
|
1792
|
+
|
|
1793
|
+
Spawns nothing: the whole check is one env read plus ``/proc`` reads.
|
|
1794
|
+
"""
|
|
1795
|
+
raw = os.environ.get(INTERACTION_ENV_VAR)
|
|
1796
|
+
stamp = raw.strip().lower() if isinstance(raw, str) else ""
|
|
1797
|
+
chain = _process_ancestry(os.getppid())
|
|
1798
|
+
inferred = _classify_ancestry(chain)
|
|
1799
|
+
bundle_agent = _bundle_agent(_BUNDLE_ROOT)
|
|
1800
|
+
|
|
1801
|
+
mode, source = (stamp, "env-stamp") if stamp in INTERACTION_MODES else (None, None)
|
|
1802
|
+
# A POSITIVE contradiction between the two signals resolves to neither. The
|
|
1803
|
+
# stamp normally wins (it is stated, not inferred), but when ancestry has a
|
|
1804
|
+
# confident opposite reading the honest answer is that we do not know — and
|
|
1805
|
+
# `unknown` (self-assess, ask) is the only resolution that cannot silently
|
|
1806
|
+
# skip a question in a session someone is actually watching. Ancestry that
|
|
1807
|
+
# merely fails to read a mode is not a contradiction and does not trigger this.
|
|
1808
|
+
conflict = (
|
|
1809
|
+
source == "env-stamp"
|
|
1810
|
+
and inferred["mode"] is not None
|
|
1811
|
+
and inferred["mode"] != mode
|
|
1812
|
+
)
|
|
1813
|
+
if conflict:
|
|
1814
|
+
mode, source = None, None
|
|
1815
|
+
if mode is None and not conflict and inferred["mode"] is not None:
|
|
1816
|
+
mode, source = inferred["mode"], "ancestry"
|
|
1817
|
+
host = bundle_agent or inferred["host"]
|
|
1818
|
+
host_arg = None if host is None else (host if host in _NAMED_HOST_ARGS else "generic")
|
|
1819
|
+
|
|
1820
|
+
evidence: dict = {
|
|
1821
|
+
"mode": mode or "unknown",
|
|
1822
|
+
"modeSource": source,
|
|
1823
|
+
"rung": 3 if mode == "non-interactive" else None,
|
|
1824
|
+
"host": host,
|
|
1825
|
+
"hostArg": host_arg,
|
|
1826
|
+
"hostSource": "bundle" if bundle_agent else ("ancestry" if host else None),
|
|
1827
|
+
"envVar": INTERACTION_ENV_VAR,
|
|
1828
|
+
"envStamp": _head(raw) if raw else None,
|
|
1829
|
+
"harness": inferred["harness"],
|
|
1830
|
+
# Basenames and headless flags only — never a parent's raw argv (§ _process_ancestry).
|
|
1831
|
+
"ancestry": chain,
|
|
1832
|
+
}
|
|
1833
|
+
if raw and stamp not in INTERACTION_MODES:
|
|
1834
|
+
evidence["envStampError"] = (
|
|
1835
|
+
f"{INTERACTION_ENV_VAR} is not one of {', '.join(INTERACTION_MODES)}"
|
|
1836
|
+
)
|
|
1837
|
+
if conflict:
|
|
1838
|
+
evidence["conflict"] = (
|
|
1839
|
+
f"{INTERACTION_ENV_VAR}={stamp} but {inferred['harness']} ancestry "
|
|
1840
|
+
f"looks {inferred['mode']} — neither is trusted"
|
|
1841
|
+
)
|
|
1842
|
+
|
|
1843
|
+
where = f"host {host or 'unknown'}"
|
|
1844
|
+
if conflict:
|
|
1845
|
+
return _result(
|
|
1846
|
+
"warn",
|
|
1847
|
+
f"interaction signals contradict ({where}): {evidence['conflict']}; "
|
|
1848
|
+
"treating the mode as unknown — skills self-assess the rung",
|
|
1849
|
+
evidence,
|
|
1850
|
+
_remedy(
|
|
1851
|
+
f"Unset {INTERACTION_ENV_VAR} in this shell if it was exported by hand, or "
|
|
1852
|
+
f"correct the launcher that sets it — a stale stamp claiming 'non-interactive' "
|
|
1853
|
+
f"in an attended session would make skills skip their questions silently",
|
|
1854
|
+
None,
|
|
1855
|
+
"read-only",
|
|
1856
|
+
),
|
|
1857
|
+
)
|
|
1858
|
+
if mode is None:
|
|
1859
|
+
return _result(
|
|
1860
|
+
"na",
|
|
1861
|
+
f"interaction mode undetermined ({where}) — skills self-assess the rung as before",
|
|
1862
|
+
evidence,
|
|
1863
|
+
_remedy(
|
|
1864
|
+
f"Have the launcher state the mode: set {INTERACTION_ENV_VAR} to "
|
|
1865
|
+
f"'non-interactive' when spawning a session with no reply channel, or "
|
|
1866
|
+
f"'interactive' otherwise. Never guessed from the host (INV-5)",
|
|
1867
|
+
None,
|
|
1868
|
+
"read-only",
|
|
1869
|
+
),
|
|
1870
|
+
)
|
|
1871
|
+
if mode == "non-interactive":
|
|
1872
|
+
return _result(
|
|
1873
|
+
"ok",
|
|
1874
|
+
f"non-interactive session ({where}, via {source}) — rung 3: declared defaults apply",
|
|
1875
|
+
evidence,
|
|
1876
|
+
)
|
|
1877
|
+
return _result("ok", f"interactive session ({where}, via {source})", evidence)
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
#: The check registry — registry order is output order (docs/doctor-checks.md).
|
|
1881
|
+
DOCTOR_CHECKS: Final[tuple[_CheckSpec, ...]] = (
|
|
1882
|
+
_make_spec("plugin-root", "blocking", _check_plugin_root),
|
|
1883
|
+
_make_spec("root-version-skew", "advisory", _check_root_version_skew),
|
|
1884
|
+
_make_spec("runner-binary", "blocking", _check_runner_binary),
|
|
1885
|
+
_make_spec("runner-version", "blocking", _check_runner_version),
|
|
1886
|
+
_make_spec("runner-wired", "blocking", _check_runner_wired),
|
|
1887
|
+
_make_spec("runner-legacy-layout", "blocking", _check_runner_legacy_layout),
|
|
1888
|
+
_make_spec("runner-artifacts-stale", "advisory", _check_runner_artifacts_stale),
|
|
1889
|
+
_make_spec("runner-profile-drift", "advisory", _check_runner_profile_drift),
|
|
1890
|
+
_make_spec("config-completeness", "advisory", _check_config_completeness),
|
|
1891
|
+
_make_spec("config-schema", "advisory", _check_config_schema),
|
|
1892
|
+
_make_spec("config-local-ignored", "advisory", _check_config_local_ignored),
|
|
1893
|
+
_make_spec("backlog-present", "blocking", _check_backlog_present),
|
|
1894
|
+
_make_spec("backlog-valid", "blocking", _check_backlog_valid),
|
|
1895
|
+
_make_spec("branch-state", "advisory", _check_branch_state),
|
|
1896
|
+
_make_spec("gh-available", "advisory", _check_gh_available),
|
|
1897
|
+
_make_spec("sandbox-root", "advisory", _check_sandbox_root),
|
|
1898
|
+
_make_spec("interaction-mode", "advisory", _check_interaction_mode),
|
|
1899
|
+
)
|
|
1900
|
+
|
|
1901
|
+
#: The ids, in registry order, for ``--check`` choices and the catalog parity test.
|
|
1902
|
+
DOCTOR_CHECK_IDS: Final[tuple[str, ...]] = tuple(spec.id for spec in DOCTOR_CHECKS)
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
def _run_checks(ctx: _CheckContext, specs: list[_CheckSpec] | tuple[_CheckSpec, ...]) -> list[dict]:
|
|
1906
|
+
"""Run each spec with crash isolation and return the validated records.
|
|
1907
|
+
|
|
1908
|
+
A check that raises — or returns something ``_check_record`` rejects, or
|
|
1909
|
+
something ``json.dumps`` cannot serialise — becomes an ``na`` record whose
|
|
1910
|
+
``detail`` names the exception. A ``fail`` from a check outside
|
|
1911
|
+
``FAIL_PROMOTED_CHECK_IDS`` is demoted to ``warn`` with
|
|
1912
|
+
``evidence.demotedFromFail`` set, so INV-1 holds structurally.
|
|
1913
|
+
"""
|
|
1914
|
+
records: list[dict] = []
|
|
1915
|
+
for spec in specs:
|
|
1916
|
+
try:
|
|
1917
|
+
raw = spec.run(ctx)
|
|
1918
|
+
if not isinstance(raw, dict):
|
|
1919
|
+
raise TypeError(f"check returned {type(raw).__name__}, expected dict")
|
|
1920
|
+
status = raw.get("status")
|
|
1921
|
+
evidence = raw.get("evidence")
|
|
1922
|
+
if status == "fail" and spec.id not in FAIL_PROMOTED_CHECK_IDS:
|
|
1923
|
+
status = "warn"
|
|
1924
|
+
evidence = {**(evidence or {}), "demotedFromFail": True}
|
|
1925
|
+
record = _check_record(
|
|
1926
|
+
spec.id, status, spec.severity, raw.get("detail", ""), evidence, raw.get("remedy"),
|
|
1927
|
+
)
|
|
1928
|
+
json.dumps(record)
|
|
1929
|
+
except Exception as exc: # crash isolation (INV-3)
|
|
1930
|
+
record = _check_record(
|
|
1931
|
+
spec.id, "na", spec.severity, f"check crashed: {_exc_text(exc)}",
|
|
1932
|
+
)
|
|
1933
|
+
records.append(record)
|
|
1934
|
+
return records
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
def _checks_summary(checks: list[dict]) -> dict:
|
|
1938
|
+
"""Count ``checks[]`` by status, always emitting every status key."""
|
|
1939
|
+
summary = {status: 0 for status in CHECK_STATUSES}
|
|
1940
|
+
for record in checks:
|
|
1941
|
+
summary[record["status"]] += 1
|
|
1942
|
+
return summary
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
def cluster_checks(checks: list[dict]) -> list[dict]:
|
|
1946
|
+
"""Group ``checks[]`` with a runnable remedy by identical ``remedy.command``.
|
|
1947
|
+
|
|
1948
|
+
Returns ``[{command, safety, checkIds[], description}, …]`` in first-seen
|
|
1949
|
+
order. Records whose remedy is null, or whose remedy has no command
|
|
1950
|
+
(description-only advice), are not clustered — they are report-only.
|
|
1951
|
+
Identical commands merge; the cluster's ``safety`` is the most conservative
|
|
1952
|
+
tier among its members and its ``description`` the first member's.
|
|
1953
|
+
"""
|
|
1954
|
+
clusters: dict[str, dict] = {}
|
|
1955
|
+
for record in checks:
|
|
1956
|
+
remedy = record.get("remedy")
|
|
1957
|
+
if not isinstance(remedy, dict) or not remedy.get("command"):
|
|
1958
|
+
continue
|
|
1959
|
+
command = remedy["command"]
|
|
1960
|
+
entry = clusters.get(command)
|
|
1961
|
+
if entry is None:
|
|
1962
|
+
clusters[command] = {
|
|
1963
|
+
"command": command,
|
|
1964
|
+
"safety": remedy["safety"],
|
|
1965
|
+
"checkIds": [record["id"]],
|
|
1966
|
+
"description": remedy["description"],
|
|
1967
|
+
}
|
|
1968
|
+
continue
|
|
1969
|
+
entry["checkIds"].append(record["id"])
|
|
1970
|
+
if REMEDY_SAFETY_TIERS.index(remedy["safety"]) > REMEDY_SAFETY_TIERS.index(entry["safety"]):
|
|
1971
|
+
entry["safety"] = remedy["safety"]
|
|
1972
|
+
return list(clusters.values())
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
_CHECK_MARKERS: Final[dict[str, str]] = {"ok": "ok", "warn": "!", "fail": "X", "na": "na"}
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def _print_checks(report: dict, *, verbose: bool = False) -> None:
|
|
1979
|
+
"""Print the ``checks[]`` block: a one-line summary, then one line per finding.
|
|
1980
|
+
|
|
1981
|
+
``warn``/``fail`` records always print (with their remedy on the next
|
|
1982
|
+
line); ``ok``/``na`` records only with ``verbose``.
|
|
1983
|
+
"""
|
|
1984
|
+
summary = report.get("checksSummary") or {}
|
|
1985
|
+
print(
|
|
1986
|
+
"checks: " + ", ".join(f"{summary.get(s, 0)} {s}" for s in CHECK_STATUSES)
|
|
1987
|
+
)
|
|
1988
|
+
for record in report.get("checks") or []:
|
|
1989
|
+
if record["status"] in ("ok", "na") and not verbose:
|
|
1990
|
+
continue
|
|
1991
|
+
marker = _CHECK_MARKERS.get(record["status"], "?")
|
|
1992
|
+
print(f" {marker:>2} {record['id']}: {record['detail']}")
|
|
1993
|
+
remedy = record.get("remedy")
|
|
1994
|
+
if remedy:
|
|
1995
|
+
print(
|
|
1996
|
+
f" remedy [{remedy['safety']}]: "
|
|
1997
|
+
+ (remedy["command"] or remedy["description"])
|
|
1998
|
+
)
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
def _print_doctor(report: dict, *, verbose: bool = False) -> None:
|
|
2002
|
+
"""Print the human-readable doctor report (legacy lines, then the checks block)."""
|
|
2003
|
+
root = report["pluginRoot"]
|
|
2004
|
+
if root.get("resolved"):
|
|
2005
|
+
detail = " ".join(
|
|
2006
|
+
f"{key}={root[key]}" for key in ("version", "commit") if key in root
|
|
2007
|
+
)
|
|
2008
|
+
print(f"plugin root: {root['root']}" + (f" ({detail})" if detail else ""))
|
|
2009
|
+
else:
|
|
2010
|
+
print(f"plugin root: UNRESOLVED — {root.get('error', 'unknown')}")
|
|
2011
|
+
print(f"current branch: {report['currentBranch'] or '(not a git repo)'}")
|
|
2012
|
+
specs_error = report.get("specsDirError")
|
|
2013
|
+
print(
|
|
2014
|
+
f"specs dir: {report['specsDir']}"
|
|
2015
|
+
+ ("" if report["specsDirExists"] else " (MISSING)")
|
|
2016
|
+
+ (f" (UNREADABLE — {specs_error}; features not scanned)" if specs_error else "")
|
|
2017
|
+
)
|
|
2018
|
+
print(
|
|
2019
|
+
f"config: {report['configPath']}"
|
|
2020
|
+
+ ("" if report["configExists"] else " (MISSING)")
|
|
2021
|
+
)
|
|
2022
|
+
counts = report["counts"]
|
|
2023
|
+
print(
|
|
2024
|
+
f"features: {counts['active']} active "
|
|
2025
|
+
f"(paused: {counts['paused']}, abandoned: {counts['abandoned']})"
|
|
2026
|
+
)
|
|
2027
|
+
for feat in report["features"]:
|
|
2028
|
+
label = feat["name"] + (f" [{feat['epic']}]" if feat["epic"] else "")
|
|
2029
|
+
branch = feat["stateBranch"] or "?"
|
|
2030
|
+
if feat["branchMatchesState"] is False:
|
|
2031
|
+
if feat.get("branchReconcile") == "adopt-current":
|
|
2032
|
+
branch += " (MISMATCH — reconcile: adopt current branch)"
|
|
2033
|
+
elif feat.get("branchReconcile") == "warn-drift":
|
|
2034
|
+
branch += " (MISMATCH — on default branch; create a topic branch)"
|
|
2035
|
+
else:
|
|
2036
|
+
branch += " (MISMATCH vs current)"
|
|
2037
|
+
backlog = "exists" if feat["backlogExists"] else "MISSING"
|
|
2038
|
+
print(
|
|
2039
|
+
f" - {label}: stage={feat['currentStage']} "
|
|
2040
|
+
f"verify={feat['verifyState']} branch={branch} "
|
|
2041
|
+
f"backlog={backlog} ({feat['backlogPath']})"
|
|
2042
|
+
)
|
|
2043
|
+
invalid = report.get("invalidAutoVerifyKeys") or []
|
|
2044
|
+
if invalid:
|
|
2045
|
+
print(" ! invalid autoVerifyStages keys (ignored): " + ", ".join(invalid))
|
|
2046
|
+
duplicates = report.get("duplicateConfigKeys") or []
|
|
2047
|
+
if duplicates:
|
|
2048
|
+
print(
|
|
2049
|
+
" ! duplicate config keys (last value wins): " + ", ".join(duplicates)
|
|
2050
|
+
)
|
|
2051
|
+
rs = report.get("rootSandbox") or {}
|
|
2052
|
+
if rs.get("isRoot"):
|
|
2053
|
+
if rs.get("isSandboxSet"):
|
|
2054
|
+
print("root/sandbox: running as root; IS_SANDBOX already set — loop launch OK")
|
|
2055
|
+
else:
|
|
2056
|
+
print(
|
|
2057
|
+
"root/sandbox: running as root; IS_SANDBOX not set — forge-5-loop will "
|
|
2058
|
+
"export IS_SANDBOX=1 at launch so rauf's "
|
|
2059
|
+
"--dangerously-skip-permissions is not refused"
|
|
2060
|
+
)
|
|
2061
|
+
if "checks" in report:
|
|
2062
|
+
_print_checks(report, verbose=verbose)
|
|
2063
|
+
|
|
2064
|
+
|
|
2065
|
+
__all__ = [
|
|
2066
|
+
"_resolve_plugin_root",
|
|
2067
|
+
"_bundle_version",
|
|
2068
|
+
"_backlog_path",
|
|
2069
|
+
"doctor_report",
|
|
2070
|
+
"_root_sandbox_status",
|
|
2071
|
+
"CHECK_STATUSES",
|
|
2072
|
+
"CHECK_SEVERITIES",
|
|
2073
|
+
"REMEDY_SAFETY_TIERS",
|
|
2074
|
+
"FAIL_PROMOTED_CHECK_IDS",
|
|
2075
|
+
"NO_NA_CHECKS",
|
|
2076
|
+
"_CHECK_ID_RE",
|
|
2077
|
+
"_PROBE_TIMEOUT_S",
|
|
2078
|
+
"_PROBE_OUTPUT_CAP",
|
|
2079
|
+
"INTERACTION_ENV_VAR",
|
|
2080
|
+
"_ADAPTER_AGENT_IDS",
|
|
2081
|
+
"INTERACTION_MODES",
|
|
2082
|
+
"_ANCESTRY_MODE_HARNESSES",
|
|
2083
|
+
"_ANCESTRY_HOSTS",
|
|
2084
|
+
"_HEADLESS_FLAGS",
|
|
2085
|
+
"_REPLY_CHANNEL_FLAGS",
|
|
2086
|
+
"_ANCESTRY_MAX_DEPTH",
|
|
2087
|
+
"_EXEC_NAME_RE",
|
|
2088
|
+
"_NAMED_HOST_ARGS",
|
|
2089
|
+
"_remedy",
|
|
2090
|
+
"_result",
|
|
2091
|
+
"_check_record",
|
|
2092
|
+
"_exc_text",
|
|
2093
|
+
"_head",
|
|
2094
|
+
"_run_probe",
|
|
2095
|
+
"_render_runner_command",
|
|
2096
|
+
"_SEMVER_NUM",
|
|
2097
|
+
"_SEMVER_RE",
|
|
2098
|
+
"_INSTALLED_BY_RE",
|
|
2099
|
+
"_parse_semver",
|
|
2100
|
+
"_fmt_semver",
|
|
2101
|
+
"_parse_installed_by",
|
|
2102
|
+
"_first_backticked",
|
|
2103
|
+
"_JSON_SCHEMA_TYPES",
|
|
2104
|
+
"_schema_violations",
|
|
2105
|
+
"_stage_at_or_after",
|
|
2106
|
+
"_CheckContext",
|
|
2107
|
+
"_build_check_context",
|
|
2108
|
+
"_CheckSpec",
|
|
2109
|
+
"_make_spec",
|
|
2110
|
+
"_per_feature_remedy",
|
|
2111
|
+
"_feature_label",
|
|
2112
|
+
"_check_plugin_root",
|
|
2113
|
+
"_runner_unavailable",
|
|
2114
|
+
"_NETWORK_FETCH_RE",
|
|
2115
|
+
"_install_remedy",
|
|
2116
|
+
"_check_runner_binary",
|
|
2117
|
+
"_check_runner_version",
|
|
2118
|
+
"_check_runner_wired",
|
|
2119
|
+
"_check_runner_legacy_layout",
|
|
2120
|
+
"_check_runner_artifacts_stale",
|
|
2121
|
+
"_check_runner_profile_drift",
|
|
2122
|
+
"_check_config_schema",
|
|
2123
|
+
"_check_root_version_skew",
|
|
2124
|
+
"_CONFIG_KEYS_BY_STAGE",
|
|
2125
|
+
"_check_config_completeness",
|
|
2126
|
+
"_check_backlog_valid",
|
|
2127
|
+
"_check_gh_available",
|
|
2128
|
+
"_check_backlog_present",
|
|
2129
|
+
"_check_branch_state",
|
|
2130
|
+
"_check_sandbox_root",
|
|
2131
|
+
"_process_ancestry",
|
|
2132
|
+
"_classify_ancestry",
|
|
2133
|
+
"_bundle_agent",
|
|
2134
|
+
"_check_interaction_mode",
|
|
2135
|
+
"DOCTOR_CHECKS",
|
|
2136
|
+
"DOCTOR_CHECK_IDS",
|
|
2137
|
+
"_run_checks",
|
|
2138
|
+
"_checks_summary",
|
|
2139
|
+
"cluster_checks",
|
|
2140
|
+
"_CHECK_MARKERS",
|
|
2141
|
+
"_print_checks",
|
|
2142
|
+
"_print_doctor",
|
|
2143
|
+
"RECOVERY_MIN_RUNNER_VERSION",
|
|
2144
|
+
]
|