gsd-pi 2.70.1-dev.3591dcf → 2.70.1-dev.3a83e16

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 (145) hide show
  1. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +129 -30
  2. package/dist/resources/extensions/get-secrets-from-user.js +17 -1
  3. package/dist/resources/extensions/gsd/auto-start.js +3 -11
  4. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +4 -0
  5. package/dist/resources/extensions/gsd/custom-workflow-engine.js +16 -12
  6. package/dist/resources/extensions/gsd/file-lock.js +60 -0
  7. package/dist/resources/extensions/gsd/guided-flow.js +12 -10
  8. package/dist/resources/extensions/gsd/init-wizard.js +3 -11
  9. package/dist/resources/extensions/gsd/prompts/discuss.md +31 -13
  10. package/dist/resources/extensions/gsd/state.js +234 -332
  11. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +34 -0
  12. package/dist/resources/extensions/gsd/workflow-events.js +25 -13
  13. package/dist/resources/extensions/gsd/workflow-mcp-auto-prep.js +56 -0
  14. package/dist/resources/extensions/gsd/workflow-mcp.js +1 -1
  15. package/dist/web/standalone/.next/BUILD_ID +1 -1
  16. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  17. package/dist/web/standalone/.next/build-manifest.json +3 -3
  18. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  19. package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.html +1 -1
  37. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  44. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  45. package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
  46. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  47. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  48. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  49. package/dist/web/standalone/.next/static/chunks/2826.dd3dc8bbd3025fa5.js +9 -0
  50. package/dist/web/standalone/.next/static/chunks/{webpack-6e4d7e9a4f57bed4.js → webpack-b868033a5834586d.js} +1 -1
  51. package/package.json +1 -1
  52. package/packages/mcp-server/dist/env-writer.d.ts +39 -0
  53. package/packages/mcp-server/dist/env-writer.d.ts.map +1 -0
  54. package/packages/mcp-server/dist/env-writer.js +158 -0
  55. package/packages/mcp-server/dist/env-writer.js.map +1 -0
  56. package/packages/mcp-server/dist/server.d.ts +11 -2
  57. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  58. package/packages/mcp-server/dist/server.js +102 -2
  59. package/packages/mcp-server/dist/server.js.map +1 -1
  60. package/packages/mcp-server/src/env-writer.test.ts +280 -0
  61. package/packages/mcp-server/src/env-writer.ts +183 -0
  62. package/packages/mcp-server/src/secure-env-collect.test.ts +265 -0
  63. package/packages/mcp-server/src/server.ts +137 -3
  64. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.d.ts +2 -0
  65. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.d.ts.map +1 -0
  66. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +310 -0
  67. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -0
  68. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -0
  69. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  71. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts +19 -2
  72. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  73. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js +50 -1
  74. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  75. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -0
  76. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
  77. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +1 -0
  78. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
  79. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  80. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +158 -23
  81. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  82. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +1 -0
  83. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
  84. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
  85. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +6 -0
  86. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  87. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +58 -2
  88. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  89. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +1 -1
  90. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  91. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts +1 -0
  92. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  93. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.js.map +1 -1
  94. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +370 -0
  95. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
  96. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +58 -2
  97. package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +2 -0
  98. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +189 -29
  99. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
  100. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +66 -2
  101. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +1 -1
  102. package/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts +1 -0
  103. package/packages/pi-tui/dist/components/__tests__/input.test.js +9 -0
  104. package/packages/pi-tui/dist/components/__tests__/input.test.js.map +1 -1
  105. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.d.ts +2 -0
  106. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.d.ts.map +1 -0
  107. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.js +66 -0
  108. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.js.map +1 -0
  109. package/packages/pi-tui/dist/components/input.d.ts +2 -0
  110. package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
  111. package/packages/pi-tui/dist/components/input.js +7 -4
  112. package/packages/pi-tui/dist/components/input.js.map +1 -1
  113. package/packages/pi-tui/dist/components/markdown.d.ts +3 -0
  114. package/packages/pi-tui/dist/components/markdown.d.ts.map +1 -1
  115. package/packages/pi-tui/dist/components/markdown.js +17 -1
  116. package/packages/pi-tui/dist/components/markdown.js.map +1 -1
  117. package/packages/pi-tui/src/components/__tests__/input.test.ts +11 -0
  118. package/packages/pi-tui/src/components/__tests__/markdown-maxlines.test.ts +75 -0
  119. package/packages/pi-tui/src/components/input.ts +7 -4
  120. package/packages/pi-tui/src/components/markdown.ts +22 -1
  121. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +166 -31
  122. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +145 -0
  123. package/src/resources/extensions/get-secrets-from-user.ts +24 -1
  124. package/src/resources/extensions/gsd/auto-start.ts +3 -13
  125. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +4 -0
  126. package/src/resources/extensions/gsd/custom-workflow-engine.ts +19 -14
  127. package/src/resources/extensions/gsd/file-lock.ts +59 -0
  128. package/src/resources/extensions/gsd/guided-flow.ts +12 -9
  129. package/src/resources/extensions/gsd/init-wizard.ts +3 -13
  130. package/src/resources/extensions/gsd/prompts/discuss.md +31 -13
  131. package/src/resources/extensions/gsd/state.ts +274 -344
  132. package/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts +436 -0
  133. package/src/resources/extensions/gsd/tests/discuss-incremental-persistence.test.ts +9 -0
  134. package/src/resources/extensions/gsd/tests/file-lock.test.ts +103 -0
  135. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +45 -0
  136. package/src/resources/extensions/gsd/tests/workflow-mcp-auto-prep.test.ts +76 -0
  137. package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +155 -1
  138. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +22 -0
  139. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +60 -25
  140. package/src/resources/extensions/gsd/workflow-events.ts +34 -25
  141. package/src/resources/extensions/gsd/workflow-mcp-auto-prep.ts +76 -0
  142. package/src/resources/extensions/gsd/workflow-mcp.ts +1 -1
  143. package/dist/web/standalone/.next/static/chunks/2826.821e01b07d92e948.js +0 -9
  144. /package/dist/web/standalone/.next/static/{KdlODhIktLmeRKpLpHdKb → h7XsQBaK7XWBBC0Nu5f5P}/_buildManifest.js +0 -0
  145. /package/dist/web/standalone/.next/static/{KdlODhIktLmeRKpLpHdKb → h7XsQBaK7XWBBC0Nu5f5P}/_ssgManifest.js +0 -0
