@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
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Tests for migrateFlatStatus — flat status directory migration
3
+ *
4
+ * Phase 134 (v20.0): Migrates ideas/todos/jobs from state subdirectories
5
+ * to flat directories with frontmatter status fields.
6
+ */
7
+
8
+ const { describe, it, beforeEach, afterEach } = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const { createTempDir, cleanupDir, writeFile, initGitRepo } = require('./test-helpers.cjs');
14
+ const { migrateFlatStatus } = require('./migration.cjs');
15
+ const { extractFrontmatter } = require('./frontmatter.cjs');
16
+
17
+ // ─── Helpers ────────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Creates a temp directory with git init, returns realpathSync'd path
21
+ * (macOS /var -> /private/var symlink fix from Phase 118).
22
+ */
23
+ function makeGitDir() {
24
+ const tmpDir = createTempDir('flat-mig-test-');
25
+ initGitRepo(tmpDir);
26
+ return fs.realpathSync(tmpDir);
27
+ }
28
+
29
+ /**
30
+ * Creates a temp directory without git, returns realpathSync'd path.
31
+ */
32
+ function makePlainDir() {
33
+ const tmpDir = createTempDir('flat-mig-test-');
34
+ return fs.realpathSync(tmpDir);
35
+ }
36
+
37
+ function gitCommitAll(dir, msg) {
38
+ const { execSync } = require('child_process');
39
+ execSync('git add -A', { cwd: dir, stdio: 'pipe' });
40
+ execSync(`git commit -m "${msg}" --allow-empty`, { cwd: dir, stdio: 'pipe' });
41
+ }
42
+
43
+ function readFile(dir, relPath) {
44
+ return fs.readFileSync(path.join(dir, relPath), 'utf-8');
45
+ }
46
+
47
+ function fileExists(dir, relPath) {
48
+ return fs.existsSync(path.join(dir, relPath));
49
+ }
50
+
51
+ /**
52
+ * Create a minimal idea file with frontmatter
53
+ */
54
+ function makeIdea(id, title, opts = {}) {
55
+ const fm = [
56
+ '---',
57
+ `id: ${id}`,
58
+ `title: "${title}"`,
59
+ ];
60
+ if (opts.status) fm.push(`status: ${opts.status}`);
61
+ if (opts.consolidated_into) fm.push(`consolidated_into: "${opts.consolidated_into}"`);
62
+ if (opts.consolidated_from) {
63
+ fm.push('consolidated_from:');
64
+ for (const src of opts.consolidated_from) {
65
+ fm.push(` - "${src}"`);
66
+ }
67
+ }
68
+ fm.push(`created: "2026-01-01T00:00:00Z"`);
69
+ fm.push(`updated: "2026-01-01T00:00:00Z"`);
70
+ fm.push('---');
71
+ fm.push('');
72
+ fm.push(`# ${title}`);
73
+ fm.push('');
74
+ fm.push(opts.body || 'Some idea content.');
75
+ if (opts.researchLog) {
76
+ fm.push('');
77
+ fm.push('## Research Log');
78
+ fm.push('');
79
+ fm.push(opts.researchLog);
80
+ }
81
+ return fm.join('\n') + '\n';
82
+ }
83
+
84
+ /**
85
+ * Create a minimal todo file (no frontmatter — tests that frontmatter gets added)
86
+ */
87
+ function makeTodo(title) {
88
+ return `# ${title}\n\nSome todo content.\n`;
89
+ }
90
+
91
+ /**
92
+ * Create a minimal job file with bold-key Status header
93
+ */
94
+ function makeJob(title, status) {
95
+ return `---\ntitle: "${title}"\n---\n\n# ${title}\n\n**Status:** ${status}\n\nJob details.\n`;
96
+ }
97
+
98
+ // ─── Tests ──────────────────────────────────────────────────────────────────
99
+
100
+ describe('migrateFlatStatus', () => {
101
+ let tmpDir;
102
+
103
+ afterEach(() => {
104
+ if (tmpDir) cleanupDir(tmpDir);
105
+ });
106
+
107
+ describe('idempotency', () => {
108
+ it('returns early when flat_status_migration_done is true', () => {
109
+ tmpDir = makePlainDir();
110
+ writeFile(tmpDir, 'config.local.json', JSON.stringify({ flat_status_migration_done: true }));
111
+ const result = migrateFlatStatus(tmpDir);
112
+ assert.equal(result.migrated, false);
113
+ assert.equal(result.actions.length, 0);
114
+ });
115
+
116
+ it('sets flag and returns when no legacy files exist', () => {
117
+ tmpDir = makePlainDir();
118
+ writeFile(tmpDir, 'ideas/001-test.md', makeIdea(1, 'Test', { status: 'pending' }));
119
+ const result = migrateFlatStatus(tmpDir);
120
+ assert.equal(result.migrated, false);
121
+ assert.equal(result.actions.length, 0);
122
+ const config = JSON.parse(readFile(tmpDir, 'config.local.json'));
123
+ assert.equal(config.flat_status_migration_done, true);
124
+ });
125
+ });
126
+
127
+ describe('dry-run mode', () => {
128
+ it('returns actions without modifying files', () => {
129
+ tmpDir = makePlainDir();
130
+ writeFile(tmpDir, 'ideas/pending/001-test.md', makeIdea(1, 'Test'));
131
+
132
+ const result = migrateFlatStatus(tmpDir);
133
+
134
+ assert.equal(result.dryRun, true);
135
+ assert.equal(result.migrated, false);
136
+ assert.ok(result.actions.length > 0, 'should have actions');
137
+ assert.equal(result.filesMoved, 1);
138
+ // File should NOT have been moved
139
+ assert.ok(fileExists(tmpDir, 'ideas/pending/001-test.md'), 'original file should still exist');
140
+ assert.ok(!fileExists(tmpDir, 'ideas/001-test.md'), 'destination should not exist');
141
+ });
142
+
143
+ it('reports correct counts for multi-subsystem migration', () => {
144
+ tmpDir = makePlainDir();
145
+ writeFile(tmpDir, 'ideas/pending/001-idea.md', makeIdea(1, 'Idea'));
146
+ writeFile(tmpDir, 'ideas/done/002-done-idea.md', makeIdea(2, 'Done Idea'));
147
+ writeFile(tmpDir, 'todos/pending/todo-1.md', makeTodo('Todo 1'));
148
+ writeFile(tmpDir, 'jobs/completed/job-v1.md', makeJob('Job v1', 'completed'));
149
+
150
+ const result = migrateFlatStatus(tmpDir);
151
+
152
+ assert.equal(result.dryRun, true);
153
+ assert.equal(result.filesMoved, 4);
154
+ assert.equal(result.statusFieldsAdded, 4);
155
+ });
156
+ });
157
+
158
+ describe('apply mode', () => {
159
+ it('moves idea files and adds status frontmatter', () => {
160
+ tmpDir = makeGitDir();
161
+ writeFile(tmpDir, 'ideas/pending/001-test.md', makeIdea(1, 'Test'));
162
+ writeFile(tmpDir, 'ideas/done/002-shipped.md', makeIdea(2, 'Shipped'));
163
+ gitCommitAll(tmpDir, 'add ideas');
164
+
165
+ const result = migrateFlatStatus(tmpDir, { apply: true });
166
+
167
+ assert.equal(result.migrated, true);
168
+ assert.equal(result.dryRun, false);
169
+ assert.ok(result.commitHash, 'should have commit hash');
170
+
171
+ // Files moved to flat directory
172
+ assert.ok(fileExists(tmpDir, 'ideas/001-test.md'), 'idea should be in flat dir');
173
+ assert.ok(fileExists(tmpDir, 'ideas/002-shipped.md'), 'done idea should be in flat dir');
174
+ assert.ok(!fileExists(tmpDir, 'ideas/pending/001-test.md'), 'original should be gone');
175
+ assert.ok(!fileExists(tmpDir, 'ideas/done/002-shipped.md'), 'original should be gone');
176
+
177
+ // Status frontmatter added
178
+ const pendingContent = readFile(tmpDir, 'ideas/001-test.md');
179
+ const pendingFm = extractFrontmatter(pendingContent);
180
+ assert.equal(pendingFm.status, 'pending');
181
+
182
+ const doneContent = readFile(tmpDir, 'ideas/002-shipped.md');
183
+ const doneFm = extractFrontmatter(doneContent);
184
+ assert.equal(doneFm.status, 'done');
185
+ });
186
+
187
+ it('maps todo states correctly (completed -> done, resolved -> done)', () => {
188
+ tmpDir = makeGitDir();
189
+ writeFile(tmpDir, 'todos/pending/todo-1.md', makeTodo('Pending Todo'));
190
+ writeFile(tmpDir, 'todos/completed/todo-2.md', makeTodo('Completed Todo'));
191
+ writeFile(tmpDir, 'todos/done/todo-3.md', makeTodo('Done Todo'));
192
+ writeFile(tmpDir, 'todos/resolved/todo-4.md', makeTodo('Resolved Todo'));
193
+ gitCommitAll(tmpDir, 'add todos');
194
+
195
+ const result = migrateFlatStatus(tmpDir, { apply: true });
196
+
197
+ assert.equal(result.migrated, true);
198
+
199
+ // All files in flat dir
200
+ assert.ok(fileExists(tmpDir, 'todos/todo-1.md'));
201
+ assert.ok(fileExists(tmpDir, 'todos/todo-2.md'));
202
+ assert.ok(fileExists(tmpDir, 'todos/todo-3.md'));
203
+ assert.ok(fileExists(tmpDir, 'todos/todo-4.md'));
204
+
205
+ // Status mappings correct
206
+ const todo1 = extractFrontmatter(readFile(tmpDir, 'todos/todo-1.md'));
207
+ assert.equal(todo1.status, 'pending');
208
+ const todo2 = extractFrontmatter(readFile(tmpDir, 'todos/todo-2.md'));
209
+ assert.equal(todo2.status, 'done');
210
+ const todo3 = extractFrontmatter(readFile(tmpDir, 'todos/todo-3.md'));
211
+ assert.equal(todo3.status, 'done');
212
+ const todo4 = extractFrontmatter(readFile(tmpDir, 'todos/todo-4.md'));
213
+ assert.equal(todo4.status, 'done');
214
+ });
215
+
216
+ it('flattens research docs and updates path references in ideas', () => {
217
+ tmpDir = makeGitDir();
218
+ const ideaWithResearch = makeIdea(5, 'Research Idea', {
219
+ researchLog: '**Document:** docs/ideas/pending/research-idea-research.md\n**Date:** 2026-01-01'
220
+ });
221
+ writeFile(tmpDir, 'ideas/pending/005-research-idea.md', ideaWithResearch);
222
+ writeFile(tmpDir, 'docs/ideas/pending/research-idea-research.md', '# Research\n\nFindings here.\n');
223
+ gitCommitAll(tmpDir, 'add research');
224
+
225
+ const result = migrateFlatStatus(tmpDir, { apply: true });
226
+
227
+ assert.equal(result.migrated, true);
228
+
229
+ // Research doc flattened
230
+ assert.ok(fileExists(tmpDir, 'docs/ideas/research-idea-research.md'), 'research doc in flat dir');
231
+ assert.ok(!fileExists(tmpDir, 'docs/ideas/pending/research-idea-research.md'), 'research doc removed from state dir');
232
+
233
+ // Path reference updated in idea
234
+ const ideaContent = readFile(tmpDir, 'ideas/005-research-idea.md');
235
+ assert.ok(ideaContent.includes('docs/ideas/research-idea-research.md'), 'path should be flat');
236
+ assert.ok(!ideaContent.includes('docs/ideas/pending/research-idea-research.md'), 'state path should be gone');
237
+ });
238
+
239
+ it('normalizes consolidated_into and consolidated_from paths', () => {
240
+ tmpDir = makeGitDir();
241
+ const consolidatedIdea = makeIdea(3, 'Old Idea', {
242
+ consolidated_into: 'ideas/pending/010-merged.md'
243
+ });
244
+ const mergedIdea = makeIdea(10, 'Merged', {
245
+ consolidated_from: ['ideas/pending/003-old-idea.md', 'ideas/done/004-another.md']
246
+ });
247
+ writeFile(tmpDir, 'ideas/consolidated/003-old-idea.md', consolidatedIdea);
248
+ writeFile(tmpDir, 'ideas/pending/010-merged.md', mergedIdea);
249
+ gitCommitAll(tmpDir, 'add consolidated');
250
+
251
+ const result = migrateFlatStatus(tmpDir, { apply: true });
252
+
253
+ assert.equal(result.migrated, true);
254
+ assert.ok(result.pathsNormalized > 0, 'should have normalized paths');
255
+
256
+ // Check consolidated_into normalized
257
+ const oldContent = readFile(tmpDir, 'ideas/003-old-idea.md');
258
+ assert.ok(!oldContent.includes('ideas/pending/'), 'should not have state dir in path');
259
+ assert.ok(oldContent.includes('ideas/010-merged.md'), 'should have flat path');
260
+
261
+ // Check consolidated_from normalized
262
+ const mergedContent = readFile(tmpDir, 'ideas/010-merged.md');
263
+ assert.ok(!mergedContent.includes('ideas/pending/003'), 'should not have state dir');
264
+ assert.ok(!mergedContent.includes('ideas/done/004'), 'should not have state dir');
265
+ });
266
+
267
+ it('validates file count (no data loss)', () => {
268
+ tmpDir = makeGitDir();
269
+ writeFile(tmpDir, 'ideas/pending/001-a.md', makeIdea(1, 'A'));
270
+ writeFile(tmpDir, 'ideas/done/002-b.md', makeIdea(2, 'B'));
271
+ writeFile(tmpDir, 'ideas/rejected/003-c.md', makeIdea(3, 'C'));
272
+ gitCommitAll(tmpDir, 'add ideas');
273
+
274
+ const result = migrateFlatStatus(tmpDir, { apply: true });
275
+
276
+ assert.equal(result.migrated, true);
277
+ assert.equal(result.filesMoved, 3);
278
+
279
+ // Count files in flat dir (should be 3 ideas)
280
+ const flatFiles = fs.readdirSync(path.join(tmpDir, 'ideas')).filter(f => f.endsWith('.md'));
281
+ assert.equal(flatFiles.length, 3, 'all 3 ideas should be in flat dir');
282
+ });
283
+
284
+ it('handles jobs with dual-write status (frontmatter + bold-key header)', () => {
285
+ tmpDir = makeGitDir();
286
+ writeFile(tmpDir, 'jobs/completed/milestone-v5.md', makeJob('v5 Build', 'completed'));
287
+ writeFile(tmpDir, 'jobs/in-progress/milestone-v6.md', makeJob('v6 Build', 'in-progress'));
288
+ gitCommitAll(tmpDir, 'add jobs');
289
+
290
+ const result = migrateFlatStatus(tmpDir, { apply: true });
291
+
292
+ assert.equal(result.migrated, true);
293
+
294
+ // Check frontmatter status
295
+ const v5Content = readFile(tmpDir, 'jobs/milestone-v5.md');
296
+ const v5Fm = extractFrontmatter(v5Content);
297
+ assert.equal(v5Fm.status, 'completed');
298
+
299
+ // Check bold-key header still present
300
+ assert.ok(v5Content.includes('**Status:** completed'), 'bold-key header should be updated');
301
+
302
+ const v6Content = readFile(tmpDir, 'jobs/milestone-v6.md');
303
+ const v6Fm = extractFrontmatter(v6Content);
304
+ assert.equal(v6Fm.status, 'in-progress');
305
+ assert.ok(v6Content.includes('**Status:** in-progress'), 'bold-key header should be updated');
306
+ });
307
+ });
308
+
309
+ describe('realistic project fixtures', () => {
310
+ it('handles fc-like project (done ideas, research docs, pending todos, completed jobs)', () => {
311
+ tmpDir = makeGitDir();
312
+
313
+ // fc-like: 7 done ideas, 5 pending ideas, 11 pending todos, 1 resolved todo, 10 completed jobs, 1 research doc
314
+ for (let i = 1; i <= 7; i++) {
315
+ writeFile(tmpDir, `ideas/done/00${i}-done-idea-${i}.md`, makeIdea(i, `Done Idea ${i}`));
316
+ }
317
+ for (let i = 8; i <= 12; i++) {
318
+ writeFile(tmpDir, `ideas/pending/0${i}-pending-idea-${i}.md`, makeIdea(i, `Pending Idea ${i}`));
319
+ }
320
+
321
+ for (let i = 1; i <= 11; i++) {
322
+ writeFile(tmpDir, `todos/pending/todo-${i}.md`, makeTodo(`Todo ${i}`));
323
+ }
324
+ writeFile(tmpDir, 'todos/resolved/todo-resolved.md', makeTodo('Resolved Todo'));
325
+
326
+ for (let i = 1; i <= 10; i++) {
327
+ writeFile(tmpDir, `jobs/completed/milestone-v${i}.md`, makeJob(`v${i} Build`, 'completed'));
328
+ }
329
+
330
+ writeFile(tmpDir, 'docs/ideas/pending/research-doc.md', '# Research\n\nContent.\n');
331
+
332
+ gitCommitAll(tmpDir, 'init');
333
+
334
+ const result = migrateFlatStatus(tmpDir, { apply: true });
335
+
336
+ assert.equal(result.migrated, true);
337
+ // 7 done + 5 pending ideas + 12 todos + 10 jobs + 1 research = 35
338
+ assert.equal(result.filesMoved, 35);
339
+
340
+ // Spot check: done idea has status field
341
+ const doneIdea = readFile(tmpDir, 'ideas/001-done-idea-1.md');
342
+ assert.ok(doneIdea.includes('status: done'), 'done idea should have status: done');
343
+
344
+ // Resolved todo mapped to done
345
+ const resolvedTodo = readFile(tmpDir, 'todos/todo-resolved.md');
346
+ const resolvedFm = extractFrontmatter(resolvedTodo);
347
+ assert.equal(resolvedFm.status, 'done');
348
+
349
+ // Research doc flattened
350
+ assert.ok(fileExists(tmpDir, 'docs/ideas/research-doc.md'));
351
+ assert.ok(!fileExists(tmpDir, 'docs/ideas/pending/research-doc.md'));
352
+ });
353
+
354
+ it('handles gsd-like project (mixed todo states, in-progress job)', () => {
355
+ tmpDir = makeGitDir();
356
+
357
+ // gsd-like: 4 done ideas, 5 pending ideas, mixed todos, 1 in-progress job
358
+ for (let i = 1; i <= 4; i++) {
359
+ writeFile(tmpDir, `ideas/done/00${i}-done-${i}.md`, makeIdea(i, `Done ${i}`));
360
+ }
361
+ for (let i = 5; i <= 9; i++) {
362
+ writeFile(tmpDir, `ideas/pending/00${i}-pending-${i}.md`, makeIdea(i, `Pending ${i}`));
363
+ }
364
+
365
+ for (let i = 1; i <= 17; i++) {
366
+ writeFile(tmpDir, `todos/pending/todo-p${i}.md`, makeTodo(`Pending ${i}`));
367
+ }
368
+ for (let i = 1; i <= 4; i++) {
369
+ writeFile(tmpDir, `todos/completed/todo-c${i}.md`, makeTodo(`Completed ${i}`));
370
+ }
371
+ for (let i = 1; i <= 9; i++) {
372
+ writeFile(tmpDir, `todos/done/todo-d${i}.md`, makeTodo(`Done ${i}`));
373
+ }
374
+
375
+ writeFile(tmpDir, 'jobs/in-progress/milestone-v20.md', makeJob('v20 Build', 'in-progress'));
376
+ writeFile(tmpDir, 'jobs/completed/milestone-v19.md', makeJob('v19 Build', 'completed'));
377
+
378
+ gitCommitAll(tmpDir, 'init');
379
+
380
+ const result = migrateFlatStatus(tmpDir, { apply: true });
381
+
382
+ assert.equal(result.migrated, true);
383
+ // 4 done + 5 pending ideas + 17 pending + 4 completed + 9 done todos + 2 jobs = 41
384
+ assert.equal(result.filesMoved, 41);
385
+
386
+ // All todos in flat dir with correct status
387
+ const completedTodo = readFile(tmpDir, 'todos/todo-c1.md');
388
+ const completedFm = extractFrontmatter(completedTodo);
389
+ assert.equal(completedFm.status, 'done', 'completed -> done');
390
+
391
+ const doneTodo = readFile(tmpDir, 'todos/todo-d1.md');
392
+ const doneFm = extractFrontmatter(doneTodo);
393
+ assert.equal(doneFm.status, 'done', 'done -> done');
394
+ });
395
+ });
396
+ });
@@ -251,7 +251,7 @@ function parseMustHavesBlock(content, blockName) {
251
251
 
252
252
  const FRONTMATTER_SCHEMAS = {
253
253
  plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] },
