@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
@@ -60,6 +60,7 @@ export function registerExecTools(pi) {
60
60
  return executeUatExec(params, {
61
61
  baseDir,
62
62
  preferences: await loadContextModePreferences(baseDir),
63
+ signal: _signal,
63
64
  });
64
65
  },
65
66
  });
@@ -99,6 +100,7 @@ export function registerExecTools(pi) {
99
100
  return executeGsdExec(params, {
100
101
  baseDir,
101
102
  preferences: await loadContextModePreferences(baseDir),
103
+ signal: _signal,
102
104
  });
103
105
  },
104
106
  });
@@ -7,7 +7,7 @@ import { isToolCallEventType } from "@gsd/pi-coding-agent";
7
7
  import { ALWAYS_PRESERVED_SHIM_TOOL_NAMES } from "@gsd/pi-ai";
8
8
  import { updateSnapshot } from "../ecosystem/gsd-extension-api.js";
9
9
  import { buildMilestoneFileName, canonicalPhaseDirName, clearPathCache, milestonesDir, legacyMilestonesDir, resolveMilestonePath, resolveSliceFile, resolveSlicePath } from "../paths.js";
10
- 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";
10
+ import { applyAskUserQuestionsGateResult, clearDiscussionFlowState, currentWriteGateSnapshot, formatPendingAskUserQuestionsGateMessage, formatTimedOutAskUserQuestionsGateMessage, hostWriteGateAdapter, isApprovalGateVerifiedInSnapshot, isDepthConfirmationAnswer, isMilestoneDepthVerifiedInSnapshot, isQueuePhaseActive, resetWriteGateState, shouldBlockContextWrite, shouldBlockPlanningUnit, shouldBlockQueueExecution, shouldBlockWorktreeBash, shouldBlockWorktreeWrite, isGateQuestionId, getPendingGate, shouldBlockPendingGate, shouldBlockPendingGateBash, extractDepthVerificationMilestoneId } from "./write-gate.js";
11
11
  import { canonicalToolName } from "../engine-hook-contract.js";
12
12
  import { resolveManifest } from "../unit-context-manifest.js";
13
13
  import { isBlockedStateFile, isBashWriteToStateFile, BLOCKED_WRITE_ERROR } from "../write-intercept.js";
