@ktpartners/dgs-platform 2.8.0 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +41 -13
  3. package/agents/dgs-plan-checker.md +29 -3
  4. package/agents/dgs-planner.md +10 -0
  5. package/commands/dgs/abandon-quick.md +28 -0
  6. package/commands/dgs/add-tests.md +2 -2
  7. package/commands/dgs/audit-milestone.md +2 -2
  8. package/commands/dgs/capture-principle.md +11 -11
  9. package/commands/dgs/cleanup.md +2 -2
  10. package/commands/dgs/complete-milestone.md +11 -11
  11. package/commands/dgs/complete-quick.md +28 -0
  12. package/commands/dgs/create-milestone-job.md +2 -2
  13. package/commands/dgs/debug.md +3 -3
  14. package/commands/dgs/develop-idea.md +1 -1
  15. package/commands/dgs/fast.md +3 -1
  16. package/commands/dgs/health.md +1 -1
  17. package/commands/dgs/map-codebase.md +6 -6
  18. package/commands/dgs/new-milestone.md +5 -5
  19. package/commands/dgs/new-project.md +6 -6
  20. package/commands/dgs/plan-milestone-gaps.md +1 -1
  21. package/commands/dgs/progress.md +3 -3
  22. package/commands/dgs/quick-abandon.md +8 -0
  23. package/commands/dgs/quick-complete.md +8 -0
  24. package/commands/dgs/quick.md +10 -3
  25. package/commands/dgs/research-idea.md +2 -2
  26. package/commands/dgs/research-phase.md +3 -3
  27. package/commands/dgs/switch-project.md +1 -1
  28. package/commands/dgs/write-spec.md +3 -3
  29. package/deliver-great-systems/bin/dgs-tools.cjs +284 -30
  30. package/deliver-great-systems/bin/lib/commands.cjs +316 -31
  31. package/deliver-great-systems/bin/lib/commands.test.cjs +336 -0
  32. package/deliver-great-systems/bin/lib/config.cjs +39 -6
  33. package/deliver-great-systems/bin/lib/context.cjs +120 -0
  34. package/deliver-great-systems/bin/lib/core.cjs +28 -11
  35. package/deliver-great-systems/bin/lib/execution.cjs +49 -17
  36. package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
  37. package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
  38. package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
  39. package/deliver-great-systems/bin/lib/init.cjs +306 -39
  40. package/deliver-great-systems/bin/lib/init.test.cjs +416 -6
  41. package/deliver-great-systems/bin/lib/jobs.cjs +124 -21
  42. package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
  43. package/deliver-great-systems/bin/lib/migration.cjs +409 -1
  44. package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
  45. package/deliver-great-systems/bin/lib/milestone.cjs +54 -29
  46. package/deliver-great-systems/bin/lib/phase.cjs +128 -2
  47. package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
  48. package/deliver-great-systems/bin/lib/projects.cjs +28 -8
  49. package/deliver-great-systems/bin/lib/projects.test.cjs +86 -0
  50. package/deliver-great-systems/bin/lib/quick.cjs +584 -0
  51. package/deliver-great-systems/bin/lib/quick.test.cjs +596 -0
  52. package/deliver-great-systems/bin/lib/repos.cjs +25 -1
  53. package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
  54. package/deliver-great-systems/bin/lib/specs.cjs +3 -81
  55. package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
  56. package/deliver-great-systems/bin/lib/state.cjs +142 -54
  57. package/deliver-great-systems/bin/lib/sync.cjs +75 -0
  58. package/deliver-great-systems/bin/lib/verify.cjs +80 -1
  59. package/deliver-great-systems/bin/lib/worktrees.cjs +764 -0
  60. package/deliver-great-systems/bin/lib/worktrees.test.cjs +887 -0
  61. package/deliver-great-systems/templates/claude-md.md +16 -0
  62. package/deliver-great-systems/workflows/abandon-quick.md +89 -0
  63. package/deliver-great-systems/workflows/add-idea.md +3 -3
  64. package/deliver-great-systems/workflows/add-tests.md +14 -0
  65. package/deliver-great-systems/workflows/add-todo.md +1 -0
  66. package/deliver-great-systems/workflows/approve-spec.md +25 -4
  67. package/deliver-great-systems/workflows/audit-phase.md +15 -5
  68. package/deliver-great-systems/workflows/cancel-job.md +1 -1
  69. package/deliver-great-systems/workflows/check-todos.md +2 -3
  70. package/deliver-great-systems/workflows/complete-milestone.md +197 -22
  71. package/deliver-great-systems/workflows/complete-quick.md +68 -0
  72. package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
  73. package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
  74. package/deliver-great-systems/workflows/develop-idea.md +11 -11
  75. package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
  76. package/deliver-great-systems/workflows/discuss-idea.md +1 -1
  77. package/deliver-great-systems/workflows/execute-phase.md +121 -32
  78. package/deliver-great-systems/workflows/execute-plan.md +12 -21
  79. package/deliver-great-systems/workflows/help.md +33 -29
  80. package/deliver-great-systems/workflows/init-product.md +2 -18
  81. package/deliver-great-systems/workflows/new-milestone.md +40 -24
  82. package/deliver-great-systems/workflows/new-project.md +22 -680
  83. package/deliver-great-systems/workflows/progress-all.md +133 -0
  84. package/deliver-great-systems/workflows/quick-abandon.md +89 -0
  85. package/deliver-great-systems/workflows/quick-complete.md +68 -0
  86. package/deliver-great-systems/workflows/quick.md +152 -23
  87. package/deliver-great-systems/workflows/refine-spec.md +1 -1
  88. package/deliver-great-systems/workflows/research-idea.md +8 -8
  89. package/deliver-great-systems/workflows/resume-project.md +2 -2
  90. package/deliver-great-systems/workflows/run-job.md +8 -8
  91. package/deliver-great-systems/workflows/validate-phase.md +39 -1
  92. package/deliver-great-systems/workflows/verify-work.md +14 -0
  93. package/deliver-great-systems/workflows/write-spec.md +2 -2
  94. package/package.json +1 -1
