@ktpartners/dgs-platform 2.8.0 → 3.0.4

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 (94) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +41 -13
  3. package/agents/dgs-plan-checker.md +29 -3
  4. package/agents/dgs-planner.md +10 -0
  5. package/commands/dgs/abandon-quick.md +28 -0
  6. package/commands/dgs/add-tests.md +2 -2
  7. package/commands/dgs/audit-milestone.md +2 -2
  8. package/commands/dgs/capture-principle.md +11 -11
  9. package/commands/dgs/cleanup.md +2 -2
  10. package/commands/dgs/complete-milestone.md +11 -11
  11. package/commands/dgs/complete-quick.md +28 -0
  12. package/commands/dgs/create-milestone-job.md +2 -2
  13. package/commands/dgs/debug.md +3 -3
  14. package/commands/dgs/develop-idea.md +1 -1
  15. package/commands/dgs/fast.md +3 -1
  16. package/commands/dgs/health.md +1 -1
  17. package/commands/dgs/map-codebase.md +6 -6
  18. package/commands/dgs/new-milestone.md +5 -5
  19. package/commands/dgs/new-project.md +6 -6
  20. package/commands/dgs/plan-milestone-gaps.md +1 -1
  21. package/commands/dgs/progress.md +3 -3
  22. package/commands/dgs/quick-abandon.md +8 -0
  23. package/commands/dgs/quick-complete.md +8 -0
  24. package/commands/dgs/quick.md +10 -3
  25. package/commands/dgs/research-idea.md +2 -2
  26. package/commands/dgs/research-phase.md +3 -3
  27. package/commands/dgs/switch-project.md +1 -1
  28. package/commands/dgs/write-spec.md +3 -3
  29. package/deliver-great-systems/bin/dgs-tools.cjs +284 -30
  30. package/deliver-great-systems/bin/lib/commands.cjs +316 -31
  31. package/deliver-great-systems/bin/lib/commands.test.cjs +336 -0
  32. package/deliver-great-systems/bin/lib/config.cjs +39 -6
  33. package/deliver-great-systems/bin/lib/context.cjs +120 -0
  34. package/deliver-great-systems/bin/lib/core.cjs +28 -11
  35. package/deliver-great-systems/bin/lib/execution.cjs +49 -17
  36. package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
  37. package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
  38. package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
  39. package/deliver-great-systems/bin/lib/init.cjs +306 -39
  40. package/deliver-great-systems/bin/lib/init.test.cjs +416 -6
  41. package/deliver-great-systems/bin/lib/jobs.cjs +124 -21
  42. package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
  43. package/deliver-great-systems/bin/lib/migration.cjs +409 -1
  44. package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
  45. package/deliver-great-systems/bin/lib/milestone.cjs +54 -29
  46. package/deliver-great-systems/bin/lib/phase.cjs +128 -2
  47. package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
  48. package/deliver-great-systems/bin/lib/projects.cjs +28 -8
  49. package/deliver-great-systems/bin/lib/projects.test.cjs +86 -0
  50. package/deliver-great-systems/bin/lib/quick.cjs +584 -0
  51. package/deliver-great-systems/bin/lib/quick.test.cjs +596 -0
  52. package/deliver-great-systems/bin/lib/repos.cjs +25 -1
  53. package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
  54. package/deliver-great-systems/bin/lib/specs.cjs +3 -81
  55. package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
  56. package/deliver-great-systems/bin/lib/state.cjs +142 -54
  57. package/deliver-great-systems/bin/lib/sync.cjs +75 -0
  58. package/deliver-great-systems/bin/lib/verify.cjs +80 -1
  59. package/deliver-great-systems/bin/lib/worktrees.cjs +764 -0
  60. package/deliver-great-systems/bin/lib/worktrees.test.cjs +887 -0
  61. package/deliver-great-systems/templates/claude-md.md +16 -0
  62. package/deliver-great-systems/workflows/abandon-quick.md +89 -0
  63. package/deliver-great-systems/workflows/add-idea.md +3 -3
  64. package/deliver-great-systems/workflows/add-tests.md +14 -0
  65. package/deliver-great-systems/workflows/add-todo.md +1 -0
  66. package/deliver-great-systems/workflows/approve-spec.md +25 -4
  67. package/deliver-great-systems/workflows/audit-phase.md +15 -5
  68. package/deliver-great-systems/workflows/cancel-job.md +1 -1
  69. package/deliver-great-systems/workflows/check-todos.md +2 -3
  70. package/deliver-great-systems/workflows/complete-milestone.md +197 -22
  71. package/deliver-great-systems/workflows/complete-quick.md +68 -0
  72. package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
  73. package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
  74. package/deliver-great-systems/workflows/develop-idea.md +11 -11
  75. package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
  76. package/deliver-great-systems/workflows/discuss-idea.md +1 -1
  77. package/deliver-great-systems/workflows/execute-phase.md +121 -32
  78. package/deliver-great-systems/workflows/execute-plan.md +12 -21
  79. package/deliver-great-systems/workflows/help.md +33 -29
  80. package/deliver-great-systems/workflows/init-product.md +2 -18
  81. package/deliver-great-systems/workflows/new-milestone.md +40 -24
  82. package/deliver-great-systems/workflows/new-project.md +22 -680
  83. package/deliver-great-systems/workflows/progress-all.md +133 -0
  84. package/deliver-great-systems/workflows/quick-abandon.md +89 -0
  85. package/deliver-great-systems/workflows/quick-complete.md +68 -0
  86. package/deliver-great-systems/workflows/quick.md +152 -23
  87. package/deliver-great-systems/workflows/refine-spec.md +1 -1
  88. package/deliver-great-systems/workflows/research-idea.md +8 -8
  89. package/deliver-great-systems/workflows/resume-project.md +2 -2
  90. package/deliver-great-systems/workflows/run-job.md +8 -8
  91. package/deliver-great-systems/workflows/validate-phase.md +39 -1
  92. package/deliver-great-systems/workflows/verify-work.md +14 -0
  93. package/deliver-great-systems/workflows/write-spec.md +2 -2
  94. package/package.json +1 -1
