gsd-pi 2.42.0-dev.97e9e30 → 2.42.0-dev.eedc83f

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 (167) hide show
  1. package/README.md +23 -0
  2. package/dist/cli.js +15 -1
  3. package/dist/resource-loader.js +39 -6
  4. package/dist/resources/extensions/async-jobs/async-bash-tool.js +52 -4
  5. package/dist/resources/extensions/gsd/auto-prompts.js +1 -1
  6. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +11 -5
  7. package/dist/resources/extensions/gsd/detection.js +19 -0
  8. package/dist/resources/extensions/gsd/doctor-checks.js +31 -1
  9. package/dist/resources/extensions/gsd/doctor-providers.js +10 -0
  10. package/dist/resources/extensions/gsd/forensics.js +84 -0
  11. package/dist/resources/extensions/gsd/git-constants.js +1 -0
  12. package/dist/resources/extensions/gsd/git-service.js +68 -2
  13. package/dist/resources/extensions/gsd/native-git-bridge.js +1 -0
  14. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  15. package/dist/resources/extensions/gsd/preferences.js +59 -8
  16. package/dist/resources/extensions/gsd/prompts/forensics.md +12 -5
  17. package/dist/resources/extensions/gsd/repo-identity.js +46 -5
  18. package/dist/resources/extensions/gsd/service-tier.js +13 -4
  19. package/dist/resources/extensions/gsd/session-lock.js +2 -2
  20. package/dist/resources/extensions/gsd/worktree-resolver.js +2 -2
  21. package/dist/resources/extensions/mcp-client/index.js +2 -1
  22. package/dist/resources/extensions/search-the-web/tool-search.js +3 -3
  23. package/dist/web/standalone/.next/BUILD_ID +1 -1
  24. package/dist/web/standalone/.next/app-path-routes-manifest.json +12 -12
  25. package/dist/web/standalone/.next/build-manifest.json +2 -2
  26. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  27. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  28. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/api/git/route.js +1 -1
  44. package/dist/web/standalone/.next/server/app/index.html +1 -1
  45. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app-paths-manifest.json +12 -12
  52. package/dist/web/standalone/.next/server/chunks/229.js +2 -2
  53. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  54. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  55. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  56. package/dist/web-mode.d.ts +2 -0
  57. package/dist/web-mode.js +40 -4
  58. package/package.json +1 -1
  59. package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
  60. package/packages/pi-agent-core/dist/agent.js +2 -0
  61. package/packages/pi-agent-core/dist/agent.js.map +1 -1
  62. package/packages/pi-agent-core/dist/types.d.ts +6 -0
  63. package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
  64. package/packages/pi-agent-core/dist/types.js.map +1 -1
  65. package/packages/pi-agent-core/src/agent.test.ts +53 -0
  66. package/packages/pi-agent-core/src/agent.ts +3 -0
  67. package/packages/pi-agent-core/src/types.ts +6 -0
  68. package/packages/pi-agent-core/tsconfig.json +1 -1
  69. package/packages/pi-ai/dist/models.d.ts +5 -3
  70. package/packages/pi-ai/dist/models.d.ts.map +1 -1
  71. package/packages/pi-ai/dist/models.generated.d.ts +801 -1468
  72. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  73. package/packages/pi-ai/dist/models.generated.js +1135 -1588
  74. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  75. package/packages/pi-ai/dist/models.js.map +1 -1
  76. package/packages/pi-ai/dist/utils/oauth/github-copilot.d.ts.map +1 -1
  77. package/packages/pi-ai/dist/utils/oauth/github-copilot.js +60 -2
  78. package/packages/pi-ai/dist/utils/oauth/github-copilot.js.map +1 -1
  79. package/packages/pi-ai/scripts/generate-models.ts +1543 -0
  80. package/packages/pi-ai/src/models.generated.ts +1140 -1593
  81. package/packages/pi-ai/src/models.ts +7 -4
  82. package/packages/pi-ai/src/utils/oauth/github-copilot.ts +74 -2
  83. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  84. package/packages/pi-coding-agent/dist/core/agent-session.js +8 -1
  85. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  86. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts +7 -0
  87. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
  88. package/packages/pi-coding-agent/dist/core/auth-storage.js +29 -2
  89. package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  90. package/packages/pi-coding-agent/dist/core/auth-storage.test.js +60 -0
  91. package/packages/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -1
  92. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/extensions/loader.js +18 -0
  94. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  95. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  96. package/packages/pi-coding-agent/dist/core/lsp/client.js +23 -0
  97. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  98. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  99. package/packages/pi-coding-agent/dist/core/model-registry.js +2 -0
  100. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  101. package/packages/pi-coding-agent/dist/core/package-manager.d.ts +6 -0
  102. package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
  103. package/packages/pi-coding-agent/dist/core/package-manager.js +63 -11
  104. package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
  105. package/packages/pi-coding-agent/dist/core/resource-loader.d.ts +9 -0
  106. package/packages/pi-coding-agent/dist/core/resource-loader.d.ts.map +1 -1
  107. package/packages/pi-coding-agent/dist/core/resource-loader.js +20 -6
  108. package/packages/pi-coding-agent/dist/core/resource-loader.js.map +1 -1
  109. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  110. package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -5
  111. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  112. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
  113. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-editor.js +3 -0
  114. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-editor.js.map +1 -1
  115. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js +9 -6
  117. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js.map +1 -1
  118. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  119. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +30 -10
  120. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  121. package/packages/pi-coding-agent/src/core/agent-session.ts +7 -1
  122. package/packages/pi-coding-agent/src/core/auth-storage.test.ts +68 -0
  123. package/packages/pi-coding-agent/src/core/auth-storage.ts +30 -2
  124. package/packages/pi-coding-agent/src/core/extensions/loader.ts +18 -0
  125. package/packages/pi-coding-agent/src/core/lsp/client.ts +29 -0
  126. package/packages/pi-coding-agent/src/core/model-registry.ts +3 -0
  127. package/packages/pi-coding-agent/src/core/package-manager.ts +99 -58
  128. package/packages/pi-coding-agent/src/core/resource-loader.ts +24 -6
  129. package/packages/pi-coding-agent/src/core/system-prompt.ts +6 -5
  130. package/packages/pi-coding-agent/src/modes/interactive/components/extension-editor.ts +3 -0
  131. package/packages/pi-coding-agent/src/modes/interactive/components/footer.ts +10 -6
  132. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +31 -11
  133. package/src/resources/extensions/async-jobs/async-bash-timeout.test.ts +122 -0
  134. package/src/resources/extensions/async-jobs/async-bash-tool.ts +40 -4
  135. package/src/resources/extensions/gsd/auto-prompts.ts +1 -1
  136. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +13 -5
  137. package/src/resources/extensions/gsd/detection.ts +19 -0
  138. package/src/resources/extensions/gsd/doctor-checks.ts +32 -1
  139. package/src/resources/extensions/gsd/doctor-providers.ts +13 -0
  140. package/src/resources/extensions/gsd/doctor-types.ts +1 -0
  141. package/src/resources/extensions/gsd/forensics.ts +92 -0
  142. package/src/resources/extensions/gsd/git-constants.ts +1 -0
  143. package/src/resources/extensions/gsd/git-service.ts +71 -2
  144. package/src/resources/extensions/gsd/native-git-bridge.ts +1 -0
  145. package/src/resources/extensions/gsd/preferences-types.ts +3 -0
  146. package/src/resources/extensions/gsd/preferences.ts +62 -6
  147. package/src/resources/extensions/gsd/prompts/forensics.md +12 -5
  148. package/src/resources/extensions/gsd/repo-identity.ts +48 -5
  149. package/src/resources/extensions/gsd/service-tier.ts +17 -4
  150. package/src/resources/extensions/gsd/session-lock.ts +2 -2
  151. package/src/resources/extensions/gsd/tests/activity-log.test.ts +31 -69
  152. package/src/resources/extensions/gsd/tests/forensics-dedup.test.ts +48 -0
  153. package/src/resources/extensions/gsd/tests/forensics-issue-routing.test.ts +43 -0
  154. package/src/resources/extensions/gsd/tests/git-locale.test.ts +133 -0
  155. package/src/resources/extensions/gsd/tests/git-service.test.ts +49 -0
  156. package/src/resources/extensions/gsd/tests/journal.test.ts +82 -127
  157. package/src/resources/extensions/gsd/tests/manifest-status.test.ts +73 -82
  158. package/src/resources/extensions/gsd/tests/service-tier.test.ts +30 -1
  159. package/src/resources/extensions/gsd/tests/symlink-numbered-variants.test.ts +151 -0
  160. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +156 -263
  161. package/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts +35 -78
  162. package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +81 -74
  163. package/src/resources/extensions/gsd/worktree-resolver.ts +2 -2
  164. package/src/resources/extensions/mcp-client/index.ts +5 -1
  165. package/src/resources/extensions/search-the-web/tool-search.ts +3 -3
  166. /package/dist/web/standalone/.next/static/{PXrI5DoWsm7rwAVnEU2rD → JUBX5FUR73jiViQU5a-Cx}/_buildManifest.js +0 -0
  167. /package/dist/web/standalone/.next/static/{PXrI5DoWsm7rwAVnEU2rD → JUBX5FUR73jiViQU5a-Cx}/_ssgManifest.js +0 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * async-bash-timeout.test.ts — Tests for async_bash timeout behavior.
