gsd-pi 2.62.0-dev.f6ad485 → 2.62.1-dev.1ae2b74

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 (131) hide show
  1. package/dist/resources/extensions/ask-user-questions.js +47 -3
  2. package/dist/resources/extensions/gsd/auto/loop.js +8 -1
  3. package/dist/resources/extensions/gsd/auto/phases.js +10 -3
  4. package/dist/resources/extensions/gsd/auto-post-unit.js +6 -4
  5. package/dist/resources/extensions/gsd/auto-start.js +11 -6
  6. package/dist/resources/extensions/gsd/auto-timers.js +8 -2
  7. package/dist/resources/extensions/gsd/auto-verification.js +14 -3
  8. package/dist/resources/extensions/gsd/auto-worktree.js +19 -0
  9. package/dist/resources/extensions/gsd/auto.js +24 -0
  10. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +4 -0
  11. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +11 -1
  12. package/dist/resources/extensions/gsd/db-writer.js +64 -28
  13. package/dist/resources/extensions/gsd/preferences-models.js +74 -0
  14. package/dist/resources/extensions/gsd/preferences-skills.js +6 -1
  15. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
  16. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
  17. package/dist/resources/extensions/gsd/skill-catalog.js +6 -4
  18. package/dist/resources/extensions/gsd/skill-discovery.js +24 -6
  19. package/dist/resources/extensions/gsd/skill-health.js +7 -3
  20. package/dist/resources/extensions/gsd/skill-telemetry.js +5 -2
  21. package/dist/resources/extensions/gsd/state.js +1 -0
  22. package/dist/resources/extensions/gsd/tools/complete-slice.js +3 -3
  23. package/dist/resources/extensions/gsd/workflow-logger.js +13 -8
  24. package/dist/resources/extensions/gsd/workflow-reconcile.js +3 -1
  25. package/dist/web/standalone/.next/BUILD_ID +1 -1
  26. package/dist/web/standalone/.next/app-path-routes-manifest.json +20 -20
  27. package/dist/web/standalone/.next/build-manifest.json +2 -2
  28. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  29. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  30. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.html +1 -1
  46. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app-paths-manifest.json +20 -20
  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/package.json +1 -1
  57. package/packages/mcp-server/src/cli.ts +1 -1
  58. package/packages/mcp-server/src/index.ts +15 -1
  59. package/packages/mcp-server/src/readers/captures.ts +119 -0
  60. package/packages/mcp-server/src/readers/doctor-lite.ts +225 -0
  61. package/packages/mcp-server/src/readers/index.ts +16 -0
  62. package/packages/mcp-server/src/readers/knowledge.ts +111 -0
  63. package/packages/mcp-server/src/readers/metrics.ts +118 -0
  64. package/packages/mcp-server/src/readers/paths.ts +217 -0
  65. package/packages/mcp-server/src/readers/readers.test.ts +509 -0
  66. package/packages/mcp-server/src/readers/roadmap.ts +263 -0
  67. package/packages/mcp-server/src/readers/state.ts +223 -0
  68. package/packages/mcp-server/src/server.ts +134 -3
  69. package/packages/pi-ai/dist/utils/repair-tool-json.d.ts +26 -6
  70. package/packages/pi-ai/dist/utils/repair-tool-json.d.ts.map +1 -1
  71. package/packages/pi-ai/dist/utils/repair-tool-json.js +67 -9
  72. package/packages/pi-ai/dist/utils/repair-tool-json.js.map +1 -1
  73. package/packages/pi-ai/dist/utils/tests/repair-tool-json.test.js +73 -1
  74. package/packages/pi-ai/dist/utils/tests/repair-tool-json.test.js.map +1 -1
  75. package/packages/pi-ai/src/utils/repair-tool-json.ts +74 -10
  76. package/packages/pi-ai/src/utils/tests/repair-tool-json.test.ts +94 -1
  77. package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.d.ts +2 -0
  78. package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.d.ts.map +1 -0
  79. package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.js +16 -0
  80. package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.js.map +1 -0
  81. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  82. package/packages/pi-coding-agent/dist/core/agent-session.js +4 -0
  83. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  84. package/packages/pi-coding-agent/dist/core/retry-handler.d.ts +3 -0
  85. package/packages/pi-coding-agent/dist/core/retry-handler.d.ts.map +1 -1
  86. package/packages/pi-coding-agent/dist/core/retry-handler.js +48 -16
  87. package/packages/pi-coding-agent/dist/core/retry-handler.js.map +1 -1
  88. package/packages/pi-coding-agent/dist/core/retry-handler.test.js +20 -3
  89. package/packages/pi-coding-agent/dist/core/retry-handler.test.js.map +1 -1
  90. package/packages/pi-coding-agent/package.json +1 -1
  91. package/packages/pi-coding-agent/src/core/agent-session-model-switch.test.ts +21 -0
  92. package/packages/pi-coding-agent/src/core/agent-session.ts +4 -0
  93. package/packages/pi-coding-agent/src/core/retry-handler.test.ts +30 -3
  94. package/packages/pi-coding-agent/src/core/retry-handler.ts +49 -16
  95. package/pkg/package.json +1 -1
  96. package/src/resources/extensions/ask-user-questions.ts +60 -4
  97. package/src/resources/extensions/gsd/auto/loop.ts +8 -1
  98. package/src/resources/extensions/gsd/auto/phases.ts +8 -6
  99. package/src/resources/extensions/gsd/auto-post-unit.ts +6 -3
  100. package/src/resources/extensions/gsd/auto-start.ts +11 -6
  101. package/src/resources/extensions/gsd/auto-timers.ts +8 -2
  102. package/src/resources/extensions/gsd/auto-verification.ts +14 -3
  103. package/src/resources/extensions/gsd/auto-worktree.ts +18 -0
  104. package/src/resources/extensions/gsd/auto.ts +25 -0
  105. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +4 -0
  106. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +13 -1
  107. package/src/resources/extensions/gsd/db-writer.ts +67 -30
  108. package/src/resources/extensions/gsd/preferences-models.ts +78 -0
  109. package/src/resources/extensions/gsd/preferences-skills.ts +6 -1
  110. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
  111. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
  112. package/src/resources/extensions/gsd/skill-catalog.ts +6 -3
  113. package/src/resources/extensions/gsd/skill-discovery.ts +23 -6
  114. package/src/resources/extensions/gsd/skill-health.ts +7 -3
  115. package/src/resources/extensions/gsd/skill-telemetry.ts +5 -2
  116. package/src/resources/extensions/gsd/state.ts +1 -0
  117. package/src/resources/extensions/gsd/tests/ask-user-questions-dedup.test.ts +120 -0
  118. package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +22 -2
  119. package/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts +107 -0
  120. package/src/resources/extensions/gsd/tests/claude-skill-dirs.test.ts +51 -0
  121. package/src/resources/extensions/gsd/tests/db-writer.test.ts +41 -0
  122. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +75 -1
  123. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +17 -4
  124. package/src/resources/extensions/gsd/tests/workflow-logger.test.ts +17 -41
  125. package/src/resources/extensions/gsd/tests/worktree-db-respawn-truncation.test.ts +81 -2
  126. package/src/resources/extensions/gsd/tools/complete-slice.ts +3 -5
  127. package/src/resources/extensions/gsd/workflow-logger.ts +13 -8
  128. package/src/resources/extensions/gsd/workflow-reconcile.ts +3 -1
  129. package/src/resources/extensions/shared/tests/ask-user-freetext.test.ts +6 -1
  130. /package/dist/web/standalone/.next/static/{fbkSIi4k8fmB8mi0Sq9sF → erQZ_8_1lkclnPJLJnCxG}/_buildManifest.js +0 -0
  131. /package/dist/web/standalone/.next/static/{fbkSIi4k8fmB8mi0Sq9sF → erQZ_8_1lkclnPJLJnCxG}/_ssgManifest.js +0 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Tests for Claude Code skill directory support in getSkillSearchDirs().
