@opengsd/gsd-pi 1.9.0-dev.5c1a3ff2 → 1.9.0-dev.97ef11e3

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 (173) hide show
  1. package/dist/headless.js +2 -1
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/gsd/auto/loop.js +24 -4
  4. package/dist/resources/extensions/gsd/auto/run-unit.js +19 -1
  5. package/dist/resources/extensions/gsd/auto/unit-phase.js +37 -5
  6. package/dist/resources/extensions/gsd/auto-dispatch.js +2 -0
  7. package/dist/resources/extensions/gsd/auto-prompts.js +12 -4
  8. package/dist/resources/extensions/gsd/auto-runtime-state.js +5 -5
  9. package/dist/resources/extensions/gsd/auto-start.js +29 -9
  10. package/dist/resources/extensions/gsd/auto-tool-tracking.js +40 -0
  11. package/dist/resources/extensions/gsd/auto-worktree-sync.js +7 -3
  12. package/dist/resources/extensions/gsd/auto.js +15 -5
  13. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +2 -2
  14. package/dist/resources/extensions/gsd/commands-verdict.js +10 -9
  15. package/dist/resources/extensions/gsd/compat/compat-marker.js +80 -5
  16. package/dist/resources/extensions/gsd/db/engine.js +5 -2
  17. package/dist/resources/extensions/gsd/doctor-engine-checks.js +21 -2
  18. package/dist/resources/extensions/gsd/error-classifier.js +12 -2
  19. package/dist/resources/extensions/gsd/exec-sandbox.js +1 -0
  20. package/dist/resources/extensions/gsd/flat-phase-migration.js +12 -0
  21. package/dist/resources/extensions/gsd/gsd-db.js +75 -31
  22. package/dist/resources/extensions/gsd/guided-flow.js +2 -2
  23. package/dist/resources/extensions/gsd/markdown-renderer.js +3 -9
  24. package/dist/resources/extensions/gsd/paths.js +17 -7
  25. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +5 -5
  26. package/dist/resources/extensions/gsd/state-reconciliation/drift/external-markdown-edit.js +98 -2
  27. package/dist/resources/extensions/gsd/state-reconciliation/drift/external-planning-edit.js +4 -1
  28. package/dist/resources/extensions/gsd/state-reconciliation/drift/roadmap.js +5 -2
  29. package/dist/resources/extensions/gsd/stop-notice.js +9 -0
  30. package/dist/resources/extensions/gsd/worktree-state-projection.js +20 -18
  31. package/dist/resources/extensions/shared/interview-ui.js +43 -6
  32. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  33. package/dist/web/standalone/.next/BUILD_ID +1 -1
  34. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  35. package/dist/web/standalone/.next/build-manifest.json +2 -2
  36. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  37. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.html +1 -1
  54. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  61. package/dist/web/standalone/.next/server/chunks/1991.js +2 -2
  62. package/dist/web/standalone/.next/server/chunks/5124.js +1 -1
  63. package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
  64. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  65. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  66. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  67. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  68. package/package.json +2 -1
  69. package/packages/cloud-mcp-gateway/package.json +2 -2
  70. package/packages/contracts/package.json +1 -1
  71. package/packages/daemon/package.json +4 -4
  72. package/packages/gsd-agent-core/package.json +5 -5
  73. package/packages/gsd-agent-modes/package.json +7 -7
  74. package/packages/gsd-cloud/README.md +9 -3
  75. package/packages/gsd-cloud/dist/background-cli.test.d.ts +2 -0
  76. package/packages/gsd-cloud/dist/background-cli.test.d.ts.map +1 -0
  77. package/packages/gsd-cloud/dist/background-cli.test.js +248 -0
  78. package/packages/gsd-cloud/dist/background-cli.test.js.map +1 -0
  79. package/packages/gsd-cloud/dist/cli.d.ts.map +1 -1
  80. package/packages/gsd-cloud/dist/cli.js +121 -15
  81. package/packages/gsd-cloud/dist/cli.js.map +1 -1
  82. package/packages/gsd-cloud/dist/cloud-config.d.ts +1 -1
  83. package/packages/gsd-cloud/dist/cloud-config.d.ts.map +1 -1
  84. package/packages/gsd-cloud/dist/cloud-config.js +7 -1
  85. package/packages/gsd-cloud/dist/cloud-config.js.map +1 -1
  86. package/packages/gsd-cloud/dist/cloud-runtime.d.ts +1 -3
  87. package/packages/gsd-cloud/dist/cloud-runtime.d.ts.map +1 -1
  88. package/packages/gsd-cloud/dist/cloud-runtime.js +9 -7
  89. package/packages/gsd-cloud/dist/cloud-runtime.js.map +1 -1
  90. package/packages/gsd-cloud/dist/runtime-process.d.ts +27 -0
  91. package/packages/gsd-cloud/dist/runtime-process.d.ts.map +1 -0
  92. package/packages/gsd-cloud/dist/runtime-process.js +272 -0
  93. package/packages/gsd-cloud/dist/runtime-process.js.map +1 -0
  94. package/packages/gsd-cloud/dist/runtime-process.test.d.ts +2 -0
  95. package/packages/gsd-cloud/dist/runtime-process.test.d.ts.map +1 -0
  96. package/packages/gsd-cloud/dist/runtime-process.test.js +97 -0
  97. package/packages/gsd-cloud/dist/runtime-process.test.js.map +1 -0
  98. package/packages/gsd-cloud/package.json +2 -2
  99. package/packages/mcp-server/dist/cli-runner.d.ts +3 -0
  100. package/packages/mcp-server/dist/cli-runner.d.ts.map +1 -1
  101. package/packages/mcp-server/dist/cli-runner.js +119 -33
  102. package/packages/mcp-server/dist/cli-runner.js.map +1 -1
  103. package/packages/mcp-server/package.json +4 -4
  104. package/packages/native/package.json +1 -1
  105. package/packages/pi-agent-core/package.json +1 -1
  106. package/packages/pi-ai/dist/models.generated.d.ts +17 -0
  107. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  108. package/packages/pi-ai/dist/models.generated.js +17 -0
  109. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  110. package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
  111. package/packages/pi-ai/dist/providers/openai-completions.js +103 -0
  112. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  113. package/packages/pi-ai/package.json +1 -1
  114. package/packages/pi-coding-agent/package.json +7 -7
  115. package/packages/pi-tui/package.json +2 -2
  116. package/packages/rpc-client/package.json +2 -2
  117. package/pkg/package.json +1 -1
  118. package/src/resources/extensions/gsd/auto/loop.ts +48 -4
  119. package/src/resources/extensions/gsd/auto/run-unit.ts +20 -1
  120. package/src/resources/extensions/gsd/auto/unit-phase.ts +41 -5
  121. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -0
  122. package/src/resources/extensions/gsd/auto-prompts.ts +14 -4
  123. package/src/resources/extensions/gsd/auto-runtime-state.ts +12 -7
  124. package/src/resources/extensions/gsd/auto-start.ts +31 -8
  125. package/src/resources/extensions/gsd/auto-tool-tracking.ts +56 -0
  126. package/src/resources/extensions/gsd/auto-worktree-sync.ts +10 -3
  127. package/src/resources/extensions/gsd/auto.ts +20 -7
  128. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +2 -2
  129. package/src/resources/extensions/gsd/commands-verdict.ts +16 -9
  130. package/src/resources/extensions/gsd/compat/compat-marker.ts +99 -5
  131. package/src/resources/extensions/gsd/db/engine.ts +5 -2
  132. package/src/resources/extensions/gsd/doctor-engine-checks.ts +27 -2
  133. package/src/resources/extensions/gsd/error-classifier.ts +13 -2
  134. package/src/resources/extensions/gsd/exec-sandbox.ts +1 -0
  135. package/src/resources/extensions/gsd/flat-phase-migration.ts +14 -0
  136. package/src/resources/extensions/gsd/gsd-db.ts +85 -33
  137. package/src/resources/extensions/gsd/guided-flow.ts +2 -2
  138. package/src/resources/extensions/gsd/markdown-renderer.ts +3 -9
  139. package/src/resources/extensions/gsd/paths.ts +17 -7
  140. package/src/resources/extensions/gsd/prompts/validate-milestone.md +5 -5
  141. package/src/resources/extensions/gsd/state-reconciliation/drift/external-markdown-edit.ts +132 -2
  142. package/src/resources/extensions/gsd/state-reconciliation/drift/external-planning-edit.ts +4 -1
  143. package/src/resources/extensions/gsd/state-reconciliation/drift/roadmap.ts +4 -2
  144. package/src/resources/extensions/gsd/stop-notice.ts +11 -0
  145. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +264 -0
  146. package/src/resources/extensions/gsd/tests/auto-start-index-lock.test.ts +21 -1
  147. package/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts +74 -1
  148. package/src/resources/extensions/gsd/tests/commands-verdict.test.ts +10 -0
  149. package/src/resources/extensions/gsd/tests/compat-marker.test.ts +25 -0
  150. package/src/resources/extensions/gsd/tests/db-engine-cjs-compat.test.ts +12 -0
  151. package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +83 -0
  152. package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +33 -1
  153. package/src/resources/extensions/gsd/tests/external-markdown-edit.test.ts +38 -1
  154. package/src/resources/extensions/gsd/tests/flat-phase-migration.test.ts +8 -9
  155. package/src/resources/extensions/gsd/tests/flat-phase-validation-path.test.ts +20 -1
  156. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +32 -0
  157. package/src/resources/extensions/gsd/tests/guided-discuss-milestone-prompt-rendering.test.ts +22 -0
  158. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +13 -0
  159. package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +21 -0
  160. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +121 -1
  161. package/src/resources/extensions/gsd/tests/register-hooks-loop-guard-auto.test.ts +105 -0
  162. package/src/resources/extensions/gsd/tests/state-reconciliation-drift.test.ts +52 -0
  163. package/src/resources/extensions/gsd/tests/stop-notice.test.ts +21 -1
  164. package/src/resources/extensions/gsd/tests/terminated-transient.test.ts +14 -0
  165. package/src/resources/extensions/gsd/tests/tool-invocation-error-loop-break.test.ts +19 -0
  166. package/src/resources/extensions/gsd/tests/validate-milestone-prompt-verification-classes.test.ts +7 -0
  167. package/src/resources/extensions/gsd/tests/worktree-state-projection.test.ts +2 -2
  168. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +60 -0
  169. package/src/resources/extensions/gsd/worktree-state-projection.ts +21 -24
  170. package/src/resources/extensions/shared/interview-ui.ts +47 -8
  171. package/src/resources/extensions/shared/tests/interview-ui-border.test.ts +29 -0
  172. /package/dist/web/standalone/.next/static/{6YWV7VAPg-jqjfPswrKZb → DXD9fZPnqtjZSlT5xdC3v}/_buildManifest.js +0 -0
  173. /package/dist/web/standalone/.next/static/{6YWV7VAPg-jqjfPswrKZb → DXD9fZPnqtjZSlT5xdC3v}/_ssgManifest.js +0 -0
