@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,730 @@
1
+ /**
2
+ * Tests for quick.cjs -- Quick workflow lifecycle
3
+ *
4
+ * Uses real git repos in temp directories, following the same pattern as worktrees.test.cjs.
5
+ * Functions that call output()/process.exit() are tested via subprocess (dgs-tools.cjs CLI).
6
+ * Pure functions (detectQuickMode, getActiveQuick, etc.) are tested directly with
7
+ * config.local.json manipulation.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const { describe, it, beforeEach, afterEach } = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { execSync } = require('child_process');
17
+ const { resetPaths, initPaths } = require('./paths.cjs');
18
+
19
+ const DGS_TOOLS = path.resolve(__dirname, '..', 'dgs-tools.cjs');
20
+
21
+ // ─── Test Helpers ────────────────────────────────────────────────────────────
22
+
23
+ const GIT_ENV = {
24
+ GIT_AUTHOR_NAME: 'Test',
25
+ GIT_AUTHOR_EMAIL: 'test@test.com',
26
+ GIT_COMMITTER_NAME: 'Test',
27
+ GIT_COMMITTER_EMAIL: 'test@test.com',
28
+ };
29
+
30
+ /**
31
+ * Create a minimal DGS environment for quick workflow tests.
32
+ */
33
+ function createTestEnv() {
34
+ const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(require('os').tmpdir(), 'dgs-quick-')));
35
+ const planDir = path.join(tmpDir, 'planning');
36
+ const codeDir = path.join(tmpDir, 'code-repo');
37
+
38
+ // Create planning root (git repo)
39
+ fs.mkdirSync(planDir, { recursive: true });
40
+ execSync('git init -b main', { cwd: planDir, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } });
41
+ execSync('git config user.email "test@test.com"', { cwd: planDir, stdio: 'pipe' });
42
+ execSync('git config user.name "Test"', { cwd: planDir, stdio: 'pipe' });
43
+
44
+ // Create code repo (git repo on main branch)
45
+ fs.mkdirSync(codeDir, { recursive: true });
46
+ execSync('git init -b main', { cwd: codeDir, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } });
47
+ execSync('git config user.email "test@test.com"', { cwd: codeDir, stdio: 'pipe' });
48
+ execSync('git config user.name "Test"', { cwd: codeDir, stdio: 'pipe' });
49
+ fs.writeFileSync(path.join(codeDir, '.gitkeep'), '');
50
+ execSync('git add .', { cwd: codeDir, stdio: 'pipe' });
51
+ execSync('git commit -m "initial"', { cwd: codeDir, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } });
52
+
53
+ // DGS config files
54
+ fs.writeFileSync(path.join(planDir, 'config.json'), JSON.stringify({
55
+ git: { base_branch: 'main' },
56
+ }, null, 2));
57
+
58
+ fs.writeFileSync(path.join(planDir, 'config.local.json'), JSON.stringify({
59
+ current_project: 'tp',
60
+ }, null, 2));
61
+
62
+ // v2 markers
63
+ fs.writeFileSync(path.join(planDir, 'PROJECTS.md'), '# Projects\n');
64
+ fs.writeFileSync(path.join(planDir, 'REPOS.md'),
65
+ '# Repos\n\n' +
66
+ '| Name | Path | GitHub URL | Description |\n' +
67
+ '|------|------|------------|-------------|\n' +
68
+ '| code-repo | ' + path.relative(planDir, codeDir) + ' | | Test repo |\n'
69
+ );
70
+
71
+ // Project structure
72
+ fs.mkdirSync(path.join(planDir, 'projects', 'tp'), { recursive: true });
73
+ fs.writeFileSync(path.join(planDir, 'projects', 'tp', 'STATE.md'), '# State\nStatus: planning\n');
74
+
75
+ // Commit planning files
76
+ execSync('git add .', { cwd: planDir, stdio: 'pipe' });
77
+ execSync('git commit -m "setup"', { cwd: planDir, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } });
78
+
79
+ initPaths(planDir);
80
+
81
+ return {
82
+ tmpDir,
83
+ planDir,
84
+ codeDir,
85
+ cleanup: function() {
86
+ resetPaths();
87
+ // Clean up sibling worktree directories
88
+ try {
89
+ const parent = path.dirname(codeDir);
90
+ const entries = fs.readdirSync(parent);
91
+ for (const e of entries) {
92
+ if (e.startsWith('code-repo--')) {
93
+ fs.rmSync(path.join(parent, e), { recursive: true, force: true });
94
+ }
95
+ }
96
+ } catch { /* ignore */ }
97
+ fs.rmSync(tmpDir, { recursive: true, force: true });
98
+ },
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Read config.local.json from planning dir.
104
+ */
105
+ function readLocalConfig(planDir) {
106
+ return JSON.parse(fs.readFileSync(path.join(planDir, 'config.local.json'), 'utf-8'));
107
+ }
108
+
109
+ /**
110
+ * Write config.local.json to planning dir.
111
+ */
112
+ function writeLocalConfig(planDir, data) {
113
+ fs.writeFileSync(path.join(planDir, 'config.local.json'), JSON.stringify(data, null, 2) + '\n');
114
+ }
115
+
116
+ /**
117
+ * Run dgs-tools command and return parsed JSON output.
118
+ */
119
+ function runCmd(cwd, args) {
120
+ const result = execSync(
121
+ 'node ' + JSON.stringify(DGS_TOOLS) + ' ' + args,
122
+ { cwd, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', env: { ...process.env, ...GIT_ENV } }
123
+ );
124
+ return JSON.parse(result.trim());
125
+ }
126
+
127
+ // ─── detectQuickMode ─────────────────────────────────────────────────────────
128
+
129
+ describe('detectQuickMode', () => {
130
+ let env;
131
+ beforeEach(() => { env = createTestEnv(); });
132
+ afterEach(() => { env.cleanup(); });
133
+
134
+ it('returns product mode when no active_context set', () => {
135
+ const { detectQuickMode } = require('./quick.cjs');
136
+ const result = detectQuickMode(env.planDir, false);
137
+ assert.equal(result.mode, 'product');
138
+ });
139
+
140
+ it('returns product mode when forceMain is true regardless of active milestone', () => {
141
+ // Set up an active milestone worktree entry
142
+ const config = readLocalConfig(env.planDir);
143
+ config.projects = { tp: { worktrees: { 'v1-0': { type: 'milestone', repos: {} } } } };
144
+ config.execution = { active_context: 'v1-0' };
145
+ writeLocalConfig(env.planDir, config);
146
+
147
+ const { detectQuickMode } = require('./quick.cjs');
148
+ const result = detectQuickMode(env.planDir, true);
149
+ assert.equal(result.mode, 'product');
150
+ });
151
+
152
+ it('returns milestone-context when active_context points to milestone worktree', () => {
153
+ const wtPath = path.join(env.tmpDir, 'code-repo--tp-v1-0');
154
+ fs.mkdirSync(wtPath, { recursive: true });
155
+
156
+ const config = readLocalConfig(env.planDir);
157
+ config.projects = { tp: { worktrees: { 'v1-0': { type: 'milestone', repos: { 'code-repo': wtPath } } } } };
158
+ config.execution = { active_context: 'v1-0' };
159
+ writeLocalConfig(env.planDir, config);
160
+
161
+ const { detectQuickMode } = require('./quick.cjs');
162
+ const result = detectQuickMode(env.planDir, false);
163
+ assert.equal(result.mode, 'milestone-context');
164
+ assert.equal(result.activeSlug, 'v1-0');
165
+ assert.equal(result.activeMilestone, 'v1-0');
166
+ });
167
+
168
+ it('returns product mode when milestone entry has no repos (stale)', () => {
169
+ const config = readLocalConfig(env.planDir);
170
+ config.projects = { tp: { worktrees: { 'v1-0': { type: 'milestone', repos: {} } } } };
171
+ config.execution = { active_context: 'v1-0' };
172
+ writeLocalConfig(env.planDir, config);
173
+
174
+ const { detectQuickMode } = require('./quick.cjs');
175
+ const result = detectQuickMode(env.planDir, false);
176
+ assert.equal(result.mode, 'product');
177
+ });
178
+
179
+ it('returns product mode when milestone entry repos point to non-existent path (stale)', () => {
180
+ // Regression test for 260507-pdp: a stale milestone entry left over from
181
+ // an interrupted /dgs:complete-milestone (or aborted `worktrees remove`)
182
+ // silently re-routed every subsequent /dgs:quick to milestone-context.
183
+ // Pre-fix this returned 'milestone-context' — post-fix it falls through.
184
+ const config = readLocalConfig(env.planDir);
185
+ config.projects = { tp: { worktrees: { 'v1-0': { type: 'milestone', repos: { 'code-repo': '/nonexistent/path/v1-0' } } } } };
186
+ config.execution = { active_context: 'v1-0' };
187
+ writeLocalConfig(env.planDir, config);
188
+
189
+ const { detectQuickMode } = require('./quick.cjs');
190
+ const result = detectQuickMode(env.planDir, false);
191
+ assert.equal(result.mode, 'product');
192
+ });
193
+
194
+ it('does NOT auto-clear stale milestone entries from config', () => {
195
+ // Asymmetric to getActiveQuick by design: milestone state is heavier than
196
+ // quick state and may carry context worth inspecting; user clears manually
197
+ // via `dgs-tools worktrees remove <slug>`.
198
+ const config = readLocalConfig(env.planDir);
199
+ config.projects = { tp: { worktrees: { 'v1-0': { type: 'milestone', repos: { 'code-repo': '/nonexistent/path/v1-0' } } } } };
200
+ config.execution = { active_context: 'v1-0' };
201
+ writeLocalConfig(env.planDir, config);
202
+
203
+ const { detectQuickMode } = require('./quick.cjs');
204
+ const result = detectQuickMode(env.planDir, false);
205
+ assert.equal(result.mode, 'product');
206
+
207
+ // Re-read config and assert the milestone entry is STILL present
208
+ const updatedConfig = readLocalConfig(env.planDir);
209
+ const entry = updatedConfig.projects && updatedConfig.projects.tp
210
+ && updatedConfig.projects.tp.worktrees
211
+ && updatedConfig.projects.tp.worktrees['v1-0'];
212
+ assert.ok(entry, 'Stale milestone entry should NOT be auto-cleared');
213
+ assert.equal(entry.type, 'milestone');
214
+ assert.equal(updatedConfig.execution.active_context, 'v1-0',
215
+ 'active_context should also still point to the stale milestone');
216
+ });
217
+
218
+ it('returns product mode when active_context points to a quick worktree', () => {
219
+ // Create a real quick worktree directory so it's not auto-cleared
220
+ const wtPath = path.join(env.tmpDir, 'code-repo--tp-fix-bug');
221
+ fs.mkdirSync(wtPath, { recursive: true });
222
+
223
+ const config = readLocalConfig(env.planDir);
224
+ config.projects = { tp: { worktrees: { 'fix-bug': { type: 'quick', repos: { 'code-repo': wtPath } } } } };
225
+ config.execution = { active_context: 'fix-bug' };
226
+ writeLocalConfig(env.planDir, config);
227
+
228
+ const { detectQuickMode } = require('./quick.cjs');
229
+ const result = detectQuickMode(env.planDir, false);
230
+ assert.equal(result.mode, 'product');
231
+ });
232
+ });
233
+
234
+ // ─── getActiveQuick ──────────────────────────────────────────────────────────
235
+
236
+ describe('getActiveQuick', () => {
237
+ let env;
238
+ beforeEach(() => { env = createTestEnv(); });
239
+ afterEach(() => { env.cleanup(); });
240
+
241
+ it('returns null when no worktrees tracked', () => {
242
+ const { getActiveQuick } = require('./quick.cjs');
243
+ const result = getActiveQuick(env.planDir);
244
+ assert.equal(result, null);
245
+ });
246
+
247
+ it('returns null when only milestone worktrees exist', () => {
248
+ const config = readLocalConfig(env.planDir);
249
+ config.projects = { tp: { worktrees: { 'v1-0': { type: 'milestone', repos: {} } } } };
250
+ writeLocalConfig(env.planDir, config);
251
+
252
+ const { getActiveQuick } = require('./quick.cjs');
253
+ const result = getActiveQuick(env.planDir);
254
+ assert.equal(result, null);
255
+ });
256
+
257
+ it('returns the active quick entry when directory exists', () => {
258
+ // Create a real directory for the quick worktree
259
+ const wtPath = path.join(env.tmpDir, 'code-repo--tp-fix-bug');
260
+ fs.mkdirSync(wtPath, { recursive: true });
261
+
262
+ const config = readLocalConfig(env.planDir);
263
+ config.projects = { tp: { worktrees: { 'fix-bug': { type: 'quick', repos: { 'code-repo': wtPath } } } } };
264
+ writeLocalConfig(env.planDir, config);
265
+
266
+ const { getActiveQuick } = require('./quick.cjs');
267
+ const result = getActiveQuick(env.planDir);
268
+ assert.ok(result);
269
+ assert.equal(result.slug, 'fix-bug');
270
+ assert.equal(result.entry.type, 'quick');
271
+ });
272
+
273
+ it('auto-clears stale entries where directory is missing', () => {
274
+ const config = readLocalConfig(env.planDir);
275
+ config.projects = { tp: { worktrees: { 'stale-fix': { type: 'quick', repos: { 'code-repo': '/nonexistent/path' } } } } };
276
+ config.execution = { active_context: 'stale-fix' };
277
+ writeLocalConfig(env.planDir, config);
278
+
279
+ const { getActiveQuick } = require('./quick.cjs');
280
+ const result = getActiveQuick(env.planDir);
281
+ assert.equal(result, null);
282
+
283
+ // Verify the stale entry was removed from config
284
+ const updatedConfig = readLocalConfig(env.planDir);
285
+ const worktrees = updatedConfig.projects && updatedConfig.projects.tp
286
+ && updatedConfig.projects.tp.worktrees;
287
+ assert.ok(!worktrees || !worktrees['stale-fix'], 'Stale entry should be removed');
288
+
289
+ // Verify active_context was cleared
290
+ assert.ok(!updatedConfig.execution || !updatedConfig.execution.active_context,
291
+ 'active_context should be cleared for stale quick');
292
+ });
293
+ });
294
+
295
+ // ─── startProductQuick ───────────────────────────────────────────────────────
296
+
297
+ describe('startProductQuick', () => {
298
+ let env;
299
+ beforeEach(() => { env = createTestEnv(); });
300
+ afterEach(() => { env.cleanup(); });
301
+
302
+ it('returns guard error when active product-level quick exists', () => {
303
+ // Create a real directory for the quick worktree
304
+ const wtPath = path.join(env.tmpDir, 'code-repo--tp-existing');
305
+ fs.mkdirSync(wtPath, { recursive: true });
306
+
307
+ const config = readLocalConfig(env.planDir);
308
+ config.projects = { tp: { worktrees: { 'existing': { type: 'quick', repos: { 'code-repo': wtPath } } } } };
309
+ writeLocalConfig(env.planDir, config);
310
+
311
+ const { startProductQuick } = require('./quick.cjs');
312
+ const result = startProductQuick(env.planDir, 'new task', null);
313
+ assert.equal(result.success, false);
314
+ assert.ok(result.error.includes('Quick worktree already active'));
315
+ assert.equal(result.activeSlug, 'existing');
316
+ });
317
+
318
+ it('creates quick worktree when no active quick exists', () => {
319
+ const { startProductQuick } = require('./quick.cjs');
320
+ const result = startProductQuick(env.planDir, 'fix token bug', null);
321
+ assert.equal(result.success, true);
322
+ // Slug now includes quickId prefix: YYMMDD-xxx-fix-token-bug
323
+ assert.ok(/^\d{6}-[a-z0-9]{3}-fix-token-bug$/.test(result.slug),
324
+ 'Slug should have quickId prefix: ' + result.slug);
325
+
326
+ // Verify active_context was set
327
+ const config = readLocalConfig(env.planDir);
328
+ assert.equal(config.execution.active_context, result.slug);
329
+
330
+ // Verify worktree entry exists
331
+ const entry = config.projects.tp.worktrees[result.slug];
332
+ assert.ok(entry, 'Worktree entry should exist');
333
+ assert.equal(entry.type, 'quick');
334
+ });
335
+
336
+ it('returns canonical slug that matches worktrees[] lookup key for long descriptions (regression: slug truncation)', () => {
337
+ // Regression for 260507-kq9: a title whose descSlug fills the 40-char cap
338
+ // produces a 51-char raw slug (10-char quickId + '-' + 40-char descSlug).
339
+ // cmdWorktreesCreate re-sanitises with a 50-char cap, so the worktrees[]
340
+ // entry is keyed under the 50-char canonical slug. Pre-fix, startProductQuick
341
+ // read back at the 51-char key → undefined → returned `repos: {}`.
342
+ // The workflow then injected no <worktree_context> and the executor
343
+ // committed to main of the registered repo instead of the quick branch.
344
+ const { startProductQuick } = require('./quick.cjs');
345
+ const longTitle = 'fix slug truncation mismatch causing empty repos and main commits';
346
+ const result = startProductQuick(env.planDir, longTitle, null);
347
+
348
+ assert.equal(result.success, true,
349
+ 'startProductQuick should succeed; got error: ' + (result.error || '<none>'));
350
+ assert.ok(result.slug.length <= 50,
351
+ 'Returned slug must respect canonical 50-char cap; got ' + result.slug.length + ' chars: ' + result.slug);
352
+ assert.ok(Object.keys(result.repos).length > 0,
353
+ 'Returned repos must be populated (regression: was {} when slug exceeded 50 chars). repos: ' + JSON.stringify(result.repos));
354
+
355
+ // The slug-as-returned must equal the slug-as-keyed in config.local.json.
356
+ const config = readLocalConfig(env.planDir);
357
+ assert.equal(config.execution.active_context, result.slug,
358
+ 'active_context should equal returned slug; active_context=' + config.execution.active_context + ' slug=' + result.slug);
359
+ const entry = config.projects.tp.worktrees[result.slug];
360
+ assert.ok(entry,
361
+ 'worktrees[result.slug] must exist; available keys: ' +
362
+ JSON.stringify(Object.keys(config.projects.tp.worktrees || {})));
363
+ assert.equal(entry.type, 'quick');
364
+ });
365
+ });
366
+
367
+ // ─── quickComplete ───────────────────────────────────────────────────────────
368
+
369
+ describe('quickComplete', () => {
370
+ let env;
371
+ beforeEach(() => { env = createTestEnv(); });
372
+ afterEach(() => { env.cleanup(); });
373
+
374
+ it('returns error when no active quick', () => {
375
+ const { quickComplete } = require('./quick.cjs');
376
+ const result = quickComplete(env.planDir);
377
+ assert.equal(result.success, false);
378
+ assert.ok(result.error.includes('No active product-level quick'));
379
+ });
380
+ });
381
+
382
+ // ─── quickAbandon ────────────────────────────────────────────────────────────
383
+
384
+ describe('quickAbandon', () => {
385
+ let env;
386
+ beforeEach(() => { env = createTestEnv(); });
387
+ afterEach(() => { env.cleanup(); });
388
+
389
+ it('returns error when confirmed is false', () => {
390
+ const { quickAbandon } = require('./quick.cjs');
391
+ const result = quickAbandon(env.planDir, false);
392
+ assert.equal(result.success, false);
393
+ assert.ok(result.error.includes('Abandon not confirmed'));
394
+ });
395
+
396
+ it('returns error when no active quick', () => {
397
+ const { quickAbandon } = require('./quick.cjs');
398
+ const result = quickAbandon(env.planDir, true);
399
+ assert.equal(result.success, false);
400
+ assert.ok(result.error.includes('No active product-level quick'));
401
+ });
402
+ });
403
+
404
+ // ─── CLI routing ─────────────────────────────────────────────────────────────
405
+
406
+ describe('CLI complete-quick routing', () => {
407
+ let env;
408
+ beforeEach(() => { env = createTestEnv(); });
409
+ afterEach(() => { env.cleanup(); });
410
+
411
+ it('abandon-quick --confirmed returns error when no active quick', () => {
412
+ try {
413
+ runCmd(env.planDir, 'abandon-quick --confirmed');
414
+ assert.fail('Should have thrown');
415
+ } catch (err) {
416
+ assert.ok(err.stderr.includes('No active product-level quick'));
417
+ }
418
+ });
419
+ });
420
+
421
+ // ─── cmdQuickFinalize ────────────────────────────────────────────────────────
422
+
423
+ /**
424
+ * Create a minimal git repo with DGS config + quick task directory structure.
425
+ * Returns {repoDir, quickDir, taskDir, statePath} with task artifacts NOT yet written.
426
+ */
427
+ function createFinalizeEnv(opts) {
428
+ opts = opts || {};
429
+ const commitDocs = opts.commitDocs !== false;
430
+ const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(require('os').tmpdir(), 'dgs-qf-')));
431
+ const repoDir = tmpDir; // single-repo layout — planning root == repo
432
+ const quickDir = path.join(repoDir, 'quick');
433
+ const quickId = opts.quickId || '260405-abc';
434
+ const taskDir = path.join(quickDir, quickId + '-test-task');
435
+ const statePath = path.join(repoDir, 'projects', 'tp', 'STATE.md');
436
+
437
+ execSync('git init -b main', { cwd: repoDir, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } });
438
+ execSync('git config user.email "test@test.com"', { cwd: repoDir, stdio: 'pipe' });
439
+ execSync('git config user.name "Test"', { cwd: repoDir, stdio: 'pipe' });
440
+
441
+ fs.mkdirSync(quickDir, { recursive: true });
442
+ if (!opts.skipTaskDir) {
443
+ fs.mkdirSync(taskDir, { recursive: true });
444
+ }
445
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
446
+
447
+ // DGS config
448
+ fs.writeFileSync(
449
+ path.join(repoDir, 'config.json'),
450
+ JSON.stringify({ commit_docs: commitDocs }, null, 2)
451
+ );
452
+ // Pre-seed config.local.json with migration marker so migrateBranchingConfig
453
+ // doesn't write an untracked file during dispatcher startup (which would
454
+ // otherwise interfere with nothing-to-commit detection).
455
+ fs.writeFileSync(
456
+ path.join(repoDir, 'config.local.json'),
457
+ JSON.stringify({ branching_migration_done: true }, null, 2)
458
+ );
459
+ // Create an initial commit so HEAD exists
460
+ fs.writeFileSync(path.join(repoDir, '.gitkeep'), '');
461
+ execSync('git add .gitkeep config.json config.local.json', { cwd: repoDir, stdio: 'pipe' });
462
+ execSync('git commit -m "initial"', { cwd: repoDir, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } });
463
+
464
+ return {
465
+ tmpDir, repoDir, quickDir, taskDir, statePath, quickId,
466
+ cleanup: function() { fs.rmSync(tmpDir, { recursive: true, force: true }); },
467
+ };
468
+ }
469
+
470
+ /**
471
+ * Run `dgs-tools quick finalize` as a subprocess and return { stdout, stderr, exitCode, parsed }.
472
+ * Does NOT throw on non-zero exit.
473
+ */
474
+ function runFinalize(cwd, argsArr) {
475
+ const { spawnSync } = require('child_process');
476
+ const res = spawnSync(
477
+ 'node',
478
+ [DGS_TOOLS, 'quick', 'finalize', ...argsArr],
479
+ { cwd, encoding: 'utf-8', env: { ...process.env, ...GIT_ENV } }
480
+ );
481
+ let parsed = null;
482
+ try { parsed = JSON.parse((res.stdout || '').trim()); } catch { /* not JSON */ }
483
+ return { stdout: res.stdout || '', stderr: res.stderr || '', exitCode: res.status, parsed };
484
+ }
485
+
486
+ /**
487
+ * Count commits on HEAD of a repo.
488
+ */
489
+ function countCommits(repoDir) {
490
+ const out = execSync('git rev-list --count HEAD', { cwd: repoDir, encoding: 'utf-8' }).trim();
491
+ return parseInt(out, 10) || 0;
492
+ }
493
+
494
+ describe('cmdQuickFinalize', () => {
495
+ let env;
496
+ afterEach(() => { if (env) env.cleanup(); env = null; });
497
+
498
+ it('commits ALL artifacts when PLAN + SUMMARY + CONTEXT + VERIFICATION + STATE + HISTORY all exist', () => {
499
+ env = createFinalizeEnv();
500
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-PLAN.md'), '# plan\n');
501
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-SUMMARY.md'), '# summary\n');
502
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-CONTEXT.md'), '# context\n');
503
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-VERIFICATION.md'), '# verification\n');
504
+ fs.writeFileSync(path.join(env.quickDir, 'HISTORY.md'), '# history\n');
505
+ fs.writeFileSync(env.statePath, '# state\n');
506
+
507
+ const res = runFinalize(env.repoDir, [
508
+ env.quickId,
509
+ '--quick-dir', env.quickDir,
510
+ '--state-path', env.statePath,
511
+ '--description', 'test all artifacts',
512
+ ]);
513
+ assert.equal(res.exitCode, 0, 'stderr: ' + res.stderr);
514
+ assert.ok(res.parsed, 'expected parsed JSON, got: ' + res.stdout);
515
+ assert.equal(res.parsed.committed, true);
516
+ assert.equal(res.parsed.commit_reason, 'committed');
517
+ assert.equal(res.parsed.files_committed.length, 6, 'files: ' + JSON.stringify(res.parsed.files_committed));
518
+
519
+ // Verify commit message
520
+ const msg = execSync('git log -1 --format=%s', { cwd: env.repoDir, encoding: 'utf-8' }).trim();
521
+ assert.equal(msg, 'docs(quick-' + env.quickId + '): test all artifacts');
522
+ });
523
+
524
+ it('commits minimal artifacts when only PLAN + SUMMARY + STATE exist', () => {
525
+ env = createFinalizeEnv();
526
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-PLAN.md'), '# plan\n');
527
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-SUMMARY.md'), '# summary\n');
528
+ fs.writeFileSync(env.statePath, '# state\n');
529
+ // No CONTEXT, VERIFICATION, or HISTORY
530
+
531
+ const res = runFinalize(env.repoDir, [
532
+ env.quickId,
533
+ '--quick-dir', env.quickDir,
534
+ '--state-path', env.statePath,
535
+ '--description', 'minimal artifacts',
536
+ ]);
537
+ assert.equal(res.exitCode, 0, 'stderr: ' + res.stderr);
538
+ assert.ok(res.parsed);
539
+ assert.equal(res.parsed.committed, true);
540
+ assert.equal(res.parsed.commit_reason, 'committed');
541
+ assert.equal(res.parsed.files_committed.length, 3);
542
+ });
543
+
544
+ it('fast mode: commit message is `docs(quick-<id>): track fast task`', () => {
545
+ env = createFinalizeEnv({ skipTaskDir: true });
546
+ fs.writeFileSync(env.statePath, '# state\n');
547
+ fs.writeFileSync(path.join(env.quickDir, 'HISTORY.md'), '# history\n');
548
+
549
+ const res = runFinalize(env.repoDir, [
550
+ env.quickId,
551
+ '--quick-dir', env.quickDir,
552
+ '--state-path', env.statePath,
553
+ '--fast',
554
+ ]);
555
+ assert.equal(res.exitCode, 0, 'stderr: ' + res.stderr);
556
+ assert.ok(res.parsed);
557
+ assert.equal(res.parsed.committed, true);
558
+ // files_committed contains STATE + HISTORY only (no PLAN/SUMMARY in fast)
559
+ assert.equal(res.parsed.files_committed.length, 2);
560
+
561
+ const msg = execSync('git log -1 --format=%s', { cwd: env.repoDir, encoding: 'utf-8' }).trim();
562
+ assert.equal(msg, 'docs(quick-' + env.quickId + '): track fast task');
563
+ });
564
+
565
+ it('fast mode with HISTORY.md: HISTORY.md is in files_committed', () => {
566
+ env = createFinalizeEnv({ skipTaskDir: true });
567
+ fs.writeFileSync(env.statePath, '# state\n');
568
+ fs.writeFileSync(path.join(env.quickDir, 'HISTORY.md'), '# history row\n');
569
+
570
+ const res = runFinalize(env.repoDir, [
571
+ env.quickId,
572
+ '--quick-dir', env.quickDir,
573
+ '--state-path', env.statePath,
574
+ '--fast',
575
+ ]);
576
+ assert.equal(res.exitCode, 0, 'stderr: ' + res.stderr);
577
+ assert.ok(res.parsed);
578
+ assert.equal(res.parsed.committed, true);
579
+ const hasHistory = res.parsed.files_committed.some(f => f.endsWith('HISTORY.md'));
580
+ assert.ok(hasHistory, 'HISTORY.md should be in files_committed: ' + JSON.stringify(res.parsed.files_committed));
581
+ });
582
+
583
+ it('invalid quick_id (no matching task dir) exits with clear error', () => {
584
+ env = createFinalizeEnv({ skipTaskDir: true });
585
+ // task dir NOT created — non-fast mode should error
586
+ fs.writeFileSync(env.statePath, '# state\n');
587
+
588
+ const res = runFinalize(env.repoDir, [
589
+ 'NONEXISTENT',
590
+ '--quick-dir', env.quickDir,
591
+ '--state-path', env.statePath,
592
+ '--description', 'some desc',
593
+ ]);
594
+ assert.notEqual(res.exitCode, 0);
595
+ assert.ok(
596
+ res.stderr.includes('task directory not found'),
597
+ 'expected "task directory not found" in stderr, got: ' + res.stderr
598
+ );
599
+ });
600
+
601
+ it('missing --description in non-fast mode exits with clear error', () => {
602
+ env = createFinalizeEnv();
603
+
604
+ const res = runFinalize(env.repoDir, [
605
+ env.quickId,
606
+ '--quick-dir', env.quickDir,
607
+ '--state-path', env.statePath,
608
+ ]);
609
+ assert.notEqual(res.exitCode, 0);
610
+ assert.ok(
611
+ res.stderr.includes('description required') || res.stderr.includes('--description'),
612
+ 'expected description error in stderr, got: ' + res.stderr
613
+ );
614
+ });
615
+
616
+ it('missing --quick-dir in non-fast mode exits with clear error', () => {
617
+ env = createFinalizeEnv();
618
+
619
+ const res = runFinalize(env.repoDir, [
620
+ env.quickId,
621
+ '--state-path', env.statePath,
622
+ '--description', 'desc here',
623
+ ]);
624
+ assert.notEqual(res.exitCode, 0);
625
+ assert.ok(
626
+ res.stderr.includes('quick-dir required') || res.stderr.includes('--quick-dir'),
627
+ 'expected quick-dir error in stderr, got: ' + res.stderr
628
+ );
629
+ });
630
+
631
+ it('config.commit_docs=false skips commit (no git commit created)', () => {
632
+ env = createFinalizeEnv({ commitDocs: false });
633
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-PLAN.md'), '# plan\n');
634
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-SUMMARY.md'), '# summary\n');
635
+ fs.writeFileSync(env.statePath, '# state\n');
636
+
637
+ const commitsBefore = countCommits(env.repoDir);
638
+ const res = runFinalize(env.repoDir, [
639
+ env.quickId,
640
+ '--quick-dir', env.quickDir,
641
+ '--state-path', env.statePath,
642
+ '--description', 'skip mode',
643
+ ]);
644
+ assert.equal(res.exitCode, 0, 'stderr: ' + res.stderr);
645
+ assert.ok(res.parsed);
646
+ assert.equal(res.parsed.committed, false);
647
+ assert.equal(res.parsed.commit_reason, 'skipped_commit_docs_false');
648
+ assert.deepEqual(res.parsed.files_committed, []);
649
+ const commitsAfter = countCommits(env.repoDir);
650
+ assert.equal(commitsAfter, commitsBefore, 'no new commit should have been created');
651
+ });
652
+
653
+ it('nothing-to-commit: returns committed=false, commit_reason=nothing_to_commit', () => {
654
+ env = createFinalizeEnv();
655
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-PLAN.md'), '# plan\n');
656
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-SUMMARY.md'), '# summary\n');
657
+ fs.writeFileSync(env.statePath, '# state\n');
658
+
659
+ // First call: succeeds and commits
660
+ const first = runFinalize(env.repoDir, [
661
+ env.quickId,
662
+ '--quick-dir', env.quickDir,
663
+ '--state-path', env.statePath,
664
+ '--description', 'first call',
665
+ ]);
666
+ assert.equal(first.exitCode, 0, 'first call stderr: ' + first.stderr);
667
+ assert.equal(first.parsed.committed, true);
668
+
669
+ // Second call: same files, nothing new staged
670
+ const second = runFinalize(env.repoDir, [
671
+ env.quickId,
672
+ '--quick-dir', env.quickDir,
673
+ '--state-path', env.statePath,
674
+ '--description', 'second call',
675
+ ]);
676
+ assert.equal(second.exitCode, 0, 'second call stderr: ' + second.stderr);
677
+ assert.ok(second.parsed);
678
+ assert.equal(second.parsed.committed, false);
679
+ assert.equal(second.parsed.commit_reason, 'nothing_to_commit');
680
+ });
681
+
682
+ it('commits both flat PLAN.md and numbered {quickId}-01-SUMMARY.md (regression: idea #18)', () => {
683
+ env = createFinalizeEnv();
684
+ // Planner writes flat PLAN, executor writes numbered SUMMARY (the 260410-ckl shape)
685
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-PLAN.md'), '# plan\n');
686
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-01-SUMMARY.md'), '# summary\n');
687
+ fs.writeFileSync(env.statePath, '# state\n');
688
+
689
+ const res = runFinalize(env.repoDir, [
690
+ env.quickId,
691
+ '--quick-dir', env.quickDir,
692
+ '--state-path', env.statePath,
693
+ '--description', 'idea-18 regression',
694
+ ]);
695
+ assert.equal(res.exitCode, 0, 'stderr: ' + res.stderr);
696
+ assert.ok(res.parsed, 'expected parsed JSON, got: ' + res.stdout);
697
+ assert.equal(res.parsed.committed, true);
698
+ assert.equal(res.parsed.commit_reason, 'committed');
699
+ // 3 files: flat PLAN + numbered SUMMARY + STATE
700
+ assert.equal(res.parsed.files_committed.length, 3, 'files: ' + JSON.stringify(res.parsed.files_committed));
701
+
702
+ // Both file basenames must appear in the actual commit
703
+ const committedFiles = execSync(
704
+ 'git log -1 --name-only --format=',
705
+ { cwd: env.repoDir, encoding: 'utf-8' }
706
+ ).trim().split('\n').filter(Boolean);
707
+ const hasFlat = committedFiles.some(f => f.endsWith(env.quickId + '-PLAN.md'));
708
+ const hasNumberedSummary = committedFiles.some(f => f.endsWith(env.quickId + '-01-SUMMARY.md'));
709
+ assert.ok(hasFlat, 'flat PLAN.md should be in commit, got: ' + JSON.stringify(committedFiles));
710
+ assert.ok(hasNumberedSummary, 'numbered SUMMARY should be in commit, got: ' + JSON.stringify(committedFiles));
711
+ });
712
+
713
+ it('commits fully numbered shape ({quickId}-01-PLAN.md + {quickId}-01-SUMMARY.md)', () => {
714
+ env = createFinalizeEnv();
715
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-01-PLAN.md'), '# plan\n');
716
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-01-SUMMARY.md'), '# summary\n');
717
+ fs.writeFileSync(env.statePath, '# state\n');
718
+
719
+ const res = runFinalize(env.repoDir, [
720
+ env.quickId,
721
+ '--quick-dir', env.quickDir,
722
+ '--state-path', env.statePath,
723
+ '--description', 'fully numbered shape',
724
+ ]);
725
+ assert.equal(res.exitCode, 0, 'stderr: ' + res.stderr);
726
+ assert.ok(res.parsed);
727
+ assert.equal(res.parsed.committed, true);
728
+ assert.equal(res.parsed.files_committed.length, 3);
729
+ });
730
+ });