@ktpartners/dgs-platform 2.9.0 → 3.3.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.
Files changed (166) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/README.md +34 -2
  3. package/agents/dgs-executor.md +124 -3
  4. package/agents/dgs-idea-researcher.md +447 -0
  5. package/agents/dgs-plan-checker.md +61 -3
  6. package/agents/dgs-planner.md +51 -8
  7. package/bin/install.js +44 -0
  8. package/commands/dgs/abandon-quick.md +28 -0
  9. package/commands/dgs/add-tests.md +2 -2
  10. package/commands/dgs/audit-milestone.md +4 -3
  11. package/commands/dgs/capture-principle.md +11 -11
  12. package/commands/dgs/cleanup.md +2 -2
  13. package/commands/dgs/complete-milestone.md +11 -11
  14. package/commands/dgs/complete-quick.md +28 -0
  15. package/commands/dgs/create-milestone-job.md +2 -2
  16. package/commands/dgs/debug.md +3 -3
  17. package/commands/dgs/develop-idea.md +1 -1
  18. package/commands/dgs/diff-report.md +124 -0
  19. package/commands/dgs/fast.md +3 -1
  20. package/commands/dgs/health.md +1 -1
  21. package/commands/dgs/map-codebase.md +6 -6
  22. package/commands/dgs/new-milestone.md +5 -5
  23. package/commands/dgs/new-project.md +8 -21
  24. package/commands/dgs/package-scan.md +43 -0
  25. package/commands/dgs/plan-milestone-gaps.md +1 -1
  26. package/commands/dgs/progress.md +3 -3
  27. package/commands/dgs/quick-abandon.md +8 -0
  28. package/commands/dgs/quick-complete.md +8 -0
  29. package/commands/dgs/quick.md +10 -3
  30. package/commands/dgs/research-idea.md +3 -2
  31. package/commands/dgs/research-phase.md +3 -3
  32. package/commands/dgs/switch-project.md +14 -1
  33. package/commands/dgs/write-spec.md +3 -3
  34. package/deliver-great-systems/bin/dgs-tools.cjs +401 -32
  35. package/deliver-great-systems/bin/lib/audit-tolerance.cjs +77 -0
  36. package/deliver-great-systems/bin/lib/audit-tolerance.test.cjs +101 -0
  37. package/deliver-great-systems/bin/lib/commands.cjs +626 -46
  38. package/deliver-great-systems/bin/lib/commands.test.cjs +451 -0
  39. package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
  40. package/deliver-great-systems/bin/lib/config.cjs +80 -6
  41. package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
  42. package/deliver-great-systems/bin/lib/context.cjs +120 -0
  43. package/deliver-great-systems/bin/lib/core.cjs +35 -14
  44. package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
  45. package/deliver-great-systems/bin/lib/execution.cjs +49 -17
  46. package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
  47. package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
  48. package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
  49. package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
  50. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
  51. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
  52. package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
  53. package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
  54. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
  55. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
  56. package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
  57. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
  58. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
  59. package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
  60. package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
  61. package/deliver-great-systems/bin/lib/governance.cjs +211 -0
  62. package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
  63. package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
  64. package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
  65. package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
  66. package/deliver-great-systems/bin/lib/init.cjs +357 -61
  67. package/deliver-great-systems/bin/lib/init.test.cjs +625 -8
  68. package/deliver-great-systems/bin/lib/jobs.cjs +131 -25
  69. package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
  70. package/deliver-great-systems/bin/lib/migration.cjs +409 -1
  71. package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
  72. package/deliver-great-systems/bin/lib/milestone.cjs +154 -31
  73. package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
  74. package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
  75. package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
  76. package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
  77. package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
  78. package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
  79. package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
  80. package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
  81. package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
  82. package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
  83. package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
  84. package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
  85. package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
  86. package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
  87. package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
  88. package/deliver-great-systems/bin/lib/phase.cjs +146 -3
  89. package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
  90. package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
  91. package/deliver-great-systems/bin/lib/projects.cjs +65 -10
  92. package/deliver-great-systems/bin/lib/projects.test.cjs +198 -2
  93. package/deliver-great-systems/bin/lib/quick.cjs +739 -0
  94. package/deliver-great-systems/bin/lib/quick.test.cjs +730 -0
  95. package/deliver-great-systems/bin/lib/repos.cjs +37 -13
  96. package/deliver-great-systems/bin/lib/review.cjs +1821 -0
  97. package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
  98. package/deliver-great-systems/bin/lib/specs.cjs +3 -81
  99. package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
  100. package/deliver-great-systems/bin/lib/state.cjs +147 -55
  101. package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
  102. package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
  103. package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
  104. package/deliver-great-systems/bin/lib/sync.cjs +75 -0
  105. package/deliver-great-systems/bin/lib/verify.cjs +198 -7
  106. package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
  107. package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
  108. package/deliver-great-systems/bin/lib/worktrees.cjs +790 -0
  109. package/deliver-great-systems/bin/lib/worktrees.test.cjs +963 -0
  110. package/deliver-great-systems/references/agent-step-reliability.md +60 -0
  111. package/deliver-great-systems/references/conflict-resolution.md +4 -0
  112. package/deliver-great-systems/references/context-tiers.md +4 -0
  113. package/deliver-great-systems/references/package-scan-config.md +151 -0
  114. package/deliver-great-systems/references/questioning.md +0 -30
  115. package/deliver-great-systems/references/spec-review-loop.md +1 -2
  116. package/deliver-great-systems/references/workflow-conventions.md +29 -0
  117. package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
  118. package/deliver-great-systems/templates/REVIEW.md +35 -0
  119. package/deliver-great-systems/templates/VALIDATION.md +1 -1
  120. package/deliver-great-systems/templates/claude-md.md +27 -0
  121. package/deliver-great-systems/templates/package-scan-report.md +108 -0
  122. package/deliver-great-systems/templates/project.md +6 -170
  123. package/deliver-great-systems/templates/summary.md +3 -1
  124. package/deliver-great-systems/workflows/abandon-quick.md +89 -0
  125. package/deliver-great-systems/workflows/add-idea.md +3 -3
  126. package/deliver-great-systems/workflows/add-phase.md +5 -0
  127. package/deliver-great-systems/workflows/add-tests.md +14 -0
  128. package/deliver-great-systems/workflows/add-todo.md +1 -0
  129. package/deliver-great-systems/workflows/approve-spec.md +25 -4
  130. package/deliver-great-systems/workflows/audit-milestone.md +66 -10
  131. package/deliver-great-systems/workflows/audit-phase.md +15 -5
  132. package/deliver-great-systems/workflows/cancel-job.md +2 -2
  133. package/deliver-great-systems/workflows/check-todos.md +2 -3
  134. package/deliver-great-systems/workflows/codereview.md +103 -9
  135. package/deliver-great-systems/workflows/complete-milestone.md +218 -24
  136. package/deliver-great-systems/workflows/complete-quick.md +106 -0
  137. package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
  138. package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
  139. package/deliver-great-systems/workflows/develop-idea.md +11 -11
  140. package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
  141. package/deliver-great-systems/workflows/discuss-idea.md +1 -1
  142. package/deliver-great-systems/workflows/discuss-phase.md +3 -2
  143. package/deliver-great-systems/workflows/execute-phase.md +209 -33
  144. package/deliver-great-systems/workflows/execute-plan.md +22 -22
  145. package/deliver-great-systems/workflows/help.md +53 -20
  146. package/deliver-great-systems/workflows/import-spec.md +65 -7
  147. package/deliver-great-systems/workflows/init-product.md +45 -167
  148. package/deliver-great-systems/workflows/new-milestone.md +140 -33
  149. package/deliver-great-systems/workflows/new-project.md +60 -331
  150. package/deliver-great-systems/workflows/package-scan.md +59 -0
  151. package/deliver-great-systems/workflows/plan-phase.md +79 -1
  152. package/deliver-great-systems/workflows/progress-all.md +133 -0
  153. package/deliver-great-systems/workflows/quick-abandon.md +89 -0
  154. package/deliver-great-systems/workflows/quick-complete.md +106 -0
  155. package/deliver-great-systems/workflows/quick.md +328 -26
  156. package/deliver-great-systems/workflows/refine-spec.md +1 -1
  157. package/deliver-great-systems/workflows/research-idea.md +77 -139
  158. package/deliver-great-systems/workflows/resume-project.md +2 -2
  159. package/deliver-great-systems/workflows/run-job.md +29 -43
  160. package/deliver-great-systems/workflows/settings.md +13 -77
  161. package/deliver-great-systems/workflows/validate-phase.md +39 -1
  162. package/deliver-great-systems/workflows/verify-work.md +14 -0
  163. package/deliver-great-systems/workflows/write-spec.md +11 -13
  164. package/hooks/dist/dgs-enforce-discipline.js +196 -0
  165. package/package.json +1 -1
  166. package/scripts/build-hooks.js +1 -0
