@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,724 @@
1
+ 'use strict';
2
+ // backup.js — writing, listing, restoring and pruning backups. Backup id `<op>_YYYY-MM-DD_HH-MM-SS`;
3
+ // each backup dir carries its own `.manifest.json` (id/operation/timestamp/source_path/
4
+ // source_commit/type/tools_affected), distinct from the top-level install manifest in manifest.js.
5
+ //
6
+ // A backup (createFileBackup, format 2) is a per-file copy of exactly the files a run changes,
7
+ // `files/<rel>` for files under the scope root and `external/<k>/<name>` for files outside it, with
8
+ // a manifest that records for each file whether it existed. It is assembled in a dot-named temp
9
+ // directory and renamed into place, so a reader sees either no backup or a complete one.
10
+ //
11
+ // Backups from DoFlow 1.19 stay readable and restorable: a full backup is a tar.gz per tool, a
12
+ // partial backup a plain copy tree per tool. Nothing writes those layouts any more.
13
+ //
14
+ // TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): authored as `.ts`, run directly by Node
15
+ // native type-stripping — no build step, no runtime dependency. Body stays CommonJS so a `.js`
16
+ // caller's `require('../install/backup.js')` keeps working while the tree is still `.js`. The
17
+ // `./paths` require already carries `.ts` (paths migrated first); `../helper/git.js` is `.ts` too.
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+ const crypto = require('node:crypto');
22
+ const { execFileSync } = require('node:child_process');
23
+ const { sourceCommit: gitSourceCommit } = require('../helper/git.js');
24
+ const {
25
+ legacyBackupReadRoot, isLegacyBackupReadRoot, scopeRootFromCanonicalBackupRoot,
26
+ } = require('./paths.js');
27
+ const { validateLedger } = require('../state/index.js');
28
+
29
+ /** Where a listed/restorable backup was found. 'current' = the canonical `.doflow/backups` root the
30
+ * installer writes to; 'legacy' = the pre-7de6d5f `.claude/backups` root, readable only. Callers
31
+ * get this on every row so a legacy restore point is never presented as a current one. */
32
+ const BACKUP_ORIGIN_CURRENT = 'current';
33
+ const BACKUP_ORIGIN_LEGACY = 'legacy';
34
+
35
+ /** The roots a READ (list/restore) consults for a scope, canonical first, legacy second.
36
+ * Order is the precedence rule too: an id present in both resolves to the canonical copy. */
37
+ function backupReadRoots(backupRoot ) {
38
+ const roots = [{ root: path.resolve(backupRoot), origin: BACKUP_ORIGIN_CURRENT }];
39
+ const scopeRoot = scopeRootFromCanonicalBackupRoot(backupRoot);
40
+ if (scopeRoot) {
41
+ const legacy = legacyBackupReadRoot({ scopeRoot });
42
+ if (legacy !== roots[0].root) roots.push({ root: legacy, origin: BACKUP_ORIGIN_LEGACY });
43
+ }
44
+ return roots;
45
+ }
46
+
47
+ /** Writes and deletes are canonical-only. The legacy root holds restore points DoFlow no longer
48
+ * manages and may be a user's sole recovery material, so a mutating call aimed at it is a bug to
49
+ * surface, not a path to follow. */
50
+ function assertMutableBackupRoot(backupRoot , operation ) {
51
+ if (isLegacyBackupReadRoot(backupRoot)) {
52
+ throw new Error(`Refusing to ${operation} in the legacy backup root (read-only): ${backupRoot}`);
53
+ }
54
+ }
55
+
56
+ function pad2(n ) { return String(n).padStart(2, '0'); }
57
+
58
+ /** `<op>_YYYY-MM-DD_HH-MM-SS`, using a caller-supplied Date (never `new Date()` internally — keeps this testable/deterministic). */
59
+ function backupId(op , date ) {
60
+ const y = date.getFullYear();
61
+ const mo = pad2(date.getMonth() + 1);
62
+ const d = pad2(date.getDate());
63
+ const h = pad2(date.getHours());
64
+ const mi = pad2(date.getMinutes());
65
+ const s = pad2(date.getSeconds());
66
+ return `${op}_${y}-${mo}-${d}_${h}-${mi}-${s}`;
67
+ }
68
+
69
+ const MANIFEST_FILE = '.manifest.json';
70
+ const OWNER_FILE = '.owner.json';
71
+ const TEMP_PREFIX = '.tmp-';
72
+ const STALE_TEMP_MS = 24 * 60 * 60 * 1000;
73
+ const MAX_ID_ATTEMPTS = 1000;
74
+ const RENAME_COLLISION_CODES = new Set(['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES']);
75
+
76
+ /** How many backups install and update keep per scope when `--prune` is not given. */
77
+ const DEFAULT_BACKUP_RETENTION = 3;
78
+
79
+ /** A backup could not be written completely. The caller stops before changing anything. */
80
+ class BackupError extends Error {
81
+
82
+ constructor(message ) {
83
+ super(message);
84
+ this.name = 'BackupError';
85
+ this.code = 'backup-failed';
86
+ }
87
+ }
88
+
89
+ /** POSIX path of `p` relative to `scopeRoot`, or null when `p` is not strictly inside it. */
90
+ function scopeRelative(scopeRoot , p ) {
91
+ const rel = path.relative(scopeRoot, p);
92
+ if (!rel || path.isAbsolute(rel) || rel.split(path.sep)[0] === '..') return null;
93
+ return rel.split(path.sep).join('/');
94
+ }
95
+
96
+ /** Manifest JSON: two-space indentation, except one compact line per `files` and `excluded` element. */
97
+ function serializeManifest(manifest ) {
98
+ const body = Object.entries(manifest).map(([key, value]) => {
99
+ if ((key === 'files' || key === 'excluded') && Array.isArray(value) && value.length > 0) {
100
+ return ` ${JSON.stringify(key)}: [\n${value.map((v) => ` ${JSON.stringify(v)}`).join(',\n')}\n ]`;
101
+ }
102
+ return ` ${JSON.stringify(key)}: ${JSON.stringify(value, null, 2).replace(/\n/g, '\n ')}`;
103
+ });
104
+ return `{\n${body.join(',\n')}\n}\n`;
105
+ }
106
+
107
+ function errorCode(err ) { return err.code || err.message; }
108
+
109
+ /**
110
+ * Copy the files of a backup set into `<backupRoot>/<id>/` (format 2). Returns null for an empty
111
+ * set, without touching the disk. Every failure removes the temp directory and throws BackupError,
112
+ * so a backup either exists complete, manifest included, or does not exist at all.
113
+ */
114
+ function createFileBackup({
115
+ operation, set, backupRoot, repoRoot, sourceCommit, version, date, restores = null,
116
+ fsImpl = fs, randomHex = () => crypto.randomBytes(4).toString('hex'),
117
+ } ) {
118
+ assertMutableBackupRoot(backupRoot, 'create a backup');
119
+ if (set.entries.length === 0) return null;
120
+
121
+ const base = backupId(operation, date);
122
+ const tmpDir = path.join(backupRoot, `${TEMP_PREFIX}${base}-${process.pid}-${randomHex()}`);
123
+ let tmpCreated = false;
124
+ const writeFailure = (err ) => new BackupError(`could not write ${backupRoot}: ${errorCode(err)}`);
125
+
126
+ try {
127
+ try {
128
+ sweepStaleTemps(backupRoot, { fsImpl });
129
+ fsImpl.mkdirSync(backupRoot, { recursive: true });
130
+ fsImpl.mkdirSync(tmpDir);
131
+ tmpCreated = true;
132
+ const owner = { pid: process.pid, hostname: os.hostname(), startedAt: date.toISOString() };
133
+ fsImpl.writeFileSync(path.join(tmpDir, OWNER_FILE), `${JSON.stringify(owner)}\n`, { flag: 'wx' });
134
+ } catch (err) { throw writeFailure(err); }
135
+
136
+ const files = [];
137
+ const summary = { files: 0, existed: 0, absent: 0, bytes: 0 };
138
+ let external = 0;
139
+ for (const entry of set.entries) {
140
+ const record = entry.rel !== null ? { path: entry.rel } : { path: entry.path, outside: true };
141
+ let stat = null;
142
+ try {
143
+ stat = fsImpl.statSync(entry.path);
144
+ } catch (err ) {
145
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
146
+ throw new BackupError(`could not read ${entry.path}: ${errorCode(err)}`);
147
+ }
148
+ }
149
+ summary.files += 1;
150
+ if (!stat) {
151
+ record.existed = false;
152
+ summary.absent += 1;
153
+ } else if (stat.isFile()) {
154
+ const stored = entry.rel !== null
155
+ ? `files/${entry.rel}`
156
+ : `external/${external++}/${path.basename(entry.path)}`;
157
+ const dest = path.join(tmpDir, ...stored.split('/'));
158
+ try {
159
+ fsImpl.mkdirSync(path.dirname(dest), { recursive: true });
160
+ fsImpl.copyFileSync(entry.path, dest);
161
+ } catch (err) {
162
+ throw new BackupError(`could not copy ${entry.path}: ${errorCode(err)}`);
163
+ }
164
+ Object.assign(record, { existed: true, size: stat.size, mode: stat.mode & 0o7777, stored });
165
+ summary.existed += 1;
166
+ summary.bytes += stat.size;
167
+ } else {
168
+ Object.assign(record, { existed: true, kind: 'other' });
169
+ summary.existed += 1;
170
+ }
171
+ record.harnesses = entry.harnesses;
172
+ files.push(record);
173
+ }
174
+
175
+ const manifest = {
176
+ id: base,
177
+ operation,
178
+ timestamp: date.toISOString(),
179
+ source_path: repoRoot,
180
+ source_commit: sourceCommit ?? gitSourceCommit(repoRoot),
181
+ type: 'files',
182
+ tools_affected: [...new Set(set.entries.flatMap((e ) => e.harnesses))].sort(),
183
+ format: 2,
184
+ doflow_version: version,
185
+ scope: set.scope,
186
+ scope_root: set.scopeRoot,
187
+ restores,
188
+ summary,
189
+ excluded: set.excluded.map((x ) => {
190
+ const rel = scopeRelative(set.scopeRoot, x.path);
191
+ return rel !== null ? { path: rel, reason: x.reason } : { path: x.path, outside: true, reason: x.reason };
192
+ }),
193
+ files,
194
+ };
195
+ const manifestPath = path.join(tmpDir, MANIFEST_FILE);
196
+ try {
197
+ fsImpl.unlinkSync(path.join(tmpDir, OWNER_FILE));
198
+ fsImpl.writeFileSync(manifestPath, serializeManifest(manifest), { flag: 'wx' });
199
+ } catch (err) { throw writeFailure(err); }
200
+
201
+ // Two runs in the same second share a base id; the loser of the rename takes the next suffix.
202
+ for (let n = 1; n <= MAX_ID_ATTEMPTS; n += 1) {
203
+ const candidate = n === 1 ? base : `${base}-${n}`;
204
+ const dir = path.join(backupRoot, candidate);
205
+ if (fsImpl.existsSync(dir)) continue;
206
+ try {
207
+ if (manifest.id !== candidate) {
208
+ manifest.id = candidate;
209
+ fsImpl.writeFileSync(manifestPath, serializeManifest(manifest));
210
+ }
211
+ fsImpl.renameSync(tmpDir, dir);
212
+ } catch (err ) {
213
+ if (RENAME_COLLISION_CODES.has(err.code) && fsImpl.existsSync(dir)) continue;
214
+ throw writeFailure(err);
215
+ }
216
+ tmpCreated = false;
217
+ return { id: candidate, dir, ...summary };
218
+ }
219
+ throw new BackupError(`no free backup id after ${MAX_ID_ATTEMPTS} attempts`);
220
+ } finally {
221
+ if (tmpCreated) {
222
+ try { fsImpl.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
223
+ }
224
+ }
225
+ }
226
+
227
+ function defaultIsAlive(pid ) {
228
+ if (!Number.isInteger(pid) || pid <= 0) return false;
229
+ try {
230
+ process.kill(pid, 0);
231
+ return true;
232
+ } catch (err ) {
233
+ return err.code === 'EPERM';
234
+ }
235
+ }
236
+
237
+ function isStaleTemp(dir , { now, fsImpl, isAlive, hostname } ) {
238
+ let owner = null;
239
+ try { owner = JSON.parse(fsImpl.readFileSync(path.join(dir, OWNER_FILE), 'utf8')); } catch { /* no owner */ }
240
+ if (!owner || typeof owner !== 'object') {
241
+ try { return now - fsImpl.lstatSync(dir).mtimeMs > STALE_TEMP_MS; } catch { return false; }
242
+ }
243
+ if (owner.hostname === hostname && !isAlive(owner.pid)) return true;
244
+ return now - Date.parse(owner.startedAt) > STALE_TEMP_MS;
245
+ }
246
+
247
+ /**
248
+ * Remove `.tmp-*` directories left in `backupRoot` by runs that died: the owner process is gone on
249
+ * this host, or the temp is older than 24 hours. Returns how many were removed.
250
+ */
251
+ function sweepStaleTemps(backupRoot , { now = Date.now(), fsImpl = fs, isAlive = defaultIsAlive, hostname = os.hostname() } = {}) {
252
+ assertMutableBackupRoot(backupRoot, 'clean up backups');
253
+ if (!fsImpl.existsSync(backupRoot)) return 0;
254
+ let removed = 0;
255
+ for (const e of fsImpl.readdirSync(backupRoot, { withFileTypes: true })) {
256
+ if (!e.isDirectory() || !e.name.startsWith(TEMP_PREFIX)) continue;
257
+ const dir = path.join(backupRoot, e.name);
258
+ if (!isStaleTemp(dir, { now, fsImpl, isAlive, hostname })) continue;
259
+ try {
260
+ fsImpl.rmSync(dir, { recursive: true, force: true });
261
+ removed += 1;
262
+ } catch { /* another run may hold or have removed it */ }
263
+ }
264
+ return removed;
265
+ }
266
+
267
+ /**
268
+ * Classify one backup directory by its manifest: 'format-2', 'newer' (a format this version does
269
+ * not know), 'full' or 'partial' (1.19, typed the way restoreBackup reads them), or 'incomplete'
270
+ * (no manifest, or one that does not parse). `manifest` is the parsed object, or null.
271
+ */
272
+ function classifyBackupDir(dir , { fsImpl = fs } = {}) {
273
+ let manifest = null;
274
+ try { manifest = JSON.parse(fsImpl.readFileSync(path.join(dir, MANIFEST_FILE), 'utf8')); } catch { /* incomplete */ }
275
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return { kind: 'incomplete', manifest: null };
276
+ const { format } = manifest;
277
+ if (format === 2) return { kind: 'format-2', manifest };
278
+ if (Number.isInteger(format) && format > 2) return { kind: 'newer', manifest };
279
+ if (format !== undefined && format !== null) return { kind: 'incomplete', manifest: null };
280
+ return { kind: (manifest.type || 'full') === 'full' ? 'full' : 'partial', manifest };
281
+ }
282
+
283
+ const ID_TIME = /^.+?_(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})(?:-(\d+))?$/;
284
+
285
+ /**
286
+ * Ordering key of a backup directory: the manifest timestamp, else the time in its id (local
287
+ * time, as backupId writes it), else its mtime. Retention and listing share it.
288
+ */
289
+ function sortKey(dir , { fsImpl = fs, manifest } = {}) {
290
+ const id = path.basename(dir);
291
+ const m = manifest === undefined ? classifyBackupDir(dir, { fsImpl }).manifest : manifest;
292
+ const match = ID_TIME.exec(id);
293
+ const suffix = match && match[7] ? Number(match[7]) : 1;
294
+ let time = m ? Date.parse(m.timestamp) : NaN;
295
+ if (!Number.isFinite(time) && match) {
296
+ const [, y, mo, d, h, mi, s] = match.map(Number);
297
+ time = new Date(y, mo - 1, d, h, mi, s).getTime();
298
+ }
299
+ if (!Number.isFinite(time)) {
300
+ try { time = fsImpl.lstatSync(dir).mtimeMs; } catch { time = 0; }
301
+ }
302
+ return { id, time, suffix };
303
+ }
304
+
305
+ /** Newest first; ties go to the larger `-n` suffix, then the id in descending order. */
306
+ function compareSortKeys(a , b ) {
307
+ if (a.time !== b.time) return b.time - a.time;
308
+ if (a.suffix !== b.suffix) return b.suffix - a.suffix;
309
+ if (a.id === b.id) return 0;
310
+ return a.id < b.id ? 1 : -1;
311
+ }
312
+
313
+ /** Bytes of the regular files under `dir`, never following a link; null when any read fails. */
314
+ function backupSize(dir , { fsImpl = fs } = {}) {
315
+ try {
316
+ let total = 0;
317
+ const pending = [dir];
318
+ while (pending.length > 0) {
319
+ const current = pending.pop();
320
+ for (const name of fsImpl.readdirSync(current)) {
321
+ const p = path.join(current, name);
322
+ const st = fsImpl.lstatSync(p);
323
+ if (st.isDirectory()) pending.push(p);
324
+ else if (st.isFile()) total += st.size;
325
+ }
326
+ }
327
+ return total;
328
+ } catch {
329
+ return null;
330
+ }
331
+ }
332
+
333
+ const BYTE_UNITS = ['KiB', 'MiB', 'GiB', 'TiB'];
334
+
335
+ /** `?` for an unknown size, `<n> B` below 1 KiB, otherwise one decimal in 1024-based units. */
336
+ function formatBytes(n ) {
337
+ if (n === null || n === undefined) return '?';
338
+ if (n < 1024) return `${n} B`;
339
+ let value = n / 1024;
340
+ let unit = 0;
341
+ while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
342
+ value /= 1024;
343
+ unit += 1;
344
+ }
345
+ return `${value.toFixed(1)} ${BYTE_UNITS[unit]}`;
346
+ }
347
+
348
+ /** Security guard: a backup id is a directory *name*, never a path — reject traversal/absolute ids. */
349
+ function assertSafeBackupId(bid ) {
350
+ if (!bid || bid.includes('/') || bid.includes('\\') || bid === '..' || bid.includes('..')) {
351
+ throw new Error(`Invalid backup id: '${bid}'`);
352
+ }
353
+ }
354
+
355
+ /** Restore a 1.19 backup id into the given tool dirs. dirs = {tool: absDstDir}. */
356
+ function restoreBackup({ bid, backupRoot, dirs, dryRun = false } ) {
357
+ assertSafeBackupId(bid);
358
+ // An id the user picked from `list-backups` may live in either root, so resolve it the same way
359
+ // the listing found it. Restoring reads the backup and writes only into the tool dirs, so a
360
+ // legacy source is safe; nothing here mutates the directory it was read from.
361
+ const found = backupReadRoots(backupRoot).find((r) => fs.existsSync(path.join(r.root, bid)));
362
+ if (!found) {
363
+ throw new Error(`Backup not found: ${bid}`);
364
+ }
365
+ const bkDir = path.join(found.root, bid);
366
+
367
+ let type = 'full';
368
+ let format;
369
+ const manifestPath = path.join(bkDir, '.manifest.json');
370
+ if (fs.existsSync(manifestPath)) {
371
+ try {
372
+ const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
373
+ type = m.type || 'full';
374
+ format = m.format;
375
+ } catch { /* fall back to 'full' */ }
376
+ }
377
+ // A format-2 backup holds no tool archive or tool copy, so this path would restore nothing.
378
+ if (format === 2) throw new Error(`Backup ${bid} is format 2; restore it with doflow rollback`);
379
+
380
+ for (const tool of Object.keys(dirs)) {
381
+ const dstDir = dirs[tool];
382
+ if (type === 'full') {
383
+ const tarPath = path.join(bkDir, `${tool}.tar.gz`);
384
+ if (!fs.existsSync(tarPath)) continue;
385
+ if (dryRun) continue;
386
+ fs.mkdirSync(dstDir, { recursive: true });
387
+ execFileSync('tar', ['-xzf', tarPath, '-C', dstDir], { stdio: ['ignore', 'ignore', 'pipe'] });
388
+ } else {
389
+ const partialDir = path.join(bkDir, tool);
390
+ if (!fs.existsSync(partialDir)) continue;
391
+ if (dryRun) continue;
392
+ fs.mkdirSync(dstDir, { recursive: true });
393
+ fs.cpSync(partialDir, dstDir, { recursive: true, force: true });
394
+ }
395
+ }
396
+ }
397
+
398
+ /** Raw member lines of a tar.gz archive. A whole-home archive can list far more than the default
399
+ * 1 MiB of output, so the buffer is unbounded. */
400
+ function defaultListTar(archive ) {
401
+ return execFileSync('tar', ['-tzf', archive], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: Infinity })
402
+ .split(/\r?\n/);
403
+ }
404
+
405
+ /** File members of an archive listing, relative to the archive root, without any `..` member. */
406
+ function archiveMembers(lines ) {
407
+ return lines
408
+ .map((line) => line.replace(/^\.\//, ''))
409
+ .filter((m) => m && m !== '.' && !m.endsWith('/') && !m.split('/').includes('..'));
410
+ }
411
+
412
+ /** Paths, relative to `root`, of the regular files under it; links are not followed. */
413
+ function regularFilesUnder(root , fsImpl ) {
414
+ const files = [];
415
+ const pending = [''];
416
+ while (pending.length > 0) {
417
+ const rel = pending.pop() ;
418
+ for (const e of fsImpl.readdirSync(path.join(root, rel), { withFileTypes: true })) {
419
+ const child = path.join(rel, e.name);
420
+ if (e.isDirectory()) pending.push(child);
421
+ else if (e.isFile()) files.push(child);
422
+ }
423
+ }
424
+ return files.sort();
425
+ }
426
+
427
+ /** Why a format-2 entry cannot be restored, or null when it can. */
428
+ function refusalOf(entry , target , { scope, scopeRoot, bkDir, fsImpl } ) {
429
+ const raw = entry.path;
430
+ if (entry.outside === true) {
431
+ if (!path.isAbsolute(raw)) return 'unsafe path';
432
+ if (scope !== 'global') return 'outside the project root';
433
+ } else if (path.isAbsolute(raw) || raw.split(/[\\/]/).includes('..') || scopeRelative(scopeRoot, target) === null) {
434
+ return 'unsafe path';
435
+ }
436
+ if (entry.kind === 'other' || typeof entry.stored !== 'string') return 'not a regular file when backed up';
437
+ const stored = path.resolve(bkDir, entry.stored);
438
+ if (scopeRelative(bkDir, stored) === null) return 'content missing from backup';
439
+ try {
440
+ if (!fsImpl.lstatSync(stored).isFile()) return 'content missing from backup';
441
+ } catch {
442
+ return 'content missing from backup';
443
+ }
444
+ return null;
445
+ }
446
+
447
+ /**
448
+ * Work out what restoring backup `bid` writes, before anything is written. A format-2 backup
449
+ * restores per file: entries recorded absent are listed and left alone, unsafe entries are refused,
450
+ * and with `explicitTargets` only entries of the named harnesses count. A 1.19 backup restores per
451
+ * tool through restoreBackup; its snapshot lists the files that restore will overwrite.
452
+ * Reads only.
453
+ */
454
+ function planRestore({ bid, backupRoot, scope, scopeRoot, targets, explicitTargets, dirs, ledgerFile = null, fsImpl = fs, listTar = defaultListTar } ) {
455
+ assertSafeBackupId(bid);
456
+ const found = backupReadRoots(backupRoot).find((r) => fsImpl.existsSync(path.join(r.root, bid)));
457
+ if (!found) throw new Error(`Backup not found: ${bid}`);
458
+ const bkDir = path.join(found.root, bid);
459
+ const { kind, manifest } = classifyBackupDir(bkDir, { fsImpl });
460
+ if (kind === 'newer') {
461
+ throw new Error(`Backup ${bid} was made by a newer DoFlow (format ${manifest.format}); this version cannot restore it`);
462
+ }
463
+
464
+ const plan = {
465
+ bid,
466
+ origin: found.origin,
467
+ bkDir,
468
+ format: kind === 'format-2' ? 2 : 1,
469
+ type: { 'format-2': 'files', partial: 'partial' }[kind] || 'full',
470
+ restore: [],
471
+ absent: [],
472
+ refused: [],
473
+ untargeted: 0,
474
+ snapshot: [],
475
+ v1: null,
476
+ };
477
+ const wanted = new Set(targets);
478
+
479
+ if (plan.format === 2) {
480
+ const ledger = typeof ledgerFile === 'string' ? path.resolve(ledgerFile) : null;
481
+ for (const entry of Array.isArray(manifest.files) ? manifest.files : []) {
482
+ const harnesses = Array.isArray(entry.harnesses) ? entry.harnesses : [];
483
+ // The ownership ledger restores per row (executeRestore), only the rows of files this restore
484
+ // writes, so --target narrows it with the files and never skips it.
485
+ const isLedger = ledger !== null && entry.outside !== true && typeof entry.path === 'string' && entry.path !== ''
486
+ && path.resolve(scopeRoot, entry.path) === ledger;
487
+ if (!isLedger && explicitTargets && !harnesses.some((h ) => wanted.has(h))) {
488
+ plan.untargeted += 1;
489
+ continue;
490
+ }
491
+ if (typeof entry.path !== 'string' || entry.path === '') {
492
+ plan.refused.push({ path: String(entry.path), reason: 'unsafe path' });
493
+ continue;
494
+ }
495
+ // Relative entries resolve against today's scope root, so a moved home still restores.
496
+ const target = entry.outside === true ? path.resolve(entry.path) : path.resolve(scopeRoot, entry.path);
497
+ if (entry.existed === false) {
498
+ plan.absent.push(target);
499
+ continue;
500
+ }
501
+ const reason = refusalOf(entry, target, { scope, scopeRoot, bkDir, fsImpl });
502
+ if (reason) {
503
+ plan.refused.push({ path: reason === 'unsafe path' ? entry.path : target, reason });
504
+ continue;
505
+ }
506
+ plan.restore.push({ path: target, stored: path.resolve(bkDir, entry.stored), mode: entry.mode, size: entry.size, harnesses, ...(isLedger ? { ledger: true } : {}) });
507
+ }
508
+ plan.snapshot = plan.restore.map((item ) => ({ path: item.path, harnesses: item.harnesses }));
509
+ return plan;
510
+ }
511
+
512
+ const tools = [];
513
+ for (const tool of wanted ) {
514
+ const dstDir = dirs[tool];
515
+ if (!dstDir) continue;
516
+ if (plan.type === 'full') {
517
+ const archive = path.join(bkDir, `${tool}.tar.gz`);
518
+ if (!fsImpl.existsSync(archive)) continue;
519
+ tools.push({ tool, dstDir, source: `${tool}.tar.gz` });
520
+ for (const member of archiveMembers(listTar(archive))) plan.snapshot.push({ path: path.join(dstDir, member), harnesses: [tool] });
521
+ } else {
522
+ const copyDir = path.join(bkDir, tool);
523
+ if (!fsImpl.existsSync(copyDir)) continue;
524
+ tools.push({ tool, dstDir, source: `${tool}/` });
525
+ for (const rel of regularFilesUnder(copyDir, fsImpl)) plan.snapshot.push({ path: path.join(dstDir, rel), harnesses: [tool] });
526
+ }
527
+ }
528
+ plan.v1 = { tools };
529
+ return plan;
530
+ }
531
+
532
+ /**
533
+ * The ownership ledger a restore leaves: the backup's rows and tombstones for the files the restore
534
+ * wrote back, and the current ledger for everything else. Rows a later run wrote, rows of harnesses
535
+ * the backup does not hold and rows of MCP entries (a file holding only MCP entries is never backed
536
+ * up) stay as they are; restoring the whole old ledger would make DoFlow forget files and entries it
537
+ * still has on disk. With no current ledger the backup's is taken whole.
538
+ */
539
+ function mergeRestoredLedger(backup , current , restored ) {
540
+ if (!current) return backup;
541
+ const at = (target ) => typeof target === 'string' && restored.has(path.resolve(target));
542
+ return {
543
+ ...current,
544
+ resources: [...current.resources.filter((r ) => !at(r.target)), ...backup.resources.filter((r ) => at(r.target))],
545
+ tombstones: [
546
+ ...(current.tombstones ?? []).filter((t ) => !at(t.fromTarget)),
547
+ ...(backup.tombstones ?? []).filter((t ) => at(t.fromTarget)),
548
+ ],
549
+ };
550
+ }
551
+
552
+ /** The bytes a ledger restore item writes: the backup's ledger merged onto the current one. */
553
+ function restoredLedgerBytes(item , restored , fsImpl ) {
554
+ const backup = validateLedger(JSON.parse(fsImpl.readFileSync(item.stored, 'utf8')));
555
+ const current = fsImpl.existsSync(item.path) ? validateLedger(JSON.parse(fsImpl.readFileSync(item.path, 'utf8'))) : null;
556
+ return Buffer.from(`${JSON.stringify(validateLedger(mergeRestoredLedger(backup, current, restored)), null, 2)}\n`);
557
+ }
558
+
559
+ /**
560
+ * Carry out a plan from planRestore. Format 2 writes each restore item through a temp file renamed
561
+ * over the target (or straight through a symlinked target, which stays a link); a failed item is
562
+ * reported and the rest continue. The ownership ledger goes last and restores only the rows of the
563
+ * files written back before it (mergeRestoredLedger). It never deletes a file and never writes
564
+ * outside `plan.restore`. A 1.19 plan goes through restoreBackup unchanged.
565
+ */
566
+ function executeRestore(plan , { backupRoot, dryRun = false, fsImpl = fs } ) {
567
+ if (plan.format !== 2) {
568
+ const toolDirs = Object.fromEntries(plan.v1.tools.map((t ) => [t.tool, t.dstDir]));
569
+ restoreBackup({ bid: plan.bid, backupRoot, dirs: toolDirs, dryRun });
570
+ return { restored: plan.v1.tools.map((t ) => t.tool), failed: [], absent: [], refused: [], untargeted: 0, legacy: true };
571
+ }
572
+
573
+ const restored = [];
574
+ const failed = [];
575
+ const files = new Set ();
576
+ const items = [...plan.restore.filter((item ) => !item.ledger), ...plan.restore.filter((item ) => item.ledger)];
577
+ for (const item of items) {
578
+ if (dryRun) {
579
+ restored.push(item.path);
580
+ continue;
581
+ }
582
+ let temp = null;
583
+ try {
584
+ const dir = path.dirname(item.path);
585
+ fsImpl.mkdirSync(dir, { recursive: true });
586
+ const bytes = item.ledger ? restoredLedgerBytes(item, files, fsImpl) : fsImpl.readFileSync(item.stored);
587
+ let isLink = false;
588
+ try {
589
+ isLink = fsImpl.lstatSync(item.path).isSymbolicLink();
590
+ } catch (err ) {
591
+ if (err.code !== 'ENOENT') throw err;
592
+ }
593
+ if (isLink) {
594
+ fsImpl.writeFileSync(item.path, bytes);
595
+ } else {
596
+ temp = path.join(dir, `.${path.basename(item.path)}.doflow-restore-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
597
+ fsImpl.writeFileSync(temp, bytes, { flag: 'wx' });
598
+ fsImpl.chmodSync(temp, item.mode);
599
+ fsImpl.renameSync(temp, item.path);
600
+ temp = null;
601
+ }
602
+ restored.push(item.path);
603
+ if (!item.ledger) files.add(path.resolve(item.path));
604
+ } catch (err) {
605
+ if (temp) {
606
+ try { fsImpl.rmSync(temp, { force: true }); } catch { /* best effort */ }
607
+ }
608
+ failed.push({ path: item.path, reason: errorCode(err) });
609
+ }
610
+ }
611
+ return { restored, failed, absent: plan.absent, refused: plan.refused, untargeted: plan.untargeted, legacy: false };
612
+ }
613
+
614
+ /** One `{ row, key }` per backup dir under `root`, tagged with where it came from. Dot-named
615
+ * directories (temps of a run in progress) and symlinks are not backups. */
616
+ function readBackupRows(root , origin ) {
617
+ if (!fs.existsSync(root)) return [];
618
+ return fs.readdirSync(root, { withFileTypes: true })
619
+ .filter((e ) => e.isDirectory() && !e.name.startsWith('.'))
620
+ .map((e ) => {
621
+ const bkDir = path.join(root, e.name);
622
+ const { kind, manifest: m } = classifyBackupDir(bkDir);
623
+ const key = sortKey(bkDir, { manifest: m });
624
+ const bytes = backupSize(bkDir);
625
+ if (kind === 'incomplete') {
626
+ return { key, row: { id: e.name, operation: 'unknown', type: '?', timestamp: '-', origin, backupRoot: root, format: null, complete: false, bytes } };
627
+ }
628
+ const format = kind === 'format-2' || kind === 'newer' ? m.format : 1;
629
+ const type = { 'format-2': 'files', full: 'full' }[kind] || m.type || '?';
630
+ return { key, row: { id: m.id || e.name, operation: m.operation || 'unknown', type, timestamp: m.timestamp || '-', origin, backupRoot: root, format, complete: true, bytes } };
631
+ });
632
+ }
633
+
634
+ /**
635
+ * List all restore points visible to this scope, newest first, reading each `.manifest.json`.
636
+ * Both the canonical root and the legacy pre-7de6d5f root are consulted, so restore points written
637
+ * before lifecycle metadata moved under `.doflow` stay visible. Every row carries `origin`
638
+ * ('current' | 'legacy') and the absolute `backupRoot` it was found in — the two sets are tagged,
639
+ * never blended into an indistinguishable list. A duplicate id resolves to the canonical copy,
640
+ * matching restoreBackup's precedence. Rows also carry `format` (2, 1 for 1.19, null when the
641
+ * manifest is missing), `complete` and `bytes` (null when unreadable), and are ordered by the
642
+ * same key retention uses.
643
+ */
644
+ function listBackups(backupRoot ) {
645
+ const found = [];
646
+ const seen = new Set();
647
+ for (const { root, origin } of backupReadRoots(backupRoot)) {
648
+ for (const item of readBackupRows(root, origin)) {
649
+ if (seen.has(item.row.id)) continue;
650
+ seen.add(item.row.id);
651
+ found.push(item);
652
+ }
653
+ }
654
+
655
+ found.sort((a, b) => compareSortKeys(a.key, b.key));
656
+ return found.map((item) => item.row);
657
+ }
658
+
659
+ /**
660
+ * Keep the newest `keep` backups of the canonical root and remove the rest; `keep` 0 removes
661
+ * nothing. Every DoFlow backup counts, 1.19 and manifest-less ones included, ordered by
662
+ * sortKey; other directories are left alone. Ids in `protect` (the run's own backup) are always kept. A dry run removes and sweeps
663
+ * nothing; `reserve` makes room for a backup the real run would add, and `kept` then counts it.
664
+ * Never reaches the legacy `.claude/backups` root: those may be a user's only restore points.
665
+ */
666
+ function applyRetention({ backupRoot, keep, protect = [], reserve = 0, dryRun = false, now = Date.now(), fsImpl = fs } ) {
667
+ assertMutableBackupRoot(backupRoot, 'prune backups');
668
+ if (!Number.isInteger(keep) || keep < 0) throw new Error(`Backup retention must be a non-negative whole number, got ${keep}`);
669
+ const result = { keep, kept: 0, removed: [], wouldRemove: [], failed: [], sweptTemps: 0 };
670
+
671
+ // Only DoFlow backups count: a directory with a readable manifest, or one named like a backup id
672
+ // (a 1.19 or interrupted backup can lack a manifest). Anything else in the root is not ours to
673
+ // delete, and must not take a restore point's place.
674
+ const candidates = [];
675
+ const entries = fsImpl.existsSync(backupRoot) ? fsImpl.readdirSync(backupRoot, { withFileTypes: true }) : [];
676
+ for (const e of entries) {
677
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
678
+ const dir = path.join(backupRoot, e.name);
679
+ const { manifest } = classifyBackupDir(dir, { fsImpl });
680
+ if (!manifest && !ID_TIME.test(e.name)) continue;
681
+ candidates.push(sortKey(dir, { fsImpl, manifest }));
682
+ }
683
+ candidates.sort(compareSortKeys);
684
+ const protectedIds = new Set(protect);
685
+ const protectedCandidates = candidates.filter((c) => protectedIds.has(c.id));
686
+ const ordered = [...protectedCandidates, ...candidates.filter((c) => !protectedIds.has(c.id))];
687
+ const keepCount = keep === 0 ? ordered.length : Math.max(keep - reserve, protectedCandidates.length);
688
+ const removal = ordered.slice(keepCount).map((c) => c.id);
689
+
690
+ if (dryRun || keep === 0) {
691
+ if (dryRun) result.wouldRemove = removal;
692
+ result.kept = ordered.length - removal.length + (dryRun ? reserve : 0);
693
+ return result;
694
+ }
695
+
696
+ result.sweptTemps = sweepStaleTemps(backupRoot, { now, fsImpl });
697
+ for (const id of removal) {
698
+ const dir = path.join(backupRoot, id);
699
+ // Another run's retention pass may have removed it already.
700
+ if (!fsImpl.existsSync(dir)) continue;
701
+ try {
702
+ fsImpl.rmSync(dir, { recursive: true, force: true });
703
+ result.removed.push(id);
704
+ } catch (err ) {
705
+ result.failed.push({ id, error: err.message });
706
+ }
707
+ }
708
+ result.kept = ordered.filter((c) => fsImpl.existsSync(path.join(backupRoot, c.id))).length;
709
+ return result;
710
+ }
711
+
712
+ /** The parenthesised end of a retention line: the `--prune` value given, or the default. */
713
+ function retentionNote(prune , keep ) {
714
+ if (prune === null || prune === undefined) return `keeping the newest ${keep}; --prune N changes this`;
715
+ if (prune === 0) return '--prune 0 keeps every backup';
716
+ return `--prune ${keep}`;
717
+ }
718
+
719
+ module.exports = {
720
+ backupId, restoreBackup, listBackups, assertSafeBackupId, scopeRelative, retentionNote,
721
+ backupReadRoots, BACKUP_ORIGIN_CURRENT, BACKUP_ORIGIN_LEGACY,
722
+ BackupError, DEFAULT_BACKUP_RETENTION, createFileBackup, sweepStaleTemps, classifyBackupDir,
723
+ sortKey, backupSize, formatBytes, planRestore, executeRestore, mergeRestoredLedger, applyRetention,
724
+ };