@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
@@ -10,6 +10,13 @@
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
 
15
22
  import { createHash } from "node:crypto";
@@ -20,11 +27,48 @@ const MAX_CONSECUTIVE_IDENTICAL_CALLS = 4;
20
27
  const STRICT_LOOP_TOOLS = new Set(["ask_user_questions"]);
21
28
  const MAX_CONSECUTIVE_STRICT = 1;
22
29
 
30
+ /**
31
+ * Per-turn cap on calls to the SAME tool name, regardless of args (#783).
32
+ *
33
+ * General-purpose execution tools are routinely called many times per turn
34
+ * (touching multiple files, running several commands), so they get a higher
35
+ * ceiling. Everything else — workflow one-shot tools (e.g. gsd_complete_milestone)
36
+ * and any non-allowlisted tool — gets the default cap. The default is generous
37
+ * enough to absorb legitimate retries but catches the reported improvisation
38
+ * loop (~51 calls) well before a cost spike.
39
+ */
40
+ const PER_TOOL_DEFAULT_CAP = 6;
41
+ const PER_TOOL_REPEATABLE_CAP = 15;
42
+
43
+ /**
44
+ * Inherently-repeatable tools: called many times per turn in normal work
45
+ * (reading/writing several files, running several commands, searching). These
46
+ * get PER_TOOL_REPEATABLE_CAP rather than the default. Keep this list
47
+ * conservative — a tool here can be invoked up to PER_TOOL_REPEATABLE_CAP times
48
+ * per turn before the guard blocks.
49
+ */
50
+ const REPEATABLE_TOOLS = new Set([
51
+ "read",
52
+ "write",
53
+ "edit",
54
+ "multi_edit",
55
+ "bash",
56
+ "grep",
57
+ "glob",
58
+ "search-the-web",
59
+ "fetch_page",
60
+ "todo_write",
61
+ "notebook_edit",
62
+ ]);
63
+
23
64
  let consecutiveCount = 0;
24
65
  let lastSignature = "";
25
66
  let lastToolName = "";
26
67
  let enabled = true;
27
68
 
69
+ /** Per-tool-name call counts within the current turn (#783 Brief C). */
70
+ const perToolCounts = new Map<string, number>();
71
+
28
72
  /** Hash tool name + args into a compact signature for comparison. */