@@ -0,0 +1,436 @@
1
+ // GSD Extension — Tests for extracted deriveStateFromDb helper functions
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+ //
4
+ // Tests the composable helpers extracted from deriveStateFromDb:
5
+ // reconcileDiskToDb, buildCompletenessSet, buildRegistryAndFindActive,
6
+ // handleNoActiveMilestone, resolveSliceDependencies, reconcileSliceTasks,
7
+ // detectBlockers, checkReplanTrigger, checkInterruptedWork
8
+ //
9
+ // Helpers are private — exercised through deriveStateFromDb integration.
10
+
11
+ import { describe, test, beforeEach, afterEach } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { tmpdir } from 'node:os';
16
+
17
+ import { invalidateStateCache, deriveStateFromDb } from '../state.ts';
18
+ import {
19
+ openDatabase,
20
+ closeDatabase,
21
+ insertMilestone,
22
+ insertSlice,
23
+ insertTask,
24
+ updateTaskStatus,
25
+ } from '../gsd-db.ts';
26
+
27
+ // ─── Fixture Helpers ───────────────────────────────────────────────────────
28
+
29
+ function createFixtureBase(): string {
30
+ const base = mkdtempSync(join(tmpdir(), 'gsd-helpers-'));
31
+ mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
32
+ return base;
33
+ }
34
+
35
+ function writeFile(base: string, relativePath: string, content: string): void {
36
+ const full = join(base, '.gsd', relativePath);
37
+ mkdirSync(join(full, '..'), { recursive: true });
38
+ writeFileSync(full, content);
39
+ }
40
+
41
+ function cleanup(base: string): void {
42
+ rmSync(base, { recursive: true, force: true });
43
+ }
44
+
45
+ const ROADMAP_CONTENT = `# M001: Test Milestone
46
+
47
+ **Vision:** Test helpers.
48
+
49
+ ## Slices
50
+
51
+ - [ ] **S01: First Slice** \`risk:low\` \`depends:[]\`
52
+ > After this: Slice done.
53
+
54
+ - [ ] **S02: Second Slice** \`risk:low\` \`depends:[S01]\`
55
+ > After this: All done.
56
+ `;
57
+
58
+ const PLAN_CONTENT = `# S01: First Slice
59
+
60
+ **Goal:** Test executing.
61
+ **Demo:** Tests pass.
62
+
63
+ ## Tasks
64
+
65
+ - [ ] **T01: First Task** \`est:10m\`
66
+ First task description.
67
+
68
+ - [x] **T02: Done Task** \`est:10m\`
69
+ Already done.
70
+ `;
71
+
72
+ // ═══════════════════════════════════════════════════════════════════════════
73
+ // Tests
74
+ // ═══════════════════════════════════════════════════════════════════════════
75
+
76
+ describe('derive-state-helpers', () => {
77
+
78
+ // ─── handleNoActiveMilestone: all parked ─────────────────────────────
79
+ test('handleNoActiveMilestone: all milestones parked returns pre-planning with unpark hint', async () => {
80
+ const base = createFixtureBase();
81
+ try {
82
+ writeFile(base, 'milestones/M001/M001-CONTEXT.md', '# M001\n\nContext.');
83
+ writeFile(base, 'milestones/M001/M001-PARKED.md', 'Parked.');
84
+ writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nContext.');
85
+ writeFile(base, 'milestones/M002/M002-PARKED.md', 'Also parked.');
86
+
87
+ openDatabase(':memory:');
88
+ insertMilestone({ id: 'M001', title: 'First', status: 'parked' });
89
+ insertMilestone({ id: 'M002', title: 'Second', status: 'parked' });
90
+
91
+ invalidateStateCache();
92
+ const state = await deriveStateFromDb(base);
93
+
94
+ assert.equal(state.phase, 'pre-planning', 'all-parked: phase is pre-planning');
95
+ assert.equal(state.activeMilestone, null, 'all-parked: no active milestone');
96
+ assert.ok(state.nextAction.includes('parked'), 'all-parked: nextAction mentions parked');
97
+ assert.ok(state.nextAction.includes('unpark'), 'all-parked: nextAction hints unpark');
98
+ assert.equal(state.registry.length, 2, 'all-parked: both in registry');
99
+ assert.ok(state.registry.every(e => e.status === 'parked'), 'all-parked: all registry entries parked');
100
+ } finally {
101
+ closeDatabase();
102
+ cleanup(base);
103
+ }
104
+ });
105
+
106
+ // ─── handleNoActiveMilestone: all complete with active requirements ──
107
+ test('handleNoActiveMilestone: all complete with unmapped requirements', async () => {
108
+ const base = createFixtureBase();
109
+ try {
110
+ writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.');
111
+ writeFile(base, 'REQUIREMENTS.md', `# Requirements\n\n## Active\n\n### R001 — Unmapped\n- Status: active\n- Description: Not mapped.\n`);
112
+
113
+ openDatabase(':memory:');
114
+ insertMilestone({ id: 'M001', title: 'First', status: 'complete' });
115
+
116
+ invalidateStateCache();
117
+ const state = await deriveStateFromDb(base);
118
+
119
+ assert.equal(state.phase, 'complete', 'complete-reqs: phase is complete');
120
+ assert.ok(state.nextAction.includes('1 active requirement'), 'complete-reqs: nextAction notes unmapped reqs');
121
+ assert.equal(state.requirements?.active, 1, 'complete-reqs: requirements.active = 1');
122
+ } finally {
123
+ closeDatabase();
124
+ cleanup(base);
125
+ }
126
+ });
127
+
128
+ // ─── resolveSliceDependencies: GSD_SLICE_LOCK with missing slice ────
129
+ test('resolveSliceDependencies: GSD_SLICE_LOCK pointing to non-existent slice returns blocked', async () => {
130
+ const base = createFixtureBase();
131
+ const origLock = process.env.GSD_SLICE_LOCK;
132
+ try {
133
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
134
+ writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
135
+ writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
136
+ writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
137
+
138
+ openDatabase(':memory:');
139
+ insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
140
+ insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
141
+ insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
142
+
143
+ process.env.GSD_SLICE_LOCK = 'S99';
144
+
145
+ invalidateStateCache();
146
+ const state = await deriveStateFromDb(base);
147
+
148
+ assert.equal(state.phase, 'blocked', 'slice-lock-miss: phase is blocked');
149
+ assert.ok(state.blockers.some(b => b.includes('GSD_SLICE_LOCK=S99')), 'slice-lock-miss: blocker mentions lock');
150
+ } finally {
151
+ if (origLock !== undefined) process.env.GSD_SLICE_LOCK = origLock;
152
+ else delete process.env.GSD_SLICE_LOCK;
153
+ closeDatabase();
154
+ cleanup(base);
155
+ }
156
+ });
157
+
158
+ // ─── resolveSliceDependencies: GSD_SLICE_LOCK with valid slice ──────
159
+ test('resolveSliceDependencies: GSD_SLICE_LOCK targeting valid slice bypasses deps', async () => {
160
+ const base = createFixtureBase();
161
+ const origLock = process.env.GSD_SLICE_LOCK;
162
+ try {
163
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
164
+ // S02 depends on S01 but we lock to S02 directly
165
+ writeFile(base, 'milestones/M001/slices/S02/S02-PLAN.md', `# S02\n\n**Goal:** Test.\n**Demo:** Pass.\n\n## Tasks\n\n- [ ] **T01: Task** \`est:5m\`\n Do thing.\n`);
166
+ writeFile(base, 'milestones/M001/slices/S02/tasks/.gitkeep', '');
167
+ writeFile(base, 'milestones/M001/slices/S02/tasks/T01-PLAN.md', '# T01 Plan');
168
+
169
+ openDatabase(':memory:');
170
+ insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
171
+ insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'pending', risk: 'low', depends: [] });
172
+ insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
173
+ insertTask({ id: 'T01', sliceId: 'S02', milestoneId: 'M001', title: 'Task', status: 'pending' });
174
+
175
+ process.env.GSD_SLICE_LOCK = 'S02';
176
+
177
+ invalidateStateCache();
178
+ const state = await deriveStateFromDb(base);
179
+
180
+ assert.equal(state.activeSlice?.id, 'S02', 'slice-lock-valid: activeSlice is S02 (locked)');
181
+ assert.equal(state.phase, 'executing', 'slice-lock-valid: phase is executing');
182
+ } finally {
183
+ if (origLock !== undefined) process.env.GSD_SLICE_LOCK = origLock;
184
+ else delete process.env.GSD_SLICE_LOCK;
185
+ closeDatabase();
186
+ cleanup(base);
187
+ }
188
+ });
189
+
190
+ // ─── reconcileSliceTasks: plan file imports tasks when DB empty ──────
191
+ test('reconcileSliceTasks: imports tasks from plan file when DB has zero tasks (#3600)', async () => {
192
+ const base = createFixtureBase();
193
+ try {
194
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
195
+ writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
196
+ writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
197
+ writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
198
+
199
+ openDatabase(':memory:');
200
+ insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
201
+ insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
202
+ insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
203
+ // No tasks inserted — reconcileSliceTasks should import from plan file
204
+
205
+ invalidateStateCache();
206
+ const state = await deriveStateFromDb(base);
207
+
208
+ // Plan has T01 (pending) and T02 (done) — reconciliation imports both
209
+ assert.equal(state.phase, 'executing', 'task-reconcile: phase is executing (tasks imported)');
210
+ assert.equal(state.activeTask?.id, 'T01', 'task-reconcile: activeTask is T01');
211
+ assert.equal(state.progress?.tasks?.total, 2, 'task-reconcile: total tasks = 2');
212
+ assert.equal(state.progress?.tasks?.done, 1, 'task-reconcile: done tasks = 1 (T02 was [x])');
213
+ } finally {
214
+ closeDatabase();
215
+ cleanup(base);
216
+ }
217
+ });
218
+
219
+ // ─── reconcileSliceTasks: stale task reconciled from disk summary ────
220
+ test('reconcileSliceTasks: stale pending task reconciled to complete when disk SUMMARY exists (#2514)', async () => {
221
+ const base = createFixtureBase();
222
+ try {
223
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
224
+ writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
225
+ writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
226
+ writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
227
+ // T01 has a summary on disk but DB still says pending
228
+ writeFile(base, 'milestones/M001/slices/S01/tasks/T01-SUMMARY.md', '# T01 Summary\n\nDone on disk.');
229
+
230
+ openDatabase(':memory:');
231
+ insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
232
+ insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
233
+ insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
234
+ insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
235
+ insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
236
+
237
+ invalidateStateCache();
238
+ const state = await deriveStateFromDb(base);
239
+
240
+ // T01 should have been reconciled to complete (SUMMARY exists on disk)
241
+ // Both tasks complete → phase should be summarizing
242
+ assert.equal(state.phase, 'summarizing', 'stale-task: phase is summarizing (T01 reconciled)');
243
+ assert.equal(state.activeTask, null, 'stale-task: no active task (all done)');
244
+ assert.equal(state.progress?.tasks?.done, 2, 'stale-task: tasks.done = 2');
245
+ } finally {
246
+ closeDatabase();
247
+ cleanup(base);
248
+ }
249
+ });
250
+
251
+ // ─── detectBlockers: blocker_discovered triggers replanning ──────────
252
+ test('detectBlockers: task with blocker_discovered triggers replanning-slice', async () => {
253
+ const base = createFixtureBase();
254
+ try {
255
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
256
+ writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
257
+ writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
258
+ writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
259
+ // T02 completed with blocker discovered — written in summary frontmatter
260
+ writeFile(base, 'milestones/M001/slices/S01/tasks/T02-SUMMARY.md',
261
+ '---\nblocker_discovered: true\n---\n\n# T02 Summary\n\nFound a blocker.');
262
+
263
+ openDatabase(':memory:');
264
+ insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
265
+ insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
266
+ insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
267
+ insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
268
+ insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
269
+
270
+ invalidateStateCache();
271
+ const state = await deriveStateFromDb(base);
272
+
273
+ assert.equal(state.phase, 'replanning-slice', 'blocker: phase is replanning-slice');
274
+ assert.ok(state.blockers.some(b => b.includes('T02')), 'blocker: blockers mention T02');
275
+ } finally {
276
+ closeDatabase();
277
+ cleanup(base);
278
+ }
279
+ });
280
+
281
+ // ─── checkInterruptedWork: continue.md triggers resume hint ─────────
282
+ test('checkInterruptedWork: continue.md present triggers resume nextAction', async () => {
283
+ const base = createFixtureBase();
284
+ try {
285
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
286
+ writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
287
+ writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
288
+ writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
289
+ writeFile(base, 'milestones/M001/slices/S01/S01-CONTINUE.md', 'Resume from here.');
290
+
291
+ openDatabase(':memory:');
292
+ insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
293
+ insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
294
+ insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
295
+ insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
296
+ insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
297
+
298
+ invalidateStateCache();
299
+ const state = await deriveStateFromDb(base);
300
+
301
+ assert.equal(state.phase, 'executing', 'continue: phase is still executing');
302
+ assert.ok(state.nextAction.includes('Resume interrupted work'), 'continue: nextAction mentions resume');
303
+ assert.ok(state.nextAction.includes('continue.md'), 'continue: nextAction mentions continue.md');
304
+ } finally {
305
+ closeDatabase();
306
+ cleanup(base);
307
+ }
308
+ });
309
+
310
+ // ─── buildCompletenessSet: SUMMARY-on-disk marks complete ───────────
311
+ test('buildCompletenessSet: milestone with SUMMARY on disk treated as complete', async () => {
312
+ const base = createFixtureBase();
313
+ try {
314
+ // M001 has summary on disk but DB status is still 'active'
315
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
316
+ writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.');
317
+ // M002 is the real active milestone
318
+ writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nActive.');
319
+
320
+ openDatabase(':memory:');
321
+ insertMilestone({ id: 'M001', title: 'First', status: 'active' });
322
+ insertMilestone({ id: 'M002', title: 'Second', status: 'active' });
323
+
324
+ invalidateStateCache();
325
+ const state = await deriveStateFromDb(base);
326
+
327
+ // M001 should be complete (summary on disk), M002 should be active
328
+ const m1 = state.registry.find(e => e.id === 'M001');
329
+ assert.equal(m1?.status, 'complete', 'summary-disk: M001 marked complete via disk SUMMARY');
330
+ assert.equal(state.activeMilestone?.id, 'M002', 'summary-disk: M002 is active');
331
+ } finally {
332
+ closeDatabase();
333
+ cleanup(base);
334
+ }
335
+ });
336
+
337
+ // ─── reconcileDiskToDb: disk slices synced into DB (#2533) ──────────
338
+ test('reconcileDiskToDb: slices in ROADMAP.md but missing from DB are auto-inserted (#2533)', async () => {
339
+ const base = createFixtureBase();
340
+ try {
341
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
342
+
343
+ openDatabase(':memory:');
344
+ insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
345
+ // No slices inserted — reconcileDiskToDb should insert from roadmap
346
+
347
+ invalidateStateCache();
348
+ const state = await deriveStateFromDb(base);
349
+
350
+ // Slices should have been reconciled from roadmap, S01 should be the active slice
351
+ assert.equal(state.activeMilestone?.id, 'M001', 'slice-reconcile: M001 is active');
352
+ assert.equal(state.activeSlice?.id, 'S01', 'slice-reconcile: S01 reconciled and active');
353
+ assert.ok((state.progress?.slices?.total ?? 0) >= 2, 'slice-reconcile: at least 2 slices reconciled');
354
+ } finally {
355
+ closeDatabase();
356
+ cleanup(base);
357
+ }
358
+ });
359
+
360
+ // ─── Queue order: milestones sorted by custom queue order ───────────
361
+ test('deriveStateFromDb respects custom queue order from QUEUE-ORDER.json', async () => {
362
+ const base = createFixtureBase();
363
+ try {
364
+ // M003 should come first per queue order, M001 second
365
+ const queueOrder = JSON.stringify({ order: ['M003', 'M001', 'M002'], updatedAt: new Date().toISOString() });
366
+ writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), queueOrder);
367
+ writeFile(base, 'milestones/M001/M001-CONTEXT.md', '# M001\n\nContext.');
368
+ writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nContext.');
369
+ writeFile(base, 'milestones/M003/M003-CONTEXT.md', '# M003\n\nContext.');
370
+
371
+ openDatabase(':memory:');
372
+ // Insert in natural order — queue ordering should override
373
+ insertMilestone({ id: 'M001', title: 'First', status: 'active' });
374
+ insertMilestone({ id: 'M002', title: 'Second', status: 'active' });
375
+ insertMilestone({ id: 'M003', title: 'Third', status: 'active' });
376
+
377
+ invalidateStateCache();
378
+ const state = await deriveStateFromDb(base);
379
+
380
+ // M003 should be the active milestone (first in queue)
381
+ assert.equal(state.activeMilestone?.id, 'M003', 'queue-order: M003 is active (first in queue)');
382
+ assert.equal(state.registry[0]?.id, 'M003', 'queue-order: registry[0] is M003');
383
+ } finally {
384
+ closeDatabase();
385
+ cleanup(base);
386
+ }
387
+ });
388
+
389
+ // ─── handleAllSlicesDone: needs-remediation re-triggers validation ──
390
+ test('handleAllSlicesDone: needs-remediation verdict triggers validating-milestone', async () => {
391
+ const base = createFixtureBase();
392
+ try {
393
+ const doneRoadmap = `# M001: Remediation Test\n\n**Vision:** Test.\n\n## Slices\n\n- [x] **S01: Done** \`risk:low\` \`depends:[]\`\n > Done.\n`;
394
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', doneRoadmap);
395
+ writeFile(base, 'milestones/M001/M001-VALIDATION.md',
396
+ '---\nverdict: needs-remediation\nremediation_round: 1\n---\n\n# Validation\nNeeds remediation.');
397
+
398
+ openDatabase(':memory:');
399
+ insertMilestone({ id: 'M001', title: 'Remediation Test', status: 'active' });
400
+ insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Done', status: 'complete', risk: 'low', depends: [] });
401
+
402
+ invalidateStateCache();
403
+ const state = await deriveStateFromDb(base);
404
+
405
+ assert.equal(state.phase, 'validating-milestone', 'remediation: phase is validating-milestone');
406
+ assert.equal(state.activeMilestone?.id, 'M001', 'remediation: activeMilestone is M001');
407
+ } finally {
408
+ closeDatabase();
409
+ cleanup(base);
410
+ }
411
+ });
412
+
413
+ // ─── Deferred queued shell: shell milestone deferred, real one promoted ──
414
+ test('buildRegistryAndFindActive: queued shell deferred, later real milestone becomes active (#3470)', async () => {
415
+ const base = createFixtureBase();
416
+ try {
417
+ // M001: queued shell — no content, no slices
418
+ mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
419
+ // M002: real milestone with context
420
+ writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002: Real\n\nActive milestone.');
421
+
422
+ openDatabase(':memory:');
423
+ insertMilestone({ id: 'M001', title: 'Shell', status: 'queued' });
424
+ insertMilestone({ id: 'M002', title: 'Real', status: 'active' });
425
+
426
+ invalidateStateCache();
427
+ const state = await deriveStateFromDb(base);
428
+
429
+ // M002 should be active (M001 queued shell deferred)
430
+ assert.equal(state.activeMilestone?.id, 'M002', 'deferred-shell: M002 is active (shell deferred)');
431
+ } finally {
432
+ closeDatabase();
433
+ cleanup(base);
434
+ }
435
+ });
436
+ });
@@ -27,10 +27,19 @@ describe("discuss incremental persistence (#2152)", () => {
27
27
  assert.match(content, /Incremental persistence/, "should have incremental persistence section");
28
28
  });
