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

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 (113) hide show
  1. package/dist/resources/extensions/gsd/auto/phases.js +51 -6
  2. package/dist/resources/extensions/gsd/auto-model-selection.js +3 -3
  3. package/dist/resources/extensions/gsd/auto-recovery.js +24 -10
  4. package/dist/resources/extensions/gsd/auto-worktree.js +2 -0
  5. package/dist/resources/extensions/gsd/bootstrap/provider-error-resume.js +5 -3
  6. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +16 -5
  7. package/dist/resources/extensions/gsd/cache.js +16 -5
  8. package/dist/resources/extensions/gsd/commands/catalog.js +6 -1
  9. package/dist/resources/extensions/gsd/commands/handlers/core.js +5 -1
  10. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +50 -3
  11. package/dist/resources/extensions/gsd/docs/preferences-reference.md +2 -0
  12. package/dist/resources/extensions/gsd/guided-flow.js +8 -6
  13. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  14. package/dist/resources/extensions/gsd/preferences-validation.js +10 -0
  15. package/dist/resources/extensions/gsd/preferences.js +5 -0
  16. package/dist/resources/extensions/gsd/safety/evidence-collector.js +15 -30
  17. package/dist/resources/extensions/gsd/templates/PREFERENCES.md +1 -0
  18. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  19. package/dist/web/standalone/.next/BUILD_ID +1 -1
  20. package/dist/web/standalone/.next/app-path-routes-manifest.json +13 -13
  21. package/dist/web/standalone/.next/build-manifest.json +2 -2
  22. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  23. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.html +1 -1
  40. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app-paths-manifest.json +13 -13
  47. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  48. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  49. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  50. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  51. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  52. package/package.json +1 -1
  53. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  54. package/packages/mcp-server/dist/workflow-tools.js +88 -6
  55. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  56. package/packages/mcp-server/src/workflow-tools.ts +95 -10
  57. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  58. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/chat-frame-compaction-tone.test.d.ts +2 -0
  59. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/chat-frame-compaction-tone.test.d.ts.map +1 -0
  60. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/chat-frame-compaction-tone.test.js +61 -0
  61. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/chat-frame-compaction-tone.test.js.map +1 -0
  62. package/packages/pi-coding-agent/dist/modes/interactive/components/chat-frame.d.ts +1 -1
  63. package/packages/pi-coding-agent/dist/modes/interactive/components/chat-frame.d.ts.map +1 -1
  64. package/packages/pi-coding-agent/dist/modes/interactive/components/chat-frame.js +9 -3
  65. package/packages/pi-coding-agent/dist/modes/interactive/components/chat-frame.js.map +1 -1
  66. package/packages/pi-coding-agent/dist/modes/interactive/components/compaction-summary-message.d.ts +8 -5
  67. package/packages/pi-coding-agent/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -1
  68. package/packages/pi-coding-agent/dist/modes/interactive/components/compaction-summary-message.js +27 -13
  69. package/packages/pi-coding-agent/dist/modes/interactive/components/compaction-summary-message.js.map +1 -1
  70. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +8 -0
  71. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  72. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +17 -0
  73. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  74. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +1 -0
  75. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  76. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +17 -0
  77. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  78. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/chat-frame-compaction-tone.test.ts +92 -0
  79. package/packages/pi-coding-agent/src/modes/interactive/components/chat-frame.ts +12 -4
  80. package/packages/pi-coding-agent/src/modes/interactive/components/compaction-summary-message.ts +36 -15
  81. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +17 -0
  82. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +19 -0
  83. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  84. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  85. package/packages/pi-tui/dist/tui.js +9 -2
  86. package/packages/pi-tui/dist/tui.js.map +1 -1
  87. package/packages/pi-tui/src/tui.ts +9 -1
  88. package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
  89. package/src/resources/extensions/gsd/auto/phases.ts +70 -6
  90. package/src/resources/extensions/gsd/auto-model-selection.ts +3 -3
  91. package/src/resources/extensions/gsd/auto-recovery.ts +29 -9
  92. package/src/resources/extensions/gsd/auto-worktree.ts +1 -0
  93. package/src/resources/extensions/gsd/bootstrap/provider-error-resume.ts +5 -3
  94. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +16 -5
  95. package/src/resources/extensions/gsd/cache.ts +16 -5
  96. package/src/resources/extensions/gsd/commands/catalog.ts +6 -1
  97. package/src/resources/extensions/gsd/commands/handlers/core.ts +5 -1
  98. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +57 -3
  99. package/src/resources/extensions/gsd/docs/preferences-reference.md +2 -0
  100. package/src/resources/extensions/gsd/guided-flow.ts +4 -2
  101. package/src/resources/extensions/gsd/preferences-types.ts +6 -0
  102. package/src/resources/extensions/gsd/preferences-validation.ts +10 -0
  103. package/src/resources/extensions/gsd/preferences.ts +6 -0
  104. package/src/resources/extensions/gsd/safety/evidence-collector.ts +15 -31
  105. package/src/resources/extensions/gsd/templates/PREFERENCES.md +1 -0
  106. package/src/resources/extensions/gsd/tests/artifacts-table-preserved-on-cache-invalidate.test.ts +177 -0
  107. package/src/resources/extensions/gsd/tests/auto-retry-mcp-churn-fixes.test.ts +272 -0
  108. package/src/resources/extensions/gsd/tests/auto-warning-noise-regression.test.ts +117 -0
  109. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +3 -3
  110. package/src/resources/extensions/gsd/tests/preferences.test.ts +145 -0
  111. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +57 -2
  112. /package/dist/web/standalone/.next/static/{3U-oZ5FT59BM7sm2GInic → wuiYdNtJdo9ISED55DAkz}/_buildManifest.js +0 -0
  113. /package/dist/web/standalone/.next/static/{3U-oZ5FT59BM7sm2GInic → wuiYdNtJdo9ISED55DAkz}/_ssgManifest.js +0 -0