3
+ *
4
+ * Verifies that ~/.claude/skills/ and .claude/skills/ are included in
5
+ * the skill search path alongside ~/.agents/skills/ and .agents/skills/.
6
+ */
7
+
8
+ import { describe, test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { getSkillSearchDirs } from "../preferences-skills.ts";
13
+
14
+ describe("getSkillSearchDirs — Claude Code directory support", () => {
15
+ const cwd = "/tmp/test-project";
16
+
17
+ test("includes ~/.agents/skills/ as user-skill", () => {
18
+ const dirs = getSkillSearchDirs(cwd);
19
+ const agents = dirs.find((d) => d.dir === join(homedir(), ".agents", "skills"));
20
+ assert.ok(agents, "should include ~/.agents/skills/");
21
+ assert.equal(agents!.method, "user-skill");
22
+ });
23
+
24
+ test("includes .agents/skills/ as project-skill", () => {
25
+ const dirs = getSkillSearchDirs(cwd);
26
+ const projectAgents = dirs.find((d) => d.dir === join(cwd, ".agents", "skills"));
27
+ assert.ok(projectAgents, "should include .agents/skills/");
28
+ assert.equal(projectAgents!.method, "project-skill");
29
+ });
30
+
31
+ test("includes ~/.claude/skills/ as user-skill", () => {
32
+ const dirs = getSkillSearchDirs(cwd);
33
+ const claude = dirs.find((d) => d.dir === join(homedir(), ".claude", "skills"));
34
+ assert.ok(claude, "should include ~/.claude/skills/");
35
+ assert.equal(claude!.method, "user-skill");
36
+ });
37
+
38
+ test("includes .claude/skills/ as project-skill", () => {
39
+ const dirs = getSkillSearchDirs(cwd);
40
+ const projectClaude = dirs.find((d) => d.dir === join(cwd, ".claude", "skills"));
41
+ assert.ok(projectClaude, "should include .claude/skills/");
42
+ assert.equal(projectClaude!.method, "project-skill");
43
+ });
44
+
45
+ test("~/.agents/skills/ appears before ~/.claude/skills/ (priority order)", () => {
46
+ const dirs = getSkillSearchDirs(cwd);
47
+ const agentsIdx = dirs.findIndex((d) => d.dir === join(homedir(), ".agents", "skills"));
48
+ const claudeIdx = dirs.findIndex((d) => d.dir === join(homedir(), ".claude", "skills"));
49
+ assert.ok(agentsIdx < claudeIdx, "~/.agents/skills/ should have higher priority than ~/.claude/skills/");
50
+ });
51
+ });
@@ -358,6 +358,47 @@ describe('db-writer', () => {
358
358
  }
359
359
  });
