gsd-pi 2.76.0-dev.76f9a2dc5 → 2.76.0-dev.97807402

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 (221) hide show
  1. package/dist/claude-cli-check.js +32 -3
  2. package/dist/mcp-server.d.ts +7 -0
  3. package/dist/mcp-server.js +35 -1
  4. package/dist/resources/extensions/claude-code-cli/readiness.js +4 -3
  5. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +77 -17
  6. package/dist/resources/extensions/gsd/auto/phases.js +14 -0
  7. package/dist/resources/extensions/gsd/auto/run-unit.js +27 -0
  8. package/dist/resources/extensions/gsd/auto-model-selection.js +1 -1
  9. package/dist/resources/extensions/gsd/auto-post-unit.js +1 -1
  10. package/dist/resources/extensions/gsd/auto-recovery.js +13 -0
  11. package/dist/resources/extensions/gsd/auto-start.js +27 -18
  12. package/dist/resources/extensions/gsd/auto-worktree.js +30 -48
  13. package/dist/resources/extensions/gsd/auto.js +13 -17
  14. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +17 -1
  15. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +39 -9
  16. package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +93 -0
  17. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +2 -0
  18. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +40 -4
  19. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +12 -1
  20. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +968 -23
  21. package/dist/resources/extensions/gsd/compaction-snapshot.js +121 -0
  22. package/dist/resources/extensions/gsd/error-classifier.js +10 -3
  23. package/dist/resources/extensions/gsd/exec-history.js +120 -0
  24. package/dist/resources/extensions/gsd/exec-sandbox.js +258 -0
  25. package/dist/resources/extensions/gsd/gsd-db.js +115 -7
  26. package/dist/resources/extensions/gsd/guided-flow.js +189 -0
  27. package/dist/resources/extensions/gsd/health-widget.js +4 -1
  28. package/dist/resources/extensions/gsd/key-manager.js +6 -0
  29. package/dist/resources/extensions/gsd/model-router.js +36 -3
  30. package/dist/resources/extensions/gsd/pre-execution-checks.js +35 -9
  31. package/dist/resources/extensions/gsd/preferences-types.js +9 -0
  32. package/dist/resources/extensions/gsd/preferences-validation.js +83 -0
  33. package/dist/resources/extensions/gsd/preferences.js +17 -17
  34. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +8 -0
  35. package/dist/resources/extensions/gsd/prompts/discuss.md +29 -2
  36. package/dist/resources/extensions/gsd/prompts/parallel-research-slices.md +5 -2
  37. package/dist/resources/extensions/gsd/safety/file-change-validator.js +9 -3
  38. package/dist/resources/extensions/gsd/safety/safety-harness.js +4 -0
  39. package/dist/resources/extensions/gsd/token-counter.js +22 -5
  40. package/dist/resources/extensions/gsd/tools/exec-search-tool.js +59 -0
  41. package/dist/resources/extensions/gsd/tools/exec-tool.js +126 -0
  42. package/dist/resources/extensions/gsd/tools/resume-tool.js +23 -0
  43. package/dist/resources/extensions/gsd/workflow-mcp.js +3 -0
  44. package/dist/resources/skills/verify-before-complete/SKILL.md +2 -1
  45. package/dist/resources/skills/write-docs/SKILL.md +2 -1
  46. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  47. package/dist/web/standalone/.next/BUILD_ID +1 -1
  48. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  49. package/dist/web/standalone/.next/build-manifest.json +2 -2
  50. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  51. package/dist/web/standalone/.next/required-server-files.json +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.html +1 -1
  69. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  71. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  73. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  74. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  75. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  76. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  77. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  78. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  79. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  80. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  81. package/dist/web/standalone/server.js +1 -1
  82. package/package.json +1 -1
  83. package/packages/mcp-server/dist/remote-questions.d.ts +45 -0
  84. package/packages/mcp-server/dist/remote-questions.d.ts.map +1 -0
  85. package/packages/mcp-server/dist/remote-questions.js +732 -0
  86. package/packages/mcp-server/dist/remote-questions.js.map +1 -0
  87. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  88. package/packages/mcp-server/dist/server.js +18 -1
  89. package/packages/mcp-server/dist/server.js.map +1 -1
  90. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  91. package/packages/mcp-server/dist/workflow-tools.js +64 -25
  92. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  93. package/packages/mcp-server/package.json +2 -1
  94. package/packages/mcp-server/src/remote-questions.test.ts +294 -0
  95. package/packages/mcp-server/src/remote-questions.ts +916 -0
  96. package/packages/mcp-server/src/server.ts +19 -1
  97. package/packages/mcp-server/src/workflow-tools.test.ts +146 -1
  98. package/packages/mcp-server/src/workflow-tools.ts +84 -43
  99. package/packages/mcp-server/tsconfig.test.json +19 -0
  100. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  101. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -1
  102. package/packages/pi-ai/dist/providers/anthropic-shared.js +2 -0
  103. package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -1
  104. package/packages/pi-ai/dist/providers/simple-options.d.ts +10 -0
  105. package/packages/pi-ai/dist/providers/simple-options.d.ts.map +1 -1
  106. package/packages/pi-ai/dist/providers/simple-options.js +16 -1
  107. package/packages/pi-ai/dist/providers/simple-options.js.map +1 -1
  108. package/packages/pi-ai/src/providers/anthropic-shared.ts +3 -1
  109. package/packages/pi-ai/src/providers/simple-options.ts +17 -1
  110. package/packages/pi-ai/tsconfig.tsbuildinfo +1 -1
  111. package/packages/pi-coding-agent/dist/core/model-registry-custom-caps.test.d.ts +2 -0
  112. package/packages/pi-coding-agent/dist/core/model-registry-custom-caps.test.d.ts.map +1 -0
  113. package/packages/pi-coding-agent/dist/core/model-registry-custom-caps.test.js +203 -0
  114. package/packages/pi-coding-agent/dist/core/model-registry-custom-caps.test.js.map +1 -0
  115. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/core/model-registry.js +14 -0
  117. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  118. package/packages/pi-coding-agent/dist/core/redact-secrets.d.ts +2 -0
  119. package/packages/pi-coding-agent/dist/core/redact-secrets.d.ts.map +1 -0
  120. package/packages/pi-coding-agent/dist/core/redact-secrets.js +49 -0
  121. package/packages/pi-coding-agent/dist/core/redact-secrets.js.map +1 -0
  122. package/packages/pi-coding-agent/dist/core/redact-secrets.test.d.ts +2 -0
  123. package/packages/pi-coding-agent/dist/core/redact-secrets.test.d.ts.map +1 -0
  124. package/packages/pi-coding-agent/dist/core/redact-secrets.test.js +67 -0
  125. package/packages/pi-coding-agent/dist/core/redact-secrets.test.js.map +1 -0
  126. package/packages/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
  127. package/packages/pi-coding-agent/dist/core/session-manager.js +9 -5
  128. package/packages/pi-coding-agent/dist/core/session-manager.js.map +1 -1
  129. package/packages/pi-coding-agent/dist/core/session-manager.test.js +25 -1
  130. package/packages/pi-coding-agent/dist/core/session-manager.test.js.map +1 -1
  131. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  132. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +13 -1
  133. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  134. package/packages/pi-coding-agent/src/core/model-registry-custom-caps.test.ts +245 -0
  135. package/packages/pi-coding-agent/src/core/model-registry.ts +16 -0
  136. package/packages/pi-coding-agent/src/core/redact-secrets.test.ts +86 -0
  137. package/packages/pi-coding-agent/src/core/redact-secrets.ts +58 -0
  138. package/packages/pi-coding-agent/src/core/session-manager.test.ts +36 -1
  139. package/packages/pi-coding-agent/src/core/session-manager.ts +9 -5
  140. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +13 -1
  141. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  142. package/src/resources/extensions/claude-code-cli/readiness.ts +4 -3
  143. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +78 -17
  144. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +149 -5
  145. package/src/resources/extensions/gsd/auto/phases.ts +14 -0
  146. package/src/resources/extensions/gsd/auto/run-unit.ts +29 -0
  147. package/src/resources/extensions/gsd/auto-model-selection.ts +1 -1
  148. package/src/resources/extensions/gsd/auto-post-unit.ts +1 -2
  149. package/src/resources/extensions/gsd/auto-recovery.ts +15 -0
  150. package/src/resources/extensions/gsd/auto-start.ts +29 -19
  151. package/src/resources/extensions/gsd/auto-worktree.ts +34 -52
  152. package/src/resources/extensions/gsd/auto.ts +12 -17
  153. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +23 -1
  154. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +40 -9
  155. package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +109 -0
  156. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +2 -0
  157. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +42 -4
  158. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +13 -1
  159. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +898 -32
  160. package/src/resources/extensions/gsd/compaction-snapshot.ts +165 -0
  161. package/src/resources/extensions/gsd/error-classifier.ts +10 -3
  162. package/src/resources/extensions/gsd/exec-history.ts +153 -0
  163. package/src/resources/extensions/gsd/exec-sandbox.ts +326 -0
  164. package/src/resources/extensions/gsd/gsd-db.ts +122 -7
  165. package/src/resources/extensions/gsd/guided-flow.ts +221 -0
  166. package/src/resources/extensions/gsd/health-widget.ts +3 -1
  167. package/src/resources/extensions/gsd/journal.ts +2 -1
  168. package/src/resources/extensions/gsd/key-manager.ts +6 -0
  169. package/src/resources/extensions/gsd/model-router.ts +42 -1
  170. package/src/resources/extensions/gsd/pre-execution-checks.ts +36 -10
  171. package/src/resources/extensions/gsd/preferences-types.ts +46 -0
  172. package/src/resources/extensions/gsd/preferences-validation.ts +79 -0
  173. package/src/resources/extensions/gsd/preferences.ts +17 -17
  174. package/src/resources/extensions/gsd/prompts/discuss-headless.md +8 -0
  175. package/src/resources/extensions/gsd/prompts/discuss.md +29 -2
  176. package/src/resources/extensions/gsd/prompts/parallel-research-slices.md +5 -2
  177. package/src/resources/extensions/gsd/safety/file-change-validator.ts +13 -2
  178. package/src/resources/extensions/gsd/safety/safety-harness.ts +6 -0
  179. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +116 -0
  180. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +49 -0
  181. package/src/resources/extensions/gsd/tests/compaction-snapshot.test.ts +123 -0
  182. package/src/resources/extensions/gsd/tests/complete-slice.test.ts +2 -2
  183. package/src/resources/extensions/gsd/tests/complete-task.test.ts +2 -2
  184. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +31 -0
  185. package/src/resources/extensions/gsd/tests/ensure-db-open.test.ts +1 -1
  186. package/src/resources/extensions/gsd/tests/escalation.test.ts +1 -1
  187. package/src/resources/extensions/gsd/tests/exec-history.test.ts +124 -0
  188. package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +210 -0
  189. package/src/resources/extensions/gsd/tests/file-change-validator.test.ts +38 -0
  190. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +152 -1
  191. package/src/resources/extensions/gsd/tests/isolation-none-branch-guard.test.ts +1 -1
  192. package/src/resources/extensions/gsd/tests/issue-4540-regressions.test.ts +288 -0
  193. package/src/resources/extensions/gsd/tests/key-manager.test.ts +7 -0
  194. package/src/resources/extensions/gsd/tests/md-importer.test.ts +1 -1
  195. package/src/resources/extensions/gsd/tests/memory-store.test.ts +2 -2
  196. package/src/resources/extensions/gsd/tests/parallel-research-dispatch.test.ts +19 -0
  197. package/src/resources/extensions/gsd/tests/pre-exec-backtick-strip.test.ts +14 -0
  198. package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +234 -0
  199. package/src/resources/extensions/gsd/tests/preferences.test.ts +110 -0
  200. package/src/resources/extensions/gsd/tests/prefs-wizard-coverage.test.ts +44 -0
  201. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +48 -0
  202. package/src/resources/extensions/gsd/tests/ready-phrase-no-files-4573.test.ts +388 -0
  203. package/src/resources/extensions/gsd/tests/restore-tools-after-discuss.test.ts +9 -3
  204. package/src/resources/extensions/gsd/tests/save-gate-result-render.test.ts +95 -0
  205. package/src/resources/extensions/gsd/tests/session-start-footer.test.ts +32 -40
  206. package/src/resources/extensions/gsd/tests/stash-queued-context-files.test.ts +56 -0
  207. package/src/resources/extensions/gsd/tests/token-counter.test.ts +105 -1
  208. package/src/resources/extensions/gsd/tests/tool-compatibility.test.ts +107 -0
  209. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +65 -2
  210. package/src/resources/extensions/gsd/tests/write-gate.test.ts +64 -0
  211. package/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts +3 -1
  212. package/src/resources/extensions/gsd/token-counter.ts +22 -5
  213. package/src/resources/extensions/gsd/tools/exec-search-tool.ts +81 -0
  214. package/src/resources/extensions/gsd/tools/exec-tool.ts +183 -0
  215. package/src/resources/extensions/gsd/tools/resume-tool.ts +40 -0
  216. package/src/resources/extensions/gsd/workflow-logger.ts +2 -1
  217. package/src/resources/extensions/gsd/workflow-mcp.ts +3 -0
  218. package/src/resources/skills/verify-before-complete/SKILL.md +2 -1
  219. package/src/resources/skills/write-docs/SKILL.md +2 -1
  220. /package/dist/web/standalone/.next/static/{UMCfv_sVnLXawpUAjvArc → pI48IF3dgfs0CBrYi2bh_}/_buildManifest.js +0 -0
  221. /package/dist/web/standalone/.next/static/{UMCfv_sVnLXawpUAjvArc → pI48IF3dgfs0CBrYi2bh_}/_ssgManifest.js +0 -0
