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

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 (409) 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 +1061 -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 +48 -4
  250. package/src/adapters/antigravity/index.js +631 -0
  251. package/src/adapters/claude/index.js +548 -0
  252. package/src/adapters/claude/mcp.js +94 -0
  253. package/src/adapters/codex/agents.js +208 -0
  254. package/src/adapters/codex/config.js +272 -0
  255. package/src/adapters/codex/hooks.js +203 -0
  256. package/src/adapters/codex/index.js +707 -0
  257. package/src/adapters/codex/mcp.js +271 -0
  258. package/src/adapters/codex/native-projection.js +34 -0
  259. package/src/adapters/copilot/index.js +413 -0
  260. package/src/adapters/copy-tree.js +620 -0
  261. package/src/adapters/gemini/hooks.js +210 -0
  262. package/src/adapters/gemini/index.js +390 -0
  263. package/src/adapters/hook-commands.js +60 -0
  264. package/src/adapters/index.js +106 -0
  265. package/src/adapters/kiro/index.js +310 -0
  266. package/src/adapters/mcp-entries.js +331 -0
  267. package/src/adapters/opencode/index.js +466 -0
  268. package/src/adapters/pi/index.js +279 -0
  269. package/src/adapters/pi/json-members.js +185 -0
  270. package/src/adapters/pi/mcp.js +373 -0
  271. package/src/adapters/resolve-source.js +19 -0
  272. package/src/cli/commands/install.js +218 -0
  273. package/src/cli/commands/list-backups.js +21 -0
  274. package/src/cli/commands/reconcile.js +120 -0
  275. package/src/cli/commands/remove.js +82 -0
  276. package/src/cli/commands/rollback.js +172 -0
  277. package/src/cli/commands/self-update.js +33 -0
  278. package/src/cli/commands/status.js +138 -0
  279. package/src/cli/commands/tools.js +92 -0
  280. package/src/cli/commands/update.js +162 -0
  281. package/src/cli/index.js +568 -0
  282. package/src/cli/runtime-commands.js +281 -0
  283. package/src/cli/shared.js +218 -0
  284. package/src/helper/bash-helper.js +34 -0
  285. package/src/helper/git.js +24 -0
  286. package/src/helper/guardrails.js +49 -0
  287. package/src/helper/harness-paths.js +140 -0
  288. package/src/helper/marker-merge.js +116 -0
  289. package/src/helper/prompt.js +61 -0
  290. package/src/helper/repo-root.js +29 -0
  291. package/src/helper/settings-merge.js +151 -0
  292. package/src/helper/settings-scope.js +33 -0
  293. package/src/helper/toml.js +244 -0
  294. package/src/install/backup-set.js +149 -0
  295. package/src/install/backup.js +724 -0
  296. package/src/install/context.js +58 -0
  297. package/src/install/manifest.js +145 -0
  298. package/src/install/mcp.js +254 -0
  299. package/src/install/paths.js +84 -0
  300. package/src/install/targets.js +90 -0
  301. package/src/install/tool-lifecycle.js +151 -0
  302. package/src/lifecycle/bash-availability.js +25 -0
  303. package/src/lifecycle/index.js +812 -0
  304. package/src/lifecycle/mcp-index.js +28 -0
  305. package/src/lifecycle/policies.js +73 -0
  306. package/src/lifecycle/view.js +175 -0
  307. package/src/registry/index.js +611 -0
  308. package/src/release/build-package.js +91 -0
  309. package/src/release/changelog-section.js +70 -0
  310. package/src/release/sync-plugin-versions.js +66 -0
  311. package/src/runtime/advice/cli.js +283 -0
  312. package/src/runtime/advice/client.js +324 -0
  313. package/src/runtime/advice/config.js +189 -0
  314. package/src/runtime/advice/doctor.js +169 -0
  315. package/src/runtime/advice/endpoint-guard.js +134 -0
  316. package/src/runtime/advice/kinds.js +154 -0
  317. package/src/runtime/advice/providers/ollama-systemone.js +209 -0
  318. package/src/runtime/advice/record.js +166 -0
  319. package/src/runtime/advice/registry.js +170 -0
  320. package/src/runtime/advice/strength.js +66 -0
  321. package/src/runtime/brief-tools.js +342 -0
  322. package/src/runtime/c4-project.js +371 -0
  323. package/src/runtime/capability-router.js +484 -0
  324. package/src/runtime/checkouts.js +170 -0
  325. package/src/runtime/claims.js +468 -0
  326. package/src/runtime/cli-result.js +55 -0
  327. package/src/runtime/cli.js +678 -0
  328. package/src/runtime/command-detect.js +516 -0
  329. package/src/runtime/context-pack.js +492 -0
  330. package/src/runtime/decision-register.js +493 -0
  331. package/src/runtime/evidence-ledger.js +338 -0
  332. package/src/runtime/failure/capture.js +272 -0
  333. package/src/runtime/failure/classifier.js +68 -0
  334. package/src/runtime/failure/cli.js +178 -0
  335. package/src/runtime/failure/home.js +64 -0
  336. package/src/runtime/failure/store.js +226 -0
  337. package/src/runtime/feature-resolve.js +79 -0
  338. package/src/runtime/freshness.js +177 -0
  339. package/src/runtime/health.js +863 -0
  340. package/src/runtime/history-compactor.js +237 -0
  341. package/src/runtime/implementation-gate.js +321 -0
  342. package/src/runtime/inventory/coverage.js +218 -0
  343. package/src/runtime/inventory/identity.js +65 -0
  344. package/src/runtime/inventory/index.js +705 -0
  345. package/src/runtime/inventory/precedence.js +134 -0
  346. package/src/runtime/inventory/read-scopes.js +131 -0
  347. package/src/runtime/inventory/siblings.js +192 -0
  348. package/src/runtime/knowledge/chunker.js +124 -0
  349. package/src/runtime/knowledge/hybrid.js +149 -0
  350. package/src/runtime/knowledge/index-store.js +145 -0
  351. package/src/runtime/knowledge/retrieval.js +194 -0
  352. package/src/runtime/leak-scan.js +185 -0
  353. package/src/runtime/lifecycle/cli.js +276 -0
  354. package/src/runtime/lifecycle/event-store.js +340 -0
  355. package/src/runtime/lifecycle/fold.js +353 -0
  356. package/src/runtime/lifecycle/followup.js +432 -0
  357. package/src/runtime/lifecycle/goal.js +249 -0
  358. package/src/runtime/lifecycle/intent-writer.js +86 -0
  359. package/src/runtime/lifecycle/overview.js +292 -0
  360. package/src/runtime/lifecycle/release.js +241 -0
  361. package/src/runtime/lifecycle/report-store.js +221 -0
  362. package/src/runtime/lifecycle/retention.js +212 -0
  363. package/src/runtime/lifecycle/root.js +71 -0
  364. package/src/runtime/lifecycle/status.js +353 -0
  365. package/src/runtime/lifecycle/store-upkeep.js +393 -0
  366. package/src/runtime/locator-resolve.js +158 -0
  367. package/src/runtime/mask.js +210 -0
  368. package/src/runtime/mcp-consultations.js +412 -0
  369. package/src/runtime/mcp-reach.js +157 -0
  370. package/src/runtime/model-router.js +153 -0
  371. package/src/runtime/outcome.js +467 -0
  372. package/src/runtime/reach.js +148 -0
  373. package/src/runtime/readiness-record.js +150 -0
  374. package/src/runtime/readiness.js +435 -0
  375. package/src/runtime/recovery.js +282 -0
  376. package/src/runtime/research-request.js +231 -0
  377. package/src/runtime/retrieval-plan.js +578 -0
  378. package/src/runtime/scaffold/artifacts.js +217 -0
  379. package/src/runtime/scaffold/fingerprint.js +56 -0
  380. package/src/runtime/scaffold/generate.js +911 -0
  381. package/src/runtime/scaffold/languages.js +245 -0
  382. package/src/runtime/storage/cli.js +64 -0
  383. package/src/runtime/storage/engine.js +249 -0
  384. package/src/runtime/storage/registry.js +130 -0
  385. package/src/runtime/storage/roots.js +63 -0
  386. package/src/runtime/storage/rules.js +282 -0
  387. package/src/runtime/storage/upkeep.js +243 -0
  388. package/src/runtime/storage/work-status.js +326 -0
  389. package/src/runtime/task-attempts.js +415 -0
  390. package/src/runtime/task-classifier.js +550 -0
  391. package/src/runtime/task-scope.js +130 -0
  392. package/src/runtime/task-state.js +176 -0
  393. package/src/runtime/trace/indicators.js +235 -0
  394. package/src/runtime/trace/ledger.js +626 -0
  395. package/src/runtime/trace/render.js +76 -0
  396. package/src/runtime/trace/views.js +459 -0
  397. package/src/runtime/verification/contract-runner.js +341 -0
  398. package/src/runtime/verification/engine.js +1461 -0
  399. package/src/runtime/verification/registry.js +130 -0
  400. package/src/runtime/verification/scope-bound.js +418 -0
  401. package/src/runtime/verification/test-selection.js +172 -0
  402. package/src/runtime/workflow-engine.js +588 -0
  403. package/src/runtime/workflow-orchestrator.js +953 -0
  404. package/src/runtime/worktree.js +340 -0
  405. package/src/state/index.js +193 -0
  406. package/src/state/lockfile.js +136 -0
  407. package/src/state/run-lock.js +408 -0
  408. package/types/node-commonjs.d.ts +68 -0
  409. package/types/runtime.d.ts +91 -0