@@ -4,7 +4,7 @@ import type { GSDState } from "../../types.js";
4
4
 
5
5
  import { computeProgressScore, formatProgressLine } from "../../progress-score.js";
6
6
  import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath, getProjectGSDPreferencesPath } from "../../preferences.js";
7
- import { ensurePreferencesFile, handlePrefs, handlePrefsMode, handlePrefsWizard } from "../../commands-prefs-wizard.js";
7
+ import { ensurePreferencesFile, handlePrefs, handlePrefsMode, handlePrefsWizard, handleLanguage } from "../../commands-prefs-wizard.js";
8
8
  import { runEnvironmentChecks } from "../../doctor-environment.js";
9
9
  import { deriveState } from "../../state.js";
10
10
  import { handleCmux } from "../../commands-cmux.js";
@@ -395,6 +395,10 @@ export async function handleCoreCommand(
395
395
  await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx);
396
396
  return true;
397
397
  }
398
+ if (trimmed === "language" || trimmed.startsWith("language ")) {
399
+ await handleLanguage(trimmed.replace(/^language\s*/, "").trim(), ctx);
400
+ return true;
401
+ }
398
402
  if (trimmed === "cmux" || trimmed.startsWith("cmux ")) {
399
403
  await handleCmux(trimmed.replace(/^cmux\s*/, "").trim(), ctx);
400
404
  return true;
@@ -756,8 +756,8 @@ export async function handlePrefsWizard(
756
756
  /** Wrap a YAML value in double quotes if it contains special characters. */
757
757
  export function yamlSafeString(val: unknown): string {
758
758
  if (typeof val !== "string") return String(val);
759
- if (/[:#{\[\]'"`,|>&*!?@%]/.test(val) || val.trim() !== val || val === "") {
760
- return `"${val.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
759
+ if (/[:#{\[\]'"`,|>&*!?@%\r\n]/.test(val) || val.trim() !== val || val === "") {
760
+ return `"${val.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "\\r").replace(/\n/g, "\\n")}"`;
761
761
  }
762
762
  return val;
763
763
  }
@@ -825,7 +825,7 @@ export function serializePreferencesToFrontmatter(prefs: Record<string, unknown>
825
825
  "dynamic_routing", "uok", "token_profile", "phases", "parallel",
826
826
  "auto_visualize", "auto_report",
827
827
  "verification_commands", "verification_auto_fix", "verification_max_retries",
828
- "search_provider", "context_selection",
828
+ "search_provider", "context_selection", "language",
829
829
  ];
830
830
 
831
831
  const seen = new Set<string>();
@@ -862,3 +862,57 @@ export async function ensurePreferencesFile(
862
862
  ctx.ui.notify(`Using existing ${scope} GSD skill preferences at ${path}`, "info");
863
863
  }
864
864
  }
865
+
866
+ /**
867
+ * Handle `/gsd language [code]` — set or clear the global language preference.
868
+ * Without an argument, shows the current setting.
869
+ * Project-level override can be set by editing `.gsd/preferences.md` directly
870
+ * (project language overrides global when both are set).
871
+ */
872
+ export async function handleLanguage(args: string, ctx: ExtensionCommandContext): Promise<void> {
873
+ const path = getGlobalGSDPreferencesPath();
874
+ const lang = args.trim();
875
+
876
+ // Show current setting when called without argument
877
+ if (!lang) {
878
+ const loaded = loadGlobalGSDPreferences();
879
+ const current = loaded?.preferences.language;
880
+ if (current) {
881
+ ctx.ui.notify(`Current language preference: ${current}\nUse /gsd language <name> to change, or /gsd language off to clear.`, "info");
882
+ } else {
883
+ ctx.ui.notify("No language preference set. Use /gsd language <name> to set one (e.g. /gsd language Chinese).", "info");
884
+ }
885
+ return;
886
+ }
887
+
888
+ // Ensure preferences file exists with the canonical template
889
+ await ensurePreferencesFile(path, ctx, "global");
890
+
891
+ // Read via the same validated path as other handlers
892
+ const existing = loadGlobalGSDPreferences();
893
+ const prefs: Record<string, unknown> = existing?.preferences ? { ...existing.preferences } : { version: 1 };
894
+
895
+ if (lang === "off" || lang === "none" || lang === "clear") {
896
+ delete prefs.language;
897
+ ctx.ui.notify("Language preference cleared. GSD will use the default language.", "info");
898
+ } else {
899
+ // Validate before writing — reject values that would fail on next load
900
+ if (lang.length > 50 || /[\r\n]/.test(lang)) {
901
+ ctx.ui.notify(
902
+ "Language value must be 50 characters or fewer with no newlines (e.g. /gsd language Chinese).",
903
+ "warning",
904
+ );
905
+ return;
906
+ }
907
+ prefs.language = lang;
908
+ ctx.ui.notify(`Language preference set to: ${lang}\nGSD will now respond in ${lang} across all sessions.`, "info");
909
+ }
910
+
911
+ const rawContent = existsSync(path) ? readFileSync(path, "utf-8") : `---\nversion: 1\n---\n`;
912
+ const frontmatter = serializePreferencesToFrontmatter(prefs);
913
+ const body = extractBodyAfterFrontmatter(rawContent)
914
+ ?? "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n";
915
+ await saveFile(path, `---\n${frontmatter}---${body}`);
916
+ await ctx.waitForIdle();
917
+ await ctx.reload();
918
+ }
@@ -102,6 +102,8 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
102
102
 
103
103
  - `custom_instructions`: extra durable instructions related to skill use. For operational project knowledge (recurring rules, gotchas, patterns), use `.gsd/KNOWLEDGE.md` instead — it's injected into every agent prompt automatically and agents can append to it during execution.
104
104
 
105
+ - `language`: preferred response language for all GSD interactions. Accepts any language name or code — `"Chinese"`, `"zh"`, `"German"`, `"de"`, `"日本語"`, etc. When set, GSD injects "Always respond in \<language\>" into every agent's system prompt, including after `/clear`. Quickest way to set it: `/gsd language <name>`. To clear: `/gsd language off`.
106
+
105
107
  - `models`: per-stage model selection (applies to both auto-mode and guided-flow dispatches). Keys: `research`, `planning`, `discuss`, `execution`, `execution_simple`, `completion`, `validation`, `subagent`. Values can be:
106
108
  - Simple string: `"claude-sonnet-4-6"` — single model, no fallbacks
107
109
  - Provider-qualified string: `"bedrock/claude-sonnet-4-6"` — targets a specific provider when the same model ID exists across multiple providers
@@ -269,10 +269,12 @@ export function checkAutoStartAfterDiscuss(): boolean {
269
269
  } catch (e) { logWarning("guided", `CONTEXT-DRAFT.md unlink failed: ${(e as Error).message}`); }
270
270
 
271
271
  // Cleanup: remove discussion manifest after auto-start (only needed during discussion)
272
- try { unlinkSync(manifestPath); } catch (e) { logWarning("guided", `manifest unlink failed: ${(e as Error).message}`); }
272
+ if (existsSync(manifestPath)) {
273
+ try { unlinkSync(manifestPath); } catch (e) { logWarning("guided", `manifest unlink failed: ${(e as Error).message}`); }
274
+ }
273
275
 
274
276
  pendingAutoStartMap.delete(basePath);
275
- ctx.ui.notify(`Milestone ${milestoneId} ready.`, "info");
277
+ ctx.ui.notify(`Milestone ${milestoneId} ready.`, "success");
276
278
  startAutoDetached(ctx, pi, basePath, false, { step });
277
279
  return true;
278
280
  }
