@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
|
@@ -11,7 +11,7 @@ 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
15
|
|
|
16
16
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
17
17
|
|
|
@@ -774,3 +774,223 @@ describe('cmdListTodos flat-first scanning', () => {
|
|
|
774
774
|
}
|
|
775
775
|
});
|
|
776
776
|
});
|
|
777
|
+
|
|
778
|
+
// ─── findPhaseInternal ambiguity surfacing (LOOK-01 callers) ──────────────────
|
|
779
|
+
|
|
780
|
+
describe('findPhaseInternal ambiguity surfacing (LOOK-01 callers)', () => {
|
|
781
|
+
// Captures stderr + the error()-driven process.exit so a guarded caller's
|
|
782
|
+
// milestone-qualified message is inspectable without killing the test worker.
|
|
783
|
+
// error() (core.cjs) writes to process.stderr then process.exit(1).
|
|
784
|
+
function captureError(fn) {
|
|
785
|
+
const chunks = [];
|
|
786
|
+
const origStderr = process.stderr.write.bind(process.stderr);
|
|
787
|
+
const origStdout = process.stdout.write.bind(process.stdout);
|
|
788
|
+
const origExit = process.exit;
|
|
789
|
+
let exitCode = null;
|
|
790
|
+
let threw = null;
|
|
791
|
+
process.stderr.write = (data) => { chunks.push(String(data)); return true; };
|
|
792
|
+
process.stdout.write = (data) => { chunks.push(String(data)); return true; };
|
|
793
|
+
process.exit = (code) => { exitCode = code == null ? 0 : code; throw new Error('__EXIT__'); };
|
|
794
|
+
try {
|
|
795
|
+
fn();
|
|
796
|
+
} catch (e) {
|
|
797
|
+
if (e && e.message !== '__EXIT__') threw = e;
|
|
798
|
+
} finally {
|
|
799
|
+
process.stderr.write = origStderr;
|
|
800
|
+
process.stdout.write = origStdout;
|
|
801
|
+
process.exit = origExit;
|
|
802
|
+
}
|
|
803
|
+
return { output: chunks.join(''), exitCode, threw };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Build a v2 project where bare "03" is NOT an active phase but exists in TWO
|
|
807
|
+
// milestone archives — the cross-milestone collision case.
|
|
808
|
+
function ambiguousFixture() {
|
|
809
|
+
return createFixture({
|
|
810
|
+
'config.json': JSON.stringify({}),
|
|
811
|
+
'config.local.json': JSON.stringify({ current_project: 'auth-overhaul' }),
|
|
812
|
+
'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
|
|
813
|
+
'REPOS.md': '# Repos\n\n| Name | Path |\n',
|
|
814
|
+
'projects/auth-overhaul/PROJECT.md': '# Project',
|
|
815
|
+
'projects/auth-overhaul/STATE.md': '---\ncurrent_milestone: v25.0\n---\n# State',
|
|
816
|
+
'projects/auth-overhaul/ROADMAP.md': '# Roadmap',
|
|
817
|
+
'projects/auth-overhaul/phases/': null,
|
|
818
|
+
'milestones/v3.0-phases/03-alpha/01-PLAN.md': '# Plan',
|
|
819
|
+
'milestones/v4.0-phases/03-beta/01-PLAN.md': '# Plan',
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
it('cmdPlanFinalize (commands.cjs:623 path) surfaces the milestone-qualified message, not a TypeError or silent resolution', () => {
|
|
824
|
+
const commands = require('./commands.cjs');
|
|
825
|
+
const fixture = ambiguousFixture();
|
|
826
|
+
try {
|
|
827
|
+
const { output, exitCode, threw } = captureError(() =>
|
|
828
|
+
commands.cmdPlanFinalize(fixture.cwd, '03', '01', {}, true)
|
|
829
|
+
);
|
|
830
|
+
// Must NOT crash with a TypeError (the pre-hardening failure mode).
|
|
831
|
+
assert.equal(threw, null, threw ? `unexpected throw: ${threw.stack}` : 'no throw');
|
|
832
|
+
// The guard fired with error() → exit(1).
|
|
833
|
+
assert.equal(exitCode, 1, 'guarded caller exits via error()');
|
|
834
|
+
// The milestone-qualified ambiguity message surfaced, naming both versions.
|
|
835
|
+
assert.ok(output.includes('v3.0'), `output names v3.0: ${output}`);
|
|
836
|
+
assert.ok(output.includes('v4.0'), `output names v4.0: ${output}`);
|
|
837
|
+
assert.ok(/ambiguous/i.test(output), `output flags ambiguity: ${output}`);
|
|
838
|
+
// NOT silently resolved to one archive directory.
|
|
839
|
+
assert.ok(!output.includes('03-alpha'), 'must not silently resolve to v3.0 dir');
|
|
840
|
+
assert.ok(!output.includes('03-beta'), 'must not silently resolve to v4.0 dir');
|
|
841
|
+
} finally {
|
|
842
|
+
fixture.cleanup();
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
// ─── cmdHistoryDigest composite key (LOOK-02) ──────────────────────────────────
|
|
848
|
+
//
|
|
849
|
+
// Documented output shape (composite {milestone, phase} key, string encoding):
|
|
850
|
+
// - When an entry carries a milestone (archived dirs, or current-phase entries
|
|
851
|
+
// once the current milestone is resolvable via resolveMilestoneVersion), the
|
|
852
|
+
// bucket key is `"<milestone>/<phase>"` (e.g. `digest.phases['v4.0/03']`).
|
|
853
|
+
// - When an entry has no resolvable milestone (flat-layout repos), the key is the
|
|
854
|
+
// bare phase number (e.g. `digest.phases['03']`) — byte-identical to the
|
|
855
|
+
// pre-LOOK-02 shape, so flat installs see no data loss and no key change.
|
|
856
|
+
// - decisions[] entries carry both `phase` and `milestone` so two milestones'
|
|
857
|
+
// same-numbered decisions are never ambiguous.
|
|
858
|
+
describe('cmdHistoryDigest composite key (LOOK-02)', () => {
|
|
859
|
+
const { cmdHistoryDigest } = require('./commands.cjs');
|
|
860
|
+
|
|
861
|
+
// Build a fixture with TWO milestone archives that BOTH contain a phase "03",
|
|
862
|
+
// plus a current active phase. STATE.md carries current_milestone so the
|
|
863
|
+
// current-phase entry resolves to that milestone.
|
|
864
|
+
function twoMilestoneFixture(currentMilestone) {
|
|
865
|
+
const structure = {
|
|
866
|
+
'config.json': JSON.stringify({}),
|
|
867
|
+
'config.local.json': JSON.stringify({ planningRoot: '.' }),
|
|
868
|
+
'PROJECT.md': '# Project\n',
|
|
869
|
+
'ROADMAP.md': '# Roadmap\n',
|
|
870
|
+
'PROJECTS.md': '# Projects\n',
|
|
871
|
+
'REPOS.md': '# Repos\n',
|
|
872
|
+
'STATE.md':
|
|
873
|
+
'---\n' +
|
|
874
|
+
'dgs_state_version: 1.0\n' +
|
|
875
|
+
(currentMilestone ? `current_milestone: ${currentMilestone}\n` : '') +
|
|
876
|
+
'milestone: v5.0\n' +
|
|
877
|
+
'---\n\n# Project State\n\nPhase: 7\n',
|
|
878
|
+
// v3.0 archive, phase 03 → provides A
|
|
879
|
+
'milestones/v3.0-phases/v3.0-ROADMAP.md': '# v3.0\n',
|
|
880
|
+
'milestones/v3.0-phases/03-alpha/03-SUMMARY.md':
|
|
881
|
+
'---\nphase: "03"\nname: "Alpha"\nprovides:\n - "A"\nkey-decisions:\n - "Decision Alpha"\n---\n',
|
|
882
|
+
// v4.0 archive, phase 03 → provides B
|
|
883
|
+
'milestones/v4.0-phases/v4.0-ROADMAP.md': '# v4.0\n',
|
|
884
|
+
'milestones/v4.0-phases/03-beta/03-SUMMARY.md':
|
|
885
|
+
'---\nphase: "03"\nname: "Beta"\nprovides:\n - "B"\nkey-decisions:\n - "Decision Beta"\n---\n',
|
|
886
|
+
// current active phase 07 → provides Current
|
|
887
|
+
'phases/07-current/07-SUMMARY.md':
|
|
888
|
+
'---\nphase: "07"\nname: "Current"\nprovides:\n - "Current"\n---\n',
|
|
889
|
+
};
|
|
890
|
+
return createFixture(structure);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function runDigest(cwd) {
|
|
894
|
+
const { json } = captureStdout(() => cmdHistoryDigest(cwd, false));
|
|
895
|
+
assert.ok(json, 'digest JSON emitted');
|
|
896
|
+
return json;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
it('Test 1 (no conflation): restarted "03" from two milestones lands in DISTINCT buckets', () => {
|
|
900
|
+
const fixture = twoMilestoneFixture('v5.0');
|
|
901
|
+
try {
|
|
902
|
+
const digest = runDigest(fixture.cwd);
|
|
903
|
+
const v3 = digest.phases['v3.0/03'];
|
|
904
|
+
const v4 = digest.phases['v4.0/03'];
|
|
905
|
+
assert.ok(v3, "v3.0/03 bucket exists");
|
|
906
|
+
assert.ok(v4, "v4.0/03 bucket exists");
|
|
907
|
+
// v3.0 bucket has A, NOT B; v4.0 bucket has B, NOT A.
|
|
908
|
+
assert.ok(v3.provides.includes('A'), `v3.0/03 provides A: ${JSON.stringify(v3.provides)}`);
|
|
909
|
+
assert.ok(!v3.provides.includes('B'), 'v3.0/03 must NOT contain B');
|
|
910
|
+
assert.ok(v4.provides.includes('B'), `v4.0/03 provides B: ${JSON.stringify(v4.provides)}`);
|
|
911
|
+
assert.ok(!v4.provides.includes('A'), 'v4.0/03 must NOT contain A');
|
|
912
|
+
// The old conflated bare bucket must NOT exist for these milestone entries.
|
|
913
|
+
assert.ok(!digest.phases['03'], 'no conflated bare "03" bucket for milestone entries');
|
|
914
|
+
} finally {
|
|
915
|
+
fixture.cleanup();
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it('Test 2 (current phase carries milestone): current-phase entry keyed under current milestone', () => {
|
|
920
|
+
const fixture = twoMilestoneFixture('v5.0');
|
|
921
|
+
try {
|
|
922
|
+
const digest = runDigest(fixture.cwd);
|
|
923
|
+
assert.ok(digest.phases['v5.0/07'], 'current phase 07 keyed under v5.0');
|
|
924
|
+
assert.ok(
|
|
925
|
+
digest.phases['v5.0/07'].provides.includes('Current'),
|
|
926
|
+
'current phase provides surfaced under its milestone bucket'
|
|
927
|
+
);
|
|
928
|
+
// Must NOT be keyed under bare "07" (would mean milestone was not assigned).
|
|
929
|
+
assert.ok(!digest.phases['07'], 'current phase must not land in a bare "07" bucket');
|
|
930
|
+
} finally {
|
|
931
|
+
fixture.cleanup();
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it('Test 3 (documented shape): keys use the "<milestone>/<phase>" encoding', () => {
|
|
936
|
+
const fixture = twoMilestoneFixture('v5.0');
|
|
937
|
+
try {
|
|
938
|
+
const digest = runDigest(fixture.cwd);
|
|
939
|
+
const keys = Object.keys(digest.phases);
|
|
940
|
+
assert.ok(keys.includes('v3.0/03'), `keys include v3.0/03: ${keys}`);
|
|
941
|
+
assert.ok(keys.includes('v4.0/03'), `keys include v4.0/03: ${keys}`);
|
|
942
|
+
assert.ok(keys.includes('v5.0/07'), `keys include v5.0/07: ${keys}`);
|
|
943
|
+
// Every milestone-qualified key matches the documented grammar.
|
|
944
|
+
for (const k of keys) {
|
|
945
|
+
assert.ok(/^(v\d+\.\d+\/.+|[^/]+)$/.test(k), `key '${k}' matches documented shape`);
|
|
946
|
+
}
|
|
947
|
+
} finally {
|
|
948
|
+
fixture.cleanup();
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it('Test 4 (decisions carry milestone): decisions tagged so two milestones are not ambiguous', () => {
|
|
953
|
+
const fixture = twoMilestoneFixture('v5.0');
|
|
954
|
+
try {
|
|
955
|
+
const digest = runDigest(fixture.cwd);
|
|
956
|
+
const alpha = digest.decisions.find(d => d.decision === 'Decision Alpha');
|
|
957
|
+
const beta = digest.decisions.find(d => d.decision === 'Decision Beta');
|
|
958
|
+
assert.ok(alpha, 'Decision Alpha present');
|
|
959
|
+
assert.ok(beta, 'Decision Beta present');
|
|
960
|
+
assert.strictEqual(alpha.milestone, 'v3.0', 'Decision Alpha tagged with v3.0');
|
|
961
|
+
assert.strictEqual(beta.milestone, 'v4.0', 'Decision Beta tagged with v4.0');
|
|
962
|
+
assert.strictEqual(alpha.phase, '03', 'Decision Alpha keeps readable phase');
|
|
963
|
+
} finally {
|
|
964
|
+
fixture.cleanup();
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
it('Test 5 (flat-layout backward-compat): no milestone → bare phase key preserved', () => {
|
|
969
|
+
// Flat repo: no milestones/ archives, no current_milestone. Current phases
|
|
970
|
+
// must keep the byte-identical bare-number key shape (information preserved).
|
|
971
|
+
const fixture = createFixture({
|
|
972
|
+
'config.json': JSON.stringify({}),
|
|
973
|
+
'config.local.json': JSON.stringify({ planningRoot: '.' }),
|
|
974
|
+
'PROJECT.md': '# Project\n',
|
|
975
|
+
'ROADMAP.md': '# Roadmap\n',
|
|
976
|
+
'PROJECTS.md': '# Projects\n',
|
|
977
|
+
'REPOS.md': '# Repos\n',
|
|
978
|
+
'STATE.md': '# Project State\n\nPhase: 1\n',
|
|
979
|
+
'phases/01-foundation/01-SUMMARY.md':
|
|
980
|
+
'---\nphase: "01"\nname: "Foundation"\nprovides:\n - "DB"\n---\n',
|
|
981
|
+
'phases/02-api/02-SUMMARY.md':
|
|
982
|
+
'---\nphase: "02"\nname: "API"\nprovides:\n - "REST"\n---\n',
|
|
983
|
+
});
|
|
984
|
+
try {
|
|
985
|
+
const digest = runDigest(fixture.cwd);
|
|
986
|
+
assert.ok(digest.phases['01'], 'flat phase 01 keyed bare');
|
|
987
|
+
assert.ok(digest.phases['02'], 'flat phase 02 keyed bare');
|
|
988
|
+
assert.ok(digest.phases['01'].provides.includes('DB'), 'flat 01 provides preserved');
|
|
989
|
+
assert.ok(digest.phases['02'].provides.includes('REST'), 'flat 02 provides preserved');
|
|
990
|
+
// No spurious milestone-qualified keys in a flat repo.
|
|
991
|
+
assert.ok(!Object.keys(digest.phases).some(k => k.includes('/')), 'no milestone-qualified keys in flat repo');
|
|
992
|
+
} finally {
|
|
993
|
+
fixture.cleanup();
|
|
994
|
+
}
|
|
995
|
+
});
|
|
996
|
+
});
|
|
@@ -526,15 +526,15 @@ function getMilestoneSummaries(cwd, planningRoot, currentPhaseNum) {
|
|
|
526
526
|
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
|
527
527
|
|
|
528
528
|
// Scan phases directory for milestone phases with summaries
|
|
529
|
-
|
|
529
|
+
const { phasesDir: resolvePhasesDir } = require('./core.cjs');
|
|
530
|
+
let phasesRel;
|
|
530
531
|
try {
|
|
531
|
-
|
|
532
|
-
projectRoot = getProjectRoot(cwd);
|
|
532
|
+
phasesRel = resolvePhasesDir(cwd);
|
|
533
533
|
} catch {
|
|
534
|
-
|
|
534
|
+
phasesRel = path.join(path.relative(cwd, planningRoot) || '.', 'phases');
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
-
const phasesDir = path.join(cwd,
|
|
537
|
+
const phasesDir = path.join(cwd, phasesRel);
|
|
538
538
|
if (!fs.existsSync(phasesDir)) return results;
|
|
539
539
|
|
|
540
540
|
try {
|
|
@@ -561,7 +561,7 @@ function getMilestoneSummaries(cwd, planningRoot, currentPhaseNum) {
|
|
|
561
561
|
const phaseFiles = fs.readdirSync(phasePath);
|
|
562
562
|
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
563
563
|
for (const summary of summaries) {
|
|
564
|
-
const relPath = toPosixPath(path.join(
|
|
564
|
+
const relPath = toPosixPath(path.join(phasesRel, dir, summary));
|
|
565
565
|
results.push({ path: relPath, category: 'milestone-summary' });
|
|
566
566
|
}
|
|
567
567
|
} catch {}
|
|
@@ -99,22 +99,22 @@ describe('parseTierDefinitions', () => {
|
|
|
99
99
|
assert.equal(none.files.length, 0, 'none tier files should be empty');
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
it('lite tier has
|
|
102
|
+
it('lite tier has 4 files (PROJECT.md, PRODUCT-SUMMARY.md, STATE.md, config.json)', () => {
|
|
103
103
|
const tiers = parseTierDefinitions();
|
|
104
104
|
const lite = tiers.get('lite');
|
|
105
105
|
assert.ok(Array.isArray(lite.files), 'lite tier files should be array');
|
|
106
|
-
assert.equal(lite.files.length,
|
|
106
|
+
assert.equal(lite.files.length, 4, 'lite tier should have 4 files');
|
|
107
107
|
const paths = lite.files.map(f => f.path);
|
|
108
108
|
assert.ok(paths.includes('PROJECT.md'), 'should include PROJECT.md');
|
|
109
109
|
assert.ok(paths.includes('STATE.md'), 'should include STATE.md');
|
|
110
110
|
assert.ok(paths.includes('config.json'), 'should include config.json');
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
it('planning tier has
|
|
113
|
+
it('planning tier has 4 own files plus dynamic codebase glob', () => {
|
|
114
114
|
const tiers = parseTierDefinitions();
|
|
115
115
|
const planning = tiers.get('planning');
|
|
116
116
|
assert.ok(Array.isArray(planning.files), 'planning tier files should be array');
|
|
117
|
-
assert.equal(planning.files.length,
|
|
117
|
+
assert.equal(planning.files.length, 4, 'planning tier should have 4 own files');
|
|
118
118
|
const paths = planning.files.map(f => f.path);
|
|
119
119
|
assert.ok(paths.includes('ROADMAP.md'), 'should include ROADMAP.md');
|
|
120
120
|
assert.ok(paths.includes('REQUIREMENTS.md'), 'should include REQUIREMENTS.md');
|
|
@@ -674,20 +674,20 @@ describe('resolveTierFiles', () => {
|
|
|
674
674
|
it('returns lite files for lite tier', () => {
|
|
675
675
|
const tiers = parseTierDefinitions();
|
|
676
676
|
const result = resolveTierFiles('lite', tiers);
|
|
677
|
-
assert.equal(result.length,
|
|
677
|
+
assert.equal(result.length, 4);
|
|
678
678
|
});
|
|
679
679
|
|
|
680
680
|
it('planning tier inherits lite files', () => {
|
|
681
681
|
const tiers = parseTierDefinitions();
|
|
682
682
|
const result = resolveTierFiles('planning', tiers);
|
|
683
|
-
// lite has
|
|
684
|
-
assert.equal(result.length,
|
|
683
|
+
// lite has 4, planning adds 4 more
|
|
684
|
+
assert.equal(result.length, 8, 'planning should have 8 static files (4 lite + 4 planning)');
|
|
685
685
|
});
|
|
686
686
|
|
|
687
687
|
it('execution tier inherits planning+lite files', () => {
|
|
688
688
|
const tiers = parseTierDefinitions();
|
|
689
689
|
const result = resolveTierFiles('execution', tiers);
|
|
690
|
-
// execution has files: [] itself but inherits from planning which has
|
|
691
|
-
assert.equal(result.length,
|
|
690
|
+
// execution has files: [] itself but inherits from planning which has 8
|
|
691
|
+
assert.equal(result.length, 8, 'execution inherits all planning files');
|
|
692
692
|
});
|
|
693
693
|
});
|
|
@@ -289,18 +289,17 @@ function findPhaseInternal(cwd, phase) {
|
|
|
289
289
|
|
|
290
290
|
const normalized = normalizePhaseName(phase);
|
|
291
291
|
|
|
292
|
-
// Resolve phases directory
|
|
293
|
-
let
|
|
292
|
+
// Resolve phases directory via canonical resolver; keep soft '.' fallback
|
|
293
|
+
let phasesRel;
|
|
294
294
|
try {
|
|
295
|
-
|
|
295
|
+
phasesRel = phasesDir(cwd);
|
|
296
296
|
} catch {
|
|
297
|
-
|
|
297
|
+
phasesRel = 'phases'; // flat-layout fallback (== path.join('.', 'phases'))
|
|
298
298
|
}
|
|
299
|
-
const
|
|
300
|
-
const phasesDir = path.join(cwd, phasesRel);
|
|
299
|
+
const phasesAbs = path.join(cwd, phasesRel);
|
|
301
300
|
|
|
302
301
|
// Search current phases first
|
|
303
|
-
const current = searchPhaseInDir(
|
|
302
|
+
const current = searchPhaseInDir(phasesAbs, phasesRel, normalized);
|
|
304
303
|
if (current) return current;
|
|
305
304
|
|
|
306
305
|
// Search archived milestone phases (newest first)
|
|
@@ -318,6 +317,12 @@ function findPhaseInternal(cwd, phase) {
|
|
|
318
317
|
.reverse();
|
|
319
318
|
|
|
320
319
|
const planRootRel = path.relative(cwd, planRoot);
|
|
320
|
+
|
|
321
|
+
// LOOK-01: collect ALL archive matches before deciding, instead of
|
|
322
|
+
// returning the first newest-first hit. Once phase numbers restart per
|
|
323
|
+
// milestone (Phase 165), a bare number can collide across several milestone
|
|
324
|
+
// archives; silently returning the newest is a correctness bug.
|
|
325
|
+
const collected = [];
|
|
321
326
|
for (const archiveName of archiveDirs) {
|
|
322
327
|
const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
|
|
323
328
|
const archivePath = path.join(milestonesDir, archiveName);
|
|
@@ -325,9 +330,35 @@ function findPhaseInternal(cwd, phase) {
|
|
|
325
330
|
const result = searchPhaseInDir(archivePath, relBase, normalized);
|
|
326
331
|
if (result) {
|
|
327
332
|
result.archived = version;
|
|
328
|
-
|
|
333
|
+
collected.push({ version, result });
|
|
329
334
|
}
|
|
330
335
|
}
|
|
336
|
+
|
|
337
|
+
// Branch on the collected count:
|
|
338
|
+
// - 0 matches → null (unchanged)
|
|
339
|
+
// - 1 match → return that match with .archived set — BYTE-IDENTICAL to
|
|
340
|
+
// the previous single-archive path. Flat-layout / globally-
|
|
341
|
+
// unique numbers always land here, so their behaviour is
|
|
342
|
+
// preserved exactly.
|
|
343
|
+
// - 2+ matches → return a PINNED, NON-throwing structured ambiguity signal:
|
|
344
|
+
// { found:false, ambiguous:true, phase, matches:[{milestone,
|
|
345
|
+
// directory}...], message }. It carries NO top-level
|
|
346
|
+
// `directory` and NO single `archived` tag, so callers that
|
|
347
|
+
// guard on `!phaseInfo.found` treat it as not-found, while
|
|
348
|
+
// hardened callers surface its `message`. This object shape
|
|
349
|
+
// is part of findPhaseInternal's return contract.
|
|
350
|
+
if (collected.length === 0) return null;
|
|
351
|
+
if (collected.length === 1) return collected[0].result;
|
|
352
|
+
|
|
353
|
+
const versions = collected.map(c => c.version);
|
|
354
|
+
const newestVersion = [...versions].sort().reverse()[0];
|
|
355
|
+
return {
|
|
356
|
+
found: false,
|
|
357
|
+
ambiguous: true,
|
|
358
|
+
phase: normalized,
|
|
359
|
+
matches: collected.map(c => ({ milestone: c.version, directory: c.result.directory })),
|
|
360
|
+
message: `Phase ${normalized} is ambiguous across milestones ${versions.join(', ')} — use a milestone-qualified reference (e.g. ${newestVersion}/${normalized})`,
|
|
361
|
+
};
|
|
331
362
|
} catch {}
|
|
332
363
|
|
|
333
364
|
return null;
|
|
@@ -514,12 +545,122 @@ function getMilestoneInfo(cwd) {
|
|
|
514
545
|
}
|
|
515
546
|
}
|
|
516
547
|
|
|
548
|
+
// ─── Authoritative milestone-version signal (Phase 163-01) ─────────────────────
|
|
549
|
+
//
|
|
550
|
+
// Grounding (verified 2026-06-25) — the canonical structured field name:
|
|
551
|
+
// - The field persisted in STATE.md frontmatter TODAY is `milestone:`
|
|
552
|
+
// (written by state.cjs buildStateFrontmatter, derived from the getMilestoneInfo
|
|
553
|
+
// ROADMAP prose scrape). `current_milestone` exists only in init.cjs JSON output
|
|
554
|
+
// (init.cjs:560), NOT as a persisted STATE.md field.
|
|
555
|
+
// - The spec/CONTEXT name the authoritative field `current_milestone`.
|
|
556
|
+
// - To honour the locked name WHILE staying compatible with what exists today,
|
|
557
|
+
// resolveMilestoneVersion reads `current_milestone` FIRST and falls back to
|
|
558
|
+
// `milestone` SECOND. Plan 02 standardises the writer on `current_milestone`.
|
|
559
|
+
//
|
|
560
|
+
// This is the structured-first, grammar-validated, FAIL-LOUD signal. It deliberately
|
|
561
|
+
// does NOT trust getMilestoneInfo's `v1.0` default — that scrape is advisory only and
|
|
562
|
+
// is consulted here solely for an optional mismatch warning.
|
|
563
|
+
|
|
564
|
+
/** Canonical milestone-version grammar (RSLV-05): exactly `vN.N`, no trim, no flags. */
|
|
565
|
+
const MILESTONE_VERSION_RE = /^v\d+\.\d+$/;
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Validate a milestone-version string against the canonical grammar `^v\d+\.\d+$`.
|
|
569
|
+
* Out-of-grammar values are rejected, never coerced. Does NOT trim — trimming is the
|
|
570
|
+
* caller's choice, so malformed (whitespace-padded) input is not silently masked.
|
|
571
|
+
*
|
|
572
|
+
* @param {*} value
|
|
573
|
+
* @returns {boolean}
|
|
574
|
+
*/
|
|
575
|
+
function isValidMilestoneVersion(value) {
|
|
576
|
+
return typeof value === 'string' && MILESTONE_VERSION_RE.test(value);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Resolve the current milestone version from the structured STATE.md frontmatter
|
|
581
|
+
* field (authoritative), validate it against the canonical grammar, and FAIL LOUD
|
|
582
|
+
* when it is required but undeterminable. NEVER defaults to or returns 'v1.0'.
|
|
583
|
+
*
|
|
584
|
+
* Read precedence within STATE.md frontmatter: `current_milestone` then `milestone`.
|
|
585
|
+
* On a STATE-vs-ROADMAP mismatch, warns to stderr and trusts STATE.
|
|
586
|
+
*
|
|
587
|
+
* @param {string} cwd - Working directory
|
|
588
|
+
* @param {{ required?: boolean }} [opts]
|
|
589
|
+
* @returns {string|null} The validated `vN.N` version, or null when undeterminable
|
|
590
|
+
* and not required.
|
|
591
|
+
* @throws {Error} when required is true and the version is undeterminable/invalid.
|
|
592
|
+
*/
|
|
593
|
+
function resolveMilestoneVersion(cwd, { required = false } = {}) {
|
|
594
|
+
const remediation =
|
|
595
|
+
"Cannot determine current milestone version — set 'current_milestone' " +
|
|
596
|
+
'(e.g. v25.0) in STATE.md frontmatter.';
|
|
597
|
+
|
|
598
|
+
// 1. Read STATE.md (structured, authoritative). Mirror getMilestoneInfo's
|
|
599
|
+
// project-scoped read: project STATE.md first, then planning-root STATE.md.
|
|
600
|
+
let stateContent = null;
|
|
601
|
+
try {
|
|
602
|
+
const projectRoot = getProjectRoot(cwd);
|
|
603
|
+
stateContent = safeReadFile(path.join(cwd, projectRoot, 'STATE.md'));
|
|
604
|
+
} catch {
|
|
605
|
+
stateContent = null;
|
|
606
|
+
}
|
|
607
|
+
if (stateContent === null) {
|
|
608
|
+
stateContent = safeReadFile(path.join(getPlanningRoot(cwd), 'STATE.md'));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// 2. Local minimal frontmatter parse. Intentionally does NOT import the
|
|
612
|
+
// frontmatter parser module to avoid a require cycle (that module requires this one).
|
|
613
|
+
let raw;
|
|
614
|
+
if (stateContent) {
|
|
615
|
+
const fmMatch = stateContent.match(/^---\n([\s\S]+?)\n---/);
|
|
616
|
+
if (fmMatch) {
|
|
617
|
+
const block = fmMatch[1];
|
|
618
|
+
const currentMatch = block.match(/^current_milestone:\s*["']?(\S+?)["']?\s*$/m);
|
|
619
|
+
const fallbackMatch = block.match(/^milestone:\s*["']?(\S+?)["']?\s*$/m);
|
|
620
|
+
raw = (currentMatch && currentMatch[1]) || (fallbackMatch && fallbackMatch[1]);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// 3-4. Valid grammar → optional advisory mismatch warning, then return.
|
|
625
|
+
if (isValidMilestoneVersion(raw)) {
|
|
626
|
+
try {
|
|
627
|
+
const advisory = getMilestoneInfo(cwd);
|
|
628
|
+
if (advisory && advisory.version && advisory.version !== raw) {
|
|
629
|
+
process.stderr.write(
|
|
630
|
+
`Warning: milestone version mismatch — STATE.md says ${raw} but ROADMAP ` +
|
|
631
|
+
`says ${advisory.version}. Trusting STATE.md (${raw}).\n`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
} catch { /* advisory only — never block on the prose scrape */ }
|
|
635
|
+
return raw;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 5-6. Present-but-invalid or absent → fail loud if required, else null.
|
|
639
|
+
// The abandoned getMilestoneInfo default is deliberately NOT used here.
|
|
640
|
+
if (required) {
|
|
641
|
+
throw new Error(remediation);
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
|
|
517
646
|
/**
|
|
518
647
|
* Returns a filter function that checks whether a phase directory belongs
|
|
519
|
-
* to the current milestone
|
|
648
|
+
* to the current milestone.
|
|
649
|
+
* Under versioned layout (phases/<version>/) membership is structural — every
|
|
650
|
+
* entry already belongs to the milestone — so this returns a pass-all predicate.
|
|
651
|
+
* Under flat layout (shared phases/) it returns a ROADMAP number-set predicate.
|
|
520
652
|
* If no ROADMAP exists or no phases are listed, returns a pass-all filter.
|
|
521
653
|
*/
|
|
522
654
|
function getMilestonePhaseFilter(cwd) {
|
|
655
|
+
// Versioned layout: phases/<version>/ is already milestone-scoped — every entry belongs to the milestone, so number-set filtering is redundant (NUM-04).
|
|
656
|
+
let versioned = false;
|
|
657
|
+
try { versioned = /^v\d+\.\d+$/.test(path.basename(phasesDir(cwd))); } catch { versioned = false; }
|
|
658
|
+
if (versioned) {
|
|
659
|
+
const passAll = () => true;
|
|
660
|
+
passAll.phaseCount = 0;
|
|
661
|
+
return passAll;
|
|
662
|
+
}
|
|
663
|
+
|
|
523
664
|
const milestonePhaseNums = new Set();
|
|
524
665
|
try {
|
|
525
666
|
let roadmap;
|
|
@@ -662,6 +803,52 @@ function resolveProjectPath(cwd, relativePath) {
|
|
|
662
803
|
return relativePath ? path.join(root, relativePath) : root;
|
|
663
804
|
}
|
|
664
805
|
|
|
806
|
+
/**
|
|
807
|
+
* Canonical resolver for the active-project phases directory.
|
|
808
|
+
*
|
|
809
|
+
* Returns the RELATIVE project-scoped phases path:
|
|
810
|
+
* - v2 layout: 'projects/<slug>/phases'
|
|
811
|
+
* - v1 / flat layout: 'phases'
|
|
812
|
+
*
|
|
813
|
+
* FAIL-LOUD: delegates to resolveProjectPath -> requireProjectRoot, which
|
|
814
|
+
* throws when there is no project root. Callers that want a soft fallback
|
|
815
|
+
* (e.g. flat-layout tooling) keep their own try/catch around this call —
|
|
816
|
+
* the fallback policy stays local to each call site, never buried here.
|
|
817
|
+
*
|
|
818
|
+
* This is the SINGLE seam for phases-path resolution, and it IS version-aware
|
|
819
|
+
* (Phase 165 / RSLV-04): when a 'phases/<version>' directory exists on disk under
|
|
820
|
+
* the active project — where <version> is the validated `vN.N` milestone signal
|
|
821
|
+
* (resolveMilestoneVersion, Phase 163) — it returns the RELATIVE versioned path
|
|
822
|
+
* 'phases/<version>'. Otherwise it falls back to flat 'phases'. The fallback is
|
|
823
|
+
* permanent and intentional (dual-mode cutover): pre-existing flat layouts and the
|
|
824
|
+
* in-flight milestone (no 'phases/<version>' dir on disk) resolve FLAT by design —
|
|
825
|
+
* versioning is never applied retroactively. The read path NEVER defaults to 'v1.0'
|
|
826
|
+
* and NEVER throws on an undeterminable version (it just resolves flat).
|
|
827
|
+
*
|
|
828
|
+
* Do not reintroduce ad-hoc path.join(...,'phases') sites — path-audit.test.cjs
|
|
829
|
+
* gates against that. The only phases-bearing joins below are path.join(base, version)
|
|
830
|
+
* (relative return) and the existsSync probe path.join(cwd, base, version); neither is
|
|
831
|
+
* a bare path.join(..., 'phases') literal, so the 164-05 gate stays satisfied.
|
|
832
|
+
*
|
|
833
|
+
* @param {string} cwd - Working directory
|
|
834
|
+
* @returns {string} Relative project-scoped phases path ('phases', 'projects/<slug>/phases',
|
|
835
|
+
* or the versioned '…/phases/<version>')
|
|
836
|
+
* @throws {Error} NO_CURRENT_PROJECT_V2 | PROJECT_NOT_FOUND | INVALID_PROJECT_NAME | PROJECT_COMPLETED
|
|
837
|
+
*/
|
|
838
|
+
function phasesDir(cwd) {
|
|
839
|
+
const base = resolveProjectPath(cwd, 'phases'); // RELATIVE, fail-loud (unchanged)
|
|
840
|
+
let version = null;
|
|
841
|
+
try {
|
|
842
|
+
version = resolveMilestoneVersion(cwd); // non-required: validated vN.N or null, never throws, never v1.0
|
|
843
|
+
} catch {
|
|
844
|
+
version = null; // read path never blocks on an undeterminable version
|
|
845
|
+
}
|
|
846
|
+
if (version && fs.existsSync(path.join(cwd, base, version))) {
|
|
847
|
+
return path.join(base, version); // versioned dir present → return RELATIVE versioned path
|
|
848
|
+
}
|
|
849
|
+
return base; // dual-mode fallback: flat phases/ (incl. the in-flight v25.0)
|
|
850
|
+
}
|
|
851
|
+
|
|
665
852
|
/**
|
|
666
853
|
* Check if a project is completed by reading its STATE.md directly.
|
|
667
854
|
*
|
|
@@ -749,9 +936,12 @@ module.exports = {
|
|
|
749
936
|
pathExistsInternal,
|
|
750
937
|
generateSlugInternal,
|
|
751
938
|
getMilestoneInfo,
|
|
939
|
+
isValidMilestoneVersion,
|
|
940
|
+
resolveMilestoneVersion,
|
|
752
941
|
getMilestonePhaseFilter,
|
|
753
942
|
toPosixPath,
|
|
754
943
|
resolveProjectPath,
|
|
944
|
+
phasesDir,
|
|
755
945
|
getProjectRoot,
|
|
756
946
|
requireProjectRoot,
|
|
757
947
|
isV2Install,
|