360
360
 
361
+ // ═══════════════════════════════════════════════════════════════════════════
362
+ // Parallel save race condition regression (#3326, #3339, #3459)
363
+ // ═══════════════════════════════════════════════════════════════════════════
364
+
365
+ test('parallel saveDecisionToDb calls produce unique IDs', async () => {
366
+ const tmpDir = makeTmpDir();
367
+ const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
368
+ openDatabase(dbPath);
369
+
370
+ try {
371
+ // Fire 5 saves concurrently — before the fix, all would get D001
372
+ const results = await Promise.all([
373
+ saveDecisionToDb({ scope: 'a', decision: 'd1', choice: 'c1', rationale: 'r1' }, tmpDir),
374
+ saveDecisionToDb({ scope: 'b', decision: 'd2', choice: 'c2', rationale: 'r2' }, tmpDir),
375
+ saveDecisionToDb({ scope: 'c', decision: 'd3', choice: 'c3', rationale: 'r3' }, tmpDir),
376
+ saveDecisionToDb({ scope: 'd', decision: 'd4', choice: 'c4', rationale: 'r4' }, tmpDir),
377
+ saveDecisionToDb({ scope: 'e', decision: 'd5', choice: 'c5', rationale: 'r5' }, tmpDir),
378
+ ]);
379
+
380
+ const ids = results.map((r) => r.id);
381
+ const uniqueIds = new Set(ids);
382
+
383
+ // All 5 IDs must be unique
384
+ assert.equal(uniqueIds.size, 5, `Expected 5 unique IDs, got ${uniqueIds.size}: ${ids.join(', ')}`);
385
+
386
+ // IDs should be D001-D005 (order may vary due to concurrency)
387
+ for (const id of ids) {
388
+ assert.match(id, /^D\d{3}$/, `ID ${id} should match D### pattern`);
389
+ }
390
+
391
+ // Verify all 5 exist in DB
392
+ for (const id of ids) {
393
+ const row = getDecisionById(id);
394
+ assert.ok(row, `Decision ${id} should exist in DB`);
395
+ }
396
+ } finally {
397
+ closeDatabase();
398
+ cleanupDir(tmpDir);
399
+ }
400
+ });
401
+
361
402
  // ═══════════════════════════════════════════════════════════════════════════
