@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.
Files changed (753) hide show
  1. package/README.md +1 -1
  2. package/adapters/GENERATION-REPORT.md +20 -0
  3. package/adapters/claude/.claude-plugin/plugin.json +16 -0
  4. package/adapters/claude/.feature-forge-bundle.json +1 -1
  5. package/adapters/claude/agents/forge-verifier.md +16 -5
  6. package/adapters/claude/references/forge-config-schema.json +3 -3
  7. package/adapters/claude/references/portable-root.md +20 -16
  8. package/adapters/claude/references/preflight-and-self-heal.md +183 -0
  9. package/adapters/claude/references/process-overview.md +1 -1
  10. package/adapters/claude/references/select-outcome.md +138 -0
  11. package/adapters/claude/references/shared-conventions.md +175 -22
  12. package/adapters/claude/references/skill-frontmatter.schema.json +3 -1
  13. package/adapters/claude/references/stack-resolution.md +1 -1
  14. package/adapters/claude/references/stage-exit-protocol.md +19 -4
  15. package/adapters/claude/references/templates/root-hygiene/AGENTS.md +14 -0
  16. package/adapters/claude/references/templates/root-hygiene/CLAUDE.md +14 -0
  17. package/adapters/claude/references/templates/specs-hygiene/AGENTS.md +3 -1
  18. package/adapters/claude/references/templates/specs-hygiene/CLAUDE.md +2 -1
  19. package/adapters/claude/references/verifier-patterns/MEMORY.md +35 -0
  20. package/adapters/claude/references/verifier-patterns/pattern_absence_claims.md +36 -0
  21. package/adapters/claude/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
  22. package/adapters/claude/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
  23. package/adapters/claude/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
  24. package/adapters/claude/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
  25. package/adapters/claude/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
  26. package/adapters/claude/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
  27. package/adapters/claude/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
  28. package/adapters/claude/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
  29. package/adapters/claude/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
  30. package/adapters/claude/references/verifier-patterns/pattern_self_referential_control.md +26 -0
  31. package/adapters/claude/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
  32. package/adapters/claude/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
  33. package/adapters/claude/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
  34. package/adapters/claude/references/verifier-patterns/pattern_test_count_units.md +20 -0
  35. package/adapters/claude/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
  36. package/adapters/claude/references/verify-state.md +98 -0
  37. package/adapters/claude/scripts/forge-bootstrap.py +23 -2
  38. package/adapters/claude/scripts/forge-root.sh +104 -21
  39. package/adapters/claude/scripts/forge-session.py +620 -6973
  40. package/adapters/claude/scripts/forge_session/__init__.py +11 -0
  41. package/adapters/claude/scripts/forge_session/_common.py +1566 -0
  42. package/adapters/claude/scripts/forge_session/_doctor_util.py +218 -0
  43. package/adapters/claude/scripts/forge_session/cli.py +1067 -0
  44. package/adapters/claude/scripts/forge_session/decisions.py +355 -0
  45. package/adapters/claude/scripts/forge_session/discover.py +472 -0
  46. package/adapters/claude/scripts/forge_session/doctor.py +2144 -0
  47. package/adapters/claude/scripts/forge_session/exit.py +1081 -0
  48. package/adapters/claude/scripts/forge_session/outcomes.py +574 -0
  49. package/adapters/claude/scripts/forge_session/routes.py +1162 -0
  50. package/adapters/claude/scripts/forge_session/state.py +1479 -0
  51. package/adapters/claude/scripts/forge_session/topology.py +443 -0
  52. package/adapters/claude/skills/forge/SKILL.md +7 -7
  53. package/adapters/claude/skills/forge/references/process-overview.md +1 -1
  54. package/adapters/claude/skills/forge/references/shared-conventions.md +175 -22
  55. package/adapters/claude/skills/forge/references/stage-exit-protocol.md +19 -4
  56. package/adapters/claude/skills/forge-0-epic/SKILL.md +7 -7
  57. package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +9 -5
  58. package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +6 -1
  59. package/adapters/claude/skills/forge-0-epic/references/portable-root.md +20 -16
  60. package/adapters/claude/skills/forge-0-epic/references/shared-conventions.md +175 -22
  61. package/adapters/claude/skills/forge-0-epic/references/stage-exit-protocol.md +19 -4
  62. package/adapters/claude/skills/forge-1-prd/SKILL.md +7 -7
  63. package/adapters/claude/skills/forge-1-prd/references/shared-conventions.md +175 -22
  64. package/adapters/claude/skills/forge-1-prd/references/stage-exit-protocol.md +19 -4
  65. package/adapters/claude/skills/forge-2-tech/SKILL.md +7 -7
  66. package/adapters/claude/skills/forge-2-tech/references/shared-conventions.md +175 -22
  67. package/adapters/claude/skills/forge-2-tech/references/stack-resolution.md +1 -1
  68. package/adapters/claude/skills/forge-2-tech/references/stage-exit-protocol.md +19 -4
  69. package/adapters/claude/skills/forge-3-specs/SKILL.md +5 -5
  70. package/adapters/claude/skills/forge-3-specs/references/shared-conventions.md +175 -22
  71. package/adapters/claude/skills/forge-3-specs/references/stage-exit-protocol.md +19 -4
  72. package/adapters/claude/skills/forge-4-backlog/SKILL.md +16 -8
  73. package/adapters/claude/skills/forge-4-backlog/references/shared-conventions.md +175 -22
  74. package/adapters/claude/skills/forge-4-backlog/references/stage-exit-protocol.md +19 -4
  75. package/adapters/claude/skills/forge-4-backlog/references/verify-state.md +98 -0
  76. package/adapters/claude/skills/forge-5-loop/SKILL.md +36 -41
  77. package/adapters/claude/skills/forge-5-loop/references/agent-selection.md +4 -0
  78. package/adapters/claude/skills/forge-5-loop/references/preflight-and-self-heal.md +183 -0
  79. package/adapters/claude/skills/forge-5-loop/references/recovery-procedure.md +10 -0
  80. package/adapters/claude/skills/forge-5-loop/references/runner-contract.md +15 -5
  81. package/adapters/claude/skills/forge-5-loop/references/shared-conventions.md +175 -22
  82. package/adapters/claude/skills/forge-5-loop/references/stage-exit-protocol.md +19 -4
  83. package/adapters/claude/skills/forge-5-loop/references/verify-state.md +98 -0
  84. package/adapters/claude/skills/forge-6-docs/SKILL.md +21 -14
  85. package/adapters/claude/skills/forge-6-docs/references/shared-conventions.md +175 -22
  86. package/adapters/claude/skills/forge-6-docs/references/stage-exit-protocol.md +19 -4
  87. package/adapters/claude/skills/forge-6-docs/references/verify-state.md +98 -0
  88. package/adapters/claude/skills/forge-bootstrap/SKILL.md +19 -10
  89. package/adapters/claude/skills/forge-bootstrap/references/shared-conventions.md +572 -0
  90. package/adapters/claude/skills/forge-fix/SKILL.md +15 -18
  91. package/adapters/claude/skills/forge-fix/references/select-outcome.md +138 -0
  92. package/adapters/claude/skills/forge-fix/references/shared-conventions.md +175 -22
  93. package/adapters/claude/skills/forge-fix/references/stage-exit-protocol.md +19 -4
  94. package/adapters/claude/skills/forge-guide/SKILL.md +87 -1
  95. package/adapters/claude/skills/forge-guide/references/forge-config-schema.json +3 -3
  96. package/adapters/claude/skills/forge-guide/references/preflight-and-self-heal.md +183 -0
  97. package/adapters/claude/skills/forge-guide/references/process-overview.md +1 -1
  98. package/adapters/claude/skills/forge-guide/references/shared-conventions.md +175 -22
  99. package/adapters/claude/skills/forge-guide/references/stack-resolution.md +1 -1
  100. package/adapters/claude/skills/forge-init/SKILL.md +58 -7
  101. package/adapters/claude/skills/forge-init/references/preflight-and-self-heal.md +183 -0
  102. package/adapters/claude/skills/forge-init/references/shared-conventions.md +572 -0
  103. package/adapters/claude/skills/forge-verify/SKILL.md +20 -23
  104. package/adapters/claude/skills/forge-verify/references/findings-template.md +2 -2
  105. package/adapters/claude/skills/forge-verify/references/select-outcome.md +138 -0
  106. package/adapters/claude/skills/forge-verify/references/shared-conventions.md +175 -22
  107. package/adapters/claude/skills/forge-verify/references/stage-exit-protocol.md +19 -4
  108. package/adapters/claude/skills/forge-verify/references/verification-checklists/epic.md +1 -1
  109. package/adapters/claude/skills/forge-verify/references/verifier-patterns/MEMORY.md +35 -0
  110. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_absence_claims.md +36 -0
  111. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
  112. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
  113. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
  114. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
  115. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
  116. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
  117. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
  118. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
  119. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
  120. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_self_referential_control.md +26 -0
  121. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
  122. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
  123. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
  124. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_test_count_units.md +20 -0
  125. package/adapters/claude/skills/forge-verify/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
  126. package/adapters/codex/.feature-forge-bundle.json +1 -1
  127. package/adapters/codex/agents/forge-researcher.toml +1 -1
  128. package/adapters/codex/agents/forge-verifier.toml +17 -6
  129. package/adapters/codex/references/forge-config-schema.json +5 -5
  130. package/adapters/codex/references/portable-root.md +20 -16
  131. package/adapters/codex/references/preflight-and-self-heal.md +183 -0
  132. package/adapters/codex/references/process-overview.md +10 -10
  133. package/adapters/codex/references/select-outcome.md +138 -0
  134. package/adapters/codex/references/shared-conventions.md +188 -35
  135. package/adapters/codex/references/skill-frontmatter.schema.json +3 -1
  136. package/adapters/codex/references/stack-resolution.md +1 -1
  137. package/adapters/codex/references/stage-exit-protocol.md +22 -7
  138. package/adapters/codex/references/templates/root-hygiene/AGENTS.md +14 -0
  139. package/adapters/codex/references/templates/root-hygiene/CLAUDE.md +14 -0
  140. package/adapters/codex/references/templates/specs-hygiene/AGENTS.md +3 -1
  141. package/adapters/codex/references/templates/specs-hygiene/CLAUDE.md +2 -1
  142. package/adapters/codex/references/verifier-patterns/MEMORY.md +35 -0
  143. package/adapters/codex/references/verifier-patterns/pattern_absence_claims.md +36 -0
  144. package/adapters/codex/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
  145. package/adapters/codex/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
  146. package/adapters/codex/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
  147. package/adapters/codex/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
  148. package/adapters/codex/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
  149. package/adapters/codex/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
  150. package/adapters/codex/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
  151. package/adapters/codex/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
  152. package/adapters/codex/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
  153. package/adapters/codex/references/verifier-patterns/pattern_self_referential_control.md +26 -0
  154. package/adapters/codex/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
  155. package/adapters/codex/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
  156. package/adapters/codex/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
  157. package/adapters/codex/references/verifier-patterns/pattern_test_count_units.md +20 -0
  158. package/adapters/codex/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
  159. package/adapters/codex/references/verify-state.md +98 -0
  160. package/adapters/codex/scripts/forge-bootstrap.py +23 -2
  161. package/adapters/codex/scripts/forge-root.sh +104 -21
  162. package/adapters/codex/scripts/forge-session.py +620 -6973
  163. package/adapters/codex/scripts/forge_session/__init__.py +11 -0
  164. package/adapters/codex/scripts/forge_session/_common.py +1566 -0
  165. package/adapters/codex/scripts/forge_session/_doctor_util.py +218 -0
  166. package/adapters/codex/scripts/forge_session/cli.py +1067 -0
  167. package/adapters/codex/scripts/forge_session/decisions.py +355 -0
  168. package/adapters/codex/scripts/forge_session/discover.py +472 -0
  169. package/adapters/codex/scripts/forge_session/doctor.py +2144 -0
  170. package/adapters/codex/scripts/forge_session/exit.py +1081 -0
  171. package/adapters/codex/scripts/forge_session/outcomes.py +574 -0
  172. package/adapters/codex/scripts/forge_session/routes.py +1162 -0
  173. package/adapters/codex/scripts/forge_session/state.py +1479 -0
  174. package/adapters/codex/scripts/forge_session/topology.py +443 -0
  175. package/adapters/codex/skills/forge/SKILL.md +40 -40
  176. package/adapters/codex/skills/forge/references/process-overview.md +10 -10
  177. package/adapters/codex/skills/forge/references/shared-conventions.md +188 -35
  178. package/adapters/codex/skills/forge/references/stage-exit-protocol.md +22 -7
  179. package/adapters/codex/skills/forge-0-epic/SKILL.md +15 -15
  180. package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +14 -10
  181. package/adapters/codex/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
  182. package/adapters/codex/skills/forge-0-epic/references/portable-root.md +20 -16
  183. package/adapters/codex/skills/forge-0-epic/references/shared-conventions.md +188 -35
  184. package/adapters/codex/skills/forge-0-epic/references/stage-exit-protocol.md +22 -7
  185. package/adapters/codex/skills/forge-1-prd/SKILL.md +15 -15
  186. package/adapters/codex/skills/forge-1-prd/references/shared-conventions.md +188 -35
  187. package/adapters/codex/skills/forge-1-prd/references/stage-exit-protocol.md +22 -7
  188. package/adapters/codex/skills/forge-2-tech/SKILL.md +15 -15
  189. package/adapters/codex/skills/forge-2-tech/references/shared-conventions.md +188 -35
  190. package/adapters/codex/skills/forge-2-tech/references/stack-resolution.md +1 -1
  191. package/adapters/codex/skills/forge-2-tech/references/stage-exit-protocol.md +22 -7
  192. package/adapters/codex/skills/forge-3-specs/SKILL.md +10 -10
  193. package/adapters/codex/skills/forge-3-specs/references/shared-conventions.md +188 -35
  194. package/adapters/codex/skills/forge-3-specs/references/stage-exit-protocol.md +22 -7
  195. package/adapters/codex/skills/forge-4-backlog/SKILL.md +19 -11
  196. package/adapters/codex/skills/forge-4-backlog/references/shared-conventions.md +188 -35
  197. package/adapters/codex/skills/forge-4-backlog/references/stage-exit-protocol.md +22 -7
  198. package/adapters/codex/skills/forge-4-backlog/references/verify-state.md +98 -0
  199. package/adapters/codex/skills/forge-5-loop/SKILL.md +48 -53
  200. package/adapters/codex/skills/forge-5-loop/references/agent-selection.md +5 -1
  201. package/adapters/codex/skills/forge-5-loop/references/preflight-and-self-heal.md +183 -0
  202. package/adapters/codex/skills/forge-5-loop/references/recovery-procedure.md +14 -4
  203. package/adapters/codex/skills/forge-5-loop/references/runner-contract.md +18 -8
  204. package/adapters/codex/skills/forge-5-loop/references/shared-conventions.md +188 -35
  205. package/adapters/codex/skills/forge-5-loop/references/stage-exit-protocol.md +22 -7
  206. package/adapters/codex/skills/forge-5-loop/references/verify-state.md +98 -0
  207. package/adapters/codex/skills/forge-6-docs/SKILL.md +27 -20
  208. package/adapters/codex/skills/forge-6-docs/references/shared-conventions.md +188 -35
  209. package/adapters/codex/skills/forge-6-docs/references/stage-exit-protocol.md +22 -7
  210. package/adapters/codex/skills/forge-6-docs/references/verify-state.md +98 -0
  211. package/adapters/codex/skills/forge-bootstrap/SKILL.md +23 -15
  212. package/adapters/codex/skills/forge-bootstrap/references/shared-conventions.md +572 -0
  213. package/adapters/codex/skills/forge-bootstrap/references/templates/hygiene/CLAUDE.md +2 -2
  214. package/adapters/codex/skills/forge-fix/SKILL.md +27 -30
  215. package/adapters/codex/skills/forge-fix/references/select-outcome.md +138 -0
  216. package/adapters/codex/skills/forge-fix/references/shared-conventions.md +188 -35
  217. package/adapters/codex/skills/forge-fix/references/stage-exit-protocol.md +22 -7
  218. package/adapters/codex/skills/forge-guide/SKILL.md +84 -7
  219. package/adapters/codex/skills/forge-guide/references/forge-config-schema.json +5 -5
  220. package/adapters/codex/skills/forge-guide/references/preflight-and-self-heal.md +183 -0
  221. package/adapters/codex/skills/forge-guide/references/process-overview.md +10 -10
  222. package/adapters/codex/skills/forge-guide/references/shared-conventions.md +188 -35
  223. package/adapters/codex/skills/forge-guide/references/stack-resolution.md +1 -1
  224. package/adapters/codex/skills/forge-init/SKILL.md +62 -12
  225. package/adapters/codex/skills/forge-init/references/preflight-and-self-heal.md +183 -0
  226. package/adapters/codex/skills/forge-init/references/shared-conventions.md +572 -0
  227. package/adapters/codex/skills/forge-verify/SKILL.md +27 -30
  228. package/adapters/codex/skills/forge-verify/references/findings-template.md +2 -2
  229. package/adapters/codex/skills/forge-verify/references/select-outcome.md +138 -0
  230. package/adapters/codex/skills/forge-verify/references/shared-conventions.md +188 -35
  231. package/adapters/codex/skills/forge-verify/references/stage-exit-protocol.md +22 -7
  232. package/adapters/codex/skills/forge-verify/references/verification-checklists/epic.md +2 -2
  233. package/adapters/codex/skills/forge-verify/references/verifier-patterns/MEMORY.md +35 -0
  234. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_absence_claims.md +36 -0
  235. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
  236. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
  237. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
  238. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
  239. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
  240. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
  241. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
  242. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
  243. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
  244. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_self_referential_control.md +26 -0
  245. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
  246. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
  247. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
  248. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_test_count_units.md +20 -0
  249. package/adapters/codex/skills/forge-verify/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
  250. package/adapters/copilot/.feature-forge-bundle.json +1 -1
  251. package/adapters/copilot/agents/forge-researcher.md +1 -1
  252. package/adapters/copilot/agents/forge-verifier.md +17 -6
  253. package/adapters/copilot/references/forge-config-schema.json +5 -5
  254. package/adapters/copilot/references/portable-root.md +20 -16
  255. package/adapters/copilot/references/preflight-and-self-heal.md +183 -0
  256. package/adapters/copilot/references/process-overview.md +10 -10
  257. package/adapters/copilot/references/select-outcome.md +138 -0
  258. package/adapters/copilot/references/shared-conventions.md +188 -35
  259. package/adapters/copilot/references/skill-frontmatter.schema.json +3 -1
  260. package/adapters/copilot/references/stack-resolution.md +1 -1
  261. package/adapters/copilot/references/stage-exit-protocol.md +22 -7
  262. package/adapters/copilot/references/templates/root-hygiene/AGENTS.md +14 -0
  263. package/adapters/copilot/references/templates/root-hygiene/CLAUDE.md +14 -0
  264. package/adapters/copilot/references/templates/specs-hygiene/AGENTS.md +3 -1
  265. package/adapters/copilot/references/templates/specs-hygiene/CLAUDE.md +2 -1
  266. package/adapters/copilot/references/verifier-patterns/MEMORY.md +35 -0
  267. package/adapters/copilot/references/verifier-patterns/pattern_absence_claims.md +36 -0
  268. package/adapters/copilot/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
  269. package/adapters/copilot/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
  270. package/adapters/copilot/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
  271. package/adapters/copilot/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
  272. package/adapters/copilot/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
  273. package/adapters/copilot/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
  274. package/adapters/copilot/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
  275. package/adapters/copilot/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
  276. package/adapters/copilot/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
  277. package/adapters/copilot/references/verifier-patterns/pattern_self_referential_control.md +26 -0
  278. package/adapters/copilot/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
  279. package/adapters/copilot/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
  280. package/adapters/copilot/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
  281. package/adapters/copilot/references/verifier-patterns/pattern_test_count_units.md +20 -0
  282. package/adapters/copilot/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
  283. package/adapters/copilot/references/verify-state.md +98 -0
  284. package/adapters/copilot/scripts/forge-bootstrap.py +23 -2
  285. package/adapters/copilot/scripts/forge-root.sh +104 -21
  286. package/adapters/copilot/scripts/forge-session.py +620 -6973
  287. package/adapters/copilot/scripts/forge_session/__init__.py +11 -0
  288. package/adapters/copilot/scripts/forge_session/_common.py +1566 -0
  289. package/adapters/copilot/scripts/forge_session/_doctor_util.py +218 -0
  290. package/adapters/copilot/scripts/forge_session/cli.py +1067 -0
  291. package/adapters/copilot/scripts/forge_session/decisions.py +355 -0
  292. package/adapters/copilot/scripts/forge_session/discover.py +472 -0
  293. package/adapters/copilot/scripts/forge_session/doctor.py +2144 -0
  294. package/adapters/copilot/scripts/forge_session/exit.py +1081 -0
  295. package/adapters/copilot/scripts/forge_session/outcomes.py +574 -0
  296. package/adapters/copilot/scripts/forge_session/routes.py +1162 -0
  297. package/adapters/copilot/scripts/forge_session/state.py +1479 -0
  298. package/adapters/copilot/scripts/forge_session/topology.py +443 -0
  299. package/adapters/copilot/skills/forge/forge.md +40 -40
  300. package/adapters/copilot/skills/forge/references/process-overview.md +10 -10
  301. package/adapters/copilot/skills/forge/references/shared-conventions.md +188 -35
  302. package/adapters/copilot/skills/forge/references/stage-exit-protocol.md +22 -7
  303. package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +15 -15
  304. package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +14 -10
  305. package/adapters/copilot/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
  306. package/adapters/copilot/skills/forge-0-epic/references/portable-root.md +20 -16
  307. package/adapters/copilot/skills/forge-0-epic/references/shared-conventions.md +188 -35
  308. package/adapters/copilot/skills/forge-0-epic/references/stage-exit-protocol.md +22 -7
  309. package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +15 -15
  310. package/adapters/copilot/skills/forge-1-prd/references/shared-conventions.md +188 -35
  311. package/adapters/copilot/skills/forge-1-prd/references/stage-exit-protocol.md +22 -7
  312. package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +15 -15
  313. package/adapters/copilot/skills/forge-2-tech/references/shared-conventions.md +188 -35
  314. package/adapters/copilot/skills/forge-2-tech/references/stack-resolution.md +1 -1
  315. package/adapters/copilot/skills/forge-2-tech/references/stage-exit-protocol.md +22 -7
  316. package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +10 -10
  317. package/adapters/copilot/skills/forge-3-specs/references/shared-conventions.md +188 -35
  318. package/adapters/copilot/skills/forge-3-specs/references/stage-exit-protocol.md +22 -7
  319. package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +19 -11
  320. package/adapters/copilot/skills/forge-4-backlog/references/shared-conventions.md +188 -35
  321. package/adapters/copilot/skills/forge-4-backlog/references/stage-exit-protocol.md +22 -7
  322. package/adapters/copilot/skills/forge-4-backlog/references/verify-state.md +98 -0
  323. package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +48 -53
  324. package/adapters/copilot/skills/forge-5-loop/references/agent-selection.md +5 -1
  325. package/adapters/copilot/skills/forge-5-loop/references/preflight-and-self-heal.md +183 -0
  326. package/adapters/copilot/skills/forge-5-loop/references/recovery-procedure.md +14 -4
  327. package/adapters/copilot/skills/forge-5-loop/references/runner-contract.md +18 -8
  328. package/adapters/copilot/skills/forge-5-loop/references/shared-conventions.md +188 -35
  329. package/adapters/copilot/skills/forge-5-loop/references/stage-exit-protocol.md +22 -7
  330. package/adapters/copilot/skills/forge-5-loop/references/verify-state.md +98 -0
  331. package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +27 -20
  332. package/adapters/copilot/skills/forge-6-docs/references/shared-conventions.md +188 -35
  333. package/adapters/copilot/skills/forge-6-docs/references/stage-exit-protocol.md +22 -7
  334. package/adapters/copilot/skills/forge-6-docs/references/verify-state.md +98 -0
  335. package/adapters/copilot/skills/forge-bootstrap/forge-bootstrap.md +23 -15
  336. package/adapters/copilot/skills/forge-bootstrap/references/shared-conventions.md +572 -0
  337. package/adapters/copilot/skills/forge-bootstrap/references/templates/hygiene/CLAUDE.md +2 -2
  338. package/adapters/copilot/skills/forge-fix/forge-fix.md +27 -30
  339. package/adapters/copilot/skills/forge-fix/references/select-outcome.md +138 -0
  340. package/adapters/copilot/skills/forge-fix/references/shared-conventions.md +188 -35
  341. package/adapters/copilot/skills/forge-fix/references/stage-exit-protocol.md +22 -7
  342. package/adapters/copilot/skills/forge-guide/forge-guide.md +84 -7
  343. package/adapters/copilot/skills/forge-guide/references/forge-config-schema.json +5 -5
  344. package/adapters/copilot/skills/forge-guide/references/preflight-and-self-heal.md +183 -0
  345. package/adapters/copilot/skills/forge-guide/references/process-overview.md +10 -10
  346. package/adapters/copilot/skills/forge-guide/references/shared-conventions.md +188 -35
  347. package/adapters/copilot/skills/forge-guide/references/stack-resolution.md +1 -1
  348. package/adapters/copilot/skills/forge-init/forge-init.md +62 -12
  349. package/adapters/copilot/skills/forge-init/references/preflight-and-self-heal.md +183 -0
  350. package/adapters/copilot/skills/forge-init/references/shared-conventions.md +572 -0
  351. package/adapters/copilot/skills/forge-verify/forge-verify.md +27 -30
  352. package/adapters/copilot/skills/forge-verify/references/findings-template.md +2 -2
  353. package/adapters/copilot/skills/forge-verify/references/select-outcome.md +138 -0
  354. package/adapters/copilot/skills/forge-verify/references/shared-conventions.md +188 -35
  355. package/adapters/copilot/skills/forge-verify/references/stage-exit-protocol.md +22 -7
  356. package/adapters/copilot/skills/forge-verify/references/verification-checklists/epic.md +2 -2
  357. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/MEMORY.md +35 -0
  358. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_absence_claims.md +36 -0
  359. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
  360. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
  361. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
  362. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
  363. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
  364. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
  365. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
  366. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
  367. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
  368. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_self_referential_control.md +26 -0
  369. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
  370. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
  371. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
  372. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_test_count_units.md +20 -0
  373. package/adapters/copilot/skills/forge-verify/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
  374. package/adapters/cursor/.feature-forge-bundle.json +1 -1
  375. package/adapters/cursor/agents/forge-researcher.mdc +1 -1
  376. package/adapters/cursor/agents/forge-verifier.mdc +17 -6
  377. package/adapters/cursor/references/forge-config-schema.json +5 -5
  378. package/adapters/cursor/references/portable-root.md +20 -16
  379. package/adapters/cursor/references/preflight-and-self-heal.md +183 -0
  380. package/adapters/cursor/references/process-overview.md +10 -10
  381. package/adapters/cursor/references/select-outcome.md +138 -0
  382. package/adapters/cursor/references/shared-conventions.md +188 -35
  383. package/adapters/cursor/references/skill-frontmatter.schema.json +3 -1
  384. package/adapters/cursor/references/stack-resolution.md +1 -1
  385. package/adapters/cursor/references/stage-exit-protocol.md +22 -7
  386. package/adapters/cursor/references/templates/root-hygiene/AGENTS.md +14 -0
  387. package/adapters/cursor/references/templates/root-hygiene/CLAUDE.md +14 -0
  388. package/adapters/cursor/references/templates/specs-hygiene/AGENTS.md +3 -1
  389. package/adapters/cursor/references/templates/specs-hygiene/CLAUDE.md +2 -1
  390. package/adapters/cursor/references/verifier-patterns/MEMORY.md +35 -0
  391. package/adapters/cursor/references/verifier-patterns/pattern_absence_claims.md +36 -0
  392. package/adapters/cursor/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
  393. package/adapters/cursor/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
  394. package/adapters/cursor/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
  395. package/adapters/cursor/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
  396. package/adapters/cursor/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
  397. package/adapters/cursor/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
  398. package/adapters/cursor/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
  399. package/adapters/cursor/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
  400. package/adapters/cursor/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
  401. package/adapters/cursor/references/verifier-patterns/pattern_self_referential_control.md +26 -0
  402. package/adapters/cursor/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
  403. package/adapters/cursor/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
  404. package/adapters/cursor/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
  405. package/adapters/cursor/references/verifier-patterns/pattern_test_count_units.md +20 -0
  406. package/adapters/cursor/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
  407. package/adapters/cursor/references/verify-state.md +98 -0
  408. package/adapters/cursor/scripts/forge-bootstrap.py +23 -2
  409. package/adapters/cursor/scripts/forge-root.sh +104 -21
  410. package/adapters/cursor/scripts/forge-session.py +620 -6973
  411. package/adapters/cursor/scripts/forge_session/__init__.py +11 -0
  412. package/adapters/cursor/scripts/forge_session/_common.py +1566 -0
  413. package/adapters/cursor/scripts/forge_session/_doctor_util.py +218 -0
  414. package/adapters/cursor/scripts/forge_session/cli.py +1067 -0
  415. package/adapters/cursor/scripts/forge_session/decisions.py +355 -0
  416. package/adapters/cursor/scripts/forge_session/discover.py +472 -0
  417. package/adapters/cursor/scripts/forge_session/doctor.py +2144 -0
  418. package/adapters/cursor/scripts/forge_session/exit.py +1081 -0
  419. package/adapters/cursor/scripts/forge_session/outcomes.py +574 -0
  420. package/adapters/cursor/scripts/forge_session/routes.py +1162 -0
  421. package/adapters/cursor/scripts/forge_session/state.py +1479 -0
  422. package/adapters/cursor/scripts/forge_session/topology.py +443 -0
  423. package/adapters/cursor/skills/forge/forge.mdc +40 -40
  424. package/adapters/cursor/skills/forge/references/process-overview.md +10 -10
  425. package/adapters/cursor/skills/forge/references/shared-conventions.md +188 -35
  426. package/adapters/cursor/skills/forge/references/stage-exit-protocol.md +22 -7
  427. package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +15 -15
  428. package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +14 -10
  429. package/adapters/cursor/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
  430. package/adapters/cursor/skills/forge-0-epic/references/portable-root.md +20 -16
  431. package/adapters/cursor/skills/forge-0-epic/references/shared-conventions.md +188 -35
  432. package/adapters/cursor/skills/forge-0-epic/references/stage-exit-protocol.md +22 -7
  433. package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +15 -15
  434. package/adapters/cursor/skills/forge-1-prd/references/shared-conventions.md +188 -35
  435. package/adapters/cursor/skills/forge-1-prd/references/stage-exit-protocol.md +22 -7
  436. package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +15 -15
  437. package/adapters/cursor/skills/forge-2-tech/references/shared-conventions.md +188 -35
  438. package/adapters/cursor/skills/forge-2-tech/references/stack-resolution.md +1 -1
  439. package/adapters/cursor/skills/forge-2-tech/references/stage-exit-protocol.md +22 -7
  440. package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +10 -10
  441. package/adapters/cursor/skills/forge-3-specs/references/shared-conventions.md +188 -35
  442. package/adapters/cursor/skills/forge-3-specs/references/stage-exit-protocol.md +22 -7
  443. package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +19 -11
  444. package/adapters/cursor/skills/forge-4-backlog/references/shared-conventions.md +188 -35
  445. package/adapters/cursor/skills/forge-4-backlog/references/stage-exit-protocol.md +22 -7
  446. package/adapters/cursor/skills/forge-4-backlog/references/verify-state.md +98 -0
  447. package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +48 -53
  448. package/adapters/cursor/skills/forge-5-loop/references/agent-selection.md +5 -1
  449. package/adapters/cursor/skills/forge-5-loop/references/preflight-and-self-heal.md +183 -0
  450. package/adapters/cursor/skills/forge-5-loop/references/recovery-procedure.md +14 -4
  451. package/adapters/cursor/skills/forge-5-loop/references/runner-contract.md +18 -8
  452. package/adapters/cursor/skills/forge-5-loop/references/shared-conventions.md +188 -35
  453. package/adapters/cursor/skills/forge-5-loop/references/stage-exit-protocol.md +22 -7
  454. package/adapters/cursor/skills/forge-5-loop/references/verify-state.md +98 -0
  455. package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +27 -20
  456. package/adapters/cursor/skills/forge-6-docs/references/shared-conventions.md +188 -35
  457. package/adapters/cursor/skills/forge-6-docs/references/stage-exit-protocol.md +22 -7
  458. package/adapters/cursor/skills/forge-6-docs/references/verify-state.md +98 -0
  459. package/adapters/cursor/skills/forge-bootstrap/forge-bootstrap.mdc +23 -15
  460. package/adapters/cursor/skills/forge-bootstrap/references/shared-conventions.md +572 -0
  461. package/adapters/cursor/skills/forge-bootstrap/references/templates/hygiene/CLAUDE.md +2 -2
  462. package/adapters/cursor/skills/forge-fix/forge-fix.mdc +27 -30
  463. package/adapters/cursor/skills/forge-fix/references/select-outcome.md +138 -0
  464. package/adapters/cursor/skills/forge-fix/references/shared-conventions.md +188 -35
  465. package/adapters/cursor/skills/forge-fix/references/stage-exit-protocol.md +22 -7
  466. package/adapters/cursor/skills/forge-guide/forge-guide.mdc +84 -7
  467. package/adapters/cursor/skills/forge-guide/references/forge-config-schema.json +5 -5
  468. package/adapters/cursor/skills/forge-guide/references/preflight-and-self-heal.md +183 -0
  469. package/adapters/cursor/skills/forge-guide/references/process-overview.md +10 -10
  470. package/adapters/cursor/skills/forge-guide/references/shared-conventions.md +188 -35
  471. package/adapters/cursor/skills/forge-guide/references/stack-resolution.md +1 -1
  472. package/adapters/cursor/skills/forge-init/forge-init.mdc +62 -12
  473. package/adapters/cursor/skills/forge-init/references/preflight-and-self-heal.md +183 -0
  474. package/adapters/cursor/skills/forge-init/references/shared-conventions.md +572 -0
  475. package/adapters/cursor/skills/forge-verify/forge-verify.mdc +27 -30
  476. package/adapters/cursor/skills/forge-verify/references/findings-template.md +2 -2
  477. package/adapters/cursor/skills/forge-verify/references/select-outcome.md +138 -0
  478. package/adapters/cursor/skills/forge-verify/references/shared-conventions.md +188 -35
  479. package/adapters/cursor/skills/forge-verify/references/stage-exit-protocol.md +22 -7
  480. package/adapters/cursor/skills/forge-verify/references/verification-checklists/epic.md +2 -2
  481. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/MEMORY.md +35 -0
  482. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_absence_claims.md +36 -0
  483. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
  484. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
  485. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
  486. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
  487. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
  488. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
  489. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
  490. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
  491. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
  492. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_self_referential_control.md +26 -0
  493. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
  494. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
  495. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
  496. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_test_count_units.md +20 -0
  497. package/adapters/cursor/skills/forge-verify/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
  498. package/adapters/gemini/.feature-forge-bundle.json +1 -1
  499. package/adapters/gemini/agents/forge-researcher.md +1 -1
  500. package/adapters/gemini/agents/forge-verifier.md +17 -6
  501. package/adapters/gemini/gemini-extension.json +13 -13
  502. package/adapters/gemini/references/forge-config-schema.json +5 -5
  503. package/adapters/gemini/references/portable-root.md +20 -16
  504. package/adapters/gemini/references/preflight-and-self-heal.md +183 -0
  505. package/adapters/gemini/references/process-overview.md +10 -10
  506. package/adapters/gemini/references/select-outcome.md +138 -0
  507. package/adapters/gemini/references/shared-conventions.md +188 -35
  508. package/adapters/gemini/references/skill-frontmatter.schema.json +3 -1
  509. package/adapters/gemini/references/stack-resolution.md +1 -1
  510. package/adapters/gemini/references/stage-exit-protocol.md +22 -7
  511. package/adapters/gemini/references/templates/root-hygiene/AGENTS.md +14 -0
  512. package/adapters/gemini/references/templates/root-hygiene/CLAUDE.md +14 -0
  513. package/adapters/gemini/references/templates/specs-hygiene/AGENTS.md +3 -1
  514. package/adapters/gemini/references/templates/specs-hygiene/CLAUDE.md +2 -1
  515. package/adapters/gemini/references/verifier-patterns/MEMORY.md +35 -0
  516. package/adapters/gemini/references/verifier-patterns/pattern_absence_claims.md +36 -0
  517. package/adapters/gemini/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
  518. package/adapters/gemini/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
  519. package/adapters/gemini/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
  520. package/adapters/gemini/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
  521. package/adapters/gemini/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
  522. package/adapters/gemini/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
  523. package/adapters/gemini/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
  524. package/adapters/gemini/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
  525. package/adapters/gemini/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
  526. package/adapters/gemini/references/verifier-patterns/pattern_self_referential_control.md +26 -0
  527. package/adapters/gemini/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
  528. package/adapters/gemini/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
  529. package/adapters/gemini/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
  530. package/adapters/gemini/references/verifier-patterns/pattern_test_count_units.md +20 -0
  531. package/adapters/gemini/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
  532. package/adapters/gemini/references/verify-state.md +98 -0
  533. package/adapters/gemini/scripts/forge-bootstrap.py +23 -2
  534. package/adapters/gemini/scripts/forge-root.sh +104 -21
  535. package/adapters/gemini/scripts/forge-session.py +620 -6973
  536. package/adapters/gemini/scripts/forge_session/__init__.py +11 -0
  537. package/adapters/gemini/scripts/forge_session/_common.py +1566 -0
  538. package/adapters/gemini/scripts/forge_session/_doctor_util.py +218 -0
  539. package/adapters/gemini/scripts/forge_session/cli.py +1067 -0
  540. package/adapters/gemini/scripts/forge_session/decisions.py +355 -0
  541. package/adapters/gemini/scripts/forge_session/discover.py +472 -0
  542. package/adapters/gemini/scripts/forge_session/doctor.py +2144 -0
  543. package/adapters/gemini/scripts/forge_session/exit.py +1081 -0
  544. package/adapters/gemini/scripts/forge_session/outcomes.py +574 -0
  545. package/adapters/gemini/scripts/forge_session/routes.py +1162 -0
  546. package/adapters/gemini/scripts/forge_session/state.py +1479 -0
  547. package/adapters/gemini/scripts/forge_session/topology.py +443 -0
  548. package/adapters/gemini/skills/forge/forge.md +40 -40
  549. package/adapters/gemini/skills/forge/references/process-overview.md +10 -10
  550. package/adapters/gemini/skills/forge/references/shared-conventions.md +188 -35
  551. package/adapters/gemini/skills/forge/references/stage-exit-protocol.md +22 -7
  552. package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +15 -15
  553. package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +14 -10
  554. package/adapters/gemini/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
  555. package/adapters/gemini/skills/forge-0-epic/references/portable-root.md +20 -16
  556. package/adapters/gemini/skills/forge-0-epic/references/shared-conventions.md +188 -35
  557. package/adapters/gemini/skills/forge-0-epic/references/stage-exit-protocol.md +22 -7
  558. package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +15 -15
  559. package/adapters/gemini/skills/forge-1-prd/references/shared-conventions.md +188 -35
  560. package/adapters/gemini/skills/forge-1-prd/references/stage-exit-protocol.md +22 -7
  561. package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +15 -15
  562. package/adapters/gemini/skills/forge-2-tech/references/shared-conventions.md +188 -35
  563. package/adapters/gemini/skills/forge-2-tech/references/stack-resolution.md +1 -1
  564. package/adapters/gemini/skills/forge-2-tech/references/stage-exit-protocol.md +22 -7
  565. package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +10 -10
  566. package/adapters/gemini/skills/forge-3-specs/references/shared-conventions.md +188 -35
  567. package/adapters/gemini/skills/forge-3-specs/references/stage-exit-protocol.md +22 -7
  568. package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +19 -11
  569. package/adapters/gemini/skills/forge-4-backlog/references/shared-conventions.md +188 -35
  570. package/adapters/gemini/skills/forge-4-backlog/references/stage-exit-protocol.md +22 -7
  571. package/adapters/gemini/skills/forge-4-backlog/references/verify-state.md +98 -0
  572. package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +48 -53
  573. package/adapters/gemini/skills/forge-5-loop/references/agent-selection.md +5 -1
  574. package/adapters/gemini/skills/forge-5-loop/references/preflight-and-self-heal.md +183 -0
  575. package/adapters/gemini/skills/forge-5-loop/references/recovery-procedure.md +14 -4
  576. package/adapters/gemini/skills/forge-5-loop/references/runner-contract.md +18 -8
  577. package/adapters/gemini/skills/forge-5-loop/references/shared-conventions.md +188 -35
  578. package/adapters/gemini/skills/forge-5-loop/references/stage-exit-protocol.md +22 -7
  579. package/adapters/gemini/skills/forge-5-loop/references/verify-state.md +98 -0
  580. package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +27 -20
  581. package/adapters/gemini/skills/forge-6-docs/references/shared-conventions.md +188 -35
  582. package/adapters/gemini/skills/forge-6-docs/references/stage-exit-protocol.md +22 -7
  583. package/adapters/gemini/skills/forge-6-docs/references/verify-state.md +98 -0
  584. package/adapters/gemini/skills/forge-bootstrap/forge-bootstrap.md +23 -15
  585. package/adapters/gemini/skills/forge-bootstrap/references/shared-conventions.md +572 -0
  586. package/adapters/gemini/skills/forge-bootstrap/references/templates/hygiene/CLAUDE.md +2 -2
  587. package/adapters/gemini/skills/forge-fix/forge-fix.md +27 -30
  588. package/adapters/gemini/skills/forge-fix/references/select-outcome.md +138 -0
  589. package/adapters/gemini/skills/forge-fix/references/shared-conventions.md +188 -35
  590. package/adapters/gemini/skills/forge-fix/references/stage-exit-protocol.md +22 -7
  591. package/adapters/gemini/skills/forge-guide/forge-guide.md +84 -7
  592. package/adapters/gemini/skills/forge-guide/references/forge-config-schema.json +5 -5
  593. package/adapters/gemini/skills/forge-guide/references/preflight-and-self-heal.md +183 -0
  594. package/adapters/gemini/skills/forge-guide/references/process-overview.md +10 -10
  595. package/adapters/gemini/skills/forge-guide/references/shared-conventions.md +188 -35
  596. package/adapters/gemini/skills/forge-guide/references/stack-resolution.md +1 -1
  597. package/adapters/gemini/skills/forge-init/forge-init.md +62 -12
  598. package/adapters/gemini/skills/forge-init/references/preflight-and-self-heal.md +183 -0
  599. package/adapters/gemini/skills/forge-init/references/shared-conventions.md +572 -0
  600. package/adapters/gemini/skills/forge-verify/forge-verify.md +27 -30
  601. package/adapters/gemini/skills/forge-verify/references/findings-template.md +2 -2
  602. package/adapters/gemini/skills/forge-verify/references/select-outcome.md +138 -0
  603. package/adapters/gemini/skills/forge-verify/references/shared-conventions.md +188 -35
  604. package/adapters/gemini/skills/forge-verify/references/stage-exit-protocol.md +22 -7
  605. package/adapters/gemini/skills/forge-verify/references/verification-checklists/epic.md +2 -2
  606. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/MEMORY.md +35 -0
  607. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_absence_claims.md +36 -0
  608. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
  609. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
  610. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
  611. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
  612. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
  613. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
  614. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
  615. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
  616. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
  617. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_self_referential_control.md +26 -0
  618. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
  619. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
  620. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
  621. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_test_count_units.md +20 -0
  622. package/adapters/gemini/skills/forge-verify/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
  623. package/adapters/pi/.feature-forge-bundle.json +1 -1
  624. package/adapters/pi/agents/forge-verifier.md +16 -5
  625. package/adapters/pi/extensions/forge-invocation-args/README.md +106 -0
  626. package/adapters/pi/extensions/forge-invocation-args/index.ts +31 -0
  627. package/adapters/pi/extensions/forge-invocation-args/wiring.ts +172 -0
  628. package/adapters/pi/package.json +1 -0
  629. package/adapters/pi/references/forge-config-schema.json +3 -3
  630. package/adapters/pi/references/portable-root.md +20 -16
  631. package/adapters/pi/references/preflight-and-self-heal.md +183 -0
  632. package/adapters/pi/references/select-outcome.md +138 -0
  633. package/adapters/pi/references/shared-conventions.md +175 -22
  634. package/adapters/pi/references/skill-frontmatter.schema.json +3 -1
  635. package/adapters/pi/references/stack-resolution.md +1 -1
  636. package/adapters/pi/references/stage-exit-protocol.md +19 -4
  637. package/adapters/pi/references/templates/root-hygiene/AGENTS.md +14 -0
  638. package/adapters/pi/references/templates/root-hygiene/CLAUDE.md +14 -0
  639. package/adapters/pi/references/templates/specs-hygiene/AGENTS.md +3 -1
  640. package/adapters/pi/references/templates/specs-hygiene/CLAUDE.md +2 -1
  641. package/adapters/pi/references/verifier-patterns/MEMORY.md +35 -0
  642. package/adapters/pi/references/verifier-patterns/pattern_absence_claims.md +36 -0
  643. package/adapters/pi/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
  644. package/adapters/pi/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
  645. package/adapters/pi/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
  646. package/adapters/pi/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
  647. package/adapters/pi/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
  648. package/adapters/pi/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
  649. package/adapters/pi/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
  650. package/adapters/pi/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
  651. package/adapters/pi/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
  652. package/adapters/pi/references/verifier-patterns/pattern_self_referential_control.md +26 -0
  653. package/adapters/pi/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
  654. package/adapters/pi/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
  655. package/adapters/pi/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
  656. package/adapters/pi/references/verifier-patterns/pattern_test_count_units.md +20 -0
  657. package/adapters/pi/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
  658. package/adapters/pi/references/verify-state.md +98 -0
  659. package/adapters/pi/scripts/forge-bootstrap.py +23 -2
  660. package/adapters/pi/scripts/forge-root.sh +104 -21
  661. package/adapters/pi/scripts/forge-session.py +620 -6973
  662. package/adapters/pi/scripts/forge_session/__init__.py +11 -0
  663. package/adapters/pi/scripts/forge_session/_common.py +1566 -0
  664. package/adapters/pi/scripts/forge_session/_doctor_util.py +218 -0
  665. package/adapters/pi/scripts/forge_session/cli.py +1067 -0
  666. package/adapters/pi/scripts/forge_session/decisions.py +355 -0
  667. package/adapters/pi/scripts/forge_session/discover.py +472 -0
  668. package/adapters/pi/scripts/forge_session/doctor.py +2144 -0
  669. package/adapters/pi/scripts/forge_session/exit.py +1081 -0
  670. package/adapters/pi/scripts/forge_session/outcomes.py +574 -0
  671. package/adapters/pi/scripts/forge_session/routes.py +1162 -0
  672. package/adapters/pi/scripts/forge_session/state.py +1479 -0
  673. package/adapters/pi/scripts/forge_session/topology.py +443 -0
  674. package/adapters/pi/skills/forge/SKILL.md +9 -8
  675. package/adapters/pi/skills/forge/references/shared-conventions.md +175 -22
  676. package/adapters/pi/skills/forge/references/stage-exit-protocol.md +19 -4
  677. package/adapters/pi/skills/forge-0-epic/SKILL.md +9 -8
  678. package/adapters/pi/skills/forge-0-epic/references/edit-mode.md +9 -5
  679. package/adapters/pi/skills/forge-0-epic/references/epic-manifest-subcommands.md +6 -1
  680. package/adapters/pi/skills/forge-0-epic/references/portable-root.md +20 -16
  681. package/adapters/pi/skills/forge-0-epic/references/shared-conventions.md +175 -22
  682. package/adapters/pi/skills/forge-0-epic/references/stage-exit-protocol.md +19 -4
  683. package/adapters/pi/skills/forge-1-prd/SKILL.md +9 -8
  684. package/adapters/pi/skills/forge-1-prd/references/shared-conventions.md +175 -22
  685. package/adapters/pi/skills/forge-1-prd/references/stage-exit-protocol.md +19 -4
  686. package/adapters/pi/skills/forge-2-tech/SKILL.md +8 -7
  687. package/adapters/pi/skills/forge-2-tech/references/shared-conventions.md +175 -22
  688. package/adapters/pi/skills/forge-2-tech/references/stack-resolution.md +1 -1
  689. package/adapters/pi/skills/forge-2-tech/references/stage-exit-protocol.md +19 -4
  690. package/adapters/pi/skills/forge-3-specs/SKILL.md +7 -6
  691. package/adapters/pi/skills/forge-3-specs/references/shared-conventions.md +175 -22
  692. package/adapters/pi/skills/forge-3-specs/references/stage-exit-protocol.md +19 -4
  693. package/adapters/pi/skills/forge-4-backlog/SKILL.md +18 -9
  694. package/adapters/pi/skills/forge-4-backlog/references/shared-conventions.md +175 -22
  695. package/adapters/pi/skills/forge-4-backlog/references/stage-exit-protocol.md +19 -4
  696. package/adapters/pi/skills/forge-4-backlog/references/verify-state.md +98 -0
  697. package/adapters/pi/skills/forge-5-loop/SKILL.md +38 -42
  698. package/adapters/pi/skills/forge-5-loop/references/agent-selection.md +4 -0
  699. package/adapters/pi/skills/forge-5-loop/references/preflight-and-self-heal.md +183 -0
  700. package/adapters/pi/skills/forge-5-loop/references/recovery-procedure.md +10 -0
  701. package/adapters/pi/skills/forge-5-loop/references/runner-contract.md +15 -5
  702. package/adapters/pi/skills/forge-5-loop/references/shared-conventions.md +175 -22
  703. package/adapters/pi/skills/forge-5-loop/references/stage-exit-protocol.md +19 -4
  704. package/adapters/pi/skills/forge-5-loop/references/verify-state.md +98 -0
  705. package/adapters/pi/skills/forge-6-docs/SKILL.md +23 -15
  706. package/adapters/pi/skills/forge-6-docs/references/shared-conventions.md +175 -22
  707. package/adapters/pi/skills/forge-6-docs/references/stage-exit-protocol.md +19 -4
  708. package/adapters/pi/skills/forge-6-docs/references/verify-state.md +98 -0
  709. package/adapters/pi/skills/forge-bootstrap/SKILL.md +20 -11
  710. package/adapters/pi/skills/forge-bootstrap/references/shared-conventions.md +572 -0
  711. package/adapters/pi/skills/forge-fix/SKILL.md +17 -19
  712. package/adapters/pi/skills/forge-fix/references/select-outcome.md +138 -0
  713. package/adapters/pi/skills/forge-fix/references/shared-conventions.md +175 -22
  714. package/adapters/pi/skills/forge-fix/references/stage-exit-protocol.md +19 -4
  715. package/adapters/pi/skills/forge-guide/SKILL.md +79 -1
  716. package/adapters/pi/skills/forge-guide/references/forge-config-schema.json +3 -3
  717. package/adapters/pi/skills/forge-guide/references/preflight-and-self-heal.md +183 -0
  718. package/adapters/pi/skills/forge-guide/references/shared-conventions.md +175 -22
  719. package/adapters/pi/skills/forge-guide/references/stack-resolution.md +1 -1
  720. package/adapters/pi/skills/forge-init/SKILL.md +59 -8
  721. package/adapters/pi/skills/forge-init/references/preflight-and-self-heal.md +183 -0
  722. package/adapters/pi/skills/forge-init/references/shared-conventions.md +572 -0
  723. package/adapters/pi/skills/forge-verify/SKILL.md +17 -19
  724. package/adapters/pi/skills/forge-verify/references/findings-template.md +1 -1
  725. package/adapters/pi/skills/forge-verify/references/select-outcome.md +138 -0
  726. package/adapters/pi/skills/forge-verify/references/shared-conventions.md +175 -22
  727. package/adapters/pi/skills/forge-verify/references/stage-exit-protocol.md +19 -4
  728. package/adapters/pi/skills/forge-verify/references/verification-checklists/epic.md +1 -1
  729. package/adapters/pi/skills/forge-verify/references/verifier-patterns/MEMORY.md +35 -0
  730. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_absence_claims.md +36 -0
  731. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_carveout_sibling_semantics.md +34 -0
  732. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_deviation_judgment.md +36 -0
  733. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_enum_vocabulary_ripple.md +43 -0
  734. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_extend_the_existing.md +31 -0
  735. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_guard_substitution_detection.md +34 -0
  736. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_mechanical_rewrite_damage.md +30 -0
  737. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_postfix_reverify.md +82 -0
  738. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_proximity_window_guards.md +26 -0
  739. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_scratch_root_probes.md +40 -0
  740. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_self_referential_control.md +26 -0
  741. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_sibling_docstring_sweep.md +28 -0
  742. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_spec_literals_are_claims.md +45 -0
  743. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_stale_counts_after_split.md +36 -0
  744. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_test_count_units.md +20 -0
  745. package/adapters/pi/skills/forge-verify/references/verifier-patterns/pattern_vacuous_self_reading_tests.md +32 -0
  746. package/dist/manifest.d.ts +1 -1
  747. package/dist/rauf.d.ts +3 -3
  748. package/dist/rauf.js +2 -2
  749. package/dist/source.d.ts +5 -2
  750. package/dist/source.js +5 -2
  751. package/dist/types.d.ts +1 -1
  752. package/dist/types.js +1 -1
  753. 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
+ ]