@khoavu882/doflow 0.0.0-stage → 2.0.0-beta.3

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 (407) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -2
  3. package/bin/doflow.js +44 -0
  4. package/core/.antigravity-plugin/hooks.json +39 -0
  5. package/core/.antigravity-plugin/mcp_config.json +18 -0
  6. package/core/.antigravity-plugin/plugin.json +31 -0
  7. package/core/.claude-plugin/marketplace.json +20 -0
  8. package/core/.claude-plugin/plugin.json +21 -0
  9. package/core/.codex-plugin/marketplace.json +20 -0
  10. package/core/.codex-plugin/plugin.json +35 -0
  11. package/core/.plugin/plugin.json +19 -0
  12. package/core/harnesses/antigravity/hooks/pre-implementation-gate.sh +9 -0
  13. package/core/harnesses/antigravity/hooks/stop-check.sh +10 -0
  14. package/core/harnesses/claude/hooks/lib.sh +304 -0
  15. package/core/harnesses/claude/hooks/mcp-tool-guard.sh +20 -0
  16. package/core/harnesses/claude/hooks/permission-audit.sh +47 -0
  17. package/core/harnesses/claude/hooks/post-compact.sh +74 -0
  18. package/core/harnesses/claude/hooks/post-edit-lint.sh +36 -0
  19. package/core/harnesses/claude/hooks/post-tool-failure.sh +47 -0
  20. package/core/harnesses/claude/hooks/pre-bash-guard.sh +20 -0
  21. package/core/harnesses/claude/hooks/pre-compact.sh +43 -0
  22. package/core/harnesses/claude/hooks/pre-implement-gate.sh +21 -0
  23. package/core/harnesses/claude/hooks/session-end.sh +89 -0
  24. package/core/harnesses/claude/hooks/session-start.sh +5 -0
  25. package/core/harnesses/claude/hooks/skill-config-audit.sh +39 -0
  26. package/core/harnesses/claude/hooks/stop-check.sh +5 -0
  27. package/core/harnesses/claude/hooks/subagent-audit.sh +7 -0
  28. package/core/harnesses/claude/hooks/user-prompt-submit.sh +5 -0
  29. package/core/harnesses/claude/settings/keybindings.json +20 -0
  30. package/core/harnesses/claude/settings/settings.json +201 -0
  31. package/core/harnesses/claude/statusline/doflow-statusline.sh +49 -0
  32. package/core/harnesses/codex/agents/core-implementer.toml +7 -0
  33. package/core/harnesses/codex/agents/quality-guardian.toml +8 -0
  34. package/core/harnesses/codex/agents/research-writer.toml +8 -0
  35. package/core/harnesses/codex/agents/spec-analyst.toml +8 -0
  36. package/core/harnesses/codex/agents/system-architect.toml +9 -0
  37. package/core/harnesses/codex/config/config.toml +10 -0
  38. package/core/harnesses/codex/hooks/hooks.json +122 -0
  39. package/core/harnesses/codex/hooks/lib.sh +335 -0
  40. package/core/harnesses/codex/hooks/mcp-tool-guard.sh +18 -0
  41. package/core/harnesses/codex/hooks/post-edit-lint.impl.sh +52 -0
  42. package/core/harnesses/codex/hooks/post-edit-lint.sh +6 -0
  43. package/core/harnesses/codex/hooks/pre-bash-guard.sh +18 -0
  44. package/core/harnesses/codex/hooks/pre-compact.impl.sh +46 -0
  45. package/core/harnesses/codex/hooks/pre-compact.sh +9 -0
  46. package/core/harnesses/codex/hooks/pre-implement-gate.sh +19 -0
  47. package/core/harnesses/codex/hooks/session-end.impl.sh +92 -0
  48. package/core/harnesses/codex/hooks/session-end.sh +5 -0
  49. package/core/harnesses/codex/hooks/session-start.sh +5 -0
  50. package/core/harnesses/codex/hooks/stop-check.sh +8 -0
  51. package/core/harnesses/codex/hooks/subagent-audit.sh +8 -0
  52. package/core/harnesses/codex/hooks/user-prompt-submit.sh +6 -0
  53. package/core/harnesses/gemini/hooks/hooks.json +77 -0
  54. package/core/harnesses/gemini/hooks/lib.sh +302 -0
  55. package/core/harnesses/gemini/hooks/mcp-tool-guard.sh +21 -0
  56. package/core/harnesses/gemini/hooks/post-edit-lint.sh +39 -0
  57. package/core/harnesses/gemini/hooks/pre-bash-guard.sh +21 -0
  58. package/core/harnesses/gemini/hooks/pre-compact.sh +38 -0
  59. package/core/harnesses/gemini/hooks/pre-implement-gate.sh +22 -0
  60. package/core/harnesses/gemini/hooks/session-end.impl.sh +89 -0
  61. package/core/harnesses/gemini/hooks/session-end.sh +5 -0
  62. package/core/harnesses/gemini/hooks/session-start.sh +5 -0
  63. package/core/harnesses/gemini/settings/adapter-defaults.json +4 -0
  64. package/core/harnesses/kiro/agents/doflow.json +58 -0
  65. package/core/harnesses/kiro/hooks/doflow.json +53 -0
  66. package/core/harnesses/kiro/hooks/lib.sh +324 -0
  67. package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +9 -0
  68. package/core/harnesses/kiro/hooks/pre-bash-guard.sh +9 -0
  69. package/core/harnesses/kiro/hooks/pre-implement-gate.sh +9 -0
  70. package/core/harnesses/kiro/hooks/session-start.sh +9 -0
  71. package/core/harnesses/kiro/hooks/stop-check.sh +43 -0
  72. package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +39 -0
  73. package/core/harnesses/shared/hooks/adapters/antigravity.js +111 -0
  74. package/core/harnesses/shared/hooks/adapters/arg-fields.js +19 -0
  75. package/core/harnesses/shared/hooks/adapters/gemini.js +114 -0
  76. package/core/harnesses/shared/hooks/hooks.json +47 -0
  77. package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +91 -0
  78. package/core/harnesses/shared/hooks/policies/capture-failure.sh +97 -0
  79. package/core/harnesses/shared/hooks/policies/deny-json.sh +29 -0
  80. package/core/harnesses/shared/hooks/policies/lib.sh +501 -0
  81. package/core/harnesses/shared/hooks/policies/mcp-policy.conf +21 -0
  82. package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +91 -0
  83. package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +328 -0
  84. package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +496 -0
  85. package/core/harnesses/shared/hooks/policies/prompt-nudge.jq +131 -0
  86. package/core/harnesses/shared/hooks/policies/session-context.sh +141 -0
  87. package/core/harnesses/shared/hooks/policies/stop-check.sh +264 -0
  88. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +46 -0
  89. package/core/harnesses/shared/hooks/policies/user-prompt-submit.sh +235 -0
  90. package/core/harnesses/shared/hooks/stream-hook-runner.js +590 -0
  91. package/core/harnesses/shared/locator/doflow-run +87 -0
  92. package/core/registry/advice.json +101 -0
  93. package/core/registry/assets.json +792 -0
  94. package/core/registry/capabilities.json +120 -0
  95. package/core/registry/contracts.json +189 -0
  96. package/core/registry/external-tools.json +191 -0
  97. package/core/registry/harnesses.json +2062 -0
  98. package/core/registry/lifecycle.json +231 -0
  99. package/core/registry/mcp.json +18 -0
  100. package/core/registry/models.json +79 -0
  101. package/core/registry/readiness-templates.json +118 -0
  102. package/core/registry/routes.json +47 -0
  103. package/core/registry/storage.json +1057 -0
  104. package/core/registry/verification.json +258 -0
  105. package/core/registry/workflows.json +790 -0
  106. package/core/shared/agent-specs/core-implementer.md +73 -0
  107. package/core/shared/agent-specs/quality-guardian.md +28 -0
  108. package/core/shared/agent-specs/research-writer.md +31 -0
  109. package/core/shared/agent-specs/spec-analyst.md +33 -0
  110. package/core/shared/agent-specs/system-architect.md +29 -0
  111. package/core/shared/guidance/DOFLOW_CORE.md +8 -0
  112. package/core/shared/guidance/FLAGS.md +25 -0
  113. package/core/shared/guidance/PRINCIPLES.md +61 -0
  114. package/core/shared/guidance/VERSION +1 -0
  115. package/core/shared/guidance/mcp/MCP_Context7.md +18 -0
  116. package/core/shared/guidance/mcp/MCP_Sequential.md +17 -0
  117. package/core/shared/guidance/modes/MODE_Brainstorming.md +46 -0
  118. package/core/shared/guidance/modes/MODE_DeepResearch.md +69 -0
  119. package/core/shared/guidance/modes/MODE_Introspection.md +33 -0
  120. package/core/shared/guidance/modes/MODE_Orchestration.md +57 -0
  121. package/core/shared/guidance/modes/MODE_Task_Management.md +74 -0
  122. package/core/shared/guidance/pointers/claude-gemini.md +2 -0
  123. package/core/shared/guidance/pointers/codex.md +6 -0
  124. package/core/shared/guidance/references/ARTIFACT_FORMAT.md +373 -0
  125. package/core/shared/guidance/references/ARTIFACT_VOICE.md +104 -0
  126. package/core/shared/guidance/references/CONSTITUTION_BASE.md +47 -0
  127. package/core/shared/guidance/references/DIAGRAM_GUIDE.md +161 -0
  128. package/core/shared/guidance/references/DOFLOW_CHAIN.md +87 -0
  129. package/core/shared/guidance/references/EVIDENCE_LEDGER.md +93 -0
  130. package/core/shared/guidance/references/MODEL_SELECTION.md +74 -0
  131. package/core/shared/guidance/references/ORCHESTRATION.md +36 -0
  132. package/core/shared/guidance/references/RESEARCH_CONFIG.md +21 -0
  133. package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +81 -0
  134. package/core/shared/guidance/rules/RULE_01_SAFETY.md +25 -0
  135. package/core/shared/guidance/rules/RULE_02_WORKFLOW.md +23 -0
  136. package/core/shared/guidance/rules/RULE_03_QUALITY.md +14 -0
  137. package/core/shared/guidance/rules/RULE_04_QUESTIONS.md +46 -0
  138. package/core/shared/scripts/doflow/assets/mermaid-theme.json +46 -0
  139. package/core/shared/scripts/doflow/bash/do-exec-paths.sh +167 -0
  140. package/core/shared/scripts/doflow/bash/do-git-state.sh +546 -0
  141. package/core/shared/scripts/doflow/bash/do-parallel-check.sh +208 -0
  142. package/core/shared/scripts/doflow/bash/do-paths.sh +347 -0
  143. package/core/shared/scripts/doflow/bash/do-prereqs.sh +66 -0
  144. package/core/shared/scripts/doflow/bash/do-review-package.sh +127 -0
  145. package/core/shared/scripts/doflow/bash/do-task-brief.sh +698 -0
  146. package/core/shared/scripts/doflow/bash/render-audit.sh +224 -0
  147. package/core/shared/scripts/doflow/bash/render-diagrams.sh +177 -0
  148. package/core/shared/scripts/doflow/bash/render-puml.sh +223 -0
  149. package/core/shared/scripts/doflow/bash/sync-context.sh +66 -0
  150. package/core/shared/scripts/doflow/bash/validate-artifacts.sh +526 -0
  151. package/core/shared/scripts/doflow/bin/doflow-run +592 -0
  152. package/core/shared/skills/do/SKILL.md +128 -0
  153. package/core/shared/skills/do/references/estimation.md +13 -0
  154. package/core/shared/skills/do/references/pm_routing.md +25 -0
  155. package/core/shared/skills/do/references/tool_matrix.md +52 -0
  156. package/core/shared/skills/do-brainstorm/SKILL.md +252 -0
  157. package/core/shared/skills/do-code-review/README.md +49 -0
  158. package/core/shared/skills/do-code-review/SKILL.md +333 -0
  159. package/core/shared/skills/do-code-review/assets/sample_c_clean.c +71 -0
  160. package/core/shared/skills/do-code-review/assets/sample_c_smells.c +66 -0
  161. package/core/shared/skills/do-code-review/assets/sample_cpp_shift.cpp +19 -0
  162. package/core/shared/skills/do-code-review/assets/sample_csharp_clean.cs +100 -0
  163. package/core/shared/skills/do-code-review/assets/sample_csharp_smells.cs +78 -0
  164. package/core/shared/skills/do-code-review/assets/sample_java_clean.java +55 -0
  165. package/core/shared/skills/do-code-review/assets/sample_java_smells.java +56 -0
  166. package/core/shared/skills/do-code-review/assets/sample_json_clean.json +7 -0
  167. package/core/shared/skills/do-code-review/assets/sample_markdown_clean.md +30 -0
  168. package/core/shared/skills/do-code-review/assets/sample_markdown_smells.md +29 -0
  169. package/core/shared/skills/do-code-review/assets/sample_shell_heredoc.sh +38 -0
  170. package/core/shared/skills/do-code-review/assets/sample_shell_smells.sh +15 -0
  171. package/core/shared/skills/do-code-review/assets/sample_typescript_strings.ts +36 -0
  172. package/core/shared/skills/do-code-review/assets/sample_yaml_smells.yaml +6 -0
  173. package/core/shared/skills/do-code-review/content-types/config.md +83 -0
  174. package/core/shared/skills/do-code-review/content-types/markdown.md +132 -0
  175. package/core/shared/skills/do-code-review/expected_outputs/sample_c_clean_quality.json +79 -0
  176. package/core/shared/skills/do-code-review/expected_outputs/sample_c_smells_quality.json +169 -0
  177. package/core/shared/skills/do-code-review/expected_outputs/sample_cpp_shift_quality.json +49 -0
  178. package/core/shared/skills/do-code-review/expected_outputs/sample_csharp_clean_quality.json +149 -0
  179. package/core/shared/skills/do-code-review/expected_outputs/sample_csharp_smells_quality.json +157 -0
  180. package/core/shared/skills/do-code-review/expected_outputs/sample_java_clean_quality.json +67 -0
  181. package/core/shared/skills/do-code-review/expected_outputs/sample_java_smells_quality.json +97 -0
  182. package/core/shared/skills/do-code-review/expected_outputs/sample_json_clean_quality.json +33 -0
  183. package/core/shared/skills/do-code-review/expected_outputs/sample_markdown_clean_quality.json +1 -0
  184. package/core/shared/skills/do-code-review/expected_outputs/sample_markdown_smells_quality.json +16 -0
  185. package/core/shared/skills/do-code-review/expected_outputs/sample_shell_heredoc_quality.json +54 -0
  186. package/core/shared/skills/do-code-review/expected_outputs/sample_shell_smells_quality.json +91 -0
  187. package/core/shared/skills/do-code-review/expected_outputs/sample_typescript_strings_quality.json +60 -0
  188. package/core/shared/skills/do-code-review/expected_outputs/sample_yaml_smells_quality.json +46 -0
  189. package/core/shared/skills/do-code-review/languages/c.md +103 -0
  190. package/core/shared/skills/do-code-review/languages/cpp.md +103 -0
  191. package/core/shared/skills/do-code-review/languages/csharp.md +97 -0
  192. package/core/shared/skills/do-code-review/languages/dart.md +108 -0
  193. package/core/shared/skills/do-code-review/languages/go.md +92 -0
  194. package/core/shared/skills/do-code-review/languages/java.md +103 -0
  195. package/core/shared/skills/do-code-review/languages/kotlin.md +88 -0
  196. package/core/shared/skills/do-code-review/languages/php.md +109 -0
  197. package/core/shared/skills/do-code-review/languages/python.md +94 -0
  198. package/core/shared/skills/do-code-review/languages/ruby.md +105 -0
  199. package/core/shared/skills/do-code-review/languages/rust.md +99 -0
  200. package/core/shared/skills/do-code-review/languages/shell.md +90 -0
  201. package/core/shared/skills/do-code-review/languages/swift.md +88 -0
  202. package/core/shared/skills/do-code-review/languages/typescript.md +97 -0
  203. package/core/shared/skills/do-code-review/review-policy.json +47 -0
  204. package/core/shared/skills/do-code-review/rules/universal.md +49 -0
  205. package/core/shared/skills/do-code-review/scripts/code_quality_checker.py +1696 -0
  206. package/core/shared/skills/do-code-review/scripts/doc_quality_checker.py +532 -0
  207. package/core/shared/skills/do-code-review/scripts/pr_analyzer.py +620 -0
  208. package/core/shared/skills/do-code-review/scripts/review_report_generator.py +505 -0
  209. package/core/shared/skills/do-constitution/SKILL.md +72 -0
  210. package/core/shared/skills/do-design/SKILL.md +212 -0
  211. package/core/shared/skills/do-diagnose/SKILL.md +156 -0
  212. package/core/shared/skills/do-diagnose/references/code_audit.md +15 -0
  213. package/core/shared/skills/do-diagnose/references/refactoring.md +12 -0
  214. package/core/shared/skills/do-diagnose/references/root_cause.md +17 -0
  215. package/core/shared/skills/do-document/SKILL.md +81 -0
  216. package/core/shared/skills/do-document/references/api-reference.md +42 -0
  217. package/core/shared/skills/do-document/references/deep_research.md +18 -0
  218. package/core/shared/skills/do-document/references/embedded-research.md +11 -0
  219. package/core/shared/skills/do-document/references/implementation-flow.md +105 -0
  220. package/core/shared/skills/do-document/references/index_generator.md +9 -0
  221. package/core/shared/skills/do-document/references/user-guide.md +22 -0
  222. package/core/shared/skills/do-execute-plan/SKILL.md +261 -0
  223. package/core/shared/skills/do-execute-plan/references/handoff_record.md +57 -0
  224. package/core/shared/skills/do-execute-plan/references/parallel_dispatch.md +92 -0
  225. package/core/shared/skills/do-execute-plan/references/readiness_gate.md +155 -0
  226. package/core/shared/skills/do-execute-plan/references/run_position.md +63 -0
  227. package/core/shared/skills/do-execute-plan/references/scaffold.md +441 -0
  228. package/core/shared/skills/do-execute-plan/references/task_attempts.md +41 -0
  229. package/core/shared/skills/do-flow/SKILL.md +186 -0
  230. package/core/shared/skills/do-flow/references/task_classes.md +134 -0
  231. package/core/shared/skills/do-git/SKILL.md +115 -0
  232. package/core/shared/skills/do-git/references/hotfix-ritual.md +44 -0
  233. package/core/shared/skills/do-git/references/lifecycle-policy.md +54 -0
  234. package/core/shared/skills/do-git/references/release-ritual.md +64 -0
  235. package/core/shared/skills/do-implement/SKILL.md +222 -0
  236. package/core/shared/skills/do-plan/SKILL.md +219 -0
  237. package/core/shared/skills/do-test/SKILL.md +113 -0
  238. package/core/shared/templates/doflow/constitution-template.md +28 -0
  239. package/core/shared/templates/doflow/data-model-template.md +169 -0
  240. package/core/shared/templates/doflow/design-template.md +193 -0
  241. package/core/shared/templates/doflow/external-contract-template.md +35 -0
  242. package/core/shared/templates/doflow/intent-template.md +45 -0
  243. package/core/shared/templates/doflow/plan-template.md +151 -0
  244. package/core/shared/templates/doflow/question-log-template.md +51 -0
  245. package/core/shared/templates/doflow/requirement-template.md +126 -0
  246. package/core/shared/templates/doflow/specs-template.md +55 -0
  247. package/core/shared/templates/doflow/state-template.md +59 -0
  248. package/core/shared/workflows/do-flow-chain.md +17 -0
  249. package/package.json +46 -4
  250. package/src/adapters/antigravity/index.ts +631 -0
  251. package/src/adapters/claude/index.ts +548 -0
  252. package/src/adapters/claude/mcp.ts +94 -0
  253. package/src/adapters/codex/agents.ts +208 -0
  254. package/src/adapters/codex/config.ts +272 -0
  255. package/src/adapters/codex/hooks.ts +203 -0
  256. package/src/adapters/codex/index.ts +707 -0
  257. package/src/adapters/codex/mcp.ts +271 -0
  258. package/src/adapters/codex/native-projection.ts +34 -0
  259. package/src/adapters/copilot/index.ts +413 -0
  260. package/src/adapters/copy-tree.ts +620 -0
  261. package/src/adapters/gemini/hooks.ts +210 -0
  262. package/src/adapters/gemini/index.ts +390 -0
  263. package/src/adapters/hook-commands.ts +60 -0
  264. package/src/adapters/index.ts +106 -0
  265. package/src/adapters/kiro/index.ts +310 -0
  266. package/src/adapters/mcp-entries.ts +331 -0
  267. package/src/adapters/opencode/index.ts +466 -0
  268. package/src/adapters/pi/index.ts +279 -0
  269. package/src/adapters/pi/json-members.ts +185 -0
  270. package/src/adapters/pi/mcp.ts +373 -0
  271. package/src/adapters/resolve-source.ts +19 -0
  272. package/src/cli/commands/install.ts +218 -0
  273. package/src/cli/commands/list-backups.ts +21 -0
  274. package/src/cli/commands/reconcile.ts +120 -0
  275. package/src/cli/commands/remove.ts +82 -0
  276. package/src/cli/commands/rollback.ts +172 -0
  277. package/src/cli/commands/self-update.ts +33 -0
  278. package/src/cli/commands/status.ts +138 -0
  279. package/src/cli/commands/tools.ts +92 -0
  280. package/src/cli/commands/update.ts +162 -0
  281. package/src/cli/index.ts +568 -0
  282. package/src/cli/runtime-commands.ts +281 -0
  283. package/src/cli/shared.ts +218 -0
  284. package/src/helper/bash-helper.ts +34 -0
  285. package/src/helper/git.ts +24 -0
  286. package/src/helper/guardrails.ts +49 -0
  287. package/src/helper/harness-paths.ts +140 -0
  288. package/src/helper/marker-merge.ts +116 -0
  289. package/src/helper/prompt.ts +61 -0
  290. package/src/helper/repo-root.ts +29 -0
  291. package/src/helper/settings-merge.ts +151 -0
  292. package/src/helper/settings-scope.ts +33 -0
  293. package/src/helper/toml.ts +244 -0
  294. package/src/install/backup-set.ts +149 -0
  295. package/src/install/backup.ts +724 -0
  296. package/src/install/context.ts +58 -0
  297. package/src/install/manifest.ts +145 -0
  298. package/src/install/mcp.ts +254 -0
  299. package/src/install/paths.ts +84 -0
  300. package/src/install/targets.ts +90 -0
  301. package/src/install/tool-lifecycle.ts +151 -0
  302. package/src/lifecycle/bash-availability.ts +25 -0
  303. package/src/lifecycle/index.ts +812 -0
  304. package/src/lifecycle/mcp-index.ts +28 -0
  305. package/src/lifecycle/policies.ts +73 -0
  306. package/src/lifecycle/view.ts +175 -0
  307. package/src/registry/index.ts +611 -0
  308. package/src/release/sync-plugin-versions.ts +66 -0
  309. package/src/runtime/advice/cli.ts +283 -0
  310. package/src/runtime/advice/client.ts +324 -0
  311. package/src/runtime/advice/config.ts +189 -0
  312. package/src/runtime/advice/doctor.ts +169 -0
  313. package/src/runtime/advice/endpoint-guard.ts +134 -0
  314. package/src/runtime/advice/kinds.ts +154 -0
  315. package/src/runtime/advice/providers/ollama-systemone.ts +209 -0
  316. package/src/runtime/advice/record.ts +166 -0
  317. package/src/runtime/advice/registry.ts +170 -0
  318. package/src/runtime/advice/strength.ts +66 -0
  319. package/src/runtime/brief-tools.ts +342 -0
  320. package/src/runtime/c4-project.ts +371 -0
  321. package/src/runtime/capability-router.ts +484 -0
  322. package/src/runtime/checkouts.ts +170 -0
  323. package/src/runtime/claims.ts +468 -0
  324. package/src/runtime/cli-result.ts +55 -0
  325. package/src/runtime/cli.ts +678 -0
  326. package/src/runtime/command-detect.ts +516 -0
  327. package/src/runtime/context-pack.ts +492 -0
  328. package/src/runtime/decision-register.ts +493 -0
  329. package/src/runtime/evidence-ledger.ts +338 -0
  330. package/src/runtime/failure/capture.ts +272 -0
  331. package/src/runtime/failure/classifier.ts +68 -0
  332. package/src/runtime/failure/cli.ts +178 -0
  333. package/src/runtime/failure/home.ts +64 -0
  334. package/src/runtime/failure/store.ts +226 -0
  335. package/src/runtime/feature-resolve.ts +79 -0
  336. package/src/runtime/freshness.ts +177 -0
  337. package/src/runtime/health.ts +863 -0
  338. package/src/runtime/history-compactor.ts +237 -0
  339. package/src/runtime/implementation-gate.ts +321 -0
  340. package/src/runtime/inventory/coverage.ts +218 -0
  341. package/src/runtime/inventory/identity.ts +65 -0
  342. package/src/runtime/inventory/index.ts +705 -0
  343. package/src/runtime/inventory/precedence.ts +134 -0
  344. package/src/runtime/inventory/read-scopes.ts +131 -0
  345. package/src/runtime/inventory/siblings.ts +192 -0
  346. package/src/runtime/knowledge/chunker.ts +124 -0
  347. package/src/runtime/knowledge/hybrid.ts +149 -0
  348. package/src/runtime/knowledge/index-store.ts +145 -0
  349. package/src/runtime/knowledge/retrieval.ts +194 -0
  350. package/src/runtime/leak-scan.ts +185 -0
  351. package/src/runtime/lifecycle/cli.ts +276 -0
  352. package/src/runtime/lifecycle/event-store.ts +340 -0
  353. package/src/runtime/lifecycle/fold.ts +353 -0
  354. package/src/runtime/lifecycle/followup.ts +432 -0
  355. package/src/runtime/lifecycle/goal.ts +249 -0
  356. package/src/runtime/lifecycle/intent-writer.ts +86 -0
  357. package/src/runtime/lifecycle/overview.ts +292 -0
  358. package/src/runtime/lifecycle/release.ts +241 -0
  359. package/src/runtime/lifecycle/report-store.ts +221 -0
  360. package/src/runtime/lifecycle/retention.ts +212 -0
  361. package/src/runtime/lifecycle/root.ts +71 -0
  362. package/src/runtime/lifecycle/status.ts +353 -0
  363. package/src/runtime/lifecycle/store-upkeep.ts +393 -0
  364. package/src/runtime/locator-resolve.ts +158 -0
  365. package/src/runtime/mask.ts +210 -0
  366. package/src/runtime/mcp-consultations.ts +412 -0
  367. package/src/runtime/mcp-reach.ts +157 -0
  368. package/src/runtime/model-router.ts +153 -0
  369. package/src/runtime/outcome.ts +467 -0
  370. package/src/runtime/reach.ts +148 -0
  371. package/src/runtime/readiness-record.ts +150 -0
  372. package/src/runtime/readiness.ts +435 -0
  373. package/src/runtime/recovery.ts +282 -0
  374. package/src/runtime/research-request.ts +231 -0
  375. package/src/runtime/retrieval-plan.ts +578 -0
  376. package/src/runtime/scaffold/artifacts.ts +217 -0
  377. package/src/runtime/scaffold/fingerprint.ts +56 -0
  378. package/src/runtime/scaffold/generate.ts +911 -0
  379. package/src/runtime/scaffold/languages.ts +245 -0
  380. package/src/runtime/storage/cli.ts +64 -0
  381. package/src/runtime/storage/engine.ts +249 -0
  382. package/src/runtime/storage/registry.ts +130 -0
  383. package/src/runtime/storage/roots.ts +63 -0
  384. package/src/runtime/storage/rules.ts +282 -0
  385. package/src/runtime/storage/upkeep.ts +243 -0
  386. package/src/runtime/storage/work-status.ts +326 -0
  387. package/src/runtime/task-attempts.ts +415 -0
  388. package/src/runtime/task-classifier.ts +550 -0
  389. package/src/runtime/task-scope.ts +130 -0
  390. package/src/runtime/task-state.ts +176 -0
  391. package/src/runtime/trace/indicators.ts +235 -0
  392. package/src/runtime/trace/ledger.ts +626 -0
  393. package/src/runtime/trace/render.ts +76 -0
  394. package/src/runtime/trace/views.ts +459 -0
  395. package/src/runtime/verification/contract-runner.ts +341 -0
  396. package/src/runtime/verification/engine.ts +1461 -0
  397. package/src/runtime/verification/registry.ts +130 -0
  398. package/src/runtime/verification/scope-bound.ts +418 -0
  399. package/src/runtime/verification/test-selection.ts +172 -0
  400. package/src/runtime/workflow-engine.ts +588 -0
  401. package/src/runtime/workflow-orchestrator.ts +953 -0
  402. package/src/runtime/worktree.ts +340 -0
  403. package/src/state/index.ts +193 -0
  404. package/src/state/lockfile.ts +136 -0
  405. package/src/state/run-lock.ts +408 -0
  406. package/types/node-commonjs.d.ts +68 -0
  407. package/types/runtime.d.ts +91 -0