@@ -6,8 +6,10 @@ import { join } from "node:path";
6
6
  import { randomUUID } from "node:crypto";
7
7
 
8
8
  import { autoSession } from "../auto-runtime-state.ts";
9
+ import { postUnitPreVerification } from "../auto-post-unit.ts";
9
10
  import { registerHooks } from "../bootstrap/register-hooks.ts";
10
11
  import { resetToolCallLoopGuard } from "../bootstrap/tool-call-loop-guard.ts";
12
+ import { closeDatabase, openDatabase } from "../gsd-db.ts";
11
13
  import { readUnitHarnessAbort } from "../unit-runtime.ts";
12
14
 
13
15
  type Handler = (event: any, ctx?: any) => Promise<any> | any;
@@ -166,6 +168,109 @@ test("register-hooks does not record normal product tool failures as harness abo
166
168
  assert.equal(abort, null);
167
169
  });
168
170
 
171
+ test("tool_execution_end latches a failed ScheduleWakeup across unrelated tool success", async (t) => {
172
+ const base = makeRuntimeBase();
173
+ mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
174
+ openDatabase(join(base, ".gsd", "gsd.db"));
175
+ autoSession.reset();
176
+ autoSession.active = true;
177
+ autoSession.basePath = base;
178
+ autoSession.currentUnit = { type: "discuss-milestone", id: "M001", startedAt: Date.now() };
179
+ t.after(() => {
180
+ closeDatabase();
181
+ autoSession.reset();
182
+ rmSync(base, { recursive: true, force: true });
183
+ });
184
+
185
+ const { emitToolExecutionEnd } = makeHookHarness();
186
+ const error = "`prompt` is required when `stop` is not true";
187
+
188
+ await emitToolExecutionEnd({
189
+ toolName: "ScheduleWakeup",
190
+ isError: true,
191
+ result: error,
192
+ });
193
+ assert.equal(autoSession.lastToolInvocationError, `ScheduleWakeup: ${error}`);
194
+
195
+ await emitToolExecutionEnd({
196
+ toolName: "read",
197
+ isError: false,
198
+ result: "file contents",
199
+ });
200
+ assert.equal(
201
+ autoSession.lastToolInvocationError,
202
+ `ScheduleWakeup: ${error}`,
203
+ "an unrelated success must not erase a failed continuation before the unit boundary",
204
+ );
205
+
206
+ await emitToolExecutionEnd({
207
+ toolName: "parse_payload",
208
+ isError: true,
209
+ result: "Unexpected end of JSON input",
210
+ });
211
+ assert.equal(
212
+ autoSession.lastToolInvocationError,
213
+ `ScheduleWakeup: ${error}`,
214
+ "a later invocation error must not replace the failed continuation",
215
+ );
216
+
217
+ let pauseCalled = false;
218
+ const notifications: Array<{ message: string; severity?: string }> = [];
219
+ const result = await postUnitPreVerification(
220
+ {
221
+ s: autoSession,
222
+ ctx: {
223
+ ui: {
224
+ notify: (message: string, severity?: string) => notifications.push({ message, severity }),
225
+ },
226
+ } as any,
227
+ pi: {} as any,
228
+ buildSnapshotOpts: () => ({}) as any,
229
+ lockBase: () => base,
230
+ stopAuto: async () => {},
231
+ pauseAuto: async () => { pauseCalled = true; },
232
+ updateProgressWidget: () => {},
233
+ },
234
+ { skipSettleDelay: true, skipWorktreeSync: true },
235
+ );
236
+
237
+ assert.equal(result, "dispatched");
238
+ assert.equal(pauseCalled, true);
239
+ assert.equal(autoSession.pendingVerificationRetry, null);
240
+ assert.ok(
241
+ notifications.some(({ message, severity }) => severity === "error" && message.includes(error)),
242
+ "the unit boundary must surface the rejected continuation",
243
+ );
244
+ assert.equal(autoSession.lastToolInvocationError, null);
245
+
246
+ await emitToolExecutionEnd({
247
+ toolName: "ScheduleWakeup",
248
+ isError: true,
249
+ result: error,
250
+ });
251
+ await emitToolExecutionEnd({
252
+ toolName: "ScheduleWakeup",
253
+ isError: false,
254
+ result: "Wakeup scheduled",
255
+ });
256
+ assert.equal(autoSession.lastToolInvocationError, null);
257
+ });
258
+
259
+ test("tool_execution_end ignores ScheduleWakeup-shaped business validation from other tools", async (t) => {
260
+ autoSession.reset();
261
+ autoSession.active = true;
262
+ t.after(() => autoSession.reset());
263
+
264
+ const { emitToolExecutionEnd } = makeHookHarness();
265
+ await emitToolExecutionEnd({
266
+ toolName: "submit_review",
267
+ isError: true,
268
+ result: "`prompt` is required when `stop` is not true",
269
+ });
270
+
271
+ assert.equal(autoSession.lastToolInvocationError, null);
272
+ });
273
+
169
274
  test("register-hooks does not classify normal gsd_uat_exec nonzero exits as harness aborts", async (t) => {
170
275
  const base = makeRuntimeBase();
171
276
  const startedAt = Date.now();
@@ -1618,6 +1618,58 @@ test("ADR-017 (#5705): in-sync ROADMAP and DB → no roadmap-divergence drift",
1618
1618
  );
1619
1619
  });
