@ktpartners/dgs-platform 2.6.2

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 (256) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +851 -0
  3. package/agents/dgs-codebase-cross-analyzer.md +183 -0
  4. package/agents/dgs-codebase-mapper.md +782 -0
  5. package/agents/dgs-codebase-synthesizer.md +156 -0
  6. package/agents/dgs-debugger.md +1256 -0
  7. package/agents/dgs-executor.md +550 -0
  8. package/agents/dgs-integration-checker.md +481 -0
  9. package/agents/dgs-nyquist-auditor.md +178 -0
  10. package/agents/dgs-phase-researcher.md +563 -0
  11. package/agents/dgs-phase-verifier.md +450 -0
  12. package/agents/dgs-plan-checker.md +708 -0
  13. package/agents/dgs-planner.md +1324 -0
  14. package/agents/dgs-project-researcher.md +631 -0
  15. package/agents/dgs-research-synthesizer.md +249 -0
  16. package/agents/dgs-roadmapper.md +652 -0
  17. package/agents/dgs-verifier.md +607 -0
  18. package/bin/install.js +2073 -0
  19. package/commands/dgs/add-doc.md +45 -0
  20. package/commands/dgs/add-idea.md +38 -0
  21. package/commands/dgs/add-phase.md +43 -0
  22. package/commands/dgs/add-repo.md +54 -0
  23. package/commands/dgs/add-tests.md +41 -0
  24. package/commands/dgs/add-todo.md +47 -0
  25. package/commands/dgs/approve-spec.md +38 -0
  26. package/commands/dgs/audit-milestone.md +36 -0
  27. package/commands/dgs/audit-phase.md +37 -0
  28. package/commands/dgs/cancel-job.md +23 -0
  29. package/commands/dgs/capture-principle.md +143 -0
  30. package/commands/dgs/check-todos.md +45 -0
  31. package/commands/dgs/cleanup.md +18 -0
  32. package/commands/dgs/complete-milestone.md +136 -0
  33. package/commands/dgs/complete-project.md +70 -0
  34. package/commands/dgs/consolidate-ideas.md +50 -0
  35. package/commands/dgs/create-milestone-job.md +37 -0
  36. package/commands/dgs/debug.md +164 -0
  37. package/commands/dgs/develop-idea.md +53 -0
  38. package/commands/dgs/discuss-idea.md +41 -0
  39. package/commands/dgs/discuss-phase.md +83 -0
  40. package/commands/dgs/execute-phase.md +41 -0
  41. package/commands/dgs/fast.md +38 -0
  42. package/commands/dgs/find-related-ideas.md +43 -0
  43. package/commands/dgs/health.md +28 -0
  44. package/commands/dgs/help.md +22 -0
  45. package/commands/dgs/import-spec.md +36 -0
  46. package/commands/dgs/init-product.md +28 -0
  47. package/commands/dgs/insert-phase.md +32 -0
  48. package/commands/dgs/join-discord.md +18 -0
  49. package/commands/dgs/list-docs.md +40 -0
  50. package/commands/dgs/list-ideas.md +42 -0
  51. package/commands/dgs/list-jobs.md +22 -0
  52. package/commands/dgs/list-phase-assumptions.md +46 -0
  53. package/commands/dgs/list-projects.md +57 -0
  54. package/commands/dgs/list-specs.md +40 -0
  55. package/commands/dgs/map-codebase.md +92 -0
  56. package/commands/dgs/new-milestone.md +44 -0
  57. package/commands/dgs/new-project.md +42 -0
  58. package/commands/dgs/node-repair.md +26 -0
  59. package/commands/dgs/overlap-check.md +20 -0
  60. package/commands/dgs/pause-work.md +38 -0
  61. package/commands/dgs/plan-milestone-gaps.md +34 -0
  62. package/commands/dgs/plan-phase.md +44 -0
  63. package/commands/dgs/progress.md +24 -0
  64. package/commands/dgs/quick.md +41 -0
  65. package/commands/dgs/reactivate-project.md +70 -0
  66. package/commands/dgs/reapply-patches.md +110 -0
  67. package/commands/dgs/refine-spec.md +38 -0
  68. package/commands/dgs/reject-idea.md +43 -0
  69. package/commands/dgs/remove-doc.md +44 -0
  70. package/commands/dgs/remove-phase.md +31 -0
  71. package/commands/dgs/remove-repo.md +69 -0
  72. package/commands/dgs/research-idea.md +43 -0
  73. package/commands/dgs/research-phase.md +189 -0
  74. package/commands/dgs/restore-idea.md +45 -0
  75. package/commands/dgs/resume-work.md +40 -0
  76. package/commands/dgs/rollback-job.md +24 -0
  77. package/commands/dgs/run-job.md +35 -0
  78. package/commands/dgs/search.md +40 -0
  79. package/commands/dgs/set-profile.md +34 -0
  80. package/commands/dgs/settings.md +38 -0
  81. package/commands/dgs/switch-project.md +58 -0
  82. package/commands/dgs/undo-consolidation.md +42 -0
  83. package/commands/dgs/update-idea.md +44 -0
  84. package/commands/dgs/update.md +37 -0
  85. package/commands/dgs/validate-phase.md +35 -0
  86. package/commands/dgs/verify-work.md +39 -0
  87. package/commands/dgs/write-spec.md +49 -0
  88. package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-01-SUMMARY.md +84 -0
  89. package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-02-SUMMARY.md +86 -0
  90. package/deliver-great-systems/.planning/phases/10-v1-to-v2-migration-flow/10-01-SUMMARY.md +85 -0
  91. package/deliver-great-systems/bin/dgs-tools.cjs +1444 -0
  92. package/deliver-great-systems/bin/lib/auto-test.cjs +1365 -0
  93. package/deliver-great-systems/bin/lib/commands.cjs +570 -0
  94. package/deliver-great-systems/bin/lib/config.cjs +417 -0
  95. package/deliver-great-systems/bin/lib/conflict-agent.cjs +1063 -0
  96. package/deliver-great-systems/bin/lib/conflict-agent.test.cjs +554 -0
  97. package/deliver-great-systems/bin/lib/context.cjs +929 -0
  98. package/deliver-great-systems/bin/lib/context.test.cjs +693 -0
  99. package/deliver-great-systems/bin/lib/core.cjs +744 -0
  100. package/deliver-great-systems/bin/lib/core.test.cjs +822 -0
  101. package/deliver-great-systems/bin/lib/docs.cjs +919 -0
  102. package/deliver-great-systems/bin/lib/docs.test.cjs +211 -0
  103. package/deliver-great-systems/bin/lib/execution.cjs +705 -0
  104. package/deliver-great-systems/bin/lib/execution.test.cjs +1472 -0
  105. package/deliver-great-systems/bin/lib/frontmatter.cjs +324 -0
  106. package/deliver-great-systems/bin/lib/ideas.cjs +1406 -0
  107. package/deliver-great-systems/bin/lib/ideas.test.cjs +1417 -0
  108. package/deliver-great-systems/bin/lib/identity.cjs +125 -0
  109. package/deliver-great-systems/bin/lib/init.cjs +1114 -0
  110. package/deliver-great-systems/bin/lib/init.test.cjs +1271 -0
  111. package/deliver-great-systems/bin/lib/jobs.cjs +2015 -0
  112. package/deliver-great-systems/bin/lib/jobs.test.cjs +2619 -0
  113. package/deliver-great-systems/bin/lib/merge-conflicts.cjs +654 -0
  114. package/deliver-great-systems/bin/lib/merge-conflicts.test.cjs +370 -0
  115. package/deliver-great-systems/bin/lib/migration.cjs +352 -0
  116. package/deliver-great-systems/bin/lib/migration.test.cjs +582 -0
  117. package/deliver-great-systems/bin/lib/milestone.cjs +243 -0
  118. package/deliver-great-systems/bin/lib/overlap.cjs +437 -0
  119. package/deliver-great-systems/bin/lib/overlap.test.cjs +747 -0
  120. package/deliver-great-systems/bin/lib/path-audit.test.cjs +384 -0
  121. package/deliver-great-systems/bin/lib/paths.cjs +144 -0
  122. package/deliver-great-systems/bin/lib/paths.test.cjs +486 -0
  123. package/deliver-great-systems/bin/lib/phase.cjs +910 -0
  124. package/deliver-great-systems/bin/lib/projects.cjs +691 -0
  125. package/deliver-great-systems/bin/lib/projects.test.cjs +871 -0
  126. package/deliver-great-systems/bin/lib/repos.cjs +1432 -0
  127. package/deliver-great-systems/bin/lib/repos.test.cjs +1882 -0
  128. package/deliver-great-systems/bin/lib/roadmap.cjs +305 -0
  129. package/deliver-great-systems/bin/lib/search.cjs +570 -0
  130. package/deliver-great-systems/bin/lib/specs.cjs +1303 -0
  131. package/deliver-great-systems/bin/lib/state.cjs +893 -0
  132. package/deliver-great-systems/bin/lib/template.cjs +228 -0
  133. package/deliver-great-systems/bin/lib/test-helpers.cjs +291 -0
  134. package/deliver-great-systems/bin/lib/verify.cjs +796 -0
  135. package/deliver-great-systems/references/checkpoints.md +776 -0
  136. package/deliver-great-systems/references/conflict-resolution.md +66 -0
  137. package/deliver-great-systems/references/context-tiers.md +166 -0
  138. package/deliver-great-systems/references/continuation-format.md +249 -0
  139. package/deliver-great-systems/references/decimal-phase-calculation.md +67 -0
  140. package/deliver-great-systems/references/git-integration.md +250 -0
  141. package/deliver-great-systems/references/git-planning-commit.md +40 -0
  142. package/deliver-great-systems/references/model-profile-resolution.md +36 -0
  143. package/deliver-great-systems/references/model-profiles.md +95 -0
  144. package/deliver-great-systems/references/phase-argument-parsing.md +61 -0
  145. package/deliver-great-systems/references/planning-config.md +224 -0
  146. package/deliver-great-systems/references/questioning.md +162 -0
  147. package/deliver-great-systems/references/spec-review-loop.md +177 -0
  148. package/deliver-great-systems/references/tdd.md +265 -0
  149. package/deliver-great-systems/references/ui-brand.md +160 -0
  150. package/deliver-great-systems/references/verification-patterns.md +612 -0
  151. package/deliver-great-systems/templates/DEBUG.md +166 -0
  152. package/deliver-great-systems/templates/UAT.md +251 -0
  153. package/deliver-great-systems/templates/VALIDATION.md +95 -0
  154. package/deliver-great-systems/templates/claude-md.md +74 -0
  155. package/deliver-great-systems/templates/codebase/architecture.md +257 -0
  156. package/deliver-great-systems/templates/codebase/concerns.md +312 -0
  157. package/deliver-great-systems/templates/codebase/conventions.md +309 -0
  158. package/deliver-great-systems/templates/codebase/integrations.md +282 -0
  159. package/deliver-great-systems/templates/codebase/stack.md +188 -0
  160. package/deliver-great-systems/templates/codebase/structure.md +287 -0
  161. package/deliver-great-systems/templates/codebase/testing.md +482 -0
  162. package/deliver-great-systems/templates/config.json +38 -0
  163. package/deliver-great-systems/templates/context.md +354 -0
  164. package/deliver-great-systems/templates/continue-here.md +80 -0
  165. package/deliver-great-systems/templates/debug-subagent-prompt.md +93 -0
  166. package/deliver-great-systems/templates/discovery.md +148 -0
  167. package/deliver-great-systems/templates/milestone-archive.md +125 -0
  168. package/deliver-great-systems/templates/milestone.md +117 -0
  169. package/deliver-great-systems/templates/phase-prompt.md +615 -0
  170. package/deliver-great-systems/templates/planner-subagent-prompt.md +119 -0
  171. package/deliver-great-systems/templates/project.md +186 -0
  172. package/deliver-great-systems/templates/requirements.md +233 -0
  173. package/deliver-great-systems/templates/research-project/ARCHITECTURE.md +206 -0
  174. package/deliver-great-systems/templates/research-project/FEATURES.md +149 -0
  175. package/deliver-great-systems/templates/research-project/PITFALLS.md +202 -0
  176. package/deliver-great-systems/templates/research-project/STACK.md +122 -0
  177. package/deliver-great-systems/templates/research-project/SUMMARY.md +172 -0
  178. package/deliver-great-systems/templates/research.md +554 -0
  179. package/deliver-great-systems/templates/retrospective.md +54 -0
  180. package/deliver-great-systems/templates/roadmap.md +204 -0
  181. package/deliver-great-systems/templates/state.md +178 -0
  182. package/deliver-great-systems/templates/summary-complex.md +59 -0
  183. package/deliver-great-systems/templates/summary-minimal.md +41 -0
  184. package/deliver-great-systems/templates/summary-standard.md +48 -0
  185. package/deliver-great-systems/templates/summary.md +253 -0
  186. package/deliver-great-systems/templates/user-setup.md +313 -0
  187. package/deliver-great-systems/templates/verification-report.md +324 -0
  188. package/deliver-great-systems/workflows/add-doc.md +151 -0
  189. package/deliver-great-systems/workflows/add-idea.md +96 -0
  190. package/deliver-great-systems/workflows/add-phase.md +120 -0
  191. package/deliver-great-systems/workflows/add-tests.md +359 -0
  192. package/deliver-great-systems/workflows/add-todo.md +162 -0
  193. package/deliver-great-systems/workflows/approve-spec.md +194 -0
  194. package/deliver-great-systems/workflows/audit-milestone.md +364 -0
  195. package/deliver-great-systems/workflows/audit-phase.md +462 -0
  196. package/deliver-great-systems/workflows/cancel-job.md +108 -0
  197. package/deliver-great-systems/workflows/check-todos.md +181 -0
  198. package/deliver-great-systems/workflows/cleanup.md +247 -0
  199. package/deliver-great-systems/workflows/codereview.md +526 -0
  200. package/deliver-great-systems/workflows/complete-milestone.md +1298 -0
  201. package/deliver-great-systems/workflows/consolidate-ideas.md +365 -0
  202. package/deliver-great-systems/workflows/create-milestone-job.md +177 -0
  203. package/deliver-great-systems/workflows/develop-idea.md +544 -0
  204. package/deliver-great-systems/workflows/diagnose-issues.md +231 -0
  205. package/deliver-great-systems/workflows/discovery-phase.md +301 -0
  206. package/deliver-great-systems/workflows/discuss-idea.md +263 -0
  207. package/deliver-great-systems/workflows/discuss-phase.md +733 -0
  208. package/deliver-great-systems/workflows/execute-phase.md +571 -0
  209. package/deliver-great-systems/workflows/execute-plan.md +592 -0
  210. package/deliver-great-systems/workflows/find-related-ideas.md +271 -0
  211. package/deliver-great-systems/workflows/health.md +173 -0
  212. package/deliver-great-systems/workflows/help.md +997 -0
  213. package/deliver-great-systems/workflows/import-spec.md +381 -0
  214. package/deliver-great-systems/workflows/init-product.md +767 -0
  215. package/deliver-great-systems/workflows/insert-phase.md +138 -0
  216. package/deliver-great-systems/workflows/list-docs.md +119 -0
  217. package/deliver-great-systems/workflows/list-ideas.md +154 -0
  218. package/deliver-great-systems/workflows/list-jobs.md +89 -0
  219. package/deliver-great-systems/workflows/list-phase-assumptions.md +192 -0
  220. package/deliver-great-systems/workflows/list-specs.md +101 -0
  221. package/deliver-great-systems/workflows/map-codebase.md +621 -0
  222. package/deliver-great-systems/workflows/new-milestone.md +591 -0
  223. package/deliver-great-systems/workflows/new-project.md +1113 -0
  224. package/deliver-great-systems/workflows/node-repair.md +94 -0
  225. package/deliver-great-systems/workflows/overlap-check.md +86 -0
  226. package/deliver-great-systems/workflows/pause-work.md +134 -0
  227. package/deliver-great-systems/workflows/plan-milestone-gaps.md +306 -0
  228. package/deliver-great-systems/workflows/plan-phase.md +698 -0
  229. package/deliver-great-systems/workflows/progress.md +386 -0
  230. package/deliver-great-systems/workflows/quick.md +845 -0
  231. package/deliver-great-systems/workflows/refine-spec.md +275 -0
  232. package/deliver-great-systems/workflows/reject-idea.md +109 -0
  233. package/deliver-great-systems/workflows/remove-doc.md +117 -0
  234. package/deliver-great-systems/workflows/remove-phase.md +163 -0
  235. package/deliver-great-systems/workflows/research-idea.md +325 -0
  236. package/deliver-great-systems/workflows/research-phase.md +81 -0
  237. package/deliver-great-systems/workflows/restore-idea.md +101 -0
  238. package/deliver-great-systems/workflows/resume-project.md +311 -0
  239. package/deliver-great-systems/workflows/rollback-job.md +130 -0
  240. package/deliver-great-systems/workflows/run-job.md +498 -0
  241. package/deliver-great-systems/workflows/search.md +130 -0
  242. package/deliver-great-systems/workflows/set-profile.md +83 -0
  243. package/deliver-great-systems/workflows/settings.md +470 -0
  244. package/deliver-great-systems/workflows/transition.md +563 -0
  245. package/deliver-great-systems/workflows/undo-consolidation.md +155 -0
  246. package/deliver-great-systems/workflows/update-idea.md +157 -0
  247. package/deliver-great-systems/workflows/update.md +242 -0
  248. package/deliver-great-systems/workflows/validate-phase.md +177 -0
  249. package/deliver-great-systems/workflows/verify-phase.md +253 -0
  250. package/deliver-great-systems/workflows/verify-work.md +671 -0
  251. package/deliver-great-systems/workflows/write-spec.md +450 -0
  252. package/hooks/dist/dgs-check-update.js +62 -0
  253. package/hooks/dist/dgs-context-monitor.js +141 -0
  254. package/hooks/dist/dgs-statusline.js +115 -0
  255. package/package.json +60 -0
  256. package/scripts/build-hooks.js +43 -0