@@ -0,0 +1,953 @@
1
+ 'use strict';
2
+
3
+ // TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): run directly by Node native type-stripping.
4
+
5
+ // WorkflowOrchestrator — deterministic execution over the workflow registry. The registry
6
+ // declares WHAT a class's
7
+ // stages are (workflow-engine resolves that); this module owns the state machine that walks it:
8
+ // one linear program per task, human gates as first-class pause points, readiness as a scripted
9
+ // cascade gate before any source-mutating stage completes, and every transition journaled to
10
+ // neutral state so runs survive process death. The orchestrator never asks a model what runs next;
11
+ // callers assert stage completion, exactly like they assert evidence.
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+ const { WorkflowEngine } = require('./workflow-engine.js');
16
+ const { updateTaskState, readTaskState } = require('./task-state.js');
17
+ const { ResearchRequestStore } = require('./research-request.js');
18
+ const { REPO_ROOT } = require('../helper/repo-root.js');
19
+ const { resolveActiveFeature } = require('./feature-resolve.js');
20
+ const { compactDecisions } = require('./decision-register.js');
21
+ const { EvidenceLedger } = require('./evidence-ledger.js');
22
+ const { resolveConsultations } = require('./mcp-consultations.js');
23
+ const { mcpReach } = require('./mcp-reach.js');
24
+ const { loadRegistry } = require('../registry/index.js');
25
+ const { loadStorageRegistry } = require('./storage/registry.js');
26
+ const { resolveRoots, resolveStorePath } = require('./storage/roots.js');
27
+ const { checkCompletion: checkTaskAttemptCompletion } = require('./task-attempts.js');
28
+ const { appendOutcome, findPick, readWindow } = require('./advice/record.js');
29
+ const {
30
+ checkReadiness, harnessHookNote, featureSlugFor, runSlugOf, findRunFile,
31
+ } = require('./implementation-gate.js');
32
+
33
+ /** Appended to the note of an implementation stage completed with no task-attempt ledger (FR-006). */
34
+ const NO_LEDGER_NOTE = 'No task-attempt ledger exists for this feature, so its tasks were not verified; outcome recorded as unverified.';
35
+
36
+ const RUN_STATES = Object.freeze(['RUNNING', 'AWAITING_GATE', 'COMPLETED', 'REJECTED']);
37
+
38
+ /**
39
+ * Every write to `run.state` passes through here, so RUN_STATES is the closed set it is declared to
40
+ * be rather than a comment that happens to list four strings.
41
+ *
42
+ * It was the latter: the set was frozen and exported while all six state writes were bare literals,
43
+ * so `'COMPLETE'` for `'COMPLETED'` would have produced a run in a state no branch tests for — and
44
+ * every one of those branches reads `!== 'RUNNING'` or `=== 'COMPLETED'`, so the run would simply
45
+ * stop responding to stage completions with no error naming the cause. Throwing is right here
46
+ * precisely because the value is always a literal in this file: a failure can only be a typo
47
+ * introduced while editing, which is a defect to surface at once, never data to tolerate.
48
+ *
49
+ * @param {string} state
50
+ * @returns {string} the same state, once it is known to be one
51
+ */
52
+ function runState(state ) {
53
+ if (!RUN_STATES.includes(state)) {
54
+ throw new Error(`Unknown run state '${state}' (expected one of: ${RUN_STATES.join(', ')})`);
55
+ }
56
+ return state;
57
+ }
58
+
59
+ const GATE_DECISIONS = Object.freeze(['approve', 'reject']);
60
+ /** What a completed stage established, separate from the fact it was walked past (review A1). */
61
+ const STAGE_OUTCOMES = new Set(['passed', 'failed', 'unverified']);
62
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
63
+ /** Why a handoff with no run and no class records nothing, as the result and the CLI line say. */
64
+ const STANDALONE_REASON = 'no run exists for this task and no --task-class was given, so nothing was recorded and no readiness was required';
65
+
66
+ function assertSafeId(value , label ) {
67
+ if (!ID_PATTERN.test(String(value ?? ''))) throw new Error(`Invalid ${label}: '${value}'`);
68
+ return String(value);
69
+ }
70
+
71
+ function iso(now ) { return (now ?? new Date()).toISOString(); }
72
+
73
+ /** Interleave stages with their gates: each gate sits immediately after the stage named by its
74
+ * `afterStage`. A gate anchored to an unknown stage is a compile error here, not a silent orphan. */
75
+ function compileProgram(workflow ) {
76
+ const gates = [...(workflow.gates || [])];
77
+ const nodes = [];
78
+ for (const stage of workflow.stages || []) {
79
+ nodes.push({
80
+ type: 'stage', id: stage.id, skill: stage.skill, kind: stage.kind,
81
+ mutatesSource: Boolean(stage.mutatesSource),
82
+ readinessTemplate: stage.readinessTemplate ?? null,
83
+ optional: Boolean(stage.optional),
84
+ status: 'pending',
85
+ });
86
+ for (let i = gates.length - 1; i >= 0; i -= 1) {
87
+ const gate = gates[i];
88
+ if (gate.afterStage !== stage.id) continue;
89
+ nodes.push({
90
+ type: 'gate', id: gate.id, name: gate.name ?? gate.id,
91
+ kind: gate.kind ?? 'approval', trigger: gate.trigger ?? null,
92
+ prompt: gate.prompt ?? null, status: 'pending',
93
+ });
94
+ gates.splice(i, 1);
95
+ }
96
+ }
97
+ if (gates.length) throw new Error(`Unanchored gate(s) in workflow '${workflow.taskClass}': ${gates.map((g) => g.id).join(', ')}`);
98
+ return nodes;
99
+ }
100
+
101
+ class WorkflowOrchestrator {
102
+
103
+
104
+
105
+
106
+
107
+
108
+
109
+
110
+
111
+ /**
112
+ * @param {Object} [options]
113
+ * @param {string} [options.repoRoot] install root owning core/registry + default state dir.
114
+ * @param {string} [options.stateDir] where run journals live.
115
+ * @param {WorkflowEngine} [options.engine] pre-built engine (injected workflows in tests).
116
+ * @param {(node: object, run: object) => string|{verdict: string, message?: string}|null} [options.readinessEvaluate]
117
+ * cascade gate: returns a readiness verdict, as a string or with the refusal message; anything
118
+ * but READY blocks completion of a source-mutating gated stage.
119
+ * Unwired means such stages cannot be completed — fail closed, never open.
120
+ * @param {(node: object, run: object) => {state: 'none'|'complete'|'incomplete', slug?: string|null, incomplete?: Array<string>}|null} [options.taskAttemptCheck]
121
+ * FR-006 gate for the do-execute-plan implementation stage: reports whether the feature's
122
+ * task-attempt ledger exists and is complete. `incomplete` refuses completion naming the tasks;
123
+ * `none` (no ledger) records the stage as `unverified` whatever outcome was supplied. Unwired
124
+ * means the stage is not checked, which only a unit test that builds the orchestrator by hand
125
+ * relies on: the CLI always wires the real one.
126
+ * @param {object} [options.fsImpl]
127
+ * @param {string|null} [options.featureSlug] the feature a run started here belongs to
128
+ * @param {() => object} [options.buildAdapters] builds the adapter registry `mcpReach` reads installs
129
+ * through; without it install facts are unknown and no outcome is inferred from them
130
+ */
131
+ constructor({ repoRoot = REPO_ROOT, projectRoot, stateDir, engine, readinessEvaluate, taskAttemptCheck, fsImpl, featureSlug = null, buildAdapters = null } = {}) {
132
+ this.fsImpl = fsImpl || fs;
133
+ this.repoRoot = repoRoot;
134
+ this.projectRoot = projectRoot || (stateDir?.endsWith(path.join('.doflow', 'state', 'orchestration'))
135
+ ? path.resolve(stateDir, '../../..') : repoRoot);
136
+ this.engine = engine || new WorkflowEngine({ repoRoot });
137
+ this.stateDir = stateDir || path.join(this.projectRoot, '.doflow', 'state', 'orchestration');
138
+ this.readinessEvaluate = readinessEvaluate || null;
139
+ this.taskAttemptCheck = taskAttemptCheck || null;
140
+ this.featureSlug = featureSlug;
141
+ this.buildAdapters = buildAdapters;
142
+ }
143
+
144
+ runFile(taskId ) {
145
+ return path.join(this.stateDir, `${assertSafeId(taskId, 'taskId')}.json`);
146
+ }
147
+
148
+ readRun(taskId ) {
149
+ const file = this.runFile(taskId);
150
+ if (!this.fsImpl.existsSync(file)) return null;
151
+ return readTaskState(this.fsImpl, file);
152
+ }
153
+
154
+ writeRun(run , now , beforeWrite ) {
155
+ run.updatedAt = iso(now);
156
+ this.fsImpl.mkdirSync(this.stateDir, { recursive: true });
157
+ const expectedRevision = run.revision || 0;
158
+ updateTaskState({ file: this.runFile(run.taskId), fsImpl: this.fsImpl, build: (disk ) => {
159
+ if ((disk?.revision || 0) !== expectedRevision) {
160
+ throw new Error(`Concurrent workflow update for '${run.taskId}'; reload the run before retrying. Nothing was written.`);
161
+ }
162
+ if (beforeWrite) beforeWrite();
163
+ return run;
164
+ } });
165
+ run.revision = expectedRevision + 1;
166
+ return run;
167
+ }
168
+
169
+ /** Begin a new run. An existing run for the same task id is refused — resume instead. */
170
+ start({ taskId, taskClass, now } = {}) {
171
+ assertSafeId(taskId, 'taskId');
172
+ if (this.readRun(taskId)) throw new Error(`Workflow run '${taskId}' already exists; resume it instead of restarting`);
173
+ const workflow = this.engine.resolveWorkflow(taskClass); // unknown classes are rejected loudly
174
+ const program = compileProgram(workflow);
175
+ const run = {
176
+ version: 1,
177
+ taskId,
178
+ taskClass,
179
+ workflowName: workflow.name ?? taskClass,
180
+ terminalStage: workflow.terminalStage ?? null,
181
+ state: runState('RUNNING'),
182
+ cursor: 0,
183
+ program,
184
+ history: [{ at: iso(now), action: 'start', detail: `task-class=${taskClass}` }],
185
+ startedAt: iso(now),
186
+ // The feature the run belongs to, so another checkout can tell it from a run with the same id.
187
+ ...(this.featureSlug ? { featureSlug: this.featureSlug } : {}),
188
+ };
189
+ this.writeRun(run, now);
190
+ return this.snapshot(run);
191
+ }
192
+
193
+ snapshot(run ) {
194
+ const current = run.cursor < run.program.length ? run.program[run.cursor] : null;
195
+ const done = run.program.filter((n ) => n.status === 'completed' || n.status === 'skipped' || n.status === 'approved').length;
196
+ return {
197
+ taskId: run.taskId,
198
+ taskClass: run.taskClass,
199
+ workflowName: run.workflowName,
200
+ state: run.state,
201
+ current: current ? { type: current.type, id: current.id, kind: current.kind ?? null, mutatesSource: current.mutatesSource ?? null, readinessTemplate: current.readinessTemplate ?? null } : null,
202
+ awaitingGate: run.state === 'AWAITING_GATE' && current ? { gateId: current.id, name: current.name, prompt: current.prompt } : null,
203
+ progress: { done, total: run.program.length },
204
+ terminalStage: run.terminalStage,
205
+ };
206
+ }
207
+
208
+ status(taskId ) {
209
+ const run = this.requireRun(taskId);
210
+ return this.snapshot(run);
211
+ }
212
+
213
+ /** Record completed work by skill identity. Gate decisions remain a separate action.
214
+ * With no existing run and no explicit class this is a standalone no-op, not a new workflow.
215
+ *
216
+ * The MCP servers the stage names get one outcome each (062, IC-008): resolved here, stored on the
217
+ * entry this handoff writes, and returned as `consultations` and `mcpWarnings`. Only a handoff
218
+ * that completes a stage or annotates one stores a record; a standalone one reports and stores
219
+ * nothing, a deferred one does neither. Nothing in that path can fail or refuse the handoff. */
220
+ handoff({ taskId, taskClass, callingSkill, note, outcome, mcpOutcomes, now } = {}) {
221
+ assertSafeId(taskId, 'taskId');
222
+ assertSafeId(callingSkill, 'callingSkill');
223
+ if (!note || !String(note).trim()) throw new Error('handoff requires a note describing the work recorded');
224
+ if (outcome !== undefined && !STAGE_OUTCOMES.has(outcome)) throw new Error(`Unknown stage outcome '${outcome}'`);
225
+ const existing = this.readRun(taskId);
226
+ if (!existing && !taskClass) {
227
+ const mcp = this.resolveMcp({ taskId, stageId: null, skill: callingSkill, flags: mcpOutcomes });
228
+ return {
229
+ taskId, disposition: 'standalone', state: null, awaitingGate: null, reason: STANDALONE_REASON,
230
+ consultations: null, mcpWarnings: mcp ? mcp.messages : [],
231
+ };
232
+ }
233
+ if (existing && taskClass && taskClass !== existing.taskClass) throw new Error(`Task class '${taskClass}' does not match run '${existing.taskClass}'`);
234
+ const resolvedClass = existing?.taskClass || taskClass;
235
+ const stages = existing ? existing.program.filter((n ) => n.type === 'stage')
236
+ : this.engine.resolveWorkflow(resolvedClass).stages;
237
+ const candidates = stages.filter((n ) => n.skill === callingSkill).map((n ) => n.id);
238
+ if (!candidates.length) throw new Error(`Skill '${callingSkill}' owns no stage in '${resolvedClass}'`);
239
+ // The stage this handoff would complete is known before anything is written, so a gated stage
240
+ // without readiness is refused before catch-up starts a run or backfills a stage.
241
+ const preview = this.previewHandoff({ taskId, taskClass: resolvedClass, candidateStageIds: candidates });
242
+ if (preview.reason === 'reached-candidate' && preview.node.mutatesSource && preview.node.readinessTemplate) {
243
+ this.assertReady(preview.node, existing || { taskId, taskClass: resolvedClass });
244
+ }
245
+ // The ledger is checked here too, before catch-up writes anything, so a refused implementation
246
+ // handoff leaves no backfilled stage behind.
247
+ if (preview.reason === 'reached-candidate') this.assertTaskAttempts(preview.node, existing || { taskId, taskClass: resolvedClass });
248
+ const positioned = this.catchUp({ taskId, taskClass: resolvedClass, candidateStageIds: candidates, note, now });
249
+ if (positioned.reason === 'reached-candidate') {
250
+ // A run this handoff started has no earlier stage: every evidence item counts.
251
+ const since = existing ? this.stageSince(taskId, positioned.caughtUpTo) : null;
252
+ const mcp = this.resolveMcp({ taskId, stageId: positioned.caughtUpTo, skill: callingSkill, flags: mcpOutcomes, since });
253
+ const consultations = mcp ? this.storeToolsUsed(mcp) : null;
254
+ const snapshot = this.completeStage({ taskId, stageId: positioned.caughtUpTo, note, outcome, consultations, now });
255
+ return { ...snapshot, disposition: 'completed', recordedStage: positioned.caughtUpTo, consultations, mcpWarnings: mcp ? mcp.messages : [] };
256
+ }
257
+ if (positioned.reason.startsWith('already-completed:')) {
258
+ // A rerun after all occurrences is an annotation on the latest occurrence, never a replay.
259
+ const recordedStage = candidates[candidates.length - 1];
260
+ const mcp = this.resolveMcp({ taskId, stageId: recordedStage, skill: callingSkill, flags: mcpOutcomes, since: this.stageSince(taskId, recordedStage) });
261
+ const consultations = mcp ? this.storeToolsUsed(mcp) : null;
262
+ return {
263
+ ...this.annotate({ taskId, node: recordedStage, note, consultations, now }), disposition: 'annotated', recordedStage,
264
+ consultations, mcpWarnings: mcp ? mcp.messages : [],
265
+ };
266
+ }
267
+ return { ...positioned, disposition: 'deferred', recordedStage: null, consultations: null, mcpWarnings: [] };
268
+ }
269
+
270
+ /**
271
+ * Resolve one stage run's MCP outcomes from what is on disk. Reads only: the evidence ledger, the
272
+ * research requests, the brief records under the feature's `exec/` folder, the reports they name,
273
+ * and the install facts `mcpReach` reads. `since` bounds the evidence rule 3 counts (stageSince).
274
+ * Any failure to read one of them means that source
275
+ * contributes nothing, and a registry that cannot load means no record at all (NFR-003).
276
+ * @returns {{record: object, messages: object[], evidenceToAdd: object[], ledger: EvidenceLedger}|null}
277
+ */
278
+ resolveMcp({ taskId, stageId, skill, flags, since = null } ) {
279
+ let registry;
280
+ try { registry = loadRegistry({ repoRoot: this.repoRoot }); } catch { return null; }
281
+ const ledger = new EvidenceLedger({ repoRoot: this.projectRoot, slug: this.featureSlug, fsImpl: this.fsImpl });
282
+ const loaded = new Set();
283
+ let reachMemo ;
284
+ const reachOf = () => {
285
+ if (reachMemo === undefined) {
286
+ try {
287
+ reachMemo = this.buildAdapters
288
+ ? mcpReach({ registry, projectRoot: this.projectRoot, fsImpl: this.fsImpl, adapters: this.buildAdapters() })
289
+ : null;
290
+ } catch { reachMemo = null; }
291
+ }
292
+ return reachMemo;
293
+ };
294
+ const displayName = (id ) => {
295
+ const harness = (registry.harnesses || []).find((item ) => item.id === id);
296
+ return harness ? harness.displayName || harness.id : id;
297
+ };
298
+ const { briefRecords, repoRoot } = stageId ? this.readBriefRecords(taskId) : { briefRecords: [], repoRoot: this.projectRoot };
299
+ if (stageId) this.recordBriefTierAdvice({ briefRecords, taskId, stageId, repoRoot });
300
+ try {
301
+ const result = resolveConsultations({
302
+ taskId, stageId, skill, flags, registry, ledger: {
303
+ queryEvidence: ({ taskId: id } ) => {
304
+ if (!loaded.has(id)) { loaded.add(id); ledger.load(id); }
305
+ return ledger.queryEvidence({ taskId: id });
306
+ },
307
+ },
308
+ requests: new ResearchRequestStore({ projectRoot: this.projectRoot, fsImpl: this.fsImpl, slug: this.featureSlug }),
309
+ briefRecords,
310
+ readReport: (file ) => {
311
+ const target = path.resolve(repoRoot, file);
312
+ // A brief record is project data: a report path that leaves the project is not read.
313
+ if (path.relative(repoRoot, target).startsWith('..') || path.isAbsolute(path.relative(repoRoot, target))) return null;
314
+ try { return this.fsImpl.readFileSync(target, 'utf8'); } catch { return null; }
315
+ },
316
+ reach: {
317
+ anyHarnessHas: (server ) => { const reach = reachOf(); return reach ? reach.anyHarnessHas(server) : null; },
318
+ harnessWith: (server ) => {
319
+ const row = reachOf()?.harnesses.find((item ) => ['selected', 'configured'].includes(item.servers[server]));
320
+ return row ? displayName(row.id) : null;
321
+ },
322
+ },
323
+ harnessName: displayName,
324
+ since,
325
+ addCommand: (server ) => {
326
+ const reach = reachOf();
327
+ const row = reach?.harnesses.find((item ) => !['selected', 'configured'].includes(item.servers[server]));
328
+ return row ? reach.addCommand(row.id, row.scope, server) : null;
329
+ },
330
+ });
331
+ return { record: result.record, messages: [...result.warnings, ...result.notices], evidenceToAdd: result.evidenceToAdd, ledger };
332
+ } catch { return null; }
333
+ }
334
+
335
+ /** When this stage run began, for counting its evidence (IC-008 rule 3): the `at` of the last
336
+ * stage completed or skipped before it that was not backfilled, else the run start. A stage
337
+ * already completed (an annotation) is measured from the completion before its own. */
338
+ stageSince(taskId , stageId ) {
339
+ try {
340
+ const run = this.readRun(taskId);
341
+ const history = Array.isArray(run?.history) ? run.history : [];
342
+ let end = history.length;
343
+ for (let i = history.length - 1; i >= 0; i -= 1) {
344
+ if (history[i]?.action === 'complete-stage' && history[i].node === stageId) { end = i; break; }
345
+ }
346
+ for (let i = end - 1; i >= 0; i -= 1) {
347
+ const item = history[i];
348
+ if ((item?.action === 'complete-stage' || item?.action === 'skip-stage') && !item.backfilled && item.node !== stageId) return item.at;
349
+ }
350
+ return run?.startedAt || history[0]?.at || null;
351
+ } catch { return null; }
352
+ }
353
+
354
+ /** Record advice outcomes for the same brief records the IC-008 handoff pass consumes. This is
355
+ * advisory telemetry only: every storage/read failure is ignored and never affects the handoff.
356
+ */
357
+ recordBriefTierAdvice({ briefRecords, taskId, stageId, repoRoot } ) {
358
+ try {
359
+ const storage = loadStorageRegistry({ repoRoot: this.repoRoot });
360
+ const roots = resolveRoots({ registry: storage, projectRoot: repoRoot || this.projectRoot });
361
+ const declaredStores = new Map((storage.stores || []).map((store ) => [store.id, store]));
362
+ const safeRoots = { ...roots };
363
+ for (const id of ['advice-picks-project', 'advice-picks-user']) {
364
+ const store = declaredStores.get(id);
365
+ if (!store) return;
366
+ if (!resolveStorePath({ store, roots, fsImpl: this.fsImpl })) safeRoots[store.root] = null;
367
+ }
368
+
369
+ const existing = readWindow({ roots: safeRoots, fsImpl: this.fsImpl }).records
370
+ .filter(({ record } ) => record.type === 'outcome')
371
+ .map(({ record } ) => record.pickId);
372
+ const seen = new Set(existing);
373
+ for (const record of briefRecords) {
374
+ const advice = record?.advice;
375
+ const pickId = advice?.pickId;
376
+ if (record?.runTaskId !== taskId || record?.stage !== stageId
377
+ || advice?.kind !== 'tier' || advice?.status !== 'answered'
378
+ || typeof pickId !== 'string' || !pickId || seen.has(pickId)) continue;
379
+ const pick = findPick({ pickId, roots: safeRoots, fsImpl: this.fsImpl });
380
+ if (!pick || pick.pick.kind !== 'tier' || !Array.isArray(pick.pick.options)
381
+ || !pick.pick.options.includes(record.tier)) continue;
382
+ seen.add(pickId);
383
+ appendOutcome({
384
+ roots: safeRoots, fsImpl: this.fsImpl, store: pick.store,
385
+ pickId, actual: record.tier, source: 'brief-record',
386
+ });
387
+ }
388
+ } catch { /* advice storage must never change a handoff */ }
389
+ }
390
+
391
+ /** The brief records (`*-brief.json`) of the feature this run belongs to, parsed; unreadable ones
392
+ * are left out. Resolving the feature spawns the resolver, so it is only done where a feature
393
+ * folder exists above the project. */
394
+ readBriefRecords(taskId ) {
395
+ const none = { briefRecords: [], repoRoot: this.projectRoot };
396
+ try {
397
+ let dir = path.resolve(this.projectRoot);
398
+ let hasFolder = false;
399
+ for (;;) {
400
+ if (this.fsImpl.existsSync(path.join(dir, 'agent-docs', 'doflow'))) { hasFolder = true; break; }
401
+ const parent = path.dirname(dir);
402
+ if (parent === dir) break;
403
+ dir = parent;
404
+ }
405
+ if (!hasFolder) return none;
406
+ const feature = resolveActiveFeature({ projectRoot: this.projectRoot, slug: taskId });
407
+ if (feature.error) return none;
408
+ const exec = path.join(feature.featureDir, 'exec');
409
+ const briefRecords = [];
410
+ for (const name of this.fsImpl.readdirSync(exec).filter((entry ) => entry.endsWith('-brief.json')).sort()) {
411
+ try { briefRecords.push(JSON.parse(this.fsImpl.readFileSync(path.join(exec, name), 'utf8'))); } catch { /* an unreadable record contributes nothing */ }
412
+ }
413
+ return { briefRecords, repoRoot: feature.repoRoot };
414
+ } catch { return none; }
415
+ }
416
+
417
+ /** Add the Tools used evidence items the resolver asked for, and stamp each `recorded` row with
418
+ * its evidence id. Evidence goes in before the stage entry is written, so the entry carries the
419
+ * ids; a retry after a refused completion finds the items and reports them `already-recorded`.
420
+ * A ledger that cannot be written leaves the rows without an id and never stops the handoff. */
421
+ storeToolsUsed({ record, evidenceToAdd, ledger } ) {
422
+ if (!evidenceToAdd.length) return record;
423
+ try {
424
+ const idsByTask = new Map();
425
+ for (const item of evidenceToAdd) {
426
+ if (!idsByTask.has(item.taskId)) ledger.load(item.taskId);
427
+ idsByTask.set(item.taskId, [...(idsByTask.get(item.taskId) || []), ledger.addEvidence(item)]);
428
+ }
429
+ for (const taskId of idsByTask.keys()) ledger.save(taskId);
430
+ for (const row of record.toolsUsed) {
431
+ if (row.status === 'recorded') row.evidenceId = idsByTask.get(row.taskId)?.shift();
432
+ }
433
+ } catch { /* the rows stay as resolved */ }
434
+ return record;
435
+ }
436
+
437
+ requireRun(taskId ) {
438
+ const run = this.readRun(taskId);
439
+ if (!run) throw new Error(`No workflow run for task '${taskId}'`);
440
+ return run;
441
+ }
442
+
443
+ currentNode(run ) {
444
+ return run.cursor < run.program.length ? run.program[run.cursor] : null;
445
+ }
446
+
447
+ expectOpenStage(run , stageId ) {
448
+ if (run.state !== 'RUNNING') throw new Error(`Run '${run.taskId}' is ${run.state}, not RUNNING`);
449
+ const node = this.currentNode(run);
450
+ if (!node || node.type !== 'stage') throw new Error(`Run '${run.taskId}' has no stage to complete next`);
451
+ if (node.id !== stageId) throw new Error(`Expected stage '${node.id}' next, got '${stageId}'`);
452
+ return node;
453
+ }
454
+
455
+ /** Complete the current stage. A source-mutating stage carrying a readiness template must pass
456
+ * the injected evaluator with READY first — the cascade rule: cheap scripted verdicts gate
457
+ * expensive work, and NEEDS_EVIDENCE / NEEDS_USER_DECISION / BLOCKED stop the run here.
458
+ *
459
+ * Execution status and outcome are separate facts on the node (review A1): `status: 'completed'`
460
+ * only ever says the program walked past this stage. `executionStatus` says HOW — 'completed'
461
+ * (the owning skill ran it) or 'imported' (catch-up backfilled it, nobody ran it here) — and
462
+ * `outcome` says what the run established: 'passed', 'failed', or 'unverified'. A backfilled
463
+ * stage is always imported+unverified, whatever the caller says: catch-up importing history
464
+ * cannot import a verification. A 'failed' outcome still advances — recording the failure
465
+ * honestly beats refusing to record it, and a bug reproduction legitimately completes by
466
+ * observing the expected failure — but the record no longer reads as success. */
467
+ completeStage({ taskId, stageId, note, outcome, backfilled = false, consultations, now } = {}) {
468
+ const run = this.requireRun(taskId);
469
+ const node = this.expectOpenStage(run, stageId);
470
+ if (outcome !== undefined && !STAGE_OUTCOMES.has(outcome)) {
471
+ throw new Error(`Unknown stage outcome '${outcome}'. Valid: ${[...STAGE_OUTCOMES].join(', ')} — omit the flag to record 'unverified'.`);
472
+ }
473
+ if (node.mutatesSource && node.readinessTemplate) this.assertReady(node, run);
474
+ const attempts = backfilled ? null : this.assertTaskAttempts(node, run);
475
+ // No ledger for the feature: the stage is recorded, but nothing verified its tasks, and the
476
+ // record says so whatever outcome the caller supplied (FR-006).
477
+ const unledgered = attempts?.state === 'none';
478
+ node.status = 'completed';
479
+ node.executionStatus = backfilled ? 'imported' : 'completed';
480
+ node.outcome = backfilled || unledgered ? 'unverified' : (outcome ?? 'unverified');
481
+ run.history.push({
482
+ at: iso(now), action: 'complete-stage', node: node.id,
483
+ note: unledgered ? [note, NO_LEDGER_NOTE].filter(Boolean).join(' ') : (note ?? null),
484
+ backfilled: Boolean(backfilled), executionStatus: node.executionStatus, outcome: node.outcome,
485
+ ...(consultations ? { consultations } : {}),
486
+ });
487
+ this.advance(run, now);
488
+ this.writeRun(run, now, () => {
489
+ if (run.taskClass !== 'feature') return;
490
+ const request = new ResearchRequestStore({ projectRoot: this.projectRoot, fsImpl: this.fsImpl })
491
+ .blockingGap(taskId, stageId);
492
+ if (request) throw new Error(`Research request '${request.id}' blocks stage '${stageId}': ${request.failureReason || request.gap || request.status}`);
493
+ });
494
+ return this.snapshot(run);
495
+ }
496
+
497
+ /** Skip the current OPTIONAL stage without running it. Gates anchored directly to a skipped
498
+ * stage cannot fire, so they are recorded as skipped too rather than blocking forever. */
499
+ skipStage({ taskId, stageId, reason, backfilled = false, now } = {}) {
500
+ const run = this.requireRun(taskId);
501
+ const node = this.expectOpenStage(run, stageId);
502
+ if (!node.optional) throw new Error(`Stage '${node.id}' is required and cannot be skipped`);
503
+ node.status = 'skipped';
504
+ run.history.push({ at: iso(now), action: 'skip-stage', node: node.id, note: reason ?? null, backfilled: Boolean(backfilled) });
505
+ let lookahead = run.cursor + 1;
506
+ while (lookahead < run.program.length) {
507
+ const nextNode = run.program[lookahead];
508
+ // A gate sits immediately after its anchor in the program, so any pending gate directly
509
+ // following the skipped stage is anchored to it and can never fire.
510
+ if (nextNode.type !== 'gate' || nextNode.status !== 'pending') break;
511
+ nextNode.status = 'skipped';
512
+ run.history.push({ at: iso(now), action: 'skip-gate', node: nextNode.id, note: 'anchor stage skipped', backfilled: Boolean(backfilled) });
513
+ lookahead += 1;
514
+ }
515
+ this.advance(run, now);
516
+ this.writeRun(run, now);
517
+ return this.snapshot(run);
518
+ }
519
+
520
+ /** Advance the run toward whichever of `candidateStageIds` the cursor reaches first, starting
521
+ * the run if none exists yet. A skill knows its OWN stage, not where the cursor happens to be:
522
+ * `start` always begins at the first stage, so any skill but the first one could never record
523
+ * its handoff on a fresh run, and a skill occupying two stages of one class (do-test owns both
524
+ * `reproduction` and `regression-verification` in `bug`) cannot tell which occurrence is live.
525
+ * Catch-up resolves both by walking the cursor forward for the caller and stopping ON the node
526
+ * the caller must act on — it never completes a candidate stage itself, since only the owning
527
+ * skill knows whether its own work is actually done.
528
+ *
529
+ * A candidate already behind the cursor (`already-completed:<id>`) means this same skill already
530
+ * recorded this handoff on an earlier invocation; walking forward from here would complete later
531
+ * stages as a side effect of a skill re-run, which the caller does not own. Callers must use
532
+ * `annotate` for that case instead.
533
+ *
534
+ * Every stage walked through on the way to a candidate is marked `backfilled: true` in the
535
+ * journal, distinct from a stage the owning skill actually completed — a stage nobody ran must
536
+ * never be indistinguishable, in `audit.md`, from one that was. It stops, advancing no further,
537
+ * at: a candidate stage (the normal case), ANY gate (a human — or the gate's own owning skill —
538
+ * decides it; catch-up decides nothing), a non-candidate stage carrying a readiness template
539
+ * (that stage mutates source and only its owner may complete it), or a terminal run state. An
540
+ * optional non-candidate stage is skipped rather than completed, matching what its own skill
541
+ * would have done with it. */
542
+ catchUp({ taskId, taskClass, candidateStageIds, note, now } = {}) {
543
+ if (!Array.isArray(candidateStageIds) || candidateStageIds.length === 0) {
544
+ throw new Error('catch-up requires at least one candidate stage id');
545
+ }
546
+ for (const id of candidateStageIds) assertSafeId(id, 'stageId');
547
+ let run = this.readRun(taskId);
548
+ if (run && typeof taskClass === 'string' && taskClass.trim() !== '' && taskClass !== run.taskClass) {
549
+ throw new Error(`Run '${taskId}' was started as task class '${run.taskClass}', not '${taskClass}' — a caller must not walk a run under a class it did not start`);
550
+ }
551
+ // Candidates are validated before `start` ever runs, so a typo'd first catch-up for a fresh
552
+ // task cannot persist a durable, unrecoverable orphan run pinned to whatever class the failed
553
+ // call happened to carry (no CLI verb can undo it — `start` refuses on an existing run). The
554
+ // SOURCE of truth for "known stage id" differs by whether a run already exists, and that split
555
+ // matters: a run's own `program` is the side that goes STALE relative to the registry (compiled
556
+ // once at `start`, so a later registry rename or removal leaves a stage id in the program the
557
+ // registry no longer declares — exactly the drift this check exists to catch), while for a
558
+ // fresh task there is no program yet to consult, so the registry is the only source available.
559
+ // Validating a run that already exists against the registry instead of its own program was
560
+ // tried and reverted — it stopped catching stale ids from a registry rename, silently backfilling
561
+ // through them instead of refusing.
562
+ let unknownCandidates;
563
+ if (run) {
564
+ const programStageIds = new Set(run.program.filter((n ) => n.type === 'stage').map((n ) => n.id));
565
+ unknownCandidates = candidateStageIds.filter((id) => !programStageIds.has(id));
566
+ } else {
567
+ const workflow = this.engine.resolveWorkflow(taskClass); // unknown classes are rejected loudly, same as start()
568
+ const workflowStageIds = new Set((workflow.stages || []).map((s ) => s.id));
569
+ unknownCandidates = candidateStageIds.filter((id) => !workflowStageIds.has(id));
570
+ }
571
+ if (unknownCandidates.length > 0) {
572
+ const where = run ? `in run '${taskId}''s program` : `declared by task class '${taskClass}'`;
573
+ throw new Error(`catch-up: candidate stage id(s) not ${where}: ${unknownCandidates.join(', ')} — check the workflow's own stage ids rather than walking the run forward on an unresolvable candidate`);
574
+ }
575
+ if (!run) {
576
+ this.start({ taskId, taskClass, now });
577
+ run = this.requireRun(taskId);
578
+ }
579
+ const candidates = new Set(candidateStageIds);
580
+ // A REJECTED run answers with its own terminal state first — before the already-completed
581
+ // pre-check below, which would otherwise misreport a REJECTED run whose named candidates
582
+ // happen to already be complete (discovery finished, then gate-0 was rejected, say) as
583
+ // `already-completed` instead of the more informative `run-rejected`. A COMPLETED run needs no
584
+ // such override and must NOT be included here: every one of its stages reached a terminal
585
+ // per-node status by definition (that is what COMPLETED means — settleCursor only sets it once
586
+ // every node has been walked past as completed/skipped/approved), so any validated candidate is
587
+ // always caught by the already-completed check below on its own. Special-casing COMPLETED here
588
+ // too was tried and reverted — it pre-empted `already-completed:<id>` with a bare
589
+ // `run-completed` for a re-invocation naming an already-recorded terminal stage, and six chain
590
+ // skills branch on that exact distinction in opposite directions (annotate vs. stop-and-report),
591
+ // so the trail silently stopped recording the re-run.
592
+ if (run.state === 'REJECTED') {
593
+ return { ...this.snapshot(run), caughtUpTo: null, reason: 'run-rejected' };
594
+ }
595
+ // ALL candidates already done means this caller's own handoff already happened — detect it
596
+ // before the walk below can complete anything past it as a side effect. A caller with more than
597
+ // one candidate (do-test owns two occurrences in `bug`/`refactor`) is not done until every
598
+ // occurrence is: one completed and one still pending must still walk forward to the pending one.
599
+ const candidateNodes = run.program.filter((n ) => candidates.has(n.id));
600
+ if (candidateNodes.length > 0 && candidateNodes.every((n ) => n.status === 'completed' || n.status === 'approved' || n.status === 'skipped')) {
601
+ return { ...this.snapshot(run), caughtUpTo: null, reason: `already-completed:${candidateNodes.map((n ) => n.id).join(',')}` };
602
+ }
603
+ for (;;) {
604
+ if (run.state === 'COMPLETED' || run.state === 'REJECTED') {
605
+ return { ...this.snapshot(run), caughtUpTo: null, reason: `run-${run.state.toLowerCase()}` };
606
+ }
607
+ const node = this.currentNode(run);
608
+ if (!node) return { ...this.snapshot(run), caughtUpTo: null, reason: 'run-completed' };
609
+ if (node.type === 'stage') {
610
+ if (candidates.has(node.id)) {
611
+ return { ...this.snapshot(run), caughtUpTo: node.id, reason: 'reached-candidate' };
612
+ }
613
+ // Gated on `mutatesSource` — not `readinessTemplate` — because the safety boundary this
614
+ // exists to protect ("only a stage's own owner may complete a stage that mutates source")
615
+ // is about mutation, not about whether a template happens to be attached. Every shipped
616
+ // mutatesSource stage also carries a template today, so the two conditions currently agree;
617
+ // gating on the template alone would silently backfill-complete a hypothetical
618
+ // mutatesSource stage with no template, which is exactly the mutation this check exists to
619
+ // block.
620
+ if (node.mutatesSource) {
621
+ return { ...this.snapshot(run), caughtUpTo: null, reason: `blocked-on-mutating-stage:${node.id}` };
622
+ }
623
+ if (node.optional) {
624
+ this.skipStage({ taskId, stageId: node.id, reason: note ?? 'catch-up: backfilled', backfilled: true, now });
625
+ } else {
626
+ this.completeStage({ taskId, stageId: node.id, note: note ?? 'catch-up: backfilled', backfilled: true, now });
627
+ }
628
+ run = this.requireRun(taskId);
629
+ continue;
630
+ }
631
+ // Every gate — clarification-kind included — is a stop, not a walk-through: only its own
632
+ // owning skill (or a human) may decide it. `gate-0` is `clarification`-kind and is resolved
633
+ // explicitly by do-brainstorm's own step 8; auto-approving it here on a later skill's catch-up
634
+ // call bypassed exactly the "leave it for a human" case do-brainstorm's own prose documents.
635
+ return { ...this.snapshot(run), caughtUpTo: null, reason: `awaiting-gate:${node.id}` };
636
+ }
637
+ }
638
+
639
+ /** Resolve the gate the run is paused on. approve resumes; reject terminates the run. A
640
+ * `forced` decision (e.g. approving despite unresolved clarification markers) requires an
641
+ * explicit `note` reason — never a silent override — and is flagged in history for
642
+ * `render-audit.sh` to distinguish from a routine handoff. */
643
+ decideGate({ taskId, gateId, decision, note, forced = false, now } = {}) {
644
+ assertSafeId(gateId, 'gateId');
645
+ if (!GATE_DECISIONS.includes(decision)) throw new Error(`Invalid gate decision '${decision}'; valid: ${GATE_DECISIONS.join(', ')}`);
646
+ if (forced && (!note || !String(note).trim())) throw new Error('A forced gate decision requires a --note reason');
647
+ const run = this.requireRun(taskId);
648
+ if (run.state !== 'AWAITING_GATE') throw new Error(`Run '${run.taskId}' is not awaiting a gate (state=${run.state})`);
649
+ const node = this.currentNode(run);
650
+ if (!node || node.type !== 'gate') throw new Error('Run state says AWAITING_GATE but no gate is at the cursor');
651
+ if (node.id !== gateId) throw new Error(`Expected gate '${node.id}' next, got '${gateId}'`);
652
+ node.status = decision === 'approve' ? 'approved' : 'rejected';
653
+ node.decision = decision;
654
+ run.history.push({ at: iso(now), action: 'decide-gate', node: node.id, detail: decision, note: note ?? null, forced: Boolean(forced) });
655
+ if (decision === 'reject') {
656
+ run.state = runState('REJECTED');
657
+ } else {
658
+ run.state = runState('RUNNING');
659
+ run.cursor += 1;
660
+ this.settleCursor(run);
661
+ }
662
+ this.writeRun(run, now);
663
+ return this.snapshot(run);
664
+ }
665
+
666
+ /** Append a pure audit-trail entry — a stage re-run after its own handoff, or an artifact
667
+ * hand-edited after that stage's own handoff — without touching program/cursor/state. Not a
668
+ * stage completion or a gate decision, so it never collides with the state machine's ordering
669
+ * rules (D3). `forced` requires an explicit `note` reason, same rule as decideGate. */
670
+ annotate({ taskId, node: nodeId, note, forced = false, consultations, now } = {}) {
671
+ // Id validated before the run is loaded, matching decideGate's order exactly: an unsafe id is
672
+ // the caller's problem to hear about first, whether or not the run happens to exist.
673
+ assertSafeId(nodeId, 'node');
674
+ if (forced && (!note || !String(note).trim())) throw new Error('A forced annotation requires a --note reason');
675
+ const run = this.requireRun(taskId);
676
+ const found = run.program.find((n ) => n.id === nodeId);
677
+ if (!found) throw new Error(`Invalid node: '${nodeId}' is not part of the program for run '${taskId}'`);
678
+ run.history.push({ at: iso(now), action: 'annotate', node: nodeId, note, forced: Boolean(forced), ...(consultations ? { consultations } : {}) });
679
+ this.writeRun(run, now);
680
+ return this.snapshot(run);
681
+ }
682
+
683
+ /** After any advance: walk past finished nodes, land on the next pending action point, and set
684
+ * the run state from what is found there. Cursor always sits ON the node awaiting action. */
685
+ settleCursor(run ) {
686
+ for (;;) {
687
+ if (run.cursor >= run.program.length) { run.state = runState('COMPLETED'); return; }
688
+ const node = run.program[run.cursor];
689
+ if (node.status === 'completed' || node.status === 'skipped' || node.status === 'approved') { run.cursor += 1; continue; }
690
+ if (node.type === 'gate' && node.status === 'pending') { run.state = runState('AWAITING_GATE'); return; }
691
+ run.state = runState('RUNNING');
692
+ return;
693
+ }
694
+ }
695
+
696
+ advance(run , now ) {
697
+ run.cursor += 1;
698
+ this.settleCursor(run);
699
+ if (run.state === 'COMPLETED') run.history.push({ at: iso(now), action: 'complete-run' });
700
+ }
701
+
702
+ /** Refuses a gated stage unless the evaluator answers READY. The evaluator returns a verdict
703
+ * string, or `{ verdict, message }`: `message` replaces the generic refusal. */
704
+ assertReady(node , run ) {
705
+ if (!this.readinessEvaluate) {
706
+ // Fail closed: without an evaluator there is no way to know the tree is safe to mutate.
707
+ throw new Error(`Stage '${node.id}' is gated by the '${node.readinessTemplate}' readiness template but no readiness evaluator is wired into this orchestrator`);
708
+ }
709
+ const answer = this.readinessEvaluate(node, run);
710
+ if (!answer) throw new Error(`Readiness evaluator returned no verdict for stage '${node.id}'`);
711
+ const { verdict, message = null } = typeof answer === 'string' ? { verdict: answer } : answer;
712
+ if (verdict !== 'READY') {
713
+ throw new Error(message || `Readiness for stage '${node.id}' returned ${verdict}; expected READY — resolve evidence or the user decision first`);
714
+ }
715
+ }
716
+
717
+ /** FR-006: the implementation stage of a run (the `do-execute-plan` stage that mutates source)
718
+ * cannot complete while its feature's task-attempt ledger exists and is incomplete. Returns the
719
+ * checker's answer for a stage the rule applies to, `null` otherwise. */
720
+ assertTaskAttempts(node , run ) {
721
+ if (!this.taskAttemptCheck || node.skill !== 'do-execute-plan' || !node.mutatesSource) return null;
722
+ const answer = this.taskAttemptCheck(node, run);
723
+ if (!answer || !['none', 'complete', 'incomplete'].includes(answer.state)) {
724
+ throw new Error(`Task-attempt check returned no usable answer for stage '${node.id}'`);
725
+ }
726
+ if (answer.state === 'incomplete') {
727
+ const names = (answer.incomplete || []).join(', ') || 'unknown';
728
+ throw new Error(`Stage '${node.id}' cannot be completed: the task-attempt ledger${answer.slug ? ` for '${answer.slug}'` : ''} is incomplete — no COMPLETED attempt for ${names}. `
729
+ + 'Claim, verify and finish each task with doflow-run task-attempt, then complete the stage.');
730
+ }
731
+ return answer;
732
+ }
733
+
734
+ /** Where `catchUp` would stop for these candidates, worked out on a copy of the run's program (or
735
+ * of a fresh run's) without writing anything. Same stop rules and reason vocabulary as `catchUp`;
736
+ * `stageId` is what `catchUp` reports as `caughtUpTo`, and `node` the program node it stops on.
737
+ * @returns {{reason: string, stageId: string|null, node: Object|null}} */
738
+ previewHandoff({ taskId, taskClass, candidateStageIds } ) {
739
+ const existing = this.readRun(taskId);
740
+ const run = existing
741
+ ? { state: existing.state, cursor: existing.cursor, program: structuredClone(existing.program) }
742
+ : { state: 'RUNNING', cursor: 0, program: compileProgram(this.engine.resolveWorkflow(taskClass)) };
743
+ const candidates = new Set(candidateStageIds);
744
+ const stop = (reason , node = null) => ({ reason, stageId: reason === 'reached-candidate' ? node.id : null, node });
745
+ if (run.state === 'REJECTED') return stop('run-rejected');
746
+ const candidateNodes = run.program.filter((n ) => candidates.has(n.id));
747
+ if (candidateNodes.length > 0 && candidateNodes.every((n ) => n.status === 'completed' || n.status === 'approved' || n.status === 'skipped')) {
748
+ return stop(`already-completed:${candidateNodes.map((n ) => n.id).join(',')}`);
749
+ }
750
+ for (;;) {
751
+ if (run.state === 'COMPLETED' || run.state === 'REJECTED') return stop(`run-${run.state.toLowerCase()}`);
752
+ const node = this.currentNode(run);
753
+ if (!node) return stop('run-completed');
754
+ if (node.type !== 'stage') return stop(`awaiting-gate:${node.id}`, node);
755
+ if (candidates.has(node.id)) return stop('reached-candidate', node);
756
+ if (node.mutatesSource) return stop(`blocked-on-mutating-stage:${node.id}`, node);
757
+ node.status = node.optional ? 'skipped' : 'completed';
758
+ if (node.optional) {
759
+ for (let next = run.cursor + 1; next < run.program.length; next += 1) {
760
+ const gate = run.program[next];
761
+ if (gate.type !== 'gate' || gate.status !== 'pending') break;
762
+ gate.status = 'skipped';
763
+ }
764
+ }
765
+ run.cursor += 1;
766
+ this.settleCursor(run);
767
+ }
768
+ }
769
+ }
770
+
771
+ /**
772
+ * IC-013: fold the finished stage's History into the feature's archive. The task id is the feature
773
+ * slug. This is housekeeping after a recorded handoff, so it reports and never throws: the
774
+ * disposition and the exit code stay whatever the handoff itself decided.
775
+ * @returns {{status:'compacted'|'unchanged'|'partial'|'skipped'|'failed', reason?:string, moved?:Array,
776
+ * failed?:Array<{artifact:string, path:string, message:string}>}} `partial` carries `failed`, one entry per refused artifact
777
+ */
778
+ function compactAfterHandoff({ taskId, projectRoot } ) {
779
+ try {
780
+ const feature = resolveActiveFeature({ projectRoot, slug: taskId });
781
+ if (feature.error) return { status: 'skipped', reason: feature.message };
782
+ // A task id such as a plan task (`B.1`) resolves on a feature branch to a folder that was
783
+ // never created; say that, rather than blaming a missing register.
784
+ if (!fs.existsSync(feature.featureDir)) return { status: 'skipped', reason: `no feature folder for task id "${taskId}"` };
785
+ const result = compactDecisions({
786
+ featureDir: feature.featureDir, slug: feature.paths.feature_slug, repoRoot: feature.repoRoot, paths: feature.paths,
787
+ });
788
+ if (result.finding === 'no-register') return { status: 'skipped', reason: result.message };
789
+ // Some artifacts compacted and some were refused: `partial`, with each refusal named (IC-005).
790
+ if (result.finding === 'compaction-failed') {
791
+ return { status: 'partial', reason: result.message, moved: result.moved, failed: result.failed };
792
+ }
793
+ if (result.finding) return { status: 'failed', reason: result.message };
794
+ return { status: result.status, moved: result.moved };
795
+ } catch (error ) {
796
+ return { status: 'failed', reason: error.message };
797
+ }
798
+ }
799
+
800
+ /** CLI handler for `doflow orchestrate`. The run journal lives in the CALLER's project state
801
+ * (like evidence), while templates come from this install — same two-roots split readiness uses.
802
+ * Completing a source-mutating gated stage needs a READY readiness record of the stage's template,
803
+ * made by `doflow readiness` before this call; nothing is evaluated here. */
804
+ function handleOrchestrateCommand({
805
+ action = 'status', taskId, taskClass, stage, gate, node, decision, note, reason, forced = false,
806
+ verificationPlan, scope, invariants, result, mcpOutcome, callingSkill, json = false, repoRoot, stateRoot, slug = null, buildAdapters = null,
807
+ } = {}) {
808
+ const { finishRuntime, usageError } = require('./cli-result.js');
809
+
810
+ const root = repoRoot || REPO_ROOT;
811
+ // Same scope rule every other stateful command uses (evidenceRoot(o) in runtime-commands.js):
812
+ // a bare `stateRoot: stateRoot || process.cwd()` silently ignored `--global`, so `orchestrate`
813
+ // wrote its journal to $PWD while a `--global` `evidence`/`readiness` call on the same task read
814
+ // and wrote $HOME — two roots disagreeing about where one task's state lives.
815
+ const state = stateRoot || process.cwd();
816
+ const safeId = ID_PATTERN.test(String(taskId ?? ''));
817
+ // The feature this task belongs to: recorded on a run this command starts, matched against a run
818
+ // found in another checkout, and the namespace its readiness record is read from.
819
+ const featureSlug = safeId ? featureSlugFor({ projectRoot: state, taskId, slug }) : slug;
820
+ // A run started in another checkout of this repository for the same feature is read and updated
821
+ // where it is. `start` is always local: an unrelated run with the same id elsewhere neither
822
+ // refuses nor satisfies it.
823
+ let runRoot = state;
824
+ if (safeId && action !== 'start') {
825
+ const found = findRunFile({ stateRoot: state, taskId, slug: featureSlug });
826
+ if (found.status === 'ambiguous') {
827
+ console.error(`[ERROR] orchestrate: run '${taskId}' exists in more than one other checkout (${found.candidates.join(', ')}); run the command from the checkout that holds the run you mean. Nothing was changed.`);
828
+ return finishRuntime(1);
829
+ }
830
+ if (found.status === 'found') runRoot = found.root;
831
+ }
832
+ const orchestrator = new WorkflowOrchestrator({
833
+ repoRoot: root, projectRoot: runRoot,
834
+ stateDir: path.join(runRoot, '.doflow', 'state', 'orchestration'),
835
+ featureSlug, buildAdapters,
836
+ });
837
+ orchestrator.readinessEvaluate = (node , run ) => {
838
+ // This closure only ever runs for a stage that is actually gated (evaluateReadiness only calls
839
+ // it for a mutating stage carrying a readiness template) — so a missing --task-class here is a
840
+ // caller mistake on THIS command, not a fact about the task's readiness. Reported before the
841
+ // ledger/engine machinery below turns it into a generic readiness-layer exception.
842
+ if (action !== 'handoff' && (typeof taskClass !== 'string' || taskClass.trim() === '')) {
843
+ throw new Error("orchestrate complete-stage: this stage is gated by a readiness template, so --task-class is required — pass the same class this run was started/caught-up with.");
844
+ }
845
+ // The gate must grade this stage against the contract the run was actually started under, not
846
+ // whatever class the caller happened to pass on THIS command — catchUp already refuses a
847
+ // mismatched class for the same reason (a caller must not walk, or complete, a run under a
848
+ // class it did not start). Without this, `--task-class trivial-edit` on a `bug` run's gated
849
+ // `implementation` stage would grade source mutation against a 2-requirement contract instead
850
+ // of the 5-requirement one the run actually declared, silently.
851
+ if (taskClass && taskClass !== run.taskClass) {
852
+ throw new Error(`orchestrate complete-stage: --task-class '${taskClass}' does not match run '${run.taskId}''s own class '${run.taskClass}' — pass the class this run was started under, not a different one.`);
853
+ }
854
+ // The record `doflow readiness` wrote before this call decides; inputs stated here would let a
855
+ // handoff grade itself with whatever it was handed at the last moment.
856
+ const template = node.readinessTemplate;
857
+ const check = checkReadiness({ stateRoot: state, taskId: run.taskId, slug: runSlugOf(run, runRoot) || featureSlug, template, now: new Date() });
858
+ if (check.ok) return { verdict: 'READY', message: null };
859
+ return { verdict: check.code, message: [check.message, harnessHookNote({ repoRoot: root })].filter(Boolean).join(' ') };
860
+ };
861
+
862
+ // FR-006. The ledger lives under the main worktree, so the plan and the store are found from any
863
+ // linked worktree; a run whose feature cannot be named has no ledger to check.
864
+ orchestrator.taskAttemptCheck = (node , run ) => checkTaskAttemptCompletion({
865
+ slug: runSlugOf(run, runRoot) || featureSlug, cwd: state,
866
+ });
867
+
868
+ if ((action === 'handoff' || action === 'complete-stage')
869
+ && [verificationPlan, scope, invariants].some((v) => typeof v === 'string' && v.trim() !== '')) {
870
+ console.error(`orchestrate: note: --verification-plan, --scope and --invariants are inputs to doflow-run readiness; this ${action} did not read them.`);
871
+ }
872
+
873
+ try {
874
+ // `--forced` is only read by the two actions that can override a decision. Accepting it
875
+ // silently on any other action lets an operator believe a stage completion or skip was
876
+ // recorded as forced when nothing recorded it — refuse instead of dropping the flag.
877
+ if (forced && action !== 'decide-gate' && action !== 'annotate') {
878
+ return usageError('orchestrate', `'--forced' has no effect on action '${action}' — only decide-gate and annotate read it`, json);
879
+ }
880
+ // Same rule for --result: dropping it silently would let an operator believe an outcome was
881
+ // recorded when nothing recorded it.
882
+ if (result !== undefined && action !== 'complete-stage' && action !== 'handoff') {
883
+ return usageError('orchestrate', `'--result' has no effect on action '${action}' — only complete-stage and handoff record a stage outcome`, json);
884
+ }
885
+ // And for --mcp-outcome: an outcome that nothing records must not look recorded.
886
+ if (mcpOutcome !== undefined && action !== 'handoff') {
887
+ return usageError('orchestrate', `'--mcp-outcome' has no effect on action '${action}' — only handoff records MCP outcomes`, json);
888
+ }
889
+ let snapshot;
890
+ switch (action) {
891
+ case 'handoff':
892
+ snapshot = orchestrator.handoff({ taskId, taskClass, callingSkill, note, outcome: result, mcpOutcomes: mcpOutcome });
893
+ break;
894
+ case 'start':
895
+ if (!taskId || !taskClass) return usageError('orchestrate', 'start requires --task-id and --task-class', json);
896
+ snapshot = orchestrator.start({ taskId, taskClass });
897
+ break;
898
+ case 'complete-stage':
899
+ if (!taskId || !stage) return usageError('orchestrate', 'complete-stage requires --task-id and --stage', json);
900
+ snapshot = orchestrator.completeStage({ taskId, stageId: stage, note, outcome: result });
901
+ break;
902
+ case 'catch-up': {
903
+ if (!taskId || !taskClass || !stage) return usageError('orchestrate', 'catch-up requires --task-id, --task-class and --stage (comma-separated candidate ids)', json);
904
+ const candidateStageIds = String(stage).split(',').map((s) => s.trim()).filter(Boolean);
905
+ snapshot = orchestrator.catchUp({ taskId, taskClass, candidateStageIds, note });
906
+ break;
907
+ }
908
+ case 'skip-stage':
909
+ if (!taskId || !stage) return usageError('orchestrate', 'skip-stage requires --task-id and --stage', json);
910
+ snapshot = orchestrator.skipStage({ taskId, stageId: stage, reason: reason ?? note });
911
+ break;
912
+ case 'decide-gate':
913
+ if (!taskId || !gate || !decision) return usageError('orchestrate', 'decide-gate requires --task-id, --gate and --decision approve|reject', json);
914
+ snapshot = orchestrator.decideGate({ taskId, gateId: gate, decision, note, forced });
915
+ break;
916
+ case 'annotate':
917
+ if (!taskId || !node || !note) return usageError('orchestrate', 'annotate requires --task-id, --node and --note', json);
918
+ snapshot = orchestrator.annotate({ taskId, node, note, forced });
919
+ break;
920
+ case 'status':
921
+ if (!taskId) return usageError('orchestrate', 'status requires --task-id', json);
922
+ snapshot = orchestrator.status(taskId);
923
+ break;
924
+ default:
925
+ return usageError('orchestrate', `unknown action '${action}'; valid: start | status | handoff | catch-up | complete-stage | skip-stage | decide-gate | annotate`, json);
926
+ }
927
+ // Only a handoff that recorded work compacts; `deferred` and `standalone` recorded nothing.
928
+ if (action === 'handoff' && (snapshot.disposition === 'completed' || snapshot.disposition === 'annotated')) {
929
+ snapshot = { ...snapshot, compaction: compactAfterHandoff({ taskId, projectRoot: state }) };
930
+ }
931
+ if (json) { console.log(JSON.stringify(snapshot, null, 2)); return finishRuntime(0); }
932
+ console.log(`Workflow ${snapshot.taskId} [${snapshot.taskClass}] — ${snapshot.state}`);
933
+ if (snapshot.disposition === 'standalone') console.log(`Handoff: standalone (${snapshot.reason})`);
934
+ else if (snapshot.disposition) console.log(`Handoff: ${snapshot.disposition}`);
935
+ if (snapshot.compaction) console.log(`Compaction: ${snapshot.compaction.status}${snapshot.compaction.reason ? ` — ${snapshot.compaction.reason}` : ''}`);
936
+ if (snapshot.progress) console.log(`Progress: ${snapshot.progress.done}/${snapshot.progress.total}`);
937
+ if (snapshot.current) {
938
+ console.log(snapshot.awaitingGate
939
+ ? `Awaiting gate ${snapshot.awaitingGate.gateId}${snapshot.awaitingGate.prompt ? `: ${snapshot.awaitingGate.prompt}` : ''}`
940
+ : `Next: ${snapshot.current.type} '${snapshot.current.id}'${snapshot.current.mutatesSource ? ' (mutates source)' : ''}`);
941
+ }
942
+ // MCP warnings go to stderr and notices to stdout, after everything else (IC-011).
943
+ for (const message of snapshot.mcpWarnings || []) (message.text.startsWith('[WARN]') ? console.error : console.log)(message.text);
944
+ return finishRuntime(0);
945
+ } catch (error ) {
946
+ // Transition refusals are findings, not crashes: out-of-order stages and unready gates are
947
+ // exactly what an operator asked about when they ran this.
948
+ console.error(`[ERROR] orchestrate: ${error.message}`);
949
+ return finishRuntime(1);
950
+ }
951
+ }
952
+
953
+ module.exports = { WorkflowOrchestrator, RUN_STATES, GATE_DECISIONS, STAGE_OUTCOMES, handleOrchestrateCommand };