@ktpartners/dgs-platform 3.4.1 → 3.5.0
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 +19 -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 +12 -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 +16 -10
- package/deliver-great-systems/bin/lib/jobs.test.cjs +17 -1
- 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 +9 -6
- 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/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/execute-plan.md +1 -1
- package/deliver-great-systems/workflows/init-product.md +8 -8
- 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
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { execSync } = require('child_process');
|
|
8
|
-
const { escapeRegex, getMilestonePhaseFilter, getProjectRoot, output, error, loadConfig } = require('./core.cjs');
|
|
8
|
+
const { escapeRegex, getMilestonePhaseFilter, getProjectRoot, output, error, loadConfig, phasesDir, resolveMilestoneVersion, resolveProjectPath } = require('./core.cjs');
|
|
9
|
+
const { phaseInitVersionedDirInternal } = require('./phase.cjs');
|
|
9
10
|
const { extractFrontmatter, spliceFrontmatter } = require('./frontmatter.cjs');
|
|
10
11
|
const { getPlanningRoot } = require('./paths.cjs');
|
|
11
12
|
const { writeStateMd, stateReadAdhoc } = require('./state.cjs');
|
|
@@ -118,7 +119,9 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
118
119
|
const statePath = path.join(projectRoot, 'STATE.md');
|
|
119
120
|
const milestonesPath = path.join(planRoot, 'MILESTONES.md');
|
|
120
121
|
const archiveDir = path.join(planRoot, 'milestones');
|
|
121
|
-
const
|
|
122
|
+
const phasesAbs = path.join(cwd, phasesDir(cwd));
|
|
123
|
+
// Versioned layout → phases/<version>/ is milestone-scoped; flat → shared phases/ needs number-set filtering.
|
|
124
|
+
const versionedLayout = /^v\d+\.\d+$/.test(path.basename(phasesDir(cwd)));
|
|
122
125
|
const today = new Date().toISOString().split('T')[0];
|
|
123
126
|
const milestoneName = options.name || version;
|
|
124
127
|
|
|
@@ -128,6 +131,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
128
131
|
// Scope stats and accomplishments to only the phases belonging to the
|
|
129
132
|
// current milestone's ROADMAP. Uses the shared filter from core.cjs
|
|
130
133
|
// (same logic used by cmdPhasesList and other callers).
|
|
134
|
+
// Version-aware as of NUM-04: returns a pass-all predicate under versioned
|
|
135
|
+
// layout, so the stats loop counts every entry in phases/<version>/.
|
|
131
136
|
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
|
132
137
|
|
|
133
138
|
// Gather stats from phases (scoped to current milestone only)
|
|
@@ -137,14 +142,14 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
137
142
|
const accomplishments = [];
|
|
138
143
|
|
|
139
144
|
try {
|
|
140
|
-
const entries = fs.readdirSync(
|
|
145
|
+
const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
|
|
141
146
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
|
142
147
|
|
|
143
148
|
for (const dir of dirs) {
|
|
144
149
|
if (!isDirInMilestone(dir)) continue;
|
|
145
150
|
|
|
146
151
|
phaseCount++;
|
|
147
|
-
const phaseFiles = fs.readdirSync(path.join(
|
|
152
|
+
const phaseFiles = fs.readdirSync(path.join(phasesAbs, dir));
|
|
148
153
|
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
149
154
|
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
150
155
|
totalPlans += plans.length;
|
|
@@ -152,7 +157,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
152
157
|
// Extract one-liners from summaries
|
|
153
158
|
for (const s of summaries) {
|
|
154
159
|
try {
|
|
155
|
-
const content = fs.readFileSync(path.join(
|
|
160
|
+
const content = fs.readFileSync(path.join(phasesAbs, dir, s), 'utf-8');
|
|
156
161
|
const fm = extractFrontmatter(content);
|
|
157
162
|
if (fm['one-liner']) {
|
|
158
163
|
accomplishments.push(fm['one-liner']);
|
|
@@ -302,17 +307,29 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
302
307
|
if (options.archivePhases) {
|
|
303
308
|
try {
|
|
304
309
|
const phaseArchiveDir = path.join(archiveDir, `${version}-phases`);
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
310
|
+
if (versionedLayout) {
|
|
311
|
+
// NUM-03: phases/<version>/ is the whole milestone → archive in ONE move.
|
|
312
|
+
// Collision guard: never overwrite/merge an existing archive (no mkdir of dest —
|
|
313
|
+
// renameSync of a dir requires the destination to NOT exist).
|
|
314
|
+
if (fs.existsSync(phaseArchiveDir)) {
|
|
315
|
+
error(`Cannot archive phases: ${phaseArchiveDir} already exists. Move or remove it, then re-run milestone complete --archive-phases.`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
fs.renameSync(phasesAbs, phaseArchiveDir);
|
|
319
|
+
phasesArchived = true;
|
|
320
|
+
} else {
|
|
321
|
+
// Flat mode: shared phases/ — preserve the per-directory filtered move byte-for-byte.
|
|
322
|
+
fs.mkdirSync(phaseArchiveDir, { recursive: true });
|
|
323
|
+
const phaseEntries = fs.readdirSync(phasesAbs, { withFileTypes: true });
|
|
324
|
+
const phaseDirNames = phaseEntries.filter(e => e.isDirectory()).map(e => e.name);
|
|
325
|
+
let archivedCount = 0;
|
|
326
|
+
for (const dir of phaseDirNames) {
|
|
327
|
+
if (!isDirInMilestone(dir)) continue;
|
|
328
|
+
fs.renameSync(path.join(phasesAbs, dir), path.join(phaseArchiveDir, dir));
|
|
329
|
+
archivedCount++;
|
|
330
|
+
}
|
|
331
|
+
phasesArchived = archivedCount > 0;
|
|
314
332
|
}
|
|
315
|
-
phasesArchived = archivedCount > 0;
|
|
316
333
|
} catch {}
|
|
317
334
|
}
|
|
318
335
|
|
|
@@ -431,6 +448,46 @@ function _adhocTools(cwd, argStr) {
|
|
|
431
448
|
}
|
|
432
449
|
}
|
|
433
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Seed an `## Active Milestone: <version>` section into ROADMAP.md (Decision A).
|
|
453
|
+
*
|
|
454
|
+
* Idempotent: if a `## Active Milestone: <version>` heading already exists, it
|
|
455
|
+
* returns false without writing. Otherwise it strips a placeholder
|
|
456
|
+
* `### 📋 Next Milestone — TBD` block (if present) and APPENDS the active section
|
|
457
|
+
* terminated by `\n---\n`, so the section's trailing separator becomes the file's
|
|
458
|
+
* LAST `\n---` — exactly the anchor cmdPhaseAdd inserts `### Phase N:` entries
|
|
459
|
+
* before. With zero `### Phase` headings present, the first add-phase computes
|
|
460
|
+
* maxPhase=0 → Phase 1 / 01-<slug> under phases/<version>/.
|
|
461
|
+
*
|
|
462
|
+
* Guard: if the roadmap file does not exist, returns false (create-adhoc's STATE
|
|
463
|
+
* existence check is the authoritative precondition; we never crash here).
|
|
464
|
+
*
|
|
465
|
+
* @returns {boolean} true if it wrote the section, false on no-op.
|
|
466
|
+
*/
|
|
467
|
+
function seedAdhocRoadmapSection(roadmapPath, version, name) {
|
|
468
|
+
if (!fs.existsSync(roadmapPath)) return false;
|
|
469
|
+
let content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
470
|
+
// Idempotent: already seeded for this version → no-op.
|
|
471
|
+
if (new RegExp('^##\\s+Active Milestone:\\s*' + escapeRegex(version) + '\\b', 'm').test(content)) {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
// Strip a placeholder "### 📋 Next Milestone — TBD" heading-and-body block if
|
|
475
|
+
// present (consume up to the next heading / separator / EOF).
|
|
476
|
+
content = content.replace(
|
|
477
|
+
/\n?#{2,3}\s*(?:📋\s*)?Next Milestone\s*[—-]\s*TBD[\s\S]*?(?=\n#{1,3}\s|\n---|$)/,
|
|
478
|
+
''
|
|
479
|
+
);
|
|
480
|
+
// Normalize trailing whitespace to a single newline, then append the section.
|
|
481
|
+
content = content.replace(/\s*$/, '\n');
|
|
482
|
+
const section =
|
|
483
|
+
'\n## Active Milestone: ' + version + ' ' + name + '\n\n' +
|
|
484
|
+
'**Goal:** ' + name + '\n' +
|
|
485
|
+
'**Status:** In progress (ad-hoc container — phases added on demand via /dgs:add-phase)\n\n' +
|
|
486
|
+
'Phases:\n\n---\n';
|
|
487
|
+
fs.writeFileSync(roadmapPath, content + section, 'utf-8');
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
|
|
434
491
|
/**
|
|
435
492
|
* Create an ad-hoc milestone container atomically (Phase 159).
|
|
436
493
|
*
|
|
@@ -508,13 +565,34 @@ function cmdMilestoneCreateAdhoc(cwd, args, raw) {
|
|
|
508
565
|
let refCreated = false;
|
|
509
566
|
let worktreesCreated = false;
|
|
510
567
|
let stateCommitHash = null;
|
|
511
|
-
|
|
568
|
+
// Decision D — seeded-scaffolding rollback bookkeeping. The versioned dir is
|
|
569
|
+
// untracked (empty at creation) so a commit-reset never removes it; rollback
|
|
570
|
+
// drops it explicitly.
|
|
571
|
+
let versionedDirCreated = false;
|
|
572
|
+
let versionedDirAbs = null;
|
|
573
|
+
const projRootRel = getProjectRoot(cwd);
|
|
574
|
+
const statePath = path.join(planningRoot, projRootRel, 'STATE.md');
|
|
575
|
+
const roadmapPath = path.join(planningRoot, projRootRel, 'ROADMAP.md');
|
|
512
576
|
|
|
513
577
|
const rollback = (reasonMsg) => {
|
|
514
578
|
// Reverse order. active_context is set LAST so a pre-step-6 failure never set it.
|
|
579
|
+
// (i) Drop the seeded versioned dir first — it is untracked, so neither the
|
|
580
|
+
// commit-reset nor a doc restore would remove it.
|
|
581
|
+
if (versionedDirCreated && versionedDirAbs) {
|
|
582
|
+
try { fs.rmSync(versionedDirAbs, { recursive: true, force: true }); } catch {}
|
|
583
|
+
}
|
|
515
584
|
if (stateCommitHash) {
|
|
516
|
-
//
|
|
585
|
+
// Post-commit failure: the reset reverts BOTH staged docs (STATE + ROADMAP).
|
|
517
586
|
_adhocGit(planningRoot, ['reset', '--hard', stateCommitHash + '^']);
|
|
587
|
+
} else {
|
|
588
|
+
// Pre-commit failure (5c versioned-dir / 5d add-or-commit): the on-disk
|
|
589
|
+
// current_milestone + Active Milestone section were written but never
|
|
590
|
+
// committed. Restore both docs from HEAD so NO residue survives — this
|
|
591
|
+
// makes the "no residue on any step-5 failure" guarantee hold literally
|
|
592
|
+
// regardless of which window the failure hits (plan-check advisory).
|
|
593
|
+
_adhocGit(planningRoot, ['checkout', 'HEAD', '--',
|
|
594
|
+
path.relative(planningRoot, statePath),
|
|
595
|
+
path.relative(planningRoot, roadmapPath)]);
|
|
518
596
|
}
|
|
519
597
|
if (worktreesCreated) {
|
|
520
598
|
_adhocTools(cwd, 'worktrees remove ' + JSON.stringify(slug) + ' --force');
|
|
@@ -522,7 +600,7 @@ function cmdMilestoneCreateAdhoc(cwd, args, raw) {
|
|
|
522
600
|
if (refCreated) {
|
|
523
601
|
_adhocGit(planningRoot, ['update-ref', '-d', baseRef]);
|
|
524
602
|
}
|
|
525
|
-
error(reasonMsg + ' — rolled back (no worktree, no base ref, no STATE marker, active_context unset).');
|
|
603
|
+
error(reasonMsg + ' — rolled back (no versioned dir, no roadmap section, no current_milestone, no worktree, no base ref, no STATE marker, active_context unset).');
|
|
526
604
|
};
|
|
527
605
|
|
|
528
606
|
// ── Step 2 — capture planning base ref (ADH-06). ──
|
|
@@ -549,7 +627,10 @@ function cmdMilestoneCreateAdhoc(cwd, args, raw) {
|
|
|
549
627
|
} catch { entryOk = false; }
|
|
550
628
|
if (!entryOk) rollback('Worktree entry missing adhoc marker / base-ref');
|
|
551
629
|
|
|
552
|
-
// ── Step 5 —
|
|
630
|
+
// ── Step 5 — seed planning scaffolding + commit STATE.md & ROADMAP.md atomically
|
|
631
|
+
// (ADH-04 primary + Decision D). Folds current_milestone (5a), the Active
|
|
632
|
+
// Milestone ROADMAP section (5b), and the versioned phases dir (5c) into ONE
|
|
633
|
+
// commit (5d), all undone in reverse by rollback(). ──
|
|
553
634
|
if (!fs.existsSync(statePath)) rollback('STATE.md not found at ' + statePath);
|
|
554
635
|
try {
|
|
555
636
|
const content = fs.readFileSync(statePath, 'utf-8');
|
|
@@ -558,17 +639,37 @@ function cmdMilestoneCreateAdhoc(cwd, args, raw) {
|
|
|
558
639
|
fm.milestone_name = name;
|
|
559
640
|
fm.status = 'executing';
|
|
560
641
|
fm.adhoc = true;
|
|
642
|
+
// 5a — the authoritative version signal. The verb already validated/normalized
|
|
643
|
+
// `version` to a grammar-valid vX.Y, so create-adhoc is a SOLE setter of it.
|
|
644
|
+
fm.current_milestone = version;
|
|
561
645
|
const newContent = spliceFrontmatter(content, fm);
|
|
562
646
|
fs.writeFileSync(statePath, newContent, 'utf-8');
|
|
563
647
|
} catch (e) {
|
|
564
648
|
rollback('Failed to write STATE.md adhoc marker: ' + e.message);
|
|
565
649
|
}
|
|
650
|
+
|
|
651
|
+
// 5b — seed the ROADMAP active-milestone section (idempotent, guarded).
|
|
652
|
+
seedAdhocRoadmapSection(roadmapPath, version, name);
|
|
653
|
+
|
|
654
|
+
// 5c — create phases/<version>/ via the SHARED versioned-dir creator. Track
|
|
655
|
+
// whether THIS call materialized it so rollback can drop the untracked dir.
|
|
656
|
+
try {
|
|
657
|
+
const r = phaseInitVersionedDirInternal(cwd);
|
|
658
|
+
versionedDirAbs = path.join(cwd, r.directory);
|
|
659
|
+
versionedDirCreated = r.created;
|
|
660
|
+
} catch (e) {
|
|
661
|
+
rollback('Failed to create versioned phases dir: ' + e.message);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// 5d — stage BOTH docs (STATE.md + ROADMAP.md if present) and commit atomically.
|
|
566
665
|
const stateRel = path.relative(planningRoot, statePath);
|
|
567
|
-
const
|
|
568
|
-
if (
|
|
666
|
+
const stagePaths = [stateRel];
|
|
667
|
+
if (fs.existsSync(roadmapPath)) stagePaths.push(path.relative(planningRoot, roadmapPath));
|
|
668
|
+
const addRes = _adhocGit(planningRoot, ['add'].concat(stagePaths));
|
|
669
|
+
if (!addRes.ok) rollback('Failed to stage planning docs: ' + addRes.stderr);
|
|
569
670
|
const commitRes = _adhocGit(planningRoot, ['commit', '-m',
|
|
570
671
|
'feat(milestone): establish ad-hoc container ' + slug + ' (' + version + ')']);
|
|
571
|
-
if (!commitRes.ok) rollback('Failed to commit
|
|
672
|
+
if (!commitRes.ok) rollback('Failed to commit planning docs: ' + commitRes.stderr);
|
|
572
673
|
const hashRes = _adhocGit(planningRoot, ['rev-parse', 'HEAD']);
|
|
573
674
|
stateCommitHash = hashRes.ok ? hashRes.stdout : null;
|
|
574
675
|
|
|
@@ -671,6 +772,20 @@ function cmdAbandonMilestone(cwd, args, raw) {
|
|
|
671
772
|
error('Planning docs have uncommitted/staged edits (' + dirty.stdout.trim() + '). Commit or discard them first, then retry /dgs:abandon-milestone. No changes made.');
|
|
672
773
|
}
|
|
673
774
|
|
|
775
|
+
// ── Decision E — resolve the seeded versioned phases dir (phases/<version>/) EARLY,
|
|
776
|
+
// from the still-present current_milestone (the 6b restore wipes it). It is removed
|
|
777
|
+
// in teardown so abandon leaves no versioned residue. Guarded/non-fatal: a no-op when
|
|
778
|
+
// the version is undeterminable or the dir is absent. ──
|
|
779
|
+
let versionedAbs = null, relVersioned = null;
|
|
780
|
+
try {
|
|
781
|
+
const v = resolveMilestoneVersion(cwd);
|
|
782
|
+
if (v) {
|
|
783
|
+
const base = resolveProjectPath(cwd, 'phases');
|
|
784
|
+
const abs = path.join(planningRoot, base, v);
|
|
785
|
+
if (fs.existsSync(abs)) { versionedAbs = abs; relVersioned = path.relative(planningRoot, abs); }
|
|
786
|
+
}
|
|
787
|
+
} catch { /* undeterminable version — leave versionedAbs null (no-op) */ }
|
|
788
|
+
|
|
674
789
|
// ── Step 5 — delete the OWNED pushed milestone branch on origin (ADH-20).
|
|
675
790
|
// For each repo whose remote-tracking ref exists, run a NON-FATAL
|
|
676
791
|
// `git push origin --delete milestone/<slug>` so the milestone name is
|
|
@@ -706,6 +821,20 @@ function cmdAbandonMilestone(cwd, args, raw) {
|
|
|
706
821
|
'Re-run /dgs:abandon-milestone after resolving, or remove the worktree manually.');
|
|
707
822
|
}
|
|
708
823
|
|
|
824
|
+
// Decision E — remove the seeded versioned phases dir (phases/<version>/) so abandon
|
|
825
|
+
// leaves no versioned residue. Stage any TRACKED phase files for deletion so they land
|
|
826
|
+
// in the 6c attributed reversion commit. Non-fatal/guarded: abandon still reports
|
|
827
|
+
// abandoned:true when the dir was already absent (the seeded dir is normally empty +
|
|
828
|
+
// untracked, so this is a pure on-disk cleanup unless real phases were added/committed).
|
|
829
|
+
let phasesDirRemoved = null;
|
|
830
|
+
if (versionedAbs) {
|
|
831
|
+
try { fs.rmSync(versionedAbs, { recursive: true, force: true }); } catch {}
|
|
832
|
+
if (relVersioned) {
|
|
833
|
+
_adhocGit(planningRoot, ['add', '-A', '--', relVersioned]);
|
|
834
|
+
phasesDirRemoved = relVersioned;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
709
838
|
// 6b. Path-scoped restore of exactly the present docs (ADH-09). NEVER a
|
|
710
839
|
// whole-tree reset / checkout <ref> .
|
|
711
840
|
if (present.length > 0) {
|
|
@@ -720,29 +849,30 @@ function cmdAbandonMilestone(cwd, args, raw) {
|
|
|
720
849
|
reverted.docs = true; // nothing to restore
|
|
721
850
|
}
|
|
722
851
|
|
|
723
|
-
// 6c. Attributed reversion commit (ADH-20).
|
|
724
|
-
//
|
|
852
|
+
// 6c. Attributed reversion commit (ADH-20). The commit guard is the STAGED state,
|
|
853
|
+
// not present.length, so it also fires when the ONLY change is the versioned-dir
|
|
854
|
+
// deletion staged above. Skips gracefully if nothing is staged (base === current
|
|
855
|
+
// for the docs AND no tracked versioned files existed).
|
|
725
856
|
if (present.length > 0) {
|
|
726
857
|
const addRes = _adhocGit(planningRoot, ['add', '--'].concat(present));
|
|
727
858
|
if (!addRes.ok) {
|
|
728
859
|
error('Docs restored but failed to stage them: ' + addRes.stderr +
|
|
729
860
|
'. Reverted so far: ' + JSON.stringify(reverted) + '. Recoverable — stage and commit the restored docs manually.');
|
|
730
861
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
} else {
|
|
742
|
-
// No diff to commit — restore was a no-op (base == current). Treated as success.
|
|
743
|
-
reverted.committed = true;
|
|
862
|
+
}
|
|
863
|
+
const hasStaged = !_adhocGit(planningRoot, ['diff', '--cached', '--quiet']).ok;
|
|
864
|
+
if (hasStaged) {
|
|
865
|
+
const author = formatAuthorString(requireGitIdentity(cwd));
|
|
866
|
+
const commitRes = _adhocGit(planningRoot, ['commit', '--author', author, '-m',
|
|
867
|
+
'revert(milestone): abandon ad-hoc ' + slug + ' — restore project docs to pre-milestone state']);
|
|
868
|
+
reverted.committed = commitRes.ok;
|
|
869
|
+
if (!commitRes.ok) {
|
|
870
|
+
error('Docs restored + staged but commit failed: ' + commitRes.stderr +
|
|
871
|
+
'. Reverted so far: ' + JSON.stringify(reverted) + '. Recoverable — commit the staged docs manually.');
|
|
744
872
|
}
|
|
745
873
|
} else {
|
|
874
|
+
// Nothing staged — restore was a no-op (base == current) and no tracked versioned
|
|
875
|
+
// files existed. Treated as success.
|
|
746
876
|
reverted.committed = true;
|
|
747
877
|
}
|
|
748
878
|
|
|
@@ -761,6 +891,7 @@ function cmdAbandonMilestone(cwd, args, raw) {
|
|
|
761
891
|
base_ref: baseRef,
|
|
762
892
|
restored: present,
|
|
763
893
|
reverted,
|
|
894
|
+
phases_dir_removed: phasesDirRemoved,
|
|
764
895
|
base_ref_deleted: cleanup.ref_deleted,
|
|
765
896
|
warnings,
|
|
766
897
|
}, raw);
|
|
@@ -622,7 +622,10 @@ function addMalformedQuickRow(planDir) {
|
|
|
622
622
|
* disk_status==='complete'; complete=false → PLAN.md only (planned, not counted).
|
|
623
623
|
*/
|
|
624
624
|
function addCompletedPhase(planDir, { complete }) {
|
|
625
|
-
|
|
625
|
+
// setupAdhoc seeds phases/v0.1/ and current_milestone:v0.1, so the version-aware
|
|
626
|
+
// resolver now routes phase lookups into phases/v0.1/ — write the fixture phase
|
|
627
|
+
// there (not the flat phases/) so adhoc-readiness enumerates it.
|
|
628
|
+
const phaseDir = path.join(planDir, 'projects', 'tp', 'phases', 'v0.1', '01-foo');
|
|
626
629
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
627
630
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
628
631
|
if (complete) {
|
|
@@ -854,3 +857,112 @@ describe('milestone cleanup-adhoc / lifecycle cleanup', () => {
|
|
|
854
857
|
assert.ok(/abandon-milestone/.test(doc), 'claude-md.md must mention abandon-milestone');
|
|
855
858
|
});
|
|
856
859
|
});
|
|
860
|
+
|
|
861
|
+
// ─── ad-hoc full lifecycle seeding (quick-260627-mv4) ────────────────────────
|
|
862
|
+
//
|
|
863
|
+
// Proves create-adhoc seeds the planning scaffolding that lets a hand-added
|
|
864
|
+
// phase flow through discuss/plan/execute-phase: current_milestone + the
|
|
865
|
+
// versioned phases dir + an Active Milestone ROADMAP section, all atomic and
|
|
866
|
+
// rolled back together; that the first add-phase lands 01-<slug> UNDER
|
|
867
|
+
// phases/<version>/ (restart-at-1); that the roadmap seed is idempotent; that
|
|
868
|
+
// abandon removes the versioned dir + restores the docs; and that a precondition
|
|
869
|
+
// failure leaves the first milestone's seed intact with no new residue.
|
|
870
|
+
|
|
871
|
+
describe('ad-hoc full lifecycle seeding', () => {
|
|
872
|
+
let env;
|
|
873
|
+
beforeEach(() => { env = createAbandonEnv(); });
|
|
874
|
+
afterEach(() => { if (env) env.cleanup(); });
|
|
875
|
+
|
|
876
|
+
it('SEED: create-adhoc writes current_milestone + phases/<version>/ + Active Milestone section', () => {
|
|
877
|
+
setupAdhoc(env.planDir);
|
|
878
|
+
|
|
879
|
+
// (a) STATE.md frontmatter carries the authoritative current_milestone signal.
|
|
880
|
+
const stateContent = fs.readFileSync(path.join(env.planDir, _STATE_PATH_REL), 'utf-8');
|
|
881
|
+
assert.match(stateContent, /current_milestone:\s*v0\.1\b/, 'STATE.md must carry current_milestone: v0.1');
|
|
882
|
+
|
|
883
|
+
// (b) the versioned phases dir exists on disk.
|
|
884
|
+
assert.ok(
|
|
885
|
+
fs.existsSync(path.join(env.planDir, 'projects', 'tp', 'phases', 'v0.1')),
|
|
886
|
+
'projects/tp/phases/v0.1/ must exist after create-adhoc'
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
// (c) ROADMAP.md has the Active Milestone section terminated by a --- anchor.
|
|
890
|
+
const roadmap = fs.readFileSync(path.join(env.planDir, _ROADMAP_PATH_REL), 'utf-8');
|
|
891
|
+
assert.match(roadmap, /^##\s+Active Milestone:\s*v0\.1\b/m, 'ROADMAP must have ## Active Milestone: v0.1');
|
|
892
|
+
assert.ok(roadmap.trimEnd().endsWith('---'), 'ROADMAP must end with a --- anchor for add-phase');
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it('RESTART-AT-1: first add-phase lands 01-<slug> UNDER phases/v0.1/ (milestone-local)', () => {
|
|
896
|
+
setupAdhoc(env.planDir);
|
|
897
|
+
|
|
898
|
+
const res = abandonRun(env.planDir, 'phase add ' + JSON.stringify('do a thing'));
|
|
899
|
+
assert.equal(res.padded, '01', 'first add-phase under an empty milestone restarts at 01');
|
|
900
|
+
const dirPosix = res.directory.split(path.sep).join('/');
|
|
901
|
+
assert.ok(
|
|
902
|
+
dirPosix.includes('phases/v0.1/01-'),
|
|
903
|
+
'add-phase directory must be under phases/v0.1/ : ' + res.directory
|
|
904
|
+
);
|
|
905
|
+
assert.ok(fs.existsSync(path.join(env.planDir, res.directory)), 'the 01- phase dir must exist on disk');
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it('IDEMPOTENT ROADMAP SEED: exactly one ## Active Milestone: v0.1 after create', () => {
|
|
909
|
+
setupAdhoc(env.planDir);
|
|
910
|
+
const roadmap = fs.readFileSync(path.join(env.planDir, _ROADMAP_PATH_REL), 'utf-8');
|
|
911
|
+
const occurrences = (roadmap.match(/^##\s+Active Milestone:\s*v0\.1\b/gm) || []).length;
|
|
912
|
+
assert.equal(occurrences, 1, 'the Active Milestone section must not be duplicated');
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it('ABANDON REMOVES VERSIONED DIR: phases/v0.1/ gone + docs restored to pre-milestone', () => {
|
|
916
|
+
setupAdhoc(env.planDir);
|
|
917
|
+
const result = abandonRun(env.planDir, 'milestone abandon --confirmed');
|
|
918
|
+
assert.equal(result.abandoned, true);
|
|
919
|
+
// The removed versioned dir is reported.
|
|
920
|
+
assert.match(String(result.phases_dir_removed || ''), /phases[\\/]v0\.1$/, 'phases_dir_removed should name the versioned dir');
|
|
921
|
+
|
|
922
|
+
// Versioned dir is gone from disk.
|
|
923
|
+
assert.ok(
|
|
924
|
+
!fs.existsSync(path.join(env.planDir, 'projects', 'tp', 'phases', 'v0.1')),
|
|
925
|
+
'phases/v0.1/ must be removed by abandon'
|
|
926
|
+
);
|
|
927
|
+
// ROADMAP restored: no Active Milestone section.
|
|
928
|
+
const roadmap = fs.readFileSync(path.join(env.planDir, _ROADMAP_PATH_REL), 'utf-8');
|
|
929
|
+
assert.ok(!/##\s+Active Milestone/.test(roadmap), 'ROADMAP must no longer contain an Active Milestone section');
|
|
930
|
+
// STATE restored: no current_milestone.
|
|
931
|
+
const stateContent = fs.readFileSync(path.join(env.planDir, _STATE_PATH_REL), 'utf-8');
|
|
932
|
+
assert.ok(!/current_milestone:/.test(stateContent), 'STATE.md must no longer carry current_milestone');
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it('ROLLBACK-NO-RESIDUE: a precondition failure leaves the first seed intact and adds nothing', () => {
|
|
936
|
+
const { slug } = setupAdhoc(env.planDir);
|
|
937
|
+
|
|
938
|
+
// A second create-adhoc while a context is active fails at the precondition
|
|
939
|
+
// guard (before any mutation). The first milestone's seed must be untouched
|
|
940
|
+
// and NO second versioned dir / dirty doc residue may appear.
|
|
941
|
+
const out = abandonRunFail(env.planDir, 'milestone create-adhoc ' + JSON.stringify('second one') + ' --version v0.2');
|
|
942
|
+
assert.ok(out, 'second create-adhoc must fail while a context is active');
|
|
943
|
+
|
|
944
|
+
// First seed intact.
|
|
945
|
+
const stateContent = fs.readFileSync(path.join(env.planDir, _STATE_PATH_REL), 'utf-8');
|
|
946
|
+
assert.match(stateContent, /current_milestone:\s*v0\.1\b/, 'first current_milestone must survive');
|
|
947
|
+
assert.ok(fs.existsSync(path.join(env.planDir, 'projects', 'tp', 'phases', 'v0.1')), 'first phases/v0.1/ must survive');
|
|
948
|
+
|
|
949
|
+
// No residue from the failed attempt.
|
|
950
|
+
assert.ok(!fs.existsSync(path.join(env.planDir, 'projects', 'tp', 'phases', 'v0.2')), 'no phases/v0.2/ residue');
|
|
951
|
+
const roadmap = fs.readFileSync(path.join(env.planDir, _ROADMAP_PATH_REL), 'utf-8');
|
|
952
|
+
assert.equal(
|
|
953
|
+
(roadmap.match(/^##\s+Active Milestone:/gm) || []).length, 1,
|
|
954
|
+
'no duplicate / second Active Milestone section from the failed attempt'
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
// active_context unchanged.
|
|
958
|
+
const cfg = readLocal(env.planDir);
|
|
959
|
+
assert.equal(cfg.execution.active_context, slug, 'active_context must be unchanged after a failed create');
|
|
960
|
+
|
|
961
|
+
// Planning tree carries no dirty STATE.md/ROADMAP.md residue (rollback no-residue guarantee).
|
|
962
|
+
const porcelain = _execSync(
|
|
963
|
+
'git status --porcelain -- ' + JSON.stringify(_STATE_PATH_REL) + ' ' + JSON.stringify(_ROADMAP_PATH_REL),
|
|
964
|
+
{ cwd: env.planDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
965
|
+
).trim();
|
|
966
|
+
assert.equal(porcelain, '', 'STATE.md/ROADMAP.md must not be left dirty after a failed create-adhoc');
|
|
967
|
+
});
|
|
968
|
+
});
|
|
@@ -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);
|