@@ -0,0 +1,2015 @@
1
+ /**
2
+ * Jobs — Job file parse, step update, file move, find, header update,
3
+ * step insertion, gap-fix sections, milestone job creation, and visibility/polish
4
+ *
5
+ * Provides the programmatic foundation for all job lifecycle operations.
6
+ * Parses markdown job files into structured JSON, updates individual step
7
+ * statuses, moves files between lifecycle directories, locates job files
8
+ * by version, updates header fields in-place, inserts new steps dynamically,
9
+ * manages gap-fix cycle sections (both milestone-level and phase-level),
10
+ * generates milestone job files from roadmap phase analysis, lists jobs,
11
+ * cancels jobs, runs health checks, previews dry runs, and generates
12
+ * job summaries.
13
+ *
14
+ * Exports: parseJobFile, updateJobStep, moveJobFile, findJobFile,
15
+ * updateJobHeader, insertJobSteps, buildGapFixSteps,
16
+ * buildPhaseGapFixSteps, insertGapFixSection,
17
+ * insertPhaseGapFixSection, updatePhaseFixCycleHeader,
18
+ * generateMilestoneSteps, buildJobFileContent, listJobs,
19
+ * cancelJob, recordStartShas, rollbackJob,
20
+ * healthCheck, dryRunPreview, generateJobSummary,
21
+ * cmdJobsParse, cmdJobsUpdateStep, cmdJobsMove,
22
+ * cmdJobsFindJob, cmdJobsUpdateHeader, cmdJobsInsertSteps,
23
+ * cmdJobsInsertGapFixSection, cmdJobsInsertPhaseGapFix,
24
+ * cmdJobsCreateMilestone, cmdJobsMilestonePreview,
25
+ * cmdJobsListJobs, cmdJobsCancelJob,
26
+ * cmdJobsRecordStartShas, cmdJobsRollback,
27
+ * cmdJobsHealthCheck, cmdJobsDryRun, cmdJobsGenerateSummary
28
+ */
29
+
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const { execSync } = require('child_process');
33
+ const { output, error, getProjectRoot } = require('./core.cjs');
34
+ const { getPlanningRoot } = require('./paths.cjs');
35
+ const { requireGitIdentity, formatAuthorString } = require('./identity.cjs');
36
+
37
+ // ─── Constants ──────────────────────────────────────────────────────────────
38
+
39
+ /** Known DGS command patterns for step validation */
40
+ const KNOWN_COMMANDS = new Set([
41
+ 'plan-phase',
42
+ 'execute-phase',
43
+ 'verify-work',
44
+ 'map-codebase',
45
+ 'audit-milestone',
46
+ 'audit-phase',
47
+ 'complete-milestone',
48
+ 'plan-milestone-gaps',
49
+ 'discuss-phase',
50
+ 'research-phase',
51
+ 'verify-phase',
52
+ ]);
53
+
54
+ /** Required header fields */
55
+ const REQUIRED_FIELDS = ['Version', 'Created', 'Status', 'Check'];
56
+
57
+ /** Maximum error message length on step lines */
58
+ const MAX_ERROR_LENGTH = 120;
59
+
60
+ // ─── v2-aware Path Resolution ────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Resolve the project root directory for milestone/roadmap operations.
64
+ * Uses getProjectRoot (v2-aware) with a fallback to getPlanningRoot (v1).
65
+ * Returns an absolute path in all cases.
66
+ *
67
+ * @param {string} cwd - Working directory
68
+ * @returns {string} Absolute path to project root
69
+ */
70
+ function resolveProjectRoot(cwd) {
71
+ try {
72
+ const projRoot = getProjectRoot(cwd);
73
+ return path.isAbsolute(projRoot) ? projRoot : path.join(cwd, projRoot);
74
+ } catch {
75
+ return getPlanningRoot(cwd);
76
+ }
77
+ }
78
+
79
+ /** Checkbox marker to status mapping */
80
+ const MARKER_TO_STATUS = {
81
+ 'x': 'completed',
82
+ '>': 'in-progress',
83
+ '!': 'failed',
84
+ ' ': 'pending',
85
+ };
86
+
87
+ /** Status to checkbox marker mapping */
88
+ const STATUS_TO_MARKER = {
89
+ 'completed': 'x',
90
+ 'in-progress': '>',
91
+ 'failed': '!',
92
+ 'pending': ' ',
93
+ };
94
+
95
+ // ─── Internal Helpers ───────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Parse header fields from job file content.
99
+ * @param {string} content - Raw file content
100
+ * @returns {Object} Parsed header fields { version, created, status, check }
101
+ * @throws {Error} If a required field is missing
102
+ */
103
+ function parseHeader(content) {
104
+ const fields = {};
105
+
106
+ const versionMatch = content.match(/^\*\*Version:\*\*\s*(.+)$/m);
107
+ if (versionMatch) fields.version = versionMatch[1].trim();
108
+
109
+ const createdMatch = content.match(/^\*\*Created:\*\*\s*(.+)$/m);
110
+ if (createdMatch) fields.created = createdMatch[1].trim();
111
+
112
+ const statusMatch = content.match(/^\*\*Status:\*\*\s*(.+)$/m);
113
+ if (statusMatch) fields.status = statusMatch[1].trim();
114
+
115
+ const checkMatch = content.match(/^\*\*Check:\*\*\s*(.+)$/m);
116
+ if (checkMatch) {
117
+ const val = checkMatch[1].trim().toLowerCase();
118
+ fields.check = val === 'true';
119
+ }
120
+
121
+ // Optional: Created_by field (not required for backward compat)
122
+ const createdByMatch = content.match(/^\*\*Created_by:\*\*\s*(.+)$/m);
123
+ if (createdByMatch) fields.created_by = createdByMatch[1].trim();
124
+
125
+ // Validate required fields
126
+ for (const field of REQUIRED_FIELDS) {
127
+ const key = field.toLowerCase();
128
+ if (fields[key] === undefined) {
129
+ throw new Error(`Missing required field: ${field}`);
130
+ }
131
+ }
132
+
133
+ return fields;
134
+ }
135
+
136
+ /**
137
+ * Parse a single step line into structured data.
138
+ * @param {string} line - The step line (e.g., `- [x] \`/dgs:plan-phase 41\` -- completed ...`)
139
+ * @param {number} index - Zero-based step index
140
+ * @returns {Object|null} Parsed step object or null if line is not a step
141
+ */
142
+ function parseStepLine(line, index) {
143
+ // Match: - [marker] `/dgs:command args` [— annotation]
144
+ const stepRegex = /^-\s*\[([x >!])\]\s*`\/dgs:([^\s`]+)(?:\s+([^`]*))?`(?:\s*\u2014\s*(.+))?$/;
145
+ const match = line.match(stepRegex);
146
+ if (!match) return null;
147
+
148
+ const marker = match[1];
149
+ const command = match[2];
150
+ const args = (match[3] || '').trim();
151
+ const annotation = match[4] || null;
152
+ const raw = `/dgs:${command}${args ? ' ' + args : ''}`;
153
+
154
+ const status = MARKER_TO_STATUS[marker] || 'pending';
155
+
156
+ let timestamp = null;
157
+ let stepError = null;
158
+
159
+ if (annotation) {
160
+ // Parse annotation: "completed|failed|started <ISO timestamp>[: error message]"
161
+ const annoRegex = /^(?:completed|failed|started)\s+(\d{4}-\d{2}-\d{2}T[\d:.]+Z)(?::\s*(.+))?$/;
162
+ const annoMatch = annotation.match(annoRegex);
163
+ if (annoMatch) {
164
+ timestamp = annoMatch[1];
165
+ stepError = annoMatch[2] || null;
166
+ }
167
+ }
168
+
169
+ const step = {
170
+ index,
171
+ status,
172
+ command,
173
+ args,
174
+ raw,
175
+ timestamp,
176
+ error: stepError,
177
+ };
178
+
179
+ // Add warning for unrecognized commands
180
+ if (!KNOWN_COMMANDS.has(command)) {
181
+ step.warning = `Unrecognized command: ${command}`;
182
+ }
183
+
184
+ return step;
185
+ }
186
+
187
+ /**
188
+ * Build a step line from structured data.
189
+ * @param {Object} step - Step object with command, args
190
+ * @param {string} status - New status
191
+ * @param {Object} [options] - { timestamp, error }
192
+ * @returns {string} Formatted step line
193
+ */
194
+ function buildStepLine(step, status, options) {
195
+ const opts = options || {};
196
+ const marker = STATUS_TO_MARKER[status] || ' ';
197
+ let line = `- [${marker}] \`/dgs:${step.command}${step.args ? ' ' + step.args : ''}\``;
198
+
199
+ if (status === 'completed' && opts.timestamp) {
200
+ line += ` \u2014 completed ${opts.timestamp}`;
201
+ } else if (status === 'in-progress' && opts.timestamp) {
202
+ line += ` \u2014 started ${opts.timestamp}`;
203
+ } else if (status === 'failed' && opts.timestamp) {
204
+ let errorMsg = opts.error || '';
205
+ if (errorMsg.length > MAX_ERROR_LENGTH) {
206
+ errorMsg = errorMsg.substring(0, MAX_ERROR_LENGTH) + '...';
207
+ }
208
+ line += ` \u2014 failed ${opts.timestamp}`;
209
+ if (errorMsg) {
210
+ line += `: ${errorMsg}`;
211
+ }
212
+ }
213
+
214
+ return line;
215
+ }
216
+
217
+ // ─── Exported Functions ─────────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Parse a job markdown file into structured JSON.
221
+ *
222
+ * @param {string} filePath - Absolute path to the job file
223
+ * @returns {Object} Structured job data with steps and computed fields
224
+ * @throws {Error} On missing file, missing required fields, or malformed content
225
+ */
226
+ function parseJobFile(filePath) {
227
+ if (!fs.existsSync(filePath)) {
228
+ throw new Error(`Job file not found: ${filePath}`);
229
+ }
230
+
231
+ const content = fs.readFileSync(filePath, 'utf-8');
232
+ const header = parseHeader(content);
233
+
234
+ // Parse optional StartShas header (defaults to null for backwards compat)
235
+ const startShasMatch = content.match(/^\*\*StartShas:\*\*\s*(.+)$/m);
236
+ let startShas = null;
237
+ if (startShasMatch) {
238
+ try {
239
+ startShas = JSON.parse(startShasMatch[1].trim());
240
+ } catch {
241
+ // Malformed JSON — treat as absent
242
+ }
243
+ }
244
+
245
+ // Parse optional GapFixCycle header (defaults to 0 for backwards compat)
246
+ const gapFixMatch = content.match(/^\*\*GapFixCycle:\*\*\s*(\d+)$/m);
247
+ const gapFixCycle = gapFixMatch ? parseInt(gapFixMatch[1], 10) : 0;
248
+
249
+ // Parse optional PhaseFixCycle header (format: "N:phase_number")
250
+ const phaseFixMatch = content.match(/^\*\*PhaseFixCycle:\*\*\s*(\d+):(\S+)$/m);
251
+ const phaseFixCycle = phaseFixMatch ? parseInt(phaseFixMatch[1], 10) : 0;
252
+ const phaseFixCyclePhase = phaseFixMatch ? phaseFixMatch[2] : null;
253
+
254
+ // Parse steps from lines
255
+ const lines = content.split('\n');
256
+ const steps = [];
257
+ let stepIndex = 0;
258
+
259
+ for (const line of lines) {
260
+ const trimmed = line.trim();
261
+ if (trimmed.startsWith('- [')) {
262
+ const step = parseStepLine(trimmed, stepIndex);
263
+ if (step) {
264
+ steps.push(step);
265
+ stepIndex++;
266
+ }
267
+ }
268
+ }
269
+
270
+ // Compute aggregate fields
271
+ const stepCount = steps.length;
272
+ const completedCount = steps.filter(s => s.status === 'completed').length;
273
+ const failedCount = steps.filter(s => s.status === 'failed').length;
274
+ const inProgressCount = steps.filter(s => s.status === 'in-progress').length;
275
+
276
+ // nextStepIndex: first pending step, or null if all done/empty
277
+ const nextPending = steps.find(s => s.status === 'pending');
278
+ const nextStepIndex = nextPending ? nextPending.index : null;
279
+
280
+ // Progress: percentage of completed steps (100 if no steps)
281
+ const progress = stepCount === 0 ? 100 : Math.round((completedCount / stepCount) * 100);
282
+
283
+ return {
284
+ version: header.version,
285
+ created: header.created,
286
+ created_by: header.created_by || null,
287
+ status: header.status,
288
+ check: header.check,
289
+ startShas,
290
+ gapFixCycle,
291
+ phaseFixCycle,
292
+ phaseFixCyclePhase,
293
+ steps,
294
+ stepCount,
295
+ completedCount,
296
+ failedCount,
297
+ inProgressCount,
298
+ nextStepIndex,
299
+ progress,
300
+ };
301
+ }
302
+
303
+ /**
304
+ * Update a single step's status in-place in the job file.
305
+ *
306
+ * @param {string} filePath - Absolute path to the job file
307
+ * @param {number} stepIndex - Zero-based index of the step to update
308
+ * @param {string} status - New status: 'completed', 'failed', 'in-progress', 'pending'
309
+ * @param {Object} [options] - { timestamp: string, error: string }
310
+ * @throws {Error} On out-of-range step index
311
+ */
312
+ function updateJobStep(filePath, stepIndex, status, options) {
313
+ const content = fs.readFileSync(filePath, 'utf-8');
314
+ const lines = content.split('\n');
315
+
316
+ // Find all step lines and their line indices
317
+ const stepLines = [];
318
+ for (let i = 0; i < lines.length; i++) {
319
+ const trimmed = lines[i].trim();
320
+ if (trimmed.startsWith('- [')) {
321
+ const step = parseStepLine(trimmed, stepLines.length);
322
+ if (step) {
323
+ stepLines.push({ lineIndex: i, step });
324
+ }
325
+ }
326
+ }
327
+
328
+ const stepCount = stepLines.length;
329
+
330
+ if (stepIndex < 0 || stepIndex >= stepCount) {
331
+ throw new Error(`Step index ${stepIndex} out of range (0-${stepCount - 1})`);
332
+ }
333
+
334
+ const target = stepLines[stepIndex];
335
+ const newLine = buildStepLine(target.step, status, options);
336
+ lines[target.lineIndex] = newLine;
337
+
338
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
339
+ }
340
+
341
+ /**
342
+ * Move a job file from its current location to a target directory.
343
+ *
344
+ * @param {string} filePath - Absolute path to the job file
345
+ * @param {string} targetDir - Absolute path to the target directory
346
+ * @returns {{ moved: boolean, from: string, to: string, warning?: string }}
347
+ */
348
+ function moveJobFile(filePath, targetDir) {
349
+ const fileName = path.basename(filePath);
350
+ const currentDir = path.dirname(filePath);
351
+ const destPath = path.join(targetDir, fileName);
352
+
353
+ // Check if file is already in the target directory
354
+ if (path.resolve(currentDir) === path.resolve(targetDir)) {
355
+ return {
356
+ moved: false,
357
+ from: filePath,
358
+ to: destPath,
359
+ warning: `File already in target directory: ${targetDir}`,
360
+ };
361
+ }
362
+
363
+ // Auto-create target directory
364
+ fs.mkdirSync(targetDir, { recursive: true });
365
+
366
+ // Move file
367
+ fs.renameSync(filePath, destPath);
368
+
369
+ return {
370
+ moved: true,
371
+ from: filePath,
372
+ to: destPath,
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Locate a job file by milestone version, checking directories in priority order.
378
+ *
379
+ * Search order: in-progress/ (resume case), pending/ (new job), completed/ (inspection).
380
+ *
381
+ * @param {string} cwd - Working directory
382
+ * @param {string} version - Milestone version (e.g., "v6.0" or "6.0")
383
+ * @returns {{ found: boolean, path?: string, directory?: string }}
384
+ */
385
+ function findJobFile(cwd, version) {
386
+ // Normalize version: ensure 'v' prefix
387
+ const normalized = version.startsWith('v') ? version : 'v' + version;
388
+ const fileName = `milestone-${normalized}.md`;
389
+ const jobsDir = path.join(getPlanningRoot(cwd), 'jobs');
390
+
391
+ // Search order: in-progress first (resume case), then pending, then completed (inspection)
392
+ for (const dir of ['in-progress', 'pending', 'completed']) {
393
+ const filePath = path.join(jobsDir, dir, fileName);
394
+ if (fs.existsSync(filePath)) {
395
+ return { found: true, path: filePath, directory: dir };
396
+ }
397
+ }
398
+
399
+ // Search project subdirectories: .planning/projects/*/jobs/
400
+ const projectsDir = path.join(getPlanningRoot(cwd), 'projects');
401
+ if (fs.existsSync(projectsDir)) {
402
+ const projects = fs.readdirSync(projectsDir).filter(d =>
403
+ fs.statSync(path.join(projectsDir, d)).isDirectory()
404
+ );
405
+ for (const project of projects) {
406
+ for (const dir of ['in-progress', 'pending', 'completed']) {
407
+ const filePath = path.join(projectsDir, project, 'jobs', dir, fileName);
408
+ if (fs.existsSync(filePath)) {
409
+ return { found: true, path: filePath, directory: dir };
410
+ }
411
+ }
412
+ }
413
+ }
414
+
415
+ return { found: false };
416
+ }
417
+
418
+ /**
419
+ * Update a header field value in-place in a job file.
420
+ *
421
+ * Finds the line matching `**{Field}:** ...` and replaces the value portion.
422
+ * Does NOT alter any step lines.
423
+ *
424
+ * @param {string} filePath - Absolute path to the job file
425
+ * @param {string} field - Header field name (e.g., "Status")
426
+ * @param {string} value - New value for the field
427
+ * @throws {Error} If the field is not found in the file
428
+ */
429
+ function updateJobHeader(filePath, field, value) {
430
+ const content = fs.readFileSync(filePath, 'utf-8');
431
+ const lines = content.split('\n');
432
+
433
+ const fieldPattern = new RegExp(`^\\*\\*${field}:\\*\\*\\s+.*$`);
434
+ let found = false;
435
+
436
+ for (let i = 0; i < lines.length; i++) {
437
+ if (fieldPattern.test(lines[i])) {
438
+ lines[i] = `**${field}:** ${value}`;
439
+ found = true;
440
+ break;
441
+ }
442
+ }
443
+
444
+ if (!found) {
445
+ throw new Error(`Field not found in job file: ${field}`);
446
+ }
447
+
448
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
449
+ }
450
+
451
+ /**
452
+ * Insert new step lines at a specific position in the job file.
453
+ *
454
+ * @param {string} filePath - Absolute path to the job file
455
+ * @param {number} afterStepIndex - Insert after this step index (-1 = insert before all steps)
456
+ * @param {Array<{ command: string, args: string }>} newSteps - Steps to insert
457
+ * @returns {{ inserted: boolean, count: number, startIndex: number }}
458
+ */
459
+ function insertJobSteps(filePath, afterStepIndex, newSteps) {
460
+ const content = fs.readFileSync(filePath, 'utf-8');
461
+ const lines = content.split('\n');
462
+
463
+ // Find all step lines and their line indices
464
+ const stepLines = [];
465
+ for (let i = 0; i < lines.length; i++) {
466
+ const trimmed = lines[i].trim();
467
+ if (trimmed.startsWith('- [')) {
468
+ const step = parseStepLine(trimmed, stepLines.length);
469
+ if (step) {
470
+ stepLines.push({ lineIndex: i, step });
471
+ }
472
+ }
473
+ }
474
+
475
+ // Build the new step lines using buildStepLine
476
+ const newLines = newSteps.map(s => buildStepLine(s, 'pending'));
477
+
478
+ // Determine insertion line index
479
+ let insertAtLine;
480
+ if (afterStepIndex === -1) {
481
+ // Insert before the first step
482
+ if (stepLines.length > 0) {
483
+ insertAtLine = stepLines[0].lineIndex;
484
+ } else {
485
+ // No steps exist; insert at end of file
486
+ insertAtLine = lines.length;
487
+ }
488
+ } else {
489
+ // Insert after the specified step's line
490
+ insertAtLine = stepLines[afterStepIndex].lineIndex + 1;
491
+ }
492
+
493
+ // Splice in the new lines
494
+ lines.splice(insertAtLine, 0, ...newLines);
495
+
496
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
497
+
498
+ return {
499
+ inserted: true,
500
+ count: newSteps.length,
501
+ startIndex: afterStepIndex + 1,
502
+ };
503
+ }
504
+
505
+ /**
506
+ * Build the step sequence for a gap-fix cycle.
507
+ *
508
+ * For each phase: plan-phase + execute-phase (no discuss-phase, no verify-work).
509
+ * Ends with audit-milestone for re-audit.
510
+ *
511
+ * @param {Array<{ number: string, name: string }>} newPhases - Gap-fix phases
512
+ * @param {string} version - Milestone version for re-audit step
513
+ * @returns {Array<{ command: string, args: string }>} Step sequence
514
+ */
515
+ function buildGapFixSteps(newPhases, version) {
516
+ const steps = [];
517
+ for (const phase of newPhases) {
518
+ steps.push({ command: 'plan-phase', args: `${phase.number} --non-interactive` });
519
+ steps.push({ command: 'execute-phase', args: `${phase.number} --non-interactive` });
520
+ }
521
+ steps.push({ command: 'audit-milestone', args: version });
522
+ return steps;
523
+ }
524
+
525
+ /**
526
+ * Build the step sequence for a phase-level gap-fix cycle.
527
+ *
528
+ * Simpler than buildGapFixSteps (milestone-level): no plan-phase, no
529
+ * audit-milestone. Just execute existing fix plans and re-verify.
530
+ *
531
+ * @param {string} phaseNumber - Phase number (e.g., "41")
532
+ * @returns {Array<{ command: string, args: string }>} Step sequence (2 steps)
533
+ */
534
+ function buildPhaseGapFixSteps(phaseNumber) {
535
+ return [
536
+ { command: 'execute-phase', args: `${phaseNumber} --gaps-only` },
537
+ { command: 'audit-phase', args: `${phaseNumber} --rerun-failed` },
538
+ ];
539
+ }
540
+
541
+ /**
542
+ * Insert a gap-fix section into a job file.
543
+ *
544
+ * Inserts a section marker line followed by gap-fix step lines after the
545
+ * specified step index. Updates the GapFixCycle header field.
546
+ *
547
+ * @param {string} filePath - Absolute path to the job file
548
+ * @param {number} afterStepIndex - Insert after this step index (typically the audit step)
549
+ * @param {number} cycleNumber - Gap-fix cycle number (1, 2, 3, ...)
550
+ * @param {Array<{ number: string, name: string }>} newPhases - Gap-fix phases
551
+ * @param {string} version - Milestone version for re-audit step
552
+ * @returns {{ inserted: boolean, count: number, cycleNumber: number, sectionMarker: string }}
553
+ */
554
+ function insertGapFixSection(filePath, afterStepIndex, cycleNumber, newPhases, version) {
555
+ // Build the section marker
556
+ const gapCount = newPhases.length;
557
+ let phaseNames = newPhases.map(p => p.name).join(', ');
558
+ if (phaseNames.length > 80) {
559
+ phaseNames = phaseNames.substring(0, 77) + '...';
560
+ }
561
+ const sectionMarker = `\n--- Gap-Fix Cycle ${cycleNumber} (${gapCount} gaps: ${phaseNames}) ---`;
562
+
563
+ // Build gap-fix steps
564
+ const gapSteps = buildGapFixSteps(newPhases, version);
565
+
566
+ // Insert the step lines using existing insertJobSteps
567
+ insertJobSteps(filePath, afterStepIndex, gapSteps);
568
+
569
+ // Now read the file again and insert the section marker line
570
+ // The section marker goes immediately before the first newly-inserted step line
571
+ const content = fs.readFileSync(filePath, 'utf-8');
572
+ const lines = content.split('\n');
573
+
574
+ // Find all step lines and their positions
575
+ const stepPositions = [];
576
+ for (let i = 0; i < lines.length; i++) {
577
+ const trimmed = lines[i].trim();
578
+ if (trimmed.startsWith('- [')) {
579
+ const step = parseStepLine(trimmed, stepPositions.length);
580
+ if (step) {
581
+ stepPositions.push({ lineIndex: i, step });
582
+ }
583
+ }
584
+ }
585
+
586
+ // The first inserted step is at step index afterStepIndex + 1
587
+ const firstInsertedStepIdx = afterStepIndex + 1;
588
+ if (firstInsertedStepIdx < stepPositions.length) {
589
+ const insertLineIdx = stepPositions[firstInsertedStepIdx].lineIndex;
590
+ lines.splice(insertLineIdx, 0, sectionMarker);
591
+ }
592
+
593
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
594
+
595
+ // Update the GapFixCycle header field
596
+ const updatedContent = fs.readFileSync(filePath, 'utf-8');
597
+ const hasGapFixField = /^\*\*GapFixCycle:\*\*\s+.*$/m.test(updatedContent);
598
+
599
+ if (hasGapFixField) {
600
+ updateJobHeader(filePath, 'GapFixCycle', String(cycleNumber));
601
+ } else {
602
+ // Insert GapFixCycle line after the Check header line
603
+ const fileLines = fs.readFileSync(filePath, 'utf-8').split('\n');
604
+ const checkLineIdx = fileLines.findIndex(l => /^\*\*Check:\*\*\s+/.test(l));
605
+ if (checkLineIdx !== -1) {
606
+ fileLines.splice(checkLineIdx + 1, 0, `**GapFixCycle:** ${cycleNumber}`);
607
+ fs.writeFileSync(filePath, fileLines.join('\n'), 'utf-8');
608
+ }
609
+ }
610
+
611
+ return {
612
+ inserted: true,
613
+ count: gapSteps.length,
614
+ cycleNumber,
615
+ sectionMarker,
616
+ };
617
+ }
618
+
619
+ /**
620
+ * CLI wrapper for insertGapFixSection.
621
+ * Usage: dgs-tools.cjs jobs insert-gap-fix-section <file> <after-index> <cycle> <version> <phases-json>
622
+ *
623
+ * @param {string} cwd - Working directory
624
+ * @param {string} file - Path to job file
625
+ * @param {string} afterIndexStr - Step index to insert after (string, parsed to int)
626
+ * @param {string} cycleStr - Cycle number (string, parsed to int)
627
+ * @param {string} version - Milestone version
628
+ * @param {string} phasesJson - JSON string of phases array [{number, name}]
629
+ * @param {boolean} raw - Raw output mode
630
+ */
631
+ function cmdJobsInsertGapFixSection(cwd, file, afterIndexStr, cycleStr, version, phasesJson, raw) {
632
+ if (!file || afterIndexStr === undefined || cycleStr === undefined || !version || !phasesJson) {
633
+ error('Usage: jobs insert-gap-fix-section <file> <after-index> <cycle> <version> <phases-json>');
634
+ }
635
+ const filePath = path.isAbsolute(file) ? file : path.join(cwd, file);
636
+ const afterIndex = parseInt(afterIndexStr, 10);
637
+ if (isNaN(afterIndex)) {
638
+ error(`Invalid step index: ${afterIndexStr}`);
639
+ }
640
+ const cycle = parseInt(cycleStr, 10);
641
+ if (isNaN(cycle)) {
642
+ error(`Invalid cycle number: ${cycleStr}`);
643
+ }
644
+ let phases;
645
+ try {
646
+ phases = JSON.parse(phasesJson);
647
+ } catch (parseErr) {
648
+ error(`Invalid JSON for phases: ${parseErr.message}`);
649
+ }
650
+ try {
651
+ const result = insertGapFixSection(filePath, afterIndex, cycle, phases, version);
652
+ output(result, raw);
653
+ } catch (err) {
654
+ error(err.message);
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Update the PhaseFixCycle header in a job file.
660
+ *
661
+ * If the header exists, updates it in-place. If not, inserts it after
662
+ * the GapFixCycle header (if present) or after the Check header.
663
+ *
664
+ * @param {string} filePath - Absolute path to the job file
665
+ * @param {number} cycleNumber - Phase fix cycle number
666
+ * @param {string} phaseNumber - Phase number (e.g., "41")
667
+ */
668
+ function updatePhaseFixCycleHeader(filePath, cycleNumber, phaseNumber) {
669
+ const content = fs.readFileSync(filePath, 'utf-8');
670
+ const headerPattern = /^\*\*PhaseFixCycle:\*\*\s+.*$/m;
671
+
672
+ if (headerPattern.test(content)) {
673
+ const updated = content.replace(headerPattern, `**PhaseFixCycle:** ${cycleNumber}:${phaseNumber}`);
674
+ fs.writeFileSync(filePath, updated, 'utf-8');
675
+ } else {
676
+ // Insert after GapFixCycle if it exists, else after Check line
677
+ const lines = content.split('\n');
678
+ const checkLineIdx = lines.findIndex(l => /^\*\*Check:\*\*\s+/.test(l));
679
+ if (checkLineIdx !== -1) {
680
+ const gapFixIdx = lines.findIndex(l => /^\*\*GapFixCycle:\*\*\s+/.test(l));
681
+ const insertIdx = gapFixIdx !== -1 ? gapFixIdx + 1 : checkLineIdx + 1;
682
+ lines.splice(insertIdx, 0, `**PhaseFixCycle:** ${cycleNumber}:${phaseNumber}`);
683
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
684
+ }
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Insert a phase-level gap-fix section into a job file.
690
+ *
691
+ * Inserts a section marker line followed by phase gap-fix step lines
692
+ * (execute-phase --gaps-only + audit-phase --rerun-failed) after the
693
+ * specified step index. Updates the PhaseFixCycle header field.
694
+ *
695
+ * @param {string} filePath - Absolute path to the job file
696
+ * @param {number} afterStepIndex - Insert after this step index (typically the audit-phase step)
697
+ * @param {number} cycleNumber - Phase fix cycle number (1, 2)
698
+ * @param {string} phaseNumber - Phase number (e.g., "41")
699
+ * @returns {{ inserted: boolean, count: number, cycleNumber: number, phaseNumber: string, sectionMarker: string }}
700
+ */
701
+ function insertPhaseGapFixSection(filePath, afterStepIndex, cycleNumber, phaseNumber) {
702
+ const sectionMarker = `\n--- Phase ${phaseNumber} Gap-Fix Cycle ${cycleNumber}/2 ---`;
703
+ const gapSteps = buildPhaseGapFixSteps(phaseNumber);
704
+
705
+ // Insert steps using existing infrastructure
706
+ insertJobSteps(filePath, afterStepIndex, gapSteps);
707
+
708
+ // Insert section marker before the first inserted step (same technique as insertGapFixSection)
709
+ const content = fs.readFileSync(filePath, 'utf-8');
710
+ const lines = content.split('\n');
711
+ const stepPositions = [];
712
+ for (let i = 0; i < lines.length; i++) {
713
+ const trimmed = lines[i].trim();
714
+ if (trimmed.startsWith('- [')) {
715
+ const step = parseStepLine(trimmed, stepPositions.length);
716
+ if (step) stepPositions.push({ lineIndex: i, step });
717
+ }
718
+ }
719
+ const firstInsertedStepIdx = afterStepIndex + 1;
720
+ if (firstInsertedStepIdx < stepPositions.length) {
721
+ const insertLineIdx = stepPositions[firstInsertedStepIdx].lineIndex;
722
+ lines.splice(insertLineIdx, 0, sectionMarker);
723
+ }
724
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
725
+
726
+ // Update PhaseFixCycle header
727
+ updatePhaseFixCycleHeader(filePath, cycleNumber, phaseNumber);
728
+
729
+ return {
730
+ inserted: true,
731
+ count: gapSteps.length,
732
+ cycleNumber,
733
+ phaseNumber,
734
+ sectionMarker,
735
+ };
736
+ }
737
+
738
+ /**
739
+ * CLI wrapper for insertPhaseGapFixSection.
740
+ * Usage: dgs-tools.cjs jobs insert-phase-gap-fix <file> <after-index> <cycle> <phase-number>
741
+ *
742
+ * @param {string} cwd - Working directory
743
+ * @param {string} file - Path to job file
744
+ * @param {string} afterIndexStr - Step index to insert after (string, parsed to int)
745
+ * @param {string} cycleStr - Cycle number (string, parsed to int)
746
+ * @param {string} phaseNumber - Phase number (e.g., "41")
747
+ * @param {boolean} raw - Raw output mode
748
+ */
749
+ function cmdJobsInsertPhaseGapFix(cwd, file, afterIndexStr, cycleStr, phaseNumber, raw) {
750
+ if (!file || afterIndexStr === undefined || cycleStr === undefined || !phaseNumber) {
751
+ error('Usage: jobs insert-phase-gap-fix <file> <after-index> <cycle> <phase-number>');
752
+ }
753
+ const filePath = path.isAbsolute(file) ? file : path.join(cwd, file);
754
+ const afterIndex = parseInt(afterIndexStr, 10);
755
+ if (isNaN(afterIndex)) error(`Invalid step index: ${afterIndexStr}`);
756
+ const cycle = parseInt(cycleStr, 10);
757
+ if (isNaN(cycle)) error(`Invalid cycle number: ${cycleStr}`);
758
+ try {
759
+ const result = insertPhaseGapFixSection(filePath, afterIndex, cycle, phaseNumber);
760
+ output(result, raw);
761
+ } catch (err) {
762
+ error(err.message);
763
+ }
764
+ }
765
+
766
+ // ─── CLI Wrapper Functions ──────────────────────────────────────────────────
767
+
768
+ /**
769
+ * CLI wrapper for parseJobFile.
770
+ * Usage: dgs-tools.cjs jobs parse <file>
771
+ *
772
+ * @param {string} cwd - Working directory
773
+ * @param {string} file - Path to job file (relative to cwd or absolute)
774
+ * @param {boolean} raw - Raw output mode
775
+ */
776
+ function cmdJobsParse(cwd, file, raw) {
777
+ if (!file) {
778
+ error('file path required. Usage: jobs parse <file>');
779
+ }
780
+ const filePath = path.isAbsolute(file) ? file : path.join(cwd, file);
781
+ try {
782
+ const result = parseJobFile(filePath);
783
+ output(result, raw);
784
+ } catch (err) {
785
+ error(err.message);
786
+ }
787
+ }
788
+
789
+ /**
790
+ * CLI wrapper for updateJobStep.
791
+ * Usage: dgs-tools.cjs jobs update-step <file> <step-index> <status> [--timestamp T] [--error E]
792
+ *
793
+ * @param {string} cwd - Working directory
794
+ * @param {string} file - Path to job file
795
+ * @param {string} stepIndexStr - Step index as string
796
+ * @param {string} status - New status
797
+ * @param {Object} opts - { timestamp, error }
798
+ * @param {boolean} raw - Raw output mode
799
+ */
800
+ function cmdJobsUpdateStep(cwd, file, stepIndexStr, status, opts, raw) {
801
+ if (!file || stepIndexStr === undefined || !status) {
802
+ error('Usage: jobs update-step <file> <step-index> <status> [--timestamp T] [--error E]');
803
+ }
804
+ const filePath = path.isAbsolute(file) ? file : path.join(cwd, file);
805
+ const stepIndex = parseInt(stepIndexStr, 10);
806
+ if (isNaN(stepIndex)) {
807
+ error(`Invalid step index: ${stepIndexStr}`);
808
+ }
809
+ try {
810
+ updateJobStep(filePath, stepIndex, status, opts);
811
+ output({ updated: true, step: stepIndex, status }, raw, `Step ${stepIndex} updated to ${status}`);
812
+ } catch (err) {
813
+ error(err.message);
814
+ }
815
+ }
816
+
817
+ /**
818
+ * CLI wrapper for moveJobFile.
819
+ * Usage: dgs-tools.cjs jobs move <file> <target-dir>
820
+ *
821
+ * @param {string} cwd - Working directory
822
+ * @param {string} file - Path to job file
823
+ * @param {string} targetDir - Target directory path
824
+ * @param {boolean} raw - Raw output mode
825
+ */
826
+ function cmdJobsMove(cwd, file, targetDir, raw) {
827
+ if (!file || !targetDir) {
828
+ error('Usage: jobs move <file> <target-dir>');
829
+ }
830
+ const filePath = path.isAbsolute(file) ? file : path.join(cwd, file);
831
+ const target = path.isAbsolute(targetDir) ? targetDir : path.join(cwd, targetDir);
832
+ try {
833
+ const result = moveJobFile(filePath, target);
834
+ const fileName = path.basename(filePath);
835
+ const dirName = path.basename(target);
836
+ output(result, raw, result.moved ? `${fileName} moved to ${dirName}` : result.warning);
837
+ } catch (err) {
838
+ error(err.message);
839
+ }
840
+ }
841
+
842
+ /**
843
+ * CLI wrapper for findJobFile.
844
+ * Usage: dgs-tools.cjs jobs find-job <version>
845
+ *
846
+ * @param {string} cwd - Working directory
847
+ * @param {string} version - Milestone version (e.g., "v6.0" or "6.0")
848
+ * @param {boolean} raw - Raw output mode
849
+ */
850
+ function cmdJobsFindJob(cwd, version, raw) {
851
+ if (!version) {
852
+ error('version required. Usage: jobs find-job <version>');
853
+ }
854
+ const result = findJobFile(cwd, version);
855
+ output(result, raw);
856
+ }
857
+
858
+ /**
859
+ * CLI wrapper for updateJobHeader.
860
+ * Usage: dgs-tools.cjs jobs update-header <file> <field> <value>
861
+ *
862
+ * @param {string} cwd - Working directory
863
+ * @param {string} file - Path to job file
864
+ * @param {string} field - Header field name
865
+ * @param {string} value - New value
866
+ * @param {boolean} raw - Raw output mode
867
+ */
868
+ function cmdJobsUpdateHeader(cwd, file, field, value, raw) {
869
+ if (!file || !field || !value) {
870
+ error('Usage: jobs update-header <file> <field> <value>');
871
+ }
872
+ const filePath = path.isAbsolute(file) ? file : path.join(cwd, file);
873
+ try {
874
+ updateJobHeader(filePath, field, value);
875
+ output({ updated: true, field, value }, raw, `${field} updated to ${value}`);
876
+ } catch (err) {
877
+ error(err.message);
878
+ }
879
+ }
880
+
881
+ /**
882
+ * CLI wrapper for insertJobSteps.
883
+ * Usage: dgs-tools.cjs jobs insert-steps <file> <after-index> <steps-json>
884
+ *
885
+ * @param {string} cwd - Working directory
886
+ * @param {string} file - Path to job file
887
+ * @param {string} afterIndexStr - Step index to insert after (string, parsed to int)
888
+ * @param {string} stepsJson - JSON string of steps array
889
+ * @param {boolean} raw - Raw output mode
890
+ */
891
+ function cmdJobsInsertSteps(cwd, file, afterIndexStr, stepsJson, raw) {
892
+ if (!file || afterIndexStr === undefined || !stepsJson) {
893
+ error('Usage: jobs insert-steps <file> <after-index> <steps-json>');
894
+ }
895
+ const filePath = path.isAbsolute(file) ? file : path.join(cwd, file);
896
+ const afterIndex = parseInt(afterIndexStr, 10);
897
+ if (isNaN(afterIndex)) {
898
+ error(`Invalid step index: ${afterIndexStr}`);
899
+ }
900
+ let steps;
901
+ try {
902
+ steps = JSON.parse(stepsJson);
903
+ } catch (parseErr) {
904
+ error(`Invalid JSON for steps: ${parseErr.message}`);
905
+ }
906
+ try {
907
+ const result = insertJobSteps(filePath, afterIndex, steps);
908
+ output(result, raw, `${result.count} steps inserted at index ${result.startIndex}`);
909
+ } catch (err) {
910
+ error(err.message);
911
+ }
912
+ }
913
+
914
+ // ─── Milestone Step Generation ──────────────────────────────────────────────
915
+
916
+ /** Disk statuses that require map-codebase + plan-phase + execute-phase + audit-phase */
917
+ const NEEDS_PLANNING = new Set(['no_directory', 'empty', 'discussed', 'researched']);
918
+
919
+ /** Disk statuses that require execute-phase + audit-phase (already planned) */
920
+ const NEEDS_EXECUTION = new Set(['planned', 'partial']);
921
+
922
+ /**
923
+ * Compare phase numbers numerically (handles decimals like 50.1).
924
+ * @param {string} a - Phase number
925
+ * @param {string} b - Phase number
926
+ * @returns {number} Comparison result
927
+ */
928
+ function comparePhaseNumbers(a, b) {
929
+ return parseFloat(a) - parseFloat(b);
930
+ }
931
+
932
+ /**
933
+ * Generate the step sequence for a milestone job based on phase states.
934
+ *
935
+ * @param {Array} phases - Array of phase objects from roadmap analysis
936
+ * @param {Object} options - { check: boolean, version: string }
937
+ * @returns {Array<{ command: string, args: string }>} Ordered step sequence
938
+ */
939
+ function generateMilestoneSteps(phases, options) {
940
+ const { check, version } = options;
941
+
942
+ // Sort phases by number (numeric, handles decimals)
943
+ const sorted = [...phases].sort((a, b) => comparePhaseNumbers(a.number, b.number));
944
+
945
+ const steps = [];
946
+
947
+ for (const phase of sorted) {
948
+ // Skip completed phases
949
+ if (phase.disk_status === 'complete' || phase.roadmap_complete === true) {
950
+ continue;
951
+ }
952
+
953
+ const num = phase.number;
954
+
955
+ if (NEEDS_PLANNING.has(phase.disk_status)) {
956
+ steps.push({ command: 'map-codebase', args: `${num} --auto` });
957
+ steps.push({ command: 'plan-phase', args: `${num} --non-interactive` });
958
+ steps.push({ command: 'execute-phase', args: `${num} --non-interactive` });
959
+ steps.push({ command: 'audit-phase', args: `${num}` });
960
+ } else if (NEEDS_EXECUTION.has(phase.disk_status)) {
961
+ steps.push({ command: 'execute-phase', args: `${num} --non-interactive` });
962
+ steps.push({ command: 'audit-phase', args: `${num}` });
963
+ }
964
+ }
965
+
966
+ // Append audit + complete steps if check is enabled
967
+ if (check) {
968
+ steps.push({ command: 'audit-milestone', args: version });
969
+ steps.push({ command: 'complete-milestone', args: version });
970
+ }
971
+
972
+ return steps;
973
+ }
974
+
975
+ /**
976
+ * Build the markdown content for a milestone job file.
977
+ *
978
+ * @param {string} version - Milestone version (e.g., "v6.0")
979
+ * @param {boolean} check - Whether audit/complete steps are included
980
+ * @param {Array<{ command: string, args: string }>} steps - Step sequence
981
+ * @param {string|null} [createdBy=null] - Author string (e.g., "Name <email>"). Omits Created_by line when null/undefined.
982
+ * @returns {string} Complete markdown file content
983
+ */
984
+ function buildJobFileContent(version, check, steps, createdBy) {
985
+ const created = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
986
+
987
+ let content = `# Milestone Job: ${version}\n\n`;
988
+ content += `**Version:** ${version}\n`;
989
+ content += `**Created:** ${created}\n`;
990
+ if (createdBy) {
991
+ content += `**Created_by:** ${createdBy}\n`;
992
+ }
993
+ content += `**Status:** pending\n`;
994
+ content += `**Check:** ${check}\n`;
995
+ content += `\n## Steps\n\n`;
996
+
997
+ for (const step of steps) {
998
+ content += `- [ ] \`/dgs:${step.command} ${step.args}\`\n`;
999
+ }
1000
+
1001
+ return content;
1002
+ }
1003
+
1004
+ // ─── Milestone Phase Analysis ───────────────────────────────────────────────
1005
+
1006
+ /**
1007
+ * Analyze phases belonging to a specific milestone from ROADMAP.md.
1008
+ * Replicates the disk_status detection from roadmap.cjs but scoped to a
1009
+ * single milestone's phases, avoiding circular dependency with output().
1010
+ *
1011
+ * @param {string} cwd - Working directory
1012
+ * @param {string} version - Milestone version to analyze (e.g., "v6.0")
1013
+ * @returns {Array} Phase objects with { number, name, disk_status, roadmap_complete }
1014
+ * @throws {Error} If ROADMAP.md not found or version not found
1015
+ */
1016
+ function analyzeMilestonePhases(cwd, version) {
1017
+ const projectRoot = resolveProjectRoot(cwd);
1018
+ const roadmapPath = path.join(projectRoot, 'ROADMAP.md');
1019
+ if (!fs.existsSync(roadmapPath)) {
1020
+ throw new Error('ROADMAP.md not found at ' + roadmapPath);
1021
+ }
1022
+
1023
+ const content = fs.readFileSync(roadmapPath, 'utf-8');
1024
+ const phasesDir = path.join(projectRoot, 'phases');
1025
+
1026
+ // Find the milestone section: look for heading containing the version + "In Progress" or "SHIPPED"
1027
+ // Also match the milestone in the summary list to find its phase range
1028
+ const escapedVersion = version.replace(/\./g, '\\.');
1029
+
1030
+ // Fast path: explicit "Phases N-M" in milestone summary line
1031
+ const milestoneRangePattern = new RegExp(
1032
+ `${escapedVersion}\\s+[^\\n]*Phases\\s+(\\d+)\\s*-\\s*(\\d+)`,
1033
+ 'i'
1034
+ );
1035
+ const rangeMatch = content.match(milestoneRangePattern);
1036
+
1037
+ let phaseStart, phaseEnd;
1038
+
1039
+ if (rangeMatch) {
1040
+ phaseStart = parseInt(rangeMatch[1], 10);
1041
+ phaseEnd = parseInt(rangeMatch[2], 10);
1042
+ } else {
1043
+ // Fallback: find milestone section heading containing the version, then
1044
+ // scan for Phase N: headers within that section to determine the range.
1045
+ const sectionHeadingPattern = new RegExp(
1046
+ `^(#{2,4})\\s+.*${escapedVersion}[^\\n]*$`,
1047
+ 'im'
1048
+ );
1049
+ const sectionMatch = content.match(sectionHeadingPattern);
1050
+ if (!sectionMatch) {
1051
+ throw new Error(`Milestone ${version} not found in ROADMAP.md`);
1052
+ }
1053
+
1054
+ const headingLevel = sectionMatch[1].length;
1055
+ const sectionStart = sectionMatch.index + sectionMatch[0].length;
1056
+
1057
+ // Find the end of this section (next heading at same or higher level)
1058
+ const endPattern = new RegExp(
1059
+ `^#{2,${headingLevel}}\\s+`,
1060
+ 'im'
1061
+ );
1062
+ const restContent = content.slice(sectionStart);
1063
+ const endMatch = restContent.match(endPattern);
1064
+ const sectionContent = endMatch
1065
+ ? restContent.slice(0, endMatch.index)
1066
+ : restContent;
1067
+
1068
+ // Collect Phase N: headers within this section
1069
+ const phaseHeaderPattern = /Phase\s+(\d+)\s*:/gi;
1070
+ const phaseNumbers = [];
1071
+ let phMatch;
1072
+ while ((phMatch = phaseHeaderPattern.exec(sectionContent)) !== null) {
1073
+ phaseNumbers.push(parseInt(phMatch[1], 10));
1074
+ }
1075
+
1076
+ // If section-based search found nothing (common when ROADMAP has peer-level
1077
+ // ## headings like ## v1.0, ## Overview, ## Phase Details), search the entire
1078
+ // ROADMAP for Phase N: headings. For single-milestone ROADMAPs all phases
1079
+ // belong to the milestone.
1080
+ if (phaseNumbers.length === 0) {
1081
+ const fullPhasePattern = new RegExp(
1082
+ `Phase\\s+(\\d+)`,
1083
+ 'gi'
1084
+ );
1085
+ let fpMatch;
1086
+ while ((fpMatch = fullPhasePattern.exec(content)) !== null) {
1087
+ phaseNumbers.push(parseInt(fpMatch[1], 10));
1088
+ }
1089
+ }
1090
+
1091
+ if (phaseNumbers.length === 0) {
1092
+ throw new Error(`Milestone ${version} not found in ROADMAP.md`);
1093
+ }
1094
+
1095
+ phaseStart = Math.min(...phaseNumbers);
1096
+ phaseEnd = Math.max(...phaseNumbers);
1097
+ }
1098
+
1099
+ // Extract all phase detail sections: ### Phase N: Name
1100
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*:\s*([^\n]+)/gi;
1101
+ const phases = [];
1102
+ let match;
1103
+
1104
+ while ((match = phasePattern.exec(content)) !== null) {
1105
+ const phaseNum = match[1];
1106
+ const phaseFloat = parseFloat(phaseNum);
1107
+
1108
+ // Only include phases in this milestone's range
1109
+ if (phaseFloat < phaseStart || phaseFloat > phaseEnd) continue;
1110
+
1111
+ const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
1112
+
1113
+ // Determine disk status by examining the phases directory
1114
+ const padded = phaseNum.replace(/^(\d+)/, (m) => m.padStart(2, '0'));
1115
+ let diskStatus = 'no_directory';
1116
+
1117
+ try {
1118
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1119
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1120
+ const dirMatch = dirs.find(d => d.startsWith(padded + '-') || d === padded);
1121
+
1122
+ if (dirMatch) {
1123
+ const phaseFiles = fs.readdirSync(path.join(phasesDir, dirMatch));
1124
+ const planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
1125
+ const summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
1126
+ const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
1127
+ const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
1128
+
1129
+ if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete';
1130
+ else if (summaryCount > 0) diskStatus = 'partial';
1131
+ else if (planCount > 0) diskStatus = 'planned';
1132
+ else if (hasResearch) diskStatus = 'researched';
1133
+ else if (hasContext) diskStatus = 'discussed';
1134
+ else diskStatus = 'empty';
1135
+ }
1136
+ } catch {}
1137
+
1138
+ // Check ROADMAP checkbox status
1139
+ const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseNum.replace('.', '\\.')}`, 'i');
1140
+ const checkboxMatch = content.match(checkboxPattern);
1141
+ const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
1142
+
1143
+ phases.push({
1144
+ number: phaseNum,
1145
+ name: phaseName,
1146
+ disk_status: diskStatus,
1147
+ roadmap_complete: roadmapComplete,
1148
+ });
1149
+ }
1150
+
1151
+ return phases;
1152
+ }
1153
+
1154
+ /**
1155
+ * Detect the active milestone version from ROADMAP.md.
1156
+ *
1157
+ * @param {string} content - ROADMAP.md content
1158
+ * @returns {string|null} Version string (e.g., "v6.0") or null if not found
1159
+ */
1160
+ function detectActiveMilestone(content) {
1161
+ // Primary: heading with (In Progress)
1162
+ const match = content.match(/##\s+.*?(v\d+\.\d+).*?\((?:In|in)\s+[Pp]rogress\)/i);
1163
+ if (match) return match[1];
1164
+ // Fallback: milestone list item with (in progress)
1165
+ const listMatch = content.match(/-\s+.*?(v\d+\.\d+).*?\((?:in\s+progress)\)/i);
1166
+ return listMatch ? listMatch[1] : null;
1167
+ }
1168
+
1169
+ // ─── Milestone Job CLI Wrappers ─────────────────────────────────────────────
1170
+
1171
+ /**
1172
+ * Create a milestone job file from roadmap analysis.
1173
+ *
1174
+ * @param {string} cwd - Working directory
1175
+ * @param {string|null} version - Milestone version (null = auto-detect)
1176
+ * @param {boolean} check - Include audit/complete steps
1177
+ * @param {boolean} raw - Raw output mode
1178
+ * @returns {Object} Result JSON (when called directly, not via CLI)
1179
+ */
1180
+ function cmdJobsCreateMilestone(cwd, version, check, raw) {
1181
+ const roadmapPath = path.join(resolveProjectRoot(cwd), 'ROADMAP.md');
1182
+ if (!fs.existsSync(roadmapPath)) {
1183
+ throw new Error('ROADMAP.md not found at ' + roadmapPath);
1184
+ }
1185
+
1186
+ const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
1187
+
1188
+ // Resolve version
1189
+ let resolvedVersion = version;
1190
+ if (!resolvedVersion) {
1191
+ resolvedVersion = detectActiveMilestone(roadmapContent);
1192
+ if (!resolvedVersion) {
1193
+ throw new Error('No active milestone found in ROADMAP.md');
1194
+ }
1195
+ } else {
1196
+ // Validate the version exists in ROADMAP
1197
+ const escapedV = resolvedVersion.replace(/\./g, '\\.');
1198
+ const versionPattern = new RegExp(escapedV + '\\s+', 'i');
1199
+ if (!versionPattern.test(roadmapContent)) {
1200
+ throw new Error(`Milestone ${resolvedVersion} not found in ROADMAP.md`);
1201
+ }
1202
+ }
1203
+
1204
+ // Analyze phases for this milestone
1205
+ const phases = analyzeMilestonePhases(cwd, resolvedVersion);
1206
+
1207
+ // Generate steps
1208
+ const steps = generateMilestoneSteps(phases, { check, version: resolvedVersion });
1209
+
1210
+ // Resolve git identity for Created_by (best-effort; don't block creation)
1211
+ let createdBy = null;
1212
+ try {
1213
+ const identity = requireGitIdentity(cwd);
1214
+ createdBy = formatAuthorString(identity);
1215
+ } catch {
1216
+ // Identity resolution is best-effort for jobs; don't block creation
1217
+ }
1218
+
1219
+ // Build file content
1220
+ const content = buildJobFileContent(resolvedVersion, check, steps, createdBy);
1221
+
1222
+ // Write to pending directory
1223
+ const pendingDir = path.join(getPlanningRoot(cwd), 'jobs', 'pending');
1224
+ fs.mkdirSync(pendingDir, { recursive: true });
1225
+ const fileName = `milestone-${resolvedVersion}.md`;
1226
+ const filePath = path.join(pendingDir, fileName);
1227
+ fs.writeFileSync(filePath, content, 'utf-8');
1228
+
1229
+ // Count phases that contribute at least one step
1230
+ const phasesWithSteps = phases.filter(p =>
1231
+ p.disk_status !== 'complete' && p.roadmap_complete !== true
1232
+ );
1233
+
1234
+ const result = {
1235
+ created: true,
1236
+ version: resolvedVersion,
1237
+ file: path.join(path.relative(cwd, getPlanningRoot(cwd)) || '.', 'jobs', 'pending', fileName),
1238
+ step_count: steps.length,
1239
+ check,
1240
+ phase_count: phasesWithSteps.length,
1241
+ steps_preview: steps.map(s => `/dgs:${s.command} ${s.args}`),
1242
+ };
1243
+
1244
+ return result;
1245
+ }
1246
+
1247
+ /**
1248
+ * Preview a milestone job without writing any file.
1249
+ *
1250
+ * @param {string} cwd - Working directory
1251
+ * @param {string|null} version - Milestone version (null = auto-detect)
1252
+ * @param {boolean} check - Include audit/complete steps
1253
+ * @param {boolean} raw - Raw output mode
1254
+ * @returns {Object} Preview JSON
1255
+ */
1256
+ function cmdJobsMilestonePreview(cwd, version, check, raw) {
1257
+ const roadmapPath = path.join(resolveProjectRoot(cwd), 'ROADMAP.md');
1258
+ if (!fs.existsSync(roadmapPath)) {
1259
+ throw new Error('ROADMAP.md not found at ' + roadmapPath);
1260
+ }
1261
+
1262
+ const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
1263
+
1264
+ // Resolve version
1265
+ let resolvedVersion = version;
1266
+ if (!resolvedVersion) {
1267
+ resolvedVersion = detectActiveMilestone(roadmapContent);
1268
+ if (!resolvedVersion) {
1269
+ throw new Error('No active milestone found in ROADMAP.md');
1270
+ }
1271
+ } else {
1272
+ const escapedV = resolvedVersion.replace(/\./g, '\\.');
1273
+ const versionPattern = new RegExp(escapedV + '\\s+', 'i');
1274
+ if (!versionPattern.test(roadmapContent)) {
1275
+ throw new Error(`Milestone ${resolvedVersion} not found in ROADMAP.md`);
1276
+ }
1277
+ }
1278
+
1279
+ // Analyze phases
1280
+ const phases = analyzeMilestonePhases(cwd, resolvedVersion);
1281
+
1282
+ // Generate steps
1283
+ const steps = generateMilestoneSteps(phases, { check, version: resolvedVersion });
1284
+
1285
+ // Build content (but do NOT write)
1286
+ const content = buildJobFileContent(resolvedVersion, check, steps);
1287
+
1288
+ // Count phases with steps
1289
+ const phasesWithSteps = phases.filter(p =>
1290
+ p.disk_status !== 'complete' && p.roadmap_complete !== true
1291
+ );
1292
+
1293
+ return {
1294
+ preview: true,
1295
+ version: resolvedVersion,
1296
+ check,
1297
+ step_count: steps.length,
1298
+ phase_count: phasesWithSteps.length,
1299
+ steps_preview: steps.map(s => `/dgs:${s.command} ${s.args}`),
1300
+ content,
1301
+ };
1302
+ }
1303
+
1304
+ // ─── Visibility & Polish Functions ──────────────────────────────────────────
1305
+
1306
+ /**
1307
+ * List all job files across pending/in-progress/completed, grouped by status.
1308
+ *
1309
+ * @param {string} cwd - Working directory
1310
+ * @returns {{ in_progress: Array, pending: Array, completed: Array }}
1311
+ */
1312
+ function listJobs(cwd) {
1313
+ const jobsDir = path.join(getPlanningRoot(cwd), 'jobs');
1314
+ const groups = { in_progress: [], pending: [], completed: [] };
1315
+ const dirMap = { 'in-progress': 'in_progress', 'pending': 'pending', 'completed': 'completed' };
1316
+
1317
+ for (const [dirName, groupKey] of Object.entries(dirMap)) {
1318
+ const dirPath = path.join(jobsDir, dirName);
1319
+ if (!fs.existsSync(dirPath)) {
1320
+ fs.mkdirSync(dirPath, { recursive: true });
1321
+ }
1322
+
1323
+ let files;
1324
+ try {
1325
+ files = fs.readdirSync(dirPath).filter(f => f.startsWith('milestone-') && f.endsWith('.md'));
1326
+ } catch {
1327
+ continue;
1328
+ }
1329
+
1330
+ // Sort by modification time, most recent first
1331
+ const fileStats = files.map(f => {
1332
+ const fullPath = path.join(dirPath, f);
1333
+ let mtime = 0;
1334
+ try { mtime = fs.statSync(fullPath).mtimeMs; } catch {}
1335
+ return { file: f, fullPath, mtime };
1336
+ });
1337
+ fileStats.sort((a, b) => b.mtime - a.mtime);
1338
+
1339
+ for (const { file, fullPath } of fileStats) {
1340
+ try {
1341
+ const parsed = parseJobFile(fullPath);
1342
+ groups[groupKey].push({
1343
+ version: parsed.version,
1344
+ status: parsed.status,
1345
+ check: parsed.check,
1346
+ created_by: parsed.created_by,
1347
+ progress: `${parsed.completedCount}/${parsed.stepCount}`,
1348
+ file: fullPath,
1349
+ });
1350
+ } catch {
1351
+ // Skip unparseable files
1352
+ }
1353
+ }
1354
+ }
1355
+
1356
+ return groups;
1357
+ }
1358
+
1359
+ /**
1360
+ * Cancel an in-progress job: reset [>] steps to [ ], set Status to pending, move to pending/.
1361
+ *
1362
+ * @param {string} cwd - Working directory
1363
+ * @param {string} version - Milestone version (e.g., "v6.0")
1364
+ * @returns {{ cancelled: boolean, version?: string, path?: string, steps_reset?: number, reason?: string }}
1365
+ */
1366
+ function cancelJob(cwd, version) {
1367
+ const found = findJobFile(cwd, version);
1368
+ if (!found.found) {
1369
+ return { cancelled: false, reason: 'not_found' };
1370
+ }
1371
+ if (found.directory !== 'in-progress') {
1372
+ return { cancelled: false, reason: 'not_in_progress' };
1373
+ }
1374
+
1375
+ const filePath = found.path;
1376
+
1377
+ // Read and find all in-progress steps
1378
+ const content = fs.readFileSync(filePath, 'utf-8');
1379
+ const lines = content.split('\n');
1380
+ const stepLines = [];
1381
+ for (let i = 0; i < lines.length; i++) {
1382
+ const trimmed = lines[i].trim();
1383
+ if (trimmed.startsWith('- [')) {
1384
+ const step = parseStepLine(trimmed, stepLines.length);
1385
+ if (step) {
1386
+ stepLines.push({ lineIndex: i, step });
1387
+ }
1388
+ }
1389
+ }
1390
+
1391
+ // Reset in-progress steps to pending
1392
+ let stepsReset = 0;
1393
+ for (const { step } of stepLines) {
1394
+ if (step.status === 'in-progress') {
1395
+ updateJobStep(filePath, step.index, 'pending');
1396
+ stepsReset++;
1397
+ }
1398
+ }
1399
+
1400
+ // Update Status header to pending
1401
+ updateJobHeader(filePath, 'Status', 'pending');
1402
+
1403
+ // Move to pending/
1404
+ const pendingDir = path.join(getPlanningRoot(cwd), 'jobs', 'pending');
1405
+ const moveResult = moveJobFile(filePath, pendingDir);
1406
+
1407
+ return {
1408
+ cancelled: true,
1409
+ version: version.startsWith('v') ? version : 'v' + version,
1410
+ path: moveResult.to,
1411
+ steps_reset: stepsReset,
1412
+ };
1413
+ }
1414
+
1415
+ /**
1416
+ * Validate jobs directory structure. Auto-create missing directories.
1417
+ *
1418
+ * @param {string} cwd - Working directory
1419
+ * @returns {{ healthy: boolean, directories: Array, job_count: number, issues?: Array }}
1420
+ */
1421
+ function healthCheck(cwd) {
1422
+ const jobsDir = path.join(getPlanningRoot(cwd), 'jobs');
1423
+ const requiredDirs = ['pending', 'in-progress', 'completed'];
1424
+ const directories = [];
1425
+ const issues = [];
1426
+ let jobCount = 0;
1427
+
1428
+ for (const dirName of requiredDirs) {
1429
+ const dirPath = path.join(jobsDir, dirName);
1430
+ const exists = fs.existsSync(dirPath);
1431
+ let created = false;
1432
+
1433
+ if (!exists) {
1434
+ fs.mkdirSync(dirPath, { recursive: true });
1435
+ created = true;
1436
+ issues.push(`Missing directory auto-created: ${dirName}/`);
1437
+ }
1438
+
1439
+ directories.push({ name: dirName, exists: exists || created, created });
1440
+
1441
+ // Scan for job files
1442
+ try {
1443
+ const files = fs.readdirSync(dirPath).filter(f => f.startsWith('milestone-') && f.endsWith('.md'));
1444
+ jobCount += files.length;
1445
+
1446
+ // Validate each file
1447
+ for (const file of files) {
1448
+ const filePath = path.join(dirPath, file);
1449
+ try {
1450
+ parseJobFile(filePath);
1451
+ } catch (err) {
1452
+ issues.push(`Parse failure in ${dirName}/${file}: ${err.message}`);
1453
+ }
1454
+ }
1455
+ } catch {
1456
+ // Directory just created, no files
1457
+ }
1458
+ }
1459
+
1460
+ return {
1461
+ healthy: issues.length === 0,
1462
+ directories,
1463
+ job_count: jobCount,
1464
+ issues,
1465
+ };
1466
+ }
1467
+
1468
+ /**
1469
+ * Preview a job's steps with status annotations and precondition warnings.
1470
+ *
1471
+ * @param {string} cwd - Working directory
1472
+ * @param {string} version - Milestone version
1473
+ * @returns {{ found: boolean, version?: string, status?: string, steps?: Array, resume_from?: number, warnings?: Array }}
1474
+ */
1475
+ function dryRunPreview(cwd, version) {
1476
+ const found = findJobFile(cwd, version);
1477
+ if (!found.found) {
1478
+ return { found: false };
1479
+ }
1480
+
1481
+ const parsed = parseJobFile(found.path);
1482
+ const steps = parsed.steps.map(step => {
1483
+ let marker;
1484
+ if (step.status === 'completed') marker = '[x]';
1485
+ else if (step.status === 'in-progress') marker = '[>]';
1486
+ else if (step.status === 'failed') marker = '[!]';
1487
+ else marker = '[ ]';
1488
+
1489
+ return {
1490
+ index: step.index,
1491
+ command: step.command,
1492
+ args: step.args,
1493
+ status: step.status,
1494
+ display: `${marker} /dgs:${step.command}${step.args ? ' ' + step.args : ''}`,
1495
+ };
1496
+ });
1497
+
1498
+ // Resume from first non-completed step
1499
+ const firstNonCompleted = parsed.steps.find(s => s.status !== 'completed');
1500
+ const resumeFrom = firstNonCompleted ? firstNonCompleted.index : null;
1501
+
1502
+ // Precondition warnings
1503
+ const planRoot = getPlanningRoot(cwd);
1504
+ const projectRootForHealth = resolveProjectRoot(cwd);
1505
+ const warnings = [];
1506
+ if (!fs.existsSync(planRoot)) {
1507
+ warnings.push('Planning root directory missing');
1508
+ }
1509
+ if (!fs.existsSync(path.join(projectRootForHealth, 'ROADMAP.md'))) {
1510
+ warnings.push('ROADMAP.md missing');
1511
+ }
1512
+ if (!fs.existsSync(path.join(projectRootForHealth, 'STATE.md'))) {
1513
+ warnings.push('STATE.md missing');
1514
+ }
1515
+
1516
+ return {
1517
+ found: true,
1518
+ version: parsed.version,
1519
+ status: parsed.status,
1520
+ steps,
1521
+ resume_from: resumeFrom,
1522
+ warnings,
1523
+ };
1524
+ }
1525
+
1526
+ /**
1527
+ * Generate a markdown summary report for a job.
1528
+ *
1529
+ * @param {string} cwd - Working directory
1530
+ * @param {string} version - Milestone version
1531
+ * @returns {{ found: boolean, version?: string, content?: string }}
1532
+ */
1533
+ function generateJobSummary(cwd, version) {
1534
+ const found = findJobFile(cwd, version);
1535
+ if (!found.found) {
1536
+ return { found: false };
1537
+ }
1538
+
1539
+ const parsed = parseJobFile(found.path);
1540
+ const normalizedVersion = parsed.version;
1541
+
1542
+ let md = `# Job Summary: milestone-${normalizedVersion}\n\n`;
1543
+ md += `## Overview\n\n`;
1544
+ md += `- **Version:** ${normalizedVersion}\n`;
1545
+ md += `- **Status:** ${parsed.status}\n`;
1546
+ if (parsed.created_by) {
1547
+ md += `- **Created_by:** ${parsed.created_by}\n`;
1548
+ }
1549
+ md += `- **Created:** ${parsed.created}\n`;
1550
+ md += `- **Total Steps:** ${parsed.stepCount}\n`;
1551
+ md += `- **Completed Steps:** ${parsed.completedCount}\n`;
1552
+ md += `- **Failed Steps:** ${parsed.failedCount}\n\n`;
1553
+
1554
+ // Per-step timing table
1555
+ md += `## Step Details\n\n`;
1556
+ md += `| # | Command | Started | Duration | Status |\n`;
1557
+ md += `|---|---------|---------|----------|--------|\n`;
1558
+
1559
+ for (let i = 0; i < parsed.steps.length; i++) {
1560
+ const step = parsed.steps[i];
1561
+ const cmd = `/dgs:${step.command}${step.args ? ' ' + step.args : ''}`;
1562
+ const started = step.timestamp || '-';
1563
+
1564
+ // Calculate duration from consecutive timestamps
1565
+ let duration = '-';
1566
+ if (step.timestamp && i + 1 < parsed.steps.length && parsed.steps[i + 1].timestamp) {
1567
+ const startMs = new Date(step.timestamp).getTime();
1568
+ const endMs = new Date(parsed.steps[i + 1].timestamp).getTime();
1569
+ if (!isNaN(startMs) && !isNaN(endMs) && endMs > startMs) {
1570
+ const diffSec = Math.round((endMs - startMs) / 1000);
1571
+ if (diffSec < 60) duration = `${diffSec}s`;
1572
+ else duration = `${Math.round(diffSec / 60)}m`;
1573
+ }
1574
+ }
1575
+
1576
+ md += `| ${i} | ${cmd} | ${started} | ${duration} | ${step.status} |\n`;
1577
+ }
1578
+ md += `\n`;
1579
+
1580
+ // Errors section
1581
+ const failedSteps = parsed.steps.filter(s => s.status === 'failed');
1582
+ if (failedSteps.length > 0) {
1583
+ md += `## Errors\n\n`;
1584
+ for (const step of failedSteps) {
1585
+ const cmd = `/dgs:${step.command}${step.args ? ' ' + step.args : ''}`;
1586
+ md += `- **Step ${step.index}** (${cmd}): ${step.error || 'No error message recorded'}\n`;
1587
+ }
1588
+ md += `\n`;
1589
+ }
1590
+
1591
+ // Auto-resolve audit section
1592
+ const autoSteps = parsed.steps.filter(s => s.args && (s.args.includes('--auto') || s.args.includes('--non-interactive')));
1593
+ if (autoSteps.length > 0) {
1594
+ md += `## Auto-Resolved Steps\n\n`;
1595
+ for (const step of autoSteps) {
1596
+ const cmd = `/dgs:${step.command}${step.args ? ' ' + step.args : ''}`;
1597
+ md += `- Step ${step.index}: ${cmd} (auto-resolved)\n`;
1598
+ }
1599
+ md += `\n`;
1600
+ }
1601
+
1602
+ // Human verification scanning — find UAT files with human_needed entries
1603
+ let humanVerifications = [];
1604
+ try {
1605
+ const milestonePhases = analyzeMilestonePhases(cwd, normalizedVersion);
1606
+ const projectRoot = resolveProjectRoot(cwd);
1607
+ const phasesDir = path.join(projectRoot, 'phases');
1608
+
1609
+ for (const phase of milestonePhases) {
1610
+ const phaseNum = phase.number;
1611
+ const padded = String(phaseNum).replace(/^(\d+)/, (m) => m.padStart(2, '0'));
1612
+
1613
+ // Find the phase directory on disk
1614
+ let phaseDir = null;
1615
+ try {
1616
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1617
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1618
+ phaseDir = dirs.find(d => d.startsWith(padded + '-') || d === padded);
1619
+ } catch {}
1620
+
1621
+ if (!phaseDir) continue;
1622
+
1623
+ // Look for UAT file
1624
+ const uatPath = path.join(phasesDir, phaseDir, `${padded}-UAT.md`);
1625
+ let uatContent = null;
1626
+ try {
1627
+ uatContent = fs.readFileSync(uatPath, 'utf-8');
1628
+ } catch {
1629
+ continue;
1630
+ }
1631
+
1632
+ // Check for mode: auto-test in frontmatter (first ~10 lines)
1633
+ const headerLines = uatContent.split('\n').slice(0, 15).join('\n');
1634
+ if (!/mode:\s*auto-test/i.test(headerLines)) continue;
1635
+
1636
+ // Parse test blocks for human_needed entries
1637
+ const blocks = uatContent.split(/(?=^### \d+\.)/m);
1638
+ for (const block of blocks) {
1639
+ const headingMatch = block.match(/^### \d+\.\s+(.+)/);
1640
+ if (!headingMatch) continue;
1641
+
1642
+ if (!/^result:\s*human_needed\s*$/m.test(block)) continue;
1643
+
1644
+ const description = headingMatch[1].trim();
1645
+ const sourceMatch = block.match(/^source:\s*(.+)\s*$/m);
1646
+ const source = sourceMatch ? sourceMatch[1].trim() : '-';
1647
+
1648
+ humanVerifications.push({
1649
+ phase: phaseNum,
1650
+ description,
1651
+ source,
1652
+ });
1653
+ }
1654
+ }
1655
+ } catch {
1656
+ // If milestone phase analysis fails (e.g., no ROADMAP.md), skip silently
1657
+ }
1658
+
1659
+ if (humanVerifications.length > 0) {
1660
+ md += `## Outstanding Human Verifications\n\n`;
1661
+ md += `The following tests require manual verification:\n\n`;
1662
+ md += `| Phase | Test | Source |\n`;
1663
+ md += `|-------|------|--------|\n`;
1664
+ for (const hv of humanVerifications) {
1665
+ md += `| ${hv.phase} | ${hv.description} | ${hv.source} |\n`;
1666
+ }
1667
+ md += `\nTotal: ${humanVerifications.length} human verification(s) remaining.\n\n`;
1668
+ }
1669
+
1670
+ return {
1671
+ found: true,
1672
+ version: normalizedVersion,
1673
+ content: md,
1674
+ human_verification_count: humanVerifications.length,
1675
+ };
1676
+ }
1677
+
1678
+ // ─── SHA Recording & Rollback ───────────────────────────────────────────────
1679
+
1680
+ /**
1681
+ * Record starting commit SHAs for all registered code repos and the planning repo.
1682
+ * Writes a **StartShas:** header line into the job file.
1683
+ *
1684
+ * @param {string} cwd - Working directory
1685
+ * @param {string} jobFilePath - Absolute path to the job file
1686
+ * @returns {{ recorded: boolean, shas?: Object, reason?: string }}
1687
+ */
1688
+ function recordStartShas(cwd, jobFilePath) {
1689
+ try {
1690
+ const planningRoot = getPlanningRoot(cwd);
1691
+ const shas = {};
1692
+
1693
+ // Record planning repo SHA
1694
+ try {
1695
+ const planningSha = execSync('git rev-parse HEAD', { cwd: planningRoot, stdio: 'pipe' }).toString().trim();
1696
+ shas['_planning'] = planningSha;
1697
+ } catch (gitErr) {
1698
+ return { recorded: false, reason: `Failed to get planning repo SHA: ${gitErr.message}` };
1699
+ }
1700
+
1701
+ // Check for REPOS.md (v2 multi-repo setup)
1702
+ const reposPath = path.join(planningRoot, 'REPOS.md');
1703
+ if (fs.existsSync(reposPath)) {
1704
+ const reposContent = fs.readFileSync(reposPath, 'utf-8');
1705
+ // Parse the markdown table: | Name | Path | ...
1706
+ const lines = reposContent.split('\n');
1707
+ for (const line of lines) {
1708
+ const match = line.match(/^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|/);
1709
+ if (!match) continue;
1710
+ const name = match[1].trim();
1711
+ const repoPath = match[2].trim();
1712
+ // Skip header row and separator
1713
+ if (name === 'Name' || name.startsWith('-')) continue;
1714
+ if (!repoPath || repoPath.startsWith('-')) continue;
1715
+
1716
+ // Resolve absolute path: paths in REPOS.md are relative to planning root's parent
1717
+ const absRepoPath = path.resolve(path.dirname(planningRoot), repoPath);
1718
+ if (!fs.existsSync(absRepoPath)) continue;
1719
+
1720
+ try {
1721
+ const sha = execSync('git rev-parse HEAD', { cwd: absRepoPath, stdio: 'pipe' }).toString().trim();
1722
+ shas[name] = sha;
1723
+ } catch {
1724
+ // Skip repos that aren't git repos or have issues
1725
+ }
1726
+ }
1727
+ }
1728
+
1729
+ // Write StartShas into the job file (after header block, before ## Steps)
1730
+ const content = fs.readFileSync(jobFilePath, 'utf-8');
1731
+
1732
+ // Check if StartShas already exists
1733
+ if (/^\*\*StartShas:\*\*\s+/m.test(content)) {
1734
+ return { recorded: true, shas, already_existed: true };
1735
+ }
1736
+
1737
+ const shaJson = JSON.stringify(shas);
1738
+ const lines = content.split('\n');
1739
+
1740
+ // Find the ## Steps line and insert before it
1741
+ const stepsIdx = lines.findIndex(l => /^## Steps/.test(l));
1742
+ if (stepsIdx !== -1) {
1743
+ lines.splice(stepsIdx, 0, `**StartShas:** ${shaJson}`, '');
1744
+ } else {
1745
+ // Fallback: insert after Check header line
1746
+ const checkIdx = lines.findIndex(l => /^\*\*Check:\*\*\s+/.test(l));
1747
+ if (checkIdx !== -1) {
1748
+ lines.splice(checkIdx + 1, 0, `**StartShas:** ${shaJson}`);
1749
+ }
1750
+ }
1751
+
1752
+ fs.writeFileSync(jobFilePath, lines.join('\n'), 'utf-8');
1753
+ return { recorded: true, shas };
1754
+ } catch (err) {
1755
+ return { recorded: false, reason: err.message };
1756
+ }
1757
+ }
1758
+
1759
+ /**
1760
+ * Roll back all code repo changes made by a milestone job.
1761
+ * Reads StartShas from the job file, resets CODE repos (not planning),
1762
+ * deletes SUMMARY.md files for executed phases, and marks job as rolled_back.
1763
+ *
1764
+ * @param {string} cwd - Working directory
1765
+ * @param {string} version - Milestone version (e.g., "v6.0")
1766
+ * @returns {{ rolledBack: boolean, version?: string, repos_reset?: Array, summaries_deleted?: Array, path?: string, reason?: string }}
1767
+ */
1768
+ function rollbackJob(cwd, version) {
1769
+ const found = findJobFile(cwd, version);
1770
+ if (!found.found) {
1771
+ return { rolledBack: false, reason: 'not_found' };
1772
+ }
1773
+
1774
+ const filePath = found.path;
1775
+ const parsed = parseJobFile(filePath);
1776
+
1777
+ if (!parsed.startShas) {
1778
+ return { rolledBack: false, reason: 'no_start_shas' };
1779
+ }
1780
+
1781
+ const planningRoot = getPlanningRoot(cwd);
1782
+ const reposReset = [];
1783
+ const summariesDeleted = [];
1784
+
1785
+ // Reset code repos (all keys except _planning)
1786
+ for (const [repoName, sha] of Object.entries(parsed.startShas)) {
1787
+ if (repoName === '_planning') continue;
1788
+
1789
+ // Resolve repo path: check REPOS.md for the path
1790
+ let absRepoPath = null;
1791
+ const reposPath = path.join(planningRoot, 'REPOS.md');
1792
+ if (fs.existsSync(reposPath)) {
1793
+ const reposContent = fs.readFileSync(reposPath, 'utf-8');
1794
+ const repoLines = reposContent.split('\n');
1795
+ for (const line of repoLines) {
1796
+ const match = line.match(/^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|/);
1797
+ if (!match) continue;
1798
+ const name = match[1].trim();
1799
+ const repoPathStr = match[2].trim();
1800
+ if (name === repoName && repoPathStr && !repoPathStr.startsWith('-')) {
1801
+ absRepoPath = path.resolve(path.dirname(planningRoot), repoPathStr);
1802
+ break;
1803
+ }
1804
+ }
1805
+ }
1806
+
1807
+ if (!absRepoPath || !fs.existsSync(absRepoPath)) continue;
1808
+
1809
+ try {
1810
+ execSync(`git reset --hard ${sha}`, { cwd: absRepoPath, stdio: 'pipe' });
1811
+ reposReset.push({ name: repoName, sha, path: absRepoPath });
1812
+ } catch (gitErr) {
1813
+ // Log but continue with other repos
1814
+ reposReset.push({ name: repoName, sha, error: gitErr.message });
1815
+ }
1816
+ }
1817
+
1818
+ // Identify executed phases from completed steps
1819
+ const projectRoot = resolveProjectRoot(cwd);
1820
+ const phasesDir = path.join(projectRoot, 'phases');
1821
+
1822
+ for (const step of parsed.steps) {
1823
+ if (step.status === 'completed' && step.command === 'execute-phase') {
1824
+ // Extract phase number from args (first token)
1825
+ const phaseNum = step.args.split(/\s+/)[0];
1826
+ if (!phaseNum) continue;
1827
+
1828
+ const padded = phaseNum.replace(/^(\d+)/, (m) => m.padStart(2, '0'));
1829
+
1830
+ try {
1831
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1832
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1833
+ const dirMatch = dirs.find(d => d.startsWith(padded + '-') || d === padded);
1834
+
1835
+ if (dirMatch) {
1836
+ const phaseDir = path.join(phasesDir, dirMatch);
1837
+ const phaseFiles = fs.readdirSync(phaseDir);
1838
+ const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1839
+
1840
+ for (const sf of summaryFiles) {
1841
+ const sfPath = path.join(phaseDir, sf);
1842
+ try {
1843
+ fs.unlinkSync(sfPath);
1844
+ summariesDeleted.push(sfPath);
1845
+ } catch {
1846
+ // Skip files that can't be deleted
1847
+ }
1848
+ }
1849
+ }
1850
+ } catch {
1851
+ // Phase directory doesn't exist, skip
1852
+ }
1853
+ }
1854
+ }
1855
+
1856
+ // Update job status to rolled_back
1857
+ updateJobHeader(filePath, 'Status', 'rolled_back');
1858
+
1859
+ return {
1860
+ rolledBack: true,
1861
+ version: version.startsWith('v') ? version : 'v' + version,
1862
+ repos_reset: reposReset,
1863
+ summaries_deleted: summariesDeleted,
1864
+ path: filePath,
1865
+ };
1866
+ }
1867
+
1868
+ /**
1869
+ * CLI wrapper for recordStartShas.
1870
+ * Usage: dgs-tools.cjs jobs record-start-shas <file>
1871
+ */
1872
+ function cmdJobsRecordStartShas(cwd, file, raw) {
1873
+ if (!file) {
1874
+ error('file path required. Usage: jobs record-start-shas <file>');
1875
+ }
1876
+ const filePath = path.isAbsolute(file) ? file : path.join(cwd, file);
1877
+ try {
1878
+ const result = recordStartShas(cwd, filePath);
1879
+ output(result, raw);
1880
+ } catch (err) {
1881
+ error(err.message);
1882
+ }
1883
+ }
1884
+
1885
+ /**
1886
+ * CLI wrapper for rollbackJob.
1887
+ * Usage: dgs-tools.cjs jobs rollback <version>
1888
+ */
1889
+ function cmdJobsRollback(cwd, version, raw) {
1890
+ if (!version) {
1891
+ error('version required. Usage: jobs rollback <version>');
1892
+ }
1893
+ try {
1894
+ const result = rollbackJob(cwd, version);
1895
+ output(result, raw);
1896
+ } catch (err) {
1897
+ error(err.message);
1898
+ }
1899
+ }
1900
+
1901
+ // ─── Visibility & Polish CLI Wrappers ───────────────────────────────────────
1902
+
1903
+ /**
1904
+ * CLI wrapper for listJobs.
1905
+ * Usage: dgs-tools.cjs jobs list-jobs
1906
+ */
1907
+ function cmdJobsListJobs(cwd, raw) {
1908
+ try {
1909
+ const result = listJobs(cwd);
1910
+ output(result, raw);
1911
+ } catch (err) {
1912
+ error(err.message);
1913
+ }
1914
+ }
1915
+
1916
+ /**
1917
+ * CLI wrapper for cancelJob.
1918
+ * Usage: dgs-tools.cjs jobs cancel-job <version>
1919
+ */
1920
+ function cmdJobsCancelJob(cwd, version, raw) {
1921
+ if (!version) {
1922
+ error('version required. Usage: jobs cancel-job <version>');
1923
+ }
1924
+ try {
1925
+ const result = cancelJob(cwd, version);
1926
+ output(result, raw);
1927
+ } catch (err) {
1928
+ error(err.message);
1929
+ }
1930
+ }
1931
+
1932
+ /**
1933
+ * CLI wrapper for healthCheck.
1934
+ * Usage: dgs-tools.cjs jobs health
1935
+ */
1936
+ function cmdJobsHealthCheck(cwd, raw) {
1937
+ try {
1938
+ const result = healthCheck(cwd);
1939
+ output(result, raw);
1940
+ } catch (err) {
1941
+ error(err.message);
1942
+ }
1943
+ }
1944
+
1945
+ /**
1946
+ * CLI wrapper for dryRunPreview.
1947
+ * Usage: dgs-tools.cjs jobs dry-run <version>
1948
+ */
1949
+ function cmdJobsDryRun(cwd, version, raw) {
1950
+ if (!version) {
1951
+ error('version required. Usage: jobs dry-run <version>');
1952
+ }
1953
+ try {
1954
+ const result = dryRunPreview(cwd, version);
1955
+ output(result, raw);
1956
+ } catch (err) {
1957
+ error(err.message);
1958
+ }
1959
+ }
1960
+
1961
+ /**
1962
+ * CLI wrapper for generateJobSummary.
1963
+ * Usage: dgs-tools.cjs jobs generate-summary <version>
1964
+ */
1965
+ function cmdJobsGenerateSummary(cwd, version, raw) {
1966
+ if (!version) {
1967
+ error('version required. Usage: jobs generate-summary <version>');
1968
+ }
1969
+ try {
1970
+ const result = generateJobSummary(cwd, version);
1971
+ output(result, raw);
1972
+ } catch (err) {
1973
+ error(err.message);
1974
+ }
1975
+ }
1976
+
1977
+ module.exports = {
1978
+ parseJobFile,
1979
+ updateJobStep,
1980
+ moveJobFile,
1981
+ findJobFile,
1982
+ updateJobHeader,
1983
+ insertJobSteps,
1984
+ buildGapFixSteps,
1985
+ buildPhaseGapFixSteps,
1986
+ insertGapFixSection,
1987
+ insertPhaseGapFixSection,
1988
+ updatePhaseFixCycleHeader,
1989
+ generateMilestoneSteps,
1990
+ buildJobFileContent,
1991
+ listJobs,
1992
+ cancelJob,
1993
+ recordStartShas,
1994
+ rollbackJob,
1995
+ healthCheck,
1996
+ dryRunPreview,
1997
+ generateJobSummary,
1998
+ cmdJobsParse,
1999
+ cmdJobsUpdateStep,
2000
+ cmdJobsMove,
2001
+ cmdJobsFindJob,
2002
+ cmdJobsUpdateHeader,
2003
+ cmdJobsInsertSteps,
2004
+ cmdJobsInsertGapFixSection,
2005
+ cmdJobsInsertPhaseGapFix,
2006
+ cmdJobsCreateMilestone,
2007
+ cmdJobsMilestonePreview,
2008
+ cmdJobsListJobs,
2009
+ cmdJobsCancelJob,
2010
+ cmdJobsRecordStartShas,
2011
+ cmdJobsRollback,
2012
+ cmdJobsHealthCheck,
2013
+ cmdJobsDryRun,
2014
+ cmdJobsGenerateSummary,
2015
+ };