@opengsd/gsd-pi 1.3.0-dev.965d1788 → 1.3.0-dev.cca9c184

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 (168) hide show
  1. package/dist/cli.js +17 -4
  2. package/dist/headless-answers.js +1 -1
  3. package/dist/headless-ui.d.ts +1 -1
  4. package/dist/headless-ui.js +1 -1
  5. package/dist/headless.js +1 -1
  6. package/dist/resources/.managed-resources-content-hash +1 -1
  7. package/dist/resources/extensions/claude-code-cli/models.js +9 -0
  8. package/dist/resources/extensions/gsd/auto/session.js +8 -0
  9. package/dist/resources/extensions/gsd/auto-budget.js +5 -1
  10. package/dist/resources/extensions/gsd/auto-dispatch.js +14 -1
  11. package/dist/resources/extensions/gsd/auto-prompts.js +3 -3
  12. package/dist/resources/extensions/gsd/auto-recovery.js +3 -3
  13. package/dist/resources/extensions/gsd/auto.js +19 -10
  14. package/dist/resources/extensions/gsd/bootstrap/core-session-tools.js +1 -0
  15. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +5 -4
  16. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +2 -0
  17. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +25 -2
  18. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +32 -4
  19. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +15 -4
  20. package/dist/resources/extensions/gsd/commands/handlers/auto.js +1 -1
  21. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  22. package/dist/resources/extensions/gsd/commands-usage.js +1 -1
  23. package/dist/resources/extensions/gsd/db/engine.js +4 -1
  24. package/dist/resources/extensions/gsd/db-memory-fts-schema.js +34 -0
  25. package/dist/resources/extensions/gsd/delegation-policy.js +1 -1
  26. package/dist/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  27. package/dist/resources/extensions/gsd/doctor-engine-checks.js +192 -4
  28. package/dist/resources/extensions/gsd/exec-sandbox.js +8 -2
  29. package/dist/resources/extensions/gsd/git-service.js +56 -0
  30. package/dist/resources/extensions/gsd/gitignore.js +1 -1
  31. package/dist/resources/extensions/gsd/migrate-external.js +11 -2
  32. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +2 -2
  33. package/dist/resources/extensions/gsd/prompts/plan-slice.md +3 -3
  34. package/dist/resources/extensions/gsd/root-write-leak-guard.js +7 -1
  35. package/dist/resources/extensions/gsd/safety/destructive-guard.js +8 -1
  36. package/dist/resources/extensions/gsd/safety/evidence-collector.js +16 -1
  37. package/dist/resources/extensions/gsd/tools/plan-slice.js +59 -44
  38. package/dist/resources/extensions/gsd/tools/plan-task.js +28 -5
  39. package/dist/resources/extensions/gsd/unit-registry.js +4 -2
  40. package/dist/resources/extensions/gsd/workflow-manifest.js +122 -12
  41. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  42. package/dist/web/standalone/.next/BUILD_ID +1 -1
  43. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  44. package/dist/web/standalone/.next/build-manifest.json +2 -2
  45. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  46. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/index.html +1 -1
  63. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  70. package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
  71. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  72. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  73. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  74. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  75. package/package.json +1 -1
  76. package/packages/cloud-mcp-gateway/package.json +2 -2
  77. package/packages/contracts/package.json +1 -1
  78. package/packages/daemon/package.json +4 -4
  79. package/packages/gsd-agent-core/package.json +5 -5
  80. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.d.ts +2 -0
  81. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  82. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js +23 -5
  83. package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js.map +1 -1
  84. package/packages/gsd-agent-modes/package.json +7 -7
  85. package/packages/mcp-server/dist/workflow-tools.js +1 -1
  86. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  87. package/packages/mcp-server/package.json +4 -4
  88. package/packages/native/package.json +1 -1
  89. package/packages/pi-agent-core/dist/agent-loop.js +3 -2
  90. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  91. package/packages/pi-agent-core/package.json +1 -1
  92. package/packages/pi-ai/dist/models.generated.d.ts +46 -0
  93. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  94. package/packages/pi-ai/dist/models.generated.js +38 -0
  95. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  96. package/packages/pi-ai/package.json +1 -1
  97. package/packages/pi-coding-agent/package.json +7 -7
  98. package/packages/pi-tui/package.json +2 -2
  99. package/packages/rpc-client/package.json +2 -2
  100. package/pkg/package.json +1 -1
  101. package/src/resources/extensions/claude-code-cli/models.ts +9 -0
  102. package/src/resources/extensions/claude-code-cli/tests/index.test.ts +14 -0
  103. package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
  104. package/src/resources/extensions/gsd/auto/session.ts +8 -0
  105. package/src/resources/extensions/gsd/auto-budget.ts +12 -1
  106. package/src/resources/extensions/gsd/auto-dispatch.ts +14 -1
  107. package/src/resources/extensions/gsd/auto-prompts.ts +3 -3
  108. package/src/resources/extensions/gsd/auto-recovery.ts +3 -3
  109. package/src/resources/extensions/gsd/auto.ts +23 -8
  110. package/src/resources/extensions/gsd/bootstrap/core-session-tools.ts +1 -0
  111. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +5 -4
  112. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +2 -0
  113. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +32 -2
  114. package/src/resources/extensions/gsd/bootstrap/tests/write-gate-basepath.test.ts +49 -1
  115. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +34 -4
  116. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +16 -4
  117. package/src/resources/extensions/gsd/commands/handlers/auto.ts +1 -1
  118. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  119. package/src/resources/extensions/gsd/commands-usage.ts +1 -1
  120. package/src/resources/extensions/gsd/db/engine.ts +8 -1
  121. package/src/resources/extensions/gsd/db-memory-fts-schema.ts +45 -0
  122. package/src/resources/extensions/gsd/delegation-policy.ts +1 -1
  123. package/src/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  124. package/src/resources/extensions/gsd/doctor-engine-checks.ts +233 -5
  125. package/src/resources/extensions/gsd/doctor-types.ts +3 -0
  126. package/src/resources/extensions/gsd/exec-sandbox.ts +7 -2
  127. package/src/resources/extensions/gsd/git-service.ts +63 -0
  128. package/src/resources/extensions/gsd/gitignore.ts +1 -1
  129. package/src/resources/extensions/gsd/migrate-external.ts +8 -2
  130. package/src/resources/extensions/gsd/prompts/plan-milestone.md +2 -2
  131. package/src/resources/extensions/gsd/prompts/plan-slice.md +3 -3
  132. package/src/resources/extensions/gsd/root-write-leak-guard.ts +13 -1
  133. package/src/resources/extensions/gsd/safety/destructive-guard.ts +9 -1
  134. package/src/resources/extensions/gsd/safety/evidence-collector.ts +16 -1
  135. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +14 -5
  136. package/src/resources/extensions/gsd/tests/auto-pause-double-entry-guard.test.ts +36 -0
  137. package/src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts +182 -4
  138. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +35 -0
  139. package/src/resources/extensions/gsd/tests/auto-retry-mcp-churn-fixes.test.ts +30 -0
  140. package/src/resources/extensions/gsd/tests/db-memory-fts-schema.test.ts +58 -3
  141. package/src/resources/extensions/gsd/tests/destructive-guard.test.ts +44 -0
  142. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +143 -0
  143. package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +239 -2
  144. package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +16 -0
  145. package/src/resources/extensions/gsd/tests/memory-store.test.ts +48 -0
  146. package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +57 -0
  147. package/src/resources/extensions/gsd/tests/parsers-legacy-importers.test.ts +2 -0
  148. package/src/resources/extensions/gsd/tests/plan-slice.test.ts +103 -4
  149. package/src/resources/extensions/gsd/tests/plan-task.test.ts +44 -1
  150. package/src/resources/extensions/gsd/tests/post-mutation-hook.test.ts +4 -2
  151. package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +58 -0
  152. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +8 -6
  153. package/src/resources/extensions/gsd/tests/register-hooks-loop-guard-auto.test.ts +103 -0
  154. package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +20 -1
  155. package/src/resources/extensions/gsd/tests/runtime-invariant-modules.test.ts +8 -3
  156. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +62 -5
  157. package/src/resources/extensions/gsd/tests/tool-param-optionality.test.ts +6 -3
  158. package/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts +38 -0
  159. package/src/resources/extensions/gsd/tests/workflow-manifest.test.ts +44 -17
  160. package/src/resources/extensions/gsd/tests/workflow-mcp-preflight.test.ts +1 -0
  161. package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +8 -4
  162. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +6 -5
  163. package/src/resources/extensions/gsd/tools/plan-slice.ts +60 -47
  164. package/src/resources/extensions/gsd/tools/plan-task.ts +30 -5
  165. package/src/resources/extensions/gsd/unit-registry.ts +4 -2
  166. package/src/resources/extensions/gsd/workflow-manifest.ts +137 -6
  167. /package/dist/web/standalone/.next/static/{-ji77QJck2IKIwrGza1Bs → Eud_8lddsyi_13HIlB2i7}/_buildManifest.js +0 -0
  168. /package/dist/web/standalone/.next/static/{-ji77QJck2IKIwrGza1Bs → Eud_8lddsyi_13HIlB2i7}/_ssgManifest.js +0 -0