@@ -115,6 +115,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
115
115
  "discuss_web_research",
116
116
  "discuss_depth",
117
117
  "flat_rate_providers",
118
+ "language",
118
119
  ]);
119
120
 
120
121
  /** Canonical list of all dispatch unit types. */
@@ -403,6 +404,11 @@ export interface GSDPreferences {
403
404
  * same regardless of model. Case-insensitive.
404
405
  */
405
406
  flat_rate_providers?: string[];
407
+ /**
408
+ * Language preference for GSD responses. Accepts any language name or code
409
+ * (e.g. "Chinese", "zh", "German", "de", "日本語"). Persists across /clear.
410
+ */
411
+ language?: string;
406
412
  }
407
413
 
408
414
  export interface LoadedGSDPreferences {
@@ -1107,5 +1107,15 @@ export function validatePreferences(preferences: GSDPreferences): {
1107
1107
  }
1108
1108
  }
1109
1109
 
1110
+ // ─── Language ────────────────────────────────────────────────────────
1111
+ if (preferences.language !== undefined) {
1112
+ const trimmed = typeof preferences.language === "string" ? preferences.language.trim() : undefined;
1113
+ if (trimmed && trimmed.length <= 50 && !/[\r\n]/.test(trimmed)) {
1114
+ validated.language = trimmed;
1115
+ } else {
1116
+ errors.push(`language must be a non-empty string up to 50 characters with no newlines (e.g. "Chinese", "de", "日本語")`);
1117
+ }
1118
+ }
1119
+
1110
1120
  return { preferences: validated, errors, warnings };
1111
1121
  }
@@ -447,6 +447,7 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
447
447
  slice_parallel: (base.slice_parallel || override.slice_parallel)
448
448
  ? { ...(base.slice_parallel ?? {}), ...(override.slice_parallel ?? {}) }
449
449
  : undefined,
450
+ language: override.language ?? base.language,
450
451
  };
451
452
  }
452
453
 
@@ -562,6 +563,11 @@ export function renderPreferencesForSystemPrompt(preferences: GSDPreferences, re
562
563
  }
563
564
  }
564
565
 
566
+ if (preferences.language) {
567
+ const safeLang = preferences.language.replace(/[\r\n]/g, " ").slice(0, 50);
568
+ lines.push(`- Language: Always respond in ${safeLang}.`);
569
+ }
570
+
565
571
  return lines.join("\n");
566
572
  }
567
573
 
@@ -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") {
@@ -89,6 +89,7 @@ remote_questions:
89
89
  uat_dispatch:
90
90
  post_unit_hooks: []
91
91
  pre_dispatch_hooks: []
92
+ # language:
92
93
  # experimental:
93
94
  # rtk: false
94
95
  ---
@@ -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
+ });