@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
@@ -37,6 +37,38 @@ function captureCommitOutput(fn) {
37
37
  return null;
38
38
  }
39
39
 
40
+ /**
41
+ * Capture stdout output from functions that use process.stdout.write + process.exit.
42
+ * core.cjs output() writes via process.stdout.write and then calls process.exit(0),
43
+ * which would terminate the test process. This helper mocks both so multiple
44
+ * cmdCommit invocations can run in sequence and their output is inspectable.
45
+ *
46
+ * Returns { stdout: string, exitCode: number|null, json: any|null }.
47
+ */
48
+ function captureStdout(fn) {
49
+ const chunks = [];
50
+ const origWrite = process.stdout.write.bind(process.stdout);
51
+ const origExit = process.exit;
52
+ let exitCode = null;
53
+ process.stdout.write = (data) => { chunks.push(String(data)); return true; };
54
+ process.exit = (code) => {
55
+ exitCode = code == null ? 0 : code;
56
+ throw new Error('__EXIT__');
57
+ };
58
+ try {
59
+ fn();
60
+ } catch (e) {
61
+ if (e && e.message !== '__EXIT__') throw e;
62
+ } finally {
63
+ process.stdout.write = origWrite;
64
+ process.exit = origExit;
65
+ }
66
+ const stdout = chunks.join('');
67
+ let json = null;
68
+ try { json = JSON.parse(stdout); } catch { /* not JSON */ }
69
+ return { stdout, exitCode, json };
70
+ }
71
+
40
72
  /**
41
73
  * Set up a temp project with git, write a file, and return the fixture.
42
74
  */
@@ -323,3 +355,422 @@ describe('cmdCommit with push=true, sync_push unset (defaults to off)', () => {
323
355
  assert.equal(result.needs_push, undefined);
324
356
  });
325
357
  });