@@ -42,9 +42,11 @@ function createProjectSubfolder(cwd, slug, name, options) {
42
42
  // Create project directory
43
43
  fs.mkdirSync(projectDir, { recursive: true });
44
44
 
45
- // Create standard subdirectories
45
+ // Create standard subdirectories with .gitkeep so git tracks empty folders
46
46
  for (const dir of STANDARD_DIRS) {
47
- fs.mkdirSync(path.join(projectDir, dir), { recursive: true });
47
+ const dirPath = path.join(projectDir, dir);
48
+ fs.mkdirSync(dirPath, { recursive: true });
49
+ fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
48
50
  }
49
51
 
50
52
  // Create standard files with templates
@@ -163,21 +165,25 @@ function scanProjectReposTags(cwd, slug) {
163
165
  return Array.from(allRepos).sort();
164
166
  }
165
167
 
166
- // ─── PROJECTS.md Regeneration ───────────────────────────────────────────────
168
+ // ─── Projects Readonly Listing ──────────────────────────────────────────────
167
169
 
168
170
  /**
169
- * Regenerate PROJECTS.md by scanning all project subfolders.
171
+ * Enumerate all project subfolders and build a { projects, warnings } result
172
+ * WITHOUT writing PROJECTS.md. Pure read function — no side effects.
170
173
  *
171
- * Reads each project's STATE.md for status/phase/progress, scans plan
172
- * <repos> tags for repos touched, and writes the derived PROJECTS.md.
174
+ * Thin-skeleton projects (PROJECT.md present, STATE.md absent) yield a placeholder
175
+ * entry with status "No milestone yet" and emit no warning — this is the post-thin-skeleton
176
+ * contract where /dgs:new-project writes only PROJECT.md and STATE.md is created later
177
+ * by /dgs:new-milestone.
173
178
  *
174
- * Ghost project guard: projects whose STATE.md is missing or unreadable
175
- * are warned about and omitted from the output.
179
+ * Ghost-project guard (defensive): a directory with neither PROJECT.md nor STATE.md is
180
+ * normally filtered out by getProjectFolders before reaching here. If a future caller
181
+ * passes such a slug directly, the legacy warning is preserved and the entry is omitted.
176
182
  *
177
183
  * @param {string} cwd - Working directory (product root)
178
184
  * @returns {{ projects: Array, warnings: string[] }}
179
185
  */
180
- function regenerateProjectsMd(cwd) {
186
+ function listProjectsReadonly(cwd) {
181
187
  const slugs = getProjectFolders(cwd);
182
188
  const warnings = [];
183
189
  const projects = [];
@@ -185,7 +191,22 @@ function regenerateProjectsMd(cwd) {
185
191
  for (const slug of slugs) {
186
192
  const state = readProjectState(cwd, slug);
187
193
  if (!state) {
188
- warnings.push(`Ghost project: ${slug} (STATE.md missing or unreadable)`);
194
+ // No STATE.md (or unreadable): if PROJECT.md is present, treat as thin-skeleton
195
+ // and emit a placeholder row. Only warn when neither marker is present (defensive
196
+ // guard for direct callers that bypass getProjectFolders).
197
+ const projectMdPath = path.join(getProjectDir(cwd, slug), 'PROJECT.md');
198
+ if (fs.existsSync(projectMdPath)) {
199
+ projects.push({
200
+ name: slug,
201
+ status: 'No milestone yet',
202
+ repos_touched: '',
203
+ current_phase: '',
204
+ completed_date: '',
205
+ progress: '',
206
+ });
207
+ } else {
208
+ warnings.push(`Ghost project: ${slug} (STATE.md missing or unreadable)`);
209
+ }
189
210
  continue;
190
211
  }
191
212
 
@@ -200,6 +221,25 @@ function regenerateProjectsMd(cwd) {
200
221
  });
201
222
  }
202
223
 
224
+ return { projects, warnings };
225
+ }
226
+
227
+ // ─── PROJECTS.md Regeneration ───────────────────────────────────────────────
228
+
229
+ /**
230
+ * Regenerate PROJECTS.md by scanning all project subfolders.
231
+ *
232
+ * Reads each project's STATE.md for status/phase/progress, scans plan
233
+ * <repos> tags for repos touched, and writes the derived PROJECTS.md.
234
+ *
235
+ * Delegates the read phase to listProjectsReadonly.
236
+ *
237
+ * @param {string} cwd - Working directory (product root)
238
+ * @returns {{ projects: Array, warnings: string[] }}
239
+ */
240
+ function regenerateProjectsMd(cwd) {
241
+ const { projects, warnings } = listProjectsReadonly(cwd);
242
+
203
243
  // Write PROJECTS.md
204
244
  let content = '# Projects\n\n';
205
245
  content += '## Active\n\n';
@@ -652,6 +692,20 @@ function cmdProjectsReactivate(cwd, options, raw) {
652
692
  const regenResult = regenerateProjectsMd(cwd);
653
693
  const remainingActive = regenResult.projects.filter(p => !p.status.toLowerCase().includes('completed'));
654
694
 
695
+ // Commit STATE.md and PROJECTS.md so reactivation is persisted
696
+ const planningRoot = getPlanningRoot(cwd);
697
+ const projectDir = getProjectDir(cwd, slug);
698
+ const statePath = path.join(projectDir, 'STATE.md');
699
+ const projectsPath = path.join(planningRoot, 'PROJECTS.md');
700
+ const filesToCommit = [statePath, projectsPath].filter(f => fs.existsSync(f));
701
+ try {
702
+ const { execSync } = require('child_process');
703
+ for (const f of filesToCommit) {
704
+ execSync(`git add "${f}"`, { cwd: planningRoot, stdio: 'pipe' });
705
+ }
706
+ execSync(`git commit -m "docs: reactivate project ${slug}" --allow-empty`, { cwd: planningRoot, stdio: 'pipe' });
707
+ } catch { /* commit may fail if nothing changed — safe to ignore */ }
708
+
655
709
  output({ reactivated: true, slug, set_as_current: !!opts.set_current, remaining_active: remainingActive.length }, raw);
656
710
  }
657
711
 
@@ -677,6 +731,7 @@ module.exports = {
677
731
  createProjectSubfolder,
678
732
  readProjectState,
679
733
  scanProjectReposTags,
734
+ listProjectsReadonly,
680
735
  regenerateProjectsMd,
681
736
  completeProject,
682
737
  reactivateProject,
@@ -11,22 +11,57 @@ const os = require('os');
11
11
 
12
12
  const { createTempDir, cleanupDir, createFixture , initGitRepo } = require('./test-helpers.cjs');
13
13
  const { resetPaths } = require('./paths.cjs');
14
- const { getProjectRoot } = require('./core.cjs');
14
+ const { getProjectRoot, loadConfig } = require('./core.cjs');
15
15
 
16
16
  // Helper: create projects/ directory structure
17
17
  function setupPlanning(cwd) {
18
18
  fs.mkdirSync(path.join(cwd, 'projects'), { recursive: true });
19
19
  }
20
20
 
21
- // Helper: create a project subfolder with STATE.md manually under projects/<slug>/
21
+ // Helper: create a fully-scaffolded project subfolder under projects/<slug>/
22
+ // (writes PROJECT.md as the canonical project marker plus optional STATE.md for milestone state)
22
23
  function createProjectManually(cwd, slug, stateContent) {
23
24
  const projDir = path.join(cwd, 'projects', slug);
24
25
  fs.mkdirSync(projDir, { recursive: true });
26
+ fs.writeFileSync(path.join(projDir, 'PROJECT.md'), `# Project: ${slug}\n`);
25
27
  if (stateContent) {
26
28
  fs.writeFileSync(path.join(projDir, 'STATE.md'), stateContent);
27
29
  }
28
30
  }
29
31
 
32
+ // Helper: create a thin-skeleton project (PROJECT.md only, no STATE.md) — mirrors what
33
+ // /dgs:new-project writes before /dgs:new-milestone creates STATE.md.
34
+ function createThinSkeletonProject(cwd, slug) {
35
+ const projDir = path.join(cwd, 'projects', slug);
36
+ fs.mkdirSync(projDir, { recursive: true });
37
+ fs.writeFileSync(path.join(projDir, 'PROJECT.md'), `# Project: ${slug}\n`);
38
+ }
39
+
40
+ // Capture stdout helper for cmdProjects* CLI calls (mirrors commands.test.cjs:48-70).
41
+ function captureStdout(fn) {
42
+ const chunks = [];
43
+ const origWrite = process.stdout.write.bind(process.stdout);
44
+ const origExit = process.exit;
45
+ let exitCode = null;
46
+ process.stdout.write = (data) => { chunks.push(String(data)); return true; };
47
+ process.exit = (code) => {
48
+ exitCode = code == null ? 0 : code;
49
+ throw new Error('__EXIT__');
50
+ };
51
+ try {
52
+ fn();
53
+ } catch (e) {
54
+ if (e && e.message !== '__EXIT__') throw e;
55
+ } finally {
56
+ process.stdout.write = origWrite;
57
+ process.exit = origExit;
58
+ }
59
+ const stdout = chunks.join('');
60
+ let json = null;
61
+ try { json = JSON.parse(stdout); } catch { /* not JSON */ }
62
+ return { stdout, exitCode, json };
63
+ }
64
+
30
65
  // Helper: create plan file with <repos> tags under projects/<slug>/phases/
31
66
  function createPlanFile(cwd, slug, phaseDir, planName, content) {
32
67
  const dir = path.join(cwd, 'projects', slug, 'phases', phaseDir);
@@ -39,10 +74,12 @@ const {
39
74
  readProjectState,
40
75
  scanProjectReposTags,
41
76
  regenerateProjectsMd,
77
+ listProjectsReadonly,
42
78
  completeProject,
43
79
  reactivateProject,
44
80
  parseProjectsMd,
45
81
  checkSlugPrefixCollision,
82
+ cmdProjectsSwitch,
46
83
  } = require('./projects.cjs');
47
84
 
48
85
  // ─── createProjectSubfolder ─────────────────────────────────────────────────
@@ -321,6 +358,131 @@ describe('regenerateProjectsMd', () => {
321
358
  const content = fs.readFileSync(path.join(tmpDir, 'PROJECTS.md'), 'utf-8');
322
359
  assert.ok(content.startsWith('# Projects'));
323
360
  });
361
+
362
+ it('writes thin-skeleton placeholder row into Active table', () => {
363
+ createThinSkeletonProject(tmpDir, 'word-gen');
364
+ regenerateProjectsMd(tmpDir);
365
+ const content = fs.readFileSync(path.join(tmpDir, 'PROJECTS.md'), 'utf-8');
366
+ const [activeSection, completedSection] = content.split('## Completed');
367
+ // Active row should match: | word-gen | No milestone yet | | |
368
+ assert.match(activeSection, /\|\s*word-gen\s*\|\s*No milestone yet\s*\|\s*\|\s*\|/);
369
+ // word-gen must NOT appear in the Completed section
370
+ assert.ok(!completedSection.includes('word-gen'), 'thin-skeleton project must not appear in Completed section');
371
+ });
372
+ });
373
+
374
+ // ─── listProjectsReadonly ───────────────────────────────────────────────────
375
+
376
+ describe('listProjectsReadonly', () => {
377
+ let tmpDir;
378
+
379
+ beforeEach(() => {
380
+ tmpDir = createTempDir(); tmpDir = fs.realpathSync(tmpDir); initGitRepo(tmpDir);
381
+ setupPlanning(tmpDir);
382
+ });
383
+
384
+ afterEach(() => { resetPaths(); cleanupDir(tmpDir); });
385
+
386
+ it('returns same projects shape as regenerateProjectsMd for a fixture with 2 active + 1 completed', () => {
387
+ createProjectManually(tmpDir, 'active-a', '# Project State\n\nPhase: 1\nStatus: In progress\nProgress: [##--------] 20%\n');
388
+ createProjectManually(tmpDir, 'active-b', '# Project State\n\nPhase: 3\nStatus: Active\nProgress: [#####-----] 50%\n');
389
+ createProjectManually(tmpDir, 'done-c', '# Project State\n\nPhase: 10\nStatus: completed\nProgress: [##########] 100%\nCompleted: 2026-02-15\n');
390
+
391
+ const readResult = listProjectsReadonly(tmpDir);
392
+ const regenResult = regenerateProjectsMd(tmpDir);
393
+
394
+ assert.ok(Array.isArray(readResult.projects));
395
+ assert.ok(Array.isArray(readResult.warnings));
396
+ assert.strictEqual(readResult.projects.length, regenResult.projects.length);
397
+ // Projects should have identical fields
398
+ const readByName = Object.fromEntries(readResult.projects.map(p => [p.name, p]));
399
+ const regenByName = Object.fromEntries(regenResult.projects.map(p => [p.name, p]));
400
+ for (const name of Object.keys(readByName)) {
401
+ assert.deepStrictEqual(readByName[name], regenByName[name]);
402
+ }
403
+ });
404
+
405
+ it('does NOT write PROJECTS.md', () => {
406
+ createProjectManually(tmpDir, 'proj-a', '# Project State\n\nPhase: 2\nStatus: Active\n');
407
+ const projectsMdPath = path.join(tmpDir, 'PROJECTS.md');
408
+ assert.strictEqual(fs.existsSync(projectsMdPath), false);
409
+
410
+ const result = listProjectsReadonly(tmpDir);
411
+ assert.ok(Array.isArray(result.projects));
412
+
413
+ assert.strictEqual(fs.existsSync(projectsMdPath), false, 'listProjectsReadonly must not create PROJECTS.md');
414
+ });
415
+
416
+ it('does NOT modify an existing PROJECTS.md', () => {
417
+ createProjectManually(tmpDir, 'proj-a', '# Project State\n\nPhase: 2\nStatus: Active\n');
418
+ const projectsMdPath = path.join(tmpDir, 'PROJECTS.md');
419
+ const sentinel = '# Projects\n\nSENTINEL CONTENT — should not be overwritten by listProjectsReadonly\n';
420
+ fs.writeFileSync(projectsMdPath, sentinel);
421
+
422
+ listProjectsReadonly(tmpDir);
423
+
424
+ const after = fs.readFileSync(projectsMdPath, 'utf-8');
425
+ assert.strictEqual(after, sentinel, 'listProjectsReadonly must not modify existing PROJECTS.md');
426
+ });
427
+
428
+ it('returns a project entry with all expected fields', () => {
429
+ createProjectManually(tmpDir, 'proj-a', '# Project State\n\nPhase: 5\nStatus: Active\nProgress: [###-------] 30%\n');
430
+ const result = listProjectsReadonly(tmpDir);
431
+ assert.strictEqual(result.projects.length, 1);
432
+ const p = result.projects[0];
433
+ assert.strictEqual(p.name, 'proj-a');
434
+ assert.strictEqual(p.status, 'Active');
435
+ assert.strictEqual(p.current_phase, '5');
436
+ assert.strictEqual(p.progress, 30);
437
+ assert.strictEqual(typeof p.repos_touched, 'string');
438
+ assert.ok('completed_date' in p);
439
+ });
440
+
441
+ it('returns ghost-project behavior consistent with regenerateProjectsMd (omits projects without STATE.md)', () => {
442
+ // Ghost project: directory exists but no STATE.md
443
+ fs.mkdirSync(path.join(tmpDir, 'projects', 'ghost-project'), { recursive: true });
444
+ // Real project
445
+ createProjectManually(tmpDir, 'real-project', '# State\n\nStatus: Active\n');
446
+
447
+ const result = listProjectsReadonly(tmpDir);
448
+ assert.ok(Array.isArray(result.warnings));
449
+ assert.ok(!result.projects.some(p => p.name === 'ghost-project'), 'ghost project should be omitted from projects array');
450
+ });
451
+
452
+ it('empty projects directory returns { projects: [], warnings: [] }', () => {
453
+ const result = listProjectsReadonly(tmpDir);
454
+ assert.deepStrictEqual(result.projects, []);
455
+ assert.ok(Array.isArray(result.warnings));
456
+ });
457
+
458
+ it('returns placeholder entry with status "No milestone yet" for thin-skeleton (PROJECT.md only) and emits no warning for that slug', () => {
459
+ createThinSkeletonProject(tmpDir, 'word-gen');
460
+ const result = listProjectsReadonly(tmpDir);
461
+ assert.strictEqual(result.projects.length, 1);
462
+ assert.deepStrictEqual(result.projects[0], {
463
+ name: 'word-gen',
464
+ status: 'No milestone yet',
465
+ repos_touched: '',
466
+ current_phase: '',
467
+ completed_date: '',
468
+ progress: '',
469
+ });
470
+ assert.ok(Array.isArray(result.warnings));
471
+ assert.ok(!result.warnings.some(w => w.includes('word-gen')), 'no warning should mention word-gen');
472
+ });
473
+
474
+ it('preserves ghost-warning code path when neither PROJECT.md nor STATE.md exists', () => {
475
+ // Genuine ghost: no marker files at all — getProjectFolders filters it out, so it
476
+ // never reaches listProjectsReadonly's loop. Real project alongside to keep suite non-empty.
477
+ fs.mkdirSync(path.join(tmpDir, 'projects', 'ghost'), { recursive: true });
478
+ createProjectManually(tmpDir, 'real-project', '# State\n\nStatus: Active\n');
479
+
480
+ const result = listProjectsReadonly(tmpDir);
481
+ assert.ok(Array.isArray(result.warnings));
482
+ assert.ok(!result.projects.some(p => p.name === 'ghost'), 'ghost dir should be omitted from projects array');
483
+ // The warnings array remains a defensive code path; legacy callers may still trip it.
484
+ // No assertion on warnings content here — discovery layer (getProjectFolders) handles ghost exclusion.
485
+ });
324
486
  });
325
487
 
326
488
  // ─── completeProject ────────────────────────────────────────────────────────
@@ -857,6 +1019,7 @@ describe('cmdProjectsSwitch guards', () => {
857
1019
  'config.json': JSON.stringify({ current_project: 'finished-proj' }),
858
1020
  'PROJECTS.md': '# Projects\n\n## Active\n\n| Project | Status | Repos Touched | Current Phase |\n|---------|--------|---------------|---------------|\n\n## Completed\n\n| Project | Completed | Duration |\n|---------|-----------|----------|\n| finished-proj | 2026-02-20 | |\n',
859
1021
  'REPOS.md': '# Repos\n\n| Name | Path |\n',
1022
+ 'projects/finished-proj/PROJECT.md': '# Project: Finished Proj\n',
860
1023
  'projects/finished-proj/STATE.md': '# Project State\n\nStatus: completed\nCompleted: 2026-02-20\n',
861
1024
  });
862
1025
 
@@ -870,3 +1033,36 @@ describe('cmdProjectsSwitch guards', () => {
870
1033
  }
871
1034
  });
872
1035
  });