@@ -0,0 +1,596 @@
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 config = readLocalConfig(env.planDir);
154
+ config.projects = { tp: { worktrees: { 'v1-0': { type: 'milestone', repos: {} } } } };
155
+ config.execution = { active_context: 'v1-0' };
156
+ writeLocalConfig(env.planDir, config);
157
+
158
+ const { detectQuickMode } = require('./quick.cjs');
159
+ const result = detectQuickMode(env.planDir, false);
160
+ assert.equal(result.mode, 'milestone-context');
161
+ assert.equal(result.activeSlug, 'v1-0');
162
+ assert.equal(result.activeMilestone, 'v1-0');
163
+ });
164
+
165
+ it('returns product mode when active_context points to a quick worktree', () => {
166
+ // Create a real quick worktree directory so it's not auto-cleared
167
+ const wtPath = path.join(env.tmpDir, 'code-repo--tp-fix-bug');
168
+ fs.mkdirSync(wtPath, { recursive: true });
169
+
170
+ const config = readLocalConfig(env.planDir);
171
+ config.projects = { tp: { worktrees: { 'fix-bug': { type: 'quick', repos: { 'code-repo': wtPath } } } } };
172
+ config.execution = { active_context: 'fix-bug' };
173
+ writeLocalConfig(env.planDir, config);
174
+
175
+ const { detectQuickMode } = require('./quick.cjs');
176
+ const result = detectQuickMode(env.planDir, false);
177
+ assert.equal(result.mode, 'product');
178
+ });
179
+ });
180
+
181
+ // ─── getActiveQuick ──────────────────────────────────────────────────────────
182
+
183
+ describe('getActiveQuick', () => {
184
+ let env;
185
+ beforeEach(() => { env = createTestEnv(); });
186
+ afterEach(() => { env.cleanup(); });
187
+
188
+ it('returns null when no worktrees tracked', () => {
189
+ const { getActiveQuick } = require('./quick.cjs');
190
+ const result = getActiveQuick(env.planDir);
191
+ assert.equal(result, null);
192
+ });
193
+
194
+ it('returns null when only milestone worktrees exist', () => {
195
+ const config = readLocalConfig(env.planDir);
196
+ config.projects = { tp: { worktrees: { 'v1-0': { type: 'milestone', repos: {} } } } };
197
+ writeLocalConfig(env.planDir, config);
198
+
199
+ const { getActiveQuick } = require('./quick.cjs');
200
+ const result = getActiveQuick(env.planDir);
201
+ assert.equal(result, null);
202
+ });
203
+
204
+ it('returns the active quick entry when directory exists', () => {
205
+ // Create a real directory for the quick worktree
206
+ const wtPath = path.join(env.tmpDir, 'code-repo--tp-fix-bug');
207
+ fs.mkdirSync(wtPath, { recursive: true });
208
+
209
+ const config = readLocalConfig(env.planDir);
210
+ config.projects = { tp: { worktrees: { 'fix-bug': { type: 'quick', repos: { 'code-repo': wtPath } } } } };
211
+ writeLocalConfig(env.planDir, config);
212
+
213
+ const { getActiveQuick } = require('./quick.cjs');
214
+ const result = getActiveQuick(env.planDir);
215
+ assert.ok(result);
216
+ assert.equal(result.slug, 'fix-bug');
217
+ assert.equal(result.entry.type, 'quick');
218
+ });
219
+
220
+ it('auto-clears stale entries where directory is missing', () => {
221
+ const config = readLocalConfig(env.planDir);
222
+ config.projects = { tp: { worktrees: { 'stale-fix': { type: 'quick', repos: { 'code-repo': '/nonexistent/path' } } } } };
223
+ config.execution = { active_context: 'stale-fix' };
224
+ writeLocalConfig(env.planDir, config);
225
+
226
+ const { getActiveQuick } = require('./quick.cjs');
227
+ const result = getActiveQuick(env.planDir);
228
+ assert.equal(result, null);
229
+
230
+ // Verify the stale entry was removed from config
231
+ const updatedConfig = readLocalConfig(env.planDir);
232
+ const worktrees = updatedConfig.projects && updatedConfig.projects.tp
233
+ && updatedConfig.projects.tp.worktrees;
234
+ assert.ok(!worktrees || !worktrees['stale-fix'], 'Stale entry should be removed');
235
+
236
+ // Verify active_context was cleared
237
+ assert.ok(!updatedConfig.execution || !updatedConfig.execution.active_context,
238
+ 'active_context should be cleared for stale quick');
239
+ });
240
+ });
241
+
242
+ // ─── startProductQuick ───────────────────────────────────────────────────────
243
+
244
+ describe('startProductQuick', () => {
245
+ let env;
246
+ beforeEach(() => { env = createTestEnv(); });
247
+ afterEach(() => { env.cleanup(); });
248
+
249
+ it('returns guard error when active product-level quick exists', () => {
250
+ // Create a real directory for the quick worktree
251
+ const wtPath = path.join(env.tmpDir, 'code-repo--tp-existing');
252
+ fs.mkdirSync(wtPath, { recursive: true });
253
+
254
+ const config = readLocalConfig(env.planDir);
255
+ config.projects = { tp: { worktrees: { 'existing': { type: 'quick', repos: { 'code-repo': wtPath } } } } };
256
+ writeLocalConfig(env.planDir, config);
257
+
258
+ const { startProductQuick } = require('./quick.cjs');
259
+ const result = startProductQuick(env.planDir, 'new task', null);
260
+ assert.equal(result.success, false);
261
+ assert.ok(result.error.includes('Quick worktree already active'));
262
+ assert.equal(result.activeSlug, 'existing');
263
+ });
264
+
265
+ it('creates quick worktree when no active quick exists', () => {
266
+ const { startProductQuick } = require('./quick.cjs');
267
+ const result = startProductQuick(env.planDir, 'fix token bug', null);
268
+ assert.equal(result.success, true);
269
+ assert.equal(result.slug, 'fix-token-bug');
270
+
271
+ // Verify active_context was set
272
+ const config = readLocalConfig(env.planDir);
273
+ assert.equal(config.execution.active_context, 'fix-token-bug');
274
+
275
+ // Verify worktree entry exists
276
+ const entry = config.projects.tp.worktrees['fix-token-bug'];
277
+ assert.ok(entry, 'Worktree entry should exist');
278
+ assert.equal(entry.type, 'quick');
279
+ });
280
+ });
281
+
282
+ // ─── quickComplete ───────────────────────────────────────────────────────────
283
+
284
+ describe('quickComplete', () => {
285
+ let env;
286
+ beforeEach(() => { env = createTestEnv(); });
287
+ afterEach(() => { env.cleanup(); });
288
+
289
+ it('returns error when no active quick', () => {
290
+ const { quickComplete } = require('./quick.cjs');
291
+ const result = quickComplete(env.planDir);
292
+ assert.equal(result.success, false);
293
+ assert.ok(result.error.includes('No active product-level quick'));
294
+ });
295
+ });
296
+
297
+ // ─── quickAbandon ────────────────────────────────────────────────────────────
298
+
299
+ describe('quickAbandon', () => {
300
+ let env;
301
+ beforeEach(() => { env = createTestEnv(); });
302
+ afterEach(() => { env.cleanup(); });
303
+
304
+ it('returns error when confirmed is false', () => {
305
+ const { quickAbandon } = require('./quick.cjs');
306
+ const result = quickAbandon(env.planDir, false);
307
+ assert.equal(result.success, false);
308
+ assert.ok(result.error.includes('Abandon not confirmed'));
309
+ });
310
+
311
+ it('returns error when no active quick', () => {
312
+ const { quickAbandon } = require('./quick.cjs');
313
+ const result = quickAbandon(env.planDir, true);
314
+ assert.equal(result.success, false);
315
+ assert.ok(result.error.includes('No active product-level quick'));
316
+ });
317
+ });
318
+
319
+ // ─── CLI routing ─────────────────────────────────────────────────────────────
320
+
321
+ describe('CLI complete-quick routing', () => {
322
+ let env;
323
+ beforeEach(() => { env = createTestEnv(); });
324
+ afterEach(() => { env.cleanup(); });
325
+
326
+ it('abandon-quick --confirmed returns error when no active quick', () => {
327
+ try {
328
+ runCmd(env.planDir, 'abandon-quick --confirmed');
329
+ assert.fail('Should have thrown');
330
+ } catch (err) {
331
+ assert.ok(err.stderr.includes('No active product-level quick'));
332
+ }
333
+ });
334
+ });
335
+
336
+ // ─── cmdQuickFinalize ────────────────────────────────────────────────────────
337
+
338
+ /**
339
+ * Create a minimal git repo with DGS config + quick task directory structure.
340
+ * Returns {repoDir, quickDir, taskDir, statePath} with task artifacts NOT yet written.
341
+ */
342
+ function createFinalizeEnv(opts) {
343
+ opts = opts || {};
344
+ const commitDocs = opts.commitDocs !== false;
345
+ const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(require('os').tmpdir(), 'dgs-qf-')));
346
+ const repoDir = tmpDir; // single-repo layout — planning root == repo
347
+ const quickDir = path.join(repoDir, 'quick');
348
+ const quickId = opts.quickId || '260405-abc';
349
+ const taskDir = path.join(quickDir, quickId + '-test-task');
350
+ const statePath = path.join(repoDir, 'projects', 'tp', 'STATE.md');
351
+
352
+ execSync('git init -b main', { cwd: repoDir, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } });
353
+ execSync('git config user.email "test@test.com"', { cwd: repoDir, stdio: 'pipe' });
354
+ execSync('git config user.name "Test"', { cwd: repoDir, stdio: 'pipe' });
355
+
356
+ fs.mkdirSync(quickDir, { recursive: true });
357
+ if (!opts.skipTaskDir) {
358
+ fs.mkdirSync(taskDir, { recursive: true });
359
+ }
360
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
361
+
362
+ // DGS config
363
+ fs.writeFileSync(
364
+ path.join(repoDir, 'config.json'),
365
+ JSON.stringify({ commit_docs: commitDocs }, null, 2)
366
+ );
367
+ // Pre-seed config.local.json with migration marker so migrateBranchingConfig
368
+ // doesn't write an untracked file during dispatcher startup (which would
369
+ // otherwise interfere with nothing-to-commit detection).
370
+ fs.writeFileSync(
371
+ path.join(repoDir, 'config.local.json'),
372
+ JSON.stringify({ branching_migration_done: true }, null, 2)
373
+ );
374
+ // Create an initial commit so HEAD exists
375
+ fs.writeFileSync(path.join(repoDir, '.gitkeep'), '');
376
+ execSync('git add .gitkeep config.json config.local.json', { cwd: repoDir, stdio: 'pipe' });
377
+ execSync('git commit -m "initial"', { cwd: repoDir, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } });
378
+
379
+ return {
380
+ tmpDir, repoDir, quickDir, taskDir, statePath, quickId,
381
+ cleanup: function() { fs.rmSync(tmpDir, { recursive: true, force: true }); },
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Run `dgs-tools quick finalize` as a subprocess and return { stdout, stderr, exitCode, parsed }.
387
+ * Does NOT throw on non-zero exit.
388
+ */
389
+ function runFinalize(cwd, argsArr) {
390
+ const { spawnSync } = require('child_process');
391
+ const res = spawnSync(
392
+ 'node',
393
+ [DGS_TOOLS, 'quick', 'finalize', ...argsArr],
394
+ { cwd, encoding: 'utf-8', env: { ...process.env, ...GIT_ENV } }
395
+ );
396
+ let parsed = null;
397
+ try { parsed = JSON.parse((res.stdout || '').trim()); } catch { /* not JSON */ }
398
+ return { stdout: res.stdout || '', stderr: res.stderr || '', exitCode: res.status, parsed };
399
+ }
400
+
401
+ /**
402
+ * Count commits on HEAD of a repo.
403
+ */
404
+ function countCommits(repoDir) {
405
+ const out = execSync('git rev-list --count HEAD', { cwd: repoDir, encoding: 'utf-8' }).trim();
406
+ return parseInt(out, 10) || 0;
407
+ }
408
+
409
+ describe('cmdQuickFinalize', () => {
410
+ let env;
411
+ afterEach(() => { if (env) env.cleanup(); env = null; });
412
+
413
+ it('commits ALL artifacts when PLAN + SUMMARY + CONTEXT + VERIFICATION + STATE + HISTORY all exist', () => {
414
+ env = createFinalizeEnv();
415
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-PLAN.md'), '# plan\n');
416
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-SUMMARY.md'), '# summary\n');
417
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-CONTEXT.md'), '# context\n');
418
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-VERIFICATION.md'), '# verification\n');
419
+ fs.writeFileSync(path.join(env.quickDir, 'HISTORY.md'), '# history\n');
420
+ fs.writeFileSync(env.statePath, '# state\n');
421
+
422
+ const res = runFinalize(env.repoDir, [
423
+ env.quickId,
424
+ '--quick-dir', env.quickDir,
425
+ '--state-path', env.statePath,
426
+ '--description', 'test all artifacts',
427
+ ]);
428
+ assert.equal(res.exitCode, 0, 'stderr: ' + res.stderr);
429
+ assert.ok(res.parsed, 'expected parsed JSON, got: ' + res.stdout);
430
+ assert.equal(res.parsed.committed, true);
431
+ assert.equal(res.parsed.commit_reason, 'committed');
432
+ assert.equal(res.parsed.files_committed.length, 6, 'files: ' + JSON.stringify(res.parsed.files_committed));
433
+
434
+ // Verify commit message
435
+ const msg = execSync('git log -1 --format=%s', { cwd: env.repoDir, encoding: 'utf-8' }).trim();
436
+ assert.equal(msg, 'docs(quick-' + env.quickId + '): test all artifacts');
437
+ });
438
+
439
+ it('commits minimal artifacts when only PLAN + SUMMARY + STATE exist', () => {
440
+ env = createFinalizeEnv();
441
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-PLAN.md'), '# plan\n');
442
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-SUMMARY.md'), '# summary\n');
443
+ fs.writeFileSync(env.statePath, '# state\n');
444
+ // No CONTEXT, VERIFICATION, or HISTORY
445
+
446
+ const res = runFinalize(env.repoDir, [
447
+ env.quickId,
448
+ '--quick-dir', env.quickDir,
449
+ '--state-path', env.statePath,
450
+ '--description', 'minimal artifacts',
451
+ ]);
452
+ assert.equal(res.exitCode, 0, 'stderr: ' + res.stderr);
453
+ assert.ok(res.parsed);
454
+ assert.equal(res.parsed.committed, true);
455
+ assert.equal(res.parsed.commit_reason, 'committed');
456
+ assert.equal(res.parsed.files_committed.length, 3);
457
+ });
458
+
459
+ it('fast mode: commit message is `docs(quick-<id>): track fast task`', () => {
460
+ env = createFinalizeEnv({ skipTaskDir: true });
461
+ fs.writeFileSync(env.statePath, '# state\n');
462
+ fs.writeFileSync(path.join(env.quickDir, 'HISTORY.md'), '# history\n');
463
+
464
+ const res = runFinalize(env.repoDir, [
465
+ env.quickId,
466
+ '--quick-dir', env.quickDir,
467
+ '--state-path', env.statePath,
468
+ '--fast',
469
+ ]);
470
+ assert.equal(res.exitCode, 0, 'stderr: ' + res.stderr);
471
+ assert.ok(res.parsed);
472
+ assert.equal(res.parsed.committed, true);
473
+ // files_committed contains STATE + HISTORY only (no PLAN/SUMMARY in fast)
474
+ assert.equal(res.parsed.files_committed.length, 2);
475
+
476
+ const msg = execSync('git log -1 --format=%s', { cwd: env.repoDir, encoding: 'utf-8' }).trim();
477
+ assert.equal(msg, 'docs(quick-' + env.quickId + '): track fast task');
478
+ });
479
+
480
+ it('fast mode with HISTORY.md: HISTORY.md is in files_committed', () => {
481
+ env = createFinalizeEnv({ skipTaskDir: true });
482
+ fs.writeFileSync(env.statePath, '# state\n');
483
+ fs.writeFileSync(path.join(env.quickDir, 'HISTORY.md'), '# history row\n');
484
+
485
+ const res = runFinalize(env.repoDir, [
486
+ env.quickId,
487
+ '--quick-dir', env.quickDir,
488
+ '--state-path', env.statePath,
489
+ '--fast',
490
+ ]);
491
+ assert.equal(res.exitCode, 0, 'stderr: ' + res.stderr);
492
+ assert.ok(res.parsed);
493
+ assert.equal(res.parsed.committed, true);
494
+ const hasHistory = res.parsed.files_committed.some(f => f.endsWith('HISTORY.md'));
495
+ assert.ok(hasHistory, 'HISTORY.md should be in files_committed: ' + JSON.stringify(res.parsed.files_committed));
496
+ });
497
+
498
+ it('invalid quick_id (no matching task dir) exits with clear error', () => {
499
+ env = createFinalizeEnv({ skipTaskDir: true });
500
+ // task dir NOT created — non-fast mode should error
501
+ fs.writeFileSync(env.statePath, '# state\n');
502
+
503
+ const res = runFinalize(env.repoDir, [
504
+ 'NONEXISTENT',
505
+ '--quick-dir', env.quickDir,
506
+ '--state-path', env.statePath,
507
+ '--description', 'some desc',
508
+ ]);
509
+ assert.notEqual(res.exitCode, 0);
510
+ assert.ok(
511
+ res.stderr.includes('task directory not found'),
512
+ 'expected "task directory not found" in stderr, got: ' + res.stderr
513
+ );
514
+ });
515
+
516
+ it('missing --description in non-fast mode exits with clear error', () => {
517
+ env = createFinalizeEnv();
518
+
519
+ const res = runFinalize(env.repoDir, [
520
+ env.quickId,
521
+ '--quick-dir', env.quickDir,
522
+ '--state-path', env.statePath,
523
+ ]);
524
+ assert.notEqual(res.exitCode, 0);
525
+ assert.ok(
526
+ res.stderr.includes('description required') || res.stderr.includes('--description'),
527
+ 'expected description error in stderr, got: ' + res.stderr
528
+ );
529
+ });
530
+
531
+ it('missing --quick-dir in non-fast mode exits with clear error', () => {
532
+ env = createFinalizeEnv();
533
+
534
+ const res = runFinalize(env.repoDir, [
535
+ env.quickId,
536
+ '--state-path', env.statePath,
537
+ '--description', 'desc here',
538
+ ]);
539
+ assert.notEqual(res.exitCode, 0);
540
+ assert.ok(
541
+ res.stderr.includes('quick-dir required') || res.stderr.includes('--quick-dir'),
542
+ 'expected quick-dir error in stderr, got: ' + res.stderr
543
+ );
544
+ });
545
+
546
+ it('config.commit_docs=false skips commit (no git commit created)', () => {
547
+ env = createFinalizeEnv({ commitDocs: false });
548
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-PLAN.md'), '# plan\n');
549
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-SUMMARY.md'), '# summary\n');
550
+ fs.writeFileSync(env.statePath, '# state\n');
551
+
552
+ const commitsBefore = countCommits(env.repoDir);
553
+ const res = runFinalize(env.repoDir, [
554
+ env.quickId,
555
+ '--quick-dir', env.quickDir,
556
+ '--state-path', env.statePath,
557
+ '--description', 'skip mode',
558
+ ]);
559
+ assert.equal(res.exitCode, 0, 'stderr: ' + res.stderr);
560
+ assert.ok(res.parsed);
561
+ assert.equal(res.parsed.committed, false);
562
+ assert.equal(res.parsed.commit_reason, 'skipped_commit_docs_false');
563
+ assert.deepEqual(res.parsed.files_committed, []);
564
+ const commitsAfter = countCommits(env.repoDir);
565
+ assert.equal(commitsAfter, commitsBefore, 'no new commit should have been created');
566
+ });
567
+
568
+ it('nothing-to-commit: returns committed=false, commit_reason=nothing_to_commit', () => {
569
+ env = createFinalizeEnv();
570
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-PLAN.md'), '# plan\n');
571
+ fs.writeFileSync(path.join(env.taskDir, env.quickId + '-SUMMARY.md'), '# summary\n');
572
+ fs.writeFileSync(env.statePath, '# state\n');
573
+
574
+ // First call: succeeds and commits
575
+ const first = runFinalize(env.repoDir, [
576
+ env.quickId,
577
+ '--quick-dir', env.quickDir,
578
+ '--state-path', env.statePath,
579
+ '--description', 'first call',
580
+ ]);
581
+ assert.equal(first.exitCode, 0, 'first call stderr: ' + first.stderr);
582
+ assert.equal(first.parsed.committed, true);
583
+
584
+ // Second call: same files, nothing new staged
585
+ const second = runFinalize(env.repoDir, [
586
+ env.quickId,
587
+ '--quick-dir', env.quickDir,
588
+ '--state-path', env.statePath,
589
+ '--description', 'second call',
590
+ ]);
591
+ assert.equal(second.exitCode, 0, 'second call stderr: ' + second.stderr);
592
+ assert.ok(second.parsed);
593
+ assert.equal(second.parsed.committed, false);
594
+ assert.equal(second.parsed.commit_reason, 'nothing_to_commit');
595
+ });
596
+ });
@@ -63,6 +63,7 @@ function parseReposMd(cwd) {
63
63
  path: cells[2] || '',
64
64
  url: cells[3] || '',
65
65
  description: cells[4] || '',
66
+ setup: (cells[5] || '').trim(),
66
67
  });