1620
1620
 
1621
+ test("ADR-017 (#1370): roadmap-divergence skips completed milestone sharing active worktree phase", async (t) => {
1622
+ const base = mkdtempSync(join(tmpdir(), "gsd-adr017-roadmap-shared-phase-"));
1623
+ const milestoneDir = join(base, ".gsd", "phases", "06-rlrbot-m006-rlrbot-tool-registry-and-command-sa");
1624
+ const roadmapPath = join(milestoneDir, "06-ROADMAP.md");
1625
+ mkdirSync(milestoneDir, { recursive: true });
1626
+ mkdirSync(join(base, ".gsd", "milestones", "M006"), { recursive: true });
1627
+ mkdirSync(join(base, ".gsd", "milestones", "M006-rlrbot"), { recursive: true });
1628
+ writeFileSync(join(base, ".gsd", "milestones", "M006", "M006-META.json"), "{}\n");
1629
+ writeFileSync(join(base, ".gsd", "milestones", "M006-rlrbot", "M006-rlrbot-META.json"), "{}\n");
1630
+ const activeRoadmap = [
1631
+ "# M006-rlrbot: Active worktree",
1632
+ "",
1633
+ "**Vision:** Keep the active worktree projection authoritative.",
1634
+ "",
1635
+ "## Slices",
1636
+ "",
1637
+ "- [ ] **S01: Shared slice** `risk:medium` `depends:[]`",
1638
+ " > After this: Active work remains pending.",
1639
+ "",
1640
+ ].join("\n");
1641
+ writeFileSync(roadmapPath, activeRoadmap);
1642
+ t.after(() => {
1643
+ try { closeDatabase(); } catch { /* noop */ }
1644
+ rmSync(base, { recursive: true, force: true });
1645
+ });
1646
+
1647
+ openDatabase(join(base, ".gsd", "gsd.db"));
1648
+ insertMilestone({ id: "M006", title: "Completed source", status: "complete" });
1649
+ insertSlice({ id: "S01", milestoneId: "M006", title: "Shared slice", status: "complete", risk: "medium", depends: [], demo: "Completed.", sequence: 1 });
1650
+ insertMilestone({
1651
+ id: "M006-rlrbot",
1652
+ title: "Active worktree",
1653
+ status: "active",
1654
+ planning: { vision: "Keep the active worktree projection authoritative." },
1655
+ });
1656
+ insertSlice({ id: "S01", milestoneId: "M006-rlrbot", title: "Shared slice", status: "pending", risk: "medium", depends: [], demo: "Active work remains pending.", sequence: 1 });
1657
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M006-rlrbot", title: "Plan active work", status: "pending" });
1658
+
1659
+ const result = await reconcileBeforeDispatch(base, {
1660
+ invalidateStateCache: () => {},
1661
+ deriveState: async () => makeState({ activeMilestone: { id: "M006-rlrbot", title: "Active worktree" } }),
1662
+ });
1663
+
1664
+ assert.equal(result.ok, true);
1665
+ assert.equal(
1666
+ result.repaired.some((d) => d.kind === "roadmap-divergence"),
1667
+ false,
1668
+ "completed milestone sharing the phase dir must not trigger roadmap-divergence repair",
1669
+ );
1670
+ assert.equal(readFileSync(roadmapPath, "utf-8"), activeRoadmap);
1671
+ });
1672
+
1621
1673
  // ─── #5706: missing-completion-timestamp drift ──────────────────────────────
