@ktpartners/dgs-platform 2.9.0 → 3.3.0

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 (166) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/README.md +34 -2
  3. package/agents/dgs-executor.md +124 -3
  4. package/agents/dgs-idea-researcher.md +447 -0
  5. package/agents/dgs-plan-checker.md +61 -3
  6. package/agents/dgs-planner.md +51 -8
  7. package/bin/install.js +44 -0
  8. package/commands/dgs/abandon-quick.md +28 -0
  9. package/commands/dgs/add-tests.md +2 -2
  10. package/commands/dgs/audit-milestone.md +4 -3
  11. package/commands/dgs/capture-principle.md +11 -11
  12. package/commands/dgs/cleanup.md +2 -2
  13. package/commands/dgs/complete-milestone.md +11 -11
  14. package/commands/dgs/complete-quick.md +28 -0
  15. package/commands/dgs/create-milestone-job.md +2 -2
  16. package/commands/dgs/debug.md +3 -3
  17. package/commands/dgs/develop-idea.md +1 -1
  18. package/commands/dgs/diff-report.md +124 -0
  19. package/commands/dgs/fast.md +3 -1
  20. package/commands/dgs/health.md +1 -1
  21. package/commands/dgs/map-codebase.md +6 -6
  22. package/commands/dgs/new-milestone.md +5 -5
  23. package/commands/dgs/new-project.md +8 -21
  24. package/commands/dgs/package-scan.md +43 -0
  25. package/commands/dgs/plan-milestone-gaps.md +1 -1
  26. package/commands/dgs/progress.md +3 -3
  27. package/commands/dgs/quick-abandon.md +8 -0
  28. package/commands/dgs/quick-complete.md +8 -0
  29. package/commands/dgs/quick.md +10 -3
  30. package/commands/dgs/research-idea.md +3 -2
  31. package/commands/dgs/research-phase.md +3 -3
  32. package/commands/dgs/switch-project.md +14 -1
  33. package/commands/dgs/write-spec.md +3 -3
  34. package/deliver-great-systems/bin/dgs-tools.cjs +401 -32
  35. package/deliver-great-systems/bin/lib/audit-tolerance.cjs +77 -0
  36. package/deliver-great-systems/bin/lib/audit-tolerance.test.cjs +101 -0
  37. package/deliver-great-systems/bin/lib/commands.cjs +626 -46
  38. package/deliver-great-systems/bin/lib/commands.test.cjs +451 -0
  39. package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
  40. package/deliver-great-systems/bin/lib/config.cjs +80 -6
  41. package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
  42. package/deliver-great-systems/bin/lib/context.cjs +120 -0
  43. package/deliver-great-systems/bin/lib/core.cjs +35 -14
  44. package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
  45. package/deliver-great-systems/bin/lib/execution.cjs +49 -17
  46. package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
  47. package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
  48. package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
  49. package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
  50. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
  51. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
  52. package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
  53. package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
  54. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
  55. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
  56. package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
  57. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
  58. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
  59. package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
  60. package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
  61. package/deliver-great-systems/bin/lib/governance.cjs +211 -0
  62. package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
  63. package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
  64. package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
  65. package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
  66. package/deliver-great-systems/bin/lib/init.cjs +357 -61
  67. package/deliver-great-systems/bin/lib/init.test.cjs +625 -8
  68. package/deliver-great-systems/bin/lib/jobs.cjs +131 -25
  69. package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
  70. package/deliver-great-systems/bin/lib/migration.cjs +409 -1
  71. package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
  72. package/deliver-great-systems/bin/lib/milestone.cjs +154 -31
  73. package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
  74. package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
  75. package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
  76. package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
  77. package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
  78. package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
  79. package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
  80. package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
  81. package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
  82. package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
  83. package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
  84. package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
  85. package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
  86. package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
  87. package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
  88. package/deliver-great-systems/bin/lib/phase.cjs +146 -3
  89. package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
  90. package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
  91. package/deliver-great-systems/bin/lib/projects.cjs +65 -10
  92. package/deliver-great-systems/bin/lib/projects.test.cjs +198 -2
  93. package/deliver-great-systems/bin/lib/quick.cjs +739 -0
  94. package/deliver-great-systems/bin/lib/quick.test.cjs +730 -0
  95. package/deliver-great-systems/bin/lib/repos.cjs +37 -13
  96. package/deliver-great-systems/bin/lib/review.cjs +1821 -0
  97. package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
  98. package/deliver-great-systems/bin/lib/specs.cjs +3 -81
  99. package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
  100. package/deliver-great-systems/bin/lib/state.cjs +147 -55
  101. package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
  102. package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
  103. package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
  104. package/deliver-great-systems/bin/lib/sync.cjs +75 -0
  105. package/deliver-great-systems/bin/lib/verify.cjs +198 -7
  106. package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
  107. package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
  108. package/deliver-great-systems/bin/lib/worktrees.cjs +790 -0
  109. package/deliver-great-systems/bin/lib/worktrees.test.cjs +963 -0
  110. package/deliver-great-systems/references/agent-step-reliability.md +60 -0
  111. package/deliver-great-systems/references/conflict-resolution.md +4 -0
  112. package/deliver-great-systems/references/context-tiers.md +4 -0
  113. package/deliver-great-systems/references/package-scan-config.md +151 -0
  114. package/deliver-great-systems/references/questioning.md +0 -30
  115. package/deliver-great-systems/references/spec-review-loop.md +1 -2
  116. package/deliver-great-systems/references/workflow-conventions.md +29 -0
  117. package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
  118. package/deliver-great-systems/templates/REVIEW.md +35 -0
  119. package/deliver-great-systems/templates/VALIDATION.md +1 -1
  120. package/deliver-great-systems/templates/claude-md.md +27 -0
  121. package/deliver-great-systems/templates/package-scan-report.md +108 -0
  122. package/deliver-great-systems/templates/project.md +6 -170
  123. package/deliver-great-systems/templates/summary.md +3 -1
  124. package/deliver-great-systems/workflows/abandon-quick.md +89 -0
  125. package/deliver-great-systems/workflows/add-idea.md +3 -3
  126. package/deliver-great-systems/workflows/add-phase.md +5 -0
  127. package/deliver-great-systems/workflows/add-tests.md +14 -0
  128. package/deliver-great-systems/workflows/add-todo.md +1 -0
  129. package/deliver-great-systems/workflows/approve-spec.md +25 -4
  130. package/deliver-great-systems/workflows/audit-milestone.md +66 -10
  131. package/deliver-great-systems/workflows/audit-phase.md +15 -5
  132. package/deliver-great-systems/workflows/cancel-job.md +2 -2
  133. package/deliver-great-systems/workflows/check-todos.md +2 -3
  134. package/deliver-great-systems/workflows/codereview.md +103 -9
  135. package/deliver-great-systems/workflows/complete-milestone.md +218 -24
  136. package/deliver-great-systems/workflows/complete-quick.md +106 -0
  137. package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
  138. package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
  139. package/deliver-great-systems/workflows/develop-idea.md +11 -11
  140. package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
  141. package/deliver-great-systems/workflows/discuss-idea.md +1 -1
  142. package/deliver-great-systems/workflows/discuss-phase.md +3 -2
  143. package/deliver-great-systems/workflows/execute-phase.md +209 -33
  144. package/deliver-great-systems/workflows/execute-plan.md +22 -22
  145. package/deliver-great-systems/workflows/help.md +53 -20
  146. package/deliver-great-systems/workflows/import-spec.md +65 -7
  147. package/deliver-great-systems/workflows/init-product.md +45 -167
  148. package/deliver-great-systems/workflows/new-milestone.md +140 -33
  149. package/deliver-great-systems/workflows/new-project.md +60 -331
  150. package/deliver-great-systems/workflows/package-scan.md +59 -0
  151. package/deliver-great-systems/workflows/plan-phase.md +79 -1
  152. package/deliver-great-systems/workflows/progress-all.md +133 -0
  153. package/deliver-great-systems/workflows/quick-abandon.md +89 -0
  154. package/deliver-great-systems/workflows/quick-complete.md +106 -0
  155. package/deliver-great-systems/workflows/quick.md +328 -26
  156. package/deliver-great-systems/workflows/refine-spec.md +1 -1
  157. package/deliver-great-systems/workflows/research-idea.md +77 -139
  158. package/deliver-great-systems/workflows/resume-project.md +2 -2
  159. package/deliver-great-systems/workflows/run-job.md +29 -43
  160. package/deliver-great-systems/workflows/settings.md +13 -77
  161. package/deliver-great-systems/workflows/validate-phase.md +39 -1
  162. package/deliver-great-systems/workflows/verify-work.md +14 -0
  163. package/deliver-great-systems/workflows/write-spec.md +11 -13
  164. package/hooks/dist/dgs-enforce-discipline.js +196 -0
  165. package/package.json +1 -1
  166. package/scripts/build-hooks.js +1 -0