3
+ *
4
+ * Reproduces issue #2186: when an async bash job exceeds its timeout and
5
+ * the child process ignores SIGTERM, the promise hangs indefinitely.
6
+ * The fix adds a SIGKILL fallback and a hard deadline that force-resolves
7
+ * the promise so execution can continue.
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { createAsyncBashTool } from "./async-bash-tool.ts";
13
+ import { AsyncJobManager } from "./job-manager.ts";
14
+
15
+ function getTextFromResult(result: { content: Array<{ type: string; text?: string }> }): string {
16
+ return result.content.map((c) => c.text ?? "").join("\n");
17
+ }
18
+
19
+ const noopSignal = new AbortController().signal;
20
+
21
+ test("async_bash with timeout resolves even if process ignores SIGTERM", async () => {
22
+ const manager = new AsyncJobManager();
23
+ const tool = createAsyncBashTool(() => manager, () => process.cwd());
24
+
25
+ // Start a job that traps SIGTERM (ignores it), with a 2s timeout.
26
+ // The process installs a SIGTERM trap and sleeps for 60s.
27
+ // Before the fix, this would hang forever because SIGTERM is ignored
28
+ // and the close event never fires.
29
+ const result = await tool.execute(
30
+ "tc-timeout",
31
+ {
32
+ command: "trap '' TERM; sleep 60",
33
+ timeout: 2,
34
+ label: "sigterm-resistant",
35
+ },
36
+ noopSignal,
37
+ () => {},
38
+ undefined as never,
39
+ );
40
+
41
+ const text = getTextFromResult(result);
42
+ assert.match(text, /sigterm-resistant/);
43
+
44
+ const jobId = text.match(/\*\*(bg_[a-f0-9]+)\*\*/)?.[1];
45
+ assert.ok(jobId, "Should have returned a job ID");
46
+
47
+ // Now await the job — it should resolve within a reasonable time
48
+ // (timeout 2s + SIGKILL grace 5s + buffer = well under 15s)
49
+ const start = Date.now();
50
+ const job = manager.getJob(jobId)!;
51
+ assert.ok(job, "Job should exist");
52
+
53
+ await Promise.race([
54
+ job.promise,
55
+ new Promise<never>((_, reject) => {
56
+ const t = setTimeout(() => reject(new Error(
57
+ `Job promise hung for ${Date.now() - start}ms — ` +
58
+ `this is the bug from issue #2186: timeout hangs indefinitely`,
59
+ )), 15_000);
60
+ if (typeof t === "object" && "unref" in t) t.unref();
61
+ }),
62
+ ]);
63
+
64
+ const elapsed = Date.now() - start;
65
+ // Should have resolved well within 15s (timeout 2s + kill grace ~5s)
66
+ assert.ok(elapsed < 15_000, `Job took ${elapsed}ms — expected <15s`);
67
+
68
+ // Job should have completed (resolved, not rejected) with timeout message
69
+ assert.ok(
70
+ job.status === "completed" || job.status === "failed",
71
+ `Job status should be completed or failed, got: ${job.status}`,
72
+ );
73
+
74
+ if (job.status === "completed") {
75
+ assert.ok(
76
+ job.resultText?.includes("timed out") || job.resultText?.includes("Timed out"),
77
+ `Result should mention timeout, got: ${job.resultText}`,
78
+ );
79
+ }
80
+
81
+ manager.shutdown();
82
+ });
83
+
84
+ test("async_bash with timeout resolves normally when process exits on SIGTERM", async () => {
85
+ const manager = new AsyncJobManager();
86
+ const tool = createAsyncBashTool(() => manager, () => process.cwd());
87
+
88
+ // Start a normal sleep that will die on SIGTERM, with a 1s timeout
89
+ const result = await tool.execute(
90
+ "tc-normal-timeout",
91
+ {
92
+ command: "sleep 60",
93
+ timeout: 1,
94
+ label: "normal-timeout",
95
+ },
96
+ noopSignal,
97
+ () => {},
98
+ undefined as never,
99
+ );
100
+
101
+ const text = getTextFromResult(result);
102
+ const jobId = text.match(/\*\*(bg_[a-f0-9]+)\*\*/)?.[1];
103
+ assert.ok(jobId, "Should have returned a job ID");
104
+
105
+ const job = manager.getJob(jobId)!;
106
+ const start = Date.now();
107
+
108
+ await Promise.race([
109
+ job.promise,
110
+ new Promise<never>((_, reject) => {
111
+ const t = setTimeout(() => reject(new Error("Job hung")), 10_000);
112
+ if (typeof t === "object" && "unref" in t) t.unref();
113
+ }),
114
+ ]);
115
+
116
+ const elapsed = Date.now() - start;
117
+ assert.ok(elapsed < 5_000, `Expected quick resolution after SIGTERM, took ${elapsed}ms`);
118
+ assert.equal(job.status, "completed");
119
+ assert.ok(job.resultText?.includes("timed out"), `Should mention timeout: ${job.resultText}`);
120
+
121
+ manager.shutdown();
122
+ });
@@ -109,6 +109,10 @@ function executeBashInBackground(
109
109
  timeout?: number,
110
110
  ): Promise<string> {
111
111
  return new Promise<string>((resolve, reject) => {
112
+ let settled = false;
113
+ const safeResolve = (value: string) => { if (!settled) { settled = true; resolve(value); } };
114
+ const safeReject = (err: unknown) => { if (!settled) { settled = true; reject(err); } };
115
+
112
116
  const { shell, args } = getShellConfig();
113
117
  const resolvedCommand = sanitizeCommand(command);
114
118
 
@@ -121,11 +125,39 @@ function executeBashInBackground(
121
125
 
122
126
  let timedOut = false;
123
127
  let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
128
+ let sigkillHandle: ReturnType<typeof setTimeout> | undefined;
129
+ let hardDeadlineHandle: ReturnType<typeof setTimeout> | undefined;
130
+
131
+ /** Grace period (ms) between SIGTERM and SIGKILL. */
132
+ const SIGKILL_GRACE_MS = 5_000;
133
+ /** Hard deadline (ms) after SIGKILL to force-resolve the promise. */
134
+ const HARD_DEADLINE_MS = 3_000;
124
135
 
125
136
  if (timeout !== undefined && timeout > 0) {
126
137
  timeoutHandle = setTimeout(() => {
127
138
  timedOut = true;
128
139
  if (child.pid) killTree(child.pid);
140
+
141
+ // If the process ignores SIGTERM, escalate to SIGKILL
142
+ sigkillHandle = setTimeout(() => {
143
+ if (child.pid) {
144
+ try { process.kill(-child.pid, "SIGKILL"); } catch { /* ignore */ }
145
+ try { process.kill(child.pid, "SIGKILL"); } catch { /* ignore */ }
146
+ }
147
+
148
+ // Hard deadline: if even SIGKILL doesn't trigger 'close',
149
+ // force-resolve so the job doesn't hang forever (#2186).
150
+ hardDeadlineHandle = setTimeout(() => {
151
+ const output = Buffer.concat(chunks).toString("utf-8");
152
+ safeResolve(
153
+ output
154
+ ? `${output}\n\nCommand timed out after ${timeout} seconds (force-killed)`
155
+ : `Command timed out after ${timeout} seconds (force-killed)`,
156
+ );
157
+ }, HARD_DEADLINE_MS);
158
+ if (typeof hardDeadlineHandle === "object" && "unref" in hardDeadlineHandle) hardDeadlineHandle.unref();
159
+ }, SIGKILL_GRACE_MS);
160
+ if (typeof sigkillHandle === "object" && "unref" in sigkillHandle) sigkillHandle.unref();
129
161
  }, timeout * 1000);
130
162
  }
131
163
 
@@ -168,24 +200,28 @@ function executeBashInBackground(
168
200
 
169
201
  child.on("error", (err) => {
170
202
  if (timeoutHandle) clearTimeout(timeoutHandle);
203
+ if (sigkillHandle) clearTimeout(sigkillHandle);
204
+ if (hardDeadlineHandle) clearTimeout(hardDeadlineHandle);
171
205
  signal.removeEventListener("abort", onAbort);
172
- reject(err);
206
+ safeReject(err);
173
207
  });
174
208
 
175
209
  child.on("close", (code) => {
176
210
  if (timeoutHandle) clearTimeout(timeoutHandle);
211
+ if (sigkillHandle) clearTimeout(sigkillHandle);
212
+ if (hardDeadlineHandle) clearTimeout(hardDeadlineHandle);
177
213
  signal.removeEventListener("abort", onAbort);
178
214
  if (spillStream) spillStream.end();
179
215
 
180
216
  if (signal.aborted) {
181
217
  const output = Buffer.concat(chunks).toString("utf-8");
182
- resolve(output ? `${output}\n\nCommand aborted` : "Command aborted");
218
+ safeResolve(output ? `${output}\n\nCommand aborted` : "Command aborted");
183
219
  return;
184
220
  }
185
221
 
186
222
  if (timedOut) {
187
223
  const output = Buffer.concat(chunks).toString("utf-8");
188
- resolve(output ? `${output}\n\nCommand timed out after ${timeout} seconds` : `Command timed out after ${timeout} seconds`);
224
+ safeResolve(output ? `${output}\n\nCommand timed out after ${timeout} seconds` : `Command timed out after ${timeout} seconds`);
189
225
  return;
190
226
  }
191
227
 
@@ -208,7 +244,7 @@ function executeBashInBackground(
208
244
  text += `\n\nCommand exited with code ${code}`;
209
245
  }
210
246
 
211
- resolve(text);
247
+ safeResolve(text);
212
248
  });
213
249
  });
214
250
  }
@@ -986,7 +986,7 @@ export async function buildPlanSlicePrompt(
986
986
  const prefs = loadEffectiveGSDPreferences();
987
987
  const commitDocsEnabled = prefs?.preferences?.git?.commit_docs !== false;
988
988
  const commitInstruction = commitDocsEnabled
989
- ? `Commit the plan files only: \`git add ${relSlicePath(base, mid, sid)}/ .gsd/DECISIONS.md .gitignore && git commit -m "docs(${sid}): add slice plan"\`. Do not stage .gsd/STATE.md or other runtime files — the system manages those.`
989
+ ? `Commit the plan files only: \`git add --force ${relSlicePath(base, mid, sid)}/ .gsd/DECISIONS.md .gitignore && git commit -m "docs(${sid}): add slice plan"\`. Do not stage .gsd/STATE.md or other runtime files — the system manages those.`
990
990
  : "Do not commit — planning docs are not tracked in git for this project.";
991
991
  return loadPrompt("plan-slice", {
992
992
  workingDirectory: base,
@@ -20,21 +20,27 @@ import { saveActivityLog } from "../activity-log.js";
20
20
  // printed it before the TUI launched. Only re-print on /clear (subsequent sessions).
21
21
  let isFirstSession = true;
22
22
 
23
+ async function syncServiceTierStatus(ctx: ExtensionContext): Promise<void> {
24
+ const { getEffectiveServiceTier, formatServiceTierFooterStatus } = await import("../service-tier.js");
25
+ ctx.ui.setStatus("gsd-fast", formatServiceTierFooterStatus(getEffectiveServiceTier(), ctx.model?.id));
26
+ }
27
+
23
28
  export function registerHooks(pi: ExtensionAPI): void {
24
29
  pi.on("session_start", async (_event, ctx) => {
25
30
  resetWriteGateState();
26
31
  resetToolCallLoopGuard();
32
+ await syncServiceTierStatus(ctx);
27
33
  if (isFirstSession) {
28
34
  isFirstSession = false;
29
35
  } else {
30
36
  try {
31
37
  const gsdBinPath = process.env.GSD_BIN_PATH;
32
38
  if (gsdBinPath) {
33
- const { dirname } = await import('node:path');
39
+ const { dirname } = await import("node:path");
34
40
  const { printWelcomeScreen } = await import(
35
- join(dirname(gsdBinPath), 'welcome-screen.js')
41
+ join(dirname(gsdBinPath), "welcome-screen.js")
36
42
  ) as { printWelcomeScreen: (opts: { version: string; modelName?: string; provider?: string }) => void };
37
- printWelcomeScreen({ version: process.env.GSD_VERSION || '0.0.0' });
43
+ printWelcomeScreen({ version: process.env.GSD_VERSION || "0.0.0" });
38
44
  }
39
45
  } catch { /* non-fatal */ }
40
46
  }
@@ -192,8 +198,11 @@ export function registerHooks(pi: ExtensionAPI): void {
192
198
  markToolEnd(event.toolCallId);
193
199
  });
194
200
 
201
+ pi.on("model_select", async (_event, ctx) => {
202
+ await syncServiceTierStatus(ctx);
203
+ });
204
+
195
205
  pi.on("before_provider_request", async (event) => {
196
- if (!isAutoActive()) return;
197
206
  const modelId = event.model?.id;
198
207
  if (!modelId) return;
199
208
  const { getEffectiveServiceTier, supportsServiceTier } = await import("../service-tier.js");
@@ -205,4 +214,3 @@ export function registerHooks(pi: ExtensionAPI): void {
205
214
  return payload;
206
215
  });
207
216
  }
208
-
@@ -87,6 +87,18 @@ export const PROJECT_FILES = [
87
87
  "mix.exs",
88
88
  "deno.json",
89
89
  "deno.jsonc",
90
+ // .NET
91
+ ".sln",
92
+ ".csproj",
93
+ "Directory.Build.props",
94
+ // Git submodules
95
+ ".gitmodules",
96
+ // Xcode
97
+ "project.yml",
98
+ ".xcodeproj",
99
+ ".xcworkspace",
100
+ // Docker
101
+ "Dockerfile",
90
102
  ] as const;
91
103
 
92
104
  const LANGUAGE_MAP: Record<string, string> = {
@@ -106,6 +118,13 @@ const LANGUAGE_MAP: Record<string, string> = {
106
118
  "mix.exs": "elixir",
107
119
  "deno.json": "typescript/deno",
108
120
  "deno.jsonc": "typescript/deno",
121
+ ".sln": "dotnet",
122
+ ".csproj": "dotnet",
123
+ "Directory.Build.props": "dotnet",
124
+ "project.yml": "swift/xcode",
125
+ ".xcodeproj": "swift/xcode",
126
+ ".xcworkspace": "swift/xcode",
127
+ "Dockerfile": "docker",
109
128
  };
110
129
 
111
130
  const MONOREPO_MARKERS = [
@@ -2,7 +2,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, rmSync,
2
2
  import { basename, dirname, join, sep } from "node:path";
3
3
 
4
4
  import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
5
- import { readRepoMeta, externalProjectsRoot } from "./repo-identity.js";
5
+ import { readRepoMeta, externalProjectsRoot, cleanNumberedGsdVariants } from "./repo-identity.js";
6
6
  import { loadFile, parseRoadmap } from "./files.js";
7
7
  import { resolveMilestoneFile, milestonesDir, gsdRoot, resolveGsdRootFile, relGsdRootFile } from "./paths.js";
8
8
  import { deriveState, isMilestoneComplete } from "./state.js";
@@ -776,6 +776,37 @@ export async function checkRuntimeHealth(
776
776
  // Non-fatal — external state check failed
777
777
  }
778
778
 
779
+ // ── Numbered .gsd collision variants (#2205) ───────────────────────────
780
+ // macOS APFS can create ".gsd 2", ".gsd 3" etc. when a directory blocks
781
+ // symlink creation. These must be removed so the canonical .gsd is used.
782
+ try {
783
+ const variantPattern = /^\.gsd \d+$/;
784
+ const entries = readdirSync(basePath);
785
+ const variants = entries.filter(e => variantPattern.test(e));
786
+ if (variants.length > 0) {
787
+ for (const v of variants) {
788
+ issues.push({
789
+ severity: "warning",
790
+ code: "numbered_gsd_variant",
791
+ scope: "project",
792
+ unitId: "project",
793
+ message: `Found macOS collision variant "${v}" — this can cause GSD state to appear deleted.`,
794
+ file: v,
795
+ fixable: true,
796
+ });
797
+ }
798
+
799
+ if (shouldFix("numbered_gsd_variant")) {
800
+ const removed = cleanNumberedGsdVariants(basePath);
801
+ for (const name of removed) {
802
+ fixesApplied.push(`removed numbered .gsd variant: ${name}`);
803
+ }
804
+ }
805
+ }
806
+ } catch {
807
+ // Non-fatal — variant check failed
808
+ }
809
+
779
810
  // ── Metrics ledger integrity ───────────────────────────────────────────
780
811
  try {
781
812
  const metricsPath = join(root, "metrics.json");
@@ -305,11 +305,24 @@ function checkOptionalProviders(): ProviderCheckResult[] {
305
305
  const optional = ["brave", "tavily", "jina", "context7"] as const;
306
306
  const results: ProviderCheckResult[] = [];
307
307
 
308
+ // Determine which search providers are configured so we can suppress
309
+ // "not configured" noise for alternative search providers when at least
310
+ // one is already active (e.g. don't warn about missing BRAVE_API_KEY
311
+ // when Tavily is configured).
312
+ const searchProviderIds = ["brave", "tavily"] as const;
313
+ const hasAnySearchProvider = searchProviderIds.some(id => resolveKey(id).found);
314
+
308
315
  for (const providerId of optional) {
309
316
  const info = PROVIDER_REGISTRY.find(p => p.id === providerId);
310
317
  if (!info) continue;
311
318
 
312
319
  const lookup = resolveKey(providerId);
320
+
321
+ // Skip unconfigured search providers when another search provider is active
322
+ if (!lookup.found && hasAnySearchProvider && info.category === "search") {
323
+ continue;
324
+ }
325
+
313
326
  results.push({
314
327
  name: providerId,
315
328
  label: info.label,
@@ -33,6 +33,7 @@ export type DoctorIssueCode =
33
33
  | "unresolvable_dependency"
34
34
  | "failed_migration"
35
35
  | "broken_symlink"
36
+ | "numbered_gsd_variant"
36
37
  // Environment health checks (#1221)
37
38
  | "env_node_version"
38
39
  | "env_dependencies"
@@ -30,6 +30,9 @@ import { loadPrompt } from "./prompt-loader.js";
30
30
  import { gsdRoot } from "./paths.js";
31
31
  import { formatDuration } from "../shared/format-utils.js";
32
32
  import { getAutoWorktreePath } from "./auto-worktree.js";
33
+ import { loadEffectiveGSDPreferences, loadGlobalGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
34
+ import { showNextAction } from "../shared/tui.js";
35
+ import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js";
33
36
 
34
37
  // ─── Types ────────────────────────────────────────────────────────────────────
35
38
 
@@ -67,6 +70,71 @@ interface ForensicReport {
67
70
  recentUnits: { type: string; id: string; cost: number; duration: number; model: string; finishedAt: number }[];
68
71
  }
69
72
 
73
+ // ─── Duplicate Detection ──────────────────────────────────────────────────────
74
+
75
+ const DEDUP_PROMPT_SECTION = `
76
+ ## Duplicate Detection (REQUIRED before issue creation)
77
+
78
+ Before offering to create a GitHub issue, you MUST search for existing issues and PRs that may already address this bug. This step uses the user's AI tokens for analysis.
79
+
80
+ ### Search Steps
81
+
82
+ 1. **Search closed issues** for similar keywords from your diagnosis:
83
+ \`\`\`
84
+ gh issue list --repo gsd-build/gsd-2 --state closed --search "<keywords from root cause>" --limit 20
85
+ \`\`\`
86
+
87
+ 2. **Search open PRs** that might contain the fix:
88
+ \`\`\`
89
+ gh pr list --repo gsd-build/gsd-2 --state open --search "<keywords>" --limit 10
90
+ \`\`\`
91
+
92
+ 3. **Search merged PRs** that may have already fixed this:
93
+ \`\`\`
94
+ gh pr list --repo gsd-build/gsd-2 --state merged --search "<keywords>" --limit 10
95
+ \`\`\`
96
+
97
+ ### Analysis
98
+
99
+ For each result, compare it against your root-cause diagnosis:
100
+ - Does the issue describe the same code path or file?
101
+ - Does the PR modify the same file:line you identified?
102
+ - Is the symptom description semantically similar even if keywords differ?
103
+
104
+ ### Present Findings
105
+
106
+ If you find potential matches, present them to the user:
107
+
108
+ 1. **"Already fixed by PR #X — skip issue creation"** — when a merged PR or closed issue clearly addresses the same root cause. Explain why you believe it matches.
109
+ 2. **"Add my findings to existing issue #Y"** — when an open issue exists for the same bug. Use \`gh issue comment #Y --repo gsd-build/gsd-2\` to add forensic evidence.
110
+ 3. **"Create new issue anyway"** — when existing results do not cover this specific failure.
111
+
112
+ Only proceed to issue creation if no matches were found OR the user explicitly chooses "Create new issue anyway".
113
+ `;
114
+
115
+ async function writeForensicsDedupPref(ctx: ExtensionCommandContext, enabled: boolean): Promise<void> {
116
+ const prefsPath = getGlobalGSDPreferencesPath();
117
+ await ensurePreferencesFile(prefsPath, ctx, "global");
118
+ const existing = loadGlobalGSDPreferences();
119
+ const prefs: Record<string, unknown> = existing?.preferences ? { ...existing.preferences } : {};
120
+ prefs.version = prefs.version || 1;
121
+ prefs.forensics_dedup = enabled;
122
+
123
+ const frontmatter = serializePreferencesToFrontmatter(prefs);
124
+ const raw = existsSync(prefsPath) ? readFileSync(prefsPath, "utf-8") : "";
125
+ let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n";
126
+ const start = raw.startsWith("---\n") ? 4 : raw.startsWith("---\r\n") ? 5 : -1;
127
+ if (start !== -1) {
128
+ const closingIdx = raw.indexOf("\n---", start);
129
+ if (closingIdx !== -1) {
130
+ const after = raw.slice(closingIdx + 4);
131
+ if (after.trim()) body = after;
132
+ }
133
+ }
134
+
135
+ writeFileSync(prefsPath, `---\n${frontmatter}---${body}`, "utf-8");
136
+ }
137
+
70
138
  // ─── Entry Point ──────────────────────────────────────────────────────────────
71
139
 
72
140
  export async function handleForensics(
@@ -98,6 +166,29 @@ export async function handleForensics(
98
166
  return;
99
167
  }
100
168
 
169
+ // ─── Duplicate detection opt-in ─────────────────────────────────────────────
170
+ const effectivePrefs = loadEffectiveGSDPreferences()?.preferences;
171
+ let dedupEnabled = effectivePrefs?.forensics_dedup === true;
172
+
173
+ if (effectivePrefs?.forensics_dedup === undefined) {
174
+ const choice = await showNextAction(ctx, {
175
+ title: "Duplicate detection available",
176
+ summary: ["Before filing a GitHub issue, forensics can search existing issues and PRs to avoid duplicates.", "This uses additional AI tokens for analysis."],
177
+ actions: [
178
+ { id: "enable", label: "Enable duplicate detection", description: "Search issues/PRs before filing (recommended)", recommended: true },
179
+ { id: "skip", label: "Skip for now", description: "File without checking for duplicates" },
180
+ ],
181
+ notYetMessage: "You can enable this later via preferences (forensics_dedup: true).",
182
+ });
183
+
184
+ if (choice === "enable") {
185
+ await writeForensicsDedupPref(ctx, true);
186
+ dedupEnabled = true;
187
+ }
188
+ }
189
+
190
+ const dedupSection = dedupEnabled ? DEDUP_PROMPT_SECTION : "";
191
+
101
192
  ctx.ui.notify("Building forensic report...", "info");
102
193
 
103
194
  const report = await buildForensicReport(basePath);
@@ -117,6 +208,7 @@ export async function handleForensics(
117
208
  problemDescription,
118
209
  forensicData,
119
210
  gsdSourceDir,
211
+ dedupSection,
120
212
  });
121
213
 
122
214
  ctx.ui.notify(`Forensic report saved: ${relative(basePath, savedPath)}`, "info");
@@ -8,4 +8,5 @@ export const GIT_NO_PROMPT_ENV = {
8
8
  GIT_TERMINAL_PROMPT: "0",
9
9
  GIT_ASKPASS: "",
10
10
  GIT_SVN_ID: "",
11
+ LC_ALL: "C", // force English git output so stderr string checks work on all locales (#1997)
11
12
  };
@@ -9,8 +9,8 @@
9
9
  */
10
10
 
11
11
  import { execFileSync, execSync } from "node:child_process";
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
- import { join } from "node:path";
12
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
13
+ import { join, relative } from "node:path";
14
14
  import { gsdRoot } from "./paths.js";
15
15
  import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
16
16
  import { loadEffectiveGSDPreferences } from "./preferences.js";
@@ -486,11 +486,80 @@ export class GitServiceImpl {
486
486
  // git add -A already skips it and the exclusions are harmless no-ops.
487
487
  const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
488
488
  nativeAddAllWithExclusions(this.basePath, allExclusions);
489
+
490
+ // Force-add .gsd/milestones/ when .gsd is a symlink (#2104).
491
+ // When .gsd is a symlink (external state projects), ensureGitignore adds
492
+ // `.gsd` to .gitignore. The nativeAddAllWithExclusions call above falls
493
+ // back to plain `git add -A` (symlink pathspec rejection), which respects
494
+ // .gitignore and silently skips new .gsd/milestones/ files.
495
+ //
496
+ // `git add -f` also fails with "beyond a symbolic link", so we use
497
+ // `git hash-object -w` + `git update-index --add --cacheinfo` to bypass
498
+ // the symlink restriction entirely. This stages each milestone artifact
499
+ // individually by hashing the file content and updating the index directly.
500
+ const gsdPath = join(this.basePath, ".gsd");
501
+ const milestonesDir = join(gsdPath, "milestones");
502
+ try {
503
+ if (
504
+ existsSync(gsdPath) &&
505
+ lstatSync(gsdPath).isSymbolicLink() &&
506
+ existsSync(milestonesDir)
507
+ ) {
508
+ this._forceAddMilestoneArtifacts(milestonesDir);
509
+ }
510
+ } catch {
511
+ // Non-fatal: if force-add fails, the commit proceeds without these files.
512
+ // This matches existing behavior where milestone artifacts were silently
513
+ // omitted — but now we at least attempt to include them.
514
+ }
489
515
  }
490
516
 
491
517
  /** Tracks whether runtime file cleanup has run this session. */
492
518
  private _runtimeFilesCleanedUp = false;
493
519
 
520
+ /**
521
+ * Recursively collect all files under a directory.
522
+ * Returns paths relative to `basePath` (e.g. ".gsd/milestones/M009/SUMMARY.md").
523
+ */
524
+ private _collectFiles(dir: string): string[] {
525
+ const files: string[] = [];
526
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
527
+ const full = join(dir, entry.name);
528
+ if (entry.isDirectory()) {
529
+ files.push(...this._collectFiles(full));
530
+ } else if (entry.isFile()) {
531
+ files.push(relative(this.basePath, full));
532
+ }
533
+ }
534
+ return files;
535
+ }
536
+
537
+ /**
538
+ * Stage milestone artifacts through a symlinked .gsd directory (#2104).
539
+ *
540
+ * `git add` (even with `-f`) refuses to stage files "beyond a symbolic link".
541
+ * This method bypasses that restriction by hashing each file with
542
+ * `git hash-object -w` and inserting the blob into the index with
543
+ * `git update-index --add --cacheinfo 100644 <hash> <path>`.
544
+ */
545
+ private _forceAddMilestoneArtifacts(milestonesDir: string): void {
546
+ const files = this._collectFiles(milestonesDir);
547
+ for (const filePath of files) {
548
+ const hash = execFileSync("git", ["hash-object", "-w", filePath], {
549
+ cwd: this.basePath,
550
+ stdio: ["ignore", "pipe", "pipe"],
551
+ encoding: "utf-8",
552
+ env: GIT_NO_PROMPT_ENV,
553
+ }).trim();
554
+ execFileSync("git", ["update-index", "--add", "--cacheinfo", "100644", hash, filePath], {
555
+ cwd: this.basePath,
556
+ stdio: ["ignore", "pipe", "pipe"],
557
+ encoding: "utf-8",
558
+ env: GIT_NO_PROMPT_ENV,
559
+ });
560
+ }
561
+ }
562
+
494
563
  /**
495
564
  * Stage files (smart staging) and commit.
496
565
  * Returns the commit message string on success, or null if nothing to commit.
@@ -847,6 +847,7 @@ export function nativeMergeSquash(basePath: string, branch: string): GitMergeRes
847
847
  cwd: basePath,
848
848
  stdio: ["ignore", "pipe", "pipe"],
849
849
  encoding: "utf-8",
850
+ env: GIT_NO_PROMPT_ENV,
850
851
  });
851
852
  return { success: true, conflicts: [] };
852
853
  } catch (err: unknown) {
@@ -89,6 +89,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
89
89
  "reactive_execution",
90
90
  "github",
91
91
  "service_tier",
92
+ "forensics_dedup",
92
93
  ]);
93
94
 
94
95
  /** Canonical list of all dispatch unit types. */
@@ -223,6 +224,8 @@ export interface GSDPreferences {
223
224
  github?: GitHubSyncConfig;
224
225
  /** OpenAI service tier preference. "priority" = 2x cost, faster. "flex" = 0.5x cost, slower. Only affects gpt-5.4 models. */
225
226
  service_tier?: "priority" | "flex";
227
+ /** Opt-in: search existing issues and PRs before filing from /gsd forensics. Uses additional AI tokens. */
228
+ forensics_dedup?: boolean;
226
229
  }
227
230
 
228
231
  export interface LoadedGSDPreferences {