358
+
359
+ // ─── --repo-cwd flag tests ────────────────────────────────────────────────────
360
+
361
+ describe('cmdCommit with --repo-cwd flag', () => {
362
+ let planningFixture;
363
+ let worktreeFixture;
364
+ let commands;
365
+
366
+ beforeEach(() => {
367
+ // Planning-root fixture: holds config, sync_push=off so pushAll won't be
368
+ // invoked when push=false.
369
+ planningFixture = setupGitFixture('off');
370
+ // "Worktree" fixture: a second independent git repo where commits should
371
+ // actually land when --repo-cwd is passed.
372
+ worktreeFixture = setupGitFixture('off');
373
+ // Clear require cache so we load a fresh commands module that picks up
374
+ // any test-time mutations (e.g., mocked sync.pushAll).
375
+ commands = require('./commands.cjs');
376
+ });
377
+
378
+ afterEach(() => {
379
+ planningFixture.cleanup();
380
+ worktreeFixture.cleanup();
381
+ });
382
+
383
+ it('runs git add/commit/rev-parse in repoCwd, not cwd', () => {
384
+ // Write file in the worktree, NOT in the planning root
385
+ const fileName = 'worktree-file.txt';
386
+ fs.writeFileSync(
387
+ path.join(worktreeFixture.cwd, fileName),
388
+ 'worktree content'
389
+ );
390
+
391
+ // Record each repo's HEAD before the commit
392
+ const planningHeadBefore = execSync('git rev-parse HEAD', {
393
+ cwd: planningFixture.cwd, encoding: 'utf-8'
394
+ }).trim();
395
+ const worktreeHeadBefore = execSync('git rev-parse HEAD', {
396
+ cwd: worktreeFixture.cwd, encoding: 'utf-8'
397
+ }).trim();
398
+
399
+ // Call cmdCommit with cwd=planning, repoCwd=worktree.
400
+ // raw=false so output() writes JSON (with rawValue undefined path).
401
+ const capture = captureStdout(() => {
402
+ commands.cmdCommit(
403
+ planningFixture.cwd, // cwd
404
+ 'test: commit in worktree', // message
405
+ [fileName], // files (relative to repoCwd)
406
+ false, // raw (false => JSON output)
407
+ false, // amend
408
+ false, // push
409
+ worktreeFixture.cwd // repoCwd
410
+ );
411
+ });
412
+
413
+ // Parse result
414
+ const result = capture.json;
415
+ assert.ok(result, 'Expected JSON result. stdout: ' + capture.stdout);
416
+ assert.equal(result.committed, true, 'Expected committed=true');
417
+ assert.ok(result.hash, 'Expected a commit hash');
418
+
419
+ // HEAD must have advanced in the worktree
420
+ const worktreeHeadAfter = execSync('git rev-parse HEAD', {
421
+ cwd: worktreeFixture.cwd, encoding: 'utf-8'
422
+ }).trim();
423
+ assert.notEqual(worktreeHeadAfter, worktreeHeadBefore,
424
+ 'worktree HEAD must have advanced');
425
+ assert.ok(worktreeHeadAfter.startsWith(result.hash),
426
+ `worktree HEAD (${worktreeHeadAfter}) should start with returned hash (${result.hash})`);
427
+
428
+ // Planning-root HEAD must be unchanged
429
+ const planningHeadAfter = execSync('git rev-parse HEAD', {
430
+ cwd: planningFixture.cwd, encoding: 'utf-8'
431
+ }).trim();
432
+ assert.equal(planningHeadAfter, planningHeadBefore,
433
+ 'planning-root HEAD must NOT have moved');
434
+ });
435
+
436
+ it('loads config from cwd, not repoCwd (push uses planning-root config)', () => {
437
+ // Planning fixture has sync_push=auto
438
+ planningFixture.cleanup();
439
+ planningFixture = setupGitFixture('auto');
440
+
441
+ // Write file in worktree
442
+ const fileName = 'pushable.txt';
443
+ fs.writeFileSync(
444
+ path.join(worktreeFixture.cwd, fileName),
445
+ 'pushable content'
446
+ );
447
+
448
+ // Mock pushAll and capture what cwd it's called with
449
+ let pushAllCwd = null;
450
+ const restore = mockPushAll((cwd) => {
451
+ pushAllCwd = cwd;
452
+ return { ok: true, results: [], summary: '' };
453
+ });
454
+
455
+ try {
456
+ const capture = captureStdout(() => {
457
+ commands.cmdCommit(
458
+ planningFixture.cwd,
459
+ 'test: push uses planning cwd',
460
+ [fileName],
461
+ false, // raw=false => JSON output
462
+ false,
463
+ true, // push=true
464
+ worktreeFixture.cwd // repoCwd
465
+ );
466
+ });
467
+ const result = capture.json;
468
+ assert.ok(result, 'Expected JSON result. stdout: ' + capture.stdout);
469
+ assert.equal(result.committed, true);
470
+ assert.equal(pushAllCwd, planningFixture.cwd,
471
+ 'pushAll must be called with planning-root cwd, not worktree repoCwd');
472
+ } finally {
473
+ restore();
474
+ }
475
+ });
476
+
477
+ it('behaves identically to pre-change when repoCwd is undefined', () => {
478
+ // Write file in planning root (as existing tests do)
479
+ writeAndStageFile(planningFixture.cwd, 'no-repo-cwd.txt', 'regression');
480
+ const headBefore = execSync('git rev-parse HEAD', {
481
+ cwd: planningFixture.cwd, encoding: 'utf-8'
482
+ }).trim();
483
+
484
+ // Call cmdCommit with NO repoCwd (existing 6-arg signature)
485
+ const capture = captureStdout(() => {
486
+ commands.cmdCommit(
487
+ planningFixture.cwd,
488
+ 'test: no repo-cwd regression',
489
+ [],
490
+ false, // raw=false => JSON output
491
+ false,
492
+ false
493
+ // no 7th arg
494
+ );
495
+ });
496
+ const result = capture.json;
497
+ assert.ok(result, 'Expected JSON result. stdout: ' + capture.stdout);
498
+ assert.equal(result.committed, true);
499
+ assert.ok(result.hash);
500
+
501
+ // Commit must land in planningFixture.cwd (not elsewhere)
502
+ const headAfter = execSync('git rev-parse HEAD', {
503
+ cwd: planningFixture.cwd, encoding: 'utf-8'
504
+ }).trim();
505
+ assert.notEqual(headAfter, headBefore,
506
+ 'planning-root HEAD must have advanced when no repoCwd is passed');
507
+ assert.ok(headAfter.startsWith(result.hash),
508
+ 'planning-root HEAD should match returned hash');
509
+ });
510
+
511
+ it('dgs-tools commit CLI parses --repo-cwd flag and targets that directory', () => {
512
+ // Write file in the worktree (a separate git repo)
513
+ const fileName = 'cli-repo-cwd.txt';
514
+ fs.writeFileSync(
515
+ path.join(worktreeFixture.cwd, fileName),
516
+ 'cli content'
517
+ );
518
+
519
+ const worktreeHeadBefore = execSync('git rev-parse HEAD', {
520
+ cwd: worktreeFixture.cwd, encoding: 'utf-8'
521
+ }).trim();
522
+
523
+ // Invoke dgs-tools.cjs commit with --repo-cwd from planningFixture.cwd
524
+ const dgsTools = path.resolve(__dirname, '..', 'dgs-tools.cjs');
525
+ const cliOut = execSync(
526
+ `node "${dgsTools}" commit "cli test repo-cwd" --raw --repo-cwd "${worktreeFixture.cwd}" --files "${fileName}"`,
527
+ { cwd: planningFixture.cwd, encoding: 'utf-8' }
528
+ );
529
+ // --raw with a hash rawValue outputs the hash only (not JSON)
530
+ const hash = cliOut.trim();
531
+ assert.ok(hash.length > 0, 'Expected some hash/output. stdout: ' + cliOut);
532
+
533
+ // Verify the commit landed in the worktree repo
534
+ const worktreeHeadAfter = execSync('git rev-parse HEAD', {
535
+ cwd: worktreeFixture.cwd, encoding: 'utf-8'
536
+ }).trim();
537
+ assert.notEqual(worktreeHeadAfter, worktreeHeadBefore,
538
+ 'worktree HEAD must advance when --repo-cwd targets it');
539
+ assert.ok(worktreeHeadAfter.startsWith(hash),
540
+ `worktree HEAD (${worktreeHeadAfter}) should start with CLI hash (${hash})`);
541
+ });
542
+ });
543
+
544
+ // ─── cmdCommit populates dirty_after ────────────────────────────────────────
545
+ // Uses captureStdout (not captureCommitOutput) because cmdCommit's output() goes
546
+ // through process.stdout.write + process.exit, not console.log. captureStdout
547
+ // mocks both correctly. captureCommitOutput exists for historical reasons but
548
+ // doesn't actually intercept output() writes — its assertions silently no-op
549
+ // because process.exit kills the test worker before they run.
550
+
551
+ describe('cmdCommit populates dirty_after', () => {
552
+ let fixture;
553
+ let commands;
554
+
555
+ beforeEach(() => {
556
+ fixture = setupGitFixture('off');
557
+ commands = require('./commands.cjs');
558
+ });
559
+
560
+ afterEach(() => {
561
+ fixture.cleanup();
562
+ });
563
+
564
+ it('returns dirty_after=[] when tree is clean after commit', () => {
565
+ writeAndStageFile(fixture.cwd, 'clean-after.txt', 'clean');
566
+ const capture = captureStdout(() => {
567
+ commands.cmdCommit(fixture.cwd, 'test: clean dirty_after', [], false, false, false);
568
+ });
569
+ const result = capture.json;
570
+ assert.ok(result, 'expected JSON result. stdout: ' + capture.stdout);
571
+ assert.equal(result.committed, true);
572
+ assert.ok(result.hash);
573
+ assert.ok(Array.isArray(result.dirty_after), 'dirty_after should be an array');
574
+ assert.equal(result.dirty_after.length, 0, `clean tree should have empty dirty_after, got: ${JSON.stringify(result.dirty_after)}`);
575
+ });
576
+
577
+ it('includes unstaged files in dirty_after after commit', () => {
578
+ // Stage and commit file A
579
+ writeAndStageFile(fixture.cwd, 'staged-file.txt', 'staged');
580
+ // Write (but do NOT stage) file B — it should remain dirty after the commit
581
+ fs.writeFileSync(path.join(fixture.cwd, 'dirty-file.txt'), 'unstaged');
582
+
583
+ const capture = captureStdout(() => {
584
+ commands.cmdCommit(fixture.cwd, 'test: dirty dirty_after', ['staged-file.txt'], false, false, false);
585
+ });
586
+ const result = capture.json;
587
+ assert.ok(result, 'expected JSON result. stdout: ' + capture.stdout);
588
+ assert.equal(result.committed, true);
589
+ assert.ok(result.hash);
590
+ assert.ok(Array.isArray(result.dirty_after), 'dirty_after should be an array');
591
+ assert.ok(
592
+ result.dirty_after.includes('dirty-file.txt'),
593
+ `dirty_after should include the unstaged file, got: ${JSON.stringify(result.dirty_after)}`
594
+ );
595
+ });
596
+
597
+ it('emits dirty_after in nothing_to_commit branch', () => {
598
+ // Create an unstaged file to make the tree dirty
599
+ fs.writeFileSync(path.join(fixture.cwd, 'unstaged-only.txt'), 'unstaged');
600
+
601
+ const capture = captureStdout(() => {
602
+ // Pass a pathspec that matches nothing so staging is a no-op — the
603
+ // unstaged file remains dirty AND there is nothing_to_commit.
604
+ commands.cmdCommit(fixture.cwd, 'test: nothing', ['nonexistent-pathspec-zzz'], false, false, false);
605
+ });
606
+ const result = capture.json;
607
+ assert.ok(result, 'expected JSON result. stdout: ' + capture.stdout);
608
+ assert.equal(result.committed, false, `expected committed=false, got result: ${JSON.stringify(result)}`);
609
+ assert.equal(result.reason, 'nothing_to_commit');
610
+ assert.ok(Array.isArray(result.dirty_after), 'dirty_after should be an array even when nothing_to_commit');
611
+ assert.ok(
612
+ result.dirty_after.includes('unstaged-only.txt'),
613
+ `dirty_after should include the unstaged file in nothing_to_commit branch, got: ${JSON.stringify(result.dirty_after)}`
614
+ );
615
+ });
616
+
617
+ it('runs porcelain in repoCwd when --repo-cwd is passed (not in cwd)', () => {
618
+ // Second fixture for the worktree
619
+ const worktreeFixture = setupGitFixture('off');
620
+ try {
621
+ // Write file in the worktree ONLY — planning root stays untouched
622
+ const stagedName = 'worktree-staged.txt';
623
+ fs.writeFileSync(path.join(worktreeFixture.cwd, stagedName), 'content');
624
+ execSync(`git add "${stagedName}"`, { cwd: worktreeFixture.cwd, stdio: 'pipe' });
625
+ // Dirty file in the worktree that is NOT staged
626
+ fs.writeFileSync(path.join(worktreeFixture.cwd, 'worktree-dirty.txt'), 'unstaged');
627
+ // Separately, make the PLANNING root dirty — this should NOT appear in dirty_after
628
+ fs.writeFileSync(path.join(fixture.cwd, 'planning-dirty.txt'), 'should-not-see-this');
629
+
630
+ const capture = captureStdout(() => {
631
+ commands.cmdCommit(
632
+ fixture.cwd, // cwd (planning root)
633
+ 'test: repo-cwd dirty', // message
634
+ [stagedName], // files (relative to repoCwd)
635
+ false, // raw=false -> JSON output
636
+ false,
637
+ false,
638
+ worktreeFixture.cwd // repoCwd
639
+ );
640
+ });
641
+ const result = capture.json;
642
+ assert.ok(result, 'expected JSON result. stdout: ' + capture.stdout);
643
+ assert.equal(result.committed, true);
644
+ assert.ok(Array.isArray(result.dirty_after), 'dirty_after should be an array');
645
+ assert.ok(
646
+ result.dirty_after.includes('worktree-dirty.txt'),
647
+ `dirty_after should include the worktree-unstaged file, got: ${JSON.stringify(result.dirty_after)}`
648
+ );
649
+ assert.ok(
650
+ !result.dirty_after.includes('planning-dirty.txt'),
651
+ `dirty_after must NOT include planning-root files when --repo-cwd is set, got: ${JSON.stringify(result.dirty_after)}`
652
+ );
653
+ } finally {
654
+ worktreeFixture.cleanup();
655
+ }
656
+ });
657
+ });
658
+
659
+ // ─── TODO_STATUSES constant ─────────────────────────────────────────────────
660
+
661
+ describe('TODO_STATUSES constant', () => {
662
+ it('exports correct values', () => {
663
+ const { TODO_STATUSES } = require('./commands.cjs');
664
+ assert.deepStrictEqual(TODO_STATUSES, ['pending', 'done']);
665
+ });
666
+ });
667
+
668
+ // ─── setTodoStatus ──────────────────────────────────────────────────────────
669
+
670
+ describe('setTodoStatus', () => {
671
+ it('throws on invalid status', () => {
672
+ const { setTodoStatus } = require('./commands.cjs');
673
+ assert.throws(() => {
674
+ setTodoStatus('/nonexistent', 'test.md', 'invalid-status');
675
+ }, (err) => {
676
+ assert.ok(err.message.includes('Invalid status'));
677
+ assert.ok(err.message.includes('pending'));
678
+ assert.ok(err.message.includes('done'));
679
+ return true;
680
+ });
681
+ });
682
+ });
683
+
684
+ // ─── cmdListTodos flat-first scanning ─────────────────────────────────────
685
+
686
+ describe('cmdListTodos flat-first scanning', () => {
687
+ const { cmdListTodos } = require('./commands.cjs');
688
+
689
+ /**
690
+ * Capture JSON output from cmdListTodos.
691
+ */
692
+ function captureTodosOutput(fn) {
693
+ const logs = [];
694
+ const origLog = console.log;
695
+ console.log = (...args) => logs.push(args.join(' '));
696
+ try {
697
+ fn();
698
+ } finally {
699
+ console.log = origLog;
700
+ }
701
+ return logs.length ? JSON.parse(logs[0]) : null;
702
+ }
703
+
704
+ it('lists todos from flat todos/ directory', () => {
705
+ const fixture = createTempProject({ withGit: true });
706
+ try {
707
+ const todosDir = path.join(fixture.planningDir, 'todos');
708
+ fs.mkdirSync(todosDir, { recursive: true });
709
+ fs.writeFileSync(path.join(todosDir, 'test-todo.md'), '---\nstatus: pending\ntitle: Test Todo\ncreated: 2026-04-06\narea: general\n---\n\nTodo body.\n');
710
+ const result = captureTodosOutput(() => cmdListTodos(fixture.cwd, null, true));
711
+ assert.equal(result.count, 1);
712
+ assert.equal(result.todos[0].status, 'pending');
713
+ assert.ok(!result.todos[0].path.includes('pending/'), 'Flat path should NOT contain pending/');
714
+ } finally {
715
+ fixture.cleanup();
716
+ }
717
+ });
718
+
719
+ it('excludes done todos from flat directory', () => {
720
+ const fixture = createTempProject({ withGit: true });
721
+ try {
722
+ const todosDir = path.join(fixture.planningDir, 'todos');
723
+ fs.mkdirSync(todosDir, { recursive: true });
724
+ fs.writeFileSync(path.join(todosDir, 'done-todo.md'), '---\nstatus: done\ntitle: Done Todo\ncreated: 2026-04-06\narea: general\n---\n\nDone.\n');
725
+ const result = captureTodosOutput(() => cmdListTodos(fixture.cwd, null, true));
726
+ assert.equal(result.count, 0, 'Done todos should be excluded');
727
+ } finally {
728
+ fixture.cleanup();
729
+ }
730
+ });
731
+
732
+ it('falls back to legacy todos/pending/ with warning', () => {
733
+ const fixture = createTempProject({ withTodos: true, withGit: true });
734
+ try {
735
+ const pendingDir = path.join(fixture.planningDir, 'todos', 'pending');
736
+ fs.mkdirSync(pendingDir, { recursive: true });
737
+ fs.writeFileSync(path.join(pendingDir, 'legacy-todo.md'), '---\ntitle: Legacy Todo\ncreated: 2026-04-06\narea: general\n---\n\nLegacy.\n');
738
+ const origWrite = process.stderr.write;
739
+ let stderrOutput = '';
740
+ process.stderr.write = (msg) => { stderrOutput += msg; };
741
+ try {
742
+ const result = captureTodosOutput(() => cmdListTodos(fixture.cwd, null, true));
743
+ assert.equal(result.count, 1);
744
+ assert.ok(stderrOutput.includes('[DGS] Warning:'), 'Should emit legacy warning');
745
+ assert.ok(result.todos[0].path.includes('pending/'), 'Legacy path should contain pending/');
746
+ } finally {
747
+ process.stderr.write = origWrite;
748
+ }
749
+ } finally {
750
+ fixture.cleanup();
751
+ }
752
+ });
753
+
754
+ it('deduplicates when same file in flat and legacy', () => {
755
+ const fixture = createTempProject({ withGit: true });
756
+ try {
757
+ const todosDir = path.join(fixture.planningDir, 'todos');
758
+ const pendingDir = path.join(todosDir, 'pending');
759
+ fs.mkdirSync(pendingDir, { recursive: true });
760
+ const todoContent = '---\nstatus: pending\ntitle: Dedup Test\ncreated: 2026-04-06\narea: general\n---\n\nBody.\n';
761
+ fs.writeFileSync(path.join(todosDir, 'dedup-test.md'), todoContent);
762
+ fs.writeFileSync(path.join(pendingDir, 'dedup-test.md'), todoContent);
763
+ // Suppress stderr
764
+ const origWrite = process.stderr.write;
765
+ process.stderr.write = () => {};
766
+ try {
767
+ const result = captureTodosOutput(() => cmdListTodos(fixture.cwd, null, true));
768
+ assert.equal(result.count, 1, 'No duplicates');
769
+ } finally {
770
+ process.stderr.write = origWrite;
771
+ }
772
+ } finally {
773
+ fixture.cleanup();
774
+ }
775
+ });
776
+ });