@@ -0,0 +1,1461 @@
1
+ 'use strict';
2
+
3
+ // TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): run directly by Node native type-stripping.
4
+
5
+ /**
6
+ * Registry-driven verification layer (plan task C.2, design C7): `VerificationEngine` compiles a
7
+ * contract **before** implementation and a report **after**, scaling required tiers to risk,
8
+ * detecting commands from project manifests, and wiring the C8 recovery classifier into the
9
+ * failure path with its bounded retry.
10
+ *
11
+ * Built on two modules split out alongside it:
12
+ * - `verification-contract-runner.js` — `VerificationContractRunner`, the deterministic
13
+ * check-execution primitive this engine compiles tiers of checks down to.
14
+ * - `verification-registry.js` — `loadVerificationRegistry`, which reads and validates
15
+ * `core/registry/verification.json` into the shape `compileContract` reads tiers from.
16
+ *
17
+ * Three defects in the runner this engine sits on (a check with no command reporting PASS, a
18
+ * timed-out check losing its output, an empty contract reporting PASS over zero evidence — see
19
+ * `verification-contract-runner.js`) are the reason this layer looks the way it does. Their shared
20
+ * shape is a gate answering confidently about something it never evaluated, so the engine keeps
21
+ * "not evaluated" as a first-class outcome — UNRESOLVED — that can never become PASS.
22
+ */
23
+
24
+ const path = require('node:path');
25
+ const nodeFs = require('node:fs');
26
+ const { spawnSync } = require('node:child_process');
27
+ const { detectCommands, applyTargetPattern, TARGET_PATTERN_TOKEN } = require('../command-detect.ts');
28
+ const { selectChangedTests } = require('./test-selection.ts');
29
+ const { RecoveryManager } = require('../recovery.ts');
30
+ const { finishRuntime, usageError } = require('../cli-result.ts');
31
+ const { REPO_ROOT } = require('../../helper/repo-root.ts');
32
+ const {
33
+ resolveScopeBound, resolveIntegrationBase, resolveExplicitBaseline, parseDeclaredScope, scopeReasonText, boundSourcesText,
34
+ } = require('./scope-bound.ts');
35
+ const nodeCrypto = require('node:crypto');
36
+ const { readReadinessRecord, RECORD_VERSION } = require('../readiness-record.ts');
37
+ const { verifyReadinessCheck, featureSlugFor } = require('../implementation-gate.ts');
38
+ const { assertSafeTaskId } = require('../evidence-ledger.ts');
39
+ const { upkeepAfterVerify } = require('../storage/upkeep.ts');
40
+ const {
41
+ VerificationContractRunner,
42
+ FATAL_CHECK_MARKERS,
43
+ TIMEOUT_EXIT_CODE,
44
+ MAX_STREAM_CHARS,
45
+ } = require('./contract-runner.ts');
46
+ const {
47
+ loadVerificationRegistry,
48
+ TIER_RESOLUTIONS,
49
+ TIER_STATUSES,
50
+ RECOVERY_OUTCOMES,
51
+ REGISTRY_FILENAME,
52
+ } = require('./registry.ts');
53
+
54
+ // `handleVerifyCommand` (moved from bin/doflow.js) used bin/doflow.js's own REPO_ROOT — that file's
55
+ // SCRIPT_DIR is bin/, so its repo root is path.dirname(SCRIPT_DIR). This module takes the same root
56
+ // from src/helper/repo-root.js rather than counting its own depth.
57
+
58
+ /** `PASS` and `FAIL` are the Python's vocabulary. `INCONCLUSIVE` is added for the empty-contract
59
+ * case only — see `evaluateContract`. */
60
+ const VERIFICATION_STATUSES = Object.freeze(['PASS', 'FAIL', 'INCONCLUSIVE']);
61
+
62
+ /** The targeted tier is subsumed by the broad one when both would run the same command (FR-001). */
63
+ const TARGETED_TIER_ID = 'targeted-tests';
64
+ const BROAD_TIER_ID = 'broad-tests';
65
+
66
+ /**
67
+ * @param {*} value
68
+ * @returns {Array<string>}
69
+ */
70
+ /** A stderr excerpt for a message. (Referenced by the observation errors below without ever being
71
+ * defined, so a non-zero `git status` with no spawn error threw a ReferenceError instead of reporting.) */
72
+ function truncate(text: any, max: number = 200): string {
73
+ const flat = String(text || '').trim();
74
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
75
+ }
76
+
77
+ function toStringArray(value: any): string[] {
78
+ return Array.isArray(value) ? value.filter((v) => typeof v === 'string' && v !== '') : [];
79
+ }
80
+
81
+ /**
82
+ * Parses `git status --porcelain` into the set of paths it touched.
83
+ * @param {string} stdout
84
+ * @returns {Array<string>}
85
+ */
86
+ function parsePorcelain(stdout: any): string[] {
87
+ const files: string[] = [];
88
+ for (const line of String(stdout || '').split('\n')) {
89
+ if (line.trim() === '') continue;
90
+ const body = line.slice(3);
91
+ // A rename is reported as `old -> new`; the new path is the one that exists now.
92
+ const arrow = body.indexOf(' -> ');
93
+ files.push(arrow === -1 ? body.trim() : body.slice(arrow + 4).trim());
94
+ }
95
+ return files.filter(Boolean);
96
+ }
97
+
98
+ class VerificationEngine {
99
+ cwd: string;
100
+ fsImpl: any;
101
+ exec: any;
102
+ clock: any;
103
+ registry: any;
104
+ policy: any;
105
+ runner: any;
106
+ recoveryOverride: any;
107
+
108
+ /**
109
+ * @param {Object} [options]
110
+ * @param {string} [options.cwd] directory checks run in
111
+ * @param {string} [options.repoRoot] where `core/registry/` lives
112
+ * @param {Object} [options.registry] pre-parsed registry (test seam)
113
+ * @param {Object} [options.fsImpl] injection seam for manifest and registry reads
114
+ * @param {Function} [options.exec] injection seam for shell execution; `spawnSync` contract
115
+ * @param {Function} [options.clock] injection seam returning epoch milliseconds
116
+ * @param {RecoveryManager} [options.recovery] overrides the per-risk-level manager
117
+ */
118
+ constructor(options: any = {}) {
119
+ this.cwd = options.cwd || process.cwd();
120
+ this.fsImpl = options.fsImpl || nodeFs;
121
+ this.exec = options.exec || spawnSync;
122
+ this.clock = options.clock || Date.now;
123
+ this.registry = loadVerificationRegistry({
124
+ repoRoot: options.repoRoot,
125
+ registryDir: options.registryDir,
126
+ registryPath: options.registryPath,
127
+ registry: options.registry,
128
+ fsImpl: this.fsImpl,
129
+ });
130
+ this.policy = this.registry.policy || {};
131
+ this.runner = new VerificationContractRunner({ cwd: this.cwd, exec: this.exec });
132
+ this.recoveryOverride = options.recovery || null;
133
+ }
134
+
135
+ /** @returns {string} ISO timestamp from the injected clock */
136
+ timestamp(): string {
137
+ return new Date(this.clock()).toISOString();
138
+ }
139
+
140
+ /**
141
+ * @param {string} riskLevel
142
+ * @returns {Object} the risk-level definition
143
+ */
144
+ riskDefinition(riskLevel: string): any {
145
+ const def = this.registry.riskLevels[riskLevel];
146
+ if (!def) {
147
+ // Never coerce to the default: a caller that mistyped CRITICAL would silently get MEDIUM's
148
+ // lighter tier set on the change that most needed the heavier one.
149
+ throw new Error(
150
+ `Unknown risk level '${riskLevel}'. Valid: ${Object.keys(this.registry.riskLevels).join(', ')}`,
151
+ );
152
+ }
153
+ return def;
154
+ }
155
+
156
+ /**
157
+ * Compiles the verification contract. Called **before** implementation: this is the artifact that
158
+ * states how success will be established (FR-009), and `runContract` later reports against it.
159
+ *
160
+ * @param {Object} input
161
+ * @param {string} input.taskId
162
+ * @param {string} [input.riskLevel] defaults to `policy.defaultRiskLevel`
163
+ * @param {Object} [input.detection] a `detectCommands` result; detected here when absent
164
+ * @param {string} [input.projectRoot] used for detection when `detection` is not supplied
165
+ * @param {string} [input.planPath] a feature `plan.md` whose override block wins over detection
166
+ * @param {string} [input.planText] override text supplied directly
167
+ * @param {string} [input.targetPattern] scopes the targeted-tests tier; without one, the tier is
168
+ * scoped to the feature's changed test files (see test-selection.js) where the toolchain allows
169
+ * @param {Array<string>} [input.changedFiles] files the selection treats as changed, in place of the
170
+ * change-scope observation
171
+ * @param {Array<{name: string, command: string, timeoutMs?: number}>} [input.structuralChecks]
172
+ * @param {Array<{id: string, description?: string, command?: string}>} [input.requirements]
173
+ * @param {{maxFiles?: number, allowedPaths?: Array<string>, source?: string, sources?: Array<Object>,
174
+ * baseline?: 'integration', unresolvedReason?: string, searched?: Array<Object>}} [input.scope]
175
+ * `baseline: 'integration'` measures the change from the merge base with the integration ref
176
+ * (a bound derived from a plan, scope-bound.js); without it only the working tree is observed.
177
+ * `sources` names where the bound came from; with no bound, `unresolvedReason` replaces the
178
+ * tier's generic reason and `searched` lists the places looked.
179
+ * @returns {Object} the contract
180
+ */
181
+ compileContract(input: any = {}): any {
182
+ // A contract with no task is the readiness.ts `taskId || 'default'` defect in a new place: a
183
+ // confident verdict filed against the wrong subject. Refuse instead of inventing an id.
184
+ if (typeof input.taskId !== 'string' || input.taskId.trim() === '') {
185
+ throw new Error('compileContract requires a non-empty taskId');
186
+ }
187
+
188
+ const riskLevel = input.riskLevel || this.policy.defaultRiskLevel;
189
+ const risk = this.riskDefinition(riskLevel);
190
+ const requiredTiers = new Set(risk.requiredTiers);
191
+ const advisoryTiers = new Set(risk.advisoryTiers || []);
192
+
193
+ const detection = input.detection || detectCommands({
194
+ projectRoot: input.projectRoot || this.cwd,
195
+ planPath: input.planPath,
196
+ planText: input.planText,
197
+ fsImpl: this.fsImpl,
198
+ });
199
+
200
+ let structuralChecks = Array.isArray(input.structuralChecks) ? input.structuralChecks : [];
201
+ let requirements = Array.isArray(input.requirements) ? input.requirements : [];
202
+ const scope = input.scope && typeof input.scope === 'object' ? input.scope : {};
203
+ const phase = input.phase === 'postimplementation' ? 'postimplementation' : 'unknown';
204
+ const declaredNone = new Set<string>(detection.declaredNone || []);
205
+ const absent = new Set<string>(detection.absent || []);
206
+ let provenance = input.provenance && typeof input.provenance === 'object' ? { ...input.provenance } : null;
207
+
208
+ if (input.verificationInput !== undefined && input.verificationInput !== null) {
209
+ const val = validateVerificationInput({ cwd: this.cwd, verificationInput: input.verificationInput });
210
+ if (!val.ok) throw new Error(val.message);
211
+ structuralChecks = val.structuralChecks;
212
+ requirements = val.requirements;
213
+ provenance = { ...(provenance || {}), ...val.provenance };
214
+ }
215
+ if (scope.baseline === 'explicit' && scope.baselineSha) {
216
+ provenance = { ...(provenance || {}), baselineSha: scope.baselineSha };
217
+ }
218
+
219
+ const tiers = this.registry.tiers.map((tier: any) => {
220
+ const required = requiredTiers.has(tier.id);
221
+ const advisory = advisoryTiers.has(tier.id);
222
+ const base = {
223
+ id: tier.id,
224
+ name: tier.name,
225
+ kind: tier.kind,
226
+ order: tier.order,
227
+ required,
228
+ advisory,
229
+ fatal: Boolean(tier.fatal),
230
+ modelBased: Boolean(tier.modelBased),
231
+ failureClass: tier.failureClass || null,
232
+ checks: [],
233
+ resolution: 'SKIPPED',
234
+ reason: `Not required at risk level ${riskLevel}.`,
235
+ };
236
+ if (!required && !advisory) return base;
237
+
238
+ if (tier.source === 'detected-command') {
239
+ const selectorContext = { changedFiles: input.changedFiles, scope, root: detection.projectRoot || input.projectRoot || this.cwd };
240
+ return Object.assign(base, this.resolveCommandTier(tier, detection, declaredNone, absent, input.targetPattern, selectorContext));
241
+ }
242
+ if (tier.source === 'caller-supplied' && tier.id === 'structural-invariants') {
243
+ return Object.assign(base, this.resolveStructuralTier(tier, structuralChecks));
244
+ }
245
+ if (tier.source === 'caller-supplied' && tier.id === 'requirement-satisfaction') {
246
+ return Object.assign(base, this.resolveRequirementTier(tier, requirements));
247
+ }
248
+ if (tier.source === 'scope-observation') {
249
+ return Object.assign(base, this.resolveScopeTier(tier, scope));
250
+ }
251
+ if (tier.source === 'model') {
252
+ return Object.assign(base, {
253
+ resolution: 'RESOLVED',
254
+ reason: 'Advisory model review; supplied to runContract, never establishes PASS.',
255
+ });
256
+ }
257
+ // A tier whose source this engine does not implement is unevaluated, not satisfied.
258
+ return Object.assign(base, {
259
+ resolution: 'UNRESOLVED',
260
+ reason: `Tier source '${tier.source}' has no evaluator in this engine.`,
261
+ });
262
+ });
263
+
264
+ // Subsumption is a second pass because it depends on how a *later* tier resolved: parse is
265
+ // covered by build only if build actually has a command.
266
+ const byId = new Map<string, any>(tiers.map((t: any) => [t.id, t]));
267
+ for (const tier of this.registry.tiers) {
268
+ const resolved: any = byId.get(tier.id);
269
+ if (!resolved || resolved.resolution !== 'UNRESOLVED') continue;
270
+ const cover = toStringArray(tier.subsumedBy).find((id: string) => { const e: any = byId.get(id); return e && e.resolution === 'RESOLVED'; });
271
+ if (cover) {
272
+ resolved.resolution = 'SUBSUMED';
273
+ resolved.subsumedBy = cover;
274
+ resolved.reason = `No dedicated command; covered by the '${cover}' tier, which runs the same source through a stricter step.`;
275
+ }
276
+ }
277
+
278
+ // A targeted tier that would run the very command a required broad tier runs is that tier's
279
+ // duplicate, not a cheaper check: it is reported SUBSUMED and never executed (FR-001).
280
+ const targeted: any = byId.get(TARGETED_TIER_ID);
281
+ const broad: any = byId.get(BROAD_TIER_ID);
282
+ if (targeted && broad && targeted.resolution === 'RESOLVED' && broad.required && broad.resolution === 'RESOLVED') {
283
+ const broadCommands = new Set<string>(broad.checks.map((c: any) => c.command as string));
284
+ if (targeted.checks.length > 0 && targeted.checks.every((c: any) => broadCommands.has(c.command))) {
285
+ targeted.resolution = 'SUBSUMED';
286
+ targeted.subsumedBy = BROAD_TIER_ID;
287
+ targeted.checks = [];
288
+ targeted.reason = `Would run the same command as the required '${BROAD_TIER_ID}' tier, which runs it once.`;
289
+ }
290
+ }
291
+
292
+ const unresolvedRequiredTiers = tiers
293
+ .filter((t: any) => t.required && t.resolution === 'UNRESOLVED')
294
+ .map((t: any) => ({ tier: t.id, reason: t.reason }));
295
+
296
+ return {
297
+ version: this.registry.version,
298
+ taskId: input.taskId,
299
+ riskLevel,
300
+ compiledAt: this.timestamp(),
301
+ // Compilation does not know when it runs relative to implementation, so it never asserts it ran
302
+ // before: `unknown` is null, and only an explicit `postimplementation` phase records false (IC-022).
303
+ phase,
304
+ compiledBeforeImplementation: phase === 'postimplementation' ? false : null,
305
+ ...(provenance && typeof provenance === 'object' ? { provenance } : {}),
306
+ cwd: this.cwd,
307
+ maxRecoveryIterations: risk.maxRecoveryIterations,
308
+ detection: {
309
+ usable: detection.usable,
310
+ manifestFound: detection.manifestFound,
311
+ manifests: detection.manifests,
312
+ absent: detection.absent,
313
+ declaredNone: detection.declaredNone,
314
+ errors: detection.errors,
315
+ },
316
+ scope: {
317
+ maxFiles: Number.isFinite(scope.maxFiles) ? scope.maxFiles : null,
318
+ allowedPaths: toStringArray(scope.allowedPaths),
319
+ ...(scope.baseline === 'explicit' ? { baseline: { kind: 'pinned', ref: scope.baselineRef, sha: scope.baselineSha } } : {}),
320
+ ...(Array.isArray(scope.sources) ? { sources: scope.sources } : {}),
321
+ ...(Array.isArray(scope.searched) ? { searched: scope.searched } : {}),
322
+ },
323
+ tiers,
324
+ unresolvedRequiredTiers,
325
+ runnableCheckCount: tiers.reduce((n: number, t: any) => n + t.checks.length, 0),
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Resolves a tier served by a detected command. The whole point of the three-way distinction in
331
+ * `command-detect` lands here.
332
+ * @private
333
+ */
334
+ resolveCommandTier(tier: any, detection: any, declaredNone: Set<string>, absent: Set<string>, targetPattern: any, selectorContext: any = null): any {
335
+ if (!detection.usable) {
336
+ return {
337
+ resolution: 'UNRESOLVED',
338
+ reason: `Command detection is unusable: ${detection.errors.join('; ')}`,
339
+ };
340
+ }
341
+
342
+ const roles = toStringArray(tier.commandRoles);
343
+ const selectAll = tier.commandRoleSelection === 'all';
344
+ const checks: any[] = [];
345
+ const notes: string[] = [];
346
+ let selection: any = null;
347
+
348
+ for (const role of roles) {
349
+ const found = detection.commands[role];
350
+ if (!found) continue;
351
+ let pattern = targetPattern;
352
+ if (role === 'testTargeted' && found.command.includes(TARGET_PATTERN_TOKEN)) {
353
+ if (typeof targetPattern === 'string' && targetPattern.trim() !== '') {
354
+ selection = { basis: 'explicit-pattern', selectedCount: null, totalCount: null, fallback: null };
355
+ } else {
356
+ // No caller-supplied target: scope to the files this feature changed, where the toolchain
357
+ // has a test-file convention. The changed files are the ones the change-scope tier reads.
358
+ const ctx = selectorContext || {};
359
+ const scope = ctx.scope || {};
360
+ const observation = this.observeForSelection(ctx.changedFiles, {
361
+ baseline: scope.baseline, baselineRef: scope.baselineRef, baselineSha: scope.baselineSha,
362
+ });
363
+ const selected = selectChangedTests({
364
+ found, observation, root: ctx.root || this.cwd, fsImpl: this.fsImpl,
365
+ });
366
+ if (selected) {
367
+ pattern = selected.pattern;
368
+ selection = selected.selection;
369
+ } else {
370
+ selection = { basis: 'none', selectedCount: null, totalCount: null, fallback: 'not-scopable' };
371
+ }
372
+ }
373
+ }
374
+ const { command, substituted } = applyTargetPattern(found.command, pattern);
375
+ if (command === null) {
376
+ // The command is a template and no target was supplied. Fall through to the next role —
377
+ // for targeted-tests that is the broad test command, which the report records.
378
+ notes.push(selection && selection.fallback
379
+ ? `role '${role}' has no selection (${selection.fallback})`
380
+ : `role '${role}' needs a target pattern and none was supplied`);
381
+ continue;
382
+ }
383
+ checks.push({
384
+ name: `${tier.id}:${role}`,
385
+ tier: tier.id,
386
+ role,
387
+ command,
388
+ timeoutMs: tier.timeoutMs || undefined,
389
+ source: found.source,
390
+ derivation: found.derivation,
391
+ targetScoped: substituted,
392
+ });
393
+ if (!selectAll) break;
394
+ }
395
+
396
+ if (checks.length > 0) {
397
+ const fellBack = !selectAll && checks[0].role !== roles[0];
398
+ return {
399
+ resolution: 'RESOLVED',
400
+ reason: fellBack
401
+ ? `Using the '${checks[0].role}' command; ${notes.join('; ')}.`
402
+ : null,
403
+ checks,
404
+ ...(selection ? { selection } : {}),
405
+ };
406
+ }
407
+
408
+ // Explicit human declaration outranks everything: the plan said this project has no such
409
+ // command, which is a statement, not a detection miss.
410
+ if (roles.every((role: string) => declaredNone.has(role))) {
411
+ return {
412
+ resolution: 'NOT_APPLICABLE',
413
+ reason: `plan.md declares this project has no ${roles.join('/')} command.`,
414
+ };
415
+ }
416
+
417
+ // A manifest was read and does not declare the role. For build or lint that is real evidence
418
+ // the project has no such step; for a test command it is not, and `absenceIsEvidence: false`
419
+ // in the registry is what keeps that case UNRESOLVED and off PASS.
420
+ if (tier.absenceIsEvidence && detection.manifestFound && roles.every((role: string) => absent.has(role) || declaredNone.has(role))) {
421
+ return {
422
+ resolution: 'NOT_APPLICABLE',
423
+ reason: `${detection.manifests.join(', ')} declares no ${roles.join('/')} step.`,
424
+ };
425
+ }
426
+
427
+ const why = detection.manifestFound
428
+ ? `no ${roles.join('/')} command could be detected from ${detection.manifests.join(', ')}`
429
+ : `no project manifest was found under ${detection.projectRoot || this.cwd}`;
430
+ return {
431
+ resolution: 'UNRESOLVED',
432
+ reason: `${why}. Declare it in plan.md's \`${this.policy.planOverrideBlock}\` block (use null to state the project has none).`,
433
+ };
434
+ }
435
+
436
+ /** @private */
437
+ resolveStructuralTier(tier: any, structuralChecks: any[]): any {
438
+ const checks = structuralChecks
439
+ .filter((c) => c && typeof c.command === 'string' && c.command.trim() !== '')
440
+ .map((c, i) => ({
441
+ name: `${tier.id}:${c.name || `invariant_${i + 1}`}`,
442
+ tier: tier.id,
443
+ role: 'structural',
444
+ command: c.command,
445
+ timeoutMs: c.timeoutMs || tier.timeoutMs || undefined,
446
+ source: 'caller',
447
+ derivation: 'declared',
448
+ }));
449
+ if (checks.length === 0) {
450
+ return {
451
+ resolution: 'UNRESOLVED',
452
+ reason: 'No structural invariant checks were supplied, and this risk level requires structural evidence.',
453
+ };
454
+ }
455
+ return { resolution: 'RESOLVED', checks, reason: null };
456
+ }
457
+
458
+ /** @private */
459
+ resolveRequirementTier(tier: any, requirements: any[]): any {
460
+ const checks: any[] = [];
461
+ const unevaluated: any[] = [];
462
+ for (const req of requirements) {
463
+ if (!req || typeof req.id !== 'string' || req.id === '') continue;
464
+ if (typeof req.command === 'string' && req.command.trim() !== '') {
465
+ checks.push({
466
+ name: `${tier.id}:${req.id}`,
467
+ tier: tier.id,
468
+ role: 'requirement',
469
+ requirementId: req.id,
470
+ description: req.description || null,
471
+ command: req.command,
472
+ timeoutMs: req.timeoutMs || tier.timeoutMs || undefined,
473
+ source: 'caller',
474
+ derivation: 'declared',
475
+ });
476
+ } else {
477
+ // An asserted requirement is not a verified one. It is listed so the report can name what
478
+ // is unproven, rather than counted as satisfied.
479
+ unevaluated.push({ id: req.id, description: req.description || null });
480
+ }
481
+ }
482
+ if (checks.length === 0 && unevaluated.length === 0) {
483
+ return {
484
+ resolution: 'UNRESOLVED',
485
+ reason: 'No requirement checks were supplied, and this risk level requires requirement evidence.',
486
+ };
487
+ }
488
+ return {
489
+ resolution: 'RESOLVED',
490
+ checks,
491
+ unevaluatedRequirements: unevaluated,
492
+ reason: unevaluated.length > 0
493
+ ? `${unevaluated.length} requirement(s) have no executable check and cannot be reported satisfied.`
494
+ : null,
495
+ };
496
+ }
497
+
498
+ /** @private */
499
+ resolveScopeTier(tier: any, scope: any): any {
500
+ const maxFiles = Number.isFinite(scope.maxFiles) ? scope.maxFiles : null;
501
+ const allowedPaths = toStringArray(scope.allowedPaths);
502
+ if (maxFiles === null && allowedPaths.length === 0) {
503
+ return {
504
+ resolution: 'UNRESOLVED',
505
+ reason: typeof scope.unresolvedReason === 'string' && scope.unresolvedReason !== ''
506
+ ? scope.unresolvedReason
507
+ : 'No change-scope bound was declared before implementation, so the actual scope has nothing to be compared against.',
508
+ };
509
+ }
510
+ return {
511
+ resolution: 'RESOLVED',
512
+ bound: {
513
+ maxFiles,
514
+ allowedPaths,
515
+ ...(typeof scope.source === 'string' ? { source: scope.source } : {}),
516
+ ...(Array.isArray(scope.sources) ? { sources: scope.sources } : {}),
517
+ ...(scope.baseline === 'integration' ? { baseline: 'integration' } : {}),
518
+ ...(scope.baseline === 'explicit' ? { baseline: 'explicit', baselineRef: scope.baselineRef, baselineSha: scope.baselineSha } : {}),
519
+ },
520
+ reason: null,
521
+ };
522
+ }
523
+
524
+ /**
525
+ * Observes which files the change actually touched.
526
+ * @private
527
+ * @param {Array<string>} [changedFiles] caller-supplied files, which win over any observation
528
+ * @param {Object} [bound] a bound with `baseline: 'integration'` adds the files committed since the
529
+ * merge base with the integration ref to the working tree's
530
+ * @returns {{ files: Array<string>|null, reason: string|null, baseline?: Object }}
531
+ */
532
+ observeChangedFiles(changedFiles: any, bound: any = null): any {
533
+ // An explicit baseline observes git itself: a caller's file list cannot stand in for what changed.
534
+ if (bound && bound.baseline === 'explicit') return this.observeSince(bound.baselineSha, { kind: 'pinned', ref: bound.baselineRef, sha: bound.baselineSha });
535
+ if (Array.isArray(changedFiles)) return { files: changedFiles.filter((f: any) => typeof f === 'string'), reason: null };
536
+
537
+ if (bound && bound.baseline === 'integration') return this.observeFromBase();
538
+
539
+ let res: any;
540
+ try {
541
+ res = this.exec('git status --porcelain', {
542
+ shell: true,
543
+ cwd: this.cwd,
544
+ encoding: 'utf8',
545
+ timeout: 30000,
546
+ });
547
+ } catch (error: any) {
548
+ return { files: null, reason: `Could not observe the changed files: ${error.message}` };
549
+ }
550
+ if (!res || res.error || res.status !== 0) {
551
+ const detail = (res && res.error && res.error.message) || (res && truncate(res.stderr)) || 'git returned a non-zero status';
552
+ return { files: null, reason: `Could not observe the changed files: ${detail}` };
553
+ }
554
+ return { files: parsePorcelain(res.stdout), reason: null };
555
+ }
556
+
557
+ /**
558
+ * The changed files the test selector reads. Same sources as `observeChangedFiles`, except that
559
+ * with no baseline and no caller list the working tree is read NUL-separated, unquoted and with
560
+ * untracked directories expanded, so a non-ASCII name or a new directory of tests is not dropped.
561
+ * @private
562
+ */
563
+ observeForSelection(changedFiles: any, bound: any = null): any {
564
+ const hasBaseline = bound && (bound.baseline === 'explicit' || bound.baseline === 'integration');
565
+ if (!Array.isArray(changedFiles) && !hasBaseline) return this.observeSince(null, null);
566
+ return this.observeChangedFiles(changedFiles, bound);
567
+ }
568
+
569
+ /**
570
+ * The bounded observation (IC-004): files committed since the merge base with the integration ref,
571
+ * plus the working tree. Paths are read NUL-separated and unquoted, so a name with a space or
572
+ * non-ASCII characters is the name the plan wrote, and renames are not collapsed, so a moved file
573
+ * reports both where it was and where it went.
574
+ * @private
575
+ */
576
+ observeFromBase(): any {
577
+ const base = resolveIntegrationBase({ cwd: this.cwd, exec: this.exec });
578
+ if (base.reason) {
579
+ const working = this.observeSince(null, null);
580
+ if (working.files === null) return working;
581
+ return { files: working.files, reason: null, baseline: { kind: 'working-tree', note: base.note || `${base.reason}; only the working tree was compared` } };
582
+ }
583
+ return this.observeSince(base.mergeBase, { kind: 'merge-base', ref: base.ref, mergeBase: base.mergeBase });
584
+ }
585
+
586
+ /**
587
+ * Files changed since `commit` (null: the working tree alone): committed changes to HEAD, staged,
588
+ * unstaged and untracked paths. NUL-delimited, unquoted, with renames split so both names count; only
589
+ * DoFlow's own `.doflow/` state is excluded.
590
+ * @private
591
+ */
592
+ observeSince(commit: string | null, baseline: any): any {
593
+ const git = (args: string[]) => {
594
+ let res: any;
595
+ try {
596
+ res = this.exec('git', ['-c', 'core.quotePath=false', ...args], { cwd: this.cwd, encoding: 'utf8', timeout: 30000 });
597
+ } catch (error: any) {
598
+ return { reason: `Could not observe the changed files: ${error.message}` };
599
+ }
600
+ if (!res || res.error || res.status !== 0) {
601
+ const detail = (res && res.error && res.error.message) || (res && truncate(res.stderr)) || 'git returned a non-zero status';
602
+ return { reason: `Could not observe the changed files: ${detail}` };
603
+ }
604
+ return { out: String(res.stdout || '') };
605
+ };
606
+ const status = git(['status', '--porcelain', '-z', '--no-renames', '--untracked-files=all']);
607
+ if (status.reason) return { files: null, reason: status.reason };
608
+ // `-z` porcelain entries are `XY <path>` separated by NUL.
609
+ // `.doflow/` is DoFlow's own state (records, journals): writing it is not changing the project,
610
+ // and a repo that does not ignore it would otherwise fail its own scope after any record.
611
+ const counts = (f: string) => f !== '.doflow' && !f.startsWith('.doflow/');
612
+ const working = status.out!.split('\0').filter(Boolean).map((entry: string) => entry.slice(3)).filter(Boolean).filter(counts);
613
+ if (commit === null) return { files: working, reason: null };
614
+
615
+ const diff = git(['diff', '--name-only', '--no-renames', '-z', `${commit}..HEAD`]);
616
+ if (diff.reason) return { files: null, reason: diff.reason };
617
+ const committed = diff.out!.split('\0').filter(Boolean).filter(counts);
618
+ return { files: [...new Set([...committed, ...working])], reason: null, baseline };
619
+ }
620
+
621
+ /**
622
+ * Runs a compiled contract and produces the report. Called **after** implementation.
623
+ *
624
+ * @param {Object} contract from `compileContract`
625
+ * @param {Object} [runtimeInput]
626
+ * @param {Array<string>} [runtimeInput.changedFiles] overrides the git observation
627
+ * @param {{status: string, findings?: Array<string>, reviewer?: string}} [runtimeInput.modelReview]
628
+ * @returns {Object} the report
629
+ */
630
+ runContract(contract: any, runtimeInput: any = {}): any {
631
+ if (!contract || !Array.isArray(contract.tiers)) {
632
+ throw new Error('runContract expects a contract produced by compileContract');
633
+ }
634
+
635
+ const startedAt = this.timestamp();
636
+ const reportTiers: any[] = [];
637
+ const failedChecks: string[] = [];
638
+ let fatalTier: string | null = null;
639
+ let checksRun = 0;
640
+ let checksPassed = 0;
641
+ // One evaluation pass, one cache: two tiers declaring the same command (targeted and broad
642
+ // tests are often both `npm test`) run it once and share the result. A recovery retry calls
643
+ // runContract again and gets a fresh cache, so retries always observe live behaviour.
644
+ const dedupeCache = new Map<string, any>();
645
+
646
+ for (const tier of contract.tiers) {
647
+ const entry: any = {
648
+ id: tier.id,
649
+ name: tier.name,
650
+ kind: tier.kind,
651
+ required: tier.required,
652
+ advisory: tier.advisory,
653
+ modelBased: tier.modelBased,
654
+ failureClass: tier.failureClass,
655
+ status: tier.resolution === 'RESOLVED' ? 'NOT_RUN' : tier.resolution,
656
+ reason: tier.reason,
657
+ checks: [],
658
+ ...(tier.selection ? { selection: tier.selection } : {}),
659
+ ...(tier.subsumedBy ? { subsumedBy: tier.subsumedBy } : {}),
660
+ };
661
+
662
+ if (tier.resolution !== 'RESOLVED') {
663
+ reportTiers.push(entry);
664
+ continue;
665
+ }
666
+
667
+ if (fatalTier) {
668
+ entry.status = 'NOT_RUN';
669
+ entry.reason = `Not run: the fatal '${fatalTier}' tier failed, so any result here would describe that failure rather than this tier.`;
670
+ reportTiers.push(entry);
671
+ continue;
672
+ }
673
+
674
+ if (tier.modelBased) {
675
+ Object.assign(entry, this.evaluateModelTier(tier, runtimeInput.modelReview));
676
+ reportTiers.push(entry);
677
+ continue;
678
+ }
679
+
680
+ if (tier.kind === 'scope') {
681
+ Object.assign(entry, this.evaluateScopeTier(tier, runtimeInput.changedFiles));
682
+ reportTiers.push(entry);
683
+ continue;
684
+ }
685
+
686
+ for (const check of tier.checks) {
687
+ const result = this.runner.runCheck(check.name, check.command, check.timeoutMs, dedupeCache);
688
+ result.tier = tier.id;
689
+ result.role = check.role;
690
+ if (check.requirementId) result.requirementId = check.requirementId;
691
+ entry.checks.push(result);
692
+ checksRun += 1;
693
+ if (result.status === 'PASS') checksPassed += 1;
694
+ else failedChecks.push(result.name);
695
+ }
696
+
697
+ const anyFailed = entry.checks.some((c: any) => c.status === 'FAIL');
698
+ if (anyFailed) {
699
+ entry.status = 'FAIL';
700
+ if (tier.fatal) fatalTier = tier.id;
701
+ } else if (Array.isArray(tier.unevaluatedRequirements) && tier.unevaluatedRequirements.length > 0) {
702
+ // Every executable requirement check passed, but some requirements had none. Reporting the
703
+ // tier as PASS would claim satisfaction for requirements nothing evaluated.
704
+ entry.status = 'UNRESOLVED';
705
+ entry.unevaluatedRequirements = tier.unevaluatedRequirements;
706
+ } else if (entry.checks.length === 0) {
707
+ entry.status = 'UNRESOLVED';
708
+ entry.reason = 'Tier resolved but contributed no runnable check.';
709
+ } else {
710
+ entry.status = 'PASS';
711
+ }
712
+ reportTiers.push(entry);
713
+ }
714
+
715
+ const requiredTiers = reportTiers.filter((t) => t.required);
716
+ const requiredFailed = requiredTiers.filter((t) => t.status === 'FAIL');
717
+ const requiredUnverified = requiredTiers.filter((t) => t.status === 'UNRESOLVED' || t.status === 'NOT_RUN');
718
+ const evaluated = reportTiers.filter((t) => t.status === 'PASS' || t.status === 'FAIL');
719
+ const modelTier = reportTiers.find((t) => t.modelBased);
720
+
721
+ let status: string;
722
+ let reason: string;
723
+ if (evaluated.length === 0) {
724
+ // The empty-contract defect, generalized: zero evaluated tiers is a verdict over zero
725
+ // evidence whether the contract was empty or every tier turned out unresolvable.
726
+ status = 'INCONCLUSIVE';
727
+ reason = contract.tiers.every((t: any) => t.resolution === 'SKIPPED')
728
+ ? 'Contract declared no checks; nothing was verified'
729
+ : 'No tier produced a result; nothing was verified';
730
+ } else if (requiredFailed.length > 0) {
731
+ status = 'FAIL';
732
+ reason = `Required tier(s) failed: ${requiredFailed.map((t) => t.id).join(', ')}.`;
733
+ } else if (modelTier && modelTier.status === 'FAIL') {
734
+ // A model review can lower the verdict but never raise it — checked after the deterministic
735
+ // tiers so it is never the thing that established correctness.
736
+ status = 'FAIL';
737
+ reason = 'Model-based review reported a failure.';
738
+ } else if (requiredUnverified.length > 0) {
739
+ status = 'INCONCLUSIVE';
740
+ reason = `Required tier(s) were never evaluated: ${requiredUnverified.map((t) => t.id).join(', ')}.`;
741
+ } else {
742
+ status = 'PASS';
743
+ reason = 'Every required tier passed.';
744
+ }
745
+
746
+ const requirementTier = reportTiers.find((t) => t.kind === 'requirement');
747
+ const scopeTier = reportTiers.find((t) => t.kind === 'scope');
748
+
749
+ return {
750
+ version: contract.version,
751
+ taskId: contract.taskId,
752
+ riskLevel: contract.riskLevel,
753
+ status,
754
+ reason,
755
+ startedAt,
756
+ finishedAt: this.timestamp(),
757
+ tiers: reportTiers,
758
+ failedChecks,
759
+ counts: {
760
+ checksRun,
761
+ checksPassed,
762
+ checksFailed: checksRun - checksPassed,
763
+ tiersRequired: requiredTiers.length,
764
+ tiersPassed: requiredTiers.filter((t) => t.status === 'PASS').length,
765
+ tiersFailed: requiredFailed.length,
766
+ tiersUnverified: requiredUnverified.length,
767
+ tiersNotApplicable: requiredTiers.filter((t) => t.status === 'NOT_APPLICABLE' || t.status === 'SUBSUMED').length,
768
+ },
769
+ requirementSatisfaction: requirementTier
770
+ ? {
771
+ status: requirementTier.status,
772
+ satisfied: requirementTier.checks.filter((c: any) => c.status === 'PASS').map((c: any) => c.requirementId || c.name),
773
+ failed: requirementTier.checks.filter((c: any) => c.status === 'FAIL').map((c: any) => c.requirementId || c.name),
774
+ unevaluated: (requirementTier.unevaluatedRequirements || []).map((r: any) => r.id),
775
+ }
776
+ : null,
777
+ scope: scopeTier ? scopeTier.scope || { status: scopeTier.status, reason: scopeTier.reason } : null,
778
+ modelReview: modelTier
779
+ ? { status: modelTier.status, primaryProof: false, findings: modelTier.findings || [] }
780
+ : null,
781
+ unresolved: reportTiers
782
+ .filter((t) => t.status === 'UNRESOLVED' || t.status === 'NOT_RUN')
783
+ .map((t) => ({ tier: t.id, required: t.required, reason: t.reason })),
784
+ // A PASS reached with several tiers declared inapplicable is still a PASS, but a reader who
785
+ // cannot see which tiers never ran will over-read it. Listed next to the verdict, not buried.
786
+ notApplicable: reportTiers
787
+ .filter((t) => t.status === 'NOT_APPLICABLE' || t.status === 'SUBSUMED')
788
+ .map((t) => ({ tier: t.id, status: t.status, reason: t.reason })),
789
+ // The contract's own chronology and provenance travel with the verdict, so a report cannot read
790
+ // as stronger evidence than the contract it was run against (IC-022).
791
+ contract: {
792
+ compiledAt: contract.compiledAt || null,
793
+ phase: contract.phase || 'unknown',
794
+ compiledBeforeImplementation: contract.compiledBeforeImplementation === undefined ? null : contract.compiledBeforeImplementation,
795
+ ...(contract.provenance ? { provenance: contract.provenance } : {}),
796
+ },
797
+ detection: contract.detection,
798
+ };
799
+ }
800
+
801
+ /** @private */
802
+ evaluateModelTier(tier: any, modelReview: any): any {
803
+ if (!modelReview || typeof modelReview.status !== 'string') {
804
+ return {
805
+ status: 'UNRESOLVED',
806
+ reason: 'No model review was supplied for a risk level that requires one to be recorded.',
807
+ findings: [],
808
+ };
809
+ }
810
+ const supplied = modelReview.status.toUpperCase();
811
+ if (supplied === 'FAIL') {
812
+ return { status: 'FAIL', reason: 'Model review reported findings.', findings: toStringArray(modelReview.findings) };
813
+ }
814
+ if (supplied === 'PASS') {
815
+ // Recorded as PASS for the tier, but the overall verdict already required every deterministic
816
+ // tier to pass before reaching here — this never becomes the proof of anything on its own.
817
+ return { status: 'PASS', reason: 'Model review recorded no findings; advisory only.', findings: [] };
818
+ }
819
+ return {
820
+ status: 'UNRESOLVED',
821
+ reason: `Model review status '${modelReview.status}' is neither PASS nor FAIL.`,
822
+ findings: toStringArray(modelReview.findings),
823
+ };
824
+ }
825
+
826
+ /** @private */
827
+ evaluateScopeTier(tier: any, changedFiles: any): any {
828
+ const bound = tier.bound || { maxFiles: null, allowedPaths: [] };
829
+ const { files, reason, baseline } = this.observeChangedFiles(changedFiles, bound);
830
+ if (files === null) {
831
+ return {
832
+ status: 'UNRESOLVED',
833
+ reason,
834
+ scope: { bound, actual: null, withinBound: null, reason },
835
+ };
836
+ }
837
+
838
+ const violations: string[] = [];
839
+ if (Number.isFinite(bound.maxFiles) && files.length > bound.maxFiles) {
840
+ violations.push(`${files.length} files changed, bound was ${bound.maxFiles}`);
841
+ }
842
+ if (bound.allowedPaths.length > 0) {
843
+ const outside = files.filter((f: string) => !bound.allowedPaths.some((p: string) => f === p || f.startsWith(p.endsWith('/') ? p : `${p}/`)));
844
+ if (outside.length > 0) violations.push(`outside the declared paths: ${outside.join(', ')}`);
845
+ }
846
+
847
+ const withinBound = violations.length === 0;
848
+ // A bound measured from the working tree alone (no integration ref) says so: committed work
849
+ // is invisible to it, which is a limit of the answer, not a clean result.
850
+ const note = baseline && baseline.note ? baseline.note : null;
851
+ const verdictReason = withinBound
852
+ ? note
853
+ : `Change exceeded its declared scope — ${violations.join('; ')}.${note ? ` (${note})` : ''}`;
854
+ return {
855
+ status: withinBound ? 'PASS' : 'FAIL',
856
+ reason: verdictReason,
857
+ scope: { bound, actual: { fileCount: files.length, files }, withinBound, violations, ...(baseline ? { baseline } : {}) },
858
+ };
859
+ }
860
+
861
+ /**
862
+ * Declares completion. FR-009: a task is complete only when its required checks pass, or when the
863
+ * user explicitly accepts what is still failing.
864
+ *
865
+ * @param {Object} report
866
+ * @param {Object} [acceptance]
867
+ * @param {boolean} [acceptance.userAccepted]
868
+ * @param {string} [acceptance.acceptedBy] who accepted; an unattributed acceptance is refused
869
+ * @param {string} [acceptance.note]
870
+ * @returns {{ complete: boolean, basis: string, reason: string, acceptedFailures?: Array<Object> }}
871
+ */
872
+ declareCompletion(report: any, acceptance: any = {}): any {
873
+ if (!report || typeof report.status !== 'string') {
874
+ throw new Error('declareCompletion expects a report produced by runContract');
875
+ }
876
+ if (report.status === 'PASS') {
877
+ return { complete: true, basis: 'required-checks-passed', reason: report.reason };
878
+ }
879
+ if (acceptance.userAccepted !== true) {
880
+ return {
881
+ complete: false,
882
+ basis: 'not-accepted',
883
+ reason: `Verification is ${report.status}: ${report.reason} Completion needs the required checks to pass or an explicit user acceptance.`,
884
+ };
885
+ }
886
+ const requiresAccepter = (this.policy.completion || {}).overrideRequiresAccepter !== false;
887
+ if (requiresAccepter && (typeof acceptance.acceptedBy !== 'string' || acceptance.acceptedBy.trim() === '')) {
888
+ // "The user explicitly accepts" needs a user. An anonymous flag is indistinguishable from the
889
+ // caller setting it for itself.
890
+ return {
891
+ complete: false,
892
+ basis: 'acceptance-unattributed',
893
+ reason: 'Accepting a failing verification requires naming who accepted it.',
894
+ };
895
+ }
896
+ return {
897
+ complete: true,
898
+ basis: 'user-accepted-failures',
899
+ reason: acceptance.note || `Accepted by ${acceptance.acceptedBy} while ${report.status}.`,
900
+ acceptedFailures: (report.tiers || [])
901
+ .filter((t: any) => t.required && t.status !== 'PASS' && t.status !== 'NOT_APPLICABLE' && t.status !== 'SUBSUMED' && t.status !== 'SKIPPED')
902
+ .map((t: any) => ({ tier: t.id, status: t.status, reason: t.reason })),
903
+ };
904
+ }
905
+
906
+ /**
907
+ * A stable fingerprint of what failed. Two identical fingerprints across attempts mean the retry
908
+ * changed nothing observable, which FR-010 forbids as a response.
909
+ * @param {Object} report
910
+ * @returns {string}
911
+ */
912
+ failureSignature(report: any): string {
913
+ const failed = [...(report.failedChecks || [])].sort().join('|');
914
+ const unresolved = (report.unresolved || []).map((u: any) => u.tier).sort().join('|');
915
+ return `${report.status}::${failed}::${unresolved}`;
916
+ }
917
+
918
+ /**
919
+ * Classifies a failing report and returns the targeted action for that class (C8), bounded by the
920
+ * risk level's retry limit.
921
+ *
922
+ * @param {Object} report
923
+ * @param {Object} [options]
924
+ * @param {number} [options.iteration=0] retries already spent
925
+ * @param {string} [options.lastAgent]
926
+ * @param {number} [options.maxIterations] defaults to the report's risk level
927
+ * @returns {Object|null} null when there is nothing to recover from
928
+ */
929
+ planRecovery(report: any, options: any = {}): any {
930
+ if (!report || report.status === 'PASS') return null;
931
+
932
+ const maxIterations = Number.isInteger(options.maxIterations)
933
+ ? options.maxIterations
934
+ : this.riskDefinition(report.riskLevel).maxRecoveryIterations;
935
+ const manager = this.recoveryOverride || new RecoveryManager({ maxIterations });
936
+
937
+ const failingTier = (report.tiers || []).find((t: any) => t.required && t.status === 'FAIL')
938
+ || (report.tiers || []).find((t: any) => t.status === 'FAIL');
939
+ const unverifiedTier = (report.tiers || []).find((t: any) => t.required && (t.status === 'UNRESOLVED' || t.status === 'NOT_RUN'));
940
+
941
+ let failureClass: string;
942
+ let classificationSource: string;
943
+ let errorMessage: string;
944
+ let hint: string | null = null;
945
+
946
+ if (failingTier) {
947
+ const firstFailure = (failingTier.checks || []).find((c: any) => c.status === 'FAIL');
948
+ errorMessage = [failingTier.reason, firstFailure && firstFailure.error, firstFailure && firstFailure.stderr]
949
+ .filter(Boolean)
950
+ .join(' ');
951
+ // A tier's declared class is structural evidence about what kind of check failed; the keyword
952
+ // classifier reads prose. recovery.js already prefers structure over prose between check
953
+ // names and error text, and this applies the same precedence one level up. The classifier
954
+ // still runs and is reported, so a disagreement stays visible rather than being hidden.
955
+ const classified = manager.classifyFailure(errorMessage, report.failedChecks);
956
+ failureClass = failingTier.failureClass || classified;
957
+ classificationSource = failingTier.failureClass ? 'tier-declaration' : 'classifier';
958
+ if (failingTier.failureClass && failingTier.failureClass !== classified) {
959
+ hint = `Keyword classifier suggested ${classified}; the '${failingTier.id}' tier declares ${failingTier.failureClass}.`;
960
+ }
961
+ } else if (unverifiedTier) {
962
+ errorMessage = unverifiedTier.reason || 'A required verification tier was never evaluated.';
963
+ failureClass = this.policy.unresolvedDetectionFailureClass || manager.classifyFailure(errorMessage, []);
964
+ classificationSource = 'unresolved-tier';
965
+ hint = `Nothing verified the '${unverifiedTier.id}' tier. Declare its command in plan.md's \`${this.policy.planOverrideBlock}\` block, or set it to null to state the project has none.`;
966
+ } else {
967
+ errorMessage = report.reason || 'Verification did not pass.';
968
+ failureClass = manager.classifyFailure(errorMessage, report.failedChecks);
969
+ classificationSource = 'classifier';
970
+ }
971
+
972
+ const plan = manager.planRecovery(failureClass, options.iteration || 0, options.lastAgent);
973
+ return {
974
+ ...plan,
975
+ classificationSource,
976
+ maxIterations,
977
+ errorMessage,
978
+ hint,
979
+ reportStatus: report.status,
980
+ failureSignature: this.failureSignature(report),
981
+ };
982
+ }
983
+
984
+ /**
985
+ * Runs a contract and, on failure, drives the bounded recovery loop.
986
+ *
987
+ * `remediate` must report that it changed something. FR-010 forbids re-running unchanged
988
+ * behaviour, so a remediation that returns `changed !== true` stops the loop instead of producing
989
+ * a second identical attempt, and an attempt whose failure fingerprint repeats stops it too — a
990
+ * change that altered nothing observable is the same thing wearing a different hat.
991
+ *
992
+ * @param {Object} contract
993
+ * @param {Object} [options]
994
+ * @param {Function} [options.remediate] `(plan, report, iteration) => {changed: boolean, contract?}`
995
+ * @param {string} [options.lastAgent]
996
+ * @param {Object} [options.runtimeInput] forwarded to each `runContract`
997
+ * @returns {{ outcome: string, report: Object, plan: Object|null, attempts: Array<Object>, reason: string }}
998
+ */
999
+ runWithRecovery(contract: any, options: any = {}): any {
1000
+ let current = contract;
1001
+ let lastAgent = options.lastAgent;
1002
+ let previousSignature: string | null = null;
1003
+ let iteration = 0;
1004
+ const attempts: any[] = [];
1005
+
1006
+ for (;;) {
1007
+ const report = this.runContract(current, options.runtimeInput || {});
1008
+ const signature = this.failureSignature(report);
1009
+ attempts.push({ iteration, status: report.status, signature, failedChecks: report.failedChecks });
1010
+
1011
+ if (report.status === 'PASS') {
1012
+ return { outcome: 'PASS', report, plan: null, attempts, reason: report.reason };
1013
+ }
1014
+ if (signature === previousSignature) {
1015
+ return {
1016
+ outcome: 'NO_PROGRESS',
1017
+ report,
1018
+ plan: null,
1019
+ attempts,
1020
+ reason: 'The previous recovery reported a change but produced an identical failure; retrying again would repeat unchanged behaviour.',
1021
+ };
1022
+ }
1023
+ previousSignature = signature;
1024
+
1025
+ const plan = this.planRecovery(report, { iteration, lastAgent });
1026
+ if (!plan.canRetry) {
1027
+ return { outcome: 'ABORTED', report, plan, attempts, reason: plan.reason };
1028
+ }
1029
+ if (typeof options.remediate !== 'function') {
1030
+ return {
1031
+ outcome: 'NO_REMEDIATION',
1032
+ report,
1033
+ plan,
1034
+ attempts,
1035
+ reason: `Recovery action ${plan.action} is available but no remediation callback was supplied.`,
1036
+ };
1037
+ }
1038
+
1039
+ const outcome = options.remediate(plan, report, iteration);
1040
+ if (!outcome || outcome.changed !== true) {
1041
+ return {
1042
+ outcome: 'NO_CHANGE',
1043
+ report,
1044
+ plan,
1045
+ attempts,
1046
+ reason: `Remediation for ${plan.failureClass} reported no change; re-running unchanged behaviour is not a permitted response.`,
1047
+ };
1048
+ }
1049
+ if (outcome.contract) current = outcome.contract;
1050
+ lastAgent = plan.agent;
1051
+ iteration += 1;
1052
+ }
1053
+ }
1054
+
1055
+ /**
1056
+ * Convenience: compile, run, and report in one call. The two halves stay separately callable
1057
+ * because FR-009 requires the contract to exist *before* implementation, which this shape cannot
1058
+ * express on its own.
1059
+ * @param {Object} input `compileContract` input
1060
+ * @param {Object} [runtimeInput] `runContract` input
1061
+ */
1062
+ verify(input: any, runtimeInput: any): any {
1063
+ const contract = this.compileContract(input);
1064
+ return { contract, report: this.runContract(contract, runtimeInput) };
1065
+ }
1066
+ }
1067
+
1068
+ /**
1069
+ * Handles `doflow verify` — compile the verification contract (FR-009) and, by default, run it and
1070
+ * report against it.
1071
+ *
1072
+ * `--action contract` stops after compiling, which is the before-implementation half: it states
1073
+ * how success will be established without establishing anything. The default runs the checks and
1074
+ * emits the report. The contract is recompiled rather than read back from a state file, so the two
1075
+ * halves cannot describe different contracts.
1076
+ *
1077
+ * @param {Object} options
1078
+ * @param {string} options.taskId
1079
+ * @param {'report'|'contract'|'status'} [options.action='report']
1080
+ * @param {string} [options.risk] risk level; the registry's default when omitted
1081
+ * @param {string} [options.planPath] a `plan.md` whose command override beats detection
1082
+ * @param {boolean} [options.json=false]
1083
+ * @param {string} [options.projectRoot]
1084
+ * @param {string} [options.slug] the feature whose plan bounds the change; the branch's when omitted
1085
+ * @param {string} [options.scope] a declared scope (`<path>[,<path>...]`), unioned with any plan bound
1086
+ * @returns {number} exit code
1087
+ */
1088
+ /**
1089
+ * Validates a verification input manifest file (IC-022).
1090
+ *
1091
+ * @param {Object} options
1092
+ * @param {string} options.cwd
1093
+ * @param {string} options.verificationInput
1094
+ * @param {Object} [options.fs]
1095
+ * @param {Object} [options.crypto]
1096
+ * @returns {{ ok: true, structuralChecks: Array<Object>, requirements: Array<Object>, provenance: Object } | { ok: false, message: string }}
1097
+ */
1098
+ function validateVerificationInput({ cwd, verificationInput, fs = nodeFs, crypto = nodeCrypto }: {
1099
+ cwd: string;
1100
+ verificationInput: any;
1101
+ fs?: any;
1102
+ crypto?: any;
1103
+ }): any {
1104
+ if (typeof verificationInput !== 'string' || verificationInput.trim() === '') {
1105
+ return { ok: false, message: '--verification-input requires a file path.' };
1106
+ }
1107
+ const inputAbs = path.isAbsolute(verificationInput) ? verificationInput : path.resolve(cwd, verificationInput);
1108
+ let rawBytes: Buffer;
1109
+ try {
1110
+ rawBytes = fs.readFileSync(inputAbs);
1111
+ } catch (err: any) {
1112
+ return { ok: false, message: `--verification-input '${verificationInput}' could not be read: ${err.message}` };
1113
+ }
1114
+ const manifestDigest = 'sha256:' + crypto.createHash('sha256').update(rawBytes).digest('hex');
1115
+ let manifest: any;
1116
+ try {
1117
+ manifest = JSON.parse(rawBytes.toString('utf8'));
1118
+ } catch (err: any) {
1119
+ return { ok: false, message: `--verification-input '${verificationInput}' is not valid JSON: ${err.message}` };
1120
+ }
1121
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
1122
+ return { ok: false, message: `--verification-input '${verificationInput}' must be a JSON object.` };
1123
+ }
1124
+
1125
+ const ALLOWED_KEYS = new Set(['structuralChecks', 'requirements', 'originalContractPath', 'originalContractDigest']);
1126
+ for (const key of Object.keys(manifest)) {
1127
+ if (!ALLOWED_KEYS.has(key)) {
1128
+ return { ok: false, message: `--verification-input '${verificationInput}' contains unknown field '${key}'.` };
1129
+ }
1130
+ }
1131
+
1132
+ const hasOrigPath = manifest.originalContractPath !== undefined;
1133
+ const hasOrigDigest = manifest.originalContractDigest !== undefined;
1134
+ if ((hasOrigPath && !hasOrigDigest) || (!hasOrigPath && hasOrigDigest)) {
1135
+ return { ok: false, message: `--verification-input '${verificationInput}' must specify both originalContractPath and originalContractDigest together.` };
1136
+ }
1137
+ if (hasOrigPath && hasOrigDigest) {
1138
+ if (typeof manifest.originalContractPath !== 'string' || manifest.originalContractPath.trim() === '') {
1139
+ return { ok: false, message: '--verification-input originalContractPath must be a non-empty string.' };
1140
+ }
1141
+ if (typeof manifest.originalContractDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(manifest.originalContractDigest)) {
1142
+ return { ok: false, message: '--verification-input originalContractDigest must be in format sha256:<64 hex chars>.' };
1143
+ }
1144
+ const origAbs = path.isAbsolute(manifest.originalContractPath) ? manifest.originalContractPath : path.resolve(cwd, manifest.originalContractPath);
1145
+ let origBytes: Buffer;
1146
+ try {
1147
+ origBytes = fs.readFileSync(origAbs);
1148
+ } catch (err: any) {
1149
+ return { ok: false, message: `--verification-input originalContractPath '${manifest.originalContractPath}' could not be read: ${err.message}` };
1150
+ }
1151
+ const actualOrigDigest = 'sha256:' + crypto.createHash('sha256').update(origBytes).digest('hex');
1152
+ if (actualOrigDigest !== manifest.originalContractDigest) {
1153
+ return { ok: false, message: `--verification-input originalContractDigest mismatch for '${manifest.originalContractPath}': expected ${manifest.originalContractDigest}, got ${actualOrigDigest}.` };
1154
+ }
1155
+ }
1156
+
1157
+ let structuralChecks: any[] = [];
1158
+ if (manifest.structuralChecks !== undefined) {
1159
+ if (!Array.isArray(manifest.structuralChecks)) {
1160
+ return { ok: false, message: '--verification-input structuralChecks must be an array.' };
1161
+ }
1162
+ const seenNames = new Set<string>();
1163
+ const ALLOWED_STRUCT_KEYS = new Set(['name', 'command', 'timeoutMs']);
1164
+ for (let i = 0; i < manifest.structuralChecks.length; i++) {
1165
+ const entry = manifest.structuralChecks[i];
1166
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
1167
+ return { ok: false, message: `--verification-input structuralChecks[${i}] must be an object.` };
1168
+ }
1169
+ for (const k of Object.keys(entry)) {
1170
+ if (!ALLOWED_STRUCT_KEYS.has(k)) {
1171
+ return { ok: false, message: `--verification-input structuralChecks[${i}] contains unknown field '${k}'.` };
1172
+ }
1173
+ }
1174
+ if (typeof entry.name !== 'string' || entry.name.trim() === '') {
1175
+ return { ok: false, message: `--verification-input structuralChecks[${i}].name must be a non-empty string.` };
1176
+ }
1177
+ if (seenNames.has(entry.name)) {
1178
+ return { ok: false, message: `--verification-input duplicate structural check name '${entry.name}'.` };
1179
+ }
1180
+ seenNames.add(entry.name);
1181
+ if (typeof entry.command !== 'string' || entry.command.trim() === '') {
1182
+ return { ok: false, message: `--verification-input structuralChecks[${i}].command must be a non-empty string.` };
1183
+ }
1184
+ if (entry.timeoutMs !== undefined && (!Number.isInteger(entry.timeoutMs) || entry.timeoutMs <= 0)) {
1185
+ return { ok: false, message: `--verification-input structuralChecks[${i}].timeoutMs must be a positive integer.` };
1186
+ }
1187
+ }
1188
+ structuralChecks = manifest.structuralChecks;
1189
+ }
1190
+
1191
+ let requirements: any[] = [];
1192
+ if (manifest.requirements !== undefined) {
1193
+ if (!Array.isArray(manifest.requirements)) {
1194
+ return { ok: false, message: '--verification-input requirements must be an array.' };
1195
+ }
1196
+ const seenIds = new Set<string>();
1197
+ const ALLOWED_REQ_KEYS = new Set(['id', 'description', 'command', 'timeoutMs']);
1198
+ for (let i = 0; i < manifest.requirements.length; i++) {
1199
+ const entry = manifest.requirements[i];
1200
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
1201
+ return { ok: false, message: `--verification-input requirements[${i}] must be an object.` };
1202
+ }
1203
+ for (const k of Object.keys(entry)) {
1204
+ if (!ALLOWED_REQ_KEYS.has(k)) {
1205
+ return { ok: false, message: `--verification-input requirements[${i}] contains unknown field '${k}'.` };
1206
+ }
1207
+ }
1208
+ if (typeof entry.id !== 'string' || entry.id.trim() === '') {
1209
+ return { ok: false, message: `--verification-input requirements[${i}].id must be a non-empty string.` };
1210
+ }
1211
+ if (seenIds.has(entry.id)) {
1212
+ return { ok: false, message: `--verification-input duplicate requirement id '${entry.id}'.` };
1213
+ }
1214
+ seenIds.add(entry.id);
1215
+ if (entry.description !== undefined && typeof entry.description !== 'string') {
1216
+ return { ok: false, message: `--verification-input requirements[${i}].description must be a string.` };
1217
+ }
1218
+ if (entry.command !== undefined && (typeof entry.command !== 'string' || entry.command.trim() === '')) {
1219
+ return { ok: false, message: `--verification-input requirements[${i}].command must be a non-empty string when provided.` };
1220
+ }
1221
+ if (entry.timeoutMs !== undefined && (!Number.isInteger(entry.timeoutMs) || entry.timeoutMs <= 0)) {
1222
+ return { ok: false, message: `--verification-input requirements[${i}].timeoutMs must be a positive integer.` };
1223
+ }
1224
+ }
1225
+ requirements = manifest.requirements;
1226
+ }
1227
+
1228
+ const provenance = {
1229
+ manifestPath: verificationInput,
1230
+ manifestDigest,
1231
+ ...(hasOrigPath ? {
1232
+ originalContractPath: manifest.originalContractPath,
1233
+ originalContractDigest: manifest.originalContractDigest,
1234
+ } : {}),
1235
+ };
1236
+
1237
+ return { ok: true, structuralChecks, requirements, provenance };
1238
+ }
1239
+
1240
+ /**
1241
+ * Handles `doflow verify` — compile the verification contract (FR-009) and, by default, run it and
1242
+ * report against it.
1243
+ *
1244
+ * `--action contract` stops after compiling, which is the before-implementation half: it states
1245
+ * how success will be established without establishing anything. The default runs the checks and
1246
+ * emits the report. The contract is recompiled rather than read back from a state file, so the two
1247
+ * halves cannot describe different contracts.
1248
+ *
1249
+ * @param {Object} options
1250
+ * @param {string} options.taskId
1251
+ * @param {'report'|'contract'|'status'} [options.action='report']
1252
+ * @param {string} [options.risk] risk level; the registry's default when omitted
1253
+ * @param {string} [options.planPath] a `plan.md` whose command override beats detection
1254
+ * @param {boolean} [options.json=false]
1255
+ * @param {string} [options.projectRoot]
1256
+ * @param {string} [options.slug] the feature whose plan bounds the change; the branch's when omitted
1257
+ * @param {string} [options.scope] a declared scope (`<path>[,<path>...]`), unioned with any plan bound
1258
+ * @param {string} [options.verificationInput] path to input manifest with structuralChecks and requirements (IC-022)
1259
+ * @param {string} [options.verificationPhase] explicit compilation phase (e.g. postimplementation) (IC-022)
1260
+ * @param {string} [options.baselineRef] commit or ref to measure change against (IC-022)
1261
+ * @returns {number} exit code
1262
+ */
1263
+ function handleVerifyCommand({ taskId, action = 'report', risk, planPath, json = false, projectRoot, slug = null, scope = null, verificationInput = null, verificationPhase = null, baselineRef = null }: {
1264
+ taskId?: any;
1265
+ action?: string;
1266
+ risk?: any;
1267
+ planPath?: any;
1268
+ json?: boolean;
1269
+ projectRoot?: any;
1270
+ slug?: string | null;
1271
+ scope?: any;
1272
+ verificationInput?: string | null;
1273
+ verificationPhase?: string | null;
1274
+ baselineRef?: string | null;
1275
+ } = {}): number {
1276
+ // The task id names state files here, so it obeys the rule every other task verb applies.
1277
+ try {
1278
+ assertSafeTaskId(taskId);
1279
+ } catch (error: any) {
1280
+ return usageError('verify', error.message, json);
1281
+ }
1282
+
1283
+ if (verificationPhase !== null && verificationPhase !== undefined) {
1284
+ if (verificationPhase !== 'postimplementation' && verificationPhase !== 'unknown') {
1285
+ return usageError('verify', `unknown --verification-phase '${verificationPhase}'. Valid: postimplementation`, json);
1286
+ }
1287
+ }
1288
+
1289
+ let declared: any = null;
1290
+ if (scope !== null && scope !== undefined) {
1291
+ const parsed = parseDeclaredScope(typeof scope === 'string' ? scope : '');
1292
+ if (parsed.reason) {
1293
+ return usageError('verify', `--scope ${parsed.reason}. Write repository-relative paths separated by commas, a trailing / for a directory, for example --scope src/a.js,src/lib/. Nothing was changed.`, json);
1294
+ }
1295
+ declared = { paths: parsed.paths, origin: 'verify-flag' };
1296
+ }
1297
+ const cwd = projectRoot || process.cwd();
1298
+
1299
+ let structuralChecks: any[] = [];
1300
+ let requirements: any[] = [];
1301
+ let inputProvenance: any = null;
1302
+ if (verificationInput !== null && verificationInput !== undefined) {
1303
+ const val = validateVerificationInput({ cwd, verificationInput });
1304
+ if (!val.ok) {
1305
+ return usageError('verify', val.message, json);
1306
+ }
1307
+ structuralChecks = val.structuralChecks;
1308
+ requirements = val.requirements;
1309
+ inputProvenance = val.provenance;
1310
+ }
1311
+
1312
+ // The feature the task belongs to, for the readiness record's namespace and for a run keyed by
1313
+ // the feature slug: `--slug`, else the task id's own folder, else the branch's.
1314
+ const featureSlug = featureSlugFor({ projectRoot: cwd, taskId, slug });
1315
+
1316
+ // A scope is declared in one place. The readiness record carries the one stated before the
1317
+ // work; a flag that says something else is refused rather than silently preferred. Records in two
1318
+ // other checkouts declare nothing: the contract says so and still answers.
1319
+ const recorded = readReadinessRecord({ stateRoot: cwd, taskId, slug: featureSlug });
1320
+ const recordNote = recorded.status === 'ambiguous'
1321
+ ? `readiness record: not used; task '${taskId}' has records in more than one other checkout (${recorded.candidates.join(', ')})`
1322
+ : null;
1323
+ // Only a record this runtime can read declares a scope; one it cannot read is reported by the
1324
+ // readiness check, and refusing --scope against it would send the caller to a readiness run that
1325
+ // cannot replace it.
1326
+ const usable = recorded.status === 'found' && recorded.record && recorded.record.version === RECORD_VERSION;
1327
+ const recordedScope = usable && Array.isArray(recorded.record.declaredScope) && recorded.record.declaredScope.length > 0
1328
+ ? recorded.record.declaredScope : null;
1329
+ if (recordedScope) {
1330
+ const recordRel = recorded.origin === 'current' ? path.relative(cwd, recorded.file) : recorded.file;
1331
+ if (declared && !sameSet(declared.paths, recordedScope)) {
1332
+ return usageError('verify', `--scope ${String(scope).trim()} differs from the scope declared in the readiness record ${recordRel} (${recordedScope.join(',')}). A scope is declared in one place: re-run doflow-run readiness --task-class ${recorded.record.taskClass} --task-id ${taskId} --scope ${String(scope).trim()}, or drop --scope. Nothing was changed.`, json);
1333
+ }
1334
+ if (!declared) declared = { paths: [...recordedScope], origin: 'readiness-record', record: recordRel };
1335
+ }
1336
+
1337
+ let engine: any;
1338
+ let contract: any;
1339
+ try {
1340
+ engine = new VerificationEngine({ cwd, repoRoot: REPO_ROOT });
1341
+ // The bound comes from the feature's plan, this checkout's or the main checkout's, and from a
1342
+ // declared scope; with neither the change-scope tier is UNRESOLVED and says where it looked.
1343
+ const resolved = resolveScopeBound({ projectRoot: cwd, slug, planPath, declared, baselineRef });
1344
+ if (recordNote) resolved.searched.push({ place: 'readiness record', result: recordNote.slice('readiness record: '.length) });
1345
+ const scopeInput = resolved.bound
1346
+ ? { ...resolved.bound, searched: resolved.searched }
1347
+ : { unresolvedReason: resolved.unresolvedReason || scopeReasonText({ reason: resolved.reason, taskId, planSource: resolved.planSource }), searched: resolved.searched };
1348
+ contract = engine.compileContract({
1349
+ taskId,
1350
+ riskLevel: risk,
1351
+ projectRoot: cwd,
1352
+ planPath,
1353
+ scope: scopeInput,
1354
+ phase: verificationPhase,
1355
+ structuralChecks,
1356
+ requirements,
1357
+ provenance: inputProvenance,
1358
+ });
1359
+ } catch (error: any) {
1360
+ return usageError('verify', error.message, json, error);
1361
+ }
1362
+ // The bound's sources print under the change-scope tier's line; no other line changes.
1363
+ const boundLines = new Map(contract.tiers
1364
+ .filter((t: any) => t.bound && Array.isArray(t.bound.sources) && t.bound.sources.length > 0)
1365
+ .map((t: any) => [t.id, ` bound: ${boundSourcesText(t.bound.sources)}`]));
1366
+ if (recordNote) {
1367
+ const scopeTier = contract.tiers.find((t: any) => t.kind === 'scope');
1368
+ if (scopeTier) boundLines.set(scopeTier.id, [boundLines.get(scopeTier.id), ` ${recordNote}`].filter(Boolean).join('\n'));
1369
+ }
1370
+
1371
+ if (action === 'contract') {
1372
+ if (json) console.log(JSON.stringify(contract, null, 2));
1373
+ else {
1374
+ console.log(`\nDoFlow Verification Contract [${contract.taskId}] — risk ${contract.riskLevel}:`);
1375
+ console.log('═'.repeat(78));
1376
+ for (const tier of contract.tiers) {
1377
+ console.log(` ${tier.id.padEnd(22)} ${tier.resolution.padEnd(12)} ${tier.required ? 'required' : 'advisory'}`);
1378
+ if (boundLines.has(tier.id)) console.log(boundLines.get(tier.id));
1379
+ if (tier.reason) console.log(` ${tier.reason}`);
1380
+ if (tier.selection) console.log(` ${selectionText(tier.selection)}`);
1381
+ }
1382
+ console.log('═'.repeat(78) + '\n');
1383
+ }
1384
+ // A contract is a plan, not a verdict — compiling one always answers, even when a tier could
1385
+ // not be resolved, because the unresolved tier is itself part of what the contract states.
1386
+ return finishRuntime(0);
1387
+ }
1388
+ if (action !== 'report' && action !== 'status') {
1389
+ return usageError('verify', `unknown --action '${action}'. Valid: report (default), contract`, json);
1390
+ }
1391
+
1392
+ const report = engine.runContract(contract);
1393
+ // A task whose run still has its gated stage pending is not verified while no READY readiness
1394
+ // record exists: the checks passing says nothing about whether the work should have started.
1395
+ try {
1396
+ report.readiness = verifyReadinessCheck({ stateRoot: cwd, taskId, slug: featureSlug });
1397
+ } catch (error: any) {
1398
+ return usageError('verify', error.message, json, error);
1399
+ }
1400
+ const readinessHeld = report.readiness.applies && !report.readiness.ok;
1401
+ if (readinessHeld && report.status === 'PASS') {
1402
+ report.status = 'INCONCLUSIVE';
1403
+ report.reason = report.readiness.message;
1404
+ }
1405
+ if (json) console.log(JSON.stringify(report, null, 2));
1406
+ else {
1407
+ console.log(`\nDoFlow Verification Report [${report.taskId}] — ${report.status}:`);
1408
+ console.log('═'.repeat(78));
1409
+ console.log(report.reason);
1410
+ if (readinessHeld) console.log(`readiness: ${report.readiness.message}`);
1411
+ console.log('─'.repeat(78));
1412
+ for (const tier of report.tiers) {
1413
+ console.log(` ${tier.id.padEnd(22)} ${tier.status.padEnd(16)} ${tier.required ? 'required' : 'advisory'}`);
1414
+ if (boundLines.has(tier.id)) console.log(boundLines.get(tier.id));
1415
+ if (tier.reason) console.log(` ${tier.reason}`);
1416
+ if (tier.selection) console.log(` ${selectionText(tier.selection)}`);
1417
+ }
1418
+ console.log('─'.repeat(78));
1419
+ console.log(`Checks: ${report.counts.checksPassed}/${report.counts.checksRun} passed`);
1420
+ if (report.unresolved.length) {
1421
+ console.log('Never evaluated (not a pass):');
1422
+ for (const u of report.unresolved) console.log(` ${u.tier}${u.required ? ' [required]' : ''} — ${u.reason}`);
1423
+ }
1424
+ console.log('═'.repeat(78) + '\n');
1425
+ }
1426
+ // PASS is the only status that answers "verified". FAIL and INCONCLUSIVE are both findings, and
1427
+ // collapsing INCONCLUSIVE into success would report a verdict over zero evidence as a pass.
1428
+ upkeepAfterVerify({ projectRoot: cwd });
1429
+ return finishRuntime(report.status === 'PASS' ? 0 : 1);
1430
+ }
1431
+
1432
+ /** One line stating what the targeted tier selected, or why it ran the broad command instead. */
1433
+ function selectionText(selection: any): string {
1434
+ const of = selection.totalCount === null ? '' : ` of ${selection.totalCount}`;
1435
+ if (selection.fallback) return `selection: ${selection.basis}, fell back to the broad command (${selection.fallback})`;
1436
+ if (selection.selectedCount === null) return `selection: ${selection.basis}`;
1437
+ return `selection: ${selection.basis}, ${selection.selectedCount}${of} test files`;
1438
+ }
1439
+
1440
+ /** Whether two path lists name the same set. */
1441
+ function sameSet(a: string[], b: string[]): boolean {
1442
+ const left = new Set(a);
1443
+ const right = new Set(b);
1444
+ return left.size === right.size && [...left].every((p) => right.has(p));
1445
+ }
1446
+
1447
+ module.exports = {
1448
+ VerificationContractRunner,
1449
+ VerificationEngine,
1450
+ loadVerificationRegistry,
1451
+ VERIFICATION_STATUSES,
1452
+ TIER_RESOLUTIONS,
1453
+ TIER_STATUSES,
1454
+ RECOVERY_OUTCOMES,
1455
+ FATAL_CHECK_MARKERS,
1456
+ TIMEOUT_EXIT_CODE,
1457
+ MAX_STREAM_CHARS,
1458
+ REGISTRY_FILENAME,
1459
+ handleVerifyCommand,
1460
+ validateVerificationInput,
1461
+ };