362
403
  // updateRequirementInDb Tests
363
404
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Tests for model config isolation between concurrent instances (#650, #1065).
2
+ * Tests for model config isolation between concurrent instances (#650, #1065)
3
+ * and GSD preferences override of settings.json defaults (#3517).
3
4
  */
4
5
 
5
6
  import { describe, it, beforeEach, afterEach } from "node:test";
@@ -155,3 +156,76 @@ describe("session model recovery on error (#1065)", () => {
155
156
  "Recovery should be skipped when no session model was captured");
156
157
  });
157
158
  });
159
+
160
+ // ─── GSD Preferences override settings.json (#3517) ─────────────────────────
161
+
162
+ describe("GSD preferences override settings.json for session model (#3517)", () => {
163
+ it("preferredModel takes priority over ctx.model when both are available", () => {
164
+ // Simulates auto-start.ts logic: preferredModel ?? ctx.model snapshot
165
+ const preferredModel = { provider: "openai-codex", id: "gpt-5.4" };
166
+ const ctxModel = { provider: "claude-code", id: "claude-sonnet-4-6" };
167
+
168
+ const startModelSnapshot = preferredModel
169
+ ?? { provider: ctxModel.provider, id: ctxModel.id };
170
+
171
+ assert.equal(startModelSnapshot.provider, "openai-codex",
172
+ "preferredModel provider should win over ctx.model");
173
+ assert.equal(startModelSnapshot.id, "gpt-5.4",
174
+ "preferredModel id should win over ctx.model");
175
+ });
176
+
177
+ it("falls back to ctx.model when no GSD preferences are configured", () => {
178
+ const preferredModel: { provider: string; id: string } | undefined = undefined;
179
+ const ctxModel = { provider: "claude-code", id: "claude-sonnet-4-6" };
180
+
181
+ const startModelSnapshot = preferredModel
182
+ ?? { provider: ctxModel.provider, id: ctxModel.id };
183
+
184
+ assert.equal(startModelSnapshot.provider, "claude-code",
185
+ "should fall back to ctx.model provider when no preferences");
186
+ assert.equal(startModelSnapshot.id, "claude-sonnet-4-6",
187
+ "should fall back to ctx.model id when no preferences");
188
+ });
189
+
190
+ it("handles null ctx.model with no preferences gracefully", () => {
191
+ const preferredModel: { provider: string; id: string } | undefined = undefined;
192
+ // Use a function to prevent TS from narrowing to `never` in the ternary
193
+ function getCtxModel(): { provider: string; id: string } | null { return null; }
194
+ const ctxModel = getCtxModel();
195
+
196
+ const startModelSnapshot = preferredModel
197
+ ?? (ctxModel ? { provider: ctxModel.provider, id: ctxModel.id } : null);
198
+
199
+ assert.equal(startModelSnapshot, null,
200
+ "should be null when neither preferences nor ctx.model exist");
201
+ });
202
+
203
+ it("bare model ID uses session provider when available", () => {
204
+ // Simulates: PREFERENCES.md has "gpt-5.4" (no provider), session is openai-codex
205
+ const preferredModel = { provider: "openai-codex", id: "gpt-5.4" }; // from resolveDefaultSessionModel("openai-codex")
206
+ const ctxModel = { provider: "openai-codex", id: "claude-sonnet-4-6" };
207
+
208
+ const startModelSnapshot = preferredModel
209
+ ?? { provider: ctxModel.provider, id: ctxModel.id };
210
+
211
+ assert.equal(startModelSnapshot.provider, "openai-codex");
212
+ assert.equal(startModelSnapshot.id, "gpt-5.4",
213
+ "bare model ID from preferences should still override ctx.model");
214
+ });
215
+
216
+ it("stale settings.json does not leak when preferences are set", () => {
217
+ // Scenario: settings.json has claude-code, PREFERENCES.md has openai-codex
218
+ const settingsJsonDefault = { provider: "claude-code", id: "claude-sonnet-4-6" };
219
+ const preferencesModel = { provider: "openai-codex", id: "gpt-5.4" };
220
+
221
+ // auto-start.ts captures preferredModel first, which preempts settingsJsonDefault
222
+ const startModelSnapshot = preferencesModel ?? settingsJsonDefault;
223
+
224
+ assert.equal(startModelSnapshot.provider, "openai-codex",
225
+ "PREFERENCES.md must override stale settings.json provider");
226
+ assert.equal(startModelSnapshot.id, "gpt-5.4",
227
+ "PREFERENCES.md must override stale settings.json model");
228
+ assert.notEqual(startModelSnapshot.provider, settingsJsonDefault.provider,
229
+ "settings.json provider must NOT leak through");
230
+ });
231
+ });
@@ -136,17 +136,30 @@ console.log('\n── Loop guard: nested args are not stripped ──');
136
136
  assert.deepStrictEqual(getToolCallLoopCount(), 1, `Each unique nested call should reset count to 1`);
