@opengsd/gsd-pi 1.3.0-dev.65546769 → 1.3.0-dev.eed73bea

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 (183) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +11 -2
  3. package/dist/resources/extensions/google-cli/stream-adapter.js +82 -15
  4. package/dist/resources/extensions/gsd/auto/orchestrator.js +12 -3
  5. package/dist/resources/extensions/gsd/auto-dispatch.js +17 -14
  6. package/dist/resources/extensions/gsd/auto-prompts.js +43 -12
  7. package/dist/resources/extensions/gsd/auto-recovery.js +13 -6
  8. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +103 -13
  9. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +6 -1
  10. package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +2 -0
  11. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +8 -3
  12. package/dist/resources/extensions/gsd/bootstrap/system-context.js +46 -19
  13. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +75 -1
  14. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +1 -1
  15. package/dist/resources/extensions/gsd/commands-context.js +19 -1
  16. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +16 -10
  17. package/dist/resources/extensions/gsd/commands-worktree.js +12 -10
  18. package/dist/resources/extensions/gsd/dashboard-overlay.js +32 -3
  19. package/dist/resources/extensions/gsd/db/queries.js +60 -0
  20. package/dist/resources/extensions/gsd/doctor-providers.js +92 -8
  21. package/dist/resources/extensions/gsd/exec-sandbox.js +45 -9
  22. package/dist/resources/extensions/gsd/forensics.js +2 -32
  23. package/dist/resources/extensions/gsd/git-service.js +4 -4
  24. package/dist/resources/extensions/gsd/guided-flow-queue.js +59 -5
  25. package/dist/resources/extensions/gsd/health-widget.js +55 -29
  26. package/dist/resources/extensions/gsd/markdown-renderer.js +6 -2
  27. package/dist/resources/extensions/gsd/memory-consolidation-scanner.js +44 -21
  28. package/dist/resources/extensions/gsd/milestone-implementation-evidence.js +26 -20
  29. package/dist/resources/extensions/gsd/quick.js +45 -2
  30. package/dist/resources/extensions/gsd/session-forensics.js +11 -1
  31. package/dist/resources/extensions/gsd/state-reconciliation/drift/stale-render.js +52 -3
  32. package/dist/resources/extensions/gsd/tools/complete-slice.js +34 -3
  33. package/dist/resources/extensions/gsd/tools/complete-task.js +78 -16
  34. package/dist/resources/extensions/gsd/tools/exec-tool.js +7 -2
  35. package/dist/resources/extensions/gsd/unit-context-composer.js +23 -7
  36. package/dist/resources/extensions/gsd/unit-registry.js +25 -3
  37. package/dist/resources/extensions/gsd/unmerged-milestone-guard.js +33 -3
  38. package/dist/resources/extensions/gsd/validation-block-guard.js +9 -4
  39. package/dist/resources/extensions/gsd/workspace-git-preflight.js +30 -1
  40. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  41. package/dist/web/standalone/.next/BUILD_ID +1 -1
  42. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  43. package/dist/web/standalone/.next/build-manifest.json +3 -3
  44. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  45. package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
  46. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/api/visualizer/route.js +1 -1
  63. package/dist/web/standalone/.next/server/app/index.html +1 -1
  64. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  71. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  72. package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
  73. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  74. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  75. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  76. package/dist/web/standalone/.next/static/chunks/{796.e0bdc932325d7e03.js → 796.3976108148518f7d.js} +3 -3
  77. package/dist/web/standalone/.next/static/chunks/{webpack-f46ea08200a0227e.js → webpack-7c1d97e39be2da11.js} +1 -1
  78. package/package.json +1 -1
  79. package/packages/cloud-mcp-gateway/package.json +2 -2
  80. package/packages/contracts/dist/workflow.d.ts +1 -0
  81. package/packages/contracts/dist/workflow.d.ts.map +1 -1
  82. package/packages/contracts/dist/workflow.js +2 -0
  83. package/packages/contracts/dist/workflow.js.map +1 -1
  84. package/packages/contracts/package.json +1 -1
  85. package/packages/daemon/package.json +4 -4
  86. package/packages/gsd-agent-core/package.json +5 -5
  87. package/packages/gsd-agent-modes/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  88. package/packages/gsd-agent-modes/dist/modes/interactive/controllers/chat-controller.js +21 -9
  89. package/packages/gsd-agent-modes/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  90. package/packages/gsd-agent-modes/package.json +7 -7
  91. package/packages/mcp-server/README.md +1 -1
  92. package/packages/mcp-server/dist/server.d.ts +1 -1
  93. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  94. package/packages/mcp-server/dist/server.js +3 -3
  95. package/packages/mcp-server/dist/server.js.map +1 -1
  96. package/packages/mcp-server/dist/workflow-tools.d.ts +13 -1
  97. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  98. package/packages/mcp-server/dist/workflow-tools.js +34 -20
  99. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  100. package/packages/mcp-server/package.json +4 -4
  101. package/packages/native/package.json +1 -1
  102. package/packages/pi-agent-core/package.json +1 -1
  103. package/packages/pi-ai/package.json +1 -1
  104. package/packages/pi-coding-agent/package.json +7 -7
  105. package/packages/pi-tui/package.json +2 -2
  106. package/packages/rpc-client/package.json +2 -2
  107. package/pkg/package.json +1 -1
  108. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +20 -2
  109. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +80 -0
  110. package/src/resources/extensions/google-cli/stream-adapter.ts +106 -19
  111. package/src/resources/extensions/gsd/auto/orchestrator.ts +25 -11
  112. package/src/resources/extensions/gsd/auto-dispatch.ts +18 -17
  113. package/src/resources/extensions/gsd/auto-prompts.ts +54 -12
  114. package/src/resources/extensions/gsd/auto-recovery.ts +13 -6
  115. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +125 -12
  116. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +6 -1
  117. package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +2 -0
  118. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +9 -3
  119. package/src/resources/extensions/gsd/bootstrap/system-context.ts +52 -18
  120. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +82 -1
  121. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +1 -1
  122. package/src/resources/extensions/gsd/commands-context.ts +18 -1
  123. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +14 -9
  124. package/src/resources/extensions/gsd/commands-worktree.ts +12 -10
  125. package/src/resources/extensions/gsd/dashboard-overlay.ts +32 -3
  126. package/src/resources/extensions/gsd/db/queries.ts +79 -0
  127. package/src/resources/extensions/gsd/doctor-providers.ts +103 -9
  128. package/src/resources/extensions/gsd/exec-sandbox.ts +49 -9
  129. package/src/resources/extensions/gsd/forensics.ts +2 -33
  130. package/src/resources/extensions/gsd/git-service.ts +5 -5
  131. package/src/resources/extensions/gsd/guided-flow-queue.ts +82 -4
  132. package/src/resources/extensions/gsd/health-widget.ts +69 -32
  133. package/src/resources/extensions/gsd/markdown-renderer.ts +6 -1
  134. package/src/resources/extensions/gsd/memory-consolidation-scanner.ts +51 -19
  135. package/src/resources/extensions/gsd/milestone-implementation-evidence.ts +35 -21
  136. package/src/resources/extensions/gsd/quick.ts +43 -2
  137. package/src/resources/extensions/gsd/session-forensics.ts +11 -1
  138. package/src/resources/extensions/gsd/state-reconciliation/drift/stale-render.ts +76 -8
  139. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +111 -1
  140. package/src/resources/extensions/gsd/tests/commands-context.test.ts +26 -0
  141. package/src/resources/extensions/gsd/tests/commands-worktree-clean.test.ts +80 -0
  142. package/src/resources/extensions/gsd/tests/complete-slice.test.ts +11 -0
  143. package/src/resources/extensions/gsd/tests/complete-task-rollback-evidence.test.ts +48 -8
  144. package/src/resources/extensions/gsd/tests/complete-task.test.ts +75 -0
  145. package/src/resources/extensions/gsd/tests/dashboard-overlay.test.ts +55 -2
  146. package/src/resources/extensions/gsd/tests/dispatch-rule-coverage.test.ts +26 -1
  147. package/src/resources/extensions/gsd/tests/doctor-forensics-db-open-regression.test.ts +70 -2
  148. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +107 -0
  149. package/src/resources/extensions/gsd/tests/exec-graceful-kill.test.ts +38 -0
  150. package/src/resources/extensions/gsd/tests/exec-tool.test.ts +45 -1
  151. package/src/resources/extensions/gsd/tests/forensics-error-filter.test.ts +88 -0
  152. package/src/resources/extensions/gsd/tests/guided-discuss-milestone-prompt-rendering.test.ts +42 -0
  153. package/src/resources/extensions/gsd/tests/health-widget.test.ts +268 -3
  154. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +119 -1
  155. package/src/resources/extensions/gsd/tests/integration/queue-active-milestone-context-budget.test.ts +93 -0
  156. package/src/resources/extensions/gsd/tests/integration/quick-branch-lifecycle.test.ts +56 -9
  157. package/src/resources/extensions/gsd/tests/knowledge-cold-start.test.ts +14 -0
  158. package/src/resources/extensions/gsd/tests/memory-consolidation-scanner.test.ts +78 -0
  159. package/src/resources/extensions/gsd/tests/orchestrator-logs.test.ts +43 -1
  160. package/src/resources/extensions/gsd/tests/parallel-research-dispatch.test.ts +26 -0
  161. package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +1 -1
  162. package/src/resources/extensions/gsd/tests/prefs-wizard-coverage.test.ts +54 -1
  163. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +195 -1
  164. package/src/resources/extensions/gsd/tests/read-uat-gate-verdict.test.ts +185 -0
  165. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +87 -0
  166. package/src/resources/extensions/gsd/tests/state-reconciliation-drift.test.ts +76 -0
  167. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +68 -0
  168. package/src/resources/extensions/gsd/tests/tool-param-optionality.test.ts +26 -0
  169. package/src/resources/extensions/gsd/tests/unit-context-composer.test.ts +193 -14
  170. package/src/resources/extensions/gsd/tests/unmerged-milestone-guard.test.ts +25 -0
  171. package/src/resources/extensions/gsd/tests/validation-block-guard.test.ts +79 -0
  172. package/src/resources/extensions/gsd/tests/verify-artifact-tightened.test.ts +66 -0
  173. package/src/resources/extensions/gsd/tests/workspace-git-preflight.test.ts +151 -2
  174. package/src/resources/extensions/gsd/tools/complete-slice.ts +30 -3
  175. package/src/resources/extensions/gsd/tools/complete-task.ts +86 -16
  176. package/src/resources/extensions/gsd/tools/exec-tool.ts +7 -3
  177. package/src/resources/extensions/gsd/unit-context-composer.ts +33 -7
  178. package/src/resources/extensions/gsd/unit-registry.ts +25 -3
  179. package/src/resources/extensions/gsd/unmerged-milestone-guard.ts +41 -5
  180. package/src/resources/extensions/gsd/validation-block-guard.ts +13 -7
  181. package/src/resources/extensions/gsd/workspace-git-preflight.ts +31 -0
  182. /package/dist/web/standalone/.next/static/{BTKtGFF1Y-hvVJEGhBRo9 → SzEuqWX37DR9MEpEuQjP1}/_buildManifest.js +0 -0
  183. /package/dist/web/standalone/.next/static/{BTKtGFF1Y-hvVJEGhBRo9 → SzEuqWX37DR9MEpEuQjP1}/_ssgManifest.js +0 -0
