gsd-pi 2.74.0-dev.703eabc → 2.74.0-dev.ffbcc03

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 (65) hide show
  1. package/dist/resources/extensions/gsd/auto-recovery.js +24 -10
  2. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +16 -5
  3. package/dist/resources/extensions/gsd/cache.js +16 -5
  4. package/dist/resources/extensions/gsd/guided-flow.js +1 -1
  5. package/dist/resources/extensions/gsd/safety/evidence-collector.js +15 -30
  6. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  7. package/dist/web/standalone/.next/BUILD_ID +1 -1
  8. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  9. package/dist/web/standalone/.next/build-manifest.json +2 -2
  10. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  11. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  12. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  13. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  20. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/index.html +1 -1
  28. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  35. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  36. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  37. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  38. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  39. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  40. package/package.json +1 -1
  41. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  42. package/packages/mcp-server/dist/workflow-tools.js +88 -6
  43. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  44. package/packages/mcp-server/src/workflow-tools.ts +95 -10
  45. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  46. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +8 -0
  47. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  48. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +17 -0
  49. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +1 -0
  51. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  52. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +17 -0
  53. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  54. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +17 -0
  55. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +19 -0
  56. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  57. package/src/resources/extensions/gsd/auto-recovery.ts +29 -9
  58. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +16 -5
  59. package/src/resources/extensions/gsd/cache.ts +16 -5
  60. package/src/resources/extensions/gsd/guided-flow.ts +1 -1
  61. package/src/resources/extensions/gsd/safety/evidence-collector.ts +15 -31
  62. package/src/resources/extensions/gsd/tests/artifacts-table-preserved-on-cache-invalidate.test.ts +177 -0
  63. package/src/resources/extensions/gsd/tests/auto-retry-mcp-churn-fixes.test.ts +272 -0
  64. /package/dist/web/standalone/.next/static/{3U-oZ5FT59BM7sm2GInic → kn6xzWKYnogsxp2b6RpDD}/_buildManifest.js +0 -0
  65. /package/dist/web/standalone/.next/static/{3U-oZ5FT59BM7sm2GInic → kn6xzWKYnogsxp2b6RpDD}/_ssgManifest.js +0 -0