@@ -4,10 +4,12 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { execSync } = require('child_process');
7
- const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, MODEL_PROFILES, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs');
8
- const { extractFrontmatter } = require('./frontmatter.cjs');
7
+ const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getProjectRoot, resolveModelInternal, MODEL_PROFILES, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs');
8
+ const { extractFrontmatter, spliceFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
9
9
  const { getPlanningRoot } = require('./paths.cjs');
10
10
 
11
+ const TODO_STATUSES = ['pending', 'done'];
12
+
11
13
  function cmdGenerateSlug(text, raw) {
12
14
  if (!text) {
13
15
  error('text required for slug generation');
@@ -43,15 +45,62 @@ function cmdCurrentTimestamp(format, raw) {
43
45
  }
44
46
 
45
47
  function cmdListTodos(cwd, area, raw) {
46
- const pendingDir = path.join(getPlanningRoot(cwd), 'todos', 'pending');
47
-
48
48
  let count = 0;
49
49
  const todos = [];
50
+ const seenFiles = new Set();
50
51
 
52
+ // Flat-first scan: check todos/ root directory
53
+ const flatDir = path.join(getPlanningRoot(cwd), 'todos');
51
54
  try {
52
- const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
55
+ const flatFiles = fs.readdirSync(flatDir).filter(f => f.endsWith('.md') && !fs.statSync(path.join(flatDir, f)).isDirectory());
56
+ for (const file of flatFiles) {
57
+ try {
58
+ const content = fs.readFileSync(path.join(flatDir, file), 'utf-8');
59
+
60
+ // Parse frontmatter status (if present)
61
+ let status = null;
62
+ const fmMatch = content.match(/^---\n([\s\S]+?)\n---/);
63
+ if (fmMatch) {
64
+ const statusMatch = fmMatch[1].match(/^status:\s*(.+)$/m);
65
+ if (statusMatch) status = statusMatch[1].trim();
66
+ }
67
+
68
+ // Only list pending todos (default behavior)
69
+ if (status && status !== 'pending') continue;
70
+
71
+ const createdMatch = content.match(/^created:\s*(.+)$/m);
72
+ const titleMatch = content.match(/^title:\s*(.+)$/m);
73
+ const areaMatch = content.match(/^area:\s*(.+)$/m);
74
+
75
+ const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
76
+ if (area && todoArea !== area) continue;
77
+
78
+ count++;
79
+ seenFiles.add(file);
80
+ todos.push({
81
+ file,
82
+ created: createdMatch ? createdMatch[1].trim() : 'unknown',
83
+ title: titleMatch ? titleMatch[1].trim() : 'Untitled',
84
+ area: todoArea,
85
+ status: status || 'pending',
86
+ path: toPosixPath(path.join(path.relative(cwd, getPlanningRoot(cwd)) || '.', 'todos', file)),
87
+ });
88
+ } catch {}
89
+ }
90
+ } catch {
91
+ // Flat directory may not exist
92
+ }
53
93
 
94
+ // Legacy fallback: check todos/pending/
95
+ const pendingDir = path.join(getPlanningRoot(cwd), 'todos', 'pending');
96
+ try {
97
+ const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
54
98
  for (const file of files) {
99
+ if (seenFiles.has(file)) continue; // Skip if already found in flat directory
100
+
101
+ // Legacy fallback warning
102
+ process.stderr.write(`[DGS] Warning: todo '${file}' found in legacy pending/ directory. Run migration to flatten.\n`);
103
+
55
104
  try {
56
105
  const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8');
57
106
  const createdMatch = content.match(/^created:\s*(.+)$/m);
@@ -59,8 +108,6 @@ function cmdListTodos(cwd, area, raw) {
59
108
  const areaMatch = content.match(/^area:\s*(.+)$/m);
60
109
 
61
110
  const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
62
-
63
- // Apply area filter if specified
64
111
  if (area && todoArea !== area) continue;
65
112
 
66
113
  count++;
@@ -69,6 +116,7 @@ function cmdListTodos(cwd, area, raw) {
69
116
  created: createdMatch ? createdMatch[1].trim() : 'unknown',
70
117
  title: titleMatch ? titleMatch[1].trim() : 'Untitled',
71
118
  area: todoArea,
119
+ status: 'pending',
72
120
  path: toPosixPath(path.join(path.relative(cwd, getPlanningRoot(cwd)) || '.', 'todos', 'pending', file)),
73
121
  });
74
122
  } catch {}
@@ -98,7 +146,13 @@ function cmdVerifyPathExists(cwd, targetPath, raw) {
98
146
  }
99
147
 
100
148
  function cmdHistoryDigest(cwd, raw) {
101
- const phasesDir = path.join(getPlanningRoot(cwd), 'phases');
149
+ let phasesDir;
150
+ try {
151
+ const projectRoot = getProjectRoot(cwd);
152
+ phasesDir = path.join(cwd, projectRoot, 'phases');
153
+ } catch {
154
+ phasesDir = path.join(getPlanningRoot(cwd), 'phases');
155
+ }
102
156
  const digest = { phases: {}, decisions: [], tech_stack: new Set() };
103
157
 
104
158
  // Collect all phase directories: archived + current
@@ -214,44 +268,86 @@ function cmdResolveModel(cwd, agentType, raw) {
214
268
  output(result, raw, model);
215
269
  }
216
270
 
217
- function cmdCommit(cwd, message, files, raw, amend, push) {
218
- if (!message && !amend) {
219
- error('commit message required');
220
- }
271
+ /**
272
+ * Commit staged/unstaged files with an optional push orchestration.
273
+ *
274
+ * @param {string} cwd - Planning-root cwd. Config (commit_docs, sync_push,
275
+ * current_project, worktree map) is always loaded from this directory.
276
+ * @param {string} message - Commit message (required unless amend=true).
277
+ * @param {string[]} files - Paths to stage. Resolved relative to repoCwd
278
+ * (or cwd if repoCwd is not set). Defaults to ['.'].
279
+ * @param {boolean} raw - Emit raw (non-JSON) output.
280
+ * @param {boolean} amend - Run `git commit --amend --no-edit` instead.
281
+ * @param {boolean} push - Orchestrate push via pushAll() when sync_push=auto.
282
+ * @param {string} [repoCwd] - Optional absolute path where git operations run.
283
+ * Config loading still uses cwd. Used by fast-path in milestone-context to
284
+ * commit in a worktree while loading config from the planning root.
285
+ */
286
+ // Collect the list of still-dirty paths in `gitCwd` immediately after a
287
+ // commit (or nothing_to_commit). Purely informational: populates
288
+ // `result.dirty_after` so callers can detect verify-step side effects
289
+ // (formatter reflows, type narrowings) that leaked outside the staged
290
+ // file set. Never throws — returns [] on any error.
291
+ function collectDirtyAfter(gitCwd) {
292
+ const porcelain = execGit(gitCwd, ['status', '--porcelain']);
293
+ if (porcelain.exitCode !== 0) return [];
294
+ return (porcelain.stdout || '')
295
+ .split('\n')
296
+ .map(l => l.trim())
297
+ .filter(Boolean)
298
+ .map(l => l.replace(/^..\s+/, '')); // strip two-char XY status prefix + space
299
+ }
221
300
 
301
+ /**
302
+ * Internal commit primitive — RETURNS the JSON result object instead of
303
+ * calling output()/process.exit. Extracted from cmdCommit for reuse by
304
+ * verifyPlanCommit (REL-01, Phase 156). External callers should keep
305
+ * using cmdCommit; this helper is library-internal only.
306
+ *
307
+ * @param {string} cwd - Planning root (config loaded from here)
308
+ * @param {string} message - Commit message (required unless amend)
309
+ * @param {string[]|undefined} files - Files to stage; if empty/undefined,
310
+ * the historical behaviour is to fall back to ['.'] (sweeping the
311
+ * working tree). REL-01's verifyPlanCommit guards against this fallback
312
+ * for the orchestrator commit case BEFORE it ever reaches commitInternal.
313
+ * @param {boolean} amend
314
+ * @param {boolean} push
315
+ * @param {string} [repoCwd] - Where git operations actually run.
316
+ * @returns {object} JSON result matching cmdCommit's existing contract.
317
+ */
318
+ function commitInternal(cwd, message, files, amend, push, repoCwd) {
222
319
  const config = loadConfig(cwd);
223
320
 
224
321
  // Check commit_docs config
225
322
  if (!config.commit_docs) {
226
- const result = { committed: false, hash: null, reason: 'skipped_commit_docs_false' };
227
- output(result, raw, 'skipped');
228
- return;
323
+ return { committed: false, hash: null, reason: 'skipped_commit_docs_false' };
229
324
  }
230
325
 
326
+ // Resolve git-operation cwd: use repoCwd when provided, otherwise cwd.
327
+ // Config is always loaded from cwd (planning root); only git exec targets
328
+ // the worktree filesystem.
329
+ const gitCwd = repoCwd || cwd;
330
+
231
331
  // Stage files
232
332
  const filesToStage = files && files.length > 0 ? files : ['.'];
233
333
  for (const file of filesToStage) {
234
- execGit(cwd, ['add', file]);
334
+ execGit(gitCwd, ['add', file]);
235
335
  }
236
336
 
237
337
  // Commit
238
338
  const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', message];
239
- const commitResult = execGit(cwd, commitArgs);
339
+ const commitResult = execGit(gitCwd, commitArgs);
240
340
  if (commitResult.exitCode !== 0) {
241
341
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
242
- const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
243
- output(result, raw, 'nothing');
244
- return;
342
+ return { committed: false, hash: null, reason: 'nothing_to_commit', dirty_after: collectDirtyAfter(gitCwd) };
245
343
  }
246
- const result = { committed: false, hash: null, reason: 'nothing_to_commit', error: commitResult.stderr };
247
- output(result, raw, 'nothing');
248
- return;
344
+ return { committed: false, hash: null, reason: 'nothing_to_commit', error: commitResult.stderr, dirty_after: collectDirtyAfter(gitCwd) };
249
345
  }
250
346
 
251
347
  // Get short hash
252
- const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
348
+ const hashResult = execGit(gitCwd, ['rev-parse', '--short', 'HEAD']);
253
349
  const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
254
- const result = { committed: true, hash, reason: 'committed' };
350
+ const result = { committed: true, hash, reason: 'committed', dirty_after: collectDirtyAfter(gitCwd) };
255
351
 
256
352
  // Handle push if requested
257
353
  if (push) {
@@ -273,7 +369,376 @@ function cmdCommit(cwd, message, files, raw, amend, push) {
273
369
  // 'off' or any other value: no push fields added
274
370
  }
275
371
 
276
- output(result, raw, hash || 'committed');
372
+ return result;
373
+ }
374
+
375
+ function cmdCommit(cwd, message, files, raw, amend, push, repoCwd) {
376
+ if (!message && !amend) {
377
+ error('commit message required');
378
+ }
379
+
380
+ const result = commitInternal(cwd, message, files, amend, push, repoCwd);
381
+
382
+ // Branch labels per the original cmdCommit output() calls
383
+ if (result.reason === 'skipped_commit_docs_false') {
384
+ output(result, raw, 'skipped');
385
+ return;
386
+ }
387
+ if (result.reason === 'nothing_to_commit') {
388
+ output(result, raw, 'nothing');
389
+ return;
390
+ }
391
+ output(result, raw, result.hash || 'committed');
392
+ }
393
+
394
+ /**
395
+ * REL-01 (Phase 156, plan 02): orchestrator-side commit + verification
396
+ * helper consumed by /dgs:plan-phase to commit planner-reported
397
+ * created_files. Guarantees:
398
+ *
399
+ * - Empty createdFiles → returns plan-commit-incomplete WITHOUT calling
400
+ * commitInternal. Defends against cmdCommit's `['.']` fallback that
401
+ * would otherwise sweep the working tree (Hypothesis C from
402
+ * 156-Q1-FINDINGS.md).
403
+ * - commit_docs:false → silent success ({ ok: true, hash: null,
404
+ * reason: 'skipped_commit_docs_false' }). NOT a failure.
405
+ * - cmdCommit failure → plan-commit-incomplete with reason: 'commit_failed'.
406
+ * - Verification: every entry in (createdFiles + extraFiles) MUST appear
407
+ * in `git show --name-only HEAD`; mismatches → plan-commit-incomplete
408
+ * with reason: 'verification_failed' and a `missing` array.
409
+ *
410
+ * @param {string} cwd - Planning root (config loaded from here)
411
+ * @param {object} options - {
412
+ * message: string (REQUIRED),
413
+ * createdFiles: string[] (REQUIRED),
414
+ * extraFiles?: string[],
415
+ * repoCwd?: string,
416
+ * push?: boolean,
417
+ * }
418
+ * @param {boolean} raw - Emit raw JSON via output() if true.
419
+ * @returns {object} Returns the result object directly (so the test
420
+ * harness can assert on it). Also calls output() when invoked from CLI.
421
+ */
422
+ function verifyPlanCommit(cwd, options, raw) {
423
+ const opts = options || {};
424
+ const message = opts.message;
425
+ const createdFiles = opts.createdFiles;
426
+ const extraFiles = opts.extraFiles || [];
427
+ const repoCwd = opts.repoCwd;
428
+ const push = !!opts.push;
429
+
430
+ // verifyPlanCommit is a pure helper — it RETURNS the result object
431
+ // (so library callers and tests can assert on it). The `raw` argument
432
+ // is accepted for CLI dispatch parity but is intentionally unused
433
+ // here; the dgs-tools CLI dispatcher (dgs-tools.cjs) is responsible
434
+ // for calling output()/process.exit on the returned object.
435
+ void raw;
436
+
437
+ if (!message) {
438
+ return {
439
+ ok: false,
440
+ exitLabel: 'plan-commit-incomplete',
441
+ reason: 'missing_message',
442
+ };
443
+ }
444
+
445
+ // Empty-list guard — defends Hypothesis C (cmdCommit `['.']` fallback)
446
+ if (!Array.isArray(createdFiles) || createdFiles.length === 0) {
447
+ return {
448
+ ok: false,
449
+ exitLabel: 'plan-commit-incomplete',
450
+ reason: 'empty_created_files',
451
+ };
452
+ }
453
+
454
+ const filesToCommit = [...createdFiles, ...extraFiles];
455
+
456
+ const commitResult = commitInternal(cwd, message, filesToCommit, false, push, repoCwd);
457
+
458
+ // commit_docs config gate — silent success
459
+ if (commitResult.reason === 'skipped_commit_docs_false') {
460
+ return {
461
+ ok: true,
462
+ hash: null,
463
+ reason: 'skipped_commit_docs_false',
464
+ };
465
+ }
466
+
467
+ // Any non-success commit reason is a plan-commit-incomplete failure
468
+ if (commitResult.committed !== true) {
469
+ return {
470
+ ok: false,
471
+ exitLabel: 'plan-commit-incomplete',
472
+ reason: 'commit_failed',
473
+ commit_reason: commitResult.reason,
474
+ error: commitResult.error,
475
+ };
476
+ }
477
+
478
+ // Verification: every reported path MUST appear in HEAD
479
+ const gitCwd = repoCwd || cwd;
480
+ const showResult = execGit(gitCwd, ['show', '--name-only', '--pretty=', 'HEAD']);
481
+ const committedFiles = (showResult.stdout || '')
482
+ .split('\n')
483
+ .map(l => l.trim())
484
+ .filter(Boolean);
485
+ const missing = filesToCommit.filter(f => !committedFiles.includes(f));
486
+
487
+ if (missing.length > 0) {
488
+ return {
489
+ ok: false,
490
+ exitLabel: 'plan-commit-incomplete',
491
+ reason: 'verification_failed',
492
+ hash: commitResult.hash,
493
+ missing,
494
+ committedFiles,
495
+ };
496
+ }
497
+
498
+ return {
499
+ ok: true,
500
+ hash: commitResult.hash,
501
+ reason: 'committed',
502
+ files_verified: filesToCommit,
503
+ missing: [],
504
+ };
505
+ }
506
+
507
+ /**
508
+ * REL-02 (Phase 156, plan 03): compute the executor's final-commit
509
+ * phase-dir sweep. Always sweeps the current phase directory and takes
510
+ * the UNION with the executor-reported modified_files list, then
511
+ * scope-filters out anything that does not start with the
512
+ * ${phasesDir}/${phaseDir}/ prefix.
513
+ *
514
+ * Hard scope guarantee: dirty files in sibling phases, ideas/, specs/,
515
+ * or the project root are NEVER returned in `swept`. They are returned
516
+ * in `dropped` for diagnostic visibility.
517
+ *
518
+ * @param {string} cwd - Planning root (where git status runs)
519
+ * @param {object} options - {
520
+ * phasesDir: string, // e.g. 'projects/gsd/phases'
521
+ * phaseDir: string, // e.g. '156-idea-26-closure-...'
522
+ * modifiedFiles?: string[] // executor-reported paths (planning-root-relative)
523
+ * }
524
+ * @param {boolean} raw
525
+ * @returns {{
526
+ * swept: string[], // commit list — UNION, scope-filtered, sorted
527
+ * dropped: string[], // out-of-scope paths the helper rejected
528
+ * gitDirtyPaths: string[], // git-discovered phase-dir paths (pre-union)
529
+ * reportedPaths: string[], // executor-reported list (pre-union)
530
+ * scopePrefix: string // ${phasesDir}/${phaseDir}/
531
+ * }}
532
+ */
533
+ function computePhaseSweep(cwd, options, raw) {
534
+ const opts = options || {};
535
+ const phasesDir = opts.phasesDir;
536
+ const phaseDir = opts.phaseDir;
537
+ const reportedPathsRaw = opts.modifiedFiles || [];
538
+
539
+ void raw;
540
+
541
+ if (!phasesDir || !phaseDir) {
542
+ return {
543
+ swept: [],
544
+ dropped: [],
545
+ gitDirtyPaths: [],
546
+ reportedPaths: [],
547
+ scopePrefix: null,
548
+ error: 'phasesDir and phaseDir required for compute-phase-sweep',
549
+ };
550
+ }
551
+
552
+ const scopePrefix = `${phasesDir}/${phaseDir}/`;
553
+
554
+ // Run path-scoped porcelain. Use --untracked-files=all so untracked
555
+ // files inside untracked directories are listed individually rather
556
+ // than collapsed into a single directory entry like `phases/{dir}/`.
557
+ const gitArgs = ['status', '--porcelain', '--untracked-files=all', '--', `${phasesDir}/${phaseDir}`];
558
+ const gitResult = execGit(cwd, gitArgs);
559
+ const gitDirtyPaths = gitResult.exitCode === 0
560
+ ? (gitResult.stdout || '')
561
+ .split('\n')
562
+ .map(l => l.trim())
563
+ .filter(Boolean)
564
+ // Strip 2-char XY status prefix + space (matches collectDirtyAfter)
565
+ .map(l => l.replace(/^..\s+/, ''))
566
+ : [];
567
+
568
+ // Strip optional `repoName:` prefix from reported paths — multi-repo
569
+ // entries with explicit repoName are out of scope for the planning-root
570
+ // sweep (they route through their own resolveRepoRelativePath flow).
571
+ const reportedPaths = reportedPathsRaw
572
+ .map(p => {
573
+ // If the entry contains a `:` and the leading segment looks like a
574
+ // repo name (no `/`), treat it as repoName:path and drop it for
575
+ // planning-root sweep purposes.
576
+ const colonIdx = p.indexOf(':');
577
+ if (colonIdx > 0 && p.indexOf('/') > colonIdx) return null;
578
+ return p;
579
+ })
580
+ .filter(p => p !== null);
581
+
582
+ // Compute the UNION (dedupe via Set)
583
+ const candidates = Array.from(new Set([...gitDirtyPaths, ...reportedPaths]));
584
+
585
+ // Belt-and-braces: scope-filter via prefix check
586
+ const swept = candidates
587
+ .filter(p => p.startsWith(scopePrefix))
588
+ .sort();
589
+ const dropped = candidates
590
+ .filter(p => !p.startsWith(scopePrefix))
591
+ .sort();
592
+
593
+ return {
594
+ swept,
595
+ dropped,
596
+ gitDirtyPaths,
597
+ reportedPaths,
598
+ scopePrefix,
599
+ };
600
+ }
601
+
602
+ /**
603
+ * CLI: `plan finalize <phase> <plan> [--plan-name <name>] [--push]`.
604
+ *
605
+ * Runs state update-progress + roadmap update-plan-progress + requirements
606
+ * mark-complete AND commits PLAN.md + SUMMARY.md + tracking files in a single
607
+ * atomic call. Requirement IDs are auto-extracted from PLAN.md frontmatter.
608
+ * Plan name resolution order: --plan-name flag > PLAN.md frontmatter plan_name
609
+ * > "execution" fallback.
610
+ *
611
+ * Does NOT call cmdCommit or the existing cmdStateUpdateProgress /
612
+ * cmdRoadmapUpdatePlanProgress / cmdRequirementsMarkComplete CLIs (all call
613
+ * output()/exit). Uses their *Internal helpers + execGit directly.
614
+ */
615
+ function cmdPlanFinalize(cwd, phaseNum, planNum, options, raw) {
616
+ if (!phaseNum) error('phase number required for plan finalize');
617
+ if (!planNum) error('plan number required for plan finalize');
618
+
619
+ const { stateUpdateProgressInternal } = require('./state.cjs');
620
+ const { roadmapUpdatePlanProgressInternal } = require('./roadmap.cjs');
621
+ const { requirementsMarkCompleteInternal } = require('./milestone.cjs');
622
+
623
+ // Resolve phase dir via findPhaseInternal (returns phases/NN-name relative path)
624
+ const phaseInfo = findPhaseInternal(cwd, phaseNum);
625
+ if (!phaseInfo) error(`Phase ${phaseNum} not found`);
626
+ const phaseDir = path.join(cwd, phaseInfo.directory);
627
+
628
+ // Locate PLAN.md + SUMMARY.md within the phase dir using `${phaseNum}-${planNum}` prefix
629
+ // phaseInfo.phase_number is the canonical (padded) number e.g. "01".
630
+ const prefix = `${phaseInfo.phase_number || phaseNum}-${planNum}`;
631
+ let planPath = null;
632
+ let summaryPath = null;
633
+ try {
634
+ const entries = fs.readdirSync(phaseDir);
635
+ const planFile = entries.find(f =>
636
+ f.toLowerCase().startsWith(prefix.toLowerCase()) && /-PLAN\.md$/i.test(f)
637
+ );
638
+ const summaryFile = entries.find(f =>
639
+ f.toLowerCase().startsWith(prefix.toLowerCase()) && /-SUMMARY\.md$/i.test(f)
640
+ );
641
+ if (planFile) planPath = path.join(phaseDir, planFile);
642
+ if (summaryFile) summaryPath = path.join(phaseDir, summaryFile);
643
+ } catch { /* ignore */ }
644
+
645
+ // Extract plan_name + requirements from PLAN.md frontmatter
646
+ let planName = (options && options.planName) || null;
647
+ let reqIds = [];
648
+ if (planPath && fs.existsSync(planPath)) {
649
+ try {
650
+ const content = fs.readFileSync(planPath, 'utf-8');
651
+ const fm = extractFrontmatter(content);
652
+ if (fm) {
653
+ if (!planName && fm.plan_name) planName = String(fm.plan_name);
654
+ if (fm.requirements) {
655
+ reqIds = Array.isArray(fm.requirements)
656
+ ? fm.requirements.map(String).map(s => s.trim()).filter(Boolean)
657
+ : String(fm.requirements).replace(/[\[\]]/g, '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
658
+ }
659
+ }
660
+ } catch { /* ignore */ }
661
+ }
662
+ if (!planName) planName = 'execution';
663
+
664
+ // Resolve tracking file paths (uses same project-root logic as phase finalize)
665
+ let projRoot;
666
+ try { projRoot = require('./core.cjs').getProjectRoot(cwd); } catch { projRoot = '.'; }
667
+ const roadmapPath = path.join(cwd, projRoot, 'ROADMAP.md');
668
+ const statePath = path.join(cwd, projRoot, 'STATE.md');
669
+ const requirementsPath = path.join(cwd, projRoot, 'REQUIREMENTS.md');
670
+
671
+ // Run internal updates (each returns { result, ... } without exit)
672
+ const stateUpdate = stateUpdateProgressInternal(cwd);
673
+ const roadmapUpdate = roadmapUpdatePlanProgressInternal(cwd, phaseNum);
674
+ const reqUpdate = reqIds.length > 0
675
+ ? requirementsMarkCompleteInternal(cwd, reqIds)
676
+ : { result: { updated: false, marked_complete: [], not_found: [], total: 0 }, reqPath: requirementsPath };
677
+
678
+ // Stage all files that exist on disk
679
+ const candidates = [planPath, summaryPath, statePath, roadmapPath, requirementsPath];
680
+ const filesToStage = candidates
681
+ .filter(p => p && fs.existsSync(p))
682
+ .map(p => path.relative(cwd, p));
683
+
684
+ const config = loadConfig(cwd);
685
+ const result = {
686
+ phase: phaseNum,
687
+ plan: planNum,
688
+ plan_name: planName,
689
+ state: stateUpdate.result,
690
+ roadmap: roadmapUpdate.result,
691
+ requirements: reqUpdate.result,
692
+ files_committed: [],
693
+ };
694
+
695
+ if (!config.commit_docs) {
696
+ result.committed = false;
697
+ result.commit_reason = 'skipped_commit_docs_false';
698
+ output(result, raw);
699
+ return;
700
+ }
701
+
702
+ const message = `docs(${phaseNum}-${planNum}): complete ${planName} plan`;
703
+ for (const f of filesToStage) {
704
+ execGit(cwd, ['add', f]);
705
+ }
706
+ const commitResult = execGit(cwd, ['commit', '-m', message]);
707
+ if (commitResult.exitCode !== 0) {
708
+ const nothing =
709
+ commitResult.stdout.includes('nothing to commit') ||
710
+ commitResult.stderr.includes('nothing to commit');
711
+ result.committed = false;
712
+ result.commit_reason = nothing ? 'nothing_to_commit' : 'commit_failed';
713
+ if (!nothing) result.commit_error = commitResult.stderr;
714
+ output(result, raw);
715
+ return;
716
+ }
717
+ const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
718
+ result.committed = true;
719
+ result.hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
720
+ result.commit_reason = 'committed';
721
+ result.files_committed = filesToStage;
722
+
723
+ // Optional push (same semantics as cmdCommit / cmdPhaseFinalize)
724
+ if (options && options.push) {
725
+ const syncPush = config.sync_push || 'off';
726
+ if (syncPush === 'auto') {
727
+ try {
728
+ const { pushAll } = require('./sync.cjs');
729
+ const pushRes = pushAll(cwd, { force: true });
730
+ result.pushed = pushRes.ok;
731
+ result.push_result = pushRes;
732
+ } catch (err) {
733
+ result.pushed = false;
734
+ result.push_result = { ok: false, error: err.message };
735
+ }
736
+ } else if (syncPush === 'prompt') {
737
+ result.needs_push = true;
738
+ }
739
+ }
740
+
741
+ output(result, raw);
277
742
  }
278
743
 
279
744
  function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
@@ -314,7 +779,9 @@ function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
314
779
  tech_added: (fm['tech-stack'] && fm['tech-stack'].added) || [],
315
780
  patterns: fm['patterns-established'] || [],
316
781
  decisions: parseDecisions(fm['key-decisions']),
317
- requirements_completed: fm['requirements-completed'] || [],
782
+ // REL-07/10 transitional dual-read: canonical underscore key takes precedence;
783
+ // legacy hyphen key fallback preserves audit-readability for archived v23.1 SUMMARYs.
784
+ requirements_completed: fm['requirements_completed'] || fm['requirements-completed'] || [],
318
785
  };
319
786
 
320
787
  // If fields specified, filter to only those fields
@@ -395,8 +862,17 @@ async function cmdWebsearch(query, options, raw) {
395
862
  }
396
863
 
397
864
  function cmdProgressRender(cwd, format, raw) {
398
- const phasesDir = path.join(getPlanningRoot(cwd), 'phases');
399
- const roadmapPath = path.join(getPlanningRoot(cwd), 'ROADMAP.md');
865
+ let phasesDir, roadmapPath;
866
+ try {
867
+ const projectRoot = getProjectRoot(cwd);
868
+ const projectAbs = path.join(cwd, projectRoot);
869
+ phasesDir = path.join(projectAbs, 'phases');
870
+ roadmapPath = path.join(projectAbs, 'ROADMAP.md');
871
+ } catch {
872
+ const planRoot = getPlanningRoot(cwd);
873
+ phasesDir = path.join(planRoot, 'phases');
874
+ roadmapPath = path.join(planRoot, 'ROADMAP.md');
875
+ }
400
876
  const milestone = getMilestoneInfo(cwd);
401
877
 
402
878
  const phases = [];
@@ -462,31 +938,88 @@ function cmdProgressRender(cwd, format, raw) {
462
938
  }
463
939
  }
464
940
 
465
- function cmdTodoComplete(cwd, filename, raw) {
466
- if (!filename) {
467
- error('filename required for todo complete');
941
+ /**
942
+ * Set a todo's frontmatter status field (internal helper — throws on error).
943
+ *
944
+ * If the todo file has no YAML frontmatter, wraps existing content with a
945
+ * frontmatter block containing status (and completed_date if status is 'done').
946
+ *
947
+ * @param {string} cwd - Working directory
948
+ * @param {string} filename - Todo filename
949
+ * @param {string} status - Target status (must be in TODO_STATUSES)
950
+ * @returns {{ filename: string, previous_status: string, status: string, completed_date?: string }}
951
+ * @throws {Error} If status is invalid or todo not found
952
+ */
953
+ function setTodoStatus(cwd, filename, status) {
954
+ if (!TODO_STATUSES.includes(status)) {
955
+ throw new Error(`Invalid status: ${status}. Allowed: ${TODO_STATUSES.join(', ')}`);
468
956
  }
469
957
 
470
- const pendingDir = path.join(getPlanningRoot(cwd), 'todos', 'pending');
471
- const completedDir = path.join(getPlanningRoot(cwd), 'todos', 'completed');
472
- const sourcePath = path.join(pendingDir, filename);
958
+ // Search in both pending and completed directories
959
+ const planningRoot = getPlanningRoot(cwd);
960
+ let todoPath = null;
961
+ let previousStatus = 'pending';
473
962
 
474
- if (!fs.existsSync(sourcePath)) {
475
- error(`Todo not found: ${filename}`);
963
+ for (const dir of ['pending', 'completed']) {
964
+ const candidate = path.join(planningRoot, 'todos', dir, filename);
965
+ if (fs.existsSync(candidate)) {
966
+ todoPath = candidate;
967
+ previousStatus = dir === 'completed' ? 'done' : 'pending';
968
+ break;
969
+ }
970
+ }
971
+ // Also check flat todos/ directory (future layout)
972
+ if (!todoPath) {
973
+ const flat = path.join(planningRoot, 'todos', filename);
974
+ if (fs.existsSync(flat)) {
975
+ todoPath = flat;
976
+ const content = fs.readFileSync(flat, 'utf-8');
977
+ const fm = extractFrontmatter(content);
978
+ previousStatus = fm.status || 'pending';
979
+ }
476
980
  }
477
981
 
478
- // Ensure completed directory exists
479
- fs.mkdirSync(completedDir, { recursive: true });
982
+ if (!todoPath) {
983
+ throw new Error(`Todo not found: ${filename}`);
984
+ }
480
985
 
481
- // Read, add completion timestamp, move
482
- let content = fs.readFileSync(sourcePath, 'utf-8');
483
- const today = new Date().toISOString().split('T')[0];
484
- content = `completed: ${today}\n` + content;
986
+ let content = fs.readFileSync(todoPath, 'utf-8');
987
+ const fm = extractFrontmatter(content);
485
988
 
486
- fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8');
487
- fs.unlinkSync(sourcePath);
989
+ // Build frontmatter fields
990
+ fm.status = status;
991
+ if (status === 'done') {
992
+ fm.completed_date = new Date().toISOString().split('T')[0];
993
+ }
994
+
995
+ // If file has frontmatter block, splice it; otherwise add one
996
+ if (content.match(/^---\n/)) {
997
+ content = spliceFrontmatter(content, fm);
998
+ } else {
999
+ // Strip bare "completed: YYYY-MM-DD" line if present (legacy format)
1000
+ content = content.replace(/^completed:\s*\d{4}-\d{2}-\d{2}\n/, '');
1001
+ const yamlStr = reconstructFrontmatter(fm);
1002
+ content = `---\n${yamlStr}\n---\n${content}`;
1003
+ }
1004
+
1005
+ fs.writeFileSync(todoPath, content, 'utf-8');
488
1006
 
489
- output({ completed: true, file: filename, date: today }, raw, 'completed');
1007
+ const result = { filename, previous_status: previousStatus, status };
1008
+ if (fm.completed_date) result.completed_date = fm.completed_date;
1009
+ return result;
1010
+ }
1011
+
1012
+ function cmdTodoComplete(cwd, filename, raw) {
1013
+ if (!filename) {
1014
+ error('filename required for todo complete');
1015
+ }
1016
+
1017
+ // Use setTodoStatus to edit frontmatter status to 'done'
1018
+ // setTodoStatus handles finding the file in flat or legacy directories
1019
+ // and automatically sets completed_date when status is 'done'
1020
+ const result = setTodoStatus(cwd, filename, 'done');
1021
+
1022
+ output({ completed: true, file: filename, date: result.completed_date || new Date().toISOString().split('T')[0] }, raw, 'completed');
490
1023
  }
491
1024
 
492
1025
  function cmdScaffold(cwd, type, options, raw) {
@@ -567,7 +1100,49 @@ function cmdContextHelp(raw) {
567
1100
  output(result, raw, result.subcommands.map(s => s.usage).join('\n'));
568
1101
  }
569
1102
 
1103
+ // REL-08 (Phase 157): pre-commit precondition gate.
1104
+ // Reads PLAN.md `requirements:` and SUMMARY.md `requirements_completed:` (canonical) /
1105
+ // `requirements-completed:` (legacy fallback). If PLAN is non-empty AND SUMMARY is empty,
1106
+ // writes `summary-frontmatter-mismatch:` label to stderr and exits non-zero.
1107
+ // NEVER writes to the working tree — read-only check.
1108
+ function cmdFinalCommitPrecondition(cwd, options) {
1109
+ const planPath = options && options.plan;
1110
+ const summaryPath = options && options.summary;
1111
+ if (!planPath || !summaryPath) {
1112
+ process.stderr.write('summary-frontmatter-mismatch: --plan and --summary required\n');
1113
+ process.exit(2);
1114
+ }
1115
+ const { extractFrontmatter } = require('./frontmatter.cjs');
1116
+ const planAbs = path.isAbsolute(planPath) ? planPath : path.join(cwd, planPath);
1117
+ const summaryAbs = path.isAbsolute(summaryPath) ? summaryPath : path.join(cwd, summaryPath);
1118
+
1119
+ if (!fs.existsSync(planAbs)) {
1120
+ process.stderr.write(`summary-frontmatter-mismatch: PLAN not found at ${planPath}\n`);
1121
+ process.exit(2);
1122
+ }
1123
+ if (!fs.existsSync(summaryAbs)) {
1124
+ process.stderr.write(`summary-frontmatter-mismatch: SUMMARY not found at ${summaryPath}\n`);
1125
+ process.exit(2);
1126
+ }
1127
+ const planFm = extractFrontmatter(fs.readFileSync(planAbs, 'utf-8'));
1128
+ const summaryFm = extractFrontmatter(fs.readFileSync(summaryAbs, 'utf-8'));
1129
+ const planReq = Array.isArray(planFm.requirements) ? planFm.requirements : [];
1130
+ // Dual-read: canonical underscore key first, legacy hyphen fallback (REL-07/10 dual-read).
1131
+ const summaryReq = summaryFm['requirements_completed'] || summaryFm['requirements-completed'] || [];
1132
+
1133
+ if (planReq.length > 0 && summaryReq.length === 0) {
1134
+ process.stderr.write(
1135
+ `summary-frontmatter-mismatch: PLAN.md declared requirements [${planReq.join(', ')}]; ` +
1136
+ `SUMMARY.md requirements_completed is empty. Re-run executor or manually populate before committing.\n`
1137
+ );
1138
+ process.exit(2);
1139
+ }
1140
+ process.exit(0);
1141
+ }
1142
+
570
1143
  module.exports = {
1144
+ TODO_STATUSES,
1145
+ setTodoStatus,
571
1146
  cmdGenerateSlug,
572
1147
  cmdCurrentTimestamp,
573
1148
  cmdListTodos,
@@ -575,10 +1150,15 @@ module.exports = {
575
1150
  cmdHistoryDigest,
576
1151
  cmdResolveModel,
577
1152
  cmdCommit,
1153
+ commitInternal,
1154
+ verifyPlanCommit,
1155
+ computePhaseSweep,
1156
+ cmdPlanFinalize,
578
1157
  cmdSummaryExtract,
579
1158
  cmdWebsearch,
580
1159
  cmdProgressRender,
581
1160
  cmdTodoComplete,
582
1161
  cmdScaffold,
583
1162
  cmdContextHelp,
1163
+ cmdFinalCommitPrecondition,
584
1164
  };