@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
@@ -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,307 @@ 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
+ // ─── TODO_STATUSES constant ─────────────────────────────────────────────────
545
+
546
+ describe('TODO_STATUSES constant', () => {
547
+ it('exports correct values', () => {
548
+ const { TODO_STATUSES } = require('./commands.cjs');
549
+ assert.deepStrictEqual(TODO_STATUSES, ['pending', 'done']);
550
+ });
551
+ });
552
+
553
+ // ─── setTodoStatus ──────────────────────────────────────────────────────────
554
+
555
+ describe('setTodoStatus', () => {
556
+ it('throws on invalid status', () => {
557
+ const { setTodoStatus } = require('./commands.cjs');
558
+ assert.throws(() => {
559
+ setTodoStatus('/nonexistent', 'test.md', 'invalid-status');
560
+ }, (err) => {
561
+ assert.ok(err.message.includes('Invalid status'));
562
+ assert.ok(err.message.includes('pending'));
563
+ assert.ok(err.message.includes('done'));
564
+ return true;
565
+ });
566
+ });
567
+ });
568
+
569
+ // ─── cmdListTodos flat-first scanning ─────────────────────────────────────
570
+
571
+ describe('cmdListTodos flat-first scanning', () => {
572
+ const { cmdListTodos } = require('./commands.cjs');
573
+
574
+ /**
575
+ * Capture JSON output from cmdListTodos.
576
+ */
577
+ function captureTodosOutput(fn) {
578
+ const logs = [];
579
+ const origLog = console.log;
580
+ console.log = (...args) => logs.push(args.join(' '));
581
+ try {
582
+ fn();
583
+ } finally {
584
+ console.log = origLog;
585
+ }
586
+ return logs.length ? JSON.parse(logs[0]) : null;
587
+ }
588
+
589
+ it('lists todos from flat todos/ directory', () => {
590
+ const fixture = createTempProject({ withGit: true });
591
+ try {
592
+ const todosDir = path.join(fixture.planningDir, 'todos');
593
+ fs.mkdirSync(todosDir, { recursive: true });
594
+ fs.writeFileSync(path.join(todosDir, 'test-todo.md'), '---\nstatus: pending\ntitle: Test Todo\ncreated: 2026-04-06\narea: general\n---\n\nTodo body.\n');
595
+ const result = captureTodosOutput(() => cmdListTodos(fixture.cwd, null, true));
596
+ assert.equal(result.count, 1);
597
+ assert.equal(result.todos[0].status, 'pending');
598
+ assert.ok(!result.todos[0].path.includes('pending/'), 'Flat path should NOT contain pending/');
599
+ } finally {
600
+ fixture.cleanup();
601
+ }
602
+ });
603
+
604
+ it('excludes done todos from flat directory', () => {
605
+ const fixture = createTempProject({ withGit: true });
606
+ try {
607
+ const todosDir = path.join(fixture.planningDir, 'todos');
608
+ fs.mkdirSync(todosDir, { recursive: true });
609
+ fs.writeFileSync(path.join(todosDir, 'done-todo.md'), '---\nstatus: done\ntitle: Done Todo\ncreated: 2026-04-06\narea: general\n---\n\nDone.\n');
610
+ const result = captureTodosOutput(() => cmdListTodos(fixture.cwd, null, true));
611
+ assert.equal(result.count, 0, 'Done todos should be excluded');
612
+ } finally {
613
+ fixture.cleanup();
614
+ }
615
+ });
616
+
617
+ it('falls back to legacy todos/pending/ with warning', () => {
618
+ const fixture = createTempProject({ withTodos: true, withGit: true });
619
+ try {
620
+ const pendingDir = path.join(fixture.planningDir, 'todos', 'pending');
621
+ fs.mkdirSync(pendingDir, { recursive: true });
622
+ fs.writeFileSync(path.join(pendingDir, 'legacy-todo.md'), '---\ntitle: Legacy Todo\ncreated: 2026-04-06\narea: general\n---\n\nLegacy.\n');
623
+ const origWrite = process.stderr.write;
624
+ let stderrOutput = '';
625
+ process.stderr.write = (msg) => { stderrOutput += msg; };
626
+ try {
627
+ const result = captureTodosOutput(() => cmdListTodos(fixture.cwd, null, true));
628
+ assert.equal(result.count, 1);
629
+ assert.ok(stderrOutput.includes('[DGS] Warning:'), 'Should emit legacy warning');
630
+ assert.ok(result.todos[0].path.includes('pending/'), 'Legacy path should contain pending/');
631
+ } finally {
632
+ process.stderr.write = origWrite;
633
+ }
634
+ } finally {
635
+ fixture.cleanup();
636
+ }
637
+ });
638
+
639
+ it('deduplicates when same file in flat and legacy', () => {
640
+ const fixture = createTempProject({ withGit: true });
641
+ try {
642
+ const todosDir = path.join(fixture.planningDir, 'todos');
643
+ const pendingDir = path.join(todosDir, 'pending');
644
+ fs.mkdirSync(pendingDir, { recursive: true });
645
+ const todoContent = '---\nstatus: pending\ntitle: Dedup Test\ncreated: 2026-04-06\narea: general\n---\n\nBody.\n';
646
+ fs.writeFileSync(path.join(todosDir, 'dedup-test.md'), todoContent);
647
+ fs.writeFileSync(path.join(pendingDir, 'dedup-test.md'), todoContent);
648
+ // Suppress stderr
649
+ const origWrite = process.stderr.write;
650
+ process.stderr.write = () => {};
651
+ try {
652
+ const result = captureTodosOutput(() => cmdListTodos(fixture.cwd, null, true));
653
+ assert.equal(result.count, 1, 'No duplicates');
654
+ } finally {
655
+ process.stderr.write = origWrite;
656
+ }
657
+ } finally {
658
+ fixture.cleanup();
659
+ }
660
+ });
661
+ });
@@ -26,6 +26,7 @@ const LOCAL_KEYS = new Set([
26
26
  'planningRoot',
27
27
  'v2_hint_shown',
28
28
  'sync_hint_shown',
29
+ 'execution',
29
30
  ]);