137
137
  }
138
138
 
139
- // Truly identical nested calls should still be detected
139
+ // Truly identical nested calls should still be detected.
140
+ // ask_user_questions has a strict threshold of 1, so the 2nd identical call is blocked.
141
+ resetToolCallLoopGuard();
142
+ const first = checkToolCallLoop('ask_user_questions', {
143
+ questions: [{ id: 'same', question: 'Same?' }],
144
+ });
145
+ assert.ok(first.block === false, 'First ask_user_questions call should be allowed');
146
+ const blocked = checkToolCallLoop('ask_user_questions', {
147
+ questions: [{ id: 'same', question: 'Same?' }],
148
+ });
149
+ assert.ok(blocked.block === true, '2nd identical ask_user_questions call should be blocked (strict threshold)');
150
+
151
+ // Non-strict tools still allow up to 4 identical calls
140
152
  resetToolCallLoopGuard();
141
153
  for (let i = 1; i <= 4; i++) {
142
- checkToolCallLoop('ask_user_questions', {
154
+ const r = checkToolCallLoop('web_search', {
143
155
  questions: [{ id: 'same', question: 'Same?' }],
144
156
  });
157
+ assert.ok(r.block === false, `web_search call ${i} should be allowed (normal threshold)`);
145
158
  }
146
- const blocked = checkToolCallLoop('ask_user_questions', {
159
+ const blockedNormal = checkToolCallLoop('web_search', {
147
160
  questions: [{ id: 'same', question: 'Same?' }],
148
161
  });
149
- assert.ok(blocked.block === true, 'Identical nested calls should still be blocked');
162
+ assert.ok(blockedNormal.block === true, '5th identical web_search call should be blocked');
150
163
  }
151
164
 
152
165
  // ═══════════════════════════════════════════════════════════════════════════
@@ -217,12 +217,26 @@ describe("workflow-logger", () => {
217
217
  assert.ok(formatted.includes("\n"));
218
218
  });
219
219
 
220
- test("does not include context in formatted output", () => {
220
+ test("includes context fields in formatted output", () => {
221
221
  logError("tool", "failed", { cmd: "complete_task" });
222
222
  const entries = drainLogs();
223
223
  const formatted = formatForNotification(entries);
224
- assert.equal(formatted, "[tool] failed");
225
- assert.ok(!formatted.includes("complete_task"));
224
+ assert.equal(formatted, "[tool] failed (cmd: complete_task)");
225
+ });
226
+
227
+ test("excludes error key from context to avoid redundancy", () => {
228
+ logError("tool", "disk write failed", { error: "ENOSPC", path: "/tmp/foo" });
229
+ const entries = drainLogs();
230
+ const formatted = formatForNotification(entries);
231
+ assert.ok(formatted.includes("path: /tmp/foo"));
232
+ assert.ok(!formatted.includes("error: ENOSPC"));
233
+ });
234
+
235
+ test("formats entry without context unchanged", () => {
236
+ logError("intercept", "blocked write");
237
+ const entries = drainLogs();
238
+ const formatted = formatForNotification(entries);
239
+ assert.equal(formatted, "[intercept] blocked write");
226
240
  });
227
241
  });
228
242
 
@@ -279,44 +293,6 @@ describe("workflow-logger", () => {
279
293
  });
280
294
  });
281
295
 
282
- describe("audit log persistence", () => {
283
- let dir: string;
284
-
285
- beforeEach(() => {
286
- dir = makeTempDir("wl-audit-");
287
- });
288
-
289
- afterEach(() => {
290
- setLogBasePath("");
291
- cleanup(dir);
292
- });
293
-
294
- test("writes entry to .gsd/audit-log.jsonl after setLogBasePath", () => {
295
- setLogBasePath(dir);
296
- logError("engine", "audit test entry");
297
-
298
- const auditPath = join(dir, ".gsd", "audit-log.jsonl");
299
- assert.ok(existsSync(auditPath), "audit-log.jsonl should exist");
300
- const content = readFileSync(auditPath, "utf-8");
301
- const entry = JSON.parse(content.trim());
302
- assert.equal(entry.severity, "error");
303
- assert.equal(entry.component, "engine");
304
- assert.equal(entry.message, "audit test entry");
305
- });
306
-
307
- test("_resetLogs does not clear the audit base path", () => {
308
- setLogBasePath(dir);
309
- _resetLogs();
310
- logError("engine", "post-reset entry");
311
-
312
- const auditPath = join(dir, ".gsd", "audit-log.jsonl");
313
- assert.ok(existsSync(auditPath), "audit-log.jsonl should exist after _resetLogs");
314
- const content = readFileSync(auditPath, "utf-8");
315
- const entry = JSON.parse(content.trim());
316
- assert.equal(entry.message, "post-reset entry");
317
- });
318
- });
319
-
320
296
  describe("new log components (db, dispatch)", () => {
321
297
  test("logError with 'db' component stores correct component", () => {
322
298
  logError("db", "failed to copy DB to worktree", { error: "ENOENT" });
@@ -100,8 +100,87 @@ describe('worktree-db-respawn-truncation (#2815)', async () => {
100
100
  }
101
101
  }
102
102
 
103
- // ─── 3. Milestone artifacts still synced when DB is preserved ────────
104
- console.log('\n=== 3. milestone artifacts still synced even when DB preserved ===');
103
+ // ─── 3. WAL/SHM sidecar files cleaned up when empty DB is deleted (#2478) ──
104
+ console.log('\n=== 3. orphaned WAL/SHM cleaned up alongside empty gsd.db (#2478) ===');
105
+ {
106
+ const mainBase = createBase('main');
107
+ const wtBase = createBase('wt');
108
+
109
+ try {
110
+ const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
111
+ mkdirSync(m001Dir, { recursive: true });
112
+ writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap');
113
+
114
+ // Create an empty (0-byte) gsd.db plus orphaned WAL and SHM files —
115
+ // this is the exact state that causes Node 24 node:sqlite CPU spin (#2478).
116
+ const wtGsd = join(wtBase, '.gsd');
117
+ writeFileSync(join(wtGsd, 'gsd.db'), '');
118
+ writeFileSync(join(wtGsd, 'gsd.db-wal'), Buffer.alloc(605672, 0xAA));
119
+ writeFileSync(join(wtGsd, 'gsd.db-shm'), Buffer.alloc(32768, 0xBB));
120
+
121
+ assert.ok(existsSync(join(wtGsd, 'gsd.db')), 'gsd.db exists before sync');
122
+ assert.ok(existsSync(join(wtGsd, 'gsd.db-wal')), 'gsd.db-wal exists before sync');
123
+ assert.ok(existsSync(join(wtGsd, 'gsd.db-shm')), 'gsd.db-shm exists before sync');
124
+
125
+ syncProjectRootToWorktree(mainBase, wtBase, 'M001');
126
+
127
+ assert.ok(
128
+ !existsSync(join(wtGsd, 'gsd.db')),
129
+ '#2478: empty gsd.db must be deleted',
130
+ );
131
+ assert.ok(
132
+ !existsSync(join(wtGsd, 'gsd.db-wal')),
133
+ '#2478: orphaned gsd.db-wal must be deleted alongside gsd.db',
134
+ );
135
+ assert.ok(
136
+ !existsSync(join(wtGsd, 'gsd.db-shm')),
137
+ '#2478: orphaned gsd.db-shm must be deleted alongside gsd.db',
138
+ );
139
+ } finally {
140
+ cleanup(mainBase);
141
+ cleanup(wtBase);
142
+ }
143
+ }
144
+
145
+ // ─── 4. Orphaned WAL/SHM cleaned up even when gsd.db already missing (#2478) ──
146
+ console.log('\n=== 4. orphaned WAL/SHM cleaned up even without gsd.db (#2478) ===');
147
+ {
148
+ const mainBase = createBase('main');
149
+ const wtBase = createBase('wt');
150
+
151
+ try {
152
+ const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
153
+ mkdirSync(m001Dir, { recursive: true });
154
+ writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap');
155
+
156
+ // Orphaned WAL/SHM with NO gsd.db at all — can happen from a previous
157
+ // partial cleanup. These must still be cleaned up.
158
+ const wtGsd = join(wtBase, '.gsd');
159
+ writeFileSync(join(wtGsd, 'gsd.db-wal'), Buffer.alloc(1024, 0xAA));
160
+ writeFileSync(join(wtGsd, 'gsd.db-shm'), Buffer.alloc(1024, 0xBB));
161
+
162
+ assert.ok(!existsSync(join(wtGsd, 'gsd.db')), 'gsd.db does not exist');
163
+ assert.ok(existsSync(join(wtGsd, 'gsd.db-wal')), 'orphaned gsd.db-wal exists');
164
+ assert.ok(existsSync(join(wtGsd, 'gsd.db-shm')), 'orphaned gsd.db-shm exists');
165
+
166
+ syncProjectRootToWorktree(mainBase, wtBase, 'M001');
167
+
168
+ assert.ok(
169
+ !existsSync(join(wtGsd, 'gsd.db-wal')),
170
+ '#2478: orphaned gsd.db-wal must be deleted even without main db file',
171
+ );
172
+ assert.ok(
173
+ !existsSync(join(wtGsd, 'gsd.db-shm')),
174
+ '#2478: orphaned gsd.db-shm must be deleted even without main db file',
175
+ );
176
+ } finally {
177
+ cleanup(mainBase);
178
+ cleanup(wtBase);
179
+ }
180
+ }
181
+
182
+ // ─── 5. Milestone artifacts still synced when DB is preserved ────────
183
+ console.log('\n=== 5. milestone artifacts still synced even when DB preserved ===');
105
184
  {
106
185
  const mainBase = createBase('main');
107
186
  const wtBase = createBase('wt');
@@ -292,13 +292,11 @@ export async function handleCompleteSlice(
292
292
  // Toggle roadmap checkbox via renderer module
293
293
  const roadmapToggled = await renderRoadmapCheckboxes(basePath, params.milestoneId);
294
294
  if (!roadmapToggled) {
295
- process.stderr.write(
296
- `gsd-db: complete_slice — could not find roadmap for ${params.milestoneId}, skipping checkbox toggle\n`,
297
- );
295
+ logWarning("tool", `complete_slice — could not find roadmap for ${params.milestoneId}, skipping checkbox toggle`);
298
296
  }
299
297
  } catch (renderErr) {
300
298
  // Disk render failed — roll back DB status so state stays consistent
301
- logWarning("tool", `complete_slice — disk render failed, rolling back DB status: ${(renderErr as Error).message}`);
299
+ logWarning("tool", `complete_slice — disk render failed for ${params.milestoneId}/${params.sliceId}, rolling back DB status`, { error: (renderErr as Error).message });
302
300
  updateSliceStatus(params.milestoneId, params.sliceId, 'pending');
303
301
  invalidateStateCache();
304
302
  return { error: `disk render failed: ${(renderErr as Error).message}` };
@@ -325,7 +323,7 @@ export async function handleCompleteSlice(
325
323
  trigger_reason: params.triggerReason,
326
324
  });
327
325
  } catch (hookErr) {
328
- logWarning("tool", `complete-slice post-mutation hook warning: ${(hookErr as Error).message}`);
326
+ logWarning("tool", `complete-slice post-mutation hook failed for ${params.milestoneId}/${params.sliceId}`, { error: (hookErr as Error).message });
329
327
  }
330
328
 
331
329
  return {
@@ -174,17 +174,22 @@ export function summarizeLogs(): string | null {
174
174
 
175
175
  /**
176
176
  * Format entries for display (used by auto-loop post-unit notification).
177
- * Note: context fields are not included in the formatted output.
177
+ * Includes key context fields (file paths, commands) when present.
178
178
  */
179
179
  export function formatForNotification(entries: readonly LogEntry[]): string {
180
180
  if (entries.length === 0) return "";
181
- if (entries.length === 1) {
182
- const e = entries[0];
183
- return `[${e.component}] ${e.message}`;
184
- }
185
- return entries
186
- .map((e) => `[${e.component}] ${e.message}`)
187
- .join("\n");
181
+ return entries.map((e) => {
182
+ let line = `[${e.component}] ${e.message}`;
183
+ if (e.context) {
184
+ const ctxParts = Object.entries(e.context)
185
+ .filter(([k]) => k !== "error") // error is redundant with message
186
+ .map(([k, v]) => v.includes(",") ? `${k}: "${v}"` : `${k}: ${v}`);
187
+ if (ctxParts.length > 0) {
188
+ line += ` (${ctxParts.join(", ")})`;
189
+ }
190
+ }
191
+ return line;
192
+ }).join("\n");
188
193
  }
189
194
 
190
195
  /**
@@ -348,7 +348,9 @@ function _reconcileWorktreeLogsInner(
348
348
  if (conflicts.length > 0) {
349
349
  // D-04: atomic all-or-nothing — block entire merge
350
350
  writeConflictsFile(mainBasePath, conflicts, worktreeBasePath);
351
- logError("reconcile", `${conflicts.length} conflict(s) detected`, { count: String(conflicts.length), path: join(mainBasePath, ".gsd", "CONFLICTS.md") });
351
+ const conflictSummary = conflicts.slice(0, 3).map(c => `${c.entityType}:${c.entityId}`).join(", ");
352
+ const truncated = conflicts.length > 3 ? `... and ${conflicts.length - 3} more` : "";
353
+ logError("reconcile", `${conflicts.length} conflict(s) detected on ${conflictSummary}${truncated}. Details: .gsd/CONFLICTS.md`, { count: String(conflicts.length), path: join(mainBasePath, ".gsd", "CONFLICTS.md") });
352
354
  return { autoMerged: 0, conflicts };
353
355
  }
354
356
 
@@ -10,12 +10,13 @@
10
10
  * triggers a follow-up free-text input prompt via ctx.ui.input().
11
11
  */
12
12
 
13
- import { describe, it } from "node:test";
13
+ import { describe, it, beforeEach } from "node:test";
14
14
  import assert from "node:assert/strict";
15
15
 
16
16
  // The ask-user-questions extension registers a tool via pi.registerTool().
17
17
  // We capture that registration and call execute() directly with a mock context.
18
18
  import AskUserQuestions from "../../ask-user-questions.js";
19
+ import { resetAskUserQuestionsCache } from "../../ask-user-questions.js";
19
20
 
20
21
  interface CapturedTool {
21
22
  name: string;
@@ -73,6 +74,10 @@ function makeMockCtx(opts: {
73
74
  }
74
75
 
75
76
  describe("ask-user-questions RPC fallback free-text", () => {
77
+ beforeEach(() => {
78
+ resetAskUserQuestionsCache();
79
+ });
80
+
76
81
  it("prompts for free-text input when user selects 'None of the above'", async () => {
77
82
  const tool = captureTool();
78
83
  const { ctx, selectCalls, inputCalls } = makeMockCtx({