@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
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Tests for phase.cjs::cmdPhaseFinalize and commands.cjs::cmdPlanFinalize.
3
+ *
4
+ * Atomic "update tracking files + commit" behavior in a real temp git repo.
5
+ * Uses Node.js built-in test runner (node:test) and assert (node:assert).
6
+ */
7
+
8
+ const { describe, it, beforeEach, afterEach } = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { execSync } = require('child_process');
13
+
14
+ const { createTempProject } = require('./test-helpers.cjs');
15
+
16
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Capture stdout output from CLI commands that call output() (process.stdout.write
20
+ * + process.exit). Mocks both so multiple CLI invocations can run in sequence.
21
+ * Returns { stdout, exitCode, json }.
22
+ */
23
+ function captureStdout(fn) {
24
+ const chunks = [];
25
+ const origWrite = process.stdout.write.bind(process.stdout);
26
+ const origExit = process.exit;
27
+ let exitCode = null;
28
+ process.stdout.write = (data) => { chunks.push(String(data)); return true; };
29
+ process.exit = (code) => {
30
+ exitCode = code == null ? 0 : code;
31
+ throw new Error('__EXIT__');
32
+ };
33
+ try {
34
+ fn();
35
+ } catch (e) {
36
+ if (e && e.message !== '__EXIT__') throw e;
37
+ } finally {
38
+ process.stdout.write = origWrite;
39
+ process.exit = origExit;
40
+ }
41
+ const stdout = chunks.join('');
42
+ let json = null;
43
+ try { json = JSON.parse(stdout); } catch { /* not JSON */ }
44
+ return { stdout, exitCode, json };
45
+ }
46
+
47
+ function gitLog(cwd) {
48
+ return execSync('git log --oneline', { cwd, encoding: 'utf-8' })
49
+ .trim().split('\n').filter(Boolean);
50
+ }
51
+
52
+ function gitShowFiles(cwd, ref) {
53
+ return execSync(`git show --name-only --format= ${ref}`, { cwd, encoding: 'utf-8' })
54
+ .trim().split('\n').filter(Boolean);
55
+ }
56
+
57
+ function gitLastMessage(cwd) {
58
+ return execSync('git log -1 --format=%s', { cwd, encoding: 'utf-8' }).trim();
59
+ }
60
+
61
+ /**
62
+ * Write a rich fixture atop createTempProject for Phase 01 finalize tests.
63
+ * Returns { cwd, cleanup } from createTempProject after adding ROADMAP, STATE,
64
+ * REQUIREMENTS, phase dir, and VERIFICATION.md — all committed to a clean tree.
65
+ */
66
+ function makePhaseFinalizeFixture() {
67
+ const fixture = createTempProject({ withGit: false, withPhases: false, withConfig: {} });
68
+
69
+ // Remove v2 markers so this is treated as a v1/root-layout project (phases at root)
70
+ fs.unlinkSync(path.join(fixture.cwd, 'PROJECTS.md'));
71
+ fs.unlinkSync(path.join(fixture.cwd, 'REPOS.md'));
72
+
73
+ // Richer ROADMAP.md with parseable Phase 01 entry + progress table row
74
+ fs.writeFileSync(path.join(fixture.cwd, 'ROADMAP.md'),
75
+ '# Roadmap\n\n' +
76
+ '## Progress\n\n' +
77
+ '| Phase | Plans | Status | Date |\n' +
78
+ '|-------|-------|--------|------|\n' +
79
+ '| 01. Test | 1/1 | In Progress | |\n\n' +
80
+ '## Phases\n\n' +
81
+ '- [ ] **Phase 01: Test** - desc\n\n' +
82
+ '## Phase 01: Test Phase\n\n' +
83
+ '**Plans:** 1/1 plans executed\n' +
84
+ '**Requirements:** [REQ-01]\n'
85
+ );
86
+
87
+ // STATE.md with all the fields cmdPhaseComplete regex-replaces
88
+ fs.writeFileSync(path.join(fixture.cwd, 'STATE.md'),
89
+ '# State\n\n' +
90
+ '**Current Phase:** 01\n' +
91
+ '**Current Phase Name:** Test Phase\n' +
92
+ '**Status:** In Progress\n' +
93
+ '**Current Plan:** 01-01\n' +
94
+ '**Last Activity:** 2025-01-01\n' +
95
+ '**Last Activity Description:** Working\n'
96
+ );
97
+
98
+ // REQUIREMENTS.md
99
+ fs.writeFileSync(path.join(fixture.cwd, 'REQUIREMENTS.md'),
100
+ '# Requirements\n\n' +
101
+ '- [ ] **REQ-01** Do thing\n\n' +
102
+ '| ID | Phase | Status |\n' +
103
+ '|----|-------|--------|\n' +
104
+ '| REQ-01 | Phase 01 | Pending |\n'
105
+ );
106
+
107
+ // Phase dir + PLAN + SUMMARY so findPhaseInternal finds it
108
+ fs.mkdirSync(path.join(fixture.cwd, 'phases/01-test-phase'), { recursive: true });
109
+ fs.writeFileSync(path.join(fixture.cwd, 'phases/01-test-phase/01-01-PLAN.md'), '# Plan\n');
110
+ fs.writeFileSync(path.join(fixture.cwd, 'phases/01-test-phase/01-01-SUMMARY.md'), '# Summary\n');
111
+
112
+ // Commit fixture additions so finalize produces a clean new commit
113
+ execSync('git add .', { cwd: fixture.cwd, stdio: 'pipe' });
114
+ execSync('git commit -m "fixture setup"', { cwd: fixture.cwd, stdio: 'pipe' });
115
+
116
+ // Write VERIFICATION.md AFTER the commit — this mirrors the real workflow where
117
+ // the verifier writes it just before finalize. It's untracked, so finalize must
118
+ // stage + commit it.
119
+ fs.writeFileSync(path.join(fixture.cwd, 'phases/01-test-phase/01-VERIFICATION.md'), '# Verification\n\nPassed.\n');
120
+
121
+ return fixture;
122
+ }
123
+
124
+ /**
125
+ * Write a rich fixture for Plan 01 finalize tests. Similar to above but the
126
+ * PLAN.md has real frontmatter with plan_name + requirements.
127
+ */
128
+ function makePlanFinalizeFixture() {
129
+ const fixture = createTempProject({ withGit: false, withPhases: false, withConfig: {} });
130
+
131
+ // Remove v2 markers so this is treated as a v1/root-layout project (phases at root)
132
+ fs.unlinkSync(path.join(fixture.cwd, 'PROJECTS.md'));
133
+ fs.unlinkSync(path.join(fixture.cwd, 'REPOS.md'));
134
+
135
+ // ROADMAP.md with Phase 04 (same structure pattern)
136
+ fs.writeFileSync(path.join(fixture.cwd, 'ROADMAP.md'),
137
+ '# Roadmap\n\n' +
138
+ '## Progress\n\n' +
139
+ '| Phase | Plans | Status | Date |\n' +
140
+ '|-------|-------|--------|------|\n' +
141
+ '| 04. Auth | 0/1 | Planned | |\n\n' +
142
+ '## Phases\n\n' +
143
+ '- [ ] **Phase 04: Auth** - auth layer\n\n' +
144
+ '## Phase 04: Auth\n\n' +
145
+ '**Plans:** 0/1 plans executed\n' +
146
+ '**Requirements:** [AUTH-01]\n'
147
+ );
148
+
149
+ fs.writeFileSync(path.join(fixture.cwd, 'STATE.md'),
150
+ '# State\n\n' +
151
+ '**Current Phase:** 04\n' +
152
+ '**Current Plan:** 04-01\n' +
153
+ 'Progress: [░░░░░░░░░░] 0%\n'
154
+ );
155
+
156
+ fs.writeFileSync(path.join(fixture.cwd, 'REQUIREMENTS.md'),
157
+ '# Requirements\n\n' +
158
+ '- [ ] **AUTH-01** Login\n\n' +
159
+ '| ID | Phase | Status |\n' +
160
+ '|----|-------|--------|\n' +
161
+ '| AUTH-01 | Phase 04 | Pending |\n'
162
+ );
163
+
164
+ // Phase dir + PLAN.md (committed before finalize runs)
165
+ fs.mkdirSync(path.join(fixture.cwd, 'phases/04-auth'), { recursive: true });
166
+ fs.writeFileSync(path.join(fixture.cwd, 'phases/04-auth/04-01-PLAN.md'),
167
+ '---\n' +
168
+ 'phase: 04-auth\n' +
169
+ 'plan: 01\n' +
170
+ 'plan_name: auth-login\n' +
171
+ 'requirements: [AUTH-01]\n' +
172
+ '---\n' +
173
+ '# Plan\n'
174
+ );
175
+
176
+ execSync('git add .', { cwd: fixture.cwd, stdio: 'pipe' });
177
+ execSync('git commit -m "fixture setup"', { cwd: fixture.cwd, stdio: 'pipe' });
178
+
179
+ // SUMMARY.md written AFTER the initial commit — matches real workflow where
180
+ // the executor produces SUMMARY.md as its final artifact before finalize runs.
181
+ // PLAN.md also gets touched so it's re-staged (the real workflow updates it
182
+ // with executed_by / completion metadata during the session).
183
+ fs.writeFileSync(path.join(fixture.cwd, 'phases/04-auth/04-01-SUMMARY.md'), '# Summary\n');
184
+ fs.appendFileSync(path.join(fixture.cwd, 'phases/04-auth/04-01-PLAN.md'), '\nexecuted.\n');
185
+
186
+ return fixture;
187
+ }
188
+
189
+ // ─── Tests ────────────────────────────────────────────────────────────────────
190
+
191
+ describe('cmdPhaseFinalize', () => {
192
+ let fixture;
193
+ let phase;
194
+
195
+ beforeEach(() => {
196
+ fixture = makePhaseFinalizeFixture();
197
+ // Reload phase.cjs each time so the fixture cwd's config is re-read fresh
198
+ delete require.cache[require.resolve('./phase.cjs')];
199
+ phase = require('./phase.cjs');
200
+ });
201
+
202
+ afterEach(() => {
203
+ fixture.cleanup();
204
+ });
205
+
206
+ it('creates a single commit containing tracking files + VERIFICATION.md', () => {
207
+ const before = gitLog(fixture.cwd).length;
208
+ const { json } = captureStdout(() =>
209
+ phase.cmdPhaseFinalize(fixture.cwd, '01', { push: false }, true)
210
+ );
211
+
212
+ assert.ok(json, 'should emit JSON output');
213
+ assert.equal(json.committed, true);
214
+ assert.ok(json.hash, 'should return commit hash');
215
+ assert.equal(gitLog(fixture.cwd).length, before + 1, 'exactly one new commit');
216
+
217
+ const files = gitShowFiles(fixture.cwd, 'HEAD');
218
+ assert.ok(files.includes('ROADMAP.md'), 'ROADMAP.md staged');
219
+ assert.ok(files.includes('STATE.md'), 'STATE.md staged');
220
+ assert.ok(files.includes('REQUIREMENTS.md'), 'REQUIREMENTS.md staged');
221
+ assert.ok(
222
+ files.some(f => /VERIFICATION\.md$/.test(f)),
223
+ 'VERIFICATION.md staged'
224
+ );
225
+ });
226
+
227
+ it('commit message uses docs(phase-NN): format', () => {
228
+ captureStdout(() =>
229
+ phase.cmdPhaseFinalize(fixture.cwd, '01', { push: false }, true)
230
+ );
231
+ assert.equal(gitLastMessage(fixture.cwd), 'docs(phase-01): complete phase execution');
232
+ });
233
+
234
+ it('succeeds when VERIFICATION.md is absent', () => {
235
+ // Simply delete the untracked VERIFICATION.md (never committed)
236
+ fs.unlinkSync(path.join(fixture.cwd, 'phases/01-test-phase/01-VERIFICATION.md'));
237
+
238
+ const { json } = captureStdout(() =>
239
+ phase.cmdPhaseFinalize(fixture.cwd, '01', { push: false }, true)
240
+ );
241
+
242
+ assert.equal(json.committed, true);
243
+ const files = gitShowFiles(fixture.cwd, 'HEAD');
244
+ assert.ok(!files.some(f => /VERIFICATION\.md$/.test(f)), 'no VERIFICATION in commit');
245
+ assert.ok(files.includes('ROADMAP.md'), 'ROADMAP still committed');
246
+ });
247
+
248
+ it('succeeds when REQUIREMENTS.md is absent', () => {
249
+ // VERIFICATION.md is untracked at this point — stage only the REQUIREMENTS removal.
250
+ fs.unlinkSync(path.join(fixture.cwd, 'REQUIREMENTS.md'));
251
+ execSync('git add REQUIREMENTS.md', { cwd: fixture.cwd, stdio: 'pipe' });
252
+ execSync('git commit -m "remove req"', { cwd: fixture.cwd, stdio: 'pipe' });
253
+
254
+ const { json } = captureStdout(() =>
255
+ phase.cmdPhaseFinalize(fixture.cwd, '01', { push: false }, true)
256
+ );
257
+
258
+ assert.equal(json.committed, true);
259
+ const files = gitShowFiles(fixture.cwd, 'HEAD');
260
+ assert.ok(!files.includes('REQUIREMENTS.md'), 'no REQUIREMENTS in commit');
261
+ assert.ok(files.includes('ROADMAP.md'), 'ROADMAP still committed');
262
+ assert.ok(files.includes('STATE.md'), 'STATE still committed');
263
+ assert.ok(
264
+ files.some(f => /VERIFICATION\.md$/.test(f)),
265
+ 'VERIFICATION still committed'
266
+ );
267
+ });
268
+
269
+ it('honors commit_docs=false (no commit, but file writes still happen)', () => {
270
+ const cfgPath = path.join(fixture.cwd, 'config.json');
271
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
272
+ cfg.commit_docs = false;
273
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg));
274
+ // Commit only the config change (VERIFICATION.md is untracked; leave it)
275
+ execSync('git add config.json', { cwd: fixture.cwd, stdio: 'pipe' });
276
+ execSync('git commit -m "disable commit_docs"', { cwd: fixture.cwd, stdio: 'pipe' });
277
+
278
+ const before = gitLog(fixture.cwd).length;
279
+ const { json } = captureStdout(() =>
280
+ phase.cmdPhaseFinalize(fixture.cwd, '01', { push: false }, true)
281
+ );
282
+
283
+ assert.equal(json.committed, false);
284
+ assert.equal(json.commit_reason, 'skipped_commit_docs_false');
285
+ assert.equal(gitLog(fixture.cwd).length, before, 'no new commit created');
286
+
287
+ // File writes still happened: ROADMAP should have [x]
288
+ const roadmap = fs.readFileSync(path.join(fixture.cwd, 'ROADMAP.md'), 'utf-8');
289
+ assert.match(roadmap, /\[x\]/, 'roadmap checkbox updated despite no commit');
290
+ });
291
+
292
+ it('returns committed=false with nothing_to_commit when no files to stage', () => {
293
+ // Remove all tracking files and VERIFICATION.md BEFORE finalize. findPhaseInternal
294
+ // still succeeds via the phase dir + PLAN.md, but no files exist to stage.
295
+ fs.unlinkSync(path.join(fixture.cwd, 'phases/01-test-phase/01-VERIFICATION.md'));
296
+ fs.unlinkSync(path.join(fixture.cwd, 'ROADMAP.md'));
297
+ fs.unlinkSync(path.join(fixture.cwd, 'STATE.md'));
298
+ fs.unlinkSync(path.join(fixture.cwd, 'REQUIREMENTS.md'));
299
+ execSync('git add -A', { cwd: fixture.cwd, stdio: 'pipe' });
300
+ execSync('git commit -m "remove all tracking"', { cwd: fixture.cwd, stdio: 'pipe' });
301
+
302
+ const before = gitLog(fixture.cwd).length;
303
+ const { json } = captureStdout(() =>
304
+ phase.cmdPhaseFinalize(fixture.cwd, '01', { push: false }, true)
305
+ );
306
+ assert.equal(json.committed, false);
307
+ assert.equal(json.commit_reason, 'nothing_to_commit');
308
+ assert.equal(gitLog(fixture.cwd).length, before, 'no new commit');
309
+ });
310
+ });
311
+
312
+ describe('cmdPlanFinalize', () => {
313
+ let fixture;
314
+ let commands;
315
+
316
+ beforeEach(() => {
317
+ fixture = makePlanFinalizeFixture();
318
+ delete require.cache[require.resolve('./commands.cjs')];
319
+ commands = require('./commands.cjs');
320
+ });
321
+
322
+ afterEach(() => {
323
+ fixture.cleanup();
324
+ });
325
+
326
+ it('creates a single commit containing PLAN + SUMMARY + tracking files', () => {
327
+ const before = gitLog(fixture.cwd).length;
328
+ const { json } = captureStdout(() =>
329
+ commands.cmdPlanFinalize(fixture.cwd, '04', '01', { push: false }, true)
330
+ );
331
+
332
+ assert.ok(json, 'should emit JSON output');
333
+ assert.equal(json.committed, true);
334
+ assert.ok(json.hash);
335
+ assert.equal(gitLog(fixture.cwd).length, before + 1, 'exactly one new commit');
336
+
337
+ const files = gitShowFiles(fixture.cwd, 'HEAD');
338
+ assert.ok(files.some(f => f.endsWith('04-01-PLAN.md')), 'PLAN.md staged');
339
+ assert.ok(files.some(f => f.endsWith('04-01-SUMMARY.md')), 'SUMMARY.md staged');
340
+ assert.ok(files.includes('STATE.md'), 'STATE.md staged');
341
+ assert.ok(files.includes('ROADMAP.md'), 'ROADMAP.md staged');
342
+ assert.ok(files.includes('REQUIREMENTS.md'), 'REQUIREMENTS.md staged');
343
+ });
344
+
345
+ it('commit message uses plan_name from PLAN.md frontmatter', () => {
346
+ captureStdout(() =>
347
+ commands.cmdPlanFinalize(fixture.cwd, '04', '01', { push: false }, true)
348
+ );
349
+ assert.equal(gitLastMessage(fixture.cwd), 'docs(04-01): complete auth-login plan');
350
+ });
351
+
352
+ it('--plan-name flag overrides frontmatter plan_name', () => {
353
+ captureStdout(() =>
354
+ commands.cmdPlanFinalize(fixture.cwd, '04', '01', { push: false, planName: 'override-name' }, true)
355
+ );
356
+ assert.equal(gitLastMessage(fixture.cwd), 'docs(04-01): complete override-name plan');
357
+ });
358
+
359
+ it('marks requirements from frontmatter complete', () => {
360
+ const { json } = captureStdout(() =>
361
+ commands.cmdPlanFinalize(fixture.cwd, '04', '01', { push: false }, true)
362
+ );
363
+ assert.equal(json.committed, true);
364
+ assert.deepEqual(json.requirements.marked_complete, ['AUTH-01']);
365
+
366
+ const reqContent = fs.readFileSync(path.join(fixture.cwd, 'REQUIREMENTS.md'), 'utf-8');
367
+ assert.match(reqContent, /- \[x\] \*\*AUTH-01\*\*/, 'checkbox marked');
368
+ assert.match(reqContent, /\|\s*AUTH-01\s*\|[^|]+\|\s*Complete\s*\|/, 'traceability Complete');
369
+ });
370
+
371
+ it('succeeds gracefully when REQUIREMENTS.md is absent', () => {
372
+ fs.unlinkSync(path.join(fixture.cwd, 'REQUIREMENTS.md'));
373
+ // Stage only the REQUIREMENTS removal — leave PLAN/SUMMARY untracked/dirty
374
+ // so finalize picks them up in the atomic commit.
375
+ execSync('git add REQUIREMENTS.md', { cwd: fixture.cwd, stdio: 'pipe' });
376
+ execSync('git commit -m "remove req"', { cwd: fixture.cwd, stdio: 'pipe' });
377
+
378
+ const { json } = captureStdout(() =>
379
+ commands.cmdPlanFinalize(fixture.cwd, '04', '01', { push: false }, true)
380
+ );
381
+ assert.equal(json.committed, true);
382
+ const files = gitShowFiles(fixture.cwd, 'HEAD');
383
+ assert.ok(!files.includes('REQUIREMENTS.md'), 'REQUIREMENTS not committed');
384
+ assert.ok(files.some(f => f.endsWith('04-01-PLAN.md')), 'PLAN still committed');
385
+ assert.ok(files.some(f => f.endsWith('04-01-SUMMARY.md')), 'SUMMARY still committed');
386
+ assert.ok(files.includes('STATE.md'), 'STATE still committed');
387
+ assert.ok(files.includes('ROADMAP.md'), 'ROADMAP still committed');
388
+ });
389
+
390
+ it('honors commit_docs=false (no commit, but file writes still happen)', () => {
391
+ const cfgPath = path.join(fixture.cwd, 'config.json');
392
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
393
+ cfg.commit_docs = false;
394
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg));
395
+ execSync('git add -A', { cwd: fixture.cwd, stdio: 'pipe' });
396
+ execSync('git commit -m "disable commit_docs"', { cwd: fixture.cwd, stdio: 'pipe' });
397
+
398
+ const before = gitLog(fixture.cwd).length;
399
+ const { json } = captureStdout(() =>
400
+ commands.cmdPlanFinalize(fixture.cwd, '04', '01', { push: false }, true)
401
+ );
402
+ assert.equal(json.committed, false);
403
+ assert.equal(json.commit_reason, 'skipped_commit_docs_false');
404
+ assert.equal(gitLog(fixture.cwd).length, before, 'no new commit');
405
+ });
406
+
407
+ it('falls back to "execution" when plan_name missing and no flag', () => {
408
+ // Remove plan_name from frontmatter
409
+ const planPath = path.join(fixture.cwd, 'phases/04-auth/04-01-PLAN.md');
410
+ const content = fs.readFileSync(planPath, 'utf-8').replace(/plan_name:.*\n/, '');
411
+ fs.writeFileSync(planPath, content);
412
+ execSync('git add -A', { cwd: fixture.cwd, stdio: 'pipe' });
413
+ execSync('git commit -m "strip plan_name"', { cwd: fixture.cwd, stdio: 'pipe' });
414
+
415
+ captureStdout(() =>
416
+ commands.cmdPlanFinalize(fixture.cwd, '04', '01', { push: false }, true)
417
+ );
418
+ assert.equal(gitLastMessage(fixture.cwd), 'docs(04-01): complete execution plan');
419
+ });
420
+ });
@@ -42,9 +42,11 @@ function createProjectSubfolder(cwd, slug, name, options) {
42
42
  // Create project directory
43
43
  fs.mkdirSync(projectDir, { recursive: true });
44
44
 
45
- // Create standard subdirectories
45
+ // Create standard subdirectories with .gitkeep so git tracks empty folders
46
46
  for (const dir of STANDARD_DIRS) {
47
- fs.mkdirSync(path.join(projectDir, dir), { recursive: true });
47
+ const dirPath = path.join(projectDir, dir);
48
+ fs.mkdirSync(dirPath, { recursive: true });
49
+ fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
48
50
  }
49
51
 
50
52
  // Create standard files with templates
@@ -163,13 +165,11 @@ function scanProjectReposTags(cwd, slug) {
163
165
  return Array.from(allRepos).sort();
164
166
  }
165
167
 
166
- // ─── PROJECTS.md Regeneration ───────────────────────────────────────────────
168
+ // ─── Projects Readonly Listing ──────────────────────────────────────────────
167
169
 
168
170
  /**
169
- * Regenerate PROJECTS.md by scanning all project subfolders.
170
- *
171
- * Reads each project's STATE.md for status/phase/progress, scans plan
172
- * <repos> tags for repos touched, and writes the derived PROJECTS.md.
171
+ * Enumerate all project subfolders and build a { projects, warnings } result
172
+ * WITHOUT writing PROJECTS.md. Pure read function — no side effects.
173
173
  *
174
174
  * Ghost project guard: projects whose STATE.md is missing or unreadable
175
175
  * are warned about and omitted from the output.
@@ -177,7 +177,7 @@ function scanProjectReposTags(cwd, slug) {
177
177
  * @param {string} cwd - Working directory (product root)
178
178
  * @returns {{ projects: Array, warnings: string[] }}
179
179
  */
180
- function regenerateProjectsMd(cwd) {
180
+ function listProjectsReadonly(cwd) {
181
181
  const slugs = getProjectFolders(cwd);
182
182
  const warnings = [];
183
183
  const projects = [];
@@ -200,6 +200,25 @@ function regenerateProjectsMd(cwd) {
200
200
  });
201
201
  }
202
202
 
203
+ return { projects, warnings };
204
+ }
205
+
206
+ // ─── PROJECTS.md Regeneration ───────────────────────────────────────────────
207
+
208
+ /**
209
+ * Regenerate PROJECTS.md by scanning all project subfolders.
210
+ *
211
+ * Reads each project's STATE.md for status/phase/progress, scans plan
212
+ * <repos> tags for repos touched, and writes the derived PROJECTS.md.
213
+ *
214
+ * Delegates the read phase to listProjectsReadonly.
215
+ *
216
+ * @param {string} cwd - Working directory (product root)
217
+ * @returns {{ projects: Array, warnings: string[] }}
218
+ */
219
+ function regenerateProjectsMd(cwd) {
220
+ const { projects, warnings } = listProjectsReadonly(cwd);
221
+
203
222
  // Write PROJECTS.md
204
223
  let content = '# Projects\n\n';
205
224
  content += '## Active\n\n';
@@ -677,6 +696,7 @@ module.exports = {
677
696
  createProjectSubfolder,
678
697
  readProjectState,
679
698
  scanProjectReposTags,
699
+ listProjectsReadonly,
680
700
  regenerateProjectsMd,
681
701
  completeProject,
682
702
  reactivateProject,
@@ -39,6 +39,7 @@ const {
39
39
  readProjectState,
40
40
  scanProjectReposTags,
41
41
  regenerateProjectsMd,
42
+ listProjectsReadonly,
42
43
  completeProject,
43
44
  reactivateProject,
44
45
  parseProjectsMd,
@@ -323,6 +324,91 @@ describe('regenerateProjectsMd', () => {
323
324
  });
324
325
  });
325
326
 
327
+ // ─── listProjectsReadonly ───────────────────────────────────────────────────
328
+
329
+ describe('listProjectsReadonly', () => {
330
+ let tmpDir;
331
+
332
+ beforeEach(() => {
333
+ tmpDir = createTempDir(); tmpDir = fs.realpathSync(tmpDir); initGitRepo(tmpDir);
334
+ setupPlanning(tmpDir);
335
+ });
336
+
337
+ afterEach(() => { resetPaths(); cleanupDir(tmpDir); });
338
+
339
+ it('returns same projects shape as regenerateProjectsMd for a fixture with 2 active + 1 completed', () => {
340
+ createProjectManually(tmpDir, 'active-a', '# Project State\n\nPhase: 1\nStatus: In progress\nProgress: [##--------] 20%\n');
341
+ createProjectManually(tmpDir, 'active-b', '# Project State\n\nPhase: 3\nStatus: Active\nProgress: [#####-----] 50%\n');
342
+ createProjectManually(tmpDir, 'done-c', '# Project State\n\nPhase: 10\nStatus: completed\nProgress: [##########] 100%\nCompleted: 2026-02-15\n');
343
+
344
+ const readResult = listProjectsReadonly(tmpDir);
345
+ const regenResult = regenerateProjectsMd(tmpDir);
346
+
347
+ assert.ok(Array.isArray(readResult.projects));
348
+ assert.ok(Array.isArray(readResult.warnings));
349
+ assert.strictEqual(readResult.projects.length, regenResult.projects.length);
350
+ // Projects should have identical fields
351
+ const readByName = Object.fromEntries(readResult.projects.map(p => [p.name, p]));
352
+ const regenByName = Object.fromEntries(regenResult.projects.map(p => [p.name, p]));
353
+ for (const name of Object.keys(readByName)) {
354
+ assert.deepStrictEqual(readByName[name], regenByName[name]);
355
+ }
356
+ });
357
+
358
+ it('does NOT write PROJECTS.md', () => {
359
+ createProjectManually(tmpDir, 'proj-a', '# Project State\n\nPhase: 2\nStatus: Active\n');
360
+ const projectsMdPath = path.join(tmpDir, 'PROJECTS.md');
361
+ assert.strictEqual(fs.existsSync(projectsMdPath), false);
362
+
363
+ const result = listProjectsReadonly(tmpDir);
364
+ assert.ok(Array.isArray(result.projects));
365
+
366
+ assert.strictEqual(fs.existsSync(projectsMdPath), false, 'listProjectsReadonly must not create PROJECTS.md');
367
+ });
368
+
369
+ it('does NOT modify an existing PROJECTS.md', () => {
370
+ createProjectManually(tmpDir, 'proj-a', '# Project State\n\nPhase: 2\nStatus: Active\n');
371
+ const projectsMdPath = path.join(tmpDir, 'PROJECTS.md');
372
+ const sentinel = '# Projects\n\nSENTINEL CONTENT — should not be overwritten by listProjectsReadonly\n';
373
+ fs.writeFileSync(projectsMdPath, sentinel);
374
+
375
+ listProjectsReadonly(tmpDir);
376
+
377
+ const after = fs.readFileSync(projectsMdPath, 'utf-8');
378
+ assert.strictEqual(after, sentinel, 'listProjectsReadonly must not modify existing PROJECTS.md');
379
+ });
380
+
381
+ it('returns a project entry with all expected fields', () => {
382
+ createProjectManually(tmpDir, 'proj-a', '# Project State\n\nPhase: 5\nStatus: Active\nProgress: [###-------] 30%\n');
383
+ const result = listProjectsReadonly(tmpDir);
384
+ assert.strictEqual(result.projects.length, 1);
385
+ const p = result.projects[0];
386
+ assert.strictEqual(p.name, 'proj-a');
387
+ assert.strictEqual(p.status, 'Active');
388
+ assert.strictEqual(p.current_phase, '5');
389
+ assert.strictEqual(p.progress, 30);
390
+ assert.strictEqual(typeof p.repos_touched, 'string');
391
+ assert.ok('completed_date' in p);
392
+ });
393
+
394
+ it('returns ghost-project behavior consistent with regenerateProjectsMd (omits projects without STATE.md)', () => {
395
+ // Ghost project: directory exists but no STATE.md
396
+ fs.mkdirSync(path.join(tmpDir, 'projects', 'ghost-project'), { recursive: true });
397
+ // Real project
398
+ createProjectManually(tmpDir, 'real-project', '# State\n\nStatus: Active\n');
399
+
400
+ const result = listProjectsReadonly(tmpDir);
401
+ assert.ok(Array.isArray(result.warnings));
402
+ assert.ok(!result.projects.some(p => p.name === 'ghost-project'), 'ghost project should be omitted from projects array');
403
+ });
404
+
405
+ it('empty projects directory returns { projects: [], warnings: [] }', () => {
406
+ const result = listProjectsReadonly(tmpDir);
407
+ assert.deepStrictEqual(result.projects, []);
408
+ assert.ok(Array.isArray(result.warnings));
409
+ });
410
+ });
411
+
326
412
  // ─── completeProject ────────────────────────────────────────────────────────
327
413
 
328
414
  describe('completeProject', () => {