@ktpartners/dgs-platform 3.4.2 → 3.5.1
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 +28 -0
- package/README.md +2 -0
- package/agents/dgs-codebase-cross-analyzer.md +1 -1
- package/agents/dgs-codebase-mapper.md +1 -1
- package/agents/dgs-codebase-synthesizer.md +1 -1
- package/agents/dgs-phase-researcher.md +1 -1
- package/bin/install.js +34 -2
- package/deliver-great-systems/bin/dgs-tools.cjs +7 -1
- package/deliver-great-systems/bin/lib/commands.cjs +66 -29
- package/deliver-great-systems/bin/lib/commands.test.cjs +221 -1
- package/deliver-great-systems/bin/lib/context.cjs +6 -6
- package/deliver-great-systems/bin/lib/context.test.cjs +9 -9
- package/deliver-great-systems/bin/lib/core.cjs +199 -9
- package/deliver-great-systems/bin/lib/core.test.cjs +242 -0
- package/deliver-great-systems/bin/lib/execution.cjs +7 -0
- package/deliver-great-systems/bin/lib/governance.cjs +7 -7
- package/deliver-great-systems/bin/lib/init.cjs +25 -17
- package/deliver-great-systems/bin/lib/init.test.cjs +69 -10
- package/deliver-great-systems/bin/lib/jobs.cjs +132 -67
- package/deliver-great-systems/bin/lib/jobs.test.cjs +157 -13
- package/deliver-great-systems/bin/lib/migration.test.cjs +8 -0
- package/deliver-great-systems/bin/lib/milestone-archival.test.cjs +186 -0
- package/deliver-great-systems/bin/lib/milestone.cjs +168 -37
- package/deliver-great-systems/bin/lib/milestone.test.cjs +113 -1
- package/deliver-great-systems/bin/lib/path-audit.test.cjs +128 -0
- package/deliver-great-systems/bin/lib/paths.cjs +1 -2
- package/deliver-great-systems/bin/lib/paths.test.cjs +3 -4
- package/deliver-great-systems/bin/lib/phase-versioned.test.cjs +134 -0
- package/deliver-great-systems/bin/lib/phase.cjs +60 -7
- package/deliver-great-systems/bin/lib/phase.test.cjs +168 -1
- package/deliver-great-systems/bin/lib/projects.test.cjs +38 -0
- package/deliver-great-systems/bin/lib/repos.cjs +8 -4
- package/deliver-great-systems/bin/lib/repos.test.cjs +6 -2
- package/deliver-great-systems/bin/lib/roadmap.cjs +21 -11
- package/deliver-great-systems/bin/lib/state-snapshot.test.cjs +134 -0
- package/deliver-great-systems/bin/lib/state.cjs +173 -26
- package/deliver-great-systems/references/git-integration.md +1 -1
- package/deliver-great-systems/templates/milestone-archive.md +1 -1
- package/deliver-great-systems/templates/roadmap.md +12 -10
- package/deliver-great-systems/workflows/abandon-milestone.md +8 -1
- package/deliver-great-systems/workflows/abandon-quick.md +1 -1
- package/deliver-great-systems/workflows/codereview.md +1 -1
- package/deliver-great-systems/workflows/complete-milestone.md +1 -1
- package/deliver-great-systems/workflows/execute-phase.md +2 -2
- package/deliver-great-systems/workflows/execute-plan.md +2 -2
- package/deliver-great-systems/workflows/new-milestone.md +46 -12
- package/deliver-great-systems/workflows/quick-abandon.md +1 -1
- package/deliver-great-systems/workflows/quick.md +3 -3
- package/package.json +3 -2
|
@@ -272,6 +272,134 @@ describe('path audit: comprehensive .planning/ reference scanning', () => {
|
|
|
272
272
|
}
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
+
// ─── Phases-Path Seam Gate (RSLV-03) ────────────────────────────────────
|
|
276
|
+
// Enforces the one-seam invariant from Phase 164: all active-project phases-path
|
|
277
|
+
// resolution routes through core.cjs phasesDir(cwd). Any NEW ad-hoc
|
|
278
|
+
// path.join(..., 'phases') active-project construction in a non-test lib module
|
|
279
|
+
// is a regression (re-introduces the split-brain that Phase 164 consolidated).
|
|
280
|
+
//
|
|
281
|
+
// The empirically-derived exclusion model (validated against the real
|
|
282
|
+
// post-164-04 tree, NOT the plan's pre-migration model — the tree drifted):
|
|
283
|
+
//
|
|
284
|
+
// COMMENT-STRIP — trailing // line-comments are stripped before matching, and
|
|
285
|
+
// lines that are pure // or * (JSDoc) comments are skipped. This clears
|
|
286
|
+
// core.cjs:297 / phase.cjs:21 (`phasesRel = 'phases'; // ... path.join('.','phases')`)
|
|
287
|
+
// and the core.cjs:776 / phase.cjs:13 JSDoc mentions WITHOUT allowlisting those
|
|
288
|
+
// files (their canonical-resolver code carries no gate-matching construction).
|
|
289
|
+
//
|
|
290
|
+
// (1) PER-LINE FALLBACK_SKIPS — retained catch-branch fallbacks that migrated
|
|
291
|
+
// files legitimately keep (the IN-SCOPE try branch routes through
|
|
292
|
+
// phasesDir(cwd), which carries no 'phases' literal, so the file stays gated):
|
|
293
|
+
// - getPlanningRoot(cwd),'phases' → commands.cjs:153, state.cjs:646
|
|
294
|
+
// - path.join(planRootRel,'phases') → init.cjs:865/1065/1262 (whole ctx.root ternary line), commands.cjs:1098
|
|
295
|
+
// - path.join(planRoot,'phases') → commands.cjs:872
|
|
296
|
+
// - = path.join(projectRoot,'phases') → state.cjs:300 (phasesRel) AND jobs.cjs:1133/1715/1930 (phasesAbs).
|
|
297
|
+
// Anchored on the `= path.join(projectRoot,` assignment so it matches BOTH
|
|
298
|
+
// variable names introduced across plans 03/04 (state.cjs uses phasesRel;
|
|
299
|
+
// jobs.cjs's three soft-fallback catches use phasesAbs) — the plan's model
|
|
300
|
+
// only anticipated the phasesRel form; 164-04 added the phasesAbs sites.
|
|
301
|
+
// - path.relative(cwd, planningRoot)..,'phases' → context.cjs:534 planning-root catch
|
|
302
|
+
// (retained fallback NOT in the plan's model; surfaced empirically).
|
|
303
|
+
//
|
|
304
|
+
// (2) PER-FILE ALLOWLIST — wholly-different-semantics sites with NO in-scope
|
|
305
|
+
// active-project resolution to guard (applied as a PRE-FILTER before scanning):
|
|
306
|
+
// overlap.cjs / projects.cjs (by-slug, not current project),
|
|
307
|
+
// review.cjs (caller-supplied projectRoot param),
|
|
308
|
+
// package-scan-report.cjs (cross-project scan),
|
|
309
|
+
// verify.cjs (planning-root health check via planRoot/planningDir variables).
|
|
310
|
+
// core.cjs is intentionally NOT allowlisted: Plan 01 migrated findPhaseInternal's
|
|
311
|
+
// path.join(projectRoot,'phases') to phasesDir(cwd), so post-migration it carries
|
|
312
|
+
// no gate-matching code line (only the comment the strip removes).
|
|
313
|
+
//
|
|
314
|
+
// Durable-guard proof recorded in 164-05-SUMMARY.md: with the allowlist filter and
|
|
315
|
+
// the projectRoot skip disabled, this gate fires on exactly 11 lines across 7 files
|
|
316
|
+
// {jobs, overlap, package-scan-report, projects, review, state, verify} — proving the
|
|
317
|
+
// nested-paren regex is not inert and core.cjs/phase.cjs carry no matching code line.
|
|
318
|
+
it('GATE: zero ad-hoc active-project path.join(..., "phases") in library source (RSLV-03)', () => {
|
|
319
|
+
const libDir = path.join(DGS_ROOT, 'bin', 'lib');
|
|
320
|
+
|
|
321
|
+
// (2) Per-FILE allowlist: { file: 'reason' }. EMPIRICALLY VERIFIED — these are
|
|
322
|
+
// the only non-fallback files matching the gate regex post-migration AND having
|
|
323
|
+
// no in-scope active-project site to guard.
|
|
324
|
+
const ALLOWLIST = {
|
|
325
|
+
'overlap.cjs': 'by-slug project: path.join(getProjectDir(cwd, slug), "phases") — not current-project',
|
|
326
|
+
'projects.cjs': 'by-slug project: path.join(getProjectDir(cwd, slug), "phases") — not current-project',
|
|
327
|
+
'review.cjs': 'caller-supplied projectRoot param: path.join(planningRoot, projectRoot, "phases") — not resolved here',
|
|
328
|
+
'package-scan-report.cjs': 'cross-project scan: path.join(projectsDir, pEnt.name, "phases") over all projects',
|
|
329
|
+
'verify.cjs': 'planning-root health check: path.join(planRoot|planningDir, "phases") where the root is getPlanningRoot(cwd) via a variable the generic getPlanningRoot skip does not catch',
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const files = fs.readdirSync(libDir)
|
|
333
|
+
.filter(f => f.endsWith('.cjs'))
|
|
334
|
+
.filter(f => !f.endsWith('.test.cjs'))
|
|
335
|
+
.filter(f => f !== 'test-helpers.cjs')
|
|
336
|
+
.filter(f => f !== 'migration.cjs')
|
|
337
|
+
.filter(f => !(f in ALLOWLIST)); // PRE-FILTER: allowlisted files never line-scanned
|
|
338
|
+
|
|
339
|
+
// NESTED-paren tolerant: matches path.join(<x | f(...)>, 'phases'[,)] — one level of
|
|
340
|
+
// nested parens allowed so getProjectDir(...)/getPlanningRoot(...) forms match (a plain
|
|
341
|
+
// [^)]* body could not, making nested-call regressions invisible).
|
|
342
|
+
const PHASES_JOIN_PATTERN = /path\.join\((?:[^()]|\([^()]*\))*,\s*['"]phases['"]\s*[,)]/;
|
|
343
|
+
|
|
344
|
+
// (1) Per-LINE generic skips for legitimately-retained fallback branches.
|
|
345
|
+
const FALLBACK_SKIPS = [
|
|
346
|
+
/getPlanningRoot\([^)]*\),\s*['"]phases['"]/, // path.join(getPlanningRoot(cwd), 'phases')
|
|
347
|
+
/path\.join\(planRootRel,\s*['"]phases['"]/, // init.cjs x3 + commands.cjs:1098 planning-root fallbacks
|
|
348
|
+
/path\.join\(planRoot,\s*['"]phases['"]/, // commands.cjs:872 planRoot fallback
|
|
349
|
+
/=\s*path\.join\(projectRoot,\s*['"]phases['"]/, // state.cjs:300 (phasesRel) + jobs.cjs x3 (phasesAbs) projectRoot catches
|
|
350
|
+
/path\.relative\(cwd,\s*planningRoot\)[^,]*,\s*['"]phases['"]/, // context.cjs:534 planning-root catch
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
const violations = [];
|
|
354
|
+
for (const f of files) {
|
|
355
|
+
const filePath = path.join(libDir, f);
|
|
356
|
+
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
|
357
|
+
for (let i = 0; i < lines.length; i++) {
|
|
358
|
+
const raw = lines[i];
|
|
359
|
+
if (raw.trimStart().startsWith('//')) continue;
|
|
360
|
+
if (raw.trimStart().startsWith('*')) continue;
|
|
361
|
+
const line = raw.replace(/\/\/.*$/, ''); // strip trailing line-comment before matching
|
|
362
|
+
if (!PHASES_JOIN_PATTERN.test(line)) continue;
|
|
363
|
+
if (FALLBACK_SKIPS.some(re => re.test(line))) continue; // retained fallback branch
|
|
364
|
+
violations.push(`${f}:${i + 1}: ${raw.trim()}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (violations.length > 0) {
|
|
369
|
+
assert.fail(
|
|
370
|
+
`Found ${violations.length} ad-hoc path.join(..., 'phases') site(s) outside the canonical resolver:\n` +
|
|
371
|
+
violations.join('\n') +
|
|
372
|
+
`\n\nRoute active-project phases-path resolution through core.cjs phasesDir(cwd). ` +
|
|
373
|
+
`If this is a genuinely different semantic (by-slug / planning-root / cross-project / param), add it to ALLOWLIST with a reason.`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Anti-regression: prove the gate WOULD catch a fresh ad-hoc projectRoot+'phases'
|
|
379
|
+
// construction in a non-fallback, non-comment, non-allowlisted context. Runs the
|
|
380
|
+
// same regex+skip logic over a synthetic line; the line must be flagged.
|
|
381
|
+
it('GATE self-test: a fresh ad-hoc path.join(projectRoot, "phases") IS caught (RSLV-03)', () => {
|
|
382
|
+
const PHASES_JOIN_PATTERN = /path\.join\((?:[^()]|\([^()]*\))*,\s*['"]phases['"]\s*[,)]/;
|
|
383
|
+
const FALLBACK_SKIPS = [
|
|
384
|
+
/getPlanningRoot\([^)]*\),\s*['"]phases['"]/,
|
|
385
|
+
/path\.join\(planRootRel,\s*['"]phases['"]/,
|
|
386
|
+
/path\.join\(planRoot,\s*['"]phases['"]/,
|
|
387
|
+
/=\s*path\.join\(projectRoot,\s*['"]phases['"]/,
|
|
388
|
+
/path\.relative\(cwd,\s*planningRoot\)[^,]*,\s*['"]phases['"]/,
|
|
389
|
+
];
|
|
390
|
+
// A NEW ad-hoc site: not an assignment-form catch (no leading `= `), not a comment,
|
|
391
|
+
// not a known fallback shape — e.g. used directly as a call argument.
|
|
392
|
+
const adHoc = ` const dir = path.join(projectRoot, 'phases');`;
|
|
393
|
+
const stripped = adHoc.replace(/\/\/.*$/, '');
|
|
394
|
+
assert.ok(PHASES_JOIN_PATTERN.test(stripped), 'regex must match a fresh projectRoot+phases join');
|
|
395
|
+
// The skip anchored on `= path.join(projectRoot,` does NOT match `= path.join(projectRoot,` here?
|
|
396
|
+
// It DOES (this is a const assignment). Use a true non-fallback shape: a bare call argument.
|
|
397
|
+
const adHocArg = ` doThing(path.join(projectRoot, 'phases'));`;
|
|
398
|
+
const strippedArg = adHocArg.replace(/\/\/.*$/, '');
|
|
399
|
+
assert.ok(PHASES_JOIN_PATTERN.test(strippedArg), 'regex must match a bare-argument projectRoot+phases join');
|
|
400
|
+
assert.ok(!FALLBACK_SKIPS.some(re => re.test(strippedArg)), 'a bare-argument ad-hoc site must NOT be covered by any fallback skip — it would be flagged as a violation');
|
|
401
|
+
});
|
|
402
|
+
|
|
275
403
|
// ─── Allowlist Verification ─────────────────────────────────────────────
|
|
276
404
|
|
|
277
405
|
it('allowlisted workflow files exist and are minimal', () => {
|
|
@@ -93,7 +93,7 @@ function isV2Install(cwd) {
|
|
|
93
93
|
* Results are cached per resolved absolute cwd.
|
|
94
94
|
*
|
|
95
95
|
* @param {string} [cwd] - Working directory (defaults to process.cwd())
|
|
96
|
-
* @returns {Readonly<Object>} Frozen PATHS object with ROOT,
|
|
96
|
+
* @returns {Readonly<Object>} Frozen PATHS object with ROOT, IDEAS, etc.
|
|
97
97
|
*/
|
|
98
98
|
function getPaths(cwd) {
|
|
99
99
|
const resolved = path.resolve(cwd || process.cwd());
|
|
@@ -103,7 +103,6 @@ function getPaths(cwd) {
|
|
|
103
103
|
|
|
104
104
|
const paths = Object.freeze({
|
|
105
105
|
ROOT: root,
|
|
106
|
-
PHASES: path.join(root, 'phases'),
|
|
107
106
|
IDEAS: path.join(root, 'ideas'),
|
|
108
107
|
SPECS: path.join(root, 'specs'),
|
|
109
108
|
JOBS: path.join(root, 'jobs'),
|
|
@@ -244,12 +244,12 @@ describe('PATHS object shape', () => {
|
|
|
244
244
|
});
|
|
245
245
|
|
|
246
246
|
const EXPECTED_KEYS = [
|
|
247
|
-
'ROOT', '
|
|
247
|
+
'ROOT', 'IDEAS', 'SPECS', 'JOBS', 'DOCS',
|
|
248
248
|
'CODEBASE', 'MILESTONES', 'CONFIG', 'CONFIG_LOCAL',
|
|
249
249
|
'QUICK', 'TODOS', 'RESEARCH', 'DEBUG', 'ARCHIVE', 'PROJECTS',
|
|
250
250
|
];
|
|
251
251
|
|
|
252
|
-
it('returns object with all
|
|
252
|
+
it('returns object with all 15 expected keys', () => {
|
|
253
253
|
cwd = makeGitTempDir();
|
|
254
254
|
const paths = getPaths(cwd);
|
|
255
255
|
assert.deepEqual(Object.keys(paths).sort(), EXPECTED_KEYS.slice().sort());
|
|
@@ -287,7 +287,7 @@ describe('PATHS object shape', () => {
|
|
|
287
287
|
it('subdirectory paths are derived from ROOT', () => {
|
|
288
288
|
cwd = makeGitTempDir();
|
|
289
289
|
const paths = getPaths(cwd);
|
|
290
|
-
assert.equal(paths.PHASES, path.
|
|
290
|
+
assert.equal(paths.PHASES, undefined, 'PHASES key removed — phases path resolves via core.cjs phasesDir, not the project-unaware PATHS constant');
|
|
291
291
|
assert.equal(paths.IDEAS, path.join(paths.ROOT, 'ideas'));
|
|
292
292
|
assert.equal(paths.SPECS, path.join(paths.ROOT, 'specs'));
|
|
293
293
|
assert.equal(paths.JOBS, path.join(paths.ROOT, 'jobs'));
|
|
@@ -443,7 +443,6 @@ describe('edge cases', () => {
|
|
|
443
443
|
cwd = makeGitTempDir();
|
|
444
444
|
const paths = getPaths(cwd);
|
|
445
445
|
// Should return all paths even though none of these directories exist
|
|
446
|
-
assert.ok(paths.PHASES);
|
|
447
446
|
assert.ok(paths.IDEAS);
|
|
448
447
|
assert.ok(paths.SPECS);
|
|
449
448
|
assert.ok(paths.ROOT);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restart-at-1 behaviour test (NUM-01).
|
|
3
|
+
*
|
|
4
|
+
* Dedicated file (separate from phase.test.cjs, which plan 02 owns) proving that a
|
|
5
|
+
* fresh versioned milestone's FIRST add-phase is numbered `01` (zero-padded) and
|
|
6
|
+
* lands UNDER the milestone's own phases/<version>/ directory — i.e. phase numbering
|
|
7
|
+
* restarts per milestone with NO arithmetic change (empty roadmap → maxPhase=0 →
|
|
8
|
+
* newPhaseNum=1 → '01'), and no global / MILESTONES.md continuation leaks into the number.
|
|
9
|
+
*
|
|
10
|
+
* Uses Node.js built-in test runner (node:test) + assert.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { describe, it } = require('node:test');
|
|
14
|
+
const assert = require('node:assert');
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const { createFixture } = require('./test-helpers.cjs');
|
|
18
|
+
const { cmdPhaseInitVersionedDir, cmdPhaseAdd } = require('./phase.cjs');
|
|
19
|
+
|
|
20
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Capture stdout from CLI commands that call output() (process.stdout.write +
|
|
24
|
+
* process.exit). Mirrors phase.test.cjs's captureStdout so multiple invocations
|
|
25
|
+
* can run in sequence. Returns { stdout, exitCode, json }.
|
|
26
|
+
*/
|
|
27
|
+
function captureStdout(fn) {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
30
|
+
const origExit = process.exit;
|
|
31
|
+
let exitCode = null;
|
|
32
|
+
process.stdout.write = (data) => { chunks.push(String(data)); return true; };
|
|
33
|
+
process.exit = (code) => {
|
|
34
|
+
exitCode = code == null ? 0 : code;
|
|
35
|
+
throw new Error('__EXIT__');
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
fn();
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (e && e.message !== '__EXIT__') throw e;
|
|
41
|
+
} finally {
|
|
42
|
+
process.stdout.write = origWrite;
|
|
43
|
+
process.exit = origExit;
|
|
44
|
+
}
|
|
45
|
+
const stdout = chunks.join('');
|
|
46
|
+
let json = null;
|
|
47
|
+
try { json = JSON.parse(stdout); } catch { /* not JSON */ }
|
|
48
|
+
return { stdout, exitCode, json };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fresh-milestone v2 fixture. STATE.md frontmatter `current_milestone` is what
|
|
53
|
+
* resolveMilestoneVersion(required) reads. The ROADMAP has NO `### Phase` heading
|
|
54
|
+
* (fresh milestone) plus a `---` trailer so cmdPhaseAdd's insertion logic has a
|
|
55
|
+
* separator. `roadmapBody` lets a test seed a high-number context to prove
|
|
56
|
+
* milestone-local numbering.
|
|
57
|
+
*/
|
|
58
|
+
function freshMilestoneFixture({ slug = 'auth-overhaul', milestone = 'v26.0', roadmapBody } = {}) {
|
|
59
|
+
return createFixture({
|
|
60
|
+
'config.json': JSON.stringify({}),
|
|
61
|
+
'config.local.json': JSON.stringify({ current_project: slug }),
|
|
62
|
+
'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
|
|
63
|
+
'REPOS.md': '# Repos\n\n| Name | Path |\n',
|
|
64
|
+
[`projects/${slug}/PROJECT.md`]: '# Project',
|
|
65
|
+
[`projects/${slug}/STATE.md`]: `---\ncurrent_milestone: ${milestone}\n---\n# State`,
|
|
66
|
+
[`projects/${slug}/ROADMAP.md`]: roadmapBody || '# Roadmap\n\n## Phases\n\n---\n',
|
|
67
|
+
[`projects/${slug}/phases/`]: null,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Restart-at-1 (NUM-01) ────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe('restart-at-1 in a fresh versioned milestone (NUM-01)', () => {
|
|
74
|
+
it('Test 1: first add-phase is 01 and lands under phases/<version>/', () => {
|
|
75
|
+
const fixture = freshMilestoneFixture({ slug: 'auth-overhaul', milestone: 'v26.0' });
|
|
76
|
+
try {
|
|
77
|
+
// new-milestone flow materialises phases/v26.0/ (NUM-02 command).
|
|
78
|
+
captureStdout(() => cmdPhaseInitVersionedDir(fixture.cwd, true));
|
|
79
|
+
|
|
80
|
+
// First add-phase in the fresh versioned milestone.
|
|
81
|
+
const { json } = captureStdout(() =>
|
|
82
|
+
cmdPhaseAdd(fixture.cwd, 'first thing', false)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
assert.ok(json, 'add-phase should emit JSON');
|
|
86
|
+
// Restart-at-1: empty versioned roadmap → maxPhase=0 → newPhaseNum=1 → '01'.
|
|
87
|
+
assert.equal(json.padded, '01', 'first phase is zero-padded 01');
|
|
88
|
+
assert.equal(json.phase_number, 1, 'first phase_number is 1');
|
|
89
|
+
|
|
90
|
+
// The created dir is UNAMBIGUOUS evidence: phases/v26.0/01-first-thing.
|
|
91
|
+
const expectedDir = path.join('projects', 'auth-overhaul', 'phases', 'v26.0', '01-first-thing');
|
|
92
|
+
assert.equal(json.directory, expectedDir, 'phase dir is numbered 01 under the versioned dir');
|
|
93
|
+
assert.ok(
|
|
94
|
+
fs.existsSync(path.join(fixture.cwd, expectedDir)),
|
|
95
|
+
'phases/v26.0/01-first-thing exists on disk'
|
|
96
|
+
);
|
|
97
|
+
} finally {
|
|
98
|
+
fixture.cleanup();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('Test 2: numbers stay milestone-local (no global continuation leaks into 01)', () => {
|
|
103
|
+
// Seed a ROADMAP with prose that mentions high phase numbers but NO `### Phase`
|
|
104
|
+
// heading. A global / MILESTONES.md continuation scheme would have produced a
|
|
105
|
+
// high number; the milestone-local maxPhase scan sees zero `### Phase` headings,
|
|
106
|
+
// so the first add-phase is still 01.
|
|
107
|
+
const roadmapBody =
|
|
108
|
+
'# Roadmap\n\n' +
|
|
109
|
+
'Continuation note: the previous milestone ended at phase 162.\n\n' +
|
|
110
|
+
'## Phases\n\n' +
|
|
111
|
+
'---\n';
|
|
112
|
+
const fixture = freshMilestoneFixture({
|
|
113
|
+
slug: 'auth-overhaul',
|
|
114
|
+
milestone: 'v26.0',
|
|
115
|
+
roadmapBody,
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
captureStdout(() => cmdPhaseInitVersionedDir(fixture.cwd, true));
|
|
119
|
+
const { json } = captureStdout(() =>
|
|
120
|
+
cmdPhaseAdd(fixture.cwd, 'first thing', false)
|
|
121
|
+
);
|
|
122
|
+
assert.ok(json, 'add-phase should emit JSON');
|
|
123
|
+
// Still 01 — proves no MILESTONES.md / global 163-continuation leaks into the number.
|
|
124
|
+
assert.equal(json.padded, '01', 'milestone-local: first phase is 01 despite prose mentioning 162');
|
|
125
|
+
assert.equal(json.phase_number, 1, 'milestone-local: phase_number is 1');
|
|
126
|
+
assert.ok(
|
|
127
|
+
json.directory.startsWith(path.join('projects', 'auth-overhaul', 'phases', 'v26.0')),
|
|
128
|
+
`phase dir must be under the versioned dir: ${json.directory}`
|
|
129
|
+
);
|
|
130
|
+
} finally {
|
|
131
|
+
fixture.cleanup();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, toPosixPath, output, error, getProjectRoot, loadConfig, execGit } = require('./core.cjs');
|
|
7
|
+
const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, toPosixPath, output, error, getProjectRoot, phasesDir, resolveProjectPath, resolveMilestoneVersion, loadConfig, execGit } = require('./core.cjs');
|
|
8
8
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
9
9
|
const { writeStateMd } = require('./state.cjs');
|
|
10
10
|
|
|
@@ -14,13 +14,13 @@ const { writeStateMd } = require('./state.cjs');
|
|
|
14
14
|
* from getProjectRoot() or '.' as fallback.
|
|
15
15
|
*/
|
|
16
16
|
function resolvePhasesDir(cwd) {
|
|
17
|
-
let
|
|
17
|
+
let phasesRel;
|
|
18
18
|
try {
|
|
19
|
-
|
|
19
|
+
phasesRel = phasesDir(cwd);
|
|
20
20
|
} catch {
|
|
21
|
-
|
|
21
|
+
phasesRel = 'phases'; // flat-layout fallback (== path.join('.', 'phases'))
|
|
22
22
|
}
|
|
23
|
-
return path.join(cwd,
|
|
23
|
+
return path.join(cwd, phasesRel);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -406,6 +406,54 @@ function cmdPhaseAdd(cwd, description, raw) {
|
|
|
406
406
|
output(result, raw, paddedNum);
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Create the current milestone's versioned phases directory: phases/<version>/.
|
|
411
|
+
* This is the SOLE creator of a phases/<version> directory (NUM-02). It requires a
|
|
412
|
+
* determinable milestone version (fail-loud) so a phase is never written to phases/v1.0/.
|
|
413
|
+
* Idempotent: recursive mkdir is a no-op when the directory already exists.
|
|
414
|
+
* cmdPhaseAdd never calls this — it routes through the version-aware phasesDir resolver,
|
|
415
|
+
* which returns this directory once it exists.
|
|
416
|
+
*/
|
|
417
|
+
/**
|
|
418
|
+
* Internal, output-free creator of the current milestone's versioned phases dir.
|
|
419
|
+
* Shared by the CLI wrapper (cmdPhaseInitVersionedDir) AND create-adhoc seeding
|
|
420
|
+
* (milestone.cjs) so there is exactly ONE versioned-dir creator (Decision C / NUM-02).
|
|
421
|
+
*
|
|
422
|
+
* required:true → throws the Phase-163 remediation Error when the version is
|
|
423
|
+
* undeterminable; never writes phases/v1.0/. Idempotent: recursive mkdir is a
|
|
424
|
+
* no-op when the directory already exists, and `created` reflects whether THIS
|
|
425
|
+
* call materialized the directory (probed via existsSync BEFORE mkdir).
|
|
426
|
+
*
|
|
427
|
+
* @param {string} cwd - planning root
|
|
428
|
+
* @returns {{ version: string, directory: string, created: boolean }}
|
|
429
|
+
* `directory` is relative to cwd.
|
|
430
|
+
*/
|
|
431
|
+
function phaseInitVersionedDirInternal(cwd) {
|
|
432
|
+
// required:true → throws the Phase-163 remediation Error when undeterminable; never v1.0.
|
|
433
|
+
const version = resolveMilestoneVersion(cwd, { required: true });
|
|
434
|
+
// resolveProjectPath returns the FLAT relative phases base (NOT version-aware), so joining
|
|
435
|
+
// the version is correct and idempotent on re-run. It is a function call — not a
|
|
436
|
+
// path.join(..., 'phases') literal — so the 164-05 RSLV-03 phases-join gate stays GREEN.
|
|
437
|
+
const flatBase = resolveProjectPath(cwd, 'phases'); // relative: projects/<slug>/phases or phases
|
|
438
|
+
const versionedDir = path.join(cwd, flatBase, version); // absolute phases/<version>
|
|
439
|
+
const created = !fs.existsSync(versionedDir); // did THIS call create it?
|
|
440
|
+
fs.mkdirSync(versionedDir, { recursive: true }); // idempotent
|
|
441
|
+
return { version, directory: path.relative(cwd, versionedDir), created };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Create the current milestone's versioned phases directory: phases/<version>/.
|
|
446
|
+
* This is the SOLE creator of a phases/<version> directory (NUM-02). It requires a
|
|
447
|
+
* determinable milestone version (fail-loud) so a phase is never written to phases/v1.0/.
|
|
448
|
+
* Idempotent: recursive mkdir is a no-op when the directory already exists.
|
|
449
|
+
* cmdPhaseAdd never calls this — it routes through the version-aware phasesDir resolver,
|
|
450
|
+
* which returns this directory once it exists.
|
|
451
|
+
*/
|
|
452
|
+
function cmdPhaseInitVersionedDir(cwd, raw) {
|
|
453
|
+
const { version, directory, created } = phaseInitVersionedDirInternal(cwd);
|
|
454
|
+
output({ version, directory, created }, raw, directory);
|
|
455
|
+
}
|
|
456
|
+
|
|
409
457
|
function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
410
458
|
if (!afterPhase || !description) {
|
|
411
459
|
error('after-phase and description required for phase insert');
|
|
@@ -767,8 +815,11 @@ function phaseCompleteInternal(cwd, phaseNum) {
|
|
|
767
815
|
|
|
768
816
|
// Verify phase info
|
|
769
817
|
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
770
|
-
|
|
771
|
-
|
|
818
|
+
// Guard on `found`: a LOOK-01 ambiguity object is truthy but has no
|
|
819
|
+
// .directory/.plans, so neither is reached for an ambiguity object. Surface
|
|
820
|
+
// its milestone-qualified message instead of throwing.
|
|
821
|
+
if (!phaseInfo || !phaseInfo.found) {
|
|
822
|
+
error(phaseInfo?.message || `Phase ${phaseNum} not found`);
|
|
772
823
|
}
|
|
773
824
|
|
|
774
825
|
// Absolute phase directory (phaseInfo.directory is relative to cwd)
|
|
@@ -1046,6 +1097,8 @@ module.exports = {
|
|
|
1046
1097
|
cmdFindPhase,
|
|
1047
1098
|
cmdPhasePlanIndex,
|
|
1048
1099
|
cmdPhaseAdd,
|
|
1100
|
+
cmdPhaseInitVersionedDir,
|
|
1101
|
+
phaseInitVersionedDirInternal,
|
|
1049
1102
|
cmdPhaseInsert,
|
|
1050
1103
|
cmdPhaseRemove,
|
|
1051
1104
|
cmdPhaseComplete,
|
|
@@ -11,7 +11,8 @@ const fs = require('fs');
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { execSync } = require('child_process');
|
|
13
13
|
|
|
14
|
-
const { createTempProject } = require('./test-helpers.cjs');
|
|
14
|
+
const { createTempProject, createFixture } = require('./test-helpers.cjs');
|
|
15
|
+
const { initPaths, resetPaths } = require('./paths.cjs');
|
|
15
16
|
|
|
16
17
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
17
18
|
|
|
@@ -418,3 +419,169 @@ describe('cmdPlanFinalize', () => {
|
|
|
418
419
|
assert.equal(gitLastMessage(fixture.cwd), 'docs(04-01): complete execution plan');
|
|
419
420
|
});
|
|
420
421
|
});
|
|
422
|
+
|
|
423
|
+
// ─── cmdPhaseInitVersionedDir (NUM-02 versioned write path) ────────────────────
|
|
424
|
+
|
|
425
|
+
describe('cmdPhaseInitVersionedDir (NUM-02 versioned write path)', () => {
|
|
426
|
+
let phase;
|
|
427
|
+
|
|
428
|
+
beforeEach(() => {
|
|
429
|
+
// Reload phase.cjs fresh so each fixture's config/paths are re-read.
|
|
430
|
+
delete require.cache[require.resolve('./phase.cjs')];
|
|
431
|
+
phase = require('./phase.cjs');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Shared v2 project-context builder. STATE.md frontmatter `current_milestone`
|
|
435
|
+
// is what resolveMilestoneVersion(required) reads. `roadmapBody`/`extraDirs`
|
|
436
|
+
// let individual tests scaffold a versioned ROADMAP context or pre-create dirs.
|
|
437
|
+
function v2Fixture({ slug = 'auth-overhaul', stateBody, roadmapBody, extraDirs } = {}) {
|
|
438
|
+
const tree = {
|
|
439
|
+
'config.json': JSON.stringify({}),
|
|
440
|
+
'config.local.json': JSON.stringify({ current_project: slug }),
|
|
441
|
+
'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
|
|
442
|
+
'REPOS.md': '# Repos\n\n| Name | Path |\n',
|
|
443
|
+
[`projects/${slug}/PROJECT.md`]: '# Project',
|
|
444
|
+
[`projects/${slug}/STATE.md`]: stateBody,
|
|
445
|
+
[`projects/${slug}/ROADMAP.md`]: roadmapBody || '# Roadmap',
|
|
446
|
+
[`projects/${slug}/phases/`]: null,
|
|
447
|
+
};
|
|
448
|
+
for (const d of (extraDirs || [])) tree[d] = null;
|
|
449
|
+
return createFixture(tree);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
it('Test 1: creates the current milestone versioned dir phases/<version>/', () => {
|
|
453
|
+
const fixture = v2Fixture({ stateBody: '---\ncurrent_milestone: v26.0\n---\n# State' });
|
|
454
|
+
try {
|
|
455
|
+
// raw=false → output() emits structured JSON (raw=true would print the human relDir line).
|
|
456
|
+
const { json } = captureStdout(() =>
|
|
457
|
+
phase.cmdPhaseInitVersionedDir(fixture.cwd, false)
|
|
458
|
+
);
|
|
459
|
+
const versioned = path.join(fixture.cwd, 'projects', 'auth-overhaul', 'phases', 'v26.0');
|
|
460
|
+
assert.ok(fs.existsSync(versioned), 'phases/v26.0/ must be created');
|
|
461
|
+
assert.ok(fs.statSync(versioned).isDirectory(), 'phases/v26.0/ must be a directory');
|
|
462
|
+
// Output reports the version + relative directory.
|
|
463
|
+
assert.ok(json, 'should emit JSON output');
|
|
464
|
+
assert.equal(json.version, 'v26.0');
|
|
465
|
+
assert.equal(json.created, true);
|
|
466
|
+
assert.equal(
|
|
467
|
+
json.directory,
|
|
468
|
+
path.join('projects', 'auth-overhaul', 'phases', 'v26.0')
|
|
469
|
+
);
|
|
470
|
+
} finally {
|
|
471
|
+
fixture.cleanup();
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('Test 2: idempotent — re-run does NOT throw and creates NO nested phases/<version>/<version>/', () => {
|
|
476
|
+
const fixture = v2Fixture({ stateBody: '---\ncurrent_milestone: v26.0\n---\n# State' });
|
|
477
|
+
try {
|
|
478
|
+
// First invocation creates phases/v26.0/. Now phasesDir(cwd) is version-aware
|
|
479
|
+
// and would return phases/v26.0 — so a wrong (version-aware-base) construction
|
|
480
|
+
// would yield phases/v26.0/v26.0 on the SECOND run. This asserts it does NOT.
|
|
481
|
+
assert.doesNotThrow(() => {
|
|
482
|
+
captureStdout(() => phase.cmdPhaseInitVersionedDir(fixture.cwd, true));
|
|
483
|
+
captureStdout(() => phase.cmdPhaseInitVersionedDir(fixture.cwd, true));
|
|
484
|
+
});
|
|
485
|
+
const versioned = path.join(fixture.cwd, 'projects', 'auth-overhaul', 'phases', 'v26.0');
|
|
486
|
+
const nested = path.join(versioned, 'v26.0');
|
|
487
|
+
assert.ok(fs.existsSync(versioned), 'phases/v26.0/ exists exactly once');
|
|
488
|
+
assert.ok(!fs.existsSync(nested), 'NO nested phases/v26.0/v26.0/ (flat-base idempotency)');
|
|
489
|
+
} finally {
|
|
490
|
+
fixture.cleanup();
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('Test 3: fail-loud — undeterminable version throws (current_milestone remediation), writes NO phases/v1.0/', () => {
|
|
495
|
+
const fixture = v2Fixture({ stateBody: '# State (no milestone frontmatter)' });
|
|
496
|
+
try {
|
|
497
|
+
let thrown = null;
|
|
498
|
+
try {
|
|
499
|
+
captureStdout(() => phase.cmdPhaseInitVersionedDir(fixture.cwd, true));
|
|
500
|
+
} catch (e) {
|
|
501
|
+
thrown = e;
|
|
502
|
+
}
|
|
503
|
+
assert.ok(thrown, 'must throw on undeterminable version');
|
|
504
|
+
assert.match(thrown.message, /current_milestone/, 'remediation references current_milestone');
|
|
505
|
+
assert.match(thrown.message, /STATE\.md/, 'remediation references STATE.md');
|
|
506
|
+
assert.ok(!/v1\.0/.test(thrown.message), 'message must NOT mention v1.0');
|
|
507
|
+
// Critical: no phases/v1.0/ and no phases/undefined/ were written.
|
|
508
|
+
const phasesBase = path.join(fixture.cwd, 'projects', 'auth-overhaul', 'phases');
|
|
509
|
+
assert.ok(!fs.existsSync(path.join(phasesBase, 'v1.0')), 'NO phases/v1.0/ created');
|
|
510
|
+
assert.ok(!fs.existsSync(path.join(phasesBase, 'undefined')), 'NO phases/undefined/ created');
|
|
511
|
+
} finally {
|
|
512
|
+
fixture.cleanup();
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('Test 4: after create, cmdPhaseAdd lands the new phase under phases/<version>/', () => {
|
|
517
|
+
const fixture = v2Fixture({
|
|
518
|
+
stateBody: '---\ncurrent_milestone: v26.0\n---\n# State',
|
|
519
|
+
// Versioned ROADMAP context so maxPhase resolves (empty here → maxPhase=0 → newPhaseNum=1).
|
|
520
|
+
roadmapBody: '# Roadmap\n\n## Phases\n\n',
|
|
521
|
+
});
|
|
522
|
+
try {
|
|
523
|
+
captureStdout(() => phase.cmdPhaseInitVersionedDir(fixture.cwd, true));
|
|
524
|
+
const { json } = captureStdout(() =>
|
|
525
|
+
phase.cmdPhaseAdd(fixture.cwd, 'First versioned phase', false)
|
|
526
|
+
);
|
|
527
|
+
assert.ok(json, 'add-phase should emit JSON');
|
|
528
|
+
// New phase dir lands UNDER phases/v26.0/ because version-aware phasesDir returns it.
|
|
529
|
+
assert.ok(
|
|
530
|
+
json.directory.startsWith(path.join('projects', 'auth-overhaul', 'phases', 'v26.0')),
|
|
531
|
+
`phase dir must be under versioned dir: ${json.directory}`
|
|
532
|
+
);
|
|
533
|
+
assert.equal(json.padded, '01', 'restart-at-1: empty versioned roadmap numbers from 01');
|
|
534
|
+
assert.ok(fs.existsSync(path.join(fixture.cwd, json.directory)), 'new phase dir exists on disk');
|
|
535
|
+
} finally {
|
|
536
|
+
fixture.cleanup();
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('Test 5: two active milestones — each add-phase lands in its own phases/<version>/', () => {
|
|
541
|
+
const roadmap = '# Roadmap\n\n## Phases\n\n';
|
|
542
|
+
const fixtureA = v2Fixture({
|
|
543
|
+
slug: 'proj-a',
|
|
544
|
+
stateBody: '---\ncurrent_milestone: v26.0\n---\n# State',
|
|
545
|
+
roadmapBody: roadmap,
|
|
546
|
+
});
|
|
547
|
+
const fixtureB = v2Fixture({
|
|
548
|
+
slug: 'proj-b',
|
|
549
|
+
stateBody: '---\ncurrent_milestone: v27.0\n---\n# State',
|
|
550
|
+
roadmapBody: roadmap,
|
|
551
|
+
});
|
|
552
|
+
try {
|
|
553
|
+
// Create each milestone's versioned dir, then add-phase in each context.
|
|
554
|
+
// Both fixtures live simultaneously; the planning-root cache is a single
|
|
555
|
+
// per-process value (createFixture primed it for whichever was built last),
|
|
556
|
+
// so reset + re-prime for each project before operating on it, otherwise
|
|
557
|
+
// operations on the other would read a stale root / wrong current_project.
|
|
558
|
+
resetPaths();
|
|
559
|
+
initPaths(fixtureA.cwd);
|
|
560
|
+
captureStdout(() => phase.cmdPhaseInitVersionedDir(fixtureA.cwd, true));
|
|
561
|
+
const { json: jsonA } = captureStdout(() =>
|
|
562
|
+
phase.cmdPhaseAdd(fixtureA.cwd, 'A first phase', false)
|
|
563
|
+
);
|
|
564
|
+
resetPaths();
|
|
565
|
+
initPaths(fixtureB.cwd);
|
|
566
|
+
captureStdout(() => phase.cmdPhaseInitVersionedDir(fixtureB.cwd, true));
|
|
567
|
+
const { json: jsonB } = captureStdout(() =>
|
|
568
|
+
phase.cmdPhaseAdd(fixtureB.cwd, 'B first phase', false)
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
assert.ok(
|
|
572
|
+
jsonA.directory.startsWith(path.join('projects', 'proj-a', 'phases', 'v26.0')),
|
|
573
|
+
`A lands in v26.0: ${jsonA.directory}`
|
|
574
|
+
);
|
|
575
|
+
assert.ok(
|
|
576
|
+
jsonB.directory.startsWith(path.join('projects', 'proj-b', 'phases', 'v27.0')),
|
|
577
|
+
`B lands in v27.0: ${jsonB.directory}`
|
|
578
|
+
);
|
|
579
|
+
// Each numbered relative to its own (initially-empty) versioned roadmap.
|
|
580
|
+
assert.equal(jsonA.padded, '01');
|
|
581
|
+
assert.equal(jsonB.padded, '01');
|
|
582
|
+
} finally {
|
|
583
|
+
fixtureA.cleanup();
|
|
584
|
+
fixtureB.cleanup();
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
});
|
|
@@ -225,6 +225,44 @@ describe('readProjectState', () => {
|
|
|
225
225
|
assert.ok(state !== null);
|
|
226
226
|
assert.strictEqual(state.phase, 'Unknown');
|
|
227
227
|
});
|
|
228
|
+
|
|
229
|
+
it('returns a non-stale phase for a shipped, production-shaped STATE.md', () => {
|
|
230
|
+
// Regression (DASH-STALE-01): after a milestone ships, markMilestoneComplete
|
|
231
|
+
// resets the Current Position Phase: line to the between-milestones form. This
|
|
232
|
+
// proves readProjectState — whose Phase: read regex is UNANCHORED — yields a
|
|
233
|
+
// non-stale phase when given a realistic, fully-synced production STATE.md
|
|
234
|
+
// (full frontmatter block, NOT a bare `# Project State` body).
|
|
235
|
+
const shippedState = `---
|
|
236
|
+
dgs_state_version: 1.0
|
|
237
|
+
milestone: v1.0
|
|
238
|
+
milestone_name: milestone
|
|
239
|
+
status: milestone_shipped
|
|
240
|
+
last_updated: "2026-06-27T00:00:00.000Z"
|
|
241
|
+
completed_date: 2026-06-27
|
|
242
|
+
progress:
|
|
243
|
+
total_phases: 8
|
|
244
|
+
completed_phases: 8
|
|
245
|
+
total_plans: 20
|
|
246
|
+
completed_plans: 20
|
|
247
|
+
percent: 100
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
# Project State
|
|
251
|
+
|
|
252
|
+
## Current Position
|
|
253
|
+
|
|
254
|
+
Phase: — (between milestones; v1.0 shipped 2026-06-27)
|
|
255
|
+
Status: Milestone v1.0 shipped 2026-06-27
|
|
256
|
+
Progress: [██████████] 100%
|
|
257
|
+
`;
|
|
258
|
+
createProjectManually(tmpDir, 'proj', shippedState);
|
|
259
|
+
const state = readProjectState(tmpDir, 'proj');
|
|
260
|
+
assert.ok(
|
|
261
|
+
!/\d+\s+of\s+\d+/.test(state.phase),
|
|
262
|
+
`Shipped STATE should yield a non-stale phase, got: ${state.phase}`
|
|
263
|
+
);
|
|
264
|
+
assert.ok(/between milestones/.test(state.phase), 'phase reflects between-milestones form');
|
|
265
|
+
});
|
|
228
266
|
});
|
|
229
267
|
|
|
230
268
|
// ─── scanProjectReposTags ───────────────────────────────────────────────────
|