@ktpartners/dgs-platform 3.0.4 → 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 (115) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +8 -1
  3. package/agents/dgs-executor.md +124 -3
  4. package/agents/dgs-idea-researcher.md +447 -0
  5. package/agents/dgs-plan-checker.md +32 -0
  6. package/agents/dgs-planner.md +41 -8
  7. package/bin/install.js +44 -0
  8. package/commands/dgs/audit-milestone.md +2 -1
  9. package/commands/dgs/diff-report.md +124 -0
  10. package/commands/dgs/new-project.md +8 -21
  11. package/commands/dgs/package-scan.md +43 -0
  12. package/commands/dgs/research-idea.md +1 -0
  13. package/commands/dgs/switch-project.md +13 -0
  14. package/deliver-great-systems/bin/dgs-tools.cjs +120 -5
  15. package/deliver-great-systems/bin/lib/audit-tolerance.cjs +77 -0
  16. package/deliver-great-systems/bin/lib/audit-tolerance.test.cjs +101 -0
  17. package/deliver-great-systems/bin/lib/commands.cjs +311 -16
  18. package/deliver-great-systems/bin/lib/commands.test.cjs +115 -0
  19. package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
  20. package/deliver-great-systems/bin/lib/config.cjs +41 -0
  21. package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
  22. package/deliver-great-systems/bin/lib/core.cjs +7 -3
  23. package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
  24. package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
  25. package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
  26. package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
  27. package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
  28. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
  29. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
  30. package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
  31. package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
  32. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
  33. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
  34. package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
  35. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
  36. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
  37. package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
  38. package/deliver-great-systems/bin/lib/governance.cjs +211 -0
  39. package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
  40. package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
  41. package/deliver-great-systems/bin/lib/init.cjs +56 -27
  42. package/deliver-great-systems/bin/lib/init.test.cjs +212 -5
  43. package/deliver-great-systems/bin/lib/jobs.cjs +7 -4
  44. package/deliver-great-systems/bin/lib/milestone.cjs +101 -3
  45. package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
  46. package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
  47. package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
  48. package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
  49. package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
  50. package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
  51. package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
  52. package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
  53. package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
  54. package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
  55. package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
  56. package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
  57. package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
  58. package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
  59. package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
  60. package/deliver-great-systems/bin/lib/phase.cjs +18 -1
  61. package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
  62. package/deliver-great-systems/bin/lib/projects.cjs +38 -3
  63. package/deliver-great-systems/bin/lib/projects.test.cjs +112 -2
  64. package/deliver-great-systems/bin/lib/quick.cjs +178 -23
  65. package/deliver-great-systems/bin/lib/quick.test.cjs +138 -4
  66. package/deliver-great-systems/bin/lib/repos.cjs +12 -12
  67. package/deliver-great-systems/bin/lib/review.cjs +1821 -0
  68. package/deliver-great-systems/bin/lib/state.cjs +7 -3
  69. package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
  70. package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
  71. package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
  72. package/deliver-great-systems/bin/lib/verify.cjs +118 -6
  73. package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
  74. package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
  75. package/deliver-great-systems/bin/lib/worktrees.cjs +27 -1
  76. package/deliver-great-systems/bin/lib/worktrees.test.cjs +76 -0
  77. package/deliver-great-systems/references/agent-step-reliability.md +60 -0
  78. package/deliver-great-systems/references/conflict-resolution.md +4 -0
  79. package/deliver-great-systems/references/context-tiers.md +4 -0
  80. package/deliver-great-systems/references/package-scan-config.md +151 -0
  81. package/deliver-great-systems/references/questioning.md +0 -30
  82. package/deliver-great-systems/references/spec-review-loop.md +1 -2
  83. package/deliver-great-systems/references/workflow-conventions.md +29 -0
  84. package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
  85. package/deliver-great-systems/templates/REVIEW.md +35 -0
  86. package/deliver-great-systems/templates/VALIDATION.md +1 -1
  87. package/deliver-great-systems/templates/claude-md.md +11 -0
  88. package/deliver-great-systems/templates/package-scan-report.md +108 -0
  89. package/deliver-great-systems/templates/project.md +6 -170
  90. package/deliver-great-systems/templates/summary.md +3 -1
  91. package/deliver-great-systems/workflows/add-phase.md +5 -0
  92. package/deliver-great-systems/workflows/audit-milestone.md +66 -10
  93. package/deliver-great-systems/workflows/cancel-job.md +1 -1
  94. package/deliver-great-systems/workflows/codereview.md +103 -9
  95. package/deliver-great-systems/workflows/complete-milestone.md +26 -7
  96. package/deliver-great-systems/workflows/complete-quick.md +40 -2
  97. package/deliver-great-systems/workflows/discuss-phase.md +3 -2
  98. package/deliver-great-systems/workflows/execute-phase.md +89 -2
  99. package/deliver-great-systems/workflows/execute-plan.md +10 -1
  100. package/deliver-great-systems/workflows/help.md +51 -18
  101. package/deliver-great-systems/workflows/import-spec.md +65 -7
  102. package/deliver-great-systems/workflows/init-product.md +46 -152
  103. package/deliver-great-systems/workflows/new-milestone.md +115 -14
  104. package/deliver-great-systems/workflows/new-project.md +60 -331
  105. package/deliver-great-systems/workflows/package-scan.md +59 -0
  106. package/deliver-great-systems/workflows/plan-phase.md +79 -1
  107. package/deliver-great-systems/workflows/quick-complete.md +40 -2
  108. package/deliver-great-systems/workflows/quick.md +183 -10
  109. package/deliver-great-systems/workflows/research-idea.md +80 -142
  110. package/deliver-great-systems/workflows/run-job.md +21 -35
  111. package/deliver-great-systems/workflows/settings.md +13 -77
  112. package/deliver-great-systems/workflows/write-spec.md +9 -11
  113. package/hooks/dist/dgs-enforce-discipline.js +196 -0
  114. package/package.json +1 -1
  115. package/scripts/build-hooks.js +1 -0
