@ktpartners/dgs-platform 3.0.4 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +124 -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 +9 -9
  23. package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
  24. package/deliver-great-systems/bin/lib/docs.cjs +22 -12
  25. package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
  26. package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
  27. package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
  28. package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
  29. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
  30. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
  31. package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
  32. package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
  33. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
  34. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
  35. package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
  36. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
  37. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
  38. package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
  39. package/deliver-great-systems/bin/lib/governance.cjs +211 -0
  40. package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
  41. package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
  42. package/deliver-great-systems/bin/lib/init.cjs +107 -37
  43. package/deliver-great-systems/bin/lib/init.test.cjs +212 -5
  44. package/deliver-great-systems/bin/lib/jobs.cjs +7 -4
  45. package/deliver-great-systems/bin/lib/milestone.cjs +101 -3
  46. package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
  47. package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
  48. package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
  49. package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
  50. package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
  51. package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
  52. package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
  53. package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
  54. package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
  55. package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
  56. package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
  57. package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
  58. package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
  59. package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
  60. package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
  61. package/deliver-great-systems/bin/lib/phase.cjs +18 -1
  62. package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
  63. package/deliver-great-systems/bin/lib/projects.cjs +38 -3
  64. package/deliver-great-systems/bin/lib/projects.test.cjs +112 -2
  65. package/deliver-great-systems/bin/lib/quick.cjs +178 -23
  66. package/deliver-great-systems/bin/lib/quick.test.cjs +138 -4
  67. package/deliver-great-systems/bin/lib/repos.cjs +12 -12
  68. package/deliver-great-systems/bin/lib/review.cjs +1821 -0
  69. package/deliver-great-systems/bin/lib/state.cjs +7 -3
  70. package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
  71. package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
  72. package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
  73. package/deliver-great-systems/bin/lib/sync.cjs +2 -6
  74. package/deliver-great-systems/bin/lib/verify.cjs +120 -7
  75. package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
  76. package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
  77. package/deliver-great-systems/bin/lib/worktrees.cjs +27 -1
  78. package/deliver-great-systems/bin/lib/worktrees.test.cjs +76 -0
  79. package/deliver-great-systems/references/agent-step-reliability.md +60 -0
  80. package/deliver-great-systems/references/conflict-resolution.md +4 -0
  81. package/deliver-great-systems/references/context-tiers.md +4 -0
  82. package/deliver-great-systems/references/package-scan-config.md +151 -0
  83. package/deliver-great-systems/references/questioning.md +0 -30
  84. package/deliver-great-systems/references/spec-review-loop.md +1 -2
  85. package/deliver-great-systems/references/workflow-conventions.md +29 -0
  86. package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
  87. package/deliver-great-systems/templates/REVIEW.md +35 -0
  88. package/deliver-great-systems/templates/VALIDATION.md +1 -1
  89. package/deliver-great-systems/templates/claude-md.md +11 -0
  90. package/deliver-great-systems/templates/package-scan-report.md +108 -0
  91. package/deliver-great-systems/templates/project.md +6 -170
  92. package/deliver-great-systems/templates/summary.md +3 -1
  93. package/deliver-great-systems/workflows/add-phase.md +5 -0
  94. package/deliver-great-systems/workflows/audit-milestone.md +66 -10
  95. package/deliver-great-systems/workflows/cancel-job.md +1 -1
  96. package/deliver-great-systems/workflows/codereview.md +103 -9
  97. package/deliver-great-systems/workflows/complete-milestone.md +26 -7
  98. package/deliver-great-systems/workflows/complete-quick.md +40 -2
  99. package/deliver-great-systems/workflows/discuss-phase.md +3 -2
  100. package/deliver-great-systems/workflows/execute-phase.md +89 -2
  101. package/deliver-great-systems/workflows/execute-plan.md +10 -1
  102. package/deliver-great-systems/workflows/help.md +51 -18
  103. package/deliver-great-systems/workflows/import-spec.md +65 -7
  104. package/deliver-great-systems/workflows/init-product.md +46 -152
  105. package/deliver-great-systems/workflows/new-milestone.md +115 -14
  106. package/deliver-great-systems/workflows/new-project.md +60 -331
  107. package/deliver-great-systems/workflows/package-scan.md +59 -0
  108. package/deliver-great-systems/workflows/plan-phase.md +79 -1
  109. package/deliver-great-systems/workflows/quick-complete.md +40 -2
  110. package/deliver-great-systems/workflows/quick.md +183 -10
  111. package/deliver-great-systems/workflows/research-idea.md +80 -142
  112. package/deliver-great-systems/workflows/run-job.md +21 -35
  113. package/deliver-great-systems/workflows/settings.md +13 -77
  114. package/deliver-great-systems/workflows/write-spec.md +9 -11
  115. package/hooks/dist/dgs-enforce-discipline.js +196 -0
  116. package/package.json +1 -1
  117. package/scripts/build-hooks.js +1 -0