@@ -264,18 +264,30 @@ export function verifyExpectedArtifact(
264
264
  const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
265
265
  // For unit types with no verifiable artifact (null path), the parent directory
266
266
  // is missing on disk — treat as stale completion state so the key gets evicted (#313).
267
- if (!absPath) return false;
268
- if (!existsSync(absPath)) return false;
267
+ if (!absPath) {
268
+ logWarning("recovery", `verify-fail ${unitType} ${unitId}: resolveExpectedArtifactPath returned null (parent dir missing)`);
269
+ return false;
270
+ }
271
+ if (!existsSync(absPath)) {
272
+ logWarning("recovery", `verify-fail ${unitType} ${unitId}: existsSync false for ${absPath}`);
273
+ return false;
274
+ }
269
275
 
270
276
  if (unitType === "validate-milestone") {
271
277
  const validationContent = readFileSync(absPath, "utf-8");
272
- if (!isValidationTerminal(validationContent)) return false;
278
+ if (!isValidationTerminal(validationContent)) {
279
+ logWarning("recovery", `verify-fail ${unitType} ${unitId}: validation not terminal (len=${validationContent.length}) at ${absPath}`);
280
+ return false;
281
+ }
273
282
  }
274
283
 
275
284
  if (unitType === "plan-milestone") {
276
285
  try {
277
286
  const roadmap = parseLegacyRoadmap(readFileSync(absPath, "utf-8"));
278
- if (roadmap.slices.length === 0) return false;
287
+ if (roadmap.slices.length === 0) {
288
+ logWarning("recovery", `verify-fail ${unitType} ${unitId}: roadmap has zero slices at ${absPath}`);
289
+ return false;
290
+ }
279
291
  } catch (err) {
280
292
  logWarning("recovery", `plan-milestone roadmap verification failed: ${err instanceof Error ? err.message : String(err)}`);
281
293
  return false;
@@ -292,7 +304,10 @@ export function verifyExpectedArtifact(
292
304
  // Accept checkbox-style (- [x] **T01: ...) or heading-style (### T01 -- / ### T01: / ### T01 —)
293
305
  const hasCheckboxTask = /^- \[[xX ]\] \*\*T\d+:/m.test(planContent);
294
306
  const hasHeadingTask = /^#{2,4}\s+T\d+\s*(?:--|—|:)/m.test(planContent);
295
- if (!hasCheckboxTask && !hasHeadingTask) return false;
307
+ if (!hasCheckboxTask && !hasHeadingTask) {
308
+ logWarning("recovery", `verify-fail ${unitType} ${unitId}: plan has no task checkbox/heading (len=${planContent.length}) at ${absPath}`);
309
+ return false;
310
+ }
296
311
  }
297
312
 
298
313
  // execute-task: DB status is authoritative. Fall back to checked-checkbox
@@ -349,10 +364,15 @@ export function verifyExpectedArtifact(
349
364
 
350
365
  if (taskIds && taskIds.length > 0) {
351
366
  const tasksDir = resolveTasksDir(base, mid, sid);
352
- if (tasksDir) {
353
- for (const tid of taskIds) {
354
- const taskPlanFile = join(tasksDir, `${tid}-PLAN.md`);
355
- if (!existsSync(taskPlanFile)) return false;
367
+ if (!tasksDir) {
368
+ logWarning("recovery", `verify-fail ${unitType} ${unitId}: resolveTasksDir returned null for ${mid}/${sid}`);
369
+ return false;
370
+ }
371
+ for (const tid of taskIds) {
372
+ const taskPlanFile = join(tasksDir, `${tid}-PLAN.md`);
373
+ if (!existsSync(taskPlanFile)) {
374
+ logWarning("recovery", `verify-fail ${unitType} ${unitId}: task plan missing ${taskPlanFile}`);
375
+ return false;
356
376
  }
357
377
  }
358
378
  }
@@ -52,8 +52,12 @@ export function registerHooks(
52
52
  resetToolCallLoopGuard();
53
53
  resetAskUserQuestionsCache();
54
54
  await syncServiceTierStatus(ctx);
55
- const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js");
56
- prepareWorkflowMcpForProject(ctx, process.cwd());
55
+ // Skip MCP auto-prep when running inside an auto-worktree (see session_switch below).
56
+ const { isInAutoWorktree } = await import("../auto-worktree.js");
57
+ if (!isInAutoWorktree(process.cwd())) {
58
+ const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js");
59
+ prepareWorkflowMcpForProject(ctx, process.cwd());
60
+ }
57
61
 
58
62
  // Apply show_token_cost preference (#1515)
59
63
  try {
@@ -94,8 +98,15 @@ export function registerHooks(
94
98
  resetAskUserQuestionsCache();
95
99
  clearDiscussionFlowState();
96
100
  await syncServiceTierStatus(ctx);
97
- const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js");
98
- prepareWorkflowMcpForProject(ctx, process.cwd());
101
+ // Skip MCP auto-prep when running inside an auto-worktree. The worktree
102
+ // already has .mcp.json from createAutoWorktree, and re-running the writer
103
+ // post-chdir rewrites the file mid-run (non-idempotent due to cwd-relative
104
+ // CLI path resolution), dirtying the tree and breaking the milestone merge.
105
+ const { isInAutoWorktree } = await import("../auto-worktree.js");
106
+ if (!isInAutoWorktree(process.cwd())) {
107
+ const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js");
108
+ prepareWorkflowMcpForProject(ctx, process.cwd());
109
+ }
99
110
  loadToolApiKeys();
100
111
  });
101
112
 
@@ -314,7 +325,7 @@ export function registerHooks(
314
325
  // ── Safety harness: evidence collection + destructive command warnings ──
315
326
  pi.on("tool_call", async (event, ctx) => {
316
327
  if (!isAutoActive()) return;
317
- safetyRecordToolCall(event.toolName, event.input as Record<string, unknown>);
328
+ safetyRecordToolCall(event.toolCallId, event.toolName, event.input as Record<string, unknown>);
318
329
 
319
330
  // Destructive command classification (warn only, never block)
320
331
  if (isToolCallEventType("bash", event)) {
@@ -1,6 +1,6 @@
1
1
  // GSD Extension — Unified Cache Invalidation
2
2
  //
3
- // Three module-scoped caches exist across the GSD extension:
3
+ // Three module-scoped read caches exist across the GSD extension:
4
4
  // 1. State cache (state.ts) — memoized deriveState() result
5
5
  // 2. Path cache (paths.ts) — directory listing results (readdirSync)
6
6
  // 3. Parse cache (files.ts) — parsed markdown file results
@@ -8,22 +8,33 @@
8
8
  // After any file write that changes .gsd/ contents, all three must be
9
9
  // invalidated together to prevent stale reads. This module provides a
10
10
  // single function that clears all three atomically.
11
+ //
12
+ // NOTE: The DB `artifacts` table is NOT included here. Earlier versions
13
+ // called clearArtifacts() as part of this bundle (#793), intending to
14
+ // force deriveState() to re-parse from disk when files were edited
15
+ // out-of-band. But invalidateAllCaches() fires on every post-unit pass,
16
+ // so bundling a DESTRUCTIVE `DELETE FROM artifacts` with routine cache
17
+ // invalidation meant every row written by saveArtifactToDb / writeAndStore
18
+ // was wiped within seconds — leaving the milestone completed on disk but
19
+ // the `artifacts` table empty and the agent looping on "file exists but
20
+ // DB record missing" recovery calls. If a call site genuinely needs the
21
+ // artifact table cleared after an out-of-band file mutation, it should
22
+ // invoke clearArtifacts() from gsd-db.js explicitly — do not add it back
23
+ // here.
11
24
 
12
25
  import { invalidateStateCache } from './state.js';
13
26
  import { clearPathCache } from './paths.js';
14
27
  import { clearParseCache } from './files.js';
15
- import { clearArtifacts } from './gsd-db.js';
16
28
 
17
29
  /**
18
- * Invalidate all GSD runtime caches in one call.
30
+ * Invalidate all GSD runtime read caches in one call.
19
31
  *
20
32
  * Call this after file writes, milestone transitions, merge reconciliation,
21
33
  * or any operation that changes .gsd/ contents on disk. Forgetting to clear
22
- * any single cache causes stale reads (see #431, #793).
34
+ * any single cache causes stale reads (see #431).
23
35
  */
24
36
  export function invalidateAllCaches(): void {
25
37
  invalidateStateCache();
26
38
  clearPathCache();
27
39
  clearParseCache();
28
- clearArtifacts();
29
40
  }
@@ -272,7 +272,7 @@ export function checkAutoStartAfterDiscuss(): boolean {
272
272
  try { unlinkSync(manifestPath); } catch (e) { logWarning("guided", `manifest unlink failed: ${(e as Error).message}`); }
273
273
 
274
274
  pendingAutoStartMap.delete(basePath);
275
- ctx.ui.notify(`Milestone ${milestoneId} ready.`, "info");
275
+ ctx.ui.notify(`Milestone ${milestoneId} ready.`, "success");
276
276
  startAutoDetached(ctx, pi, basePath, false, { step });
277
277
  return true;
278
278
  }
@@ -68,11 +68,11 @@ export function getFilePaths(): string[] {
68
68
  * Record a tool call at dispatch time (before execution).
69
69
  * Exit codes and output are filled in by recordToolResult after execution.
70
70
  */
71
- export function recordToolCall(toolName: string, input: Record<string, unknown>): void {
71
+ export function recordToolCall(toolCallId: string, toolName: string, input: Record<string, unknown>): void {
72
72
  if (toolName === "bash" || toolName === "Bash") {
73
73
  unitEvidence.push({
74
74
  kind: "bash",
75
- toolCallId: "",
75
+ toolCallId,
76
76
  command: String(input.command ?? ""),
77
77
  exitCode: -1,
78
78
  outputSnippet: "",
@@ -81,14 +81,14 @@ export function recordToolCall(toolName: string, input: Record<string, unknown>)
81
81
  } else if (toolName === "write" || toolName === "Write") {
82
82
  unitEvidence.push({
83
83
  kind: "write",
84
- toolCallId: "",
84
+ toolCallId,
85
85
  path: String(input.file_path ?? input.path ?? ""),
86
86
  timestamp: Date.now(),
87
87
  });
88
88
  } else if (toolName === "edit" || toolName === "Edit") {
89
89
  unitEvidence.push({
90
90
  kind: "edit",
91
- toolCallId: "",
91
+ toolCallId,
92
92
  path: String(input.file_path ?? input.path ?? ""),
93
93
  timestamp: Date.now(),
94
94
  });
@@ -96,8 +96,9 @@ export function recordToolCall(toolName: string, input: Record<string, unknown>)
96
96
  }
97
97
 
98
98
  /**
99
- * Record a tool execution result. Matches the most recent unresolved entry
100
- * of the same kind and fills in the toolCallId, exit code, and output.
99
+ * Record a tool execution result. Matches the entry by toolCallId (assigned
100
+ * at dispatch time) and fills in exit code + output. Prior versions matched
101
+ * by `kind + empty-string` which corrupted parallel tool calls.
101
102
  */
102
103
  export function recordToolResult(
103
104
  toolCallId: string,
@@ -105,36 +106,19 @@ export function recordToolResult(
105
106
  result: unknown,
106
107
  isError: boolean,
107
108
  ): void {
108
- const normalizedName = toolName.toLowerCase();
109
-
110
- if (normalizedName === "bash") {
111
- const entry = findLastUnresolved("bash") as BashEvidence | undefined;
112
- if (entry) {
113
- entry.toolCallId = toolCallId;
114
- const text = extractResultText(result);
115
- entry.outputSnippet = text.slice(0, 500);
116
- const exitMatch = text.match(/Command exited with code (\d+)/);
117
- entry.exitCode = exitMatch ? Number(exitMatch[1]) : (isError ? 1 : 0);
118
- }
119
- } else if (normalizedName === "write" || normalizedName === "edit") {
120
- const entry = findLastUnresolved(normalizedName as "write" | "edit");
121
- if (entry) {
122
- entry.toolCallId = toolCallId;
123
- }
109
+ const entry = unitEvidence.find(e => e.toolCallId === toolCallId);
110
+ if (!entry) return;
111
+
112
+ if (entry.kind === "bash") {
113
+ const text = extractResultText(result);
114
+ entry.outputSnippet = text.slice(0, 500);
115
+ const exitMatch = text.match(/Command exited with code (\d+)/);
116
+ entry.exitCode = exitMatch ? Number(exitMatch[1]) : (isError ? 1 : 0);
124
117
  }
125
118
  }
126
119
 
127
120
  // ─── Internals ──────────────────────────────────────────────────────────────
128
121
 
129
- function findLastUnresolved(kind: string): EvidenceEntry | undefined {
130
- for (let i = unitEvidence.length - 1; i >= 0; i--) {
131
- if (unitEvidence[i].kind === kind && unitEvidence[i].toolCallId === "") {
132
- return unitEvidence[i];
133
- }
134
- }
135
- return undefined;
136
- }
137
-
138
122
  function extractResultText(result: unknown): string {
139
123
  if (typeof result === "string") return result;
140
124
  if (result && typeof result === "object") {
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Regression test: invalidateAllCaches() must NOT wipe the artifacts table.
3
+ *
4
+ * Prior to this fix, `cache.ts` bundled `clearArtifacts()` (which runs
5
+ * `DELETE FROM artifacts`) into `invalidateAllCaches()`. That helper fires
6
+ * on every post-unit pass, so rows written by `saveArtifactToDb` and
7
+ * `writeAndStore` (RESEARCH, CONTEXT, VALIDATION, ASSESSMENT, PLAN,
8
+ * ROADMAP, task PLAN, task SUMMARY) got deleted within seconds of being
9
+ * written. The milestone completed on disk, but `SELECT COUNT(*) FROM
10
+ * artifacts` returned 0, and the agent fell into a "file exists but DB
11
+ * record missing" recovery loop.
12
+ *
13
+ * The artifacts table is a write-through store, not a read cache. Routine
14
+ * cache invalidation must preserve its contents.
15
+ *
16
+ * Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
17
+ */
18
+
19
+ import { describe, test, afterEach } from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { readFileSync } from "node:fs";
22
+ import { resolve } from "node:path";
23
+
24
+ import { invalidateAllCaches } from "../cache.ts";
25
+ import {
26
+ openDatabase,
27
+ closeDatabase,
28
+ insertArtifact,
29
+ isDbAvailable,
30
+ _getAdapter,
31
+ } from "../gsd-db.ts";
32
+
33
+ afterEach(() => {
34
+ if (isDbAvailable()) {
35
+ try {
36
+ closeDatabase();
37
+ } catch {
38
+ /* best-effort teardown */
39
+ }
40
+ }
41
+ });
42
+
43
+ describe("invalidateAllCaches() must preserve the artifacts table", () => {
44
+ test("rows survive a single invalidate call", () => {
45
+ const opened = openDatabase(":memory:");
46
+ assert.equal(opened, true, "in-memory DB must open");
47
+
48
+ insertArtifact({
49
+ path: "milestones/M001/slices/S01/S01-RESEARCH.md",
50
+ artifact_type: "RESEARCH",
51
+ milestone_id: "M001",
52
+ slice_id: "S01",
53
+ task_id: null,
54
+ full_content: "# Research\n\nFindings go here.\n",
55
+ });
56
+
57
+ invalidateAllCaches();
58
+
59
+ const adapter = _getAdapter();
60
+ assert.ok(adapter, "adapter should be available");
61
+ const row = adapter!
62
+ .prepare(
63
+ "SELECT path, artifact_type, length(full_content) AS len FROM artifacts WHERE path = :path",
64
+ )
65
+ .get({ ":path": "milestones/M001/slices/S01/S01-RESEARCH.md" }) as
66
+ | { path: string; artifact_type: string; len: number }
67
+ | undefined;
68
+
69
+ assert.ok(
70
+ row,
71
+ "artifact row must still exist after invalidateAllCaches — this is the Phase B bug",
72
+ );
73
+ assert.equal(row!.artifact_type, "RESEARCH");
74
+ assert.ok((row!.len ?? 0) > 0, "full_content must not be truncated");
75
+ });
76
+
77
+ test("multiple rows for a full milestone survive repeated invalidates", () => {
78
+ openDatabase(":memory:");
79
+
80
+ const inserts = [
81
+ {
82
+ path: "milestones/M001/M001-ROADMAP.md",
83
+ artifact_type: "ROADMAP",
84
+ milestone_id: "M001",
85
+ slice_id: null,
86
+ task_id: null,
87
+ },
88
+ {
89
+ path: "milestones/M001/slices/S01/S01-RESEARCH.md",
90
+ artifact_type: "RESEARCH",
91
+ milestone_id: "M001",
92
+ slice_id: "S01",
93
+ task_id: null,
94
+ },
95
+ {
96
+ path: "milestones/M001/slices/S01/S01-PLAN.md",
97
+ artifact_type: "PLAN",
98
+ milestone_id: "M001",
99
+ slice_id: "S01",
100
+ task_id: null,
101
+ },
102
+ {
103
+ path: "milestones/M001/slices/S01/tasks/T01-PLAN.md",
104
+ artifact_type: "PLAN",
105
+ milestone_id: "M001",
106
+ slice_id: "S01",
107
+ task_id: "T01",
108
+ },
109
+ {
110
+ path: "milestones/M001/slices/S01/tasks/T01-SUMMARY.md",
111
+ artifact_type: "SUMMARY",
112
+ milestone_id: "M001",
113
+ slice_id: "S01",
114
+ task_id: "T01",
115
+ },
116
+ {
117
+ path: "milestones/M001/M001-SUMMARY.md",
118
+ artifact_type: "SUMMARY",
119
+ milestone_id: "M001",
120
+ slice_id: null,
121
+ task_id: null,
122
+ },
123
+ ];
124
+
125
+ for (const i of inserts) {
126
+ insertArtifact({ ...i, full_content: `# ${i.artifact_type} content\n` });
127
+ }
128
+
129
+ // Simulate a full milestone's worth of post-unit cycles.
130
+ for (let i = 0; i < 10; i++) {
131
+ invalidateAllCaches();
132
+ }
133
+
134
+ const adapter = _getAdapter()!;
135
+ const count = (
136
+ adapter.prepare("SELECT COUNT(*) AS n FROM artifacts").get() as { n: number }
137
+ ).n;
138
+
139
+ assert.equal(
140
+ count,
141
+ inserts.length,
142
+ `all ${inserts.length} artifact rows must survive repeated invalidate calls; got ${count}`,
143
+ );
144
+ });
145
+ });
146
+
147
+ describe("cache.ts must not re-import clearArtifacts into invalidateAllCaches", () => {
148
+ const src = readFileSync(
149
+ resolve(process.cwd(), "src", "resources", "extensions", "gsd", "cache.ts"),
150
+ "utf-8",
151
+ );
152
+
153
+ test("clearArtifacts is not imported from gsd-db", () => {
154
+ assert.ok(
155
+ !/import\s*\{[^}]*clearArtifacts[^}]*\}\s*from\s*['"]\.\/gsd-db/.test(src),
156
+ "cache.ts must not import clearArtifacts — it causes the artifacts-table-wipe regression",
157
+ );
158
+ });
159
+
160
+ test("invalidateAllCaches does not call clearArtifacts", () => {
161
+ const fnIdx = src.indexOf("function invalidateAllCaches");
162
+ assert.ok(fnIdx !== -1);
163
+ const body = src.slice(fnIdx, fnIdx + 1000);
164
+ assert.ok(
165
+ !/\bclearArtifacts\s*\(/.test(body),
166
+ "invalidateAllCaches must not call clearArtifacts() — it wipes the write-through store",
167
+ );
168
+ });
169
+
170
+ test("cache.ts documents why clearArtifacts is not bundled here", () => {
171
+ // Future reviewers need to see the rationale or they'll re-add it.
172
+ assert.ok(
173
+ /artifacts.*NOT included|write-through store/i.test(src),
174
+ "cache.ts must explain why the artifacts table is NOT invalidated here",
175
+ );
176
+ });
177
+ });