29
29
 
30
+ test("new-project discuss prompt includes CONTEXT-DRAFT save instruction", () => {
31
+ const content = readFileSync(join(promptsDir, "discuss.md"), "utf-8");
32
+ assert.match(content, /CONTEXT-DRAFT/, "should mention CONTEXT-DRAFT");
33
+ assert.match(content, /Incremental persistence/, "should have incremental persistence section");
34
+ assert.match(content, /gsd_summary_save/, "should use gsd_summary_save tool");
35
+ });
36
+
30
37
  test("drafts are saved silently without user notification", () => {
31
38
  const milestone = readFileSync(join(promptsDir, "guided-discuss-milestone.md"), "utf-8");
32
39
  const slice = readFileSync(join(promptsDir, "guided-discuss-slice.md"), "utf-8");
40
+ const discuss = readFileSync(join(promptsDir, "discuss.md"), "utf-8");
33
41
  assert.match(milestone, /Do NOT mention this save to the user/);
34
42
  assert.match(slice, /Do NOT mention this to the user/);
43
+ assert.match(discuss, /Do NOT mention this save to the user/);
35
44
  });
36
45
  });
@@ -0,0 +1,103 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+
8
+ import { withFileLock, withFileLockSync } from "../file-lock.ts";
9
+
10
+ const require = createRequire(import.meta.url);
11
+
12
+ function hasProperLockfile(): boolean {
13
+ try {
14
+ require("proper-lockfile");
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ test("withFileLockSync: executes callback when file does not exist", () => {
22
+ const dir = mkdtempSync(join(tmpdir(), "gsd-file-lock-test-"));
23
+ try {
24
+ const missingPath = join(dir, "missing.txt");
25
+ let called = 0;
26
+ const result = withFileLockSync(missingPath, () => {
27
+ called++;
28
+ return "ok";
29
+ });
30
+
31
+ assert.equal(result, "ok");
32
+ assert.equal(called, 1, "callback should execute exactly once");
33
+ } finally {
34
+ rmSync(dir, { recursive: true, force: true });
35
+ }
36
+ });
37
+
38
+ test("withFileLock: executes callback when file does not exist", async () => {
39
+ const dir = mkdtempSync(join(tmpdir(), "gsd-file-lock-test-"));
40
+ try {
41
+ const missingPath = join(dir, "missing.txt");
42
+ let called = 0;
43
+ const result = await withFileLock(missingPath, async () => {
44
+ called++;
45
+ return "ok";
46
+ });
47
+
48
+ assert.equal(result, "ok");
49
+ assert.equal(called, 1, "callback should execute exactly once");
50
+ } finally {
51
+ rmSync(dir, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ test("withFileLockSync: falls back to unlocked callback on ELOCKED", () => {
56
+ if (!hasProperLockfile() || process.platform === "win32") {
57
+ return;
58
+ }
59
+
60
+ const lockfile = require("proper-lockfile");
61
+ const dir = mkdtempSync(join(tmpdir(), "gsd-file-lock-test-"));
62
+ const filePath = join(dir, "locked.jsonl");
63
+ writeFileSync(filePath, "{}\n", "utf-8");
64
+
65
+ const release = lockfile.lockSync(filePath, { retries: 0, stale: 10000 });
66
+ try {
67
+ let called = 0;
68
+ const result = withFileLockSync(filePath, () => {
69
+ called++;
70
+ return "fallback-ok";
71
+ });
72
+ assert.equal(result, "fallback-ok");
73
+ assert.equal(called, 1, "callback should run even when lock acquisition fails");
74
+ } finally {
75
+ release();
76
+ rmSync(dir, { recursive: true, force: true });
77
+ }
78
+ });
79
+
80
+ test("withFileLock: falls back to unlocked callback on ELOCKED", async () => {
81
+ if (!hasProperLockfile() || process.platform === "win32") {
82
+ return;
83
+ }
84
+
85
+ const lockfile = require("proper-lockfile");
86
+ const dir = mkdtempSync(join(tmpdir(), "gsd-file-lock-test-"));
87
+ const filePath = join(dir, "locked.jsonl");
88
+ writeFileSync(filePath, "{}\n", "utf-8");
89
+
90
+ const release = await lockfile.lock(filePath, { retries: 0, stale: 10000 });
91
+ try {
92
+ let called = 0;
93
+ const result = await withFileLock(filePath, async () => {
94
+ called++;
95
+ return "fallback-ok";
96
+ });
97
+ assert.equal(result, "fallback-ok");
98
+ assert.equal(called, 1, "callback should run even when lock acquisition fails");
99
+ } finally {
100
+ await release();
101
+ rmSync(dir, { recursive: true, force: true });
102
+ }
103
+ });
@@ -317,3 +317,48 @@ test("secure_env_collect #2997: null from ctx.ui.custom() is still treated as sk
317
317
  "Key returning null must NOT be in applied list",
318
318
  );
319
319
  });
320
+
321
+ test("secure_env_collect: falls back to secure input prompt when custom UI is unavailable", async (t) => {
322
+ const { collectSecretsFromManifest } = await loadOrchestrator();
323
+
324
+ const tmp = makeTempDir("sec-input-fallback-test");
325
+ t.after(() => {
326
+ rmSync(tmp, { recursive: true, force: true });
327
+ });
328
+
329
+ const manifest = makeManifest([
330
+ { key: "SECRET_FROM_INPUT_FALLBACK", status: "pending", formatHint: "starts with sk-" },
331
+ ]);
332
+ await writeManifestFile(tmp, manifest);
333
+
334
+ let callIndex = 0;
335
+ const inputCalls: Array<{ title: string; placeholder?: string; opts?: { secure?: boolean } }> = [];
336
+ const mockCtx = {
337
+ cwd: tmp,
338
+ hasUI: true,
339
+ ui: {
340
+ custom: async (_factory: any) => {
341
+ callIndex++;
342
+ if (callIndex <= 1) return null; // summary screen dismiss
343
+ return undefined; // collect screen unavailable on this surface
344
+ },
345
+ input: async (title: string, placeholder?: string, opts?: { secure?: boolean }) => {
346
+ inputCalls.push({ title, placeholder, opts });
347
+ return " sk-test-fallback-value ";
348
+ },
349
+ },
350
+ };
351
+
352
+ const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
353
+
354
+ assert.ok(
355
+ result.applied.includes("SECRET_FROM_INPUT_FALLBACK"),
356
+ "Fallback input should collect and apply the key",
357
+ );
358
+ assert.ok(
359
+ !result.skipped.includes("SECRET_FROM_INPUT_FALLBACK"),
360
+ "Fallback input should not mark the key as skipped",
361
+ );
362
+ assert.equal(inputCalls.length, 1, "Fallback input should be requested once");
363
+ assert.equal(inputCalls[0]?.opts?.secure, true, "Fallback input should request secure entry when supported");
364
+ });