@@ -378,3 +378,59 @@ test("#2505: back-to-back merges preserve queued CONTEXT files", () => {
378
378
  rmSync(repo, { recursive: true, force: true });
379
379
  }
380
380
  });
381
+
382
+ // #4573: When `.gsd` is a gitignored symlink (ADR-002 layout) and the project
383
+ // `.gitignore` contains `.gsd`, `git stash push --include-untracked -- <pathspec>`
384
+ // fatals with "The following paths are ignored by one of your .gitignore files".
385
+ // The prior tests used a symlinked `.gsd` but no `.gitignore`, so this failure
386
+ // mode was invisible to CI. Fixture must include BOTH the symlink AND the
387
+ // ignore rule to reproduce the bug on pre-fix code.
388
+ test("#4573: gitignored .gsd symlink does not break pre-merge stash", () => {
389
+ const repo = realpathSync(mkdtempSync(join(tmpdir(), "wt-4573-ignored-symlink-")));
390
+ const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "wt-4573-state-")));
391
+ try {
392
+ run("git init", repo);
393
+ run("git config user.email test@test.com", repo);
394
+ run("git config user.name Test", repo);
395
+ writeFileSync(join(repo, "README.md"), "# test\n");
396
+ // Matches what BASELINE_PATTERNS in gitignore.ts writes for real projects.
397
+ writeFileSync(join(repo, ".gitignore"), ".gsd\n.gsd-id\n");
398
+ symlinkSync(stateDir, join(repo, ".gsd"));
399
+ run("git add README.md .gitignore", repo);
400
+ run("git commit -m init", repo);
401
+ run("git branch -M main", repo);
402
+
403
+ const wtPath = createAutoWorktree(repo, "M001");
404
+ const worktreeName = wtPath.replaceAll("\\", "/").split("/").pop() || "M001";
405
+ const sliceBranch = `slice/${worktreeName}/S01`;
406
+ run(`git checkout -b "${sliceBranch}"`, wtPath);
407
+ writeFileSync(join(wtPath, "app.ts"), "export const app = true;\n");
408
+ run("git add app.ts", wtPath);
409
+ run('git commit -m "add feature"', wtPath);
410
+ run("git checkout milestone/M001", wtPath);
411
+ run(`git merge --no-ff "${sliceBranch}" -m "merge S01"`, wtPath);
412
+
413
+ // Dirty a tracked file so the pre-merge stash branch actually runs.
414
+ writeFileSync(join(repo, "README.md"), "# test\n\nDirty.\n");
415
+
416
+ const result = mergeMilestoneToMain(
417
+ repo,
418
+ "M001",
419
+ makeRoadmap("M001", "Feature", [{ id: "S01", title: "Feature" }]),
420
+ );
421
+
422
+ assert.ok(
423
+ result.commitMessage.includes("GSD-Milestone: M001"),
424
+ "merge must succeed despite gitignored .gsd symlink",
425
+ );
426
+ assert.ok(existsSync(join(repo, "app.ts")), "milestone code merged to main");
427
+ assert.equal(
428
+ lstatSync(join(repo, ".gsd")).isSymbolicLink(),
429
+ true,
430
+ ".gsd symlink remains in place",
431
+ );
432
+ } finally {
433
+ rmSync(repo, { recursive: true, force: true });
434
+ rmSync(stateDir, { recursive: true, force: true });
435
+ }
436
+ });
@@ -102,7 +102,8 @@ describe("token-counter: estimateTokensForProvider", () => {
102
102
 
103
103
  describe("token-counter: backward compatibility", () => {
104
104
  it("countTokensSync returns heuristic estimate when tiktoken is not loaded", () => {
105
- // Without tiktoken loaded, countTokensSync falls back to ceil(len/4)
105
+ // Without tiktoken loaded, countTokensSync falls back to estimateTokensForProvider.
106
+ // With no provider (defaults to "unknown", ratio 4.0): ceil(100/4) = 25.
106
107
  const text = "A".repeat(100);
107
108
  const result = countTokensSync(text);
108
109
  // Either tiktoken is loaded (exact count) or heuristic (ceil(100/4) = 25)
@@ -127,3 +128,106 @@ describe("token-counter: backward compatibility", () => {
127
128
  assert.equal(result, 0);
128
129
  });
129
130
  });
131
+
132
+ // ─── provider-aware fallback (issue #4529) ───────────────────────────────────
133
+ // Regression tests: countTokens/countTokensSync must use provider-specific
134
+ // ratios for their heuristic fallback, not a hardcoded GPT-4o / 4 divisor.
135
+
136
+ describe("token-counter: provider-aware heuristic fallback", () => {
137
+ // These tests exercise the heuristic path (no tiktoken or before init).
138
+ // We call estimateTokensForProvider directly to validate expected values,
139
+ // then verify countTokens/countTokensSync return the same values when
140
+ // tiktoken is unavailable.
141
+
142
+ it("countTokensSync uses anthropic ratio (3.5) when provider is 'anthropic'", () => {
143
+ const text = "A".repeat(350);
144
+ // anthropic: ceil(350 / 3.5) = 100
145
+ // openai/unknown: ceil(350 / 4.0) = 88
146
+ // These are different — the provider must matter.
147
+ const anthropicEstimate = estimateTokensForProvider(text, "anthropic");
148
+ const unknownEstimate = estimateTokensForProvider(text, "unknown");
149
+ assert.equal(anthropicEstimate, 100, "anthropic ratio should give 100 tokens for 350 chars");
150
+ assert.equal(unknownEstimate, 88, "unknown ratio should give 88 tokens for 350 chars");
151
+ assert.notEqual(
152
+ anthropicEstimate,
153
+ unknownEstimate,
154
+ "anthropic and unknown estimates must differ — if they are equal the provider is being ignored",
155
+ );
156
+
157
+ // Actually call countTokensSync with the anthropic provider.
158
+ // When tiktoken is not loaded, this must return the provider-aware estimate (100).
159
+ // When tiktoken is loaded, it returns the tiktoken count (which is also > 0 and
160
+ // will be in the range [88, 120] for 350 "A" characters with cl100k_base).
161
+ const syncResult = countTokensSync(text, "anthropic");
162
+ assert.ok(typeof syncResult === "number", "countTokensSync must return a number");
163
+ assert.ok(syncResult > 0, "countTokensSync must return a positive count");
164
+ // If tiktoken is unavailable the result must exactly match the anthropic heuristic.
165
+ // If tiktoken is available we cannot assert the exact value, but we know it will
166
+ // not equal the unknown-provider heuristic (88) for 350 identical characters.
167
+ const tiktokenAvailable = syncResult !== anthropicEstimate;
168
+ if (!tiktokenAvailable) {
169
+ assert.equal(
170
+ syncResult,
171
+ anthropicEstimate,
172
+ `countTokensSync with 'anthropic' provider must return ${anthropicEstimate} (not the unknown-provider value ${unknownEstimate}) when tiktoken is unavailable`,
173
+ );
174
+ }
175
+ });
176
+
177
+ it("countTokens uses anthropic ratio when provider='anthropic' and tiktoken unavailable", async () => {
178
+ const text = "A".repeat(350);
179
+ // anthropic heuristic: ceil(350 / 3.5) = 100
180
+ // unknown/hardcoded-4 heuristic: ceil(350 / 4.0) = 88
181
+ const anthropicEstimate = estimateTokensForProvider(text, "anthropic"); // 100
182
+ const unknownEstimate = estimateTokensForProvider(text, "unknown"); // 88
183
+ const hardcodedFallback = Math.ceil(text.length / 4); // 88
184
+
185
+ // The anthropic heuristic must produce more tokens than the old /4 fallback.
186
+ assert.equal(anthropicEstimate, 100, "anthropic heuristic should give 100 tokens for 350 chars");
187
+ assert.ok(
188
+ anthropicEstimate > hardcodedFallback,
189
+ `anthropic estimate (${anthropicEstimate}) must exceed the hardcoded /4 fallback (${hardcodedFallback})`,
190
+ );
191
+ assert.notEqual(
192
+ anthropicEstimate,
193
+ unknownEstimate,
194
+ "anthropic and unknown estimates must differ — provider is being ignored if equal",
195
+ );
196
+
197
+ // Call countTokens with the anthropic provider and verify the result.
198
+ const result = await countTokens(text, "anthropic");
199
+ assert.ok(typeof result === "number", "should return a number");
200
+ assert.ok(result > 0, "should return a positive token count");
201
+
202
+ // When tiktoken is unavailable: result must equal the anthropic heuristic (100),
203
+ // NOT the unknown-provider heuristic (88). This is the core regression guard for #4529.
204
+ // When tiktoken IS available: result is the tiktoken count, which will differ from
205
+ // the heuristic — but it must never equal the wrong (unknown) heuristic for this text.
206
+ if (result === anthropicEstimate || result === unknownEstimate) {
207
+ // We are on the heuristic path — assert the correct provider ratio was used.
208
+ assert.equal(
209
+ result,
210
+ anthropicEstimate,
211
+ `countTokens with 'anthropic' provider returned ${result} but expected ${anthropicEstimate} (anthropic heuristic) not ${unknownEstimate} (unknown/hardcoded heuristic)`,
212
+ );
213
+ } else {
214
+ // tiktoken is active — result is an exact BPE count.
215
+ // For 350 identical "A" characters cl100k_base produces a count in [80, 130].
216
+ assert.ok(
217
+ result >= 80 && result <= 130,
218
+ `tiktoken count ${result} for 350 "A" chars is outside expected range [80, 130]`,
219
+ );
220
+ }
221
+ });
222
+
223
+ it("countTokens with provider='anthropic' yields more tokens than provider='openai' (heuristic)", () => {
224
+ const text = "A".repeat(400);
225
+ // anthropic: ceil(400/3.5) = 115, openai: ceil(400/4.0) = 100
226
+ const anthropic = estimateTokensForProvider(text, "anthropic");
227
+ const openai = estimateTokensForProvider(text, "openai");
228
+ assert.ok(
229
+ anthropic > openai,
230
+ `anthropic estimate (${anthropic}) must exceed openai estimate (${openai}) for same text`,
231
+ );
232
+ });
233
+ });
@@ -14,6 +14,7 @@ import {
14
14
  isToolCompatibleWithProvider,
15
15
  filterToolsForProvider,
16
16
  adjustToolSet,
17
+ GROQ_MAX_TOOLS,
17
18
  } from "../model-router.js";
18
19
 
19
20
  import {
@@ -197,3 +198,109 @@ describe("adjustToolSet", () => {
197
198
  assert.deepEqual(removedTools, ["mcp_complex"]);
198
199
  });
199
200
  });
201
+
202
+ // ─── GROQ_MAX_TOOLS constant ─────────────────────────────────────────────────
203
+
204
+ describe("GROQ_MAX_TOOLS", () => {
205
+ test("equals 128", () => {
206
+ assert.equal(GROQ_MAX_TOOLS, 128);
207
+ });
208
+ });
209
+
210
+ // ─── Groq tool-count cap (#4376) ────────────────────────────────────────────
211
+
212
+ describe("filterToolsForProvider — Groq 128-tool cap", () => {
213
+ beforeEach(() => {
214
+ resetToolCompatibilityRegistry();
215
+ });
216
+
217
+ test("does not cap when provider is not groq", () => {
218
+ const toolNames = Array.from({ length: 200 }, (_, i) => `tool_${i}`);
219
+ const { compatible, filtered } = filterToolsForProvider(toolNames, "openai-completions");
220
+ assert.equal(compatible.length, 200);
221
+ assert.equal(filtered.length, 0);
222
+ });
223
+
224
+ test("does not cap when <= 128 tools with groq provider", () => {
225
+ const toolNames = Array.from({ length: 128 }, (_, i) => `tool_${i}`);
226
+ const { compatible, filtered } = filterToolsForProvider(toolNames, "openai-completions", "groq");
227
+ assert.equal(compatible.length, 128);
228
+ assert.equal(filtered.length, 0);
229
+ });
230
+
231
+ test("caps to 128 when >128 tools with groq provider", () => {
232
+ const toolNames = Array.from({ length: 200 }, (_, i) => `tool_${i}`);
233
+ const { compatible, filtered } = filterToolsForProvider(toolNames, "openai-completions", "groq");
234
+ assert.equal(compatible.length, 128);
235
+ assert.equal(filtered.length, 72);
236
+ });
237
+
238
+ test("keeps the first 128 tools when capping", () => {
239
+ const toolNames = Array.from({ length: 200 }, (_, i) => `tool_${i}`);
240
+ const { compatible } = filterToolsForProvider(toolNames, "openai-completions", "groq");
241
+ assert.equal(compatible[0], "tool_0");
242
+ assert.equal(compatible[127], "tool_127");
243
+ });
244
+
245
+ test("trimmed tools appear in filtered list", () => {
246
+ const toolNames = Array.from({ length: 130 }, (_, i) => `tool_${i}`);
247
+ const { filtered } = filterToolsForProvider(toolNames, "openai-completions", "groq");
248
+ assert.equal(filtered.length, 2);
249
+ assert.equal(filtered[0], "tool_128");
250
+ assert.equal(filtered[1], "tool_129");
251
+ });
252
+
253
+ test("emits a warning when tools are trimmed", () => {
254
+ const warnings: string[] = [];
255
+ const original = console.warn;
256
+ console.warn = (...args: unknown[]) => { warnings.push(String(args[0])); };
257
+ try {
258
+ const toolNames = Array.from({ length: 129 }, (_, i) => `tool_${i}`);
259
+ filterToolsForProvider(toolNames, "openai-completions", "groq");
260
+ assert.equal(warnings.length, 1);
261
+ assert.ok(warnings[0].includes("128"), "warning mentions Groq limit");
262
+ } finally {
263
+ console.warn = original;
264
+ }
265
+ });
266
+
267
+ test("does not warn when tools are at the limit", () => {
268
+ const warnings: string[] = [];
269
+ const original = console.warn;
270
+ console.warn = (...args: unknown[]) => { warnings.push(String(args[0])); };
271
+ try {
272
+ const toolNames = Array.from({ length: 128 }, (_, i) => `tool_${i}`);
273
+ filterToolsForProvider(toolNames, "openai-completions", "groq");
274
+ assert.equal(warnings.length, 0);
275
+ } finally {
276
+ console.warn = original;
277
+ }
278
+ });
279
+ });
280
+
281
+ describe("adjustToolSet — Groq 128-tool cap", () => {
282
+ beforeEach(() => {
283
+ resetToolCompatibilityRegistry();
284
+ });
285
+
286
+ test("caps to 128 tools when provider is groq and >128 tools active", () => {
287
+ const toolNames = Array.from({ length: 150 }, (_, i) => `tool_${i}`);
288
+ const { toolNames: result, removedTools } = adjustToolSet(toolNames, "openai-completions", "groq");
289
+ assert.equal(result.length, 128);
290
+ assert.equal(removedTools.length, 22);
291
+ });
292
+
293
+ test("does not cap for non-groq providers even with >128 tools", () => {
294
+ const toolNames = Array.from({ length: 150 }, (_, i) => `tool_${i}`);
295
+ const { toolNames: result, removedTools } = adjustToolSet(toolNames, "openai-completions", "openai");
296
+ assert.equal(result.length, 150);
297
+ assert.equal(removedTools.length, 0);
298
+ });
299
+
300
+ test("does not cap when provider is omitted", () => {
301
+ const toolNames = Array.from({ length: 150 }, (_, i) => `tool_${i}`);
302
+ const { toolNames: result, removedTools } = adjustToolSet(toolNames, "openai-completions");
303
+ assert.equal(result.length, 150);
304
+ assert.equal(removedTools.length, 0);
305
+ });
306
+ });
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync, unlinkSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { randomUUID } from "node:crypto";
@@ -11,7 +11,7 @@ import {
11
11
  _getAdapter,
12
12
  insertGateRow,
13
13
  } from "../gsd-db.ts";
14
- import { markDepthVerified, clearDiscussionFlowState } from "../bootstrap/write-gate.ts";
14
+ import { markDepthVerified, clearDiscussionFlowState, loadWriteGateSnapshot } from "../bootstrap/write-gate.ts";
15
15
  import {
16
16
  executeCompleteMilestone,
17
17
  executePlanMilestone,
@@ -742,3 +742,66 @@ test("executeSummarySave leaves sibling CONTEXT-DRAFT intact for non-CONTEXT art
742
742
  cleanup(base);
743
743
  }
744
744
  });
745
+
746
+ test("executeSummarySave CONTEXT HARD BLOCK clears after write-gate state file is deleted (#4343)", async () => {
747
+ const base = makeTmpBase();
748
+ const originalEnv = process.env.GSD_PERSIST_WRITE_GATE_STATE;
749
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = "1";
750
+ try {
751
+ openTestDb(base);
752
+ clearDiscussionFlowState();
753
+
754
+ // First call: CONTEXT artifact without depth verification → HARD BLOCK
755
+ const blocked = await inProjectDir(base, () => executeSummarySave({
756
+ milestone_id: "M001",
757
+ artifact_type: "CONTEXT",
758
+ content: "# Context\n\ncontent",
759
+ }, base));
760
+ assert.equal(blocked.isError, true, "should be blocked without depth verification");
761
+ assert.match(
762
+ blocked.content[0].text,
763
+ /HARD BLOCK/,
764
+ "blocked result should mention HARD BLOCK",
765
+ );
766
+
767
+ // Verify the state file was written (persist mode is active)
768
+ const stateFilePath = join(base, ".gsd", "runtime", "write-gate-state.json");
769
+ // The state file may or may not exist at this point (block doesn't write state).
770
+ // Write a fake state file simulating stale persisted block state.
771
+ mkdirSync(join(base, ".gsd", "runtime"), { recursive: true });
772
+ writeFileSync(stateFilePath, JSON.stringify({
773
+ verifiedDepthMilestones: [],
774
+ activeQueuePhase: false,
775
+ pendingGateId: "depth_verification_M001",
776
+ }));
777
+
778
+ // User deletes the state file to reset the block
779
+ unlinkSync(stateFilePath);
780
+ assert.ok(!existsSync(stateFilePath), "state file deleted");
781
+
782
+ // The snapshot loaded after deletion should be clean (no pending gate, no block)
783
+ const snapshot = loadWriteGateSnapshot(base);
784
+ assert.equal(snapshot.pendingGateId, null, "pendingGateId should be null after file deletion");
785
+ assert.deepEqual(snapshot.verifiedDepthMilestones, [], "verifiedDepthMilestones should be empty after file deletion");
786
+
787
+ // Depth-verify and re-attempt: should succeed after deletion clears stale state
788
+ markDepthVerified("M001", base);
789
+
790
+ const unblocked = await inProjectDir(base, () => executeSummarySave({
791
+ milestone_id: "M001",
792
+ artifact_type: "CONTEXT",
793
+ content: "# Context\n\nfinal content",
794
+ }, base));
795
+ assert.equal(unblocked.isError, undefined, "should not be blocked after depth verification");
796
+ assert.equal(unblocked.details.operation, "save_summary");
797
+ } finally {
798
+ if (originalEnv === undefined) {
799
+ delete process.env.GSD_PERSIST_WRITE_GATE_STATE;
800
+ } else {
801
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = originalEnv;
802
+ }
803
+ clearDiscussionFlowState();
804
+ closeDatabase();
805
+ cleanup(base);
806
+ }
807
+ });
@@ -11,6 +11,10 @@
11
11
 
12
12
  import test from 'node:test';
13
13
  import assert from 'node:assert/strict';
14
+ import { mkdirSync, writeFileSync, unlinkSync, existsSync, rmSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { tmpdir } from 'node:os';
17
+ import { randomUUID } from 'node:crypto';
14
18
  import {
15
19
  isDepthConfirmationAnswer,
16
20
  shouldBlockContextWrite,
@@ -20,8 +24,10 @@ import {
20
24
  markDepthVerified,
21
25
  isMilestoneDepthVerified,
22
26
  shouldBlockContextArtifactSave,
27
+ shouldBlockContextArtifactSaveInSnapshot,
23
28
  clearDiscussionFlowState,
24
29
  resetWriteGateState,
30
+ loadWriteGateSnapshot,
25
31
  } from '../bootstrap/write-gate.ts';
26
32
 
27
33
  // ─── Scenario 1: Blocks CONTEXT.md write during discussion without depth verification (absolute path) ──
@@ -488,3 +494,61 @@ test('write-gate: isDepthConfirmationAnswer falls back to (Recommended) match wi
488
494
  'should reject non-Recommended via fallback',
489
495
  );
490
496
  });
497
+
498
+ // ─── Scenario 29: loadWriteGateSnapshot returns clean state when persist file deleted (#4343) ──
499
+
500
+ test('write-gate: loadWriteGateSnapshot returns empty default when persist file is deleted (#4343)', () => {
501
+ const base = join(tmpdir(), `gsd-write-gate-4343-${randomUUID()}`);
502
+ mkdirSync(join(base, '.gsd', 'runtime'), { recursive: true });
503
+ const stateFilePath = join(base, '.gsd', 'runtime', 'write-gate-state.json');
504
+ const originalEnv = process.env.GSD_PERSIST_WRITE_GATE_STATE;
505
+
506
+ try {
507
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = '1';
508
+
509
+ // Write a state file with a pending gate and verified milestone
510
+ writeFileSync(stateFilePath, JSON.stringify({
511
+ verifiedDepthMilestones: ['M001'],
512
+ activeQueuePhase: false,
513
+ pendingGateId: 'depth_verification_M001',
514
+ }));
515
+ assert.ok(existsSync(stateFilePath), 'precondition: state file exists');
516
+
517
+ // While file exists, snapshot reflects its contents
518
+ const beforeDeletion = loadWriteGateSnapshot(base);
519
+ assert.strictEqual(beforeDeletion.pendingGateId, 'depth_verification_M001', 'pending gate from file');
520
+ assert.deepEqual(beforeDeletion.verifiedDepthMilestones, ['M001'], 'verified milestones from file');
521
+
522
+ // User deletes the state file to clear the HARD BLOCK
523
+ unlinkSync(stateFilePath);
524
+ assert.ok(!existsSync(stateFilePath), 'state file deleted');
525
+
526
+ // After deletion in persist mode, snapshot should be clean (not stale in-memory)
527
+ const afterDeletion = loadWriteGateSnapshot(base);
528
+ assert.strictEqual(afterDeletion.pendingGateId, null, 'pendingGateId cleared after file deletion');
529
+ assert.deepEqual(afterDeletion.verifiedDepthMilestones, [], 'verifiedDepthMilestones cleared after file deletion');
530
+ assert.strictEqual(afterDeletion.activeQueuePhase, false, 'activeQueuePhase cleared after file deletion');
531
+
532
+ // The CONTEXT artifact block check must also resolve to unblocked after deletion+verification
533
+ // (simulate the re-verify flow users would do: delete → depth verify → save)
534
+ const stillBlocked = shouldBlockContextArtifactSaveInSnapshot(afterDeletion, 'CONTEXT', 'M001', null);
535
+ assert.strictEqual(stillBlocked.block, true, 'still blocked without new depth verification');
536
+
537
+ const verifiedSnapshot = {
538
+ ...afterDeletion,
539
+ verifiedDepthMilestones: ['M001'],
540
+ };
541
+ const unblocked = shouldBlockContextArtifactSaveInSnapshot(verifiedSnapshot, 'CONTEXT', 'M001', null);
542
+ assert.strictEqual(unblocked.block, false, 'unblocked after fresh depth verification');
543
+ } finally {
544
+ if (originalEnv === undefined) {
545
+ delete process.env.GSD_PERSIST_WRITE_GATE_STATE;
546
+ } else {
547
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = originalEnv;
548
+ }
549
+ clearDiscussionFlowState();
550
+ try {
551
+ rmSync(base, { recursive: true, force: true });
552
+ } catch { /* swallow */ }
553
+ }
554
+ });
@@ -72,7 +72,9 @@ const autoStartSrc = readFileSync(
72
72
  const symlinkIdx = autoStartSrc.indexOf("ensureGsdSymlink(base)");
73
73
  assertTrue(symlinkIdx >= 0, "auto-start.ts calls ensureGsdSymlink(base)");
74
74
 
75
- const afterSymlink = symlinkIdx >= 0 ? autoStartSrc.slice(symlinkIdx, symlinkIdx + 800) : "";
75
+ const afterSymlink = symlinkIdx >= 0
76
+ ? autoStartSrc.slice(symlinkIdx, autoStartSrc.indexOf("Initialize GitServiceImpl", symlinkIdx))
77
+ : "";
76
78
 
77
79
  // The milestones bootstrap must check milestones path, not gsdDir
78
80
  // Old (dead) code: if (!existsSync(gsdDir)) { mkdirSync(join(gsdDir, "milestones"), ...) }
@@ -22,7 +22,11 @@ async function getEncoder(): Promise<TokenEncoder | null> {
22
22
  try {
23
23
  // @ts-ignore — tiktoken may not have type declarations in extensions tsconfig
24
24
  const tiktoken = await import("tiktoken");
25
- encoder = tiktoken.encoding_for_model("gpt-4o") as TokenEncoder;
25
+ // Use cl100k_base the most conservative and broadly compatible BPE encoding.
26
+ // It is shared by GPT-3.5/GPT-4 and gives a safer (larger) estimate than
27
+ // gpt-4o's o200k_base encoding, which produces fewer tokens for the same text
28
+ // and would cause context windows for non-OpenAI providers to be under-counted.
29
+ encoder = tiktoken.get_encoding("cl100k_base") as TokenEncoder;
26
30
  return encoder;
27
31
  } catch {
28
32
  encoderFailed = true;
@@ -30,20 +34,33 @@ async function getEncoder(): Promise<TokenEncoder | null> {
30
34
  }
31
35
  }
32
36
 
33
- export async function countTokens(text: string): Promise<number> {
37
+ /**
38
+ * Count tokens in `text` using tiktoken (cl100k_base) when available.
39
+ *
40
+ * When tiktoken is not loaded, falls back to a provider-aware character-ratio
41
+ * estimate via `estimateTokensForProvider`. Passing `provider` is recommended
42
+ * so the heuristic fallback is as accurate as possible.
43
+ */
44
+ export async function countTokens(text: string, provider?: TokenProvider): Promise<number> {
34
45
  const enc = await getEncoder();
35
46
  if (enc) {
36
47
  const tokens = enc.encode(text);
37
48
  return tokens.length;
38
49
  }
39
- return Math.ceil(text.length / 4);
50
+ return estimateTokensForProvider(text, provider ?? "unknown");
40
51
  }
41
52
 
42
- export function countTokensSync(text: string): number {
53
+ /**
54
+ * Synchronous token count — only accurate after `initTokenCounter()` resolves.
55
+ *
56
+ * Before init, or when tiktoken is unavailable, falls back to a provider-aware
57
+ * character-ratio estimate. Passing `provider` is recommended.
58
+ */
59
+ export function countTokensSync(text: string, provider?: TokenProvider): number {
43
60
  if (encoder) {
44
61
  return encoder.encode(text).length;
45
62
  }
46
- return Math.ceil(text.length / 4);
63
+ return estimateTokensForProvider(text, provider ?? "unknown");
47
64
  }
48
65
 
49
66
  export async function initTokenCounter(): Promise<boolean> {
@@ -0,0 +1,81 @@
1
+ // GSD Exec Search Tool — lists and filters prior gsd_exec runs.
2
+ //
3
+ // Scans .gsd/exec/*.meta.json and returns a ranked summary so agents can
4
+ // re-discover past runs without re-executing. Read-only; no DB writes.
5
+
6
+ import { searchExecHistory, type ExecSearchOptions } from "../exec-history.js";
7
+
8
+ export interface ExecSearchToolParams {
9
+ query?: string;
10
+ runtime?: "bash" | "node" | "python";
11
+ failing_only?: boolean;
12
+ limit?: number;
13
+ }
14
+
15
+ export interface ToolExecutionResult {
16
+ content: Array<{ type: "text"; text: string }>;
17
+ details: Record<string, unknown>;
18
+ isError?: boolean;
19
+ }
20
+
21
+ export function executeExecSearch(
22
+ params: ExecSearchToolParams,
23
+ opts: { baseDir: string },
24
+ ): ToolExecutionResult {
25
+ const searchOpts: ExecSearchOptions = {
26
+ query: typeof params.query === "string" ? params.query : undefined,
27
+ runtime: params.runtime,
28
+ failing_only: params.failing_only === true,
29
+ limit: typeof params.limit === "number" ? params.limit : undefined,
30
+ };
31
+ const hits = searchExecHistory(opts.baseDir, searchOpts);
32
+
33
+ if (hits.length === 0) {
34
+ return {
35
+ content: [{ type: "text", text: "No prior gsd_exec runs match those filters." }],
36
+ details: { operation: "gsd_exec_search", matches: 0 },
37
+ };
38
+ }
39
+
40
+ const lines: string[] = [`Found ${hits.length} exec run(s), most recent first:`];
41
+ for (const hit of hits) {
42
+ const e = hit.entry;
43
+ const status = formatStatus(e);
44
+ const purpose = e.purpose ? ` — ${e.purpose}` : "";
45
+ const truncated = e.stdout_truncated ? " (stdout truncated)" : "";
46
+ lines.push(
47
+ `- [${e.id}] ${e.runtime} ${status} ${e.duration_ms}ms${truncated}${purpose}`,
48
+ ` stdout: ${e.stdout_path}`,
49
+ );
50
+ if (hit.digest_preview) {
51
+ const preview = hit.digest_preview.replace(/\n/g, "\n ");
52
+ lines.push(` preview:\n ${preview}`);
53
+ }
54
+ }
55
+
56
+ return {
57
+ content: [{ type: "text", text: lines.join("\n") }],
58
+ details: {
59
+ operation: "gsd_exec_search",
60
+ matches: hits.length,
61
+ results: hits.map((hit) => ({
62
+ id: hit.entry.id,
63
+ runtime: hit.entry.runtime,
64
+ exit_code: hit.entry.exit_code,
65
+ timed_out: hit.entry.timed_out,
66
+ duration_ms: hit.entry.duration_ms,
67
+ purpose: hit.entry.purpose,
68
+ stdout_path: hit.entry.stdout_path,
69
+ stderr_path: hit.entry.stderr_path,
70
+ meta_path: hit.entry.meta_path,
71
+ })),
72
+ },
73
+ };
74
+ }
75
+
76
+ function formatStatus(entry: { exit_code: number | null; timed_out: boolean; signal: string | null }): string {
77
+ if (entry.timed_out) return "timeout";
78
+ if (entry.signal) return `signal:${entry.signal}`;
79
+ if (entry.exit_code === null) return "exit:null";
80
+ return `exit:${entry.exit_code}`;
81
+ }