@@ -436,6 +436,9 @@ function deferApprovalGate(gateId, basePath) {
436
436
  // workflow MCP child already verified this gate, deferring would block
437
437
  // tools for a gate that can never legitimately arm.
438
438
  const snapshot = hostWriteGateAdapter.readState(basePath);
439
+ deferApprovalGateFromSnapshot(gateId, basePath, snapshot);
440
+ }
441
+ function deferApprovalGateFromSnapshot(gateId, basePath, snapshot) {
439
442
  if (isApprovalGateVerifiedInSnapshot(snapshot, gateId))
440
443
  return;
441
444
  const milestoneId = extractDepthVerificationMilestoneId(gateId);
@@ -1009,14 +1012,16 @@ export function registerHooks(pi, ecosystemHandlers) {
1009
1012
  return;
1010
1013
  const gateId = approvalGateIdForUnit(unitType, unitId);
1011
1014
  if (gateId) {
1015
+ const basePath = contextBasePath(ctx);
1016
+ const gateSnapshot = currentWriteGateSnapshot(basePath);
1012
1017
  // Skip the gate if this milestone is already depth-verified — the approval
1013
1018
  // pattern matched again on post-verification text (a false-positive re-trigger).
1014
1019
  // Without this guard, the second firing blocks gsd_plan_milestone in the same
1015
1020
  // turn and leaves CONTEXT.md on disk with no DB row (#discuss-milestone-no-db).
1016
1021
  const gateMilestoneId = extractDepthVerificationMilestoneId(gateId);
1017
- if (gateMilestoneId && isMilestoneDepthVerified(gateMilestoneId, contextBasePath(ctx)))
1022
+ if (gateMilestoneId && isMilestoneDepthVerifiedInSnapshot(gateSnapshot, gateMilestoneId))
1018
1023
  return;
1019
- deferApprovalGate(gateId, contextBasePath(ctx));
1024
+ deferApprovalGateFromSnapshot(gateId, basePath, gateSnapshot);
1020
1025
  }
1021
1026
  approvalQuestionAbortInFlight = true;
1022
1027
  ctx.ui.notify(`${unitType ?? "The discussion"}${unitId ? ` ${unitId}` : ""} is waiting for your approval - pausing before more tool calls run.`, "info");
@@ -23,9 +23,10 @@ const DEFAULT_KNOWLEDGE_MAX_CHARS = 12_000;
23
23
  const DEFAULT_CODEBASE_MAX_CHARS = 8_000;
24
24
  const MIN_CONTEXT_MESSAGE_MAX_CHARS = 1_000;
25
25
  const MIN_KNOWLEDGE_MAX_CHARS = 1_000;
26
- const contextMaintenanceCompletedForBasePath = new Set();
27
- const contextMaintenanceInFlightByBasePath = new Map();
28
- const deferredContextMaintenanceByBasePath = new Map();
26
+ const CONTEXT_MAINTENANCE_KEY_SEPARATOR = "\0";
27
+ const contextMaintenanceCompletedForSession = new Set();
28
+ const contextMaintenanceInFlightBySession = new Map();
29
+ const deferredContextMaintenanceBySession = new Map();
29
30
  /**
30
31
  * Bundled skill triggers — resolved dynamically at runtime instead of
31
32
  * hardcoding absolute paths in the system prompt template. Only skills
@@ -100,7 +101,8 @@ function warnDeprecatedAgentInstructions() {
100
101
  }
101
102
  }
102
103
  async function runSessionStartupMaintenanceOnce(basePath, ctx) {
103
- if (contextMaintenanceCompletedForBasePath.has(basePath)) {
104
+ const maintenanceKey = getContextMaintenanceKey(basePath, ctx);
105
+ if (contextMaintenanceCompletedForSession.has(maintenanceKey)) {
104
106
  // Backfills are session-once, but memory queries and other DB-backed
105
107
  // prompt assembly still need an active adapter on every turn.
106
108
  try {
@@ -112,16 +114,16 @@ async function runSessionStartupMaintenanceOnce(basePath, ctx) {
112
114
  }
113
115
  return false;
114
116
  }
115
- const existing = contextMaintenanceInFlightByBasePath.get(basePath);
117
+ const existing = contextMaintenanceInFlightBySession.get(maintenanceKey);
116
118
  const isInitiator = !existing;
117
119
  // Use a definite Promise<boolean> so `await inFlight` has a known return type.
118
120
  let inFlight;
119
121
  if (isInitiator) {
120
- inFlight = performSessionStartupMaintenance(basePath, ctx);
121
- contextMaintenanceInFlightByBasePath.set(basePath, inFlight);
122
+ inFlight = performSessionStartupMaintenance(basePath, ctx, maintenanceKey);
123
+ contextMaintenanceInFlightBySession.set(maintenanceKey, inFlight);
122
124
  void inFlight.finally(() => {
123
- if (contextMaintenanceInFlightByBasePath.get(basePath) === inFlight) {
124
- contextMaintenanceInFlightByBasePath.delete(basePath);
125
+ if (contextMaintenanceInFlightBySession.get(maintenanceKey) === inFlight) {
126
+ contextMaintenanceInFlightBySession.delete(maintenanceKey);
125
127
  }
126
128
  });
127
129
  }
@@ -131,7 +133,7 @@ async function runSessionStartupMaintenanceOnce(basePath, ctx) {
131
133
  const result = await inFlight;
132
134
  return isInitiator ? result : false;
133
135
  }
134
- async function performSessionStartupMaintenance(basePath, ctx) {
136
+ async function performSessionStartupMaintenance(basePath, ctx, maintenanceKey) {
135
137
  // DB-backed memory backfills run below. On a cold session the database file
136
138
  // may exist without an active in-process adapter, so open the canonical
137
139
  // project DB before those best-effort operations inspect it.
@@ -154,10 +156,33 @@ async function performSessionStartupMaintenance(basePath, ctx) {
154
156
  ]);
155
157
  // Mark session complete before scheduling deferred work so any concurrent
156
158
  // caller that observes the completed state does not re-enter maintenance.
157
- contextMaintenanceCompletedForBasePath.add(basePath);
158
- scheduleDeferredContextMaintenance(basePath);
159
+ contextMaintenanceCompletedForSession.add(maintenanceKey);
160
+ scheduleDeferredContextMaintenance(basePath, maintenanceKey);
159
161
  return true;
160
162
  }
163
+ function getContextMaintenanceKey(basePath, ctx) {
164
+ return `${basePath}${CONTEXT_MAINTENANCE_KEY_SEPARATOR}${getContextSessionPart(ctx)}`;
165
+ }
166
+ function getContextSessionPart(ctx) {
167
+ const sessionManager = ctx.sessionManager;
168
+ try {
169
+ const sessionId = sessionManager?.getSessionId?.();
170
+ if (typeof sessionId === "string" && sessionId.length > 0)
171
+ return `id:${sessionId}`;
172
+ }
173
+ catch (e) {
174
+ logWarning("bootstrap", `session-id fetch failed: ${e.message}`);
175
+ }
176
+ try {
177
+ const sessionFile = sessionManager?.getSessionFile?.();
178
+ if (typeof sessionFile === "string" && sessionFile.length > 0)
179
+ return `file:${sessionFile}`;
180
+ }
181
+ catch (e) {
182
+ logWarning("bootstrap", `session-file fetch failed: ${e.message}`);
183
+ }
184
+ return "process";
185
+ }
161
186
  async function runDecisionsMemoryBackfill(ctx) {
162
187
  // ADR-013 step 5: opportunistic decisions->memories backfill. Idempotent
163
188
  // and best-effort — first run absorbs the existing decisions table into
@@ -188,18 +213,18 @@ async function runKnowledgeMemoryBackfill(basePath, ctx) {
188
213
  logWarning("bootstrap", `KNOWLEDGE.md backfill failed: ${e.message}`);
189
214
  }
190
215
  }
191
- function scheduleDeferredContextMaintenance(basePath) {
192
- if (deferredContextMaintenanceByBasePath.has(basePath))
216
+ function scheduleDeferredContextMaintenance(basePath, maintenanceKey) {
217
+ if (deferredContextMaintenanceBySession.has(maintenanceKey))
193
218
  return;
194
219
  const task = new Promise((resolve) => {
195
220
  setTimeout(() => {
196
221
  void runDeferredContextMaintenance(basePath).finally(resolve);
197
222
  }, 0);
198
223
  });
199
- deferredContextMaintenanceByBasePath.set(basePath, task);
224
+ deferredContextMaintenanceBySession.set(maintenanceKey, task);
200
225
  void task.finally(() => {
201
- if (deferredContextMaintenanceByBasePath.get(basePath) === task) {
202
- deferredContextMaintenanceByBasePath.delete(basePath);
226
+ if (deferredContextMaintenanceBySession.get(maintenanceKey) === task) {
227
+ deferredContextMaintenanceBySession.delete(maintenanceKey);
203
228
  }
204
229
  });
205
230
  }
@@ -232,8 +257,10 @@ async function reportConsolidationGapsDeferred(basePath) {
232
257
  }
233
258
  export async function _flushDeferredContextMaintenanceForTest(basePath) {
234
259
  const tasks = basePath
235
- ? [deferredContextMaintenanceByBasePath.get(basePath)].filter((task) => Boolean(task))
236
- : [...deferredContextMaintenanceByBasePath.values()];
260
+ ? [...deferredContextMaintenanceBySession.entries()]
261
+ .filter(([key]) => key.startsWith(`${basePath}${CONTEXT_MAINTENANCE_KEY_SEPARATOR}`))
262
+ .map(([, task]) => task)
263
+ : [...deferredContextMaintenanceBySession.values()];
237
264
  await Promise.allSettled(tasks);
238
265
  }
239
266
  export async function buildBeforeAgentStartResult(event, ctx) {
@@ -10,16 +10,57 @@
10
10
  * and blocks when the same signature appears more than MAX_CONSECUTIVE
11
11
  * times in a row. Resets on each agent turn (session_start, agent_end)
12
12
  * and when a different tool call breaks the streak.
13
+ *
14
+ * A second, independent check (#783 Brief C) tracks per-tool-name call
15
+ * counts within a turn regardless of args. This catches improvisation
16
+ * loops where the model attempts the same missing workflow tool through
17
+ * varied surfaces (bash → `node -e` → CLI), each with a different
18
+ * signature, so the identical-args streak never trips. Whichever guard
19
+ * trips first blocks.
13
20
  */
14
21
  import { createHash } from "node:crypto";
15
22
  const MAX_CONSECUTIVE_IDENTICAL_CALLS = 4;
16
23
  /** Interactive/user-facing tools where even 1 duplicate is confusing. */
17
24
  const STRICT_LOOP_TOOLS = new Set(["ask_user_questions"]);
18
25
  const MAX_CONSECUTIVE_STRICT = 1;
26
+ /**
27
+ * Per-turn cap on calls to the SAME tool name, regardless of args (#783).
28
+ *
29
+ * General-purpose execution tools are routinely called many times per turn
30
+ * (touching multiple files, running several commands), so they get a higher
31
+ * ceiling. Everything else — workflow one-shot tools (e.g. gsd_complete_milestone)
32
+ * and any non-allowlisted tool — gets the default cap. The default is generous
33
+ * enough to absorb legitimate retries but catches the reported improvisation
34
+ * loop (~51 calls) well before a cost spike.
35
+ */
36
+ const PER_TOOL_DEFAULT_CAP = 6;
37
+ const PER_TOOL_REPEATABLE_CAP = 15;
38
+ /**
39
+ * Inherently-repeatable tools: called many times per turn in normal work
40
+ * (reading/writing several files, running several commands, searching). These
41
+ * get PER_TOOL_REPEATABLE_CAP rather than the default. Keep this list
42
+ * conservative — a tool here can be invoked up to PER_TOOL_REPEATABLE_CAP times
43
+ * per turn before the guard blocks.
44
+ */
45
+ const REPEATABLE_TOOLS = new Set([
46
+ "read",
47
+ "write",
48
+ "edit",
49
+ "multi_edit",
50
+ "bash",
51
+ "grep",
52
+ "glob",
53
+ "search-the-web",
54
+ "fetch_page",
55
+ "todo_write",
56
+ "notebook_edit",
57
+ ]);
19
58
  let consecutiveCount = 0;
20
59
  let lastSignature = "";
21
60
  let lastToolName = "";
22
61
  let enabled = true;
62
+ /** Per-tool-name call counts within the current turn (#783 Brief C). */
63
+ const perToolCounts = new Map();
23
64
  /** Hash tool name + args into a compact signature for comparison. */
24
65
  function hashToolCall(toolName, args) {
25
66
  const h = createHash("sha256");
@@ -38,6 +79,12 @@ function hashToolCall(toolName, args) {
38
79
  *
39
80
  * Returns `{ block: false }` for allowed calls.
40
81
  * Returns `{ block: true, reason }` when the loop threshold is exceeded.
82
+ *
83
+ * Two independent guards run; whichever trips first blocks:
84
+ * 1. Identical-signature streak (MAX_CONSECUTIVE_IDENTICAL_CALLS, strict for
85
+ * ask_user_questions).
86
+ * 2. Per-tool-name cap (PER_TOOL_DEFAULT_CAP / PER_TOOL_REPEATABLE_CAP),
87
+ * independent of args — catches improvisation loops (#783).
41
88
  */
42
89
  export function checkToolCallLoop(toolName, args) {
43
90
  if (!enabled)
@@ -51,18 +98,36 @@ export function checkToolCallLoop(toolName, args) {
51
98
  lastSignature = sig;
52
99
  lastToolName = toolName;
53
100
  }
101
+ // ── Guard 1: identical-signature streak ──
54
102
  const threshold = STRICT_LOOP_TOOLS.has(toolName)
55
103
  ? MAX_CONSECUTIVE_STRICT
56
104
  : MAX_CONSECUTIVE_IDENTICAL_CALLS;
57
105
  if (consecutiveCount > threshold) {
58
106
  return {
59
107
  block: true,
60
- reason: `Tool loop detected: ${toolName} called ${consecutiveCount} times ` +
108
+ reason: `Tool loop detected (identical args): ${toolName} called ${consecutiveCount} times ` +
61
109
  `with identical arguments. Blocking to prevent infinite loop. ` +
62
110
  `Try a different approach or modify your arguments.`,
63
111
  count: consecutiveCount,
64
112
  };
65
113
  }
114
+ // ── Guard 2: per-tool-name cap, independent of args (#783 Brief C) ──
115
+ // Catches improvisation loops where the same tool is invoked many times with
116
+ // varied args (e.g. retrying a missing workflow tool via bash/node -e/CLI).
117
+ const perToolCount = (perToolCounts.get(toolName) ?? 0) + 1;
118
+ perToolCounts.set(toolName, perToolCount);
119
+ const perToolCap = REPEATABLE_TOOLS.has(toolName)
120
+ ? PER_TOOL_REPEATABLE_CAP
121
+ : PER_TOOL_DEFAULT_CAP;
122
+ if (perToolCount > perToolCap) {
123
+ return {
124
+ block: true,
125
+ reason: `Tool loop detected (repeated tool): ${toolName} called ${perToolCount} times ` +
126
+ `this turn (cap ${perToolCap}). Blocking to prevent infinite loop. ` +
127
+ `The tool may be unavailable or failing repeatedly — try a different approach.`,
128
+ count: perToolCount,
129
+ };
130
+ }
66
131
  return { block: false, count: consecutiveCount };
67
132
  }
68
133
  /** Reset the guard state. Call at agent turn boundaries. */
@@ -71,6 +136,7 @@ export function resetToolCallLoopGuard() {
71
136
  lastSignature = "";
72
137
  lastToolName = "";
73
138
  enabled = true;
139
+ perToolCounts.clear();
74
140
  }
75
141
  /** Disable the guard (e.g. during shutdown). */
76
142
  export function disableToolCallLoopGuard() {
@@ -78,8 +144,16 @@ export function disableToolCallLoopGuard() {
78
144
  consecutiveCount = 0;
79
145
  lastSignature = "";
80
146
  lastToolName = "";
147
+ perToolCounts.clear();
81
148
  }
82
149
  /** Get current consecutive count for diagnostics. */
83
150
  export function getToolCallLoopCount() {
84
151
  return consecutiveCount;
85
152
  }
153
+ /**
154
+ * Get the per-tool-name call count for the current turn (#783 Brief C).
155
+ * Returns 0 for tools not yet called. Diagnostic only.
156
+ */
157
+ export function getToolCallCountForTool(toolName) {
158
+ return perToolCounts.get(toolName) ?? 0;
159
+ }
@@ -141,7 +141,7 @@ function ensureWriteGateSnapshotDirectory(basePath) {
141
141
  }
142
142
  mkdirSync(join(gsdPath, "runtime"), { recursive: true });
143
143
  }
144
- function currentWriteGateSnapshot(basePath = process.cwd()) {
144
+ export function currentWriteGateSnapshot(basePath = process.cwd()) {
145
145
  const state = getWriteGateState(basePath);
146
146
  return {
147
147
  verifiedDepthMilestones: [...state.verifiedDepthMilestones].sort(),
@@ -8,6 +8,8 @@ import { formatPercent, formatTokenCount } from "./metrics.js";
8
8
  import { countTokensSync } from "./token-counter.js";
9
9
  import { writeContextChartHtml } from "./context-chart-html.js";
10
10
  import { openInBrowser } from "./export.js";
11
+ import { truncateWithEllipsis } from "../shared/format-utils.js";
12
+ const REDACTED_TOOL_ARGUMENT_KEYS = new Set(["content", "oldText", "newText"]);
11
13
  function resolveProvider(provider) {
12
14
  const normalized = (provider ?? "unknown").toLowerCase();
13
15
  if (normalized === "anthropic" || normalized === "claude-code")
@@ -167,6 +169,22 @@ export function parseSystemPromptSections(systemPrompt, provider) {
167
169
  }
168
170
  return sections;
169
171
  }
172
+ function redactToolCallArguments(value) {
173
+ if (Array.isArray(value))
174
+ return value.map(redactToolCallArguments);
175
+ if (!value || typeof value !== "object")
176
+ return value;
177
+ const safe = {};
178
+ for (const [key, child] of Object.entries(value)) {
179
+ if (REDACTED_TOOL_ARGUMENT_KEYS.has(key)) {
180
+ safe[key] = typeof child === "string" ? truncateWithEllipsis(child, 101) : "[redacted]";
181
+ }
182
+ else {
183
+ safe[key] = redactToolCallArguments(child);
184
+ }
185
+ }
186
+ return safe;
187
+ }
170
188
  function messageToText(message) {
171
189
  const role = message.role;
172
190
  if (role === "assistant") {
@@ -183,7 +201,7 @@ function messageToText(message) {
183
201
  parts.push(typed.thinking);
184
202
  if (typed.type === "toolCall") {
185
203
  parts.push(typed.name ?? "tool");
186
- parts.push(JSON.stringify(typed.arguments ?? {}));
204
+ parts.push(JSON.stringify(redactToolCallArguments(typed.arguments ?? {})));
187
205
  }
188
206
  }
189
207
  }
@@ -1671,19 +1671,25 @@ export function serializePreferencesToFrontmatter(prefs) {
1671
1671
  const entries = Object.entries(item);
1672
1672
  if (entries.length > 0) {
1673
1673
  const [firstKey, firstVal] = entries[0];
1674
- lines.push(`${prefix} - ${firstKey}: ${yamlSafeString(firstVal)}`);
1675
- for (let i = 1; i < entries.length; i++) {
1676
- const [k, v] = entries[i];
1677
- if (Array.isArray(v)) {
1678
- lines.push(`${prefix} ${k}:`);
1679
- for (const arrItem of v) {
1680
- lines.push(`${prefix} - ${yamlSafeString(arrItem)}`);
1681
- }
1674
+ if (Array.isArray(firstVal)) {
1675
+ lines.push(`${prefix} - ${firstKey}:`);
1676
+ for (const arrItem of firstVal) {
1677
+ lines.push(`${prefix} - ${yamlSafeString(arrItem)}`);
1682
1678
  }
1683
- else {
1684
- lines.push(`${prefix} ${k}: ${yamlSafeString(v)}`);
1679
+ }
1680
+ else if (typeof firstVal === "object" && firstVal !== null) {
1681
+ lines.push(`${prefix} - ${firstKey}:`);
1682
+ for (const [k, v] of Object.entries(firstVal)) {
1683
+ serializeValue(k, v, indent + 3);
1685
1684
  }
1686
1685
  }
1686
+ else {
1687
+ lines.push(`${prefix} - ${firstKey}: ${yamlSafeString(firstVal)}`);
1688
+ }
1689
+ for (let i = 1; i < entries.length; i++) {
1690
+ const [k, v] = entries[i];
1691
+ serializeValue(k, v, indent + 2);
1692
+ }
1687
1693
  }
1688
1694
  }
1689
1695
  else {
@@ -12,9 +12,9 @@ import { inferCommitType } from "./git-service.js";
12
12
  import { autoCommitCurrentBranch } from "./worktree.js";
13
13
  import { GSDError, GSD_GIT_ERROR } from "./errors.js";
14
14
  // ─── Status helper ─────────────────────────────────────────────────────────
15
- function getStatus(basePath, name, wtPath) {
16
- const diff = diffWorktreeAll(basePath, name);
17
- const numstat = diffWorktreeNumstat(basePath, name);
15
+ function getStatus(basePath, name, wtPath, mainBranch) {
16
+ const diff = diffWorktreeAll(basePath, name, undefined, mainBranch);
17
+ const numstat = diffWorktreeNumstat(basePath, name, undefined, mainBranch);
18
18
  const filesChanged = diff.added.length + diff.modified.length + diff.removed.length;
19
19
  let linesAdded = 0;
20
20
  let linesRemoved = 0;
@@ -31,8 +31,7 @@ function getStatus(basePath, name, wtPath) {
31
31
  }
32
32
  let commits = 0;
33
33
  try {
34
- const main = nativeDetectMainBranch(basePath);
35
- commits = nativeCommitCountBetween(basePath, main, worktreeBranchName(name));
34
+ commits = nativeCommitCountBetween(basePath, mainBranch, worktreeBranchName(name));
36
35
  }
37
36
  catch {
38
37
  // commit count unavailable → leave at 0
@@ -88,7 +87,8 @@ export function formatCleanKeepReason(status) {
88
87
  async function handleList(ctx) {
89
88
  const basePath = projectRoot();
90
89
  const worktrees = listWorktrees(basePath);
91
- const statuses = worktrees.map((wt) => getStatus(basePath, wt.name, wt.path));
90
+ const mainBranch = worktrees.length > 0 ? nativeDetectMainBranch(basePath) : "";
91
+ const statuses = worktrees.map((wt) => getStatus(basePath, wt.name, wt.path, mainBranch));
92
92
  ctx.ui.notify(formatWorktreeList(statuses), "info");
93
93
  }
94
94
  // ─── Subcommand: merge ──────────────────────────────────────────────────────
@@ -117,7 +117,8 @@ async function handleMerge(args, ctx) {
117
117
  ctx.ui.notify(`Worktree "${target}" not found.\n\nAvailable: ${available}`, "error");
118
118
  return;
119
119
  }
120
- const status = getStatus(basePath, target, wt.path);
120
+ const mainBranch = nativeDetectMainBranch(basePath);
121
+ const status = getStatus(basePath, target, wt.path, mainBranch);
121
122
  if (status.filesChanged === 0 && !status.uncommitted) {
122
123
  try {
123
124
  removeWorktree(basePath, target, { deleteBranch: true });
@@ -144,7 +145,6 @@ async function handleMerge(args, ctx) {
144
145
  }
145
146
  }
146
147
  const commitType = inferCommitType(target);
147
- const mainBranch = nativeDetectMainBranch(basePath);
148
148
  const commitMessage = `${commitType}: merge worktree ${target}\n\nGSD-Worktree: ${target}`;
149
149
  try {
150
150
  mergeWorktreeToMain(basePath, target, commitMessage);
@@ -191,8 +191,9 @@ async function handleClean(ctx) {
191
191
  }
192
192
  const removed = [];
193
193
  const kept = [];
194
+ const mainBranch = nativeDetectMainBranch(basePath);
194
195
  for (const wt of worktrees) {
195
- const status = getStatus(basePath, wt.name, wt.path);
196
+ const status = getStatus(basePath, wt.name, wt.path, mainBranch);
196
197
  if (status.filesChanged === 0 && !status.uncommitted) {
197
198
  try {
198
199
  removeWorktree(basePath, wt.name, { deleteBranch: true });
@@ -238,7 +239,8 @@ async function handleRemove(args, ctx) {
238
239
  ctx.ui.notify(`Worktree "${name}" not found.\n\nAvailable: ${available}`, "error");
239
240
  return;
240
241
  }
241
- const status = getStatus(basePath, name, wt.path);
242
+ const mainBranch = nativeDetectMainBranch(basePath);
243
+ const status = getStatus(basePath, name, wt.path, mainBranch);
242
244
  if ((status.filesChanged > 0 || status.uncommitted) && !force) {
243
245
  ctx.ui.notify([
244
246
  `Worktree "${name}" has pending changes (${formatCleanKeepReason(status)}).`,
@@ -21,7 +21,7 @@ import { getWorkerBatches, hasActiveWorkers } from "../subagent/worker-registry.
21
21
  import { formatDuration, padRight, joinColumns, centerLine, fitColumns, STATUS_GLYPH, STATUS_COLOR } from "../shared/mod.js";
22
22
  import { estimateTimeRemaining } from "./auto-dashboard.js";
23
23
  import { computeProgressScore } from "./progress-score.js";
24
- import { runEnvironmentChecks } from "./doctor-environment.js";
24
+ import { runEnvironmentChecksAsync } from "./doctor-environment.js";
25
25
  import { formattedShortcutPair } from "./shortcut-defs.js";
26
26
  import { renderDialogFrame, renderKeyHints } from "./tui/render-kit.js";
27
27
  export function unitLabel(type) {
@@ -62,6 +62,9 @@ export class GSDDashboardOverlay {
62
62
  loading = true;
63
63
  loadedDashboardIdentity;
64
64
  refreshInFlight = null;
65
+ envRefreshInFlight = null;
66
+ cachedEnvBasePath;
67
+ cachedEnvIssues = [];
65
68
  disposed = false;
66
69
  resizeHandler = null;
67
70
  cachedMetrics = null;
@@ -155,11 +158,38 @@ export class GSDDashboardOverlay {
155
158
  if (initial) {
156
159
  this.loading = false;
157
160
  }
161
+ this.scheduleEnvironmentRefresh(this.dashData.basePath || process.cwd());
158
162
  if (identityChanged) {
159
163
  this.invalidate();
160
164
  }
161
165
  this.tui.requestRender();
162
166
  }
167
+ scheduleEnvironmentRefresh(basePath) {
168
+ if (this.cachedEnvBasePath !== basePath) {
169
+ this.cachedEnvBasePath = basePath;
170
+ this.cachedEnvIssues = [];
171
+ this.invalidate();
172
+ }
173
+ if (this.envRefreshInFlight || this.disposed)
174
+ return;
175
+ this.envRefreshInFlight = this.refreshEnvironmentHealth(basePath)
176
+ .finally(() => {
177
+ this.envRefreshInFlight = null;
178
+ });
179
+ }
180
+ async refreshEnvironmentHealth(basePath) {
181
+ try {
182
+ const envResults = await runEnvironmentChecksAsync(basePath);
183
+ if (this.disposed || this.cachedEnvBasePath !== basePath)
184
+ return;
185
+ this.cachedEnvIssues = envResults.filter(r => r.status !== "ok");
186
+ this.invalidate();
187
+ this.tui.requestRender();
188
+ }
189
+ catch {
190
+ // Non-fatal — keep last known environment issues
191
+ }
192
+ }
163
193
  async loadData() {
164
194
  const base = this.dashData.basePath || process.cwd();
165
195
  try {
@@ -540,8 +570,7 @@ export class GSDDashboardOverlay {
540
570
  }
541
571
  }
542
572
  // Environment health section (#1221) — only show issues
543
- const envResults = runEnvironmentChecks(this.dashData.basePath || process.cwd());
544
- const envIssues = envResults.filter(r => r.status !== "ok");
573
+ const envIssues = this.cachedEnvIssues;
545
574
  if (envIssues.length > 0) {
546
575
  lines.push(blank());
547
576
  lines.push(hr());
@@ -12,6 +12,7 @@ import { rowToActiveDecision, rowToActiveRequirement, rowToDecision, rowToRequir
12
12
  import { rowToGate } from "../db-gate-rows.js";
13
13
  import { rowToArtifact, rowToMilestone } from "../db-milestone-artifact-rows.js";
14
14
  import { rowToSlice, rowToTask } from "../db-task-slice-rows.js";
15
+ import { TERMINAL_STATUS_SQL } from "./sql-constants.js";
15
16
  function parseStringArrayColumn(raw) {
16
17
  if (Array.isArray(raw))
17
18
  return raw.filter((entry) => typeof entry === "string");
@@ -35,6 +36,44 @@ function parseStringArrayColumn(raw) {
35
36
  function normalizeRepoPath(file) {
36
37
  return file.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
37
38
  }
39
+ function numberColumn(row, column) {
40
+ const value = row?.[column];
41
+ if (typeof value === "number")
42
+ return value;
43
+ if (typeof value === "bigint")
44
+ return Number(value);
45
+ if (typeof value === "string") {
46
+ const parsed = Number(value);
47
+ return Number.isFinite(parsed) ? parsed : 0;
48
+ }
49
+ return 0;
50
+ }
51
+ function getCompletionCount(table) {
52
+ const row = getDbOrNull().prepare(`SELECT
53
+ COUNT(*) AS total,
54
+ COALESCE(SUM(CASE WHEN status IN (${TERMINAL_STATUS_SQL}) THEN 1 ELSE 0 END), 0) AS completed
55
+ FROM ${table}`).get();
56
+ return {
57
+ completed: numberColumn(row, "completed"),
58
+ total: numberColumn(row, "total"),
59
+ };
60
+ }
61
+ export function getHierarchyCompletionCounts() {
62
+ if (!getDbOrNull()) {
63
+ return { milestones: 0, milestonesTotal: 0, slices: 0, slicesTotal: 0, tasks: 0, tasksTotal: 0 };
64
+ }
65
+ const milestones = getCompletionCount("milestones");
66
+ const slices = getCompletionCount("slices");
67
+ const tasks = getCompletionCount("tasks");
68
+ return {
69
+ milestones: milestones.completed,
70
+ milestonesTotal: milestones.total,
71
+ slices: slices.completed,
72
+ slicesTotal: slices.total,
73
+ tasks: tasks.completed,
74
+ tasksTotal: tasks.total,
75
+ };
76
+ }
38
77
  export function getDecisionById(id) {
39
78
  if (!getDbOrNull())
40
79
  return null;
@@ -391,6 +430,27 @@ export function getAssessment(path) {
391
430
  const row = getDbOrNull().prepare(`SELECT * FROM assessments WHERE path = :path`).get({ ":path": path });
392
431
  return row ?? null;
393
432
  }
433
+ /**
434
+ * Look up a slice's `run-uat` assessment by (milestoneId, sliceId) identity,
435
+ * independent of the artifact `path`. Used as a DB fallback by the UAT
436
+ * closeout gate when a path migration orphans the ASSESSMENT markdown from its
437
+ * canonical expected path (ADR-017: DB-authoritative UAT sign-off).
438
+ *
439
+ * `status` holds the normalized verdict (`pass`/`fail`/…) written by
440
+ * `executeUatResultSave`; `fullContent` carries the ASSESSMENT body so callers
441
+ * can derive `uatType` without re-reading a file that may not exist.
442
+ */
443
+ export function getSliceRunUatAssessment(milestoneId, sliceId) {
444
+ if (!getDbOrNull())
445
+ return null;
446
+ const row = getDbOrNull().prepare(`SELECT status, full_content AS fullContent FROM assessments
447
+ WHERE milestone_id = :mid AND slice_id = :sid AND scope = 'run-uat'
448
+ ORDER BY created_at DESC, ROWID DESC
449
+ LIMIT 1`).get({ ":mid": milestoneId, ":sid": sliceId });
450
+ if (!row)
451
+ return null;
452
+ return { status: String(row["status"] ?? ""), fullContent: String(row["fullContent"] ?? "") };
453
+ }
394
454
  export function getLatestAssessmentByScope(milestoneId, scope) {
395
455
  if (!getDbOrNull())
396
456
  return null;