@@ -45,8 +45,8 @@ If slice research is inlined, trust its architectural findings, but verify every
45
45
  6. Include Threat Surface (Q3), Requirement Impact (Q4), proof level, observability, integration closure, Failure Modes (Q5), Load Profile (Q6), and Negative Tests (Q7) only where applicable.
46
46
  7. Right-size tasks. Simple slices can be one task; split only when context, ownership, or verification boundaries justify it.
47
47
  8. Task `verify` commands must be safe, simple commands. Do not use shell pipes, redirects, semicolons, backticks, command substitution, output trimming, or grep regex alternation with `|`. If multiple checks are needed, create a small test file and run it with `node --test` or a package test script, or use separate simple commands joined only with `&&`. For absence checks, verify a pattern does not exist with `! grep -q 'pattern' file` or `! rg -q 'pattern' file`; do not use `grep -c` or `rg -c` to assert zero matches because count commands exit 1 when they find zero matches, and the verification gate treats that as failure.
48
- 9. Each task needs the exact `gsd_plan_slice.tasks[]` shape: `taskId`, `title`, `description`, `estimate`, `files`, `verify`, `inputs`, `expectedOutput`, and optional `observabilityImpact`. `description` should contain the Why / Do / Done-when narrative. Any npm package name in a task description's install/add instructions (`npm install`, `yarn add`, `pnpm add`) or in `require('...')` or `import ... from '...'` code examples is subject to pre-execution registry validation; reference only packages that exist on the public npm registry, and remove or correct any package name that is not real. `files`, `inputs`, and `expectedOutput` must be JSON arrays of strings, even when there is only one path (for example, `"inputs": ["src/index.ts"]`, never `"inputs": "src/index.ts"`). Use paths relative to `{{workingDirectory}}`; do not put absolute paths to the original checkout or any directory outside `{{workingDirectory}}` in `files`, `inputs`, `expectedOutput`, or verification commands. **`expectedOutput` must only list files the task actually creates or overwrites on disk.** Do NOT include files the task merely reads, verifies, tests, or describes — those belong in `inputs`, `verify`, `description`, or slice success criteria. If a task is a pure verification or test task that produces no new files, `expectedOutput` must be `[]`; if it writes a test-result log or assertion output file, list only that concrete file path. A file that does not yet exist on disk and is needed as an `input` must be produced by an earlier task's `expectedOutput` — if no prior task creates it, add a task before this one that does. Never list GSD planning artifacts — anything under `.gsd/`, `.planning/`, or `.audits/`, or artifact names like `M001-CONTEXT.md` / `S01-PLAN.md` — in `inputs`, `files`, or `expectedOutput`: their content is preloaded as context, and they are written by workflow tools, not by tasks.
49
- 10. Persist with `gsd_plan_slice` using `milestoneId`, `sliceId`, `goal`, optional `successCriteria`/`proofLevel`/`integrationClosure`/`observabilityImpact`, and `tasks`. `gsd_plan_slice` handles task persistence transactionally and renders `{{outputPath}}` plus task plans; do not call `gsd_plan_task`. The DB-backed tool is the canonical write path. Do **not** rely on direct `PLAN.md` writes as the source of truth.
48
+ 9. Each task persisted with `gsd_plan_task` needs the exact shape: `milestoneId`, `sliceId`, `taskId`, `title`, `description`, `estimate`, `files`, `verify`, `inputs`, `expectedOutput`, and optional `observabilityImpact`. `description` should contain the Why / Do / Done-when narrative. Any npm package name in a task description's install/add instructions (`npm install`, `yarn add`, `pnpm add`) or in `require('...')` or `import ... from '...'` code examples is subject to pre-execution registry validation; reference only packages that exist on the public npm registry, and remove or correct any package name that is not real. `files`, `inputs`, and `expectedOutput` must be JSON arrays of strings, even when there is only one path (for example, `"inputs": ["src/index.ts"]`, never `"inputs": "src/index.ts"`). Use paths relative to `{{workingDirectory}}`; do not put absolute paths to the original checkout or any directory outside `{{workingDirectory}}` in `files`, `inputs`, `expectedOutput`, or verification commands. **`expectedOutput` must only list files the task actually creates or overwrites on disk.** Do NOT include files the task merely reads, verifies, tests, or describes — those belong in `inputs`, `verify`, `description`, or slice success criteria. If a task is a pure verification or test task that produces no new files, `expectedOutput` must be `[]`; if it writes a test-result log or assertion output file, list only that concrete file path. A file that does not yet exist on disk and is needed as an `input` must be produced by an earlier task's `expectedOutput` — if no prior task creates it, add a task before this one that does. Never list GSD planning artifacts — anything under `.gsd/`, `.planning/`, or `.audits/`, or artifact names like `M001-CONTEXT.md` / `S01-PLAN.md` — in `inputs`, `files`, or `expectedOutput`: their content is preloaded as context, and they are written by workflow tools, not by tasks.
49
+ 10. Persist incrementally through DB-backed tools. First call `gsd_plan_slice` with `milestoneId`, `sliceId`, `goal`, and optional `successCriteria`/`proofLevel`/`integrationClosure`/`observabilityImpact`; omit `tasks` for this metadata call. Then call `gsd_plan_task` once per task, using the task shape above. Each successful tool call saves progress and re-renders `{{outputPath}}` from DB. The DB-backed tools are the canonical write path. Do **not** rely on direct `PLAN.md` writes as the source of truth.
50
50
  11. Self-audit before finishing: goal/demo closure, requirement coverage, deliverable coverage audit (cross-check every file listed in CONTEXT.md `## Scope` / `### In Scope` against task `files` or `expectedOutput`), locked decisions, concrete paths, dependency order, wiring, scope size, proof truthfulness, feature completeness, and quality gates. Quality gates: non-trivial slices/tasks include specific Q3-Q7 coverage where applicable.
