@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.
- package/CHANGELOG.md +96 -0
- package/README.md +41 -13
- package/agents/dgs-plan-checker.md +29 -3
- package/agents/dgs-planner.md +10 -0
- package/commands/dgs/abandon-quick.md +28 -0
- package/commands/dgs/add-tests.md +2 -2
- package/commands/dgs/audit-milestone.md +2 -2
- package/commands/dgs/capture-principle.md +11 -11
- package/commands/dgs/cleanup.md +2 -2
- package/commands/dgs/complete-milestone.md +11 -11
- package/commands/dgs/complete-quick.md +28 -0
- package/commands/dgs/create-milestone-job.md +2 -2
- package/commands/dgs/debug.md +3 -3
- package/commands/dgs/develop-idea.md +1 -1
- package/commands/dgs/fast.md +3 -1
- package/commands/dgs/health.md +1 -1
- package/commands/dgs/map-codebase.md +6 -6
- package/commands/dgs/new-milestone.md +5 -5
- package/commands/dgs/new-project.md +6 -6
- package/commands/dgs/plan-milestone-gaps.md +1 -1
- package/commands/dgs/progress.md +3 -3
- package/commands/dgs/quick-abandon.md +8 -0
- package/commands/dgs/quick-complete.md +8 -0
- package/commands/dgs/quick.md +10 -3
- package/commands/dgs/research-idea.md +2 -2
- package/commands/dgs/research-phase.md +3 -3
- package/commands/dgs/switch-project.md +1 -1
- package/commands/dgs/write-spec.md +3 -3
- package/deliver-great-systems/bin/dgs-tools.cjs +284 -30
- package/deliver-great-systems/bin/lib/commands.cjs +316 -31
- package/deliver-great-systems/bin/lib/commands.test.cjs +336 -0
- package/deliver-great-systems/bin/lib/config.cjs +39 -6
- package/deliver-great-systems/bin/lib/context.cjs +120 -0
- package/deliver-great-systems/bin/lib/core.cjs +28 -11
- package/deliver-great-systems/bin/lib/execution.cjs +49 -17
- package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
- package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
- package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
- package/deliver-great-systems/bin/lib/init.cjs +306 -39
- package/deliver-great-systems/bin/lib/init.test.cjs +416 -6
- package/deliver-great-systems/bin/lib/jobs.cjs +124 -21
- package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
- package/deliver-great-systems/bin/lib/migration.cjs +409 -1
- package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
- package/deliver-great-systems/bin/lib/milestone.cjs +54 -29
- package/deliver-great-systems/bin/lib/phase.cjs +128 -2
- package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
- package/deliver-great-systems/bin/lib/projects.cjs +28 -8
- package/deliver-great-systems/bin/lib/projects.test.cjs +86 -0
- package/deliver-great-systems/bin/lib/quick.cjs +584 -0
- package/deliver-great-systems/bin/lib/quick.test.cjs +596 -0
- package/deliver-great-systems/bin/lib/repos.cjs +25 -1
- package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
- package/deliver-great-systems/bin/lib/specs.cjs +3 -81
- package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
- package/deliver-great-systems/bin/lib/state.cjs +142 -54
- package/deliver-great-systems/bin/lib/sync.cjs +75 -0
- package/deliver-great-systems/bin/lib/verify.cjs +80 -1
- package/deliver-great-systems/bin/lib/worktrees.cjs +764 -0
- package/deliver-great-systems/bin/lib/worktrees.test.cjs +887 -0
- package/deliver-great-systems/templates/claude-md.md +16 -0
- package/deliver-great-systems/workflows/abandon-quick.md +89 -0
- package/deliver-great-systems/workflows/add-idea.md +3 -3
- package/deliver-great-systems/workflows/add-tests.md +14 -0
- package/deliver-great-systems/workflows/add-todo.md +1 -0
- package/deliver-great-systems/workflows/approve-spec.md +25 -4
- package/deliver-great-systems/workflows/audit-phase.md +15 -5
- package/deliver-great-systems/workflows/cancel-job.md +1 -1
- package/deliver-great-systems/workflows/check-todos.md +2 -3
- package/deliver-great-systems/workflows/complete-milestone.md +197 -22
- package/deliver-great-systems/workflows/complete-quick.md +68 -0
- package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
- package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
- package/deliver-great-systems/workflows/develop-idea.md +11 -11
- package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
- package/deliver-great-systems/workflows/discuss-idea.md +1 -1
- package/deliver-great-systems/workflows/execute-phase.md +121 -32
- package/deliver-great-systems/workflows/execute-plan.md +12 -21
- package/deliver-great-systems/workflows/help.md +33 -29
- package/deliver-great-systems/workflows/init-product.md +2 -18
- package/deliver-great-systems/workflows/new-milestone.md +40 -24
- package/deliver-great-systems/workflows/new-project.md +22 -680
- package/deliver-great-systems/workflows/progress-all.md +133 -0
- package/deliver-great-systems/workflows/quick-abandon.md +89 -0
- package/deliver-great-systems/workflows/quick-complete.md +68 -0
- package/deliver-great-systems/workflows/quick.md +152 -23
- package/deliver-great-systems/workflows/refine-spec.md +1 -1
- package/deliver-great-systems/workflows/research-idea.md +8 -8
- package/deliver-great-systems/workflows/resume-project.md +2 -2
- package/deliver-great-systems/workflows/run-job.md +8 -8
- package/deliver-great-systems/workflows/validate-phase.md +39 -1
- package/deliver-great-systems/workflows/verify-work.md +14 -0
- package/deliver-great-systems/workflows/write-spec.md +2 -2
- 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',
|
|
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: '
|
|
177
|
-
sync_pull: '
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
490
|
-
const headingMatch = cleaned.match(
|
|
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
|
-
|
|
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) {
|