1036
+
1037
+ // ─── cmdProjectsSwitch thin-skeleton ────────────────────────────────────────
1038
+
1039
+ describe('cmdProjectsSwitch thin-skeleton', () => {
1040
+ let tmpDir;
1041
+
1042
+ beforeEach(() => {
1043
+ tmpDir = createTempDir(); tmpDir = fs.realpathSync(tmpDir); initGitRepo(tmpDir);
1044
+ setupPlanning(tmpDir);
1045
+ });
1046
+
1047
+ afterEach(() => { resetPaths(); cleanupDir(tmpDir); });
1048
+
1049
+ it('cmdProjectsSwitch succeeds on a thin-skeleton slug and writes current_project to config.json', () => {
1050
+ // v2 prerequisites: config.json, PROJECTS.md, REPOS.md at planning root,
1051
+ // plus the thin-skeleton project under projects/<slug>/PROJECT.md
1052
+ fs.writeFileSync(path.join(tmpDir, 'config.json'), '{}');
1053
+ fs.writeFileSync(path.join(tmpDir, 'PROJECTS.md'), '# Projects\n');
1054
+ fs.writeFileSync(path.join(tmpDir, 'REPOS.md'), '# Repos\n');
1055
+ createThinSkeletonProject(tmpDir, 'word-gen');
1056
+
1057
+ const { exitCode } = captureStdout(() => {
1058
+ cmdProjectsSwitch(tmpDir, 'word-gen', true);
1059
+ });
1060
+
1061
+ // exitCode 0 (or unset null = success path that did not throw) signals success
1062
+ assert.ok(exitCode === 0 || exitCode === null, `expected success exit, got ${exitCode}`);
1063
+
1064
+ // config.json must now contain current_project: word-gen
1065
+ const config = loadConfig(tmpDir);
1066
+ assert.strictEqual(config.current_project, 'word-gen');
1067
+ });
1068
+ });