51
51
  12. If planning creates structural decisions, call `gsd_decision_save` for each; the tool persists the decision and regenerates `.gsd/DECISIONS.md`.
52
52
  13. {{commitInstruction}}
@@ -55,6 +55,6 @@ The slice directory already exists. Do not mkdir.
55
55
 
56
56
  **Autonomous execution:** no human is available. Do not call `ask_user_questions` or `secure_env_collect`; make reasonable assumptions and document them.
57
57
 
58
- **You MUST call `gsd_plan_slice` to persist planning state before finishing, unless the stale-path safety rule above stops the unit before safe planning can occur.**
58
+ **You MUST call `gsd_plan_slice` and then `gsd_plan_task` once for each planned task before finishing, unless the stale-path safety rule above stops the unit before safe planning can occur.**
59
59
 
60
60
  When done, say: "Slice {{sliceId}} planned." Say this exactly once — if you already said it in a prior message, do not repeat it.
@@ -5,8 +5,18 @@ import { execFileSync } from "node:child_process";
5
5
  import { createHash } from "node:crypto";
6
6
  import { existsSync, readFileSync, statSync } from "node:fs";
7
7
  import { join } from "node:path";
8
+ import { GSD_RUNTIME_PATTERNS } from "./gitignore.js";
8
9
 
9
10
  const MAX_SYNC_FINGERPRINT_BYTES = 1_500_000_000;
11
+ const ROOT_RUNTIME_PATH_PREFIXES = Array.from(
12
+ new Set(
13
+ GSD_RUNTIME_PATTERNS.map((pattern) => {
14
+ const path = pattern.replace(/\/+$/, "");
15
+ const slash = path.indexOf("/");
16
+ return slash === -1 ? path : path.slice(0, slash);
17
+ }),
18
+ ),
19
+ );
10
20
 