@@ -24,7 +24,7 @@ import { isContextModeEnabled } from "./preferences-types.js";
24
24
  import { parseRoadmap } from "./parsers-legacy.js";
25
25
  import type { GSDState, InlineLevel } from "./types.js";
26
26
  import type { GSDPreferences } from "./preferences.js";
27
- import { join, basename } from "node:path";
27
+ import { join, basename, relative } from "node:path";
28
28
  import { existsSync } from "node:fs";
29
29
  import { computeBudgets, resolveExecutorContextWindow, truncateAtSectionBoundary, type MinimalModelRegistry } from "./context-budget.js";
30
30
  import { getPendingGates, getPendingGatesForTurn } from "./gsd-db.js";
@@ -48,6 +48,7 @@ import {
48
48
  type ExcerptResolver,
49
49
  } from "./unit-context-composer.js";
50
50
  import { resolveManifest, type ArtifactKey } from "./unit-context-manifest.js";
51
+ import { resolveExpectedArtifactPath } from "./auto-artifact-paths.js";
51
52
  import { compileUnitContextContract, type UnitPromptContextContract } from "./tool-contract.js";
52
53
  import { readCompactionSnapshot } from "./compaction-snapshot.js";
53
54
  import { logWarning } from "./workflow-logger.js";
@@ -323,19 +324,53 @@ function requireComposedArtifactBlock(
323
324
  return block.body;
324
325
  }