30
31
 
31
32
  const VALID_CONFIG_KEYS = new Set([
@@ -34,7 +35,7 @@ const VALID_CONFIG_KEYS = new Set([
34
35
  'workflow.research', 'workflow.plan_check', 'workflow.verifier',
35
36
  'workflow.nyquist_validation', 'workflow.ui_phase', 'workflow.ui_safety_gate',
36
37
  'workflow._auto_chain_active', 'workflow.discipline', 'workflow.codereview',
37
- 'git.base_branch', 'git.branching_strategy', 'git.phase_branch_template', 'git.milestone_branch_template',
38
+ 'git.base_branch',
38
39
  'git.sync', 'git.sync_push', 'git.sync_pull',
39
40
  'planning.commit_docs', 'planning.search_gitignored',
40
41
  ]);
@@ -161,9 +162,6 @@ function cmdConfigEnsureSection(cwd, raw) {
161
162
  model_profile: 'balanced',
162
163
  commit_docs: true,
163
164
  search_gitignored: false,
164
- branching_strategy: 'none',
165
- phase_branch_template: 'dgs/{project}/phase-{phase}-{slug}',
166
- milestone_branch_template: 'dgs/{project}/{milestone}-{slug}',
167
165
  base_branch: 'main',
168
166
  workflow: {
169
167
  research: true,
@@ -173,8 +171,8 @@ function cmdConfigEnsureSection(cwd, raw) {
173
171
  discipline: true,
174
172
  },
175
173
  git: {
176
- sync_push: 'prompt',
177
- sync_pull: 'prompt',
174
+ sync_push: 'auto',
175
+ sync_pull: 'auto',
178
176
  },
179
177
  parallelization: true,
180
178
  brave_search: hasBraveSearch,
@@ -265,6 +263,40 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
265
263
  }
266
264
  }
267
265
 
266
+ /**
267
+ * CLI: Set a key in config.local.json (no VALID_CONFIG_KEYS gate).
268
+ * Used by workflows to write local-only fields like execution.active_context.
269
+ */
270
+ function cmdConfigLocalSet(cwd, keyPath, value, raw) {
271
+ if (!keyPath) {
272
+ error('Usage: config-local-set <key.path> <value>');
273
+ }
274
+
275
+ let parsedValue = value;
276
+ if (value === 'true') parsedValue = true;
277
+ else if (value === 'false') parsedValue = false;
278
+ else if (value === 'null') parsedValue = null;
279
+ else if (!isNaN(value) && value !== '') parsedValue = Number(value);
280
+
281
+ const localPath = getLocalConfigPath(cwd);
282
+ let config = _readJsonSafe(localPath);
283
+
284
+ const keys = keyPath.split('.');
285
+ let current = config;
286
+ for (let i = 0; i < keys.length - 1; i++) {
287
+ const key = keys[i];
288
+ if (current[key] === undefined || typeof current[key] !== 'object') {
289
+ current[key] = {};
290
+ }
291
+ current = current[key];
292
+ }
293
+ current[keys[keys.length - 1]] = parsedValue;
294
+
295
+ _writeJson(localPath, config);
296
+ const result = { updated: true, key: keyPath, value: parsedValue, file: 'config.local.json' };
297
+ output(result, raw, `${keyPath}=${parsedValue}`);
298
+ }
299
+
268
300
  function cmdConfigGet(cwd, keyPath, raw) {
269
301
  if (!keyPath) {
270
302
  error('Usage: config-get <key.path>');
@@ -520,6 +552,7 @@ module.exports = {
520
552
  getReviewKeysPath,
521
553
  cmdConfigEnsureSection,
522
554
  cmdConfigSet,
555
+ cmdConfigLocalSet,
523
556
  cmdConfigGet,
524
557
  writeConfigField,
525
558
  loadReviewConfig,
@@ -911,6 +911,125 @@ function cmdContextLoadTier(cwd, tierName, args, raw) {
911
911
  output(result, raw, result.files.map(f => f.path).join('\n'));
912
912
  }
913
913
 
914
+ // ─── Code Context Resolution ──────────────────────────────────────────────
915
+
916
+ // Lazy requires to avoid circular dependencies (repos.cjs requires core.cjs)
917
+ function _getRepos() { return require('./repos.cjs'); }
918
+ const { getLocalConfigPath } = require('./config.cjs');
919
+
920
+ /**
921
+ * Resolve the active code context for a repo.
922
+ *
923
+ * Returns the directory where code operations should target:
924
+ * - Main checkout when no active context
925
+ * - Milestone worktree when milestone context active
926
+ * - Quick worktree when quick context active
927
+ *
928
+ * Handles stale contexts (missing directories) by clearing
929
+ * active_context and falling back to main with a warning.
930
+ *
931
+ * @param {string} cwd - Working directory (for config resolution)
932
+ * @param {string} repoName - Name of the repo (as in REPOS.md)
933
+ * @returns {{ type: string, directory: string, slug?: string, mode?: string }}
934
+ */
935
+ function resolveCodeContext(cwd, repoName) {
936
+ const localPath = getLocalConfigPath(cwd);
937
+ let local;
938
+ try {
939
+ local = JSON.parse(fs.readFileSync(localPath, 'utf-8'));
940
+ } catch {
941
+ local = {};
942
+ }
943
+
944
+ const activeContext = local && local.execution && local.execution.active_context;
945
+ if (!activeContext) {
946
+ // No active context -- resolve to main checkout
947
+ return _resolveMainCheckout(cwd, repoName);
948
+ }
949
+
950
+ // Look up the current project
951
+ const project = local.current_project;
952
+ if (!project) {
953
+ // No project set -- fall back to main
954
+ return _resolveMainCheckout(cwd, repoName);
955
+ }
956
+
957
+ // Look up worktree entry
958
+ const worktreeEntry = local.projects
959
+ && local.projects[project]
960
+ && local.projects[project].worktrees
961
+ && local.projects[project].worktrees[activeContext];
962
+
963
+ if (!worktreeEntry) {
964
+ // Stale context -- entry missing from config
965
+ process.stderr.write(
966
+ 'Warning: Active context \'' + activeContext + '\' not found in worktree state. Falling back to main.\n'
967
+ );
968
+ _clearActiveContext(localPath, local);
969
+ return _resolveMainCheckout(cwd, repoName);
970
+ }
971
+
972
+ // Look up repo directory in worktree entry
973
+ const worktreeDir = worktreeEntry.repos && worktreeEntry.repos[repoName];
974
+ if (!worktreeDir) {
975
+ // Repo not in this worktree -- fall back to main
976
+ return _resolveMainCheckout(cwd, repoName);
977
+ }
978
+
979
+ // Validate directory exists on disk
980
+ if (!fs.existsSync(worktreeDir)) {
981
+ // Stale context -- directory missing
982
+ process.stderr.write(
983
+ 'Warning: Active context \'' + activeContext + '\' is stale -- worktree directory no longer exists at ' + worktreeDir + '. Falling back to main.\n'
984
+ );
985
+ _clearActiveContext(localPath, local);
986
+ return _resolveMainCheckout(cwd, repoName);
987
+ }
988
+
989
+ return {
990
+ type: worktreeEntry.type || 'milestone',
991
+ directory: worktreeDir,
992
+ slug: activeContext,
993
+ mode: worktreeEntry.mode || null,
994
+ };
995
+ }
996
+
997
+ /**
998
+ * Resolve main checkout path for a repo.
999
+ * @private
1000
+ */
1001
+ function _resolveMainCheckout(cwd, repoName) {
1002
+ try {
1003
+ const parsed = _getRepos().parseReposMd(cwd);
1004
+ if (parsed && parsed.repos) {
1005
+ const repo = parsed.repos.find(function(r) { return r.name === repoName; });
1006
+ if (repo && repo.path) {
1007
+ const root = getPlanningRoot(cwd);
1008
+ const absPath = path.resolve(root, repo.path);
1009
+ return { type: 'main', directory: absPath };
1010
+ }
1011
+ }
1012
+ } catch {
1013
+ // REPOS.md not found or parse error
1014
+ }
1015
+ // Fallback: return cwd
1016
+ return { type: 'main', directory: cwd };
1017
+ }
1018
+
1019
+ /**
1020
+ * Clear active_context in config.local.json.
1021
+ * @private
1022
+ */
1023
+ function _clearActiveContext(localPath, localData) {
1024
+ try {
1025
+ if (!localData.execution) localData.execution = {};
1026
+ localData.execution.active_context = null;
1027
+ fs.writeFileSync(localPath, JSON.stringify(localData, null, 2) + '\n', 'utf-8');
1028
+ } catch {
1029
+ // Best effort -- don't crash on write failure
1030
+ }
1031
+ }
1032
+
914
1033
  // ─── Exports ────────────────────────────────────────────────────────────────
915
1034
 
916
1035
  module.exports = {
@@ -918,6 +1037,7 @@ module.exports = {
918
1037
  cmdContextLoadTier,
919
1038
  truncateApprovedSpec,
920
1039
  resetTierCache,
1040
+ resolveCodeContext,
921
1041
  // Internal exports for testing
922
1042
  parseTierDefinitions,
923
1043
  parseSimpleYaml,
@@ -71,9 +71,6 @@ function loadConfig(cwd) {
71
71
  model_profile: 'balanced',
72
72
  commit_docs: true,
73
73
  search_gitignored: false,
74
- branching_strategy: 'none',
75
- phase_branch_template: 'dgs/{project}/phase-{phase}-{slug}',
76
- milestone_branch_template: 'dgs/{project}/{milestone}-{slug}',
77
74
  base_branch: 'main',
78
75
  sync_push: 'off',
79
76
  sync_pull: 'off',
@@ -144,9 +141,6 @@ function loadConfig(cwd) {
144
141
  model_profile: get('model_profile') ?? defaults.model_profile,
145
142
  commit_docs: get('commit_docs', { section: 'planning', field: 'commit_docs' }) ?? defaults.commit_docs,
146
143
  search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored,
147
- branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
148
- phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
149
- milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
150
144
  base_branch: get('base_branch', { section: 'git', field: 'base_branch' }) ?? defaults.base_branch,
151
145
  sync_push: get('sync_push', { section: 'git', field: 'sync_push' }) ?? defaults.sync_push,
152
146
  sync_pull: get('sync_pull', { section: 'git', field: 'sync_pull' }) ?? defaults.sync_pull,
@@ -472,7 +466,14 @@ function generateSlugInternal(text) {
472
466
 
473
467
  function getMilestoneInfo(cwd) {
474
468
  try {
475
- const roadmap = fs.readFileSync(path.join(getPlanningRoot(cwd), 'ROADMAP.md'), 'utf-8');
469
+ // Try project-scoped ROADMAP first, then planning root
470
+ let roadmap;
471
+ try {
472
+ const projectRoot = getProjectRoot(cwd);
473
+ roadmap = fs.readFileSync(path.join(cwd, projectRoot, 'ROADMAP.md'), 'utf-8');
474
+ } catch {
475
+ roadmap = fs.readFileSync(path.join(getPlanningRoot(cwd), 'ROADMAP.md'), 'utf-8');
476
+ }
476
477
 
477
478
  // First: check for list-format roadmaps using 🚧 (in-progress) marker
478
479
  // e.g. "- 🚧 **v2.1 Belgium** — Phases 24-28 (in progress)"
@@ -484,10 +485,20 @@ function getMilestoneInfo(cwd) {
484
485
  };
485
486
  }
486
487
 
487
- // Second: heading-format roadmaps — strip shipped milestones in <details> blocks
488
+ // Second: bullet-list format with "(in progress)" marker
489
+ // e.g. "- v19.0 Git Worktrees -- Phases 124-129 (in progress)"
490
+ const bulletMatch = roadmap.match(/^- v(\d+\.\d+)\s+(.+?)\s+--\s+Phases\s+\S+\s+\(in progress\)/m);
491
+ if (bulletMatch) {
492
+ return {
493
+ version: 'v' + bulletMatch[1],
494
+ name: bulletMatch[2].trim(),
495
+ };
496
+ }
497
+
498
+ // Third: heading-format roadmaps — strip shipped milestones in <details> blocks
488
499
  const cleaned = roadmap.replace(/<details>[\s\S]*?<\/details>/gi, '');
489
- // Extract version and name from the same ## heading for consistency
490
- const headingMatch = cleaned.match(/## .*v(\d+\.\d+)[:\s]+([^\n(]+)/);
500
+ // e.g. "### v19.0 Git Worktrees (In Progress)"
501
+ const headingMatch = cleaned.match(/#{2,3}\s+v(\d+\.\d+)[:\s]+([^\n(]+)/);
491
502
  if (headingMatch) {
492
503
  return {
493
504
  version: 'v' + headingMatch[1],
@@ -513,7 +524,13 @@ function getMilestoneInfo(cwd) {
513
524
  function getMilestonePhaseFilter(cwd) {
514
525
  const milestonePhaseNums = new Set();
515
526
  try {
516
- const roadmap = fs.readFileSync(path.join(getPlanningRoot(cwd), 'ROADMAP.md'), 'utf-8');
527
+ let roadmap;
528
+ try {
529
+ const projectRoot = getProjectRoot(cwd);
530
+ roadmap = fs.readFileSync(path.join(cwd, projectRoot, 'ROADMAP.md'), 'utf-8');
531
+ } catch {
532
+ roadmap = fs.readFileSync(path.join(getPlanningRoot(cwd), 'ROADMAP.md'), 'utf-8');
533
+ }
517
534
  const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
518
535
  let m;
519
536
  while ((m = phasePattern.exec(roadmap)) !== null) {