@@ -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;
53
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
+ }
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,7 +268,22 @@ function cmdResolveModel(cwd, agentType, raw) {
214
268
  output(result, raw, model);
215
269
  }
216
270
 
217
- function cmdCommit(cwd, message, files, raw, amend, push) {
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
+ function cmdCommit(cwd, message, files, raw, amend, push, repoCwd) {
218
287
  if (!message && !amend) {
219
288
  error('commit message required');
220
289
  }
@@ -228,15 +297,20 @@ function cmdCommit(cwd, message, files, raw, amend, push) {
228
297
  return;
229
298
  }
230
299
 
300
+ // Resolve git-operation cwd: use repoCwd when provided, otherwise cwd.
301
+ // Config is always loaded from cwd (planning root); only git exec targets
302
+ // the worktree filesystem.
303
+ const gitCwd = repoCwd || cwd;
304
+
231
305
  // Stage files
232
306
  const filesToStage = files && files.length > 0 ? files : ['.'];
233
307
  for (const file of filesToStage) {
234
- execGit(cwd, ['add', file]);
308
+ execGit(gitCwd, ['add', file]);
235
309
  }
236
310
 
237
311
  // Commit
238
312
  const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', message];
239
- const commitResult = execGit(cwd, commitArgs);
313
+ const commitResult = execGit(gitCwd, commitArgs);
240
314
  if (commitResult.exitCode !== 0) {
241
315
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
242
316
  const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
@@ -249,7 +323,7 @@ function cmdCommit(cwd, message, files, raw, amend, push) {
249
323
  }
250
324
 
251
325
  // Get short hash
252
- const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
326
+ const hashResult = execGit(gitCwd, ['rev-parse', '--short', 'HEAD']);
253
327
  const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
254
328
  const result = { committed: true, hash, reason: 'committed' };
255
329
 
@@ -276,6 +350,148 @@ function cmdCommit(cwd, message, files, raw, amend, push) {
276
350
  output(result, raw, hash || 'committed');
277
351
  }
278
352
 
353
+ /**
354
+ * CLI: `plan finalize <phase> <plan> [--plan-name <name>] [--push]`.
355
+ *
356
+ * Runs state update-progress + roadmap update-plan-progress + requirements
357
+ * mark-complete AND commits PLAN.md + SUMMARY.md + tracking files in a single
358
+ * atomic call. Requirement IDs are auto-extracted from PLAN.md frontmatter.
359
+ * Plan name resolution order: --plan-name flag > PLAN.md frontmatter plan_name
360
+ * > "execution" fallback.
361
+ *
362
+ * Does NOT call cmdCommit or the existing cmdStateUpdateProgress /
363
+ * cmdRoadmapUpdatePlanProgress / cmdRequirementsMarkComplete CLIs (all call
364
+ * output()/exit). Uses their *Internal helpers + execGit directly.
365
+ */
366
+ function cmdPlanFinalize(cwd, phaseNum, planNum, options, raw) {
367
+ if (!phaseNum) error('phase number required for plan finalize');
368
+ if (!planNum) error('plan number required for plan finalize');
369
+
370
+ const { stateUpdateProgressInternal } = require('./state.cjs');
371
+ const { roadmapUpdatePlanProgressInternal } = require('./roadmap.cjs');
372
+ const { requirementsMarkCompleteInternal } = require('./milestone.cjs');
373
+
374
+ // Resolve phase dir via findPhaseInternal (returns phases/NN-name relative path)
375
+ const phaseInfo = findPhaseInternal(cwd, phaseNum);
376
+ if (!phaseInfo) error(`Phase ${phaseNum} not found`);
377
+ const phaseDir = path.join(cwd, phaseInfo.directory);
378
+
379
+ // Locate PLAN.md + SUMMARY.md within the phase dir using `${phaseNum}-${planNum}` prefix
380
+ // phaseInfo.phase_number is the canonical (padded) number e.g. "01".
381
+ const prefix = `${phaseInfo.phase_number || phaseNum}-${planNum}`;
382
+ let planPath = null;
383
+ let summaryPath = null;
384
+ try {
385
+ const entries = fs.readdirSync(phaseDir);
386
+ const planFile = entries.find(f =>
387
+ f.toLowerCase().startsWith(prefix.toLowerCase()) && /-PLAN\.md$/i.test(f)
388
+ );
389
+ const summaryFile = entries.find(f =>
390
+ f.toLowerCase().startsWith(prefix.toLowerCase()) && /-SUMMARY\.md$/i.test(f)
391
+ );
392
+ if (planFile) planPath = path.join(phaseDir, planFile);
393
+ if (summaryFile) summaryPath = path.join(phaseDir, summaryFile);
394
+ } catch { /* ignore */ }
395
+
396
+ // Extract plan_name + requirements from PLAN.md frontmatter
397
+ let planName = (options && options.planName) || null;
398
+ let reqIds = [];
399
+ if (planPath && fs.existsSync(planPath)) {
400
+ try {
401
+ const content = fs.readFileSync(planPath, 'utf-8');
402
+ const fm = extractFrontmatter(content);
403
+ if (fm) {
404
+ if (!planName && fm.plan_name) planName = String(fm.plan_name);
405
+ if (fm.requirements) {
406
+ reqIds = Array.isArray(fm.requirements)
407
+ ? fm.requirements.map(String).map(s => s.trim()).filter(Boolean)
408
+ : String(fm.requirements).replace(/[\[\]]/g, '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
409
+ }
410
+ }
411
+ } catch { /* ignore */ }
412
+ }
413
+ if (!planName) planName = 'execution';
414
+
415
+ // Resolve tracking file paths (uses same project-root logic as phase finalize)
416
+ let projRoot;
417
+ try { projRoot = require('./core.cjs').getProjectRoot(cwd); } catch { projRoot = '.'; }
418
+ const roadmapPath = path.join(cwd, projRoot, 'ROADMAP.md');
419
+ const statePath = path.join(cwd, projRoot, 'STATE.md');
420
+ const requirementsPath = path.join(cwd, projRoot, 'REQUIREMENTS.md');
421
+
422
+ // Run internal updates (each returns { result, ... } without exit)
423
+ const stateUpdate = stateUpdateProgressInternal(cwd);
424
+ const roadmapUpdate = roadmapUpdatePlanProgressInternal(cwd, phaseNum);
425
+ const reqUpdate = reqIds.length > 0
426
+ ? requirementsMarkCompleteInternal(cwd, reqIds)
427
+ : { result: { updated: false, marked_complete: [], not_found: [], total: 0 }, reqPath: requirementsPath };
428
+
429
+ // Stage all files that exist on disk
430
+ const candidates = [planPath, summaryPath, statePath, roadmapPath, requirementsPath];
431
+ const filesToStage = candidates
432
+ .filter(p => p && fs.existsSync(p))
433
+ .map(p => path.relative(cwd, p));
434
+
435
+ const config = loadConfig(cwd);
436
+ const result = {
437
+ phase: phaseNum,
438
+ plan: planNum,
439
+ plan_name: planName,
440
+ state: stateUpdate.result,
441
+ roadmap: roadmapUpdate.result,
442
+ requirements: reqUpdate.result,
443
+ files_committed: [],
444
+ };
445
+
446
+ if (!config.commit_docs) {
447
+ result.committed = false;
448
+ result.commit_reason = 'skipped_commit_docs_false';
449
+ output(result, raw);
450
+ return;
451
+ }
452
+
453
+ const message = `docs(${phaseNum}-${planNum}): complete ${planName} plan`;
454
+ for (const f of filesToStage) {
455
+ execGit(cwd, ['add', f]);
456
+ }
457
+ const commitResult = execGit(cwd, ['commit', '-m', message]);
458
+ if (commitResult.exitCode !== 0) {
459
+ const nothing =
460
+ commitResult.stdout.includes('nothing to commit') ||
461
+ commitResult.stderr.includes('nothing to commit');
462
+ result.committed = false;
463
+ result.commit_reason = nothing ? 'nothing_to_commit' : 'commit_failed';
464
+ if (!nothing) result.commit_error = commitResult.stderr;
465
+ output(result, raw);
466
+ return;
467
+ }
468
+ const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
469
+ result.committed = true;
470
+ result.hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
471
+ result.commit_reason = 'committed';
472
+ result.files_committed = filesToStage;
473
+
474
+ // Optional push (same semantics as cmdCommit / cmdPhaseFinalize)
475
+ if (options && options.push) {
476
+ const syncPush = config.sync_push || 'off';
477
+ if (syncPush === 'auto') {
478
+ try {
479
+ const { pushAll } = require('./sync.cjs');
480
+ const pushRes = pushAll(cwd, { force: true });
481
+ result.pushed = pushRes.ok;
482
+ result.push_result = pushRes;
483
+ } catch (err) {
484
+ result.pushed = false;
485
+ result.push_result = { ok: false, error: err.message };
486
+ }
487
+ } else if (syncPush === 'prompt') {
488
+ result.needs_push = true;
489
+ }
490
+ }
491
+
492
+ output(result, raw);
493
+ }
494
+
279
495
  function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
280
496
  if (!summaryPath) {
281
497
  error('summary-path required for summary-extract');
@@ -395,8 +611,17 @@ async function cmdWebsearch(query, options, raw) {
395
611
  }
396
612
 
397
613
  function cmdProgressRender(cwd, format, raw) {
398
- const phasesDir = path.join(getPlanningRoot(cwd), 'phases');
399
- const roadmapPath = path.join(getPlanningRoot(cwd), 'ROADMAP.md');
614
+ let phasesDir, roadmapPath;
615
+ try {
616
+ const projectRoot = getProjectRoot(cwd);
617
+ const projectAbs = path.join(cwd, projectRoot);
618
+ phasesDir = path.join(projectAbs, 'phases');
619
+ roadmapPath = path.join(projectAbs, 'ROADMAP.md');
620
+ } catch {
621
+ const planRoot = getPlanningRoot(cwd);
622
+ phasesDir = path.join(planRoot, 'phases');
623
+ roadmapPath = path.join(planRoot, 'ROADMAP.md');
624
+ }
400
625
  const milestone = getMilestoneInfo(cwd);
401
626
 
402
627
  const phases = [];
@@ -462,31 +687,88 @@ function cmdProgressRender(cwd, format, raw) {
462
687
  }
463
688
  }
464
689
 
465
- function cmdTodoComplete(cwd, filename, raw) {
466
- if (!filename) {
467
- error('filename required for todo complete');
690
+ /**
691
+ * Set a todo's frontmatter status field (internal helper — throws on error).
692
+ *
693
+ * If the todo file has no YAML frontmatter, wraps existing content with a
694
+ * frontmatter block containing status (and completed_date if status is 'done').
695
+ *
696
+ * @param {string} cwd - Working directory
697
+ * @param {string} filename - Todo filename
698
+ * @param {string} status - Target status (must be in TODO_STATUSES)
699
+ * @returns {{ filename: string, previous_status: string, status: string, completed_date?: string }}
700
+ * @throws {Error} If status is invalid or todo not found
701
+ */
702
+ function setTodoStatus(cwd, filename, status) {
703
+ if (!TODO_STATUSES.includes(status)) {
704
+ throw new Error(`Invalid status: ${status}. Allowed: ${TODO_STATUSES.join(', ')}`);
468
705
  }
469
706
 
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);
707
+ // Search in both pending and completed directories
708
+ const planningRoot = getPlanningRoot(cwd);
709
+ let todoPath = null;
710
+ let previousStatus = 'pending';
473
711
 
474
- if (!fs.existsSync(sourcePath)) {
475
- error(`Todo not found: ${filename}`);
712
+ for (const dir of ['pending', 'completed']) {
713
+ const candidate = path.join(planningRoot, 'todos', dir, filename);
714
+ if (fs.existsSync(candidate)) {
715
+ todoPath = candidate;
716
+ previousStatus = dir === 'completed' ? 'done' : 'pending';
717
+ break;
718
+ }
719
+ }
720
+ // Also check flat todos/ directory (future layout)
721
+ if (!todoPath) {
722
+ const flat = path.join(planningRoot, 'todos', filename);
723
+ if (fs.existsSync(flat)) {
724
+ todoPath = flat;
725
+ const content = fs.readFileSync(flat, 'utf-8');
726
+ const fm = extractFrontmatter(content);
727
+ previousStatus = fm.status || 'pending';
728
+ }
476
729
  }
477
730
 
478
- // Ensure completed directory exists
479
- fs.mkdirSync(completedDir, { recursive: true });
731
+ if (!todoPath) {
732
+ throw new Error(`Todo not found: ${filename}`);
733
+ }
480
734
 
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;
735
+ let content = fs.readFileSync(todoPath, 'utf-8');
736
+ const fm = extractFrontmatter(content);
737
+
738
+ // Build frontmatter fields
739
+ fm.status = status;
740
+ if (status === 'done') {
741
+ fm.completed_date = new Date().toISOString().split('T')[0];
742
+ }
743
+
744
+ // If file has frontmatter block, splice it; otherwise add one
745
+ if (content.match(/^---\n/)) {
746
+ content = spliceFrontmatter(content, fm);
747
+ } else {
748
+ // Strip bare "completed: YYYY-MM-DD" line if present (legacy format)
749
+ content = content.replace(/^completed:\s*\d{4}-\d{2}-\d{2}\n/, '');
750
+ const yamlStr = reconstructFrontmatter(fm);
751
+ content = `---\n${yamlStr}\n---\n${content}`;
752
+ }
753
+
754
+ fs.writeFileSync(todoPath, content, 'utf-8');
755
+
756
+ const result = { filename, previous_status: previousStatus, status };
757
+ if (fm.completed_date) result.completed_date = fm.completed_date;
758
+ return result;
759
+ }
760
+
761
+ function cmdTodoComplete(cwd, filename, raw) {
762
+ if (!filename) {
763
+ error('filename required for todo complete');
764
+ }
485
765
 
486
- fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8');
487
- fs.unlinkSync(sourcePath);
766
+ // Use setTodoStatus to edit frontmatter status to 'done'
767
+ // setTodoStatus handles finding the file in flat or legacy directories
768
+ // and automatically sets completed_date when status is 'done'
769
+ const result = setTodoStatus(cwd, filename, 'done');
488
770
 
489
- output({ completed: true, file: filename, date: today }, raw, 'completed');
771
+ output({ completed: true, file: filename, date: result.completed_date || new Date().toISOString().split('T')[0] }, raw, 'completed');
490
772
  }
491
773
 
492
774
  function cmdScaffold(cwd, type, options, raw) {
@@ -568,6 +850,8 @@ function cmdContextHelp(raw) {
568
850
  }
569
851
 
570
852
  module.exports = {
853
+ TODO_STATUSES,
854
+ setTodoStatus,
571
855
  cmdGenerateSlug,
572
856
  cmdCurrentTimestamp,
573
857
  cmdListTodos,
@@ -575,6 +859,7 @@ module.exports = {
575
859
  cmdHistoryDigest,
576
860
  cmdResolveModel,
577
861
  cmdCommit,
862
+ cmdPlanFinalize,
578
863
  cmdSummaryExtract,
579
864
  cmdWebsearch,
580
865
  cmdProgressRender,