@opengsd/gsd-pi 1.4.0-dev.5fc22c6f → 1.4.0-dev.c18009cd

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 (135) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +58 -4
  3. package/dist/resources/extensions/gsd/artifact-verification.js +17 -5
  4. package/dist/resources/extensions/gsd/auto/closeout.js +45 -42
  5. package/dist/resources/extensions/gsd/auto/finalize.js +9 -0
  6. package/dist/resources/extensions/gsd/auto/orchestrator.js +1 -1
  7. package/dist/resources/extensions/gsd/auto/run-unit.js +5 -2
  8. package/dist/resources/extensions/gsd/auto/schedule-wakeup.js +3 -0
  9. package/dist/resources/extensions/gsd/auto.js +10 -3
  10. package/dist/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.js +58 -9
  11. package/dist/resources/extensions/gsd/doctor-engine-checks.js +174 -16
  12. package/dist/resources/extensions/gsd/gsd-db.js +52 -0
  13. package/dist/resources/extensions/gsd/migrate-external.js +13 -4
  14. package/dist/resources/extensions/gsd/milestone-merge-transaction.js +3 -3
  15. package/dist/resources/extensions/gsd/repo-identity.js +16 -0
  16. package/dist/resources/extensions/gsd/root-write-leak-guard.js +4 -2
  17. package/dist/resources/extensions/gsd/unit-registry.js +27 -2
  18. package/dist/resources/extensions/gsd/worktree-lifecycle.js +67 -5
  19. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  20. package/dist/web/standalone/.next/BUILD_ID +1 -1
  21. package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
  22. package/dist/web/standalone/.next/build-manifest.json +2 -2
  23. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  24. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.html +1 -1
  41. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
  48. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  49. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  50. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  51. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  52. package/integrations/hermes/CONTEXT.md +47 -0
  53. package/integrations/hermes/README.md +21 -0
  54. package/integrations/hermes/docs/issue-1162-grilling.md +197 -0
  55. package/integrations/hermes/docs/setup.md +4 -0
  56. package/integrations/hermes/open_gsd_hermes/commands.py +141 -0
  57. package/integrations/hermes/open_gsd_hermes/config.py +2 -0
  58. package/integrations/hermes/open_gsd_hermes/gsd_client.py +594 -31
  59. package/integrations/hermes/open_gsd_hermes/notifications.py +10 -1
  60. package/integrations/hermes/plugin.yaml +4 -0
  61. package/integrations/hermes/tests/test_config.py +6 -0
  62. package/integrations/hermes/tests/test_gsd_client_cache.py +3 -0
  63. package/integrations/hermes/tests/test_gsd_client_execute.py +35 -0
  64. package/integrations/hermes/tests/test_gsd_client_process_cwd.py +22 -0
  65. package/integrations/hermes/tests/test_gsd_client_timeout.py +98 -0
  66. package/integrations/hermes/tests/test_milestone_client.py +447 -0
  67. package/integrations/hermes/tests/test_new_milestone_command.py +313 -0
  68. package/integrations/hermes/tests/test_supervisor_fsm.py +40 -0
  69. package/package.json +1 -1
  70. package/packages/cloud-mcp-gateway/package.json +2 -2
  71. package/packages/contracts/package.json +1 -1
  72. package/packages/daemon/dist/session-manager.js +1 -1
  73. package/packages/daemon/dist/session-manager.js.map +1 -1
  74. package/packages/daemon/package.json +4 -4
  75. package/packages/gsd-agent-core/dist/session/agent-session-navigation.d.ts.map +1 -1
  76. package/packages/gsd-agent-core/dist/session/agent-session-navigation.js +4 -1
  77. package/packages/gsd-agent-core/dist/session/agent-session-navigation.js.map +1 -1
  78. package/packages/gsd-agent-core/package.json +5 -5
  79. package/packages/gsd-agent-modes/package.json +7 -7
  80. package/packages/mcp-server/dist/server.d.ts +18 -5
  81. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  82. package/packages/mcp-server/dist/server.js +46 -5
  83. package/packages/mcp-server/dist/server.js.map +1 -1
  84. package/packages/mcp-server/dist/session-manager.js +1 -1
  85. package/packages/mcp-server/dist/session-manager.js.map +1 -1
  86. package/packages/mcp-server/package.json +4 -4
  87. package/packages/native/package.json +1 -1
  88. package/packages/pi-agent-core/package.json +1 -1
  89. package/packages/pi-ai/package.json +1 -1
  90. package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts +1 -0
  91. package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts.map +1 -1
  92. package/packages/pi-coding-agent/dist/core/session-manager-types.js.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
  94. package/packages/pi-coding-agent/dist/core/session-manager.js +3 -0
  95. package/packages/pi-coding-agent/dist/core/session-manager.js.map +1 -1
  96. package/packages/pi-coding-agent/package.json +7 -7
  97. package/packages/pi-tui/package.json +2 -2
  98. package/packages/rpc-client/package.json +2 -2
  99. package/pkg/package.json +1 -1
  100. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +64 -3
  101. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +212 -39
  102. package/src/resources/extensions/gsd/artifact-verification.ts +16 -4
  103. package/src/resources/extensions/gsd/auto/closeout.ts +54 -56
  104. package/src/resources/extensions/gsd/auto/finalize.ts +8 -0
  105. package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
  106. package/src/resources/extensions/gsd/auto/orchestrator.ts +1 -1
  107. package/src/resources/extensions/gsd/auto/run-unit.ts +5 -2
  108. package/src/resources/extensions/gsd/auto/schedule-wakeup.ts +8 -0
  109. package/src/resources/extensions/gsd/auto.ts +9 -3
  110. package/src/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.ts +80 -12
  111. package/src/resources/extensions/gsd/doctor-engine-checks.ts +230 -21
  112. package/src/resources/extensions/gsd/gsd-db.ts +72 -0
  113. package/src/resources/extensions/gsd/migrate-external.ts +14 -4
  114. package/src/resources/extensions/gsd/milestone-merge-transaction.ts +4 -4
  115. package/src/resources/extensions/gsd/repo-identity.ts +17 -0
  116. package/src/resources/extensions/gsd/root-write-leak-guard.ts +3 -2
  117. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +131 -5
  118. package/src/resources/extensions/gsd/tests/auto-phases-lifecycle.test.ts +43 -1
  119. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +54 -0
  120. package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +335 -0
  121. package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +4 -4
  122. package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +116 -0
  123. package/src/resources/extensions/gsd/tests/milestone-merge-stash-restore.test.ts +152 -22
  124. package/src/resources/extensions/gsd/tests/milestone-merge-transaction.test.ts +1 -1
  125. package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +50 -6
  126. package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +31 -9
  127. package/src/resources/extensions/gsd/tests/schedule-wakeup-tool.test.ts +167 -0
  128. package/src/resources/extensions/gsd/tests/state-corruption-2945.test.ts +1 -1
  129. package/src/resources/extensions/gsd/tests/unit-registry.test.ts +56 -0
  130. package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +4 -4
  131. package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +2 -2
  132. package/src/resources/extensions/gsd/unit-registry.ts +34 -2
  133. package/src/resources/extensions/gsd/worktree-lifecycle.ts +135 -11
  134. /package/dist/web/standalone/.next/static/{kIEFsCryyGy6RzifULbJl → L2UMqXELiDH3oz-7yguXp}/_buildManifest.js +0 -0
  135. /package/dist/web/standalone/.next/static/{kIEFsCryyGy6RzifULbJl → L2UMqXELiDH3oz-7yguXp}/_ssgManifest.js +0 -0
