@ktpartners/dgs-platform 2.9.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 +82 -0
- package/README.md +26 -1
- 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 +2 -2
- package/deliver-great-systems/workflows/init-product.md +2 -18
- package/deliver-great-systems/workflows/new-milestone.md +30 -24
- 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
|
@@ -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
|
-
|
|
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
|
-
// ───
|
|
168
|
+
// ─── Projects Readonly Listing ──────────────────────────────────────────────
|
|
167
169
|
|
|
168
170
|
/**
|
|
169
|
-
*
|
|
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
|
|
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', () => {
|