325
326
 
327
+ interface ExecuteTaskOnDemandResult {
328
+ /** Rendered block for the prompt; empty string when not shown. */
329
+ text: string;
330
+ /**
331
+ * Telemetry skip reason when the block was suppressed; null when the block
332
+ * is included. Callers pass this directly to `trackPromptContext`.
333
+ */
334
+ skipReason: string | null;
335
+ }
336
+
326
337
  function renderExecuteTaskOnDemandContext(
327
338
  base: string,
328
339
  mid: string,
329
340
  sid: string,
330
341
  artifacts: readonly ArtifactKey[],
331
- ): string {
332
- if (!artifacts.includes("slice-research")) return "";
333
- const researchPath = relSliceFile(base, mid, sid, "RESEARCH");
334
- return [
335
- "## On-demand Context",
336
- "",
337
- `Slice research is available at \`${researchPath}\`. Read it only if the inlined task plan, slice plan excerpt, and carry-forward context do not explain a required implementation detail.`,
338
- ].join("\n");
342
+ ): ExecuteTaskOnDemandResult {
343
+ if (!artifacts.includes("slice-research")) {
344
+ return { text: "", skipReason: "not declared by contract" };
345
+ }
346
+ // Mirror dispatch's dual-resolution logic: worktree projection path first,
347
+ // then the authoritative project-root path via resolveExpectedArtifactPath.
348
+ // In worktree layouts the RESEARCH file may live under the project-root .gsd
349
+ // and not have been copied into the worktree projection, so resolveSliceFile
350
+ // (which looks only at gsdProjectionRoot) would miss it while dispatch would
351
+ // still treat research as satisfied.
352
+ const projectedFile = resolveSliceFile(base, mid, sid, "RESEARCH");
353
+ const researchFile: string | null = projectedFile ?? (() => {
354
+ const p = resolveExpectedArtifactPath("research-slice", `${mid}/${sid}`, base);
355
+ return p && existsSync(p) ? p : null;
356
+ })();
357
+ if (!researchFile) {
358
+ return { text: "", skipReason: "missing" };
359
+ }
360
+ // Use the layout-aware relative path when the file is in the worktree
361
+ // projection (relSliceFile), or fall back to node:path relative() when the
362
+ // file was only found via the project-root resolution path.
363
+ const researchPath = projectedFile
364
+ ? relSliceFile(base, mid, sid, "RESEARCH")
365
+ : relative(base, researchFile);
366
+ return {
367
+ text: [
368
+ "## On-demand Context",
369
+ "",
370
+ `Slice research is available at \`${researchPath}\`. Read it only if the inlined task plan, slice plan excerpt, and carry-forward context do not explain a required implementation detail.`,
371
+ ].join("\n"),
372
+ skipReason: null,
373
+ };
339
374
  }
340
375
 
341
376
  // ─── Executor Constraints ─────────────────────────────────────────────────────