11
21
  export interface RootDirtyEntry {
12
22
  path: string;
@@ -25,7 +35,9 @@ export interface RootWriteLeak {
25
35
  export type RootDirtySnapshot = Map<string, RootDirtyEntry>;
26
36
 
27
37
  function isRootRuntimePath(path: string): boolean {
28
- return path === ".gsd" || path.startsWith(".gsd/");
38
+ return ROOT_RUNTIME_PATH_PREFIXES.some(
39
+ (prefix) => path === prefix || path.startsWith(`${prefix}/`),
40
+ );
29
41
  }
30
42
 
31
43
  function fileFingerprint(rootPath: string, relPath: string): string {
@@ -29,6 +29,13 @@ const DESTRUCTIVE_PATTERNS: readonly DestructivePattern[] = [
29
29
  { pattern: /\bkubectl\s+(delete|apply)\b/i, label: "kubectl mutation" },
30
30
  ];
31
31
 
32
+ function stripQuotedAndComments(command: string): string {
33
+ return command
34
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
35
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''")
36
+ .replace(/(^|\s)#.*$/g, "$1");
37
+ }
38
+
32
39
  // ─── Public API ─────────────────────────────────────────────────────────────
33
40
 
34
41
  export interface CommandClassification {
@@ -41,9 +48,10 @@ export interface CommandClassification {
41
48
  * Returns the list of matched destructive pattern labels.
42
49
  */
43
50
  export function classifyCommand(command: string): CommandClassification {
51
+ const commandToClassify = stripQuotedAndComments(command);
44
52
  const labels: string[] = [];
45
53
  for (const { pattern, label } of DESTRUCTIVE_PATTERNS) {
46
- if (pattern.test(command)) {
54
+ if (pattern.test(commandToClassify)) {
47
55
  // Deduplicate labels (e.g., two force-push patterns)
48
56
  if (!labels.includes(label)) labels.push(label);
49
57
  }
@@ -65,12 +65,16 @@ const MCP_EXECUTION_TOOL_RE = /^mcp__.+__gsd_(?:uat_)?exec(?:_search)?$/;
65
65
  // ─── Module State ───────────────────────────────────────────────────────────
66
66
 
67
67
  let unitEvidence: EvidenceEntry[] = [];
68
+ let lastWritePath: string | null = null;
69
+ let lastWriteSig: string | null = null;
68
70
 
69
71
  // ─── Public API ─────────────────────────────────────────────────────────────
70
72
 
71
73
  /** Reset all evidence for a new unit. Call at unit start. */
72
74
  export function resetEvidence(): void {
73
75
  unitEvidence = [];
76
+ lastWritePath = null;
77
+ lastWriteSig = null;
74
78
  }
75
79
 
76
80
  /** Get a read-only view of all evidence collected for the current unit. */
@@ -145,10 +149,15 @@ export function saveEvidenceToDisk(
145
149
  ): void {
146
150
  try {
147
151
  const path = evidencePath(basePath, milestoneId, sliceId, taskId);
152
+ const body = JSON.stringify(unitEvidence, null, 2) + "\n";
153
+ if (path === lastWritePath && body === lastWriteSig) return;
154
+
148
155
  mkdirSync(dirname(path), { recursive: true });
149
156
  const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
150
- writeFileSync(tmp, JSON.stringify(unitEvidence, null, 2) + "\n", "utf-8");
157
+ writeFileSync(tmp, body, "utf-8");
151
158
  renameSync(tmp, path);
159
+ lastWritePath = path;
160
+ lastWriteSig = body;
152
161
  } catch {
153
162
  // Non-fatal — don't let persistence failures break unit execution
154
163
  }
@@ -167,6 +176,8 @@ export function loadEvidenceFromDisk(
167
176
  taskId: string,
168
177
  ): void {
169
178
  try {
179
+ lastWritePath = null;
180
+ lastWriteSig = null;
170
181
  const path = evidencePath(basePath, milestoneId, sliceId, taskId);
171
182
  if (!existsSync(path)) return;
172
183
  const raw = readFileSync(path, "utf-8");
@@ -195,6 +206,10 @@ export function clearEvidenceFromDisk(
195
206
  if (existsSync(path)) {
196
207
  unlinkSync(path);
197
208
  }
209
+ if (path === lastWritePath) {
210
+ lastWritePath = null;
211
+ lastWriteSig = null;
212
+ }
198
213
  } catch {
199
214
  // Non-fatal
200
215
  }
@@ -8,6 +8,7 @@ import {
8
8
  getNewBudgetAlertLevel,
9
9
  resolveCompactionThresholdPercent,
10
10
  shouldRerootStepSessionForContext,
11
+ shouldWarnStepSessionForContext,
11
12
  } from "../auto.js";
12
13
  import {
13
14
  getUnitCostSpikeAction,
@@ -73,11 +74,19 @@ test("resolveCompactionThresholdPercent defaults to 60 and accepts ratio prefs",
73
74
  assert.equal(resolveCompactionThresholdPercent(0.4), 60);
74
75
  });
75
76
 
76
- test("shouldRerootStepSessionForContext uses compaction threshold pref", () => {
77
- assert.equal(shouldRerootStepSessionForContext(59.9, 0.6), false);
78
- assert.equal(shouldRerootStepSessionForContext(60, 0.6), true);
79
- assert.equal(shouldRerootStepSessionForContext(273.8, 0.6), true);
80
- assert.equal(shouldRerootStepSessionForContext(undefined, 0.6), false);
77
+ test("shouldWarnStepSessionForContext uses compaction threshold pref", () => {
78
+ assert.equal(shouldWarnStepSessionForContext(59.9, 0.6), false);
79
+ assert.equal(shouldWarnStepSessionForContext(60, 0.6), true);
80
+ assert.equal(shouldWarnStepSessionForContext(72, 0.6), true);
81
+ assert.equal(shouldWarnStepSessionForContext(undefined, 0.6), false);
82
+ });
83
+
84
+ test("shouldRerootStepSessionForContext only fires at the hard context threshold", () => {
85
+ assert.equal(shouldRerootStepSessionForContext(72), false);
86
+ assert.equal(shouldRerootStepSessionForContext(89.9), false);
87
+ assert.equal(shouldRerootStepSessionForContext(90), true);
88
+ assert.equal(shouldRerootStepSessionForContext(273.8), true);
89
+ assert.equal(shouldRerootStepSessionForContext(undefined), false);
81
90
  });
82
91
 
83
92
  test("resolveUnitCostSpikeMultiplier disables the spike pause for burn-max", () => {
@@ -14,6 +14,7 @@ import { capturePauseAutoUnitIdentity, pauseAuto, isAutoActive } from "../auto.t
14
14
  import { _resetPendingResolve, _setCurrentResolve } from "../auto/resolve.ts";
15
15
  import { autoSession } from "../auto-runtime-state.ts";
16
16
  import { _isPauseOriginCancelledResult } from "../auto/phase-helpers.ts";
17
+ import { handleAutoCommand } from "../commands/handlers/auto.ts";
17
18
 
18
19
  test("pauseAuto sets s.active = false synchronously before first await (blocks concurrent re-entry)", async () => {
19
20
  const base = mkdtempSync(join(tmpdir(), "gsd-double-pause-guard-"));
@@ -80,6 +81,41 @@ test("pauseAuto marks paused and cancels the in-flight unit before first await",
80
81
  }
81
82
  });
82
83
 
84
+ test("/gsd pause aborts the active pi turn before first await", async () => {
85
+ autoSession.reset();
86
+ autoSession.active = true;
87
+ let abortCount = 0;
88
+ const ctx = {
89
+ hasUI: false,
90
+ isIdle: () => false,
91
+ abort: () => {
92
+ abortCount += 1;
93
+ },
94
+ sessionManager: {
95
+ getSessionFile: () => null,
96
+ },
97
+ ui: {
98
+ setStatus() {},
99
+ setWidget() {},
100
+ notify() {},
101
+ },
102
+ } as any;
103
+
104
+ try {
105
+ const pausePromise = handleAutoCommand("pause", ctx, {} as any);
106
+
107
+ assert.equal(
108
+ abortCount,
109
+ 1,
110
+ "pauseAuto must abort the active pi turn before reporting auto-mode paused",
111
+ );
112
+
113
+ await pausePromise.catch(() => {});
114
+ } finally {
115
+ autoSession.reset();
116
+ }
117
+ });
118
+
83
119
  test("transient-abort errorContext is not classified as pause-origin cancellation", () => {
84
120
  // The phases.ts fix passes unitResult.errorContext to pauseAuto for transient aborts.
85
121
  // _isPauseOriginCancelledResult must return false when errorContext is present, ensuring
@@ -3,13 +3,14 @@
3
3
  import { test } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { execFileSync } from "node:child_process";
6
- import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join, dirname } from "node:path";
9
9
 
10
10
  import {
11
11
  anchorProcessCwdForAutoResume,
12
12
  cleanupAfterLoopExit,
13
+ maybeRerootStepSessionForHighContext,
13
14
  pauseAuto,
14
15
  rerootCommandSession,
15
16
  stopAuto,
@@ -478,8 +479,8 @@ test("cleanupAfterLoopExit preserves step-mode surface and worktree session afte
478
479
  }
479
480
  });
480
481
 
481
- test("cleanupAfterLoopExit re-roots step-mode session when context exceeds compaction threshold", async (t) => {
482
- const base = mkdtempSync(join(tmpdir(), "gsd-step-reroot-"));
482
+ test("cleanupAfterLoopExit warns but does not re-root step-mode session at soft compaction threshold", async (t) => {
483
+ const base = mkdtempSync(join(tmpdir(), "gsd-step-soft-context-"));
483
484
  const worktree = join(base, ".gsd", "worktrees", "M001");
484
485
  const previousCwd = process.cwd();
485
486
  const newSessionWorkspaces: string[] = [];
@@ -515,7 +516,59 @@ test("cleanupAfterLoopExit re-roots step-mode session when context exceeds compa
515
516
  },
516
517
  } as any);
517
518
 
518
- assert.deepEqual(newSessionWorkspaces, [worktree], "hot step-mode cleanup should re-root in the active worktree");
519
+ assert.deepEqual(newSessionWorkspaces, [], "soft context pressure must not force a new command session");
520
+ assert.equal(
521
+ notifyMessages.some((msg) => msg.includes("context at 72.0%") && msg.includes("Use /compact")),
522
+ true,
523
+ "soft threshold should warn with a non-disruptive compaction prompt",
524
+ );
525
+ assert.equal(autoSession.basePath, worktree);
526
+ assert.equal(realpathSync(process.cwd()), realpathSync(worktree));
527
+ } finally {
528
+ autoSession.reset();
529
+ process.chdir(previousCwd);
530
+ rmSync(base, { recursive: true, force: true });
531
+ }
532
+ });
533
+
534
+ test("cleanupAfterLoopExit re-roots step-mode session at hard context threshold", async (t) => {
535
+ const base = mkdtempSync(join(tmpdir(), "gsd-step-reroot-"));
536
+ const worktree = join(base, ".gsd", "worktrees", "M001");
537
+ const previousCwd = process.cwd();
538
+ const newSessionWorkspaces: string[] = [];
539
+ const notifyMessages: string[] = [];
540
+
541
+ t.mock.method(WorktreeLifecycle.prototype, "restoreToProjectRoot", function () {});
542
+
543
+ mkdirSync(worktree, { recursive: true });
544
+ process.chdir(worktree);
545
+ autoSession.reset();
546
+ autoSession.active = true;
547
+ autoSession.paused = false;
548
+ autoSession.stepMode = true;
549
+ autoSession.preserveStepSurfaceAfterLoopExit = true;
550
+ autoSession.basePath = worktree;
551
+ autoSession.originalBasePath = base;
552
+ autoSession.cmdCtx = {
553
+ getContextUsage: () => ({ percent: 92, tokens: 920_000, contextWindow: 1_000_000 }),
554
+ newSession: async ({ workspaceRoot }: { workspaceRoot: string }) => {
555
+ newSessionWorkspaces.push(workspaceRoot);
556
+ return { cancelled: false };
557
+ },
558
+ } as any;
559
+
560
+ try {
561
+ await cleanupAfterLoopExit({
562
+ hasUI: true,
563
+ ui: {
564
+ setStatus: () => {},
565
+ setWidget: () => {},
566
+ setHeader: () => {},
567
+ notify: (_msg: string) => notifyMessages.push(_msg),
568
+ },
569
+ } as any);
570
+
571
+ assert.deepEqual(newSessionWorkspaces, [worktree], "critical context pressure should re-root in the active worktree");
519
572
  assert.equal(
520
573
  notifyMessages.some((msg) => msg.includes("Fresh session ready for /gsd next")),
521
574
  true,
@@ -530,6 +583,131 @@ test("cleanupAfterLoopExit re-roots step-mode session when context exceeds compa
530
583
  }
531
584
  });
532
585
 
586
+ test("maybeRerootStepSessionForHighContext re-roots at the hard threshold even when the soft pref is set above it", async () => {
587
+ // Regression: the hard 90% re-root must not be gated behind the configurable
588
+ // soft warn threshold, which is allowed up to 0.95. At 92% usage with a 0.95
589
+ // soft pref the soft warn predicate is false, but the session must still
590
+ // re-root because usage crossed the fixed hard boundary.
591
+ const dir = mkdtempSync(join(tmpdir(), "gsd-hard-reroot-high-soft-"));
592
+ const project = join(dir, "project");
593
+ const gsdHomeDir = join(dir, "home");
594
+ const previousCwd = process.cwd();
595
+ const previousGsdHome = process.env.GSD_HOME;
596
+
597
+ mkdirSync(join(project, ".gsd"), { recursive: true });
598
+ mkdirSync(gsdHomeDir, { recursive: true });
599
+ writeFileSync(
600
+ join(project, ".gsd", "PREFERENCES.md"),
601
+ ["---", "version: 1", "context_management:", " compaction_threshold_percent: 0.95", "---", ""].join("\n"),
602
+ "utf-8",
603
+ );
604
+
605
+ process.env.GSD_HOME = gsdHomeDir;
606
+ process.chdir(project);
607
+
608
+ const newSessionWorkspaces: string[] = [];
609
+ const notifyMessages: string[] = [];
610
+ const cmdCtx = {
611
+ getContextUsage: () => ({ percent: 92, tokens: 920_000, contextWindow: 1_000_000 }),
612
+ newSession: async ({ workspaceRoot }: { workspaceRoot: string }) => {
613
+ newSessionWorkspaces.push(workspaceRoot);
614
+ return { cancelled: false };
615
+ },
616
+ } as any;
617
+ const ctx = { ui: { notify: (msg: string) => notifyMessages.push(msg) } } as any;
618
+
619
+ try {
620
+ const result = await maybeRerootStepSessionForHighContext(ctx, project, cmdCtx, project);
621
+
622
+ assert.equal(result.rerooted, true, "92% usage must re-root even when the soft pref is 95%");
623
+ assert.deepEqual(newSessionWorkspaces, [project], "hard context pressure must re-root the command session");
624
+ const rerootMessage = notifyMessages.find((msg) => msg.includes("Fresh session ready for /gsd next"));
625
+ assert.ok(rerootMessage, "hard re-root should notify the user");
626
+ // The re-root was triggered by the 90% hard limit, so the notification must
627
+ // cite that limit and not the configured 95% soft compaction threshold.
628
+ assert.match(
629
+ rerootMessage,
630
+ /hard threshold: 90%/,
631
+ "hard re-root notification must cite the 90% hard threshold",
632
+ );
633
+ assert.equal(
634
+ rerootMessage.includes("95%"),
635
+ false,
636
+ "hard re-root notification must not cite the configured soft compaction threshold",
637
+ );
638
+ } finally {
639
+ process.chdir(previousCwd);
640
+ if (previousGsdHome === undefined) delete process.env.GSD_HOME;
641
+ else process.env.GSD_HOME = previousGsdHome;
642
+ rmSync(dir, { recursive: true, force: true });
643
+ }
644
+ });
645
+
646
+ test("maybeRerootStepSessionForHighContext writes a compaction snapshot only at the hard boundary", async () => {
647
+ // Regression: the snapshot is reserved for the hard re-root. Soft-only
648
+ // pressure must warn without writing a compaction snapshot.
649
+ const dir = mkdtempSync(join(tmpdir(), "gsd-soft-no-snapshot-"));
650
+ const project = join(dir, "project");
651
+ const gsdHomeDir = join(dir, "home");
652
+ const previousCwd = process.cwd();
653
+ const previousGsdHome = process.env.GSD_HOME;
654
+
655
+ mkdirSync(join(project, ".gsd"), { recursive: true });
656
+ mkdirSync(gsdHomeDir, { recursive: true });
657
+ writeFileSync(
658
+ join(project, ".gsd", "PREFERENCES.md"),
659
+ ["---", "version: 1", "context_management:", " compaction_threshold_percent: 0.6", "---", ""].join("\n"),
660
+ "utf-8",
661
+ );
662
+
663
+ process.env.GSD_HOME = gsdHomeDir;
664
+ process.chdir(project);
665
+
666
+ const newSessionWorkspaces: string[] = [];
667
+ const notifyMessages: string[] = [];
668
+ let contextPercent = 72;
669
+ const cmdCtx = {
670
+ getContextUsage: () => ({ percent: contextPercent, tokens: contextPercent * 10_000, contextWindow: 1_000_000 }),
671
+ newSession: async ({ workspaceRoot }: { workspaceRoot: string }) => {
672
+ newSessionWorkspaces.push(workspaceRoot);
673
+ return { cancelled: false };
674
+ },
675
+ } as any;
676
+ const ctx = { ui: { notify: (msg: string) => notifyMessages.push(msg) } } as any;
677
+ const snapshotPath = join(project, ".gsd", "last-snapshot.md");
678
+
679
+ try {
680
+ const softResult = await maybeRerootStepSessionForHighContext(ctx, project, cmdCtx, project);
681
+ assert.equal(softResult.rerooted, false, "soft pressure must not re-root");
682
+ assert.deepEqual(newSessionWorkspaces, [], "soft pressure must not open a new session");
683
+ assert.equal(
684
+ existsSync(snapshotPath),
685
+ false,
686
+ "soft-only pressure must not write a compaction snapshot",
687
+ );
688
+ assert.equal(
689
+ notifyMessages.some((msg) => msg.includes("context at 72.0%") && msg.includes("Use /compact")),
690
+ true,
691
+ "soft threshold should warn with a non-disruptive compaction prompt",
692
+ );
693
+
694
+ contextPercent = 92;
695
+ const hardResult = await maybeRerootStepSessionForHighContext(ctx, project, cmdCtx, project);
696
+ assert.equal(hardResult.rerooted, true, "hard pressure must re-root");
697
+ assert.deepEqual(newSessionWorkspaces, [project], "hard pressure should re-root once");
698
+ assert.equal(
699
+ existsSync(snapshotPath),
700
+ true,
701
+ "hard boundary must write the compaction snapshot",
702
+ );
703
+ } finally {
704
+ process.chdir(previousCwd);
705
+ if (previousGsdHome === undefined) delete process.env.GSD_HOME;
706
+ else process.env.GSD_HOME = previousGsdHome;
707
+ rmSync(dir, { recursive: true, force: true });
708
+ }
709
+ });
710
+
533
711
  test("cleanupAfterLoopExit restores project root through lifecycle and preserves chdir", async (t) => {
534
712
  const base = mkdtempSync(join(tmpdir(), "gsd-cleanup-lifecycle-"));
535
713
  const worktree = join(base, ".gsd", "worktrees", "M001");
@@ -1917,6 +1917,41 @@ test("#57: writeReactiveExecuteBlocker reconciles batch task statuses and append
1917
1917
  }
1918
1918
  });
1919
1919
 
1920
+ test("#1088: writeReactiveExecuteBlocker preserves deferred batch task statuses", () => {
1921
+ const base = makeTmpBase();
1922
+ try {
1923
+ openDatabase(join(base, ".gsd", "gsd.db"));
1924
+ insertMilestone({ id: "M001", title: "Milestone", status: "active" });
1925
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "pending" });
1926
+ insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "One", status: "deferred" });
1927
+ insertTask({ id: "T02", milestoneId: "M001", sliceId: "S01", title: "Two", status: "deferred" });
1928
+ writeFileSync(
1929
+ join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md"),
1930
+ "# T01 Summary\n",
1931
+ "utf-8",
1932
+ );
1933
+
1934
+ const recovery = writeReactiveExecuteBlocker(
1935
+ "M001/S01/reactive+T01,T02",
1936
+ base,
1937
+ "verification retries exhausted",
1938
+ );
1939
+
1940
+ assert.ok(recovery, "recovery should run with DB available");
1941
+ assert.deepEqual(recovery!.completedTaskIds, []);
1942
+ assert.deepEqual(recovery!.skippedTaskIds, []);
1943
+ assert.deepEqual(recovery!.unchangedTaskIds, ["T01", "T02"]);
1944
+ assert.equal(getTask("M001", "S01", "T01")?.status, "deferred");
1945
+ assert.equal(getTask("M001", "S01", "T02")?.status, "deferred");
1946
+
1947
+ const events = readEvents(join(base, ".gsd", "event-log.jsonl"));
1948
+ assert.equal(events.some((e) => e.params.taskId === "T01" || e.params.taskId === "T02"), false);
1949
+ } finally {
1950
+ closeDatabase();
1951
+ cleanup(base);
1952
+ }
1953
+ });
1954
+
1920
1955
  test("#4414: verifyExpectedArtifact parallel-research succeeds when all research-ready slices have RESEARCH", () => {
1921
1956
  const base = makeTmpBase();
1922
1957
  try {
@@ -15,15 +15,21 @@
15
15
 
16
16
  import { describe, it, beforeEach } from "node:test";
17
17
  import assert from "node:assert/strict";
18
+ import { mkdtempSync, rmSync, statSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
18
21
 
19
22
  import {
20
23
  resetEvidence,
21
24
  getEvidence,
22
25
  recordToolCall,
23
26
  recordToolResult,
27
+ saveEvidenceToDisk,
24
28
  type BashEvidence,
25
29
  } from "../safety/evidence-collector.js";
26
30
 
31
+ const waitForMtimeTick = () => new Promise(resolve => setTimeout(resolve, 25));
32
+
27
33
  describe("evidence-collector: toolCallId-based matching (A-3)", () => {
28
34
  beforeEach(() => {
29
35
  resetEvidence();
@@ -119,4 +125,28 @@ describe("evidence-collector: toolCallId-based matching (A-3)", () => {
119
125
  assert.equal(entries[1].kind, "bash");
120
126
  assert.equal(entries[1].command, "rg TODO");
121
127
  });
128
+
129
+ it("skips byte-identical evidence file rewrites", async (t) => {
130
+ const base = mkdtempSync(join(tmpdir(), "gsd-evidence-dedupe-"));
131
+ t.after(() => rmSync(base, { recursive: true, force: true }));
132
+
133
+ recordToolCall("tc-bash-1", "bash", { command: "pnpm test" });
134
+ saveEvidenceToDisk(base, "M001", "S01", "T03");
135
+
136
+ const evidenceFile = join(base, ".gsd", "safety", "evidence-M001-S01-T03.json");
137
+ const firstMtime = statSync(evidenceFile, { bigint: true }).mtimeNs;
138
+
139
+ await waitForMtimeTick();
140
+ saveEvidenceToDisk(base, "M001", "S01", "T03");
141
+ const secondMtime = statSync(evidenceFile, { bigint: true }).mtimeNs;
142
+
143
+ assert.equal(secondMtime, firstMtime, "unchanged evidence must not rewrite the evidence file");
144
+
145
+ await waitForMtimeTick();
146
+ recordToolResult("tc-bash-1", "bash", "Command exited with code 0\nok\n", false);
147
+ saveEvidenceToDisk(base, "M001", "S01", "T03");
148
+ const thirdMtime = statSync(evidenceFile, { bigint: true }).mtimeNs;
149
+
150
+ assert.ok(thirdMtime > secondMtime, "changed evidence must still be persisted");
151
+ });
122
152
  });
@@ -3,11 +3,16 @@
3
3
 
4
4
  import { describe, test } from "node:test";
5
5
  import assert from "node:assert/strict";
6
+ import { createRequire } from "node:module";
6
7
  import {
8
+ MEMORIES_FTS_REBUILT_KEY,
7
9
  isMemoriesFtsAvailableSchema,
10
+ rebuildMemoriesFtsSchemaOnce,
8
11
  tryCreateMemoriesFtsSchema,
9
12
  } from "../db-memory-fts-schema.ts";
10
- import type { DbAdapter, DbStatement } from "../db-adapter.ts";
13
+ import { createDbAdapter, type DbAdapter, type DbStatement } from "../db-adapter.ts";
14
+
15
+ const _require = createRequire(import.meta.url);
11
16
 
12
17
  class FakeStatement implements DbStatement {
13
18
  private readonly row: Record<string, unknown> | undefined;
@@ -32,6 +37,7 @@ class FakeStatement implements DbStatement {
32
37
  class FakeAdapter implements DbAdapter {
33
38
  readonly execCalls: string[] = [];
34
39
  hasFts = false;
40
+ hasRebuildMarker = false;
35
41
  failExec = false;
36
42
 
37
43
  exec(sql: string): void {
@@ -39,13 +45,26 @@ class FakeAdapter implements DbAdapter {
39
45
  if (this.failExec) throw new Error("fts unavailable");
40
46
  }
41
47
 
42
- prepare(): DbStatement {
43
- return new FakeStatement(this.hasFts ? { name: "memories_fts" } : undefined);
48
+ prepare(sql: string): DbStatement {
49
+ if (sql.includes("sqlite_master")) {
50
+ return new FakeStatement(this.hasFts ? { name: "memories_fts" } : undefined);
51
+ }
52
+ if (sql.includes("runtime_kv")) {
53
+ return new FakeStatement(this.hasRebuildMarker ? { present: 1 } : undefined);
54
+ }
55
+ return new FakeStatement(undefined);
44
56
  }
45
57
 
46
58
  close(): void {}
47
59
  }
48
60
 
61
+ function openMemoryAdapter(): { db: DbAdapter; close: () => void } {
62
+ const sqlite = _require("node:sqlite") as { DatabaseSync: new (path: string) => unknown };
63
+ const raw = new sqlite.DatabaseSync(":memory:");
64
+ const db = createDbAdapter(raw);
65
+ return { db, close: () => db.close() };
66
+ }
67
+
49
68
  describe("db-memory-fts-schema", () => {
50
69
  test("creates memories_fts and insert/delete/update triggers", () => {
51
70
  const db = new FakeAdapter();
@@ -83,4 +102,40 @@ describe("db-memory-fts-schema", () => {
83
102
  db.hasFts = true;
84
103
  assert.equal(isMemoriesFtsAvailableSchema(db), true);
85
104
  });
105
+
106
+ test("rebuilds the external-content index once and records a marker", () => {
107
+ const { db, close } = openMemoryAdapter();
108
+ try {
109
+ db.exec("CREATE TABLE memories (seq INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL)");
110
+ db.exec("INSERT INTO memories (content) VALUES ('pre FTS auth memory must be searchable')");
111
+
112
+ const ok = tryCreateMemoriesFtsSchema(db);
113
+ if (!ok) return;
114
+
115
+ const before = db.prepare("SELECT rowid FROM memories_fts WHERE memories_fts MATCH 'auth'").all();
116
+ assert.equal(before.length, 0, "external-content FTS starts empty for existing rows");
117
+
118
+ rebuildMemoriesFtsSchemaOnce(db);
119
+
120
+ const after = db.prepare("SELECT rowid FROM memories_fts WHERE memories_fts MATCH 'auth'").all();
121
+ assert.deepEqual(after.map((row) => row["rowid"]), [1]);
122
+ const marker = db.prepare(
123
+ "SELECT value_json FROM runtime_kv WHERE scope = 'global' AND scope_id = '' AND key = :key",
124
+ ).get({ ":key": MEMORIES_FTS_REBUILT_KEY });
125
+ assert.ok(marker, "rebuild marker should be stored");
126
+ } finally {
127
+ close();
128
+ }
129
+ });
130
+
131
+ test("skips rebuild when marker already exists", () => {
132
+ const db = new FakeAdapter();
133
+ db.hasFts = true;
134
+ db.hasRebuildMarker = true;
135
+
136
+ rebuildMemoriesFtsSchemaOnce(db);
137
+
138
+ assert.ok(db.execCalls.some((sql) => sql.includes("CREATE TABLE IF NOT EXISTS runtime_kv")));
139
+ assert.ok(!db.execCalls.some((sql) => sql.includes("VALUES('rebuild')")));
140
+ });
86
141
  });