254
- summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed', 'requirements-completed'] },
254
+ summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed', 'requirements_completed'] },
255
255
  verification: { required: ['phase', 'verified', 'status', 'score'] },
256
256
  };
257
257
 
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Governance — Four-eyes principle enforcement for completion gates
3
+ *
4
+ * Provides:
5
+ * - normalizeAuthor(author): lowercased name extraction for comparison
6
+ * - getContributors(cwd): aggregate contributors from milestone phases + quicks
7
+ * - checkFourEyes(contributors, currentUser, mode): four-eyes decision function
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { extractNameFromAuthor } = require('./identity.cjs');
13
+ const { getMilestonePhaseFilter, getProjectRoot } = require('./core.cjs');
14
+ const { extractFrontmatter } = require('./frontmatter.cjs');
15
+ const { getPlanningRoot } = require('./paths.cjs');
16
+
17
+ // ─── Author Normalization ───────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Normalize an author string for comparison.
21
+ * Extracts the name portion (strips email) and lowercases.
22
+ *
23
+ * @param {string|null|undefined} author - Author string ("Name" or "Name <email>")
24
+ * @returns {string} Lowercased name portion, or '' for null/undefined/empty
25
+ */
26
+ function normalizeAuthor(author) {
27
+ if (!author) return '';
28
+ return extractNameFromAuthor(author).toLowerCase();
29
+ }
30
+
31
+ // ─── Contributor Aggregation ────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Aggregate unique contributors from milestone phases and milestone-context quick tasks.
35
+ * Reads PLAN.md created_by and SUMMARY.md executed_by from:
36
+ * 1. All phase directories in current milestone
37
+ * 2. Milestone-context quick tasks (detected via STATE.md)
38
+ *
39
+ * @param {string} cwd - Working directory
40
+ * @returns {{ contributors: string[], normalized: Map<string, string> }}
41
+ * contributors: unique raw author strings (for display)
42
+ * normalized: Map of lowercased-name -> first-seen-raw-string (for comparison)
43
+ */
44
+ function getContributors(cwd) {
45
+ const normalized = new Map(); // lowercased-name -> raw-string
46
+
47
+ function addContributor(rawAuthor) {
48
+ if (!rawAuthor || typeof rawAuthor !== 'string') return;
49
+ const key = normalizeAuthor(rawAuthor);
50
+ if (!key) return;
51
+ if (!normalized.has(key)) {
52
+ normalized.set(key, rawAuthor);
53
+ }
54
+ }
55
+
56
+ // 1. Scan milestone phase directories
57
+ try {
58
+ const planRoot = getPlanningRoot(cwd);
59
+ const projectRootRel = getProjectRoot(cwd);
60
+ const projectRoot = path.join(planRoot, projectRootRel);
61
+ const phasesDir = path.join(projectRoot, 'phases');
62
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
63
+
64
+ if (fs.existsSync(phasesDir)) {
65
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
66
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
67
+
68
+ for (const dir of dirs) {
69
+ if (!isDirInMilestone(dir)) continue;
70
+
71
+ const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
72
+
73
+ // Read SUMMARY.md executed_by
74
+ const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
75
+ for (const s of summaries) {
76
+ try {
77
+ const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
78
+ const fm = extractFrontmatter(content);
79
+ addContributor(fm.executed_by);
80
+ } catch { /* skip unreadable files */ }
81
+ }
82
+
83
+ // Read PLAN.md created_by
84
+ const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
85
+ for (const p of plans) {
86
+ try {
87
+ const content = fs.readFileSync(path.join(phasesDir, dir, p), 'utf-8');
88
+ const fm = extractFrontmatter(content);
89
+ addContributor(fm.created_by);
90
+ } catch { /* skip unreadable files */ }
91
+ }
92
+ }
93
+ }
94
+
95
+ // 2. Scan milestone-context quick tasks via STATE.md
96
+ try {
97
+ const statePath = path.join(projectRoot, 'STATE.md');
98
+ if (fs.existsSync(statePath)) {
99
+ const stateContent = fs.readFileSync(statePath, 'utf-8');
100
+ // Parse quick tasks table for milestone-context entries
101
+ // Directory column format: milestone (slug)
102
+ const milestoneQuickPattern = /\|\s*(\S+)\s*\|[^|]*\|[^|]*\|[^|]*\|[^|]*\|\s*milestone\s*\(([^)]+)\)\s*\|/gi;
103
+ let match;
104
+ while ((match = milestoneQuickPattern.exec(stateContent)) !== null) {
105
+ const quickId = match[1];
106
+ // Find the quick's SUMMARY.md
107
+ const quickBaseDir = path.join(projectRoot, 'quick');
108
+ if (fs.existsSync(quickBaseDir)) {
109
+ try {
110
+ const quickDirs = fs.readdirSync(quickBaseDir);
111
+ const matchingDir = quickDirs.find(d => d.startsWith(quickId));
112
+ if (matchingDir) {
113
+ const quickSummaries = fs.readdirSync(path.join(quickBaseDir, matchingDir))
114
+ .filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
115
+ for (const qs of quickSummaries) {
116
+ try {
117
+ const content = fs.readFileSync(path.join(quickBaseDir, matchingDir, qs), 'utf-8');
118
+ const fm = extractFrontmatter(content);
119
+ addContributor(fm.executed_by);
120
+ } catch { /* skip */ }
121
+ }
122
+ }
123
+ } catch { /* skip quick dir read failure */ }
124
+ }
125
+ }
126
+ }
127
+ } catch { /* silent skip — STATE.md parse failure per CONTEXT.md */ }
128
+
129
+ } catch { /* silent skip — overall scanning failure */ }
130
+
131
+ return {
132
+ contributors: [...normalized.values()],
133
+ normalized,
134
+ };
135
+ }
136
+
137
+ // ─── Four-Eyes Check ────────────────────────────────────────────────────────
138
+
139
+ const VALID_MODES = new Set(['off', 'warn', 'enforce']);
140
+
141
+ /**
142
+ * Check whether the four-eyes principle is satisfied.
143
+ * Pure decision function — does not throw, does not produce output.
144
+ * Callers use the returned fields for display, logging, and gating.
145
+ *
146
+ * @param {string[]} contributors - Raw contributor strings from getContributors().contributors
147
+ * @param {string} currentUser - Current user's author string (from formatAuthorString)
148
+ * @param {string} mode - 'off' | 'warn' | 'enforce' (invalid values treated as 'off')
149
+ * @returns {{ passed: boolean, mode: string, currentUser: string, contributors: string[], matchedUser: string|null, message: string }}
150
+ */
151
+ function checkFourEyes(contributors, currentUser, mode) {
152
+ // Normalize mode — invalid values fall back to 'off'
153
+ const effectiveMode = VALID_MODES.has(mode) ? mode : 'off';
154
+
155
+ // Base result shape
156
+ const result = {
157
+ passed: true,
158
+ mode: effectiveMode,
159
+ currentUser: currentUser || '',
160
+ contributors: contributors || [],
161
+ matchedUser: null,
162
+ message: '',
163
+ };
164
+
165
+ // Off mode: no check, no output (REQ-06)
166
+ if (effectiveMode === 'off') {
167
+ return result;
168
+ }
169
+
170
+ // Empty contributors: skip check (CTR-05)
171
+ if (!contributors || contributors.length === 0) {
172
+ result.message = 'No contributors found — check skipped';
173
+ return result;
174
+ }
175
+
176
+ // Normalize current user for comparison
177
+ const currentNormalized = normalizeAuthor(currentUser);
178
+ if (!currentNormalized) {
179
+ result.message = 'Current user identity could not be resolved — check skipped';
180
+ return result;
181
+ }
182
+
183
+ // Check if current user is among contributors
184
+ let matchedRaw = null;
185
+ for (const contributor of contributors) {
186
+ if (normalizeAuthor(contributor) === currentNormalized) {
187
+ matchedRaw = contributor;
188
+ break;
189
+ }
190
+ }
191
+
192
+ if (!matchedRaw) {
193
+ // Current user is NOT a contributor — four-eyes satisfied
194
+ result.message = 'Four-eyes principle satisfied — completing user is not a contributor';
195
+ return result;
196
+ }
197
+
198
+ // Current user IS a contributor — fail based on mode
199
+ result.passed = false;
200
+ result.matchedUser = matchedRaw;
201
+
202
+ if (effectiveMode === 'warn') {
203
+ result.message = `Four-eyes warning: ${currentUser} completed while also a contributor. Contributors: ${contributors.join(', ')}`;
204
+ } else if (effectiveMode === 'enforce') {
205
+ result.message = `Four-eyes violation: ${currentUser} is a contributor and cannot complete in enforce mode. Contributors: ${contributors.join(', ')}`;
206
+ }
207
+
208
+ return result;
209
+ }
210
+
211
+ module.exports = { normalizeAuthor, getContributors, checkFourEyes };