@@ -24,6 +24,8 @@ const {
24
24
  loadConfig,
25
25
  isProjectCompleted,
26
26
  getProjectDir,
27
+ resolveModelInternal,
28
+ MODEL_PROFILES,
27
29
  } = require('./core.cjs');
28
30
 
29
31
  // ─── Root layout (no v2 markers) Tests ───────────────────────────────────────
@@ -388,7 +390,9 @@ describe('getProjectFolders', () => {
388
390
  const fixture = createFixture({
389
391
  'config.json': JSON.stringify({}),
390
392
  'PROJECTS.md': '# Projects\n',
393
+ 'projects/auth-overhaul/PROJECT.md': '# Project\n',
391
394
  'projects/auth-overhaul/STATE.md': '# State',
395
+ 'projects/dashboard-v2/PROJECT.md': '# Project\n',
392
396
  'projects/dashboard-v2/STATE.md': '# State',
393
397
  'phases/': null,
394
398
  'codebase/': null,
@@ -404,10 +408,11 @@ describe('getProjectFolders', () => {
404
408
  }
405
409
  });
406
410
 
407
- it('only includes directories containing STATE.md', () => {
411
+ it('only includes directories containing PROJECT.md', () => {
408
412
  const fixture = createFixture({
409
413
  'config.json': JSON.stringify({}),
410
414
  'PROJECTS.md': '# Projects\n',
415
+ 'projects/valid-project/PROJECT.md': '# Project\n',
411
416
  'projects/valid-project/STATE.md': '# State',
412
417
  'projects/empty-folder/': null,
413
418
  });
@@ -420,10 +425,43 @@ describe('getProjectFolders', () => {
420
425
  }
421
426
  });
422
427
 
428
+ it('returns thin-skeleton project folder containing PROJECT.md but no STATE.md', () => {
429
+ const fixture = createFixture({
430
+ 'config.json': JSON.stringify({}),
431
+ 'PROJECTS.md': '# Projects\n',
432
+ 'projects/word-gen/PROJECT.md': '# Project: Word Gen\n',
433
+ });
434
+
435
+ try {
436
+ const result = getProjectFolders(fixture.cwd);
437
+ assert.deepEqual(result, ['word-gen']);
438
+ } finally {
439
+ fixture.cleanup();
440
+ }
441
+ });
442
+
443
+ it('excludes directory with neither PROJECT.md nor STATE.md', () => {
444
+ const fixture = createFixture({
445
+ 'config.json': JSON.stringify({}),
446
+ 'PROJECTS.md': '# Projects\n',
447
+ 'projects/empty-folder/': null,
448
+ 'projects/real-project/PROJECT.md': '# Project\n',
449
+ });
450
+
451
+ try {
452
+ const result = getProjectFolders(fixture.cwd);
453
+ assert.deepEqual(result, ['real-project']);
454
+ } finally {
455
+ fixture.cleanup();
456
+ }
457
+ });
458
+
423
459
  it('excludes dot-directories', () => {
424
460
  const fixture = createFixture({
425
461
  'config.json': JSON.stringify({}),
462
+ 'projects/.hidden/PROJECT.md': '# Project\n',
426
463
  'projects/.hidden/STATE.md': '# State',
464
+ 'projects/real-project/PROJECT.md': '# Project\n',
427
465
  'projects/real-project/STATE.md': '# State',
428
466
  });
429
467
 
@@ -745,6 +783,7 @@ describe('root layout', () => {
745
783
  'config.json': JSON.stringify({}),
746
784
  'PROJECTS.md': '# Projects\n',
747
785
  'REPOS.md': '# Repos\n',
786
+ 'projects/proj-a/PROJECT.md': '# Project\n',
748
787
  'projects/proj-a/STATE.md': '# State\n',
749
788
  'projects/proj-a/phases/': null,
750
789
  });
@@ -837,3 +876,42 @@ describe('config two-file merge', () => {
837
876
  });
838
877
 
839
878
  });
879
+
880
+ // ─── MODEL_PROFILES dgs-idea-researcher tiering ──────────────────────────────
881
+
882
+ describe('MODEL_PROFILES dgs-idea-researcher tiering', () => {
883
+ let fixture;
884
+
885
+ afterEach(() => {
886
+ if (fixture) fixture.cleanup();
887
+ fixture = null;
888
+ });
889
+
890
+ it('MODEL_PROFILES table contains dgs-idea-researcher with quality/balanced/budget tiers', () => {
891
+ const profile = MODEL_PROFILES['dgs-idea-researcher'];
892
+ assert.ok(profile, 'MODEL_PROFILES[\'dgs-idea-researcher\'] should be defined');
893
+ assert.equal(typeof profile.quality, 'string', 'quality tier present');
894
+ assert.equal(typeof profile.balanced, 'string', 'balanced tier present');
895
+ assert.equal(typeof profile.budget, 'string', 'budget tier present');
896
+ });
897
+
898
+ it('resolveModelInternal returns inherit for dgs-idea-researcher on quality profile', () => {
899
+ fixture = createTempProject({ withConfig: { model_profile: 'quality' } });
900
+ const resolved = resolveModelInternal(fixture.cwd, 'dgs-idea-researcher');
901
+ // Per core.cjs: when the resolved tier is 'opus', the function returns 'inherit'.
902
+ assert.equal(resolved, 'inherit');
903
+ });
904
+
905
+ it('resolveModelInternal returns sonnet for dgs-idea-researcher on balanced profile', () => {
906
+ fixture = createTempProject({ withConfig: { model_profile: 'balanced' } });
907
+ const resolved = resolveModelInternal(fixture.cwd, 'dgs-idea-researcher');
908
+ assert.equal(resolved, 'sonnet');
909
+ });
910
+
911
+ it('resolveModelInternal returns haiku for dgs-idea-researcher on budget profile', () => {
912
+ fixture = createTempProject({ withConfig: { model_profile: 'budget' } });
913
+ const resolved = resolveModelInternal(fixture.cwd, 'dgs-idea-researcher');
914
+ assert.equal(resolved, 'haiku');
915
+ });
916
+
917
+ });
@@ -14,6 +14,7 @@
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
16
  const crypto = require('crypto');
17
+ const { spawnSync } = require('child_process');
17
18
  const { safeReadFile, execGit, generateSlugInternal, output, error } = require('./core.cjs');
18
19
  const { getPlanningRoot } = require('./paths.cjs');
19
20
  const { findIdeaFile } = require('./ideas.cjs');
@@ -129,10 +130,11 @@ function extractText(filePath, ext) {
129
130
  try {
130
131
  const pdfParse = require('pdf-parse');
131
132
  const buffer = fs.readFileSync(filePath);
132
- // pdf-parse returns a promise; we use execSync workaround for sync context
133
- // Instead, we'll use a sync extraction approach
133
+ // pdf-parse returns a promise; run it in a child node process via
134
+ // spawnSync so the parent can stay synchronous. spawnSync (not
135
+ // execSync with a shell-built command) is required on Windows where
136
+ // cmd.exe applies different quoting rules.
134
137
  let result = null;
135
- const { execSync } = require('child_process');
136
138
  const script = `
137
139
  const pdfParse = require('pdf-parse');
138
140
  const fs = require('fs');
@@ -143,12 +145,15 @@ function extractText(filePath, ext) {
143
145
  process.stdout.write(JSON.stringify({ error: err.message }));
144
146
  });
145
147
  `;
146
- const out = execSync(`node -e ${JSON.stringify(script)}`, {
148
+ const r = spawnSync('node', ['-e', script], {
147
149
  encoding: 'utf-8',
148
150
  timeout: 30000,
149
151
  stdio: ['pipe', 'pipe', 'pipe'],
150
152
  });
151
- result = JSON.parse(out.trim());
153
+ if (r.status !== 0) {
154
+ return { text: null, error: `PDF extraction failed: ${r.stderr || r.error?.message || 'unknown'}` };
155
+ }
156
+ result = JSON.parse(r.stdout.trim());
152
157
  if (result.error) {
153
158
  return { text: null, error: result.error };
154
159
  }
@@ -160,7 +165,6 @@ function extractText(filePath, ext) {
160
165
 
161
166
  if (ext === '.xlsx') {
162
167
  try {
163
- const { execSync } = require('child_process');
164
168
  const script = `
165
169
  const ExcelJS = require('exceljs');
166
170
  const workbook = new ExcelJS.Workbook();
@@ -178,11 +182,15 @@ function extractText(filePath, ext) {
178
182
  process.stdout.write(JSON.stringify({ error: err.message }));
179
183
  });
180
184
  `;
181
- const out = execSync(`node -e ${JSON.stringify(script)}`, {
185
+ const r = spawnSync('node', ['-e', script], {
182
186
  encoding: 'utf-8',
183
187
  timeout: 30000,
184
- }).trim();
185
- const parsed = JSON.parse(out);
188
+ stdio: ['pipe', 'pipe', 'pipe'],
189
+ });
190
+ if (r.status !== 0) {
191
+ return { text: null, error: `XLSX extraction failed: ${r.stderr || r.error?.message || 'unknown'}` };
192
+ }
193
+ const parsed = JSON.parse(r.stdout.trim());
186
194
  if (parsed.error) return { text: null, error: parsed.error };
187
195
  return { text: parsed.text, error: null };
188
196
  } catch (e) {
@@ -202,7 +210,6 @@ function extractText(filePath, ext) {
202
210
  if (ext === '.docx') {
203
211
  try {
204
212
  const mammoth = require('mammoth');
205
- const { execSync } = require('child_process');
206
213
  const script = `
207
214
  const mammoth = require('mammoth');
208
215
  mammoth.extractRawText({ path: ${JSON.stringify(filePath)} })
@@ -213,12 +220,15 @@ function extractText(filePath, ext) {
213
220
  process.stdout.write(JSON.stringify({ error: err.message }));
214
221
  });
215
222
  `;
216
- const out = execSync(`node -e ${JSON.stringify(script)}`, {
223
+ const r = spawnSync('node', ['-e', script], {
217
224
  encoding: 'utf-8',
218
225
  timeout: 30000,
219
226
  stdio: ['pipe', 'pipe', 'pipe'],
220
227
  });
221
- const result = JSON.parse(out.trim());
228
+ if (r.status !== 0) {
229
+ return { text: null, error: `DOCX extraction failed: ${r.stderr || r.error?.message || 'unknown'}` };
230
+ }
231
+ const result = JSON.parse(r.stdout.trim());
222
232
  if (result.error) {
223
233
  return { text: null, error: result.error };
224
234
  }
@@ -0,0 +1,199 @@
1
+ // deliver-great-systems/bin/lib/fast-routing.cjs
2
+ //
3
+ // REL-05/06: Fast-mode commit routing helpers for /dgs:fast.
4
+ //
5
+ // Exports:
6
+ // surveyDirty(planningRoot) -> { planningRoot: { dirty, paths }, subRepos: [{ name, path, dirty, paths }], submodules: [{ path, dirty }] }
7
+ // decideRouting(survey) -> { action: 'route-to-planning-root'|'route-to-sub-repo'|'fail', repoCwd?, exitCode?, warnings?: [string], message?: string }
8
+ // enumerateSubmodules(repoPath) -> [{ path, dirty }]
9
+ //
10
+ // REL-05: decideRouting routes commits to the correct repo on multi-repo products.
11
+ // REL-06: surveyDirty + a thin pre-edit caller (in dgs-tools dispatcher) implements pre-edit dirt check.
12
+ //
13
+ // Both helpers honour the fail-loudly contract: surveyDirty returns structured data;
14
+ // decideRouting names the exit-code label (`multi-repo-dirt`) and includes a 1-3 line
15
+ // remediation message suitable for direct display.
16
+
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+ const { execGit } = require('./core.cjs');
20
+
21
+ // Inline REPOS.md parser — does NOT use repos.cjs's parseReposMd because that
22
+ // helper goes through getPlanningRoot() which has a per-process cache that
23
+ // breaks tests using multiple temp planning roots in the same process.
24
+ function parseReposMdLocal(planningRoot) {
25
+ const filePath = path.join(planningRoot, 'REPOS.md');
26
+ let content;
27
+ try {
28
+ content = fs.readFileSync(filePath, 'utf-8');
29
+ } catch {
30
+ return { repos: [] };
31
+ }
32
+ if (!content || !content.startsWith('# Repos')) return { repos: [] };
33
+ const lines = content.split('\n');
34
+ let tableStart = -1;
35
+ for (let i = 0; i < lines.length; i++) {
36
+ if (lines[i].startsWith('|') && lines[i].includes('Name')) { tableStart = i; break; }
37
+ }
38
+ if (tableStart === -1) return { repos: [] };
39
+ const repos = [];
40
+ for (let i = tableStart + 2; i < lines.length; i++) {
41
+ const line = lines[i].trim();
42
+ if (!line.startsWith('|')) break;
43
+ const cells = line.split('|').map(c => c.trim()).filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
44
+ if (cells.length < 2) continue;
45
+ const [name, p] = cells;
46
+ if (!name || !p) continue;
47
+ repos.push({ name, path: p });
48
+ }
49
+ return { repos };
50
+ }
51
+
52
+ function readConfigLocalJson(planningRoot) {
53
+ const cfgPath = path.join(planningRoot, 'config.local.json');
54
+ if (!fs.existsSync(cfgPath)) return {};
55
+ try {
56
+ return JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
62
+ function enumerateRegisteredRepos(planningRoot) {
63
+ const seen = new Map(); // absolute path -> { name, path }
64
+
65
+ // Source 1: REPOS.md (inline parser, bypassing repos.cjs cache)
66
+ const reposMd = parseReposMdLocal(planningRoot);
67
+ for (const r of (reposMd.repos || [])) {
68
+ const abs = path.resolve(planningRoot, r.path);
69
+ if (!seen.has(abs)) seen.set(abs, { name: r.name, path: abs });
70
+ }
71
+
72
+ // Source 2: config.local.json projects.*.worktrees.*.repos (map of name -> absolute path)
73
+ const cfg = readConfigLocalJson(planningRoot);
74
+ const projects = (cfg.projects || {});
75
+ for (const proj of Object.values(projects)) {
76
+ const wts = (proj.worktrees || {});
77
+ for (const wt of Object.values(wts)) {
78
+ const repos = (wt.repos || {});
79
+ for (const [name, abs] of Object.entries(repos)) {
80
+ if (typeof abs !== 'string') continue;
81
+ const resolved = path.resolve(abs);
82
+ if (!seen.has(resolved)) seen.set(resolved, { name, path: resolved });
83
+ }
84
+ }
85
+ }
86
+
87
+ return Array.from(seen.values());
88
+ }
89
+
90
+ function isGitRepo(p) {
91
+ try {
92
+ const res = execGit(p, ['rev-parse', '--is-inside-work-tree']);
93
+ if (!res || res.exitCode !== 0) return false;
94
+ return typeof res.stdout === 'string' && res.stdout.trim() === 'true';
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ function porcelainPaths(repoPath) {
101
+ if (!isGitRepo(repoPath)) return null;
102
+ let out = '';
103
+ try {
104
+ const res = execGit(repoPath, ['status', '--porcelain']);
105
+ out = (res && res.stdout) || '';
106
+ } catch {
107
+ out = '';
108
+ }
109
+ // git status --porcelain format: "XY <path>" where XY is a 2-char status code
110
+ // Strip the 2-char status + 1 space; trim and filter empties.
111
+ return out
112
+ .split('\n')
113
+ .map(l => l.replace(/^.{2,3}/, '').trim())
114
+ .filter(Boolean);
115
+ }
116
+
117
+ function enumerateSubmodules(repoPath) {
118
+ if (!isGitRepo(repoPath)) return [];
119
+ let out = '';
120
+ try {
121
+ const res = execGit(repoPath, ['submodule', 'status']);
122
+ out = (res && res.stdout) || '';
123
+ } catch {
124
+ out = '';
125
+ }
126
+ return out
127
+ .split('\n')
128
+ .filter(Boolean)
129
+ .map(line => {
130
+ // Format: ` <sha> <path> (<ref>)` — leading char `+` (mismatch),
131
+ // `-` (uninitialised), ` ` (clean), `U` (conflict)
132
+ const lead = line[0];
133
+ const match = line.trim().match(/^[+\- U]?\w+\s+(\S+)/);
134
+ return {
135
+ path: match ? match[1] : line.trim(),
136
+ dirty: Boolean(lead && lead !== ' '),
137
+ };
138
+ });
139
+ }
140
+
141
+ function surveyDirty(planningRoot) {
142
+ const planningPaths = porcelainPaths(planningRoot) || [];
143
+ const planningRootEntry = { dirty: planningPaths.length > 0, paths: planningPaths };
144
+
145
+ const subRepos = [];
146
+ for (const repo of enumerateRegisteredRepos(planningRoot)) {
147
+ if (path.resolve(repo.path) === path.resolve(planningRoot)) continue; // skip if same dir
148
+ const paths = porcelainPaths(repo.path) || [];
149
+ subRepos.push({ name: repo.name, path: repo.path, dirty: paths.length > 0, paths });
150
+ }
151
+
152
+ const submodules = enumerateSubmodules(planningRoot);
153
+
154
+ return { planningRoot: planningRootEntry, subRepos, submodules };
155
+ }
156
+
157
+ function decideRouting(survey) {
158
+ const warnings = [];
159
+ const dirtySubmodules = (survey.submodules || []).filter(s => s.dirty);
160
+ for (const sm of dirtySubmodules) {
161
+ warnings.push(
162
+ `submodule changes detected at ${sm.path}; auto-routing not supported for submodules — commit manually or use /dgs:quick`
163
+ );
164
+ }
165
+
166
+ const planningRoot = survey.planningRoot || { dirty: false };
167
+ const subRepos = survey.subRepos || [];
168
+ const dirtySubRepos = subRepos.filter(r => r.dirty);
169
+
170
+ // Case (c): multiple sub-repos dirty OR planning + at least one sub-repo dirty
171
+ if (dirtySubRepos.length > 1 || (dirtySubRepos.length >= 1 && planningRoot.dirty)) {
172
+ return {
173
+ action: 'fail',
174
+ exitCode: 'multi-repo-dirt',
175
+ message:
176
+ 'Multiple repos have dirty changes — fast-mode cannot auto-route. Review state with `git status` in each repo, run `/dgs:fast --repo <name>` per repo, or use `/dgs:quick` for multi-repo work.',
177
+ warnings,
178
+ };
179
+ }
180
+
181
+ // Case (a): exactly one sub-repo dirty + planning clean
182
+ if (dirtySubRepos.length === 1 && !planningRoot.dirty) {
183
+ return {
184
+ action: 'route-to-sub-repo',
185
+ repoCwd: dirtySubRepos[0].path,
186
+ warnings,
187
+ };
188
+ }
189
+
190
+ // Case (b): only planning root dirty (or nothing dirty — same code path)
191
+ return { action: 'route-to-planning-root', warnings };
192
+ }
193
+
194
+ module.exports = {
195
+ surveyDirty,
196
+ decideRouting,
197
+ enumerateSubmodules,
198
+ enumerateRegisteredRepos,
199
+ };
@@ -0,0 +1,108 @@
1
+ // deliver-great-systems/bin/lib/fast-routing.test.cjs
2
+ // REL-05/06 regression test scaffold — initially RED. Turns GREEN after plan 03
3
+ // implements bin/lib/fast-routing.cjs with surveyDirty + decideRouting helpers.
4
+
5
+ const test = require('node:test');
6
+ const assert = require('node:assert');
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const os = require('node:os');
10
+ const { execSync } = require('node:child_process');
11
+
12
+ function getModule() {
13
+ try {
14
+ return require('./fast-routing.cjs');
15
+ } catch (err) {
16
+ assert.fail('REL-05/06 module not yet implemented: bin/lib/fast-routing.cjs (plan 03 task)');
17
+ }
18
+ }
19
+
20
+ function setupMultiRepoFixture() {
21
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fast-routing-'));
22
+ execSync('git init -q', { cwd: root });
23
+ execSync('git config user.email test@test', { cwd: root });
24
+ execSync('git config user.name test', { cwd: root });
25
+ const subRepo = path.join(root, '..', `fast-routing-sub-${path.basename(root)}`);
26
+ fs.mkdirSync(subRepo);
27
+ execSync('git init -q', { cwd: subRepo });
28
+ execSync('git config user.email test@test', { cwd: subRepo });
29
+ execSync('git config user.name test', { cwd: subRepo });
30
+ fs.writeFileSync(
31
+ path.join(root, 'REPOS.md'),
32
+ `# Repos\n\n| Name | Path | GitHub URL | Description |\n|------|------|------------|-------------|\n| sub | ${subRepo} | git@x:y.git | test sub-repo |\n`
33
+ );
34
+ // Commit something so the planning-root tree isn't entirely empty
35
+ execSync('git add REPOS.md', { cwd: root });
36
+ execSync('git commit -q -m init', { cwd: root });
37
+ return { root, subRepo };
38
+ }
39
+
40
+ test('REL-06: surveyDirty detects pre-existing dirt in planning root (pre-existing-dirt)', () => {
41
+ const m = getModule();
42
+ const { root } = setupMultiRepoFixture();
43
+ fs.writeFileSync(path.join(root, 'dirty.txt'), 'pre-existing');
44
+ const survey = m.surveyDirty(root);
45
+ assert.ok(survey.planningRoot.dirty, 'planning root should be flagged dirty');
46
+ assert.ok((survey.planningRoot.paths || []).some(p => /dirty\.txt/.test(p)), 'dirty.txt must appear in paths');
47
+ });
48
+
49
+ test('REL-06: surveyDirty detects pre-existing dirt in registered sub-repo', () => {
50
+ const m = getModule();
51
+ const { root, subRepo } = setupMultiRepoFixture();
52
+ fs.writeFileSync(path.join(subRepo, 'dirty.txt'), 'pre-existing');
53
+ const survey = m.surveyDirty(root);
54
+ const subEntry = (survey.subRepos || []).find(r => r.name === 'sub');
55
+ assert.ok(subEntry && subEntry.dirty, 'sub-repo should be flagged dirty');
56
+ });
57
+
58
+ test('REL-05: decideRouting returns route-to-sub-repo for single-sub-repo dirty case', () => {
59
+ const m = getModule();
60
+ const survey = {
61
+ planningRoot: { dirty: false, paths: [] },
62
+ subRepos: [{ name: 'sub', path: '/tmp/sub', dirty: true, paths: ['file.txt'] }],
63
+ submodules: [],
64
+ };
65
+ const decision = m.decideRouting(survey);
66
+ assert.strictEqual(decision.action, 'route-to-sub-repo');
67
+ assert.strictEqual(decision.repoCwd, '/tmp/sub');
68
+ });
69
+
70
+ test('REL-05: decideRouting returns route-to-planning-root for planning-only dirty case', () => {
71
+ const m = getModule();
72
+ const survey = {
73
+ planningRoot: { dirty: true, paths: ['a.md'] },
74
+ subRepos: [{ name: 'sub', path: '/tmp/sub', dirty: false, paths: [] }],
75
+ submodules: [],
76
+ };
77
+ const decision = m.decideRouting(survey);
78
+ assert.strictEqual(decision.action, 'route-to-planning-root');
79
+ });
80
+
81
+ test('REL-05: decideRouting fails with multi-repo-dirt for multiple-dirty case', () => {
82
+ const m = getModule();
83
+ const survey = {
84
+ planningRoot: { dirty: true, paths: ['a.md'] },
85
+ subRepos: [{ name: 'sub', path: '/tmp/sub', dirty: true, paths: ['b.txt'] }],
86
+ submodules: [],
87
+ };
88
+ const decision = m.decideRouting(survey);
89
+ assert.strictEqual(decision.action, 'fail');
90
+ assert.strictEqual(decision.exitCode, 'multi-repo-dirt');
91
+ });
92
+
93
+ test('REL-05: decideRouting warns on submodules but does not auto-route them', () => {
94
+ const m = getModule();
95
+ const survey = {
96
+ planningRoot: { dirty: false, paths: [] },
97
+ subRepos: [{ name: 'sub', path: '/tmp/sub', dirty: true, paths: ['file.txt'] }],
98
+ submodules: [{ path: 'vendor/lib', dirty: true }],
99
+ };
100
+ const decision = m.decideRouting(survey);
101
+ assert.strictEqual(decision.action, 'route-to-sub-repo');
102
+ assert.ok(
103
+ Array.isArray(decision.warnings) && decision.warnings.some(w => /submodule/i.test(w)),
104
+ 'submodule warning must be present in decision.warnings'
105
+ );
106
+ });
107
+
108
+ // REL-05/06 sentinel — flag this file as a Wave-0 RED scaffold for plan 03.
@@ -0,0 +1,87 @@
1
+ // deliver-great-systems/bin/lib/final-commit-precondition.test.cjs
2
+ // REL-08 regression test scaffold — initially RED. Turns GREEN after plan 02
3
+ // adds the pre-commit precondition gate to executor's <final_commit> step.
4
+
5
+ const test = require('node:test');
6
+ const assert = require('node:assert');
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const os = require('node:os');
10
+ const { execSync } = require('node:child_process');
11
+
12
+ // Resolve dgs-tools.cjs CLI: __dirname = .../deliver-great-systems/bin/lib
13
+ // CLI lives at .../deliver-great-systems/bin/dgs-tools.cjs
14
+ const CLI = path.resolve(__dirname, '..', 'dgs-tools.cjs');
15
+
16
+ function setupFixture({ planRequirements, summaryRequirementsCompleted }) {
17
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rel08-'));
18
+ execSync('git init -q', { cwd: tmpDir });
19
+ execSync('git config user.email test@test', { cwd: tmpDir });
20
+ execSync('git config user.name test', { cwd: tmpDir });
21
+ fs.writeFileSync(
22
+ path.join(tmpDir, 'PLAN.md'),
23
+ `---\nphase: test\nplan: 01\nrequirements:\n${planRequirements.map(r => ` - ${r}`).join('\n')}\n---\n`
24
+ );
25
+ fs.writeFileSync(
26
+ path.join(tmpDir, 'SUMMARY.md'),
27
+ `---\nphase: test\nplan: 01\nrequirements_completed: [${summaryRequirementsCompleted.join(', ')}]\n---\n`
28
+ );
29
+ return tmpDir;
30
+ }
31
+
32
+ test('REL-08: precondition aborts with summary-frontmatter-mismatch when PLAN non-empty AND SUMMARY empty', () => {
33
+ const fixture = setupFixture({ planRequirements: ['TEST-01'], summaryRequirementsCompleted: [] });
34
+
35
+ let exitCode = 0;
36
+ let combined = '';
37
+ try {
38
+ execSync(`node ${CLI} final-commit-precondition --plan ${fixture}/PLAN.md --summary ${fixture}/SUMMARY.md`, { stdio: 'pipe' });
39
+ } catch (err) {
40
+ exitCode = err.status;
41
+ combined = (err.stderr ? err.stderr.toString() : '') + (err.stdout ? err.stdout.toString() : '');
42
+ }
43
+
44
+ // RED: the CLI subcommand doesn't exist yet, so execSync throws with a different error
45
+ // (CLI says "Unknown command: final-commit-precondition" or similar — does NOT include the literal label).
46
+ assert.notStrictEqual(exitCode, 0, 'should exit non-zero on mismatch');
47
+ assert.match(combined, /summary-frontmatter-mismatch/, 'stderr must include the exit-code label');
48
+ });
49
+
50
+ test('REL-08: precondition leaves working tree unchanged on abort (REL-08 fail-loudly contract)', () => {
51
+ const fixture = setupFixture({ planRequirements: ['TEST-01'], summaryRequirementsCompleted: [] });
52
+ // Snapshot working-tree state BEFORE precondition runs
53
+ const before = execSync('git status --porcelain', { cwd: fixture }).toString();
54
+
55
+ try {
56
+ execSync(`node ${CLI} final-commit-precondition --plan ${fixture}/PLAN.md --summary ${fixture}/SUMMARY.md`, { stdio: 'pipe' });
57
+ } catch (_) { /* expected non-zero */ }
58
+
59
+ const after = execSync('git status --porcelain', { cwd: fixture }).toString();
60
+ assert.strictEqual(after, before, 'working tree must be unchanged after precondition abort');
61
+ });
62
+
63
+ test('REL-08: precondition is a no-op (exit 0) when PLAN requirements is empty', () => {
64
+ const fixture = setupFixture({ planRequirements: [], summaryRequirementsCompleted: [] });
65
+
66
+ let exitCode = 0;
67
+ try {
68
+ execSync(`node ${CLI} final-commit-precondition --plan ${fixture}/PLAN.md --summary ${fixture}/SUMMARY.md`, { stdio: 'pipe' });
69
+ } catch (err) {
70
+ exitCode = err.status;
71
+ }
72
+ assert.strictEqual(exitCode, 0, 'precondition is no-op when PLAN requirements is empty');
73
+ });
74
+
75
+ test('REL-08: precondition succeeds (exit 0) when SUMMARY requirements_completed matches PLAN', () => {
76
+ const fixture = setupFixture({ planRequirements: ['TEST-01'], summaryRequirementsCompleted: ['TEST-01'] });
77
+
78
+ let exitCode = 0;
79
+ try {
80
+ execSync(`node ${CLI} final-commit-precondition --plan ${fixture}/PLAN.md --summary ${fixture}/SUMMARY.md`, { stdio: 'pipe' });
81
+ } catch (err) {
82
+ exitCode = err.status;
83
+ }
84
+ assert.strictEqual(exitCode, 0, 'precondition exits 0 when SUMMARY matches PLAN');
85
+ });
86
+
87
+ // REL-08 sentinel — flag this file as a Wave-0 RED scaffold for plan 02.
@@ -0,0 +1,21 @@
1
+ {
2
+ "results": [
3
+ {
4
+ "type": "unpatched_gem",
5
+ "advisory": {
6
+ "id": "CVE-2020-8164",
7
+ "url": "https://groups.google.com/g/rubyonrails-security/c/f6ioe4sdpbY",
8
+ "title": "Possible Strong Parameters Bypass in ActionPack",
9
+ "description": "There is a strong parameters bypass vector in ActionPack versions 5.2.4.3 and below.",
10
+ "cvss_v3": 7.5,
11
+ "cvss_v2": 5.0,
12
+ "criticality": "high",
13
+ "solution": "Upgrade to activerecord >= 5.2.4.3"
14
+ },
15
+ "gem": {
16
+ "name": "activerecord",
17
+ "version": "5.2.3"
18
+ }
19
+ }
20
+ ]
21
+ }