package/pkg/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittercowboy/gsd",
3
- "version": "1.4.0-dev.5fc22c6f",
3
+ "version": "1.4.0-dev.c18009cd",
4
4
  "piConfig": {
5
5
  "name": "gsd",
6
6
  "configDir": ".gsd"
@@ -50,6 +50,7 @@ import { markToolStart, markToolEnd } from "../gsd/auto.js";
50
50
  import {
51
51
  markInteractiveElicitationStart,
52
52
  markInteractiveElicitationEnd,
53
+ isInteractiveElicitationInFlight,
53
54
  } from "../gsd/auto-tool-tracking.js";
54
55
  import {
55
56
  discoverBrowserMcpServerName,
@@ -227,6 +228,36 @@ interface SDKInputUserMessage {
227
228
  const OTHER_OPTION_LABEL = "None of the above";
228
229
  /** Regex pattern that identifies field names and descriptions that should be treated as sensitive/secure inputs. */
229
230
  const SENSITIVE_FIELD_PATTERN = /(password|passphrase|secret|token|api[_\s-]*key|private[_\s-]*key|credential)/i;
231
+ /** Mirrors @opengsd/mcp-server's ask_user_questions host elicitation timeout. */
232
+ const WORKFLOW_ELICIT_TIMEOUT_MS = 10 * 60 * 1000;
233
+ /** Close the local form just before the server drops the MCP elicitation request. */
234
+ const WORKFLOW_ELICIT_TIMEOUT_SKEW_MS = 1_000;
235
+ export const CLAUDE_CODE_INTERVIEW_FORM_TIMEOUT_MS = Math.max(
236
+ 1,
237
+ WORKFLOW_ELICIT_TIMEOUT_MS - WORKFLOW_ELICIT_TIMEOUT_SKEW_MS,
238
+ );
239
+ const ELICITATION_EXPIRED_NOTICE =
240
+ "Question expired before you answered - the form was closed and no answers were sent.";
241
+ const activeAskUserQuestionElicitationTimeoutAborters = new Set<() => void>();
242
+
243
+ function isAskUserQuestionsToolName(toolName: string | undefined): boolean {
244
+ const name = String(toolName ?? "");
245
+ return name === "ask_user_questions" || name.endsWith("__ask_user_questions");
246
+ }
247
+
248
+ function isAskUserQuestionsTimedOutResult(
249
+ toolCall: Pick<ToolCall, "name">,
250
+ result: ExternalToolResultPayload,
251
+ ): boolean {
252
+ return isAskUserQuestionsToolName(toolCall.name) && result.details?.timed_out === true;
253
+ }
254
+
255
+ function abortActiveAskUserQuestionElicitationsForTimeout(): void {
256
+ if (!isInteractiveElicitationInFlight()) return;
257
+ for (const abort of [...activeAskUserQuestionElicitationTimeoutAborters]) {
258
+ abort();
259
+ }
260
+ }
230
261
 
231
262
  // ---------------------------------------------------------------------------
232
263
  // Stream factory
@@ -1566,12 +1597,32 @@ export function createClaudeCodeElicitationHandler(
1566
1597
  // it does not tear down the very elicitation that IS the human boundary
1567
1598
  // (#cc-elicitation-self-cancel). It clears in the finally on every exit.
1568
1599
  const elicId = "cc-elicit-" + ((request as { id?: string | number }).id ?? `${Date.now()}-${nextElicitationSeq()}`);
1600
+ const formAbortController = new AbortController();
1601
+ let expiryNoticeShown = false;
1602
+ const abortExpiredForm = (): void => {
1603
+ if (formAbortController.signal.aborted) return;
1604
+ formAbortController.abort();
1605
+ if (!expiryNoticeShown) {
1606
+ expiryNoticeShown = true;
1607
+ ui.notify(ELICITATION_EXPIRED_NOTICE, "warning");
1608
+ }
1609
+ };
1610
+ const forwardSdkAbort = (): void => {
1611
+ if (!formAbortController.signal.aborted) formAbortController.abort();
1612
+ };
1613
+ if (signal.aborted) {
1614
+ forwardSdkAbort();
1615
+ } else {
1616
+ signal.addEventListener("abort", forwardSdkAbort, { once: true });
1617
+ }
1618
+ const expiryTimer = setTimeout(abortExpiredForm, CLAUDE_CODE_INTERVIEW_FORM_TIMEOUT_MS);
1569
1619
  markInteractiveElicitationStart();
1570
1620
  markToolStart(elicId, "ask_user_questions");
1621
+ activeAskUserQuestionElicitationTimeoutAborters.add(abortExpiredForm);
1571
1622
  try {
1572
1623
  const interviewResult = await showInterviewRound(
1573
1624
  questions,
1574
- { signal, overlay: true },
1625
+ { signal: formAbortController.signal, overlay: true },
1575
1626
  { ui } as any,
1576
1627
  ).catch(() => undefined);
1577
1628
  if (interviewResult === undefined) {
@@ -1580,7 +1631,7 @@ export function createClaudeCodeElicitationHandler(
1580
1631
  // `finally` runs the moment the promise is created and the fallback
1581
1632
  // wait runs with zero in-flight tools — reintroducing the
1582
1633
  // self-cancel on this path (Bugbot #1c00624d).
1583
- return await promptElicitationWithDialogs(request, questions, ui, signal);
1634
+ return await promptElicitationWithDialogs(request, questions, ui, formAbortController.signal);
1584
1635
  }
1585
1636
  if (Object.keys(interviewResult.answers).length === 0) {
1586
1637
  // A system/host teardown (compaction, session_switch, true
@@ -1596,6 +1647,9 @@ export function createClaudeCodeElicitationHandler(
1596
1647
  content: roundResultToElicitationContent(questions, interviewResult),
1597
1648
  };
1598
1649
  } finally {
1650
+ clearTimeout(expiryTimer);
1651
+ signal.removeEventListener("abort", forwardSdkAbort);
1652
+ activeAskUserQuestionElicitationTimeoutAborters.delete(abortExpiredForm);
1599
1653
  markToolEnd(elicId);
1600
1654
  markInteractiveElicitationEnd();
1601
1655
  }
@@ -2526,6 +2580,9 @@ async function pumpSdkMessages(
2526
2580
  // Push synthetic completion events with result attached so the
2527
2581
  // chat-controller can update pending ToolExecutionComponents.
2528
2582
  if (block.type === "toolCall") {
2583
+ if (isAskUserQuestionsTimedOutResult(block, extResult)) {
2584
+ abortActiveAskUserQuestionElicitationsForTimeout();
2585
+ }
2529
2586
  if (suppressDuplicateUnavailable) {
2530
2587
  delete (block as ToolCallWithExternalResult).externalResult;
2531
2588
  stream.push({
@@ -2554,9 +2611,13 @@ async function pumpSdkMessages(
2554
2611
  });
2555
2612
  emittedExternalToolResultIds.add(block.id);
2556
2613
  } else if (block.type === "serverToolUse") {
2614
+ const toolCall = serverToolUseToToolCallLike(block);
2615
+ if (isAskUserQuestionsTimedOutResult(toolCall, extResult)) {
2616
+ abortActiveAskUserQuestionElicitationsForTimeout();
2617
+ }
2557
2618
  try {
2558
2619
  await onExternalToolResult?.({
2559
- toolCall: serverToolUseToToolCallLike(block),
2620
+ toolCall,
2560
2621
  result: extResult,
2561
2622
  });
2562
2623
  } catch (error) {
@@ -1,5 +1,5 @@
1
1
  // gsd-pi - Claude Code stream adapter regression tests
2
- import { describe, test } from "node:test";
2
+ import { describe, mock, test } from "node:test";
3
3
  import { clearGuidedUnitContext, setGuidedUnitContext } from "../../gsd/guided-unit-context.ts";
4
4
  import assert from "node:assert/strict";
5
5
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
@@ -46,6 +46,7 @@ import {
46
46
  buildWorkflowMcpReadinessProgressMessage,
47
47
  pushWorkflowMcpReadinessProgressEvent,
48
48
  resolveWorkflowMcpPreflightServerConfig,
49
+ CLAUDE_CODE_INTERVIEW_FORM_TIMEOUT_MS,
49
50
  } from "../stream-adapter.ts";
50
51
  import { CLAUDE_CODE_MODELS } from "../models.ts";
51
52
  import type { AssistantMessage, Context, Message } from "@gsd/pi-ai";
@@ -107,6 +108,51 @@ function setWorkflowMcpEnv(
107
108
  };
108
109
  }
109
110
 
111
+ // ---------------------------------------------------------------------------
112
+ // Shared MCP elicitation fixture — a representative ask_user_questions
113
+ // elicitation request. Used both by the external-tool-results interview
114
+ // lifecycle tests and by the MCP elicitation bridge tests, so it lives at
115
+ // module scope.
116
+ // ---------------------------------------------------------------------------
117
+
118
+ const askUserQuestionsRequest = {
119
+ serverName: "gsd-workflow",
120
+ message: "Please answer the following question(s).",
121
+ mode: "form" as const,
122
+ requestedSchema: {
123
+ type: "object" as const,
124
+ properties: {
125
+ storage_scope: {
126
+ type: "string",
127
+ title: "Storage",
128
+ description: "Does this app need to sync across devices?",
129
+ oneOf: [
130
+ { const: "Local-only (Recommended)", title: "Local-only (Recommended)" },
131
+ { const: "Cloud-synced", title: "Cloud-synced" },
132
+ { const: "None of the above", title: "None of the above" },
133
+ ],
134
+ },
135
+ storage_scope__note: {
136
+ type: "string",
137
+ title: "Storage Note",
138
+ description: "Optional note for None of the above.",
139
+ },
140
+ platform: {
141
+ type: "array",
142
+ title: "Platform",
143
+ description: "Where should it run?",
144
+ items: {
145
+ anyOf: [
146
+ { const: "Web", title: "Web" },
147
+ { const: "Desktop", title: "Desktop" },
148
+ { const: "Mobile", title: "Mobile" },
149
+ ],
150
+ },
151
+ },
152
+ },
153
+ },
154
+ };
155
+
110
156
  // ---------------------------------------------------------------------------
111
157
  // Existing tests — exhausted stream fallback (#2575)
112
158
  // ---------------------------------------------------------------------------
@@ -820,6 +866,134 @@ describe("stream-adapter — Claude Code external tool results", () => {
820
866
  );
821
867
  });
822
868
 
869
+ test("closes an in-flight interview when ask_user_questions reports timed_out first", async (t) => {
870
+ const notifications: Array<{ message: string; type?: string }> = [];
871
+ let elicitationPromise: Promise<unknown> | undefined;
872
+ let elicitationSignalController: AbortController | undefined;
873
+ let customOpened = false;
874
+ const ui = {
875
+ custom: async (factory: any) => new Promise((resolve) => {
876
+ customOpened = true;
877
+ factory({ requestRender() {} } as any, {} as any, {} as any, resolve);
878
+ }),
879
+ notify: (message: string, type?: string) => {
880
+ notifications.push({ message, type });
881
+ },
882
+ };
883
+ t.after(() => {
884
+ elicitationSignalController?.abort();
885
+ });
886
+
887
+ const stream = streamViaClaudeCode(
888
+ { id: "claude-sonnet-4-6" } as any,
889
+ { messages: [{ role: "user", content: "Ask me." } as Message] },
890
+ {
891
+ extensionUIContext: ui as any,
892
+ _skipWorkflowMcpPreflightForTest: true,
893
+ async *_sdkQueryForTest(args: {
894
+ prompt: string | AsyncIterable<unknown>;
895
+ options?: Record<string, unknown>;
896
+ }) {
897
+ const onElicitation = args.options?.onElicitation as
898
+ | ((request: unknown, options: { signal: AbortSignal }) => Promise<unknown>)
899
+ | undefined;
900
+ assert.equal(typeof onElicitation, "function");
901
+ elicitationSignalController = new AbortController();
902
+ elicitationPromise = onElicitation!(askUserQuestionsRequest, {
903
+ signal: elicitationSignalController.signal,
904
+ });
905
+ assert.equal(customOpened, true, "interview overlay should be open before the tool result arrives");
906
+
907
+ // The adapter only assembles tool-call blocks after a
908
+ // message_start has created the partial-message builder. Real
909
+ // SDK streams always emit it before a tool_use content block;
910
+ // without it the timed_out result is never matched to the
911
+ // in-flight ask_user_questions block, so the interview would
912
+ // never be closed.
913
+ yield {
914
+ type: "stream_event",
915
+ event: { type: "message_start", message: { model: "claude-sonnet-4-6" } },
916
+ parent_tool_use_id: null,
917
+ uuid: "partial-1",
918
+ session_id: "session-1",
919
+ };
920
+ yield {
921
+ type: "stream_event",
922
+ event: {
923
+ type: "content_block_start",
924
+ index: 0,
925
+ content_block: {
926
+ type: "tool_use",
927
+ id: "ask-1",
928
+ name: "mcp__gsd-workflow__ask_user_questions",
929
+ input: {},
930
+ },
931
+ },
932
+ parent_tool_use_id: null,
933
+ uuid: "partial-1",
934
+ session_id: "session-1",
935
+ };
936
+ yield {
937
+ type: "user",
938
+ uuid: "user-result-1",
939
+ session_id: "session-1",
940
+ parent_tool_use_id: "ask-1",
941
+ message: {
942
+ role: "user",
943
+ content: [{
944
+ type: "mcp_tool_result",
945
+ tool_use_id: "ask-1",
946
+ content: [{ type: "text", text: "timed out" }],
947
+ structuredContent: { timed_out: true },
948
+ is_error: false,
949
+ }],
950
+ },
951
+ };
952
+ yield {
953
+ type: "result",
954
+ subtype: "success",
955
+ uuid: "result-1",
956
+ session_id: "session-1",
957
+ duration_ms: 1,
958
+ duration_api_ms: 1,
959
+ is_error: false,
960
+ num_turns: 1,
961
+ result: "done",
962
+ stop_reason: "end_turn",
963
+ total_cost_usd: 0,
964
+ usage: {
965
+ input_tokens: 0,
966
+ output_tokens: 0,
967
+ cache_read_input_tokens: 0,
968
+ cache_creation_input_tokens: 0,
969
+ },
970
+ };
971
+ },
972
+ } as any,
973
+ );
974
+
975
+ await stream.result();
976
+ assert.ok(elicitationPromise);
977
+ let guard: ReturnType<typeof setTimeout> | undefined;
978
+ const elicitationResult = await Promise.race([
979
+ elicitationPromise,
980
+ new Promise<never>((_, reject) => {
981
+ guard = setTimeout(
982
+ () => reject(new Error("timed out waiting for in-flight interview to close")),
983
+ 250,
984
+ );
985
+ }),
986
+ ]).finally(() => {
987
+ if (guard) clearTimeout(guard);
988
+ });
989
+
990
+ assert.deepEqual(elicitationResult, { action: "decline" });
991
+ assert.deepEqual(notifications, [{
992
+ message: "Question expired before you answered - the form was closed and no answers were sent.",
993
+ type: "warning",
994
+ }]);
995
+ });
996
+
823
997
  test("serverToolUseToToolCallLike preserves object input for extension tool_result routing", () => {
824
998
  const toolCall = serverToolUseToToolCallLike({
825
999
  id: "srv-1",
@@ -2595,44 +2769,6 @@ describe("stream-adapter — workflow MCP readiness", () => {
2595
2769
  });
2596
2770
 
2597
2771
  describe("stream-adapter — MCP elicitation bridge", () => {
2598
- const askUserQuestionsRequest = {
2599
- serverName: "gsd-workflow",
2600
- message: "Please answer the following question(s).",
2601
- mode: "form" as const,
2602
- requestedSchema: {
2603
- type: "object" as const,
2604
- properties: {
2605
- storage_scope: {
2606
- type: "string",
2607
- title: "Storage",
2608
- description: "Does this app need to sync across devices?",
2609
- oneOf: [
2610
- { const: "Local-only (Recommended)", title: "Local-only (Recommended)" },
2611
- { const: "Cloud-synced", title: "Cloud-synced" },
2612
- { const: "None of the above", title: "None of the above" },
2613
- ],
2614
- },
2615
- storage_scope__note: {
2616
- type: "string",
2617
- title: "Storage Note",
2618
- description: "Optional note for None of the above.",
2619
- },
2620
- platform: {
2621
- type: "array",
2622
- title: "Platform",
2623
- description: "Where should it run?",
2624
- items: {
2625
- anyOf: [
2626
- { const: "Web", title: "Web" },
2627
- { const: "Desktop", title: "Desktop" },
2628
- { const: "Mobile", title: "Mobile" },
2629
- ],
2630
- },
2631
- },
2632
- },
2633
- },
2634
- };
2635
-
2636
2772
  test("parseAskUserQuestionsElicitation rebuilds interview questions from the MCP schema", () => {
2637
2773
  const questions = parseAskUserQuestionsElicitation(askUserQuestionsRequest);
2638
2774
  assert.deepEqual(questions, [
@@ -3041,6 +3177,43 @@ describe("stream-adapter — MCP elicitation bridge", () => {
3041
3177
  assert.deepEqual(result, { action: "decline" });
3042
3178
  });
3043
3179
 
3180
+ test("expires the interview overlay before the MCP elicitation response is dropped", async (t) => {
3181
+ mock.timers.enable({ apis: ["setTimeout"] });
3182
+ t.after(() => {
3183
+ mock.timers.reset();
3184
+ });
3185
+
3186
+ const notifications: Array<{ message: string; type?: string }> = [];
3187
+ let customOpened = false;
3188
+ const handler = createClaudeCodeElicitationHandler({
3189
+ custom: async (factory: any) => new Promise((resolve) => {
3190
+ customOpened = true;
3191
+ factory({ requestRender() {} } as any, {} as any, {} as any, resolve);
3192
+ }),
3193
+ notify: (message: string, type?: string) => {
3194
+ notifications.push({ message, type });
3195
+ },
3196
+ select: async () => {
3197
+ throw new Error("expired interview must not re-open dialog fallback");
3198
+ },
3199
+ } as any);
3200
+ assert.ok(handler);
3201
+
3202
+ const resultPromise = handler!(askUserQuestionsRequest, { signal: new AbortController().signal });
3203
+ await Promise.resolve();
3204
+ assert.equal(customOpened, true);
3205
+
3206
+ mock.timers.tick(CLAUDE_CODE_INTERVIEW_FORM_TIMEOUT_MS);
3207
+ const result = await resultPromise;
3208
+
3209
+ assert.deepEqual(result, { action: "decline" });
3210
+ assert.deepEqual(notifications, [{
3211
+ message: "Question expired before you answered - the form was closed and no answers were sent.",
3212
+ type: "warning",
3213
+ }]);
3214
+ assert.equal(isInteractiveElicitationInFlight(), false, "marker must clear after deadline expiry");
3215
+ });
3216
+
3044
3217
  test("returns cancel (today's semantics) when the user genuinely dismisses", async () => {
3045
3218
  const handler = createClaudeCodeElicitationHandler({
3046
3219
  custom: async () => ({ endInterview: false, answers: {} }),
@@ -356,6 +356,19 @@ export function verifyExpectedArtifact(
356
356
  try {
357
357
  let taskIds: string[] | null = null;
358
358
  let dbPrimary = false;
359
+ const planContent = readFileSync(absPath, "utf-8");
360
+ let parsedTaskIds: string[] | null = null;
361
+ const getParsedTaskIds = (): string[] => {
362
+ if (parsedTaskIds) return parsedTaskIds;
363
+ parsedTaskIds = parseLegacyPlan(planContent).tasks.map((t: { id: string }) => t.id);
364
+ return parsedTaskIds;
365
+ };
366
+ const tasksBlockMatch = planContent.match(/<tasks>([\s\S]*?)<\/tasks>/i);
367
+ const tasksBlock = tasksBlockMatch?.[1] ?? "";
368
+ const hasEmbeddedTaskEntries =
369
+ tasksBlock.length > 0 &&
370
+ (/^\s*- \[[xX ]\] \*\*T\d+/m.test(tasksBlock) ||
371
+ /^\s*#{2,4}\s+T\d+\s*(?:--|—|:)/m.test(tasksBlock));
359
372
  if (isDbAvailable()) {
360
373
  const refreshed = refreshWorkflowDatabaseFromDisk();
361
374
  if (refreshed) {
@@ -368,18 +381,17 @@ export function verifyExpectedArtifact(
368
381
  }
369
382
 
370
383
  if (!taskIds) {
371
- const planContent = readFileSync(absPath, "utf-8");
372
384
  const hasCheckboxTask = /^\s*- \[[xX ]\] \*\*T\d+/m.test(planContent);
373
385
  const hasHeadingTask = /^\s*#{2,4}\s+T\d+\s*(?:--|—|:)/m.test(planContent);
374
386
  if (!hasCheckboxTask && !hasHeadingTask) {
375
387
  logWarning("recovery", `verify-fail ${unitType} ${unitId}: plan has no task checkbox/heading (len=${planContent.length}) at ${absPath}`);
376
388
  return false;
377
389
  }
378
- const plan = parseLegacyPlan(planContent);
379
- if (plan.tasks.length > 0) taskIds = plan.tasks.map((t: { id: string }) => t.id);
390
+ const parsedIds = getParsedTaskIds();
391
+ if (parsedIds.length > 0) taskIds = parsedIds;
380
392
  }
381
393
 
382
- if (taskIds && taskIds.length > 0) {
394
+ if (taskIds && taskIds.length > 0 && !hasEmbeddedTaskEntries) {
383
395
  const tasksDir = join(dirname(absPath), "tasks");
384
396
  if (existsSync(tasksDir)) {
385
397
  for (const tid of taskIds) {
@@ -79,13 +79,14 @@ export async function restorePreflightStashOrStop(
79
79
  }
80
80
 
81
81
  /**
82
- * Run a milestone merge surrounded by preflight stash + always-on postflight
83
- * pop. The previous code popped the stash only after a successful merge, which
84
- * leaked `gsd-preflight-stash:M00x:*` entries whenever `mergeAndExit` threw —
85
- * leaving the user's pre-merge working tree silently stashed away after a
86
- * merge-conflict or other merge error. This helper restores the stash on
87
- * every exit path, then surfaces the merge or stash failure (in priority
88
- * order) as the loop's stop reason.
82
+ * Run a milestone merge through Worktree Lifecycle's guarded merge option,
83
+ * which surrounds the inner merge with preflight stash + always-on postflight
84
+ * pop. The previous closeout code popped the stash only after a successful
85
+ * merge, which leaked `gsd-preflight-stash:M00x:*` entries whenever
86
+ * `mergeAndExit` threw leaving the user's pre-merge working tree silently
87
+ * stashed away after a merge-conflict or other merge error. Lifecycle now
88
+ * restores the stash on every attempted merge path, then this adapter surfaces
89
+ * the merge or stash failure (in priority order) as the loop's stop reason.
89
90
  *
90
91
  * Returns a `break` action when auto-mode must stop, or `null` when the merge
91
92
  * succeeded and the stash (if any) was restored cleanly.
@@ -97,66 +98,45 @@ export async function _runMilestoneMergeWithStashRestore(
97
98
  ): Promise<{ action: "break"; reason: string } | null> {
98
99
  const { ctx, pi, s, deps } = ic;
99
100
 
100
- const preflight = deps.preflightCleanRoot(
101
- s.originalBasePath || s.basePath,
101
+ const projectRoot = s.originalBasePath || s.basePath;
102
+ const mergeResult = deps.lifecycle.exitMilestone(
102
103
  milestoneId,
103
- ctx.ui.notify.bind(ctx.ui),
104
+ {
105
+ merge: true,
106
+ guardedMerge: {
107
+ projectRoot,
108
+ preflightCleanRoot: deps.preflightCleanRoot,
109
+ postflightPopStash: deps.postflightPopStash,
110
+ },
111
+ },
112
+ ctx.ui,
104
113
  );
105
- if (preflight.blocked) {
106
- const reason = preflight.blockedReason === "unmerged-conflicts"
114
+
115
+ if (mergeResult.ok) {
116
+ await markMilestoneMergedAndRebuild(s);
117
+ return null;
118
+ }
119
+
120
+ if (mergeResult.reason === "postflight-stash-restore-failed") {
121
+ await markMilestoneMergedAndRebuild(s);
122
+ }
123
+
124
+ if (mergeResult.reason === "preflight-dirty-overlap" || mergeResult.reason === "preflight-unmerged-conflicts") {
125
+ const reason = mergeResult.reason === "preflight-unmerged-conflicts"
107
126
  ? `Pre-merge unresolved Git conflicts block milestone ${milestoneId}`
108
127
  : `Pre-merge dirty working tree overlaps milestone ${milestoneId}`;
109
128
  await deps.stopAuto(ctx, pi, reason, {
110
129
  preserveCompletedMilestoneBranch: true,
111
130
  preserveCloseoutTranscript: options.preserveCloseoutTranscript,
112
131
  });
113
- return {
114
- action: "break",
115
- reason: preflight.blockedReason === "unmerged-conflicts"
116
- ? "preflight-unmerged-conflicts"
117
- : "preflight-dirty-overlap",
118
- };
119
- }
120
-
121
- let mergeError: unknown = null;
122
- const exitResult = deps.lifecycle.exitMilestone(
123
- milestoneId,
124
- { merge: true },
125
- ctx.ui,
126
- );
127
- if (exitResult.ok) {
128
- s.milestoneMergedInPhases = true;
129
- try {
130
- const projectRoot = s.originalBasePath || s.canonicalProjectRoot || s.basePath;
131
- const { rebuildMarkdownProjectionsFromDb } = await import("../commands-maintenance.js");
132
- await rebuildMarkdownProjectionsFromDb(projectRoot);
133
- } catch (err) {
134
- logWarning(
135
- "engine",
136
- `markdown projection rebuild after milestone merge failed: ${err instanceof Error ? err.message : String(err)}`,
137
- );
138
- }
139
- } else {
140
- mergeError = exitResult.cause ?? new Error(`exit ${exitResult.reason}`);
141
- }
142
-
143
- // Always attempt to restore the stashed working tree, even on merge error.
144
- // postflightPopStash itself does not throw; failures surface via the
145
- // PostflightResult.needsManualRecovery flag.
146
- let stashResult: PostflightResult | null = null;
147
- if (preflight.stashPushed) {
148
- stashResult = deps.postflightPopStash(
149
- s.originalBasePath || s.basePath,
150
- milestoneId,
151
- preflight.stashMarker,
152
- ctx.ui.notify.bind(ctx.ui),
153
- );
132
+ return { action: "break", reason: mergeResult.reason };
154
133
  }
155
134
 
156
135
  // Merge failure takes priority over stash recovery — the merge is the
157
136
  // authoritative gate. If the stash also needed manual recovery, the user
158
137
  // already saw the postflightPopStash notify above.
159
- if (mergeError) {
138
+ if (mergeResult.reason === "merge-conflict") {
139
+ const mergeError = mergeResult.cause;
160
140
  if (mergeError instanceof MergeConflictError) {
161
141
  // A merge conflict is a recoverable human checkpoint, not an
162
142
  // infrastructure failure — the user resolves the conflict and runs
@@ -173,6 +153,10 @@ export async function _runMilestoneMergeWithStashRestore(
173
153
  });
174
154
  return { action: "break", reason: "merge-conflict" };
175
155
  }
156
+ }
157
+
158
+ if (mergeResult.reason === "merge-failed" || mergeResult.reason === "merge-conflict") {
159
+ const mergeError = mergeResult.cause;
176
160
  logError("engine", "Milestone merge failed with non-conflict error", {
177
161
  milestone: milestoneId,
178
162
  error: String(mergeError),
@@ -190,12 +174,26 @@ export async function _runMilestoneMergeWithStashRestore(
190
174
  return { action: "break", reason: "merge-failed" };
191
175
  }
192
176
 
193
- if (stashResult) {
194
- return stopOnPostflightRecoveryNeeded(ic, stashResult, milestoneId);
177
+ if (mergeResult.postflight) {
178
+ return stopOnPostflightRecoveryNeeded(ic, mergeResult.postflight, milestoneId);
195
179
  }
196
180
  return null;
197
181
  }
198
182
 
183
+ async function markMilestoneMergedAndRebuild(s: AutoSession): Promise<void> {
184
+ s.milestoneMergedInPhases = true;
185
+ try {
186
+ const rebuildBasePath = s.originalBasePath || s.canonicalProjectRoot || s.basePath;
187
+ const { rebuildMarkdownProjectionsFromDb } = await import("../commands-maintenance.js");
188
+ await rebuildMarkdownProjectionsFromDb(rebuildBasePath);
189
+ } catch (err) {
190
+ logWarning(
191
+ "engine",
192
+ `markdown projection rebuild after milestone merge failed: ${err instanceof Error ? err.message : String(err)}`,
193
+ );
194
+ }
195
+ }
196
+
199
197
  export async function _runMilestoneMergeOnceWithStashRestore(
200
198
  ic: IterationContext,
201
199
  milestoneId: string,
@@ -373,6 +373,14 @@ export async function runFinalize(
373
373
  files: leak.files.map((file) => ({ path: file.path, status: file.status })),
374
374
  });
375
375
  ctx.ui.notify(message, "error");
376
+ try {
377
+ deps.checkpointWorkflowDatabase?.();
378
+ } catch (err) {
379
+ debugLog("autoLoop", {
380
+ phase: "root-write-leak-checkpoint-failed",
381
+ error: err instanceof Error ? err.message : String(err),
382
+ });
383
+ }
376
384
  await deps.stopAuto(ctx, pi, "Root-write leak during isolated auto-mode", {
377
385
  preserveCompletedMilestoneBranch: true,
378
386
  });
@@ -80,6 +80,7 @@ export interface LoopDeps {
80
80
  ) => Promise<void>;
81
81
  pauseAuto: PauseAutoFn;
82
82
  clearUnitTimeout: () => void;
83
+ checkpointWorkflowDatabase?: () => void;
83
84
  updateProgressWidget: (
84
85
  ctx: ExtensionContext,
85
86
  unitType: string,
@@ -731,7 +731,7 @@ export class AutoOrchestrator implements AutoOrchestrationModule {
731
731
  return new GitServiceImpl(basePath, gitConfig);
732
732
  },
733
733
  worktreeProjection: new WorktreeStateProjection(),
734
- mergeMilestoneToMain: createMilestoneMergeTransaction(mergeMilestoneToMain),
734
+ mergeMilestone: createMilestoneMergeTransaction(mergeMilestoneToMain),
735
735
  });
736
736
  }
737
737
 
@@ -26,7 +26,7 @@ import { debugLog } from "../debug-logger.js";
26
26
  import { logWarning, logError } from "../workflow-logger.js";
27
27
  import { resolveAutoSupervisorConfig } from "../preferences.js";
28
28
  import { readUnitRuntimeRecord, type AutoUnitRuntimeRecord } from "../unit-runtime.js";
29
- import { consumeAutoWakeup } from "./schedule-wakeup.js";
29
+ import { clearAutoWakeup, consumeAutoWakeup } from "./schedule-wakeup.js";
30
30
  import { applyUnitSkillVisibility } from "../skill-scope.js";
31
31
 
32
32
  const UNIT_FAILSAFE_BUFFER_MS = 30_000;
@@ -248,7 +248,7 @@ export async function runUnit(
248
248
  ((supervisor.idle_timeout_minutes ?? 10) * 60 * 1000) + UNIT_FAILSAFE_BUFFER_MS,
249
249
  );
250
250
  let unitTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
251
- let result: UnitResult;
251
+ let result: UnitResult = { status: "cancelled" };
252
252
  try {
253
253
  const awaitAgentEndWithTimeout = (agentEndPromise: Promise<UnitResult>): Promise<UnitResult> => {
254
254
  const timeoutResult = new Promise<UnitResult>((resolve) => {
@@ -360,6 +360,9 @@ export async function runUnit(
360
360
  }
361
361
  } finally {
362
362
  if (unitTimeoutHandle) clearTimeout(unitTimeoutHandle);
363
+ if (result.status !== "completed") {
364
+ clearAutoWakeup(s.basePath, unitType, unitId);
365
+ }
363
366
  ctx.ui.setWorkingMessage?.(undefined);
364
367
  setVisibleSkills?.(undefined);
365
368
  }