29
73
  function hashToolCall(toolName: string, args: Record<string, unknown>): string {
30
74
  const h = createHash("sha256");
@@ -46,6 +90,12 @@ function hashToolCall(toolName: string, args: Record<string, unknown>): string {
46
90
  *
47
91
  * Returns `{ block: false }` for allowed calls.
48
92
  * Returns `{ block: true, reason }` when the loop threshold is exceeded.
93
+ *
94
+ * Two independent guards run; whichever trips first blocks:
95
+ * 1. Identical-signature streak (MAX_CONSECUTIVE_IDENTICAL_CALLS, strict for
96
+ * ask_user_questions).
97
+ * 2. Per-tool-name cap (PER_TOOL_DEFAULT_CAP / PER_TOOL_REPEATABLE_CAP),
98
+ * independent of args — catches improvisation loops (#783).
49
99
  */
50
100
  export function checkToolCallLoop(
51
101
  toolName: string,
@@ -63,6 +113,7 @@ export function checkToolCallLoop(
63
113
  lastToolName = toolName;
64
114
  }
65
115
 
116
+ // ── Guard 1: identical-signature streak ──
66
117
  const threshold = STRICT_LOOP_TOOLS.has(toolName)
67
118
  ? MAX_CONSECUTIVE_STRICT
68
119
  : MAX_CONSECUTIVE_IDENTICAL_CALLS;
@@ -71,13 +122,33 @@ export function checkToolCallLoop(
71
122
  return {
72
123
  block: true,
73
124
  reason:
74
- `Tool loop detected: ${toolName} called ${consecutiveCount} times ` +
125
+ `Tool loop detected (identical args): ${toolName} called ${consecutiveCount} times ` +
75
126
  `with identical arguments. Blocking to prevent infinite loop. ` +
76
127
  `Try a different approach or modify your arguments.`,
77
128
  count: consecutiveCount,
78
129
  };
79
130
  }
80
131
 
132
+ // ── Guard 2: per-tool-name cap, independent of args (#783 Brief C) ──
133
+ // Catches improvisation loops where the same tool is invoked many times with
134
+ // varied args (e.g. retrying a missing workflow tool via bash/node -e/CLI).
135
+ const perToolCount = (perToolCounts.get(toolName) ?? 0) + 1;
136
+ perToolCounts.set(toolName, perToolCount);
137
+ const perToolCap = REPEATABLE_TOOLS.has(toolName)
138
+ ? PER_TOOL_REPEATABLE_CAP
139
+ : PER_TOOL_DEFAULT_CAP;
140
+
141
+ if (perToolCount > perToolCap) {
142
+ return {
143
+ block: true,
144
+ reason:
145
+ `Tool loop detected (repeated tool): ${toolName} called ${perToolCount} times ` +
146
+ `this turn (cap ${perToolCap}). Blocking to prevent infinite loop. ` +
147
+ `The tool may be unavailable or failing repeatedly — try a different approach.`,
148
+ count: perToolCount,
149
+ };
150
+ }
151
+
81
152
  return { block: false, count: consecutiveCount };
82
153
  }
83
154
 
@@ -87,6 +158,7 @@ export function resetToolCallLoopGuard(): void {
87
158
  lastSignature = "";
88
159
  lastToolName = "";
89
160
  enabled = true;
161
+ perToolCounts.clear();
90
162
  }
91
163
 
92
164
  /** Disable the guard (e.g. during shutdown). */
@@ -95,9 +167,18 @@ export function disableToolCallLoopGuard(): void {
95
167
  consecutiveCount = 0;
96
168
  lastSignature = "";
97
169
  lastToolName = "";
170
+ perToolCounts.clear();
98
171
  }
99
172
 
100
173
  /** Get current consecutive count for diagnostics. */
101
174
  export function getToolCallLoopCount(): number {
102
175
  return consecutiveCount;
103
176
  }
177
+
178
+ /**
179
+ * Get the per-tool-name call count for the current turn (#783 Brief C).
180
+ * Returns 0 for tools not yet called. Diagnostic only.
181
+ */
182
+ export function getToolCallCountForTool(toolName: string): number {
183
+ return perToolCounts.get(toolName) ?? 0;
184
+ }
@@ -180,7 +180,7 @@ function ensureWriteGateSnapshotDirectory(basePath: string): void {
180
180
  mkdirSync(join(gsdPath, "runtime"), { recursive: true });
181
181
  }
182
182
 
183
- function currentWriteGateSnapshot(basePath: string = process.cwd()): WriteGateSnapshot {
183
+ export function currentWriteGateSnapshot(basePath: string = process.cwd()): WriteGateSnapshot {
184
184
  const state = getWriteGateState(basePath);
185
185
  return {
186
186
  verifiedDepthMilestones: [...state.verifiedDepthMilestones].sort(),
@@ -11,6 +11,7 @@ import { formatPercent, formatTokenCount } from "./metrics.js";
11
11
  import { countTokensSync, type TokenProvider } from "./token-counter.js";
12
12
  import { writeContextChartHtml } from "./context-chart-html.js";
13
13
  import { openInBrowser } from "./export.js";
14
+ import { truncateWithEllipsis } from "../shared/format-utils.js";
14
15
 
15
16
  export interface ContextSectionBreakdown {
16
17
  label: string;
@@ -34,6 +35,7 @@ export interface ContextBreakdownReport {
34
35
  subagentSpawns: number;
35
36
  }
36
37
 
38
+ const REDACTED_TOOL_ARGUMENT_KEYS = new Set(["content", "oldText", "newText"]);
37
39
 
38
40
  function resolveProvider(provider: string | undefined): TokenProvider {
39
41
  const normalized = (provider ?? "unknown").toLowerCase();
@@ -206,6 +208,21 @@ export function parseSystemPromptSections(systemPrompt: string, provider: TokenP
206
208
  return sections;
207
209
  }
208
210
 
211
+ function redactToolCallArguments(value: unknown): unknown {
212
+ if (Array.isArray(value)) return value.map(redactToolCallArguments);
213
+ if (!value || typeof value !== "object") return value;
214
+
215
+ const safe: Record<string, unknown> = {};
216
+ for (const [key, child] of Object.entries(value)) {
217
+ if (REDACTED_TOOL_ARGUMENT_KEYS.has(key)) {
218
+ safe[key] = typeof child === "string" ? truncateWithEllipsis(child, 101) : "[redacted]";
219
+ } else {
220
+ safe[key] = redactToolCallArguments(child);
221
+ }
222
+ }
223
+ return safe;
224
+ }
225
+
209
226
  function messageToText(message: SessionMessageEntry["message"]): string {
210
227
  const role = message.role;
211
228
 
@@ -220,7 +237,7 @@ function messageToText(message: SessionMessageEntry["message"]): string {
220
237
  if (typed.type === "thinking" && typed.thinking) parts.push(typed.thinking);
221
238
  if (typed.type === "toolCall") {
222
239
  parts.push(typed.name ?? "tool");
223
- parts.push(JSON.stringify(typed.arguments ?? {}));
240
+ parts.push(JSON.stringify(redactToolCallArguments(typed.arguments ?? {})));
224
241
  }
225
242
  }
226
243
  }
@@ -1724,17 +1724,22 @@ export function serializePreferencesToFrontmatter(prefs: Record<string, unknown>
1724
1724
  const entries = Object.entries(item as Record<string, unknown>);
1725
1725
  if (entries.length > 0) {
1726
1726
  const [firstKey, firstVal] = entries[0];
1727
- lines.push(`${prefix} - ${firstKey}: ${yamlSafeString(firstVal)}`);
1727
+ if (Array.isArray(firstVal)) {
1728
+ lines.push(`${prefix} - ${firstKey}:`);
1729
+ for (const arrItem of firstVal) {
1730
+ lines.push(`${prefix} - ${yamlSafeString(arrItem)}`);
1731
+ }
1732
+ } else if (typeof firstVal === "object" && firstVal !== null) {
1733
+ lines.push(`${prefix} - ${firstKey}:`);
1734
+ for (const [k, v] of Object.entries(firstVal as Record<string, unknown>)) {
1735
+ serializeValue(k, v, indent + 3);
1736
+ }
1737
+ } else {
1738
+ lines.push(`${prefix} - ${firstKey}: ${yamlSafeString(firstVal)}`);
1739
+ }
1728
1740
  for (let i = 1; i < entries.length; i++) {
1729
1741
  const [k, v] = entries[i];
1730
- if (Array.isArray(v)) {
1731
- lines.push(`${prefix} ${k}:`);
1732
- for (const arrItem of v) {
1733
- lines.push(`${prefix} - ${yamlSafeString(arrItem)}`);
1734
- }
1735
- } else {
1736
- lines.push(`${prefix} ${k}: ${yamlSafeString(v)}`);
1737
- }
1742
+ serializeValue(k, v, indent + 2);
1738
1743
  }
1739
1744
  }
1740
1745
  } else {
@@ -42,9 +42,9 @@ export interface WorktreeStatus {
42
42
 
43
43
  // ─── Status helper ─────────────────────────────────────────────────────────
44
44
 
45
- function getStatus(basePath: string, name: string, wtPath: string): WorktreeStatus {
46
- const diff = diffWorktreeAll(basePath, name);
47
- const numstat = diffWorktreeNumstat(basePath, name);
45
+ function getStatus(basePath: string, name: string, wtPath: string, mainBranch: string): WorktreeStatus {
46
+ const diff = diffWorktreeAll(basePath, name, undefined, mainBranch);
47
+ const numstat = diffWorktreeNumstat(basePath, name, undefined, mainBranch);
48
48
  const filesChanged = diff.added.length + diff.modified.length + diff.removed.length;
49
49
  let linesAdded = 0;
50
50
  let linesRemoved = 0;
@@ -62,8 +62,7 @@ function getStatus(basePath: string, name: string, wtPath: string): WorktreeStat
62
62
 
63
63
  let commits = 0;
64
64
  try {
65
- const main = nativeDetectMainBranch(basePath);
66
- commits = nativeCommitCountBetween(basePath, main, worktreeBranchName(name));
65
+ commits = nativeCommitCountBetween(basePath, mainBranch, worktreeBranchName(name));
67
66
  } catch {
68
67
  // commit count unavailable → leave at 0
69
68
  }
@@ -129,7 +128,8 @@ export function formatCleanKeepReason(status: WorktreeStatus): string {
129
128
  async function handleList(ctx: ExtensionCommandContext): Promise<void> {
130
129
  const basePath = projectRoot();
131
130
  const worktrees = listWorktrees(basePath);
132
- const statuses = worktrees.map((wt) => getStatus(basePath, wt.name, wt.path));
131
+ const mainBranch = worktrees.length > 0 ? nativeDetectMainBranch(basePath) : "";
132
+ const statuses = worktrees.map((wt) => getStatus(basePath, wt.name, wt.path, mainBranch));
133
133
  ctx.ui.notify(formatWorktreeList(statuses), "info");
134
134
  }
135
135
 
@@ -161,7 +161,8 @@ async function handleMerge(args: string, ctx: ExtensionCommandContext): Promise<
161
161
  return;
162
162
  }
163
163
 
164
- const status = getStatus(basePath, target, wt.path);
164
+ const mainBranch = nativeDetectMainBranch(basePath);
165
+ const status = getStatus(basePath, target, wt.path, mainBranch);
165
166
  if (status.filesChanged === 0 && !status.uncommitted) {
166
167
  try {
167
168
  removeWorktree(basePath, target, { deleteBranch: true });
@@ -194,7 +195,6 @@ async function handleMerge(args: string, ctx: ExtensionCommandContext): Promise<
194
195
  }
195
196
 
196
197
  const commitType = inferCommitType(target);
197
- const mainBranch = nativeDetectMainBranch(basePath);
198
198
  const commitMessage = `${commitType}: merge worktree ${target}\n\nGSD-Worktree: ${target}`;
199
199
 
200
200
  try {
@@ -250,8 +250,9 @@ async function handleClean(ctx: ExtensionCommandContext): Promise<void> {
250
250
 
251
251
  const removed: string[] = [];
252
252
  const kept: string[] = [];
253
+ const mainBranch = nativeDetectMainBranch(basePath);
253
254
  for (const wt of worktrees) {
254
- const status = getStatus(basePath, wt.name, wt.path);
255
+ const status = getStatus(basePath, wt.name, wt.path, mainBranch);
255
256
  if (status.filesChanged === 0 && !status.uncommitted) {
256
257
  try {
257
258
  removeWorktree(basePath, wt.name, { deleteBranch: true });
@@ -298,7 +299,8 @@ async function handleRemove(args: string, ctx: ExtensionCommandContext): Promise
298
299
  return;
299
300
  }
300
301
 
301
- const status = getStatus(basePath, name, wt.path);
302
+ const mainBranch = nativeDetectMainBranch(basePath);
303
+ const status = getStatus(basePath, name, wt.path, mainBranch);
302
304
  if ((status.filesChanged > 0 || status.uncommitted) && !force) {
303
305
  ctx.ui.notify(
304
306
  [
@@ -29,7 +29,7 @@ import { getWorkerBatches, hasActiveWorkers, type WorkerEntry } from "../subagen
29
29
  import { formatDuration, padRight, joinColumns, centerLine, fitColumns, STATUS_GLYPH, STATUS_COLOR } from "../shared/mod.js";
30
30
  import { estimateTimeRemaining } from "./auto-dashboard.js";
31
31
  import { computeProgressScore, formatProgressLine } from "./progress-score.js";
32
- import { runEnvironmentChecks, type EnvironmentCheckResult } from "./doctor-environment.js";
32
+ import { runEnvironmentChecksAsync, type EnvironmentCheckResult } from "./doctor-environment.js";
33
33
  import { formattedShortcutPair } from "./shortcut-defs.js";
34
34
  import { renderDialogFrame, renderKeyHints } from "./tui/render-kit.js";
35
35
 
@@ -71,6 +71,9 @@ export class GSDDashboardOverlay {
71
71
  private loading = true;
72
72
  private loadedDashboardIdentity?: string;
73
73
  private refreshInFlight: Promise<void> | null = null;
74
+ private envRefreshInFlight: Promise<void> | null = null;
75
+ private cachedEnvBasePath?: string;
76
+ private cachedEnvIssues: EnvironmentCheckResult[] = [];
74
77
  private disposed = false;
75
78
  private resizeHandler: (() => void) | null = null;
76
79
  private cachedMetrics: {
@@ -181,12 +184,39 @@ export class GSDDashboardOverlay {
181
184
  this.loading = false;
182
185
  }
183
186
 
187
+ this.scheduleEnvironmentRefresh(this.dashData.basePath || process.cwd());
188
+
184
189
  if (identityChanged) {
185
190
  this.invalidate();
186
191
  }
187
192
  this.tui.requestRender();
188
193
  }
189
194
 
195
+ private scheduleEnvironmentRefresh(basePath: string): void {
196
+ if (this.cachedEnvBasePath !== basePath) {
197
+ this.cachedEnvBasePath = basePath;
198
+ this.cachedEnvIssues = [];
199
+ this.invalidate();
200
+ }
201
+ if (this.envRefreshInFlight || this.disposed) return;
202
+ this.envRefreshInFlight = this.refreshEnvironmentHealth(basePath)
203
+ .finally(() => {
204
+ this.envRefreshInFlight = null;
205
+ });
206
+ }
207
+
208
+ private async refreshEnvironmentHealth(basePath: string): Promise<void> {
209
+ try {
210
+ const envResults = await runEnvironmentChecksAsync(basePath);
211
+ if (this.disposed || this.cachedEnvBasePath !== basePath) return;
212
+ this.cachedEnvIssues = envResults.filter(r => r.status !== "ok");
213
+ this.invalidate();
214
+ this.tui.requestRender();
215
+ } catch {
216
+ // Non-fatal — keep last known environment issues
217
+ }
218
+ }
219
+
190
220
  private async loadData(): Promise<boolean> {
191
221
  const base = this.dashData.basePath || process.cwd();
192
222
  try {
@@ -629,8 +659,7 @@ export class GSDDashboardOverlay {
629
659
  }
630
660
 
631
661
  // Environment health section (#1221) — only show issues
632
- const envResults = runEnvironmentChecks(this.dashData.basePath || process.cwd());
633
- const envIssues = envResults.filter(r => r.status !== "ok");
662
+ const envIssues = this.cachedEnvIssues;
634
663
  if (envIssues.length > 0) {
635
664
  lines.push(blank());
636
665
  lines.push(hr());
@@ -28,6 +28,7 @@ import {
28
28
  import { rowToGate } from "../db-gate-rows.js";
29
29
  import { rowToArtifact, rowToMilestone, type ArtifactRow, type MilestoneRow } from "../db-milestone-artifact-rows.js";
30
30
  import { rowToSlice, rowToTask, type SliceRow, type TaskRow } from "../db-task-slice-rows.js";
31
+ import { TERMINAL_STATUS_SQL } from "./sql-constants.js";
31
32
 
32
33
 
33
34
  function parseStringArrayColumn(raw: unknown): string[] {
@@ -49,6 +50,59 @@ function normalizeRepoPath(file: string): string {
49
50
  return file.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
50
51
  }
51
52
 
53
+ export interface HierarchyCompletionCounts {
54
+ milestones: number;
55
+ milestonesTotal: number;
56
+ slices: number;
57
+ slicesTotal: number;
58
+ tasks: number;
59
+ tasksTotal: number;
60
+ }
61
+
62
+ function numberColumn(row: Record<string, unknown> | undefined, column: string): number {
63
+ const value = row?.[column];
64
+ if (typeof value === "number") return value;
65
+ if (typeof value === "bigint") return Number(value);
66
+ if (typeof value === "string") {
67
+ const parsed = Number(value);
68
+ return Number.isFinite(parsed) ? parsed : 0;
69
+ }
70
+ return 0;
71
+ }
72
+
73
+ function getCompletionCount(table: "milestones" | "slices" | "tasks"): { completed: number; total: number } {
74
+ const row = getDbOrNull()!.prepare(
75
+ `SELECT
76
+ COUNT(*) AS total,
77
+ COALESCE(SUM(CASE WHEN status IN (${TERMINAL_STATUS_SQL}) THEN 1 ELSE 0 END), 0) AS completed
78
+ FROM ${table}`,
79
+ ).get();
80
+
81
+ return {
82
+ completed: numberColumn(row, "completed"),
83
+ total: numberColumn(row, "total"),
84
+ };
85
+ }
86
+
87
+ export function getHierarchyCompletionCounts(): HierarchyCompletionCounts {
88
+ if (!getDbOrNull()!) {
89
+ return { milestones: 0, milestonesTotal: 0, slices: 0, slicesTotal: 0, tasks: 0, tasksTotal: 0 };
90
+ }
91
+
92
+ const milestones = getCompletionCount("milestones");
93
+ const slices = getCompletionCount("slices");
94
+ const tasks = getCompletionCount("tasks");
95
+
96
+ return {
97
+ milestones: milestones.completed,
98
+ milestonesTotal: milestones.total,
99
+ slices: slices.completed,
100
+ slicesTotal: slices.total,
101
+ tasks: tasks.completed,
102
+ tasksTotal: tasks.total,
103
+ };
104
+ }
105
+
52
106
  export function getDecisionById(id: string): Decision | null {
53
107
  if (!getDbOrNull()!) return null;
54
108
  const row = getDbOrNull()!.prepare("SELECT * FROM decisions WHERE id = ?").get(id);
@@ -490,6 +544,31 @@ export function getAssessment(path: string): Record<string, unknown> | null {
490
544
  return row ?? null;
491
545
  }
492
546
 
547
+ /**
548
+ * Look up a slice's `run-uat` assessment by (milestoneId, sliceId) identity,
549
+ * independent of the artifact `path`. Used as a DB fallback by the UAT
550
+ * closeout gate when a path migration orphans the ASSESSMENT markdown from its
551
+ * canonical expected path (ADR-017: DB-authoritative UAT sign-off).
552
+ *
553
+ * `status` holds the normalized verdict (`pass`/`fail`/…) written by
554
+ * `executeUatResultSave`; `fullContent` carries the ASSESSMENT body so callers
555
+ * can derive `uatType` without re-reading a file that may not exist.
556
+ */
557
+ export function getSliceRunUatAssessment(
558
+ milestoneId: string,
559
+ sliceId: string,
560
+ ): { status: string; fullContent: string } | null {
561
+ if (!getDbOrNull()!) return null;
562
+ const row = getDbOrNull()!.prepare(
563
+ `SELECT status, full_content AS fullContent FROM assessments
564
+ WHERE milestone_id = :mid AND slice_id = :sid AND scope = 'run-uat'
565
+ ORDER BY created_at DESC, ROWID DESC
566
+ LIMIT 1`,
567
+ ).get({ ":mid": milestoneId, ":sid": sliceId });
568
+ if (!row) return null;
569
+ return { status: String(row["status"] ?? ""), fullContent: String(row["fullContent"] ?? "") };
570
+ }
571
+
493
572
  export function getLatestAssessmentByScope(
494
573
  milestoneId: string,
495
574
  scope: string,
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { existsSync, readFileSync } from "node:fs";
15
+ import { access, readFile } from "node:fs/promises";
15
16
  import { delimiter, join } from "node:path";
16
17
  import { AuthStorage } from "@gsd/pi-coding-agent";
17
18
  import { getEnvApiKey } from "@gsd/pi-ai";
@@ -166,15 +167,11 @@ const CLI_AUTH_PATH_CHECK_PROVIDERS = new Set([
166
167
  "google-antigravity",
167
168
  ]);
168
169
 
169
- /**
170
- * Check if a CLI provider's binary exists anywhere in PATH.
171
- * Fast filesystem scan — no subprocess, no network, sub-1ms.
172
- */
173
- function isCliBinaryInPath(providerId: string): boolean {
174
- const binaries = CLI_BINARY_MAP[providerId];
175
- if (!binaries) return false;
170
+ let asyncCliBinaryPathCache: Map<string, boolean> | null = null;
176
171
 
177
- const pathDirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
172
+ function cliExecutableNames(providerId: string): string[] {
173
+ const binaries = CLI_BINARY_MAP[providerId];
174
+ if (!binaries) return [];
178
175
 
179
176
  // On Windows, command shims are commonly installed as .cmd/.exe/.bat/.com.
180
177
  // Scan PATHEXT candidates in addition to the bare binary name.
@@ -196,9 +193,51 @@ function isCliBinaryInPath(providerId: string): boolean {
196
193
  }
197
194
  }
198
195
 
196
+ return executableNames;
197
+ }
198
+
199
+ /**
200
+ * Check if a CLI provider's binary exists anywhere in PATH.
201
+ * Fast filesystem scan — no subprocess, no network, sub-1ms.
202
+ */
203
+ function isCliBinaryInPath(providerId: string): boolean {
204
+ const cached = asyncCliBinaryPathCache?.get(providerId);
205
+ if (cached !== undefined) return cached;
206
+
207
+ const executableNames = cliExecutableNames(providerId);
208
+ if (executableNames.length === 0) return false;
209
+
210
+ const pathDirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
211
+
199
212
  return pathDirs.some(dir => executableNames.some(name => existsSync(join(dir, name))));
200
213
  }
201
214
 
215
+ async function isCliBinaryInPathAsync(providerId: string): Promise<boolean> {
216
+ const executableNames = cliExecutableNames(providerId);
217
+ if (executableNames.length === 0) return false;
218
+
219
+ const pathDirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
220
+ const candidates = pathDirs.flatMap(dir => executableNames.map(name => join(dir, name)));
221
+ if (candidates.length === 0) return false;
222
+
223
+ try {
224
+ await Promise.any(candidates.map(candidate => access(candidate)));
225
+ return true;
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
230
+
231
+ async function loadCliBinaryPathCache(): Promise<Map<string, boolean>> {
232
+ const entries = await Promise.all(
233
+ Object.keys(CLI_BINARY_MAP).map(async providerId => [
234
+ providerId,
235
+ await isCliBinaryInPathAsync(providerId),
236
+ ] as const),
237
+ );
238
+ return new Map(entries);
239
+ }
240
+
202
241
  function modelsJsonPaths(): string[] {
203
242
  const home = homedir();
204
243
  return [
@@ -208,7 +247,13 @@ function modelsJsonPaths(): string[] {
208
247
  ];
209
248
  }
210
249
 
250
+ let asyncModelsJsonApiKeyCache: Set<string> | null = null;
251
+
211
252
  function hasModelsJsonApiKey(providerId: string): boolean {
253
+ if (asyncModelsJsonApiKeyCache) {
254
+ return asyncModelsJsonApiKeyCache.has(providerId);
255
+ }
256
+
212
257
  for (const path of modelsJsonPaths()) {
213
258
  if (!existsSync(path)) continue;
214
259
  try {
@@ -226,7 +271,27 @@ function hasModelsJsonApiKey(providerId: string): boolean {
226
271
  return false;
227
272
  }
228
273
 
229
- function resolveKey(providerId: string): KeyLookup {
274
+ async function loadModelsJsonApiKeyCache(): Promise<Set<string>> {
275
+ const providersWithKeys = new Set<string>();
276
+ for (const path of modelsJsonPaths()) {
277
+ try {
278
+ const parsed = JSON.parse(await readFile(path, "utf-8")) as {
279
+ providers?: Record<string, { apiKey?: unknown }>;
280
+ };
281
+ for (const [providerId, provider] of Object.entries(parsed.providers ?? {})) {
282
+ const apiKey = provider.apiKey;
283
+ if (typeof apiKey === "string" && apiKey.trim().length > 0) {
284
+ providersWithKeys.add(providerId);
285
+ }
286
+ }
287
+ } catch {
288
+ // Missing or malformed models.json should not break dashboard health checks.
289
+ }
290
+ }
291
+ return providersWithKeys;
292
+ }
293
+
294
+ function resolveKeyFromAuthOrEnv(providerId: string): KeyLookup | null {
230
295
  const info = PROVIDER_REGISTRY.find(p => p.id === providerId);
231
296
 
232
297
  if (providerId === "anthropic-vertex" && process.env.ANTHROPIC_VERTEX_PROJECT_ID) {
@@ -276,6 +341,13 @@ function resolveKey(providerId: string): KeyLookup {
276
341
  return { found: true, source: "env", backedOff: false };
277
342
  }
278
343
 
344
+ return null;
345
+ }
346
+
347
+ function resolveKey(providerId: string): KeyLookup {
348
+ const direct = resolveKeyFromAuthOrEnv(providerId);
349
+ if (direct) return direct;
350
+
279
351
  if (hasModelsJsonApiKey(providerId)) {
280
352
  return { found: true, source: "models.json", backedOff: false };
281
353
  }
@@ -495,6 +567,28 @@ export function runProviderChecks(): ProviderCheckResult[] {
495
567
  return results;
496
568
  }
497
569
 
570
+ /**
571
+ * Non-blocking equivalent of `runProviderChecks` for the health-widget
572
+ * background refresh. PATH checks and custom models.json discovery use async
573
+ * filesystem APIs so periodic widget refreshes do not stall the input loop.
574
+ */
575
+ export async function runProviderChecksAsync(): Promise<ProviderCheckResult[]> {
576
+ const [cliCache, modelsJsonCache] = await Promise.all([
577
+ loadCliBinaryPathCache(),
578
+ loadModelsJsonApiKeyCache(),
579
+ ]);
580
+ const previousCliCache = asyncCliBinaryPathCache;
581
+ const previousModelsJsonCache = asyncModelsJsonApiKeyCache;
582
+ asyncCliBinaryPathCache = cliCache;
583
+ asyncModelsJsonApiKeyCache = modelsJsonCache;
584
+ try {
585
+ return runProviderChecks();
586
+ } finally {
587
+ asyncCliBinaryPathCache = previousCliCache;
588
+ asyncModelsJsonApiKeyCache = previousModelsJsonCache;
589
+ }
590
+ }
591
+
498
592
  /**
499
593
  * Format provider check results as a human-readable report string.
500
594
  */