67
68
  }
68
69
 
@@ -1106,6 +1107,7 @@ function cmdReposInitProduct(cwd, options, raw) {
1106
1107
  }
1107
1108
  fs.mkdirSync(path.join(getPlanningRoot(cwd), 'specs'), { recursive: true });
1108
1109
  fs.mkdirSync(path.join(getPlanningRoot(cwd), 'docs', 'product'), { recursive: true });
1110
+ fs.mkdirSync(path.join(getPlanningRoot(cwd), 'quick'), { recursive: true });
1109
1111
  error('Product already initialized. Use /dgs:progress to see status.');
1110
1112
  }
1111
1113
 
@@ -1119,6 +1121,7 @@ function cmdReposInitProduct(cwd, options, raw) {
1119
1121
  }
1120
1122
  fs.mkdirSync(path.join(getPlanningRoot(cwd), 'specs'), { recursive: true });
1121
1123
  fs.mkdirSync(path.join(getPlanningRoot(cwd), 'docs', 'product'), { recursive: true });
1124
+ fs.mkdirSync(path.join(getPlanningRoot(cwd), 'quick'), { recursive: true });
1122
1125
 
1123
1126
  // Add .gitkeep files to empty directories so they survive git commit
1124
1127
  const gitkeepPaths = [
@@ -1127,6 +1130,7 @@ function cmdReposInitProduct(cwd, options, raw) {
1127
1130
  path.join(getPlanningRoot(cwd), 'ideas', 'done', '.gitkeep'),
1128
1131
  path.join(getPlanningRoot(cwd), 'specs', '.gitkeep'),
1129
1132
  path.join(getPlanningRoot(cwd), 'docs', 'product', '.gitkeep'),
1133
+ path.join(getPlanningRoot(cwd), 'quick', '.gitkeep'),
1130
1134
  ];
1131
1135
  for (const gk of gitkeepPaths) {
1132
1136
  if (!fs.existsSync(gk)) {
@@ -1150,6 +1154,25 @@ function cmdReposInitProduct(cwd, options, raw) {
1150
1154
  const siblingRepos = discoverSiblingRepos(cwd);
1151
1155
  const allRepos = [...discoveredRepos, ...siblingRepos];
1152
1156
 
1157
+ // Bootstrap empty code repos so git worktree creation works later.
1158
+ // git worktree add requires at least one commit (a HEAD to branch from).
1159
+ const bootstrappedRepos = [];
1160
+ for (const repo of allRepos) {
1161
+ const repoPath = path.resolve(cwd, repo.path);
1162
+ if (!fs.existsSync(path.join(repoPath, '.git'))) continue;
1163
+ const logResult = execGit(repoPath, ['rev-parse', 'HEAD']);
1164
+ if (logResult.exitCode !== 0) {
1165
+ // Empty repo — no commits yet
1166
+ const readmePath = path.join(repoPath, 'README.md');
1167
+ if (!fs.existsSync(readmePath)) {
1168
+ fs.writeFileSync(readmePath, `# ${repo.name}\n`, 'utf-8');
1169
+ }
1170
+ execGit(repoPath, ['add', 'README.md']);
1171
+ execGit(repoPath, ['commit', '-m', 'chore: initial commit']);
1172
+ bootstrappedRepos.push(repo.name);
1173
+ }
1174
+ }
1175
+
1153
1176
  // Derive product name
1154
1177
  const productName = options.productName || path.basename(cwd);
1155
1178
 
@@ -1178,7 +1201,8 @@ function cmdReposInitProduct(cwd, options, raw) {
1178
1201
  ideas_dirs_created: true,
1179
1202
  specs_dir_created: true,
1180
1203
  docs_dir_created: true,
1181
- files_created: ['config.json', 'config.local.json', 'REPOS.md', 'PROJECTS.md', 'ideas/', 'specs/', 'docs/', '.gitignore', 'review-keys.json'],
1204
+ bootstrapped_repos: bootstrappedRepos,
1205
+ files_created: ['config.json', 'config.local.json', 'REPOS.md', 'PROJECTS.md', 'ideas/', 'specs/', 'docs/', 'quick/', '.gitignore', 'review-keys.json'],
1182
1206
  }, raw);
1183
1207
  }
1184
1208