1622
1674
 
1623
1675
  test("ADR-017 (#5706): task with SUMMARY but null completed_at → backfilled", async (t) => {
@@ -7,6 +7,8 @@ import assert from "node:assert/strict";
7
7
 
8
8
  import {
9
9
  formatStopNoticePrefix,
10
+ formatVerdictRecordedNotice,
11
+ formatVerdictRejectedNotice,
10
12
  isBlockedStopReason,
11
13
  stopNoticeDisplayReason,
12
14
  stopNoticeKind,
@@ -63,6 +65,17 @@ describe("emitter↔detector round-trip", () => {
63
65
  assert.ok(isBlockedNoticeMessage(message));
64
66
  });
65
67
 
68
+ test("verdict notices classify as terminal with rejected notices blocked", () => {
69
+ const recorded = formatVerdictRecordedNotice("Milestone M001 verdict: needs-attention -> pass").toLowerCase();
70
+ const rejected = formatVerdictRejectedNotice("No milestone validation found for M001.").toLowerCase();
71
+
72
+ assert.ok(isTerminalNotice(recorded));
73
+ assert.equal(isBlockedNoticeMessage(recorded), false);
74
+
75
+ assert.ok(isTerminalNotice(rejected));
76
+ assert.ok(isBlockedNoticeMessage(rejected));
77
+ });
78
+
66
79
  test("un-showable menu notices classify as blocked (#1294)", () => {
67
80
  // Emitted verbatim by notifyCommandMenuUnavailable (next-action-ui.ts / command-feedback.ts).
68
81
  const menuUnavailable =
@@ -80,7 +93,14 @@ describe("emitter↔detector round-trip", () => {
80
93
  });
81
94
 
82
95
  test("terminal prefixes cover the known stop vocabulary", () => {
83
- for (const message of ["auto-mode stopped.", "auto-mode complete", "auto-mode idle", "no active milestone"]) {
96
+ for (const message of [
97
+ "auto-mode stopped.",
98
+ "auto-mode complete",
99
+ "auto-mode idle",
100
+ "no active milestone",
101
+ "verdict recorded: milestone m001 verdict: needs-attention -> pass",
102
+ "verdict rejected: unexpected argument: x",
103
+ ]) {
84
104
  assert.ok(TERMINAL_NOTICE_PREFIXES.some((prefix) => message.startsWith(prefix)), message);
85
105
  }
86
106
  });
@@ -17,6 +17,20 @@ test("#2309: 'terminated' errors should be classified as transient", () => {
17
17
  assert.equal("retryAfterMs" in result && result.retryAfterMs, 15_000, "'terminated' should use 15s backoff");
18
18
  });
19
19
 
20
+ test("#1364: provider abort stop reasons should be classified as transient", () => {
21
+ const result = classifyError("Unhandled stop reason: abort");
22
+ assert.equal(isTransient(result), true, "'abort' provider errors should be transient");
23
+ assert.equal(result.kind, "connection", "'abort' matches connection");
24
+ assert.equal("retryAfterMs" in result && result.retryAfterMs, 15_000, "'abort' should use 15s backoff");
25
+ });
26
+
27
+ test("#1364: provider unknown stop reasons should be classified as transient", () => {
28
+ const result = classifyError("Unhandled stop reason: unknown");
29
+ assert.equal(isTransient(result), true, "'unknown' provider stop reasons should be transient");
30
+ assert.equal(result.kind, "connection", "'unknown' stop reasons match connection");
31
+ assert.equal("retryAfterMs" in result && result.retryAfterMs, 15_000, "'unknown' should use 15s backoff");
32
+ });
33
+
20
34
  test("#2309: 'connection reset by peer' errors should be classified as transient (network)", () => {
21
35
  const result = classifyError("connection reset by peer");
22
36
  assert.equal(isTransient(result), true, "'connection reset by peer' should be transient");
@@ -47,6 +47,7 @@ describe("#2883: tool invocation error tracking on AutoSession", () => {
47
47
  import {
48
48
  isDeterministicPolicyError,
49
49
  isPendingUserApprovalGateError,
50
+ isScheduleWakeupContinuationError,
50
51
  isToolInvocationError,
51
52
  isToolUnavailableError,
52
53
  isQueuedUserMessageSkip,
@@ -118,6 +119,24 @@ describe("#2883: isToolInvocationError classification", () => {
118
119
  assert.equal(isToolInvocationError("Invalid arguments for tool gsd_slice_complete"), true);
119
120
  });
120
121
 
122
+ test("scopes conditional required-field validation to ScheduleWakeup", () => {
123
+ const error = "`prompt` is required when `stop` is not true";
124
+
125
+ assert.equal(isToolInvocationError(error), false);
126
+ assert.equal(
127
+ isScheduleWakeupContinuationError("ScheduleWakeup", error),
128
+ true,
129
+ );
130
+ assert.equal(isScheduleWakeupContinuationError("read", error), false);
131
+ assert.equal(
132
+ isScheduleWakeupContinuationError(
133
+ "ScheduleWakeup",
134
+ "`evidence` is required when `status` is blocking",
135
+ ),
136
+ false,
137
+ );
138
+ });
139
+
121
140
  test("detects 'No such tool available' error", () => {
122
141
  assert.equal(isToolInvocationError("No such tool available: mcp__gsd-workflow__memory_query"), true);
123
142
  });
@@ -22,3 +22,10 @@ test("validate-milestone prompt routes verification class analysis into verifica
22
22
  assert.match(prompt, /If Reviewer C omitted a planned class, reconstruct the missing row/i);
23
23
  assert.match(prompt, /Do not call `gsd_validate_milestone` with a partial `verificationClasses` table/i);
24
24
  });
25
+
26
+ test("validate-milestone prompt forbids reading phase directories as files", () => {
27
+ assert.match(prompt, /find \.gsd -type f/i);
28
+ assert.match(prompt, /\.gsd\/phases\/<NN>-<slug>\/` is a directory, not an artifact/i);
29
+ assert.match(prompt, /never pass a phase, slice, `tasks\/`, or `slices\/` directory/i);
30
+ assert.doesNotMatch(prompt, /Read a full SUMMARY under `\.gsd\/milestones\/\{\{milestoneId\}\}\/slices\/`/i);
31
+ });
@@ -117,8 +117,8 @@ test("projectRootToWorktree projects flat-phase discuss artifacts before dispatc
117
117
  new WorktreeStateProjection().projectRootToWorktree(scope);
118
118
 
119
119
  assert.ok(
120
- existsSync(join(worktree, ".gsd", "milestones", "M001", "M001-META.json")),
121
- "milestone metadata is projected into the worktree",
120
+ !existsSync(join(worktree, ".gsd", "milestones")),
121
+ "stale metadata-only legacy milestones/ scaffold is not projected into the worktree",
122
122
  );
123
123
  assert.ok(
124
124
  existsSync(join(worktree, ".gsd", "phases", "01-foundation", "01-CONTEXT.md")),
@@ -246,6 +246,66 @@ describe('worktree-sync-milestones', async () => {
246
246
  }
247
247
  }
248
248
 
249
+ // ─── 7b. flat-phase sync skips empty legacy milestones root ───────────
250
+ console.log('\n=== 7b. flat-phase sync skips empty legacy milestones root ===');
251
+ {
252
+ const mainBase = mkdtempSync(join(tmpdir(), 'gsd-wt-sync-flat-main-'));
253
+ const wtBase = mkdtempSync(join(tmpdir(), 'gsd-wt-sync-flat-wt-'));
254
+
255
+ try {
256
+ const phaseDir = join(mainBase, '.gsd', 'phases', '01-foundation');
257
+ mkdirSync(phaseDir, { recursive: true });
258
+ mkdirSync(join(mainBase, '.gsd', 'milestones'), { recursive: true });
259
+ mkdirSync(join(wtBase, '.gsd'), { recursive: true });
260
+ writeFileSync(join(phaseDir, '01-CONTEXT.md'), '# Foundation\n');
261
+
262
+ syncGsdStateToWorktree(mainBase, wtBase);
263
+
264
+ assert.ok(
265
+ existsSync(join(wtBase, '.gsd', 'phases', '01-foundation', '01-CONTEXT.md')),
266
+ 'flat-phase artifact is synced to worktree',
267
+ );
268
+ assert.ok(
269
+ !existsSync(join(wtBase, '.gsd', 'milestones')),
270
+ 'empty legacy milestones/ root is not recreated in worktree',
271
+ );
272
+ } finally {
273
+ rmSync(mainBase, { recursive: true, force: true });
274
+ rmSync(wtBase, { recursive: true, force: true });
275
+ }
276
+ }
277
+
278
+ // ─── 7c. flat-phase sync skips metadata-only legacy milestone dirs ────
279
+ console.log('\n=== 7c. flat-phase sync skips metadata-only legacy milestone dirs ===');
280
+ {
281
+ const mainBase = mkdtempSync(join(tmpdir(), 'gsd-wt-sync-flat-meta-main-'));
282
+ const wtBase = mkdtempSync(join(tmpdir(), 'gsd-wt-sync-flat-meta-wt-'));
283
+
284
+ try {
285
+ const phaseDir = join(mainBase, '.gsd', 'phases', '01-foundation');
286
+ const metaDir = join(mainBase, '.gsd', 'milestones', 'M001');
287
+ mkdirSync(phaseDir, { recursive: true });
288
+ mkdirSync(metaDir, { recursive: true });
289
+ mkdirSync(join(wtBase, '.gsd'), { recursive: true });
290
+ writeFileSync(join(phaseDir, '01-CONTEXT.md'), '# Foundation\n');
291
+ writeFileSync(join(metaDir, 'M001-META.json'), '{"integrationBranch":"main"}\n');
292
+
293
+ syncGsdStateToWorktree(mainBase, wtBase);
294
+
295
+ assert.ok(
296
+ existsSync(join(wtBase, '.gsd', 'phases', '01-foundation', '01-CONTEXT.md')),
297
+ 'flat-phase artifact is synced to worktree',
298
+ );
299
+ assert.ok(
300
+ !existsSync(join(wtBase, '.gsd', 'milestones')),
301
+ 'metadata-only legacy milestones/ scaffold is not recreated in worktree',
302
+ );
303
+ } finally {
304
+ rmSync(mainBase, { recursive: true, force: true });
305
+ rmSync(wtBase, { recursive: true, force: true });
306
+ }
307
+ }
308
+
249
309
  // ─── 8. syncWorktreeStateBack does not copy task projections ───────────
250
310
  console.log('\n=== 8. syncWorktreeStateBack leaves task projections in worktree ===');
251
311
  {
@@ -32,7 +32,7 @@ import {
32
32
  import { join } from "node:path";
33
33
 
34
34
  import { reconcileWorktreeDb } from "./gsd-db.js";
35
- import { resolveGsdPathContract } from "./paths.js";
35
+ import { dirIsContentBearingLegacyMilestone, resolveGsdPathContract } from "./paths.js";
36
36
  import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
37
37
  import type { MilestoneScope } from "./workspace.js";
38
38
  import { logError, logWarning } from "./workflow-logger.js";
@@ -161,6 +161,7 @@ function syncOtherMilestoneArtifacts(
161
161
  // additive safeCopyRecursive; skip it here to avoid redundant work.
162
162
  if (milestoneEntry.name === currentMilestoneId) continue;
163
163
  const srcMilestoneDir = join(srcMilestonesDir, milestoneEntry.name);
164
+ if (!dirIsContentBearingLegacyMilestone(srcMilestoneDir)) continue;
164
165
  const dstMilestoneDir = join(dstMilestonesDir, milestoneEntry.name);
165
166
 
166
167
  // Additively project the entire milestone subtree (force:false), not just
@@ -271,36 +272,32 @@ export function _projectRootToWorktreeImpl(
271
272
  // Without this, worktree-local files (e.g. VALIDATION.md written
272
273
  // by validate-milestone) get clobbered by stale project root copies,
273
274
  // causing an infinite re-validation loop (#1886).
274
- safeCopyRecursive(
275
- join(prGsd, "milestones", milestoneId),
276
- join(wtGsd, "milestones", milestoneId),
277
- { force: false },
278
- );
275
+ const prMilestoneDir = join(prGsd, "milestones", milestoneId);
276
+ const wtMilestoneDir = join(wtGsd, "milestones", milestoneId);
277
+ if (dirIsContentBearingLegacyMilestone(prMilestoneDir)) {
278
+ safeCopyRecursive(prMilestoneDir, wtMilestoneDir, { force: false });
279
+
280
+ // Force-sync ASSESSMENT files that have a verdict from project root (#2821).
281
+ // The additive-only copy above preserves worktree-local files, but
282
+ // ASSESSMENT files are special: after run-uat writes a verdict and post-unit
283
+ // syncs it to the project root, the worktree may retain a stale copy (e.g.
284
+ // verdict:fail while the project root has verdict:pass from a retry). On
285
+ // session resume the DB is rebuilt from disk, and if the stale ASSESSMENT
286
+ // persists, checkNeedsRunUat finds no passing verdict → re-dispatches
287
+ // run-uat indefinitely (stuck-loop ×9).
288
+ forceOverwriteValidationWithVerdict(prMilestoneDir, wtMilestoneDir, milestoneId);
289
+ forceOverwriteAssessmentsWithVerdict(prMilestoneDir, wtMilestoneDir);
290
+ }
279
291
 
280
- // Additively project the full subtree of every OTHER milestone so
281
- // worktree-bound units can read prior-milestone context artifacts and so
282
- // completed milestones retain their per-slice/per-task SUMMARY.md and
283
- // UAT.md on the worktree filesystem (otherwise the stale-render detector
284
- // flags them as "complete in DB but missing on disk").
292
+ // Additively project the full subtree of every OTHER content-bearing legacy
293
+ // milestone so worktree-bound units can read prior-milestone context artifacts
294
+ // without recreating empty/meta-only milestones/ scaffolds in flat-phase projects.
285
295
  syncOtherMilestoneArtifacts(
286
296
  join(prGsd, "milestones"),
287
297
  join(wtGsd, "milestones"),
288
298
  milestoneId,
289
299
  );
290
300
 
291
- // Force-sync ASSESSMENT files that have a verdict from project root (#2821).
292
- // The additive-only copy above preserves worktree-local files, but
293
- // ASSESSMENT files are special: after run-uat writes a verdict and post-unit
294
- // syncs it to the project root, the worktree may retain a stale copy (e.g.
295
- // verdict:fail while the project root has verdict:pass from a retry). On
296
- // session resume the DB is rebuilt from disk, and if the stale ASSESSMENT
297
- // persists, checkNeedsRunUat finds no passing verdict → re-dispatches
298
- // run-uat indefinitely (stuck-loop ×9).
299
- const prMilestoneDir = join(prGsd, "milestones", milestoneId);
300
- const wtMilestoneDir = join(wtGsd, "milestones", milestoneId);
301
- forceOverwriteValidationWithVerdict(prMilestoneDir, wtMilestoneDir, milestoneId);
302
- forceOverwriteAssessmentsWithVerdict(prMilestoneDir, wtMilestoneDir);
303
-
304
301
  // Forward-sync completed-units.json from project root to worktree.
305
302
  // Project root is authoritative for completion state after crash recovery;
306
303
  // without this, the worktree re-dispatches already-completed units (#1886).
@@ -252,6 +252,11 @@ export async function showInterviewRound(
252
252
  let previewTotal = 0;
253
253
  function resetPreviewScroll() { previewScroll = 0; }
254
254
 
255
+ let optionsScroll = 0;
256
+ let optionsViewport = 0;
257
+ let optionsTotal = 0;
258
+ function resetOptionsScroll() { optionsScroll = 0; }
259
+
255
260
  function finish(result: RoundResult) {
256
261
  if (completed) return;
257
262
  completed = true;
@@ -318,6 +323,7 @@ export async function showInterviewRound(
318
323
  saveEditorToState();
319
324
  currentIdx = newIdx;
320
325
  resetPreviewScroll();
326
+ resetOptionsScroll();
321
327
  loadStateToEditor();
322
328
  focusNotes = states[currentIdx].notesVisible && states[currentIdx].notes.length > 0;
323
329
  refresh();
@@ -464,20 +470,39 @@ export async function showInterviewRound(
464
470
  if (matchesKey(data, Key.right)) { switchQuestion((currentIdx + 1) % questions.length); return; }
465
471
  }
466
472
 
467
- // ── Preview scrolling ────────────────────────────────────────
473
+ // ── Side-by-side panel scrolling ─────────────────────────────
468
474
  if (matchesKey(data, Key.pageUp)) {
469
- if (previewScroll > 0) { previewScroll = Math.max(0, previewScroll - previewViewport); refresh(); }
475
+ let didScroll = false;
476
+ if (optionsScroll > 0) {
477
+ optionsScroll = Math.max(0, optionsScroll - optionsViewport);
478
+ didScroll = true;
479
+ }
480
+ if (previewScroll > 0) {
481
+ previewScroll = Math.max(0, previewScroll - previewViewport);
482
+ didScroll = true;
483
+ }
484
+ if (didScroll) refresh();
470
485
  return;
471
486
  }
472
487
  if (matchesKey(data, Key.pageDown)) {
488
+ let didScroll = false;
489
+ const maxOptionsScroll = Math.max(0, optionsTotal - optionsViewport);
490
+ if (optionsScroll < maxOptionsScroll) {
491
+ optionsScroll = Math.min(maxOptionsScroll, optionsScroll + optionsViewport);
492
+ didScroll = true;
493
+ }
473
494
  const maxScroll = Math.max(0, previewTotal - previewViewport);
474
- if (previewScroll < maxScroll) { previewScroll = Math.min(maxScroll, previewScroll + previewViewport); refresh(); }
495
+ if (previewScroll < maxScroll) {
496
+ previewScroll = Math.min(maxScroll, previewScroll + previewViewport);
497
+ didScroll = true;
498
+ }
499
+ if (didScroll) refresh();
475
500
  return;
476
501
  }
477
502
 
478
503
  // ── Cursor navigation ────────────────────────────────────────
479
- if (matchesKey(data, Key.up)) { st.cursorIndex = (st.cursorIndex - 1 + optCount) % optCount; resetPreviewScroll(); refresh(); return; }
480
- if (matchesKey(data, Key.down)) { st.cursorIndex = (st.cursorIndex + 1) % optCount; resetPreviewScroll(); refresh(); return; }
504
+ if (matchesKey(data, Key.up)) { st.cursorIndex = (st.cursorIndex - 1 + optCount) % optCount; resetPreviewScroll(); resetOptionsScroll(); refresh(); return; }
505
+ if (matchesKey(data, Key.down)) { st.cursorIndex = (st.cursorIndex + 1) % optCount; resetPreviewScroll(); resetOptionsScroll(); refresh(); return; }
481
506
 
482
507
  if (multiSel) {
483
508
  const doneI = noneOrDoneIdx(currentIdx);
@@ -695,6 +720,8 @@ export async function showInterviewRound(
695
720
  // No scrollable preview unless the side-by-side path sets these below.
696
721
  previewTotal = 0;
697
722
  previewViewport = 0;
723
+ optionsTotal = 0;
724
+ optionsViewport = 0;
698
725
 
699
726
  if (useSideBySide) {
700
727
  // ── Preview path ──────────────────────────────────────
@@ -734,10 +761,21 @@ export async function showInterviewRound(
734
761
  const leftWidth = Math.max(MIN_OPTIONS_WIDTH, contentWidth - previewWidth - DIVIDER_WIDTH);
735
762
 
736
763
  const fullLeft = renderOptionsColumn(leftWidth);
737
- const leftLines = fullLeft.slice(0, maxBody);
764
+ optionsTotal = fullLeft.length;
765
+ optionsViewport = maxBody;
766
+ const maxOptionsScroll = Math.max(0, fullLeft.length - maxBody);
767
+ if (optionsScroll > maxOptionsScroll) optionsScroll = maxOptionsScroll;
768
+
769
+ const leftLines = fullLeft.slice(optionsScroll, optionsScroll + maxBody);
738
770
  if (fullLeft.length > maxBody) {
739
- const n = fullLeft.length - maxBody + 1;
740
- leftLines[maxBody - 1] = scrollIndicator(`+${n} lines hidden`, leftWidth);
771
+ const hiddenAbove = optionsScroll;
772
+ const hiddenBelow = fullLeft.length - (optionsScroll + maxBody);
773
+ if (hiddenAbove > 0) {
774
+ leftLines[0] = scrollIndicator(`▲ ${hiddenAbove} more`, leftWidth);
775
+ }
776
+ if (hiddenBelow > 0) {
777
+ leftLines[leftLines.length - 1] = scrollIndicator(`▼ ${hiddenBelow} more · PgUp/PgDn`, leftWidth);
778
+ }
741
779
  }
742
780
 
743
781
  const preview = getCurrentPreview();
@@ -791,6 +829,7 @@ export async function showInterviewRound(
791
829
  hints.push(isLast && allAnswered() ? "enter to review" : "enter to next");
792
830
  }
793
831
  if (previewTotal > previewViewport) hints.push("pgup/pgdn scroll preview");
832
+ if (optionsTotal > optionsViewport) hints.push("pgup/pgdn scroll options");
794
833
  hints.push("esc to exit");
795
834
  const footer = ui.hints(hints)[0] ?? "";
796
835
 
@@ -192,4 +192,33 @@ describe("interview-ui dialog borders", () => {
192
192
  const back = stripAnsi(widget.render(100).join("\n"));
193
193
  assert.doesNotMatch(back, /▲ \d+ more/, "top indicator should disappear after scrolling back to top");
194
194
  });
195
+
196
+ it("makes overflowing side-by-side options scrollable with PgUp/PgDn", async () => {
197
+ const overflowOptions = Array.from({ length: 8 }, (_, i) => ({
198
+ label: `Overflow Option ${i + 1}`,
199
+ description: `Detailed explanation for option ${i + 1} that is intentionally long enough to wrap in the options column and force vertical overflow.`,
200
+ ...(i === 0 ? { preview: "Short preview" } : {}),
201
+ }));
202
+ const widget = await captureInterviewWidget([{
203
+ ...questions[0],
204
+ options: overflowOptions,
205
+ }]);
206
+
207
+ const initial = stripAnsi(widget.render(100).join("\n"));
208
+ assertFullOuterBorder(widget.render(100), 100);
209
+ assert.match(initial, /▼ \d+ more/, "bottom scroll indicator should appear when options overflow");
210
+ assert.match(initial, /pgup\/pgdn scroll options/, "footer should hint at options scrolling");
211
+ assert.doesNotMatch(initial, /\+\d+ lines hidden/, "options overflow should not be a dead-end hidden-lines marker");
212
+ assert.doesNotMatch(initial, /▲ \d+ more/, "top indicator should be absent before scrolling");
213
+
214
+ widget.handleInput(PAGE_DOWN);
215
+ const scrolled = stripAnsi(widget.render(100).join("\n"));
216
+ assertFullOuterBorder(widget.render(100), 100);
217
+ assert.match(scrolled, /▲ \d+ more/, "top scroll indicator should appear after scrolling down");
218
+ assert.ok(scrolled !== initial, "scrolling should change the rendered options");
219
+
220
+ widget.handleInput(PAGE_UP);
221
+ const back = stripAnsi(widget.render(100).join("\n"));
222
+ assert.doesNotMatch(back, /▲ \d+ more/, "top indicator should disappear after scrolling back to top");
223
+ });
195
224
  });