@@ -1691,7 +1726,13 @@ export async function buildDiscussMilestonePrompt(
1691
1726
  const draftContent = draftPath ? await loadFile(draftPath) : null;
1692
1727
 
1693
1728
  if (includeDraftSeed && draftContent) {
1694
- return `${promptWithContextMode}\n\n## Prior Discussion (Draft Seed)\n\nThe following draft was captured from a prior multi-milestone discussion. Use it as seed material — the user has already provided this context. Start with a brief reflection on what the draft covers, then probe for any gaps or open questions before writing the full CONTEXT.md.\n\n${draftContent}`;
1729
+ const draftRelPath = relMilestoneFile(base, mid, "CONTEXT-DRAFT");
1730
+ const draftSeed = `### Prior Discussion Draft\nSource: \`${draftRelPath}\`\n\n${draftContent.trim()}`;
1731
+ const cappedDraftSeed = capPreamble(draftSeed);
1732
+ const truncationNote = cappedDraftSeed !== draftSeed
1733
+ ? `\n\n_(Draft seed truncated; read the full draft at \`${draftRelPath}\` if needed.)_`
1734
+ : "";
1735
+ return `${promptWithContextMode}\n\n## Prior Discussion (Draft Seed)\n\nThe following draft was captured from a prior multi-milestone discussion. Use it as seed material — the user has already provided this context. Start with a brief reflection on what the draft covers, then probe for any gaps or open questions before writing the full CONTEXT.md.\n\n${cappedDraftSeed}${truncationNote}`;
1695
1736
  }
1696
1737
 
1697
1738
  return promptWithContextMode;
@@ -2807,13 +2848,14 @@ export async function buildExecuteTaskPrompt(
2807
2848
  const slicePlanExcerpt = requireComposedArtifactBlock(contractedContext.blocks, "execute-task", "slice-plan");
2808
2849
  const contractedCarryForward = requireComposedArtifactBlock(contractedContext.blocks, "execute-task", "prior-task-summaries");
2809
2850
  const contractedTemplates = requireComposedArtifactBlock(contractedContext.blocks, "execute-task", "templates");
2810
- const onDemandContext = renderExecuteTaskOnDemandContext(base, mid, sid, contractedContext.onDemand);
2851
+ const onDemandResult = renderExecuteTaskOnDemandContext(base, mid, sid, contractedContext.onDemand);
2852
+ const onDemandContext = onDemandResult.text;
2811
2853
  trackPromptContext(
2812
2854
  contextTelemetry,
2813
2855
  "slice-research",
2814
2856
  onDemandContext ? "on-demand" : "skipped",
2815
2857
  onDemandContext,
2816
- onDemandContext ? undefined : "not declared by contract",
2858
+ onDemandContext ? undefined : onDemandResult.skipReason ?? undefined,
2817
2859
  );
2818
2860
 
2819
2861
  const prompt = loadPrompt("execute-task", {
@@ -537,14 +537,21 @@ export function verifyExpectedArtifact(
537
537
  // discuss-milestone unit with CONTEXT at phases/NN-slug/ in the project root
538
538
  // but not in the worktree resolves to null → "resolveExpectedArtifactPath
539
539
  // returned null" → finalize-retry loop (#852).
540
- if (artifactBase !== base) {
540
+ //
541
+ // #870: the fallback must fire whenever the artifact is missing at
542
+ // `artifactBase`, NOT only when `artifactBase !== base`. When the real call
543
+ // site passes the worktree path as `base` (auto-post-unit.ts:1726 uses
544
+ // `s.currentUnit.workspaceRoot ?? s.basePath`), resolveCanonicalMilestoneRoot
545
+ // round-trips the worktree path back to itself, so `artifactBase === base`
546
+ // while still being a worktree path that lacks the projected artifact. The
547
+ // old `if (artifactBase !== base)` guard skipped the fallback in exactly that
548
+ // case, stranding CONTEXT at the project root and producing a stuck-loop.
549
+ if (!absPath || !existsSync(absPath)) {
541
550
  const projectRoot = resolve(resolveWorktreeProjectRoot(artifactBase));
542
551
  if (projectRoot && projectRoot !== artifactBase) {
543
- if (!absPath || !existsSync(absPath)) {
544
- const projectPath = resolveExpectedArtifactPath(unitType, unitId, projectRoot);
545
- if (projectPath && existsSync(projectPath)) {
546
- absPath = projectPath;
547
- }
552
+ const projectPath = resolveExpectedArtifactPath(unitType, unitId, projectRoot);
553
+ if (projectPath && existsSync(projectPath)) {
554
+ absPath = projectPath;
548
555
  }
549
556
  }
550
557
  }
@@ -1,6 +1,8 @@
1
1
  // gsd-pi + src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts - Handles provider and agent-end recovery for GSD auto-mode.
2
2
 
3
3
  import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
4
+ import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
4
6
 
5
7
  import type { AgentEndEvent, ErrorContext } from "../auto/types.js";
6
8
  import { logWarning } from "../workflow-logger.js";
@@ -11,7 +13,7 @@ import {
11
13
  maybeHandleEmptyIntentTurn,
12
14
  resetEmptyTurnCounter,
13
15
  } from "../guided-flow.js";
14
- import { clearPathCache } from "../paths.js";
16
+ import { clearPathCache, gsdRoot } from "../paths.js";
15
17
  import {
16
18
  getAutoDashboardData,
17
19
  getAutoModeStartModel,
@@ -34,7 +36,7 @@ import { resolveModelId } from "../auto-model-selection.js";
34
36
  import { resolveProjectRoot } from "../worktree.js";
35
37
  import { clearDiscussionFlowState } from "./write-gate.js";
36
38
  import { scheduleFallbackContinuation } from "./fallback-continuation.js";
37
- import { clearGuidedUnitContext } from "../guided-unit-context.js";
39
+ import { clearGuidedUnitContext, getGuidedUnitContext, type GuidedUnitContext } from "../guided-unit-context.js";
38
40
  import { resumeAutoAfterProviderDelay } from "./provider-error-resume.js";
39
41
  import {
40
42
  classifyError,
@@ -116,9 +118,14 @@ export function isUserInitiatedAbortMessage(message: string | undefined | null):
116
118
  export function shouldDeferTransientErrorToCoreRetry(
117
119
  cls: ErrorClass,
118
120
  rawErrorMsg: string,
121
+ deferCheckMsg: string = rawErrorMsg,
119
122
  ): boolean {
120
123
  if (!isTransient(cls) || cls.kind === "rate-limit") return false;
121
- return !/retry failed after \d+ attempts:/i.test(rawErrorMsg);
124
+ // Empty rawErrorMsg means the SDK terminated the session without providing an
125
+ // error string — core is done, not mid-retry. GSD must schedule its own
126
+ // retry rather than silently deferring to a core that has already exited.
127
+ if (!rawErrorMsg) return false;
128
+ return !/retry failed after \d+ attempts:/i.test(deferCheckMsg);
122
129
  }
123
130
 
124
131
  type ProviderModelFallbackParams = {
@@ -388,6 +395,108 @@ export function suppressTerminalDeletedWorktreeMessageEnd(event: MessageEndLike)
388
395
  return true;
389
396
  }
390
397
 
398
+ function modelLabel(ctx: ExtensionContext): string {
399
+ const provider = ctx.model?.provider;
400
+ const id = ctx.model?.id;
401
+ return provider && id ? `${provider}/${id}` : "unknown model";
402
+ }
403
+
404
+ function isFatalManualGuidedTerminalFailure(lastMsg: unknown): boolean {
405
+ if (!isObjectRecord(lastMsg) || !("stopReason" in lastMsg)) return false;
406
+ if (lastMsg.stopReason === "error") {
407
+ const rawErrorMsg = ("errorMessage" in lastMsg && lastMsg.errorMessage) ? String(lastMsg.errorMessage) : "";
408
+ if (isUserInitiatedAbortMessage(rawErrorMsg)) return false;
409
+ return true;
410
+ }
411
+ if (lastMsg.stopReason !== "aborted") return false;
412
+
413
+ const content = "content" in lastMsg ? lastMsg.content : undefined;
414
+ const hasErrorMessage = "errorMessage" in lastMsg && !!lastMsg.errorMessage;
415
+ return hasErrorMessage || !_hasEmptyAgentEndContent(content);
416
+ }
417
+
418
+ function terminalFailureDetail(lastMsg: unknown): string {
419
+ if (!isObjectRecord(lastMsg)) return "Provider turn ended with an unknown terminal error.";
420
+ const rawErrorMsg = ("errorMessage" in lastMsg && lastMsg.errorMessage) ? String(lastMsg.errorMessage) : "";
421
+ const content = "content" in lastMsg ? lastMsg.content : undefined;
422
+ const displayMsg = resolveAgentEndErrorDisplay(rawErrorMsg, content).replace(/\s+/g, " ").trim();
423
+ if (displayMsg) return displayMsg.length > 300 ? `${displayMsg.slice(0, 300)}...` : displayMsg;
424
+ return lastMsg.stopReason === "aborted"
425
+ ? "Provider turn aborted with error context."
426
+ : "Provider stream ended with stopReason=error.";
427
+ }
428
+
429
+ function nextManualActivitySequence(activityDir: string): string {
430
+ let maxSeq = 0;
431
+ try {
432
+ for (const file of readdirSync(activityDir)) {
433
+ const match = /^(\d+)-/.exec(file);
434
+ if (match) maxSeq = Math.max(maxSeq, Number.parseInt(match[1]!, 10));
435
+ }
436
+ } catch {
437
+ return "001";
438
+ }
439
+ return String(maxSeq + 1).padStart(3, "0");
440
+ }
441
+
442
+ function writeManualGuidedTerminalErrorActivity(
443
+ basePath: string,
444
+ unitType: string,
445
+ model: string,
446
+ detail: string,
447
+ stopReason: unknown,
448
+ ): void {
449
+ const activityDir = join(gsdRoot(basePath), "activity");
450
+ mkdirSync(activityDir, { recursive: true });
451
+ const seq = nextManualActivitySequence(activityDir);
452
+ const safeUnitType = unitType.replace(/[^a-z0-9_.-]+/gi, "-");
453
+ const markerPath = join(activityDir, `${seq}-${safeUnitType}-manual-terminal-provider-error.jsonl`);
454
+ const message = `Manual guided ${unitType} turn ended with provider ${String(stopReason)} on ${model}: ${detail}`;
455
+ writeFileSync(
456
+ markerPath,
457
+ JSON.stringify({
458
+ type: "message",
459
+ message: {
460
+ role: "toolResult",
461
+ toolCallId: "manual-guided-terminal-provider-error",
462
+ toolName: "provider",
463
+ isError: true,
464
+ content: [{ type: "text", text: message }],
465
+ },
466
+ }) + "\n",
467
+ "utf-8",
468
+ );
469
+ }
470
+
471
+ function observeManualDiscussTerminalError(
472
+ ctx: ExtensionContext,
473
+ lastMsg: unknown,
474
+ guidedUnit: GuidedUnitContext | null,
475
+ ): void {
476
+ if (!guidedUnit?.unitType.startsWith("discuss-")) return;
477
+ if (!isFatalManualGuidedTerminalFailure(lastMsg)) return;
478
+
479
+ const model = modelLabel(ctx);
480
+ const detail = terminalFailureDetail(lastMsg);
481
+ ctx.ui.notify(
482
+ `Manual /gsd discuss ${guidedUnit.unitType} ended with a provider error on ${model}: ${detail}`,
483
+ "warning",
484
+ );
485
+
486
+ try {
487
+ writeManualGuidedTerminalErrorActivity(
488
+ guidedUnit.basePath,
489
+ guidedUnit.unitType,
490
+ model,
491
+ detail,
492
+ isObjectRecord(lastMsg) ? lastMsg.stopReason : "unknown",
493
+ );
494
+ } catch (err) {
495
+ const message = err instanceof Error ? err.message : String(err);
496
+ logWarning("bootstrap", `Failed to write manual guided terminal-error activity marker: ${message}`);
497
+ }
498
+ }
499
+
391
500
  async function pauseTransientWithBackoff(
392
501
  cls: ErrorClass,
393
502
  pi: ExtensionAPI,
@@ -437,7 +546,9 @@ export async function handleAgentEnd(
437
546
  // rejected" loop even though the files are on disk.
438
547
  clearPathCache();
439
548
  const basePath = resolveAgentEndBasePath();
440
- clearGuidedUnitContext(basePath);
549
+ const lastMsg = event.messages[event.messages.length - 1];
550
+ const guidedUnit = basePath ? getGuidedUnitContext(basePath) ?? getGuidedUnitContext() : getGuidedUnitContext();
551
+ clearGuidedUnitContext(guidedUnit?.basePath ?? basePath);
441
552
 
442
553
  try {
443
554
  if (await checkDeepProjectSetupAfterTurn(event, ctx, basePath)) {
@@ -466,13 +577,15 @@ export async function handleAgentEnd(
466
577
  // discussions (where isAutoActive may be false) still get recovered.
467
578
  if (maybeHandleEmptyIntentTurn(event, isAutoActive(), basePath)) return;
468
579
 
469
- if (!isAutoActive()) return;
580
+ if (!isAutoActive()) {
581
+ observeManualDiscussTerminalError(ctx, lastMsg, guidedUnit);
582
+ return;
583
+ }
470
584
 
471
585
  if (shouldIgnoreAgentEndForActiveUnit(event)) {
472
586
  return;
473
587
  }
474
588
 
475
- const lastMsg = event.messages[event.messages.length - 1];
476
589
  if (isSessionSwitchInFlight()) {
477
590
  _handleSessionSwitchAgentEnd(lastMsg, resolveAgentEndCancelled);
478
591
  return;
@@ -555,9 +668,9 @@ export async function handleAgentEnd(
555
668
  });
556
669
  return;
557
670
  }
558
- // #3588: When errorMessage is uninformative, extract the real error from
559
- // the assistant message text content for display purposes only.
560
- // Classification still uses rawErrorMsg to avoid false positives from prose.
671
+ // #3588/#956: When errorMessage is uninformative, extract the real error
672
+ // from assistant text. Prefer rawErrorMsg for classification to avoid
673
+ // prose false-positives, but use display text when rawErrorMsg is empty.
561
674
  const displayMsg = resolveAgentEndErrorDisplay(
562
675
  rawErrorMsg,
563
676
  "content" in lastMsg ? lastMsg.content : undefined,
@@ -576,8 +689,8 @@ export async function handleAgentEnd(
576
689
  const errorDetail = displayMsg ? `: ${displayMsg}` : "";
577
690
  const explicitRetryAfterMs = ("retryAfterMs" in lastMsg && typeof lastMsg.retryAfterMs === "number") ? lastMsg.retryAfterMs : undefined;
578
691
 
579
- // ── 1. Classify using rawErrorMsg to avoid prose false-positives ────
580
- const cls = classifyError(rawErrorMsg, explicitRetryAfterMs);
692
+ // ── 1. Classify, preserving non-empty errorMessage precedence ──────
693
+ const cls = classifyError(rawErrorMsg || displayMsg, explicitRetryAfterMs);
581
694
 
582
695
  // ── 1a. Unsupported-model: provider rejected this model for the current
583
696
  // account/plan at request time (#4513). Persist a block so the
@@ -655,7 +768,7 @@ export async function handleAgentEnd(
655
768
  // Core retries transient failures in-session after this handler.
656
769
  // Keep that behavior for non-rate-limit classes to avoid pause/retry races,
657
770
  // but let rate-limit continue into model fallback logic below (#4373).
658
- if (shouldDeferTransientErrorToCoreRetry(cls, rawErrorMsg)) {
771
+ if (shouldDeferTransientErrorToCoreRetry(cls, rawErrorMsg, rawErrorMsg || displayMsg)) {
659
772
  return;
660
773
  }
661
774
 
@@ -3,6 +3,7 @@
3
3
  import { Type, StringEnum } from "@gsd/pi-ai";
4
4
  import type { ExtensionAPI } from "@gsd/pi-coding-agent";
5
5
  import { Text } from "@gsd/pi-tui";
6
+ import { SUMMARY_SAVE_CONTENT_MAX_LENGTH } from "@opengsd/contracts";
6
7
 
7
8
  import { loadEffectiveGSDPreferences } from "../preferences.js";
8
9
  import { ensureDbOpen, resolveCtxCwd, resolveWorkflowToolBasePath } from "./dynamic-tools.js";
@@ -392,13 +393,17 @@ export function registerDbTools(pi: ExtensionAPI): void {
392
393
  "Root-level artifact paths are PROJECT.md, PROJECT-DRAFT.md, REQUIREMENTS.md, and REQUIREMENTS-DRAFT.md.",
393
394
  "artifact_type must be one of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT, CONTEXT-DRAFT, PROJECT, PROJECT-DRAFT, REQUIREMENTS, REQUIREMENTS-DRAFT.",
394
395
  "Use CONTEXT-DRAFT for incremental draft persistence; use CONTEXT for the final milestone context after depth verification.",
396
+ `Keep each content payload under ${SUMMARY_SAVE_CONTENT_MAX_LENGTH} characters; save large context incrementally with CONTEXT-DRAFT/PROJECT-DRAFT/REQUIREMENTS-DRAFT instead of one oversized call.`,
395
397
  ],
396
398
  parameters: Type.Object({
397
399
  milestone_id: Type.Optional(Type.String({ description: "Milestone ID (e.g. M001). Omit only for root-level PROJECT/PROJECT-DRAFT/REQUIREMENTS/REQUIREMENTS-DRAFT artifacts." })),
398
400
  slice_id: Type.Optional(Type.String({ description: "Slice ID (e.g. S01)" })),
399
401
  task_id: Type.Optional(Type.String({ description: "Task ID (e.g. T01)" })),
400
402
  artifact_type: StringEnum(["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT", "CONTEXT-DRAFT", "PROJECT", "PROJECT-DRAFT", "REQUIREMENTS", "REQUIREMENTS-DRAFT"], { description: "Artifact type to save" }),
401
- content: Type.String({ description: "The full markdown content of the artifact" }),
403
+ content: Type.String({
404
+ description: `The full markdown content of the artifact. Maximum ${SUMMARY_SAVE_CONTENT_MAX_LENGTH} characters per save.`,
405
+ maxLength: SUMMARY_SAVE_CONTENT_MAX_LENGTH,
406
+ }),
402
407
  }),
403
408
  execute: summarySaveExecute,
404
409
  renderCall(args: any, theme: any) {
@@ -72,6 +72,7 @@ export function registerExecTools(pi: ExtensionAPI): void {
72
72
  return executeUatExec(params as Parameters<typeof executeUatExec>[0], {
73
73
  baseDir,
74
74
  preferences: await loadContextModePreferences(baseDir),
75
+ signal: _signal,
75
76
  });
76
77
  },
77
78
  });
@@ -119,6 +120,7 @@ export function registerExecTools(pi: ExtensionAPI): void {
119
120
  return executeGsdExec(params as Parameters<typeof executeGsdExec>[0], {
120
121
  baseDir,
121
122
  preferences: await loadContextModePreferences(baseDir),
123
+ signal: _signal,
122
124
  });
123
125
  },
124
126
  });
@@ -13,7 +13,7 @@ import type { GSDEcosystemBeforeAgentStartHandler } from "../ecosystem/gsd-exten
13
13
  import { updateSnapshot } from "../ecosystem/gsd-extension-api.js";
14
14
 
15
15
  import { buildMilestoneFileName, canonicalPhaseDirName, clearPathCache, milestonesDir, legacyMilestonesDir, resolveMilestonePath, resolveSliceFile, resolveSlicePath } from "../paths.js";
16
- import { applyAskUserQuestionsGateResult, clearDiscussionFlowState, formatPendingAskUserQuestionsGateMessage, formatTimedOutAskUserQuestionsGateMessage, hostWriteGateAdapter, isApprovalGateVerifiedInSnapshot, isDepthConfirmationAnswer, isMilestoneDepthVerified, isMilestoneDepthVerifiedInSnapshot, isQueuePhaseActive, resetWriteGateState, shouldBlockContextWrite, shouldBlockPlanningUnit, shouldBlockQueueExecution, shouldBlockWorktreeBash, shouldBlockWorktreeWrite, isGateQuestionId, getPendingGate, shouldBlockPendingGate, shouldBlockPendingGateBash, extractDepthVerificationMilestoneId } from "./write-gate.js";
16
+ import { applyAskUserQuestionsGateResult, clearDiscussionFlowState, currentWriteGateSnapshot, formatPendingAskUserQuestionsGateMessage, formatTimedOutAskUserQuestionsGateMessage, hostWriteGateAdapter, isApprovalGateVerifiedInSnapshot, isDepthConfirmationAnswer, isMilestoneDepthVerifiedInSnapshot, isQueuePhaseActive, resetWriteGateState, shouldBlockContextWrite, shouldBlockPlanningUnit, shouldBlockQueueExecution, shouldBlockWorktreeBash, shouldBlockWorktreeWrite, isGateQuestionId, getPendingGate, shouldBlockPendingGate, shouldBlockPendingGateBash, extractDepthVerificationMilestoneId, type WriteGateSnapshot } from "./write-gate.js";
17
17
  import { canonicalToolName } from "../engine-hook-contract.js";
18
18
  import { resolveManifest } from "../unit-context-manifest.js";
19
19
  import { isBlockedStateFile, isBashWriteToStateFile, BLOCKED_WRITE_ERROR } from "../write-intercept.js";
@@ -583,6 +583,10 @@ function deferApprovalGate(gateId: string, basePath: string): void {
583
583
  // workflow MCP child already verified this gate, deferring would block
584
584
  // tools for a gate that can never legitimately arm.
585
585
  const snapshot = hostWriteGateAdapter.readState(basePath);
586
+ deferApprovalGateFromSnapshot(gateId, basePath, snapshot);
587
+ }
588
+
589
+ function deferApprovalGateFromSnapshot(gateId: string, basePath: string, snapshot: WriteGateSnapshot): void {
586
590
  if (isApprovalGateVerifiedInSnapshot(snapshot, gateId)) return;
587
591
  const milestoneId = extractDepthVerificationMilestoneId(gateId);
588
592
  if (milestoneId && isMilestoneDepthVerifiedInSnapshot(snapshot, milestoneId)) return;
@@ -1233,13 +1237,15 @@ export function registerHooks(
1233
1237
 
1234
1238
  const gateId = approvalGateIdForUnit(unitType, unitId);
1235
1239
  if (gateId) {
1240
+ const basePath = contextBasePath(ctx);
1241
+ const gateSnapshot = currentWriteGateSnapshot(basePath);
1236
1242
  // Skip the gate if this milestone is already depth-verified — the approval
1237
1243
  // pattern matched again on post-verification text (a false-positive re-trigger).
1238
1244
  // Without this guard, the second firing blocks gsd_plan_milestone in the same
1239
1245
  // turn and leaves CONTEXT.md on disk with no DB row (#discuss-milestone-no-db).
1240
1246
  const gateMilestoneId = extractDepthVerificationMilestoneId(gateId);
1241
- if (gateMilestoneId && isMilestoneDepthVerified(gateMilestoneId, contextBasePath(ctx))) return;
1242
- deferApprovalGate(gateId, contextBasePath(ctx));
1247
+ if (gateMilestoneId && isMilestoneDepthVerifiedInSnapshot(gateSnapshot, gateMilestoneId)) return;
1248
+ deferApprovalGateFromSnapshot(gateId, basePath, gateSnapshot);
1243
1249
  }
1244
1250
 
1245
1251
  approvalQuestionAbortInFlight = true;
@@ -28,9 +28,10 @@ const DEFAULT_CODEBASE_MAX_CHARS = 8_000;
28
28
  const MIN_CONTEXT_MESSAGE_MAX_CHARS = 1_000;
29
29
  const MIN_KNOWLEDGE_MAX_CHARS = 1_000;
30
30
 
31
- const contextMaintenanceCompletedForBasePath = new Set<string>();
32
- const contextMaintenanceInFlightByBasePath = new Map<string, Promise<boolean>>();
33
- const deferredContextMaintenanceByBasePath = new Map<string, Promise<void>>();
31
+ const CONTEXT_MAINTENANCE_KEY_SEPARATOR = "\0";
32
+ const contextMaintenanceCompletedForSession = new Set<string>();
33
+ const contextMaintenanceInFlightBySession = new Map<string, Promise<boolean>>();
34
+ const deferredContextMaintenanceBySession = new Map<string, Promise<void>>();
34
35
 
35
36
  /**
36
37
  * Bundled skill triggers — resolved dynamically at runtime instead of
@@ -114,7 +115,8 @@ async function runSessionStartupMaintenanceOnce(
114
115
  basePath: string,
115
116
  ctx: ExtensionContext,
116
117
  ): Promise<boolean> {
117
- if (contextMaintenanceCompletedForBasePath.has(basePath)) {
118
+ const maintenanceKey = getContextMaintenanceKey(basePath, ctx);
119
+ if (contextMaintenanceCompletedForSession.has(maintenanceKey)) {
118
120
  // Backfills are session-once, but memory queries and other DB-backed
119
121
  // prompt assembly still need an active adapter on every turn.
120
122
  try {
@@ -126,16 +128,16 @@ async function runSessionStartupMaintenanceOnce(
126
128
  return false;
127
129
  }
128
130
 
129
- const existing = contextMaintenanceInFlightByBasePath.get(basePath);
131
+ const existing = contextMaintenanceInFlightBySession.get(maintenanceKey);
130
132
  const isInitiator = !existing;
131
133
  // Use a definite Promise<boolean> so `await inFlight` has a known return type.
132
134
  let inFlight: Promise<boolean>;
133
135
  if (isInitiator) {
134
- inFlight = performSessionStartupMaintenance(basePath, ctx);
135
- contextMaintenanceInFlightByBasePath.set(basePath, inFlight);
136
+ inFlight = performSessionStartupMaintenance(basePath, ctx, maintenanceKey);
137
+ contextMaintenanceInFlightBySession.set(maintenanceKey, inFlight);
136
138
  void inFlight.finally(() => {
137
- if (contextMaintenanceInFlightByBasePath.get(basePath) === inFlight) {
138
- contextMaintenanceInFlightByBasePath.delete(basePath);
139
+ if (contextMaintenanceInFlightBySession.get(maintenanceKey) === inFlight) {
140
+ contextMaintenanceInFlightBySession.delete(maintenanceKey);
139
141
  }
140
142
  });
141
143
  } else {
@@ -149,6 +151,7 @@ async function runSessionStartupMaintenanceOnce(
149
151
  async function performSessionStartupMaintenance(
150
152
  basePath: string,
151
153
  ctx: ExtensionContext,
154
+ maintenanceKey: string,
152
155
  ): Promise<boolean> {
153
156
  // DB-backed memory backfills run below. On a cold session the database file
154
157
  // may exist without an active in-process adapter, so open the canonical
@@ -172,11 +175,40 @@ async function performSessionStartupMaintenance(
172
175
 
173
176
  // Mark session complete before scheduling deferred work so any concurrent
174
177
  // caller that observes the completed state does not re-enter maintenance.
175
- contextMaintenanceCompletedForBasePath.add(basePath);
176
- scheduleDeferredContextMaintenance(basePath);
178
+ contextMaintenanceCompletedForSession.add(maintenanceKey);
179
+ scheduleDeferredContextMaintenance(basePath, maintenanceKey);
177
180
  return true;
178
181
  }
179
182
 
183
+ function getContextMaintenanceKey(basePath: string, ctx: ExtensionContext): string {
184
+ return `${basePath}${CONTEXT_MAINTENANCE_KEY_SEPARATOR}${getContextSessionPart(ctx)}`;
185
+ }
186
+
187
+ function getContextSessionPart(ctx: ExtensionContext): string {
188
+ const sessionManager = (ctx as {
189
+ sessionManager?: {
190
+ getSessionId?: () => string | undefined;
191
+ getSessionFile?: () => string | undefined;
192
+ };
193
+ }).sessionManager;
194
+
195
+ try {
196
+ const sessionId = sessionManager?.getSessionId?.();
197
+ if (typeof sessionId === "string" && sessionId.length > 0) return `id:${sessionId}`;
198
+ } catch (e) {
199
+ logWarning("bootstrap", `session-id fetch failed: ${(e as Error).message}`);
200
+ }
201
+
202
+ try {
203
+ const sessionFile = sessionManager?.getSessionFile?.();
204
+ if (typeof sessionFile === "string" && sessionFile.length > 0) return `file:${sessionFile}`;
205
+ } catch (e) {
206
+ logWarning("bootstrap", `session-file fetch failed: ${(e as Error).message}`);
207
+ }
208
+
209
+ return "process";
210
+ }
211
+
180
212
  async function runDecisionsMemoryBackfill(ctx: ExtensionContext): Promise<void> {
181
213
  // ADR-013 step 5: opportunistic decisions->memories backfill. Idempotent
182
214
  // and best-effort — first run absorbs the existing decisions table into
@@ -210,8 +242,8 @@ async function runKnowledgeMemoryBackfill(
210
242
  }
211
243
  }
212
244
 
213
- function scheduleDeferredContextMaintenance(basePath: string): void {
214
- if (deferredContextMaintenanceByBasePath.has(basePath)) return;
245
+ function scheduleDeferredContextMaintenance(basePath: string, maintenanceKey: string): void {
246
+ if (deferredContextMaintenanceBySession.has(maintenanceKey)) return;
215
247
 
216
248
  const task = new Promise<void>((resolve) => {
217
249
  setTimeout(() => {
@@ -219,10 +251,10 @@ function scheduleDeferredContextMaintenance(basePath: string): void {
219
251
  }, 0);
220
252
  });
221
253
 
222
- deferredContextMaintenanceByBasePath.set(basePath, task);
254
+ deferredContextMaintenanceBySession.set(maintenanceKey, task);
223
255
  void task.finally(() => {
224
- if (deferredContextMaintenanceByBasePath.get(basePath) === task) {
225
- deferredContextMaintenanceByBasePath.delete(basePath);
256
+ if (deferredContextMaintenanceBySession.get(maintenanceKey) === task) {
257
+ deferredContextMaintenanceBySession.delete(maintenanceKey);
226
258
  }
227
259
  });
228
260
  }
@@ -257,8 +289,10 @@ async function reportConsolidationGapsDeferred(basePath: string): Promise<void>
257
289
 
258
290
  export async function _flushDeferredContextMaintenanceForTest(basePath?: string): Promise<void> {
259
291
  const tasks = basePath
260
- ? [deferredContextMaintenanceByBasePath.get(basePath)].filter((task): task is Promise<void> => Boolean(task))
261
- : [...deferredContextMaintenanceByBasePath.values()];
292
+ ? [...deferredContextMaintenanceBySession.entries()]
293
+ .filter(([key]) => key.startsWith(`${basePath}${CONTEXT_MAINTENANCE_KEY_SEPARATOR}`))
294
+ .map(([, task]) => task)
295
+ : [...deferredContextMaintenanceBySession.values()];
262
296
  await Promise.allSettled(tasks);
263
297
  }
264
298