@@ -817,8 +817,9 @@ function phaseCompleteInternal(cwd, phaseNum) {
817
817
  const reqPath = path.join(cwd, projRoot, 'REQUIREMENTS.md');
818
818
  if (fs.existsSync(reqPath)) {
819
819
  // Extract Requirements line from roadmap for this phase
820
+ // Match both **Requirements:** (colon inside bold) and **Requirements**: (colon outside bold)
820
821
  const reqMatch = roadmapContent.match(
821
- new RegExp(`Phase\\s+${escapeRegex(phaseNum)}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, 'i')
822
+ new RegExp(`Phase\\s+${escapeRegex(phaseNum)}[\\s\\S]*?\\*\\*Requirements(?::\\*\\*|\\*\\*:)\\s*([^\\n]+)`, 'i')
822
823
  );
823
824
 
824
825
  if (reqMatch) {
@@ -944,6 +945,20 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
944
945
  *
945
946
  * Does NOT call cmdCommit (which exits). Uses execGit directly.
946
947
  */
948
+ // Collect the list of still-dirty paths in `gitCwd` after a commit. Purely
949
+ // informational — populates `result.dirty_after`. Duplicated from
950
+ // commands.cjs/quick.cjs instead of extracted because the three call sites
951
+ // use slightly different cwd variables and a shared module would obscure that.
952
+ function collectDirtyAfter(gitCwd) {
953
+ const porcelain = execGit(gitCwd, ['status', '--porcelain']);
954
+ if (porcelain.exitCode !== 0) return [];
955
+ return (porcelain.stdout || '')
956
+ .split('\n')
957
+ .map(l => l.trim())
958
+ .filter(Boolean)
959
+ .map(l => l.replace(/^..\s+/, ''));
960
+ }
961
+
947
962
  function cmdPhaseFinalize(cwd, phaseNum, options, raw) {
948
963
  if (!phaseNum) {
949
964
  error('phase number required for phase finalize');
@@ -993,6 +1008,7 @@ function cmdPhaseFinalize(cwd, phaseNum, options, raw) {
993
1008
  result.commit_reason = nothing ? 'nothing_to_commit' : 'commit_failed';
994
1009
  if (!nothing) result.commit_error = commitResult.stderr;
995
1010
  result.files_committed = [];
1011
+ result.dirty_after = collectDirtyAfter(cwd);
996
1012
  output(result, raw);
997
1013
  return;
998
1014
  }
@@ -1001,6 +1017,7 @@ function cmdPhaseFinalize(cwd, phaseNum, options, raw) {
1001
1017
  result.hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
1002
1018
  result.commit_reason = 'committed';
1003
1019
  result.files_committed = filesToStage;
1020
+ result.dirty_after = collectDirtyAfter(cwd);
1004
1021
 
1005
1022
  // Optional push (same semantics as cmdCommit)
1006
1023
  if (options && options.push) {
@@ -0,0 +1,48 @@
1
+ // deliver-great-systems/bin/lib/plan-number-validity.test.cjs
2
+ // REL-04 regression test scaffold — initially RED. Turns GREEN after plan 02
3
+ // adds Dimension 10 (plan-number validity) to agents/dgs-plan-checker.md.
4
+ //
5
+ // Strategy: this is an agent-prompt assertion, not a CLI test. Tests read
6
+ // agents/dgs-plan-checker.md and assert the dimension exists with the required
7
+ // remediation text + soft-fail marker + named-follow-up-phase reference.
8
+
9
+ const test = require('node:test');
10
+ const assert = require('node:assert');
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+
14
+ const repoRoot = path.resolve(__dirname, '../../..');
15
+ const checkerPath = path.join(repoRoot, 'agents/dgs-plan-checker.md');
16
+
17
+ test('REL-04: dgs-plan-checker has Dimension 10 (plan-number validity)', () => {
18
+ const content = fs.readFileSync(checkerPath, 'utf-8');
19
+ assert.match(content, /## Dimension 10: Plan Number Validity/, 'Dimension 10 must exist');
20
+ });
21
+
22
+ test('REL-04: Dimension 10 enumerates rejected plan-number forms', () => {
23
+ const content = fs.readFileSync(checkerPath, 'utf-8');
24
+ for (const form of ['plan: 00', 'plan: 0', 'positive integer']) {
25
+ assert.ok(content.includes(form), `Dimension 10 must mention "${form}"`);
26
+ }
27
+ });
28
+
29
+ test('REL-04: Dimension 10 includes exact rename remediation command', () => {
30
+ const content = fs.readFileSync(checkerPath, 'utf-8');
31
+ assert.match(content, /git mv .*-00-PLAN\.md.*-01-PLAN\.md/, 'remediation must include git mv command');
32
+ });
33
+
34
+ test('REL-04: Dimension 10 is soft-fail (warning, not block) on initial rollout', () => {
35
+ const content = fs.readFileSync(checkerPath, 'utf-8');
36
+ const dim10Match = content.match(/## Dimension 10[\s\S]+?(?=## Dimension|$)/);
37
+ assert.ok(dim10Match, 'Dimension 10 block must exist');
38
+ const dim10Text = dim10Match[0];
39
+ assert.ok(/soft-fail|warning|warn/i.test(dim10Text), 'Dimension 10 must mark itself as soft-fail/warning');
40
+ });
41
+
42
+ test('REL-04: Dimension 10 references the named follow-up phase for hard-reject promotion', () => {
43
+ const content = fs.readFileSync(checkerPath, 'utf-8');
44
+ assert.ok(/follow-up phase|hard-reject promotion/i.test(content),
45
+ 'Dimension 10 must reference hard-reject deferral to a named follow-up phase');
46
+ });
47
+
48
+ // REL-04 sentinel — flag this file as a Wave-0 RED scaffold for plan 02.
@@ -171,8 +171,14 @@ function scanProjectReposTags(cwd, slug) {
171
171
  * Enumerate all project subfolders and build a { projects, warnings } result
172
172
  * WITHOUT writing PROJECTS.md. Pure read function — no side effects.
173
173
  *
174
- * Ghost project guard: projects whose STATE.md is missing or unreadable
175
- * are warned about and omitted from the output.
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.
178
+ *
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[] }}
@@ -185,7 +191,22 @@ function listProjectsReadonly(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
 
@@ -671,6 +692,20 @@ function cmdProjectsReactivate(cwd, options, raw) {
671
692
  const regenResult = regenerateProjectsMd(cwd);
672
693
  const remainingActive = regenResult.projects.filter(p => !p.status.toLowerCase().includes('completed'));
673
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
+
674
709
  output({ reactivated: true, slug, set_as_current: !!opts.set_current, remaining_active: remainingActive.length }, raw);
675
710
  }
676
711
 
@@ -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);
@@ -44,6 +79,7 @@ const {
44
79
  reactivateProject,
45
80
  parseProjectsMd,
46
81
  checkSlugPrefixCollision,
82
+ cmdProjectsSwitch,
47
83
  } = require('./projects.cjs');
48
84
 
49
85
  // ─── createProjectSubfolder ─────────────────────────────────────────────────
@@ -322,6 +358,17 @@ describe('regenerateProjectsMd', () => {
322
358
  const content = fs.readFileSync(path.join(tmpDir, 'PROJECTS.md'), 'utf-8');
323
359
  assert.ok(content.startsWith('# Projects'));
324
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
+ });
325
372
  });
326
373
 
327
374
  // ─── listProjectsReadonly ───────────────────────────────────────────────────
@@ -407,6 +454,35 @@ describe('listProjectsReadonly', () => {
407
454
  assert.deepStrictEqual(result.projects, []);
408
455
  assert.ok(Array.isArray(result.warnings));
409
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
+ });
410
486
  });
411
487
 
412
488
  // ─── completeProject ────────────────────────────────────────────────────────
@@ -943,6 +1019,7 @@ describe('cmdProjectsSwitch guards', () => {
943
1019
  'config.json': JSON.stringify({ current_project: 'finished-proj' }),
944
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',
945
1021
  'REPOS.md': '# Repos\n\n| Name | Path |\n',
1022
+ 'projects/finished-proj/PROJECT.md': '# Project: Finished Proj\n',
946
1023
  'projects/finished-proj/STATE.md': '# Project State\n\nStatus: completed\nCompleted: 2026-02-20\n',
947
1024
  });
948
1025
 
@@ -956,3 +1033,36 @@ describe('cmdProjectsSwitch guards', () => {
956
1033
  }
957
1034
  });
958
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
+ });
@@ -19,6 +19,8 @@ const { execGit, output, error, loadConfig } = require('./core.cjs');
19
19
  const { getLocalConfigPath } = require('./config.cjs');
20
20
  const { getPlanningRoot } = require('./paths.cjs');
21
21
  const { cmdWorktreesCreate, cmdWorktreesRemove, rebaseAndMerge } = require('./worktrees.cjs');
22
+ const { checkFourEyes } = require('./governance.cjs');
23
+ const { requireGitIdentity, formatAuthorString } = require('./identity.cjs');
22
24
 
23
25
  // ─── Internal helpers ─────────────────────────────────────────────────────────
24
26
 
@@ -53,6 +55,24 @@ function _writeLocalConfig(cwd, data) {
53
55
  fs.renameSync(tmpPath, localPath);
54
56
  }
55
57
 
58
+ /**
59
+ * Generate a collision-resistant quick task ID: YYMMDD-xxx
60
+ * xxx = 2-second precision blocks since midnight, encoded as 3-char Base36.
61
+ * @param {Date} [date] - Date to use (defaults to now)
62
+ * @returns {string} e.g. '260414-v4y'
63
+ */
64
+ function generateQuickId(date) {
65
+ const now = date || new Date();
66
+ const yy = String(now.getFullYear()).slice(-2);
67
+ const mm = String(now.getMonth() + 1).padStart(2, '0');
68
+ const dd = String(now.getDate()).padStart(2, '0');
69
+ const dateStr = yy + mm + dd;
70
+ const secondsSinceMidnight = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
71
+ const timeBlocks = Math.floor(secondsSinceMidnight / 2);
72
+ const timeEncoded = timeBlocks.toString(36).padStart(3, '0');
73
+ return dateStr + '-' + timeEncoded;
74
+ }
75
+
56
76
  /**
57
77
  * Sanitize a title into a valid slug for branch/worktree naming.
58
78
  * @param {string} title
@@ -91,6 +111,13 @@ function _clearStaleQuick(cwd, project, slug, localConfig) {
91
111
 
92
112
  // ─── Exported functions ───────────────────────────────────────────────────────
93
113
 
114
+ // Stale-defence symmetry: this function probes entry.repos paths with
115
+ // fs.existsSync the same way getActiveQuick (below) does for QUICK
116
+ // entries. Don't "simplify" the on-disk check away — its absence is
117
+ // exactly the bug fixed in 260507-pdp. Asymmetry: we do NOT auto-clear
118
+ // stale milestone entries here; milestone state is heavier and may
119
+ // carry inspectable context. The user clears manually via
120
+ // `dgs-tools worktrees remove <slug>`.
94
121
  /**
95
122
  * Detect whether a quick should be product-level or milestone-context.
96
123
  *
@@ -116,7 +143,16 @@ function detectQuickMode(cwd, forceMain) {
116
143
  const entry = worktrees[activeContext];
117
144
 
118
145
  if (entry && entry.type === 'milestone') {
119
- return { mode: 'milestone-context', activeSlug: activeContext, activeMilestone: activeContext };
146
+ const repos = entry.repos || {};
147
+ const paths = Object.values(repos);
148
+ const anyExists = paths.length > 0 && paths.some(function(p) { return fs.existsSync(p); });
149
+ if (anyExists) {
150
+ return { mode: 'milestone-context', activeSlug: activeContext, activeMilestone: activeContext };
151
+ }
152
+ // Stale milestone entry — no on-disk worktree. Fall through to product
153
+ // mode so quicks land where the user expects. Don't auto-clear: milestone
154
+ // state is heavier than quick state and may carry context worth
155
+ // inspecting; the user can run `dgs-tools worktrees remove <slug>`.
120
156
  }
121
157
 
122
158
  // If active context is a quick or unknown, treat as product-level
@@ -178,9 +214,20 @@ function startProductQuick(cwd, title, mode) {
178
214
  const project = config.current_project;
179
215
  if (!project) return { success: false, error: 'No current project set' };
180
216
 
181
- // Sanitize title to slug
182
- const slug = _sanitizeSlug(title);
183
- if (!slug) return { success: false, error: 'Cannot create slug from title: ' + title };
217
+ // Generate quickId and sanitize title to slug with quickId prefix
218
+ const quickId = generateQuickId();
219
+ const descSlug = _sanitizeSlug(title);
220
+ if (!descSlug) return { success: false, error: 'Cannot create slug from title: ' + title };
221
+ const slug = quickId + '-' + descSlug;
222
+ // Match worktrees.cjs _sanitizeSlug: slice(0, 50) then strip trailing dashes.
223
+ // cmdWorktreesCreate re-sanitises whatever slug we pass, so we must pre-truncate
224
+ // here and use the canonical slug for the execSync arg, the read-back lookup,
225
+ // the active_context write, and the return value. Otherwise long descSlugs
226
+ // (40 chars) push the total past 50 and the read-back below misses, returning
227
+ // repos: {} and breaking the workflow's worktree-context injection (which
228
+ // causes the executor to commit to main of the registered repo instead of
229
+ // the quick/<slug> branch in the worktree). See quick task 260507-kq9.
230
+ const canonicalSlug = slug.slice(0, 50).replace(/-+$/, '');
184
231
 
185
232
  // Create worktree via existing cmdWorktreesCreate
186
233
  // Note: cmdWorktreesCreate calls output() which exits the process.
@@ -194,7 +241,7 @@ function startProductQuick(cwd, title, mode) {
194
241
  try {
195
242
  const modeArgs = mode ? ' --mode ' + mode : '';
196
243
  const result = execSync(
197
- 'node ' + JSON.stringify(dgsTools) + ' worktrees create ' + JSON.stringify(slug) + ' --type quick' + modeArgs,
244
+ 'node ' + JSON.stringify(dgsTools) + ' worktrees create ' + JSON.stringify(canonicalSlug) + ' --type quick' + modeArgs,
198
245
  { cwd: root, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 60000 }
199
246
  );
200
247
  // Parse output to verify creation
@@ -211,13 +258,13 @@ function startProductQuick(cwd, title, mode) {
211
258
  const localConfig = _readLocalConfig(cwd);
212
259
  if (localConfig.projects && localConfig.projects[project]
213
260
  && localConfig.projects[project].worktrees
214
- && localConfig.projects[project].worktrees[slug]) {
215
- localConfig.projects[project].worktrees[slug].mode = mode || null;
261
+ && localConfig.projects[project].worktrees[canonicalSlug]) {
262
+ localConfig.projects[project].worktrees[canonicalSlug].mode = mode || null;
216
263
  }
217
264
 
218
265
  // Set active_context
219
266
  if (!localConfig.execution) localConfig.execution = {};
220
- localConfig.execution.active_context = slug;
267
+ localConfig.execution.active_context = canonicalSlug;
221
268
 
222
269
  _writeLocalConfig(cwd, localConfig);
223
270
 
@@ -226,9 +273,9 @@ function startProductQuick(cwd, title, mode) {
226
273
  (finalConfig.projects &&
227
274
  finalConfig.projects[project] &&
228
275
  finalConfig.projects[project].worktrees &&
229
- finalConfig.projects[project].worktrees[slug] &&
230
- finalConfig.projects[project].worktrees[slug].repos) || {};
231
- return { success: true, slug: slug, repos: repos };
276
+ finalConfig.projects[project].worktrees[canonicalSlug] &&
277
+ finalConfig.projects[project].worktrees[canonicalSlug].repos) || {};
278
+ return { success: true, slug: canonicalSlug, repos: repos };
232
279
  }
233
280
 
234
281
  /**
@@ -237,7 +284,8 @@ function startProductQuick(cwd, title, mode) {
237
284
  * @param {string} cwd - Planning root
238
285
  * @returns {{ success: boolean, commitCount?: number, slug?: string, error?: string, manualInstructions?: string }}
239
286
  */
240
- function quickComplete(cwd) {
287
+ function quickComplete(cwd, options) {
288
+ options = options || {};
241
289
  const active = getActiveQuick(cwd);
242
290
  if (!active) {
243
291
  return { success: false, error: 'No active product-level quick to complete. If working in a milestone context, changes are part of the milestone.' };
@@ -256,6 +304,88 @@ function quickComplete(cwd) {
256
304
  const config = loadConfig(cwd);
257
305
  const baseBranch = config.base_branch || 'main';
258
306
 
307
+ // ── Four-Eyes Gate (GATE-02) ───────────────────────────────────────────────
308
+ const planRoot = getPlanningRoot(cwd);
309
+ const rawConfig = (() => {
310
+ try {
311
+ return JSON.parse(fs.readFileSync(path.join(planRoot, 'config.json'), 'utf-8'));
312
+ } catch { return {}; }
313
+ })();
314
+ const fourEyesMode = (rawConfig.workflow && rawConfig.workflow.four_eyes) || 'off';
315
+
316
+ if (fourEyesMode !== 'off') {
317
+ // Resolve current user identity
318
+ let currentUserStr = '';
319
+ try {
320
+ const identity = requireGitIdentity(cwd);
321
+ currentUserStr = formatAuthorString(identity);
322
+ } catch {
323
+ currentUserStr = '';
324
+ }
325
+
326
+ // Get contributors from quick task worktree commits
327
+ const quickContributors = [];
328
+ for (const repoName of repoNames) {
329
+ const worktreePath = repos[repoName];
330
+ const branchName = 'quick/' + slug;
331
+ try {
332
+ // Get unique commit authors from the quick branch
333
+ const logResult = execGit(worktreePath, ['log', '--format=%aN <%aE>', baseBranch + '..' + branchName]);
334
+ if (logResult.exitCode === 0 && logResult.stdout.trim()) {
335
+ const authors = logResult.stdout.trim().split('\n');
336
+ for (const author of authors) {
337
+ if (author.trim()) quickContributors.push(author.trim());
338
+ }
339
+ }
340
+ } catch { /* ignore — contributor detection is best-effort */ }
341
+ }
342
+
343
+ // Deduplicate contributors
344
+ const seen = new Map();
345
+ for (const c of quickContributors) {
346
+ const key = c.toLowerCase();
347
+ if (!seen.has(key)) seen.set(key, c);
348
+ }
349
+ const uniqueContribs = [...seen.values()];
350
+
351
+ // Run four-eyes check
352
+ const feResult = checkFourEyes(uniqueContribs, currentUserStr, fourEyesMode);
353
+
354
+ // Display contributor list (SHR-02 — contextual: "this task")
355
+ const contribNames = uniqueContribs.length > 0
356
+ ? uniqueContribs.join(', ')
357
+ : '(none detected)';
358
+
359
+ if (feResult.passed) {
360
+ process.stderr.write('Contributors: ' + contribNames + ' \u2014 \u2714 Four-eyes satisfied\n');
361
+ } else {
362
+ process.stderr.write('Contributors: ' + contribNames + '\n');
363
+
364
+ let displayName = currentUserStr;
365
+ try {
366
+ const identity = requireGitIdentity(cwd);
367
+ displayName = identity.name;
368
+ } catch { /* use full string */ }
369
+
370
+ if (fourEyesMode === 'warn') {
371
+ // Warn: display warning, proceed
372
+ process.stderr.write('\u26A0 You (' + displayName + ') contributed to this task. Completing anyway (warn mode).\n');
373
+ } else if (fourEyesMode === 'enforce') {
374
+ if (options.force) {
375
+ // Force: display override, proceed
376
+ process.stderr.write('\u26A0 Forced: you (' + displayName + ') are the only contributor. Override logged.\n');
377
+ } else {
378
+ // Block: return error
379
+ return {
380
+ success: false,
381
+ error: '\u2718 Blocked: you (' + displayName + ') are the only contributor. Use --force to override.',
382
+ };
383
+ }
384
+ }
385
+ }
386
+ }
387
+ // Off mode: no check, no output (GATE-03)
388
+
259
389
  // Rebase and merge each repo
260
390
  for (const repoName of repoNames) {
261
391
  const worktreePath = repos[repoName];
@@ -366,8 +496,9 @@ function quickAbandon(cwd, confirmed) {
366
496
  * CLI handler for `dgs-tools complete-quick` (also `quick-complete` for backward compat).
367
497
  * @param {string} cwd
368
498
  */
369
- function cmdQuickComplete(cwd) {
370
- const result = quickComplete(cwd);
499
+ function cmdQuickComplete(cwd, args) {
500
+ const force = args && args.includes('--force');
501
+ const result = quickComplete(cwd, { force });
371
502
  if (!result.success) {
372
503
  if (result.manualInstructions) {
373
504
  process.stderr.write(result.manualInstructions + '\n');
@@ -425,6 +556,22 @@ function cmdQuickAbandon(cwd, args) {
425
556
  * @param {object} options - { description, quickDir, statePath, push, repoCwd, fast }
426
557
  * @param {boolean} raw - true to emit raw JSON, false for pretty output
427
558
  */
559
+ // Collect the list of still-dirty paths in `gitCwd` after a commit. Purely
560
+ // informational — populates `result.dirty_after` so callers can detect
561
+ // verify-step side effects that leaked outside the staged file set. Never
562
+ // throws. Duplicated from commands.cjs/phase.cjs instead of extracted because
563
+ // the three call sites use slightly different cwd variables (gitCwd vs
564
+ // gitCwdReal vs cwd) and a shared helper would obscure that.
565
+ function collectDirtyAfter(gitCwd) {
566
+ const porcelain = execGit(gitCwd, ['status', '--porcelain']);
567
+ if (porcelain.exitCode !== 0) return [];
568
+ return (porcelain.stdout || '')
569
+ .split('\n')
570
+ .map(l => l.trim())
571
+ .filter(Boolean)
572
+ .map(l => l.replace(/^..\s+/, ''));
573
+ }
574
+
428
575
  function cmdQuickFinalize(cwd, quickId, options, raw) {
429
576
  options = options || {};
430
577
 
@@ -490,15 +637,20 @@ function cmdQuickFinalize(cwd, quickId, options, raw) {
490
637
  }
491
638
 
492
639
  const taskDir = path.join(options.quickDir, taskDirName);
493
- const candidates = [
494
- path.join(taskDir, quickId + '-PLAN.md'),
495
- path.join(taskDir, quickId + '-SUMMARY.md'),
496
- path.join(taskDir, quickId + '-CONTEXT.md'),
497
- path.join(taskDir, quickId + '-VERIFICATION.md'),
498
- ];
499
- for (const abs of candidates) {
500
- if (fs.existsSync(abs)) {
501
- filesToStage.push(toRel(abs));
640
+ // Sweep all quick-task artifacts in taskDir, accepting both the flat
641
+ // ({quickId}-NAME.md) and numbered ({quickId}-NN-NAME.md) shapes. The
642
+ // numbered shape is what the dgs-planner template renders for quick
643
+ // tasks (see agents/dgs-planner.md). False-positive risk is negligible
644
+ // because quickId is a 6-char base36 timestamp + 3-char suffix.
645
+ const escapedId = quickId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
646
+ const artifactRe = new RegExp(
647
+ '^' + escapedId + '(-\\d+)?-(PLAN|SUMMARY|CONTEXT|VERIFICATION|CODEREVIEW|DEBUG-LOG|RESEARCH)\\.md$'
648
+ );
649
+ let taskEntries = [];
650
+ try { taskEntries = fs.readdirSync(taskDir); } catch { /* taskDir guard above already handled missing dir */ }
651
+ for (const entry of taskEntries) {
652
+ if (artifactRe.test(entry)) {
653
+ filesToStage.push(toRel(path.join(taskDir, entry)));
502
654
  }
503
655
  }
504
656
  }
@@ -542,6 +694,7 @@ function cmdQuickFinalize(cwd, quickId, options, raw) {
542
694
  result.commit_reason = nothing ? 'nothing_to_commit' : 'commit_failed';
543
695
  if (!nothing) result.commit_error = commitResult.stderr;
544
696
  result.files_committed = [];
697
+ result.dirty_after = collectDirtyAfter(gitCwdReal);
545
698
  output(result, raw);
546
699
  return;
547
700
  }
@@ -550,6 +703,7 @@ function cmdQuickFinalize(cwd, quickId, options, raw) {
550
703
  result.hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
551
704
  result.commit_reason = 'committed';
552
705
  result.files_committed = filesToStage;
706
+ result.dirty_after = collectDirtyAfter(gitCwdReal);
553
707
 
554
708
  // Optional push (same semantics as cmdPhaseFinalize)
555
709
  if (options.push) {
@@ -573,6 +727,7 @@ function cmdQuickFinalize(cwd, quickId, options, raw) {
573
727
  }
574
728
 
575
729
  module.exports = {
730
+ generateQuickId,
576
731
  detectQuickMode,
577
732
  getActiveQuick,
578
733
  startProductQuick,