gentle-pi 3.7.0 → 4.0.0

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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -0,0 +1,832 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { hidePrompt } from "../extensions/history/hide-prompts.ts";
7
+ import {
8
+ drainProject,
9
+ gcProjectDir,
10
+ globalSeedPath,
11
+ projectHash,
12
+ seedFilePath,
13
+ } from "../extensions/history/store.ts";
14
+
15
+ // GC/compaction (slice 6): threshold no-op below the limits, keep-newest
16
+ // semantics, and the failure paths — the compact file lands atomically
17
+ // before any original is removed, cleanup failures are tolerated, unreadable
18
+ // files are skipped, and an append landing mid-compaction is never lost.
19
+ // All fixtures live under os.tmpdir(): the user's real ~/.pi store root is
20
+ // never touched. (Ported from the dev repo's test/history/gc.test.ts;
21
+ // gcProjectDir with explicit thresholds is the wired and tested entry
22
+ // point.)
23
+
24
+ const CWD = "/pi-history-fixtures/project-gc";
25
+
26
+ function makeRoot(): string {
27
+ return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-gc-"));
28
+ }
29
+
30
+ function projectRoot(root: string): string {
31
+ return path.join(root, "projects", projectHash(CWD));
32
+ }
33
+
34
+ function writeFile(
35
+ dir: string,
36
+ name: string,
37
+ count: number,
38
+ mtimeMs: number,
39
+ ): string {
40
+ const file = path.join(dir, name);
41
+ fs.writeFileSync(
42
+ file,
43
+ `${Array.from({ length: count }, (_, i) =>
44
+ JSON.stringify({ v: 1, text: `${name}-${i}` }),
45
+ ).join("\n")}\n`,
46
+ "utf8",
47
+ );
48
+ fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs));
49
+ return file;
50
+ }
51
+
52
+ function totalLines(dir: string): number {
53
+ let total = 0;
54
+ for (const f of fs.readdirSync(dir)) {
55
+ if (!f.endsWith(".jsonl")) continue;
56
+ total += fs
57
+ .readFileSync(path.join(dir, f), "utf8")
58
+ .split("\n")
59
+ .filter((l) => l.trim().length > 0).length;
60
+ }
61
+ return total;
62
+ }
63
+
64
+ /** Line texts of the single compact-*.jsonl file in dir (must exist). */
65
+ function compactTexts(dir: string): string[] {
66
+ const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-"));
67
+ assert.ok(compact, "a compact-*.jsonl file must exist");
68
+ return fs
69
+ .readFileSync(path.join(dir, compact), "utf8")
70
+ .trim()
71
+ .split("\n")
72
+ .map((l) => (JSON.parse(l) as { text: string }).text);
73
+ }
74
+
75
+ /** Write explicit entries (text + optional ts) with a fixed mtime. */
76
+ function writeEntries(
77
+ dir: string,
78
+ name: string,
79
+ entries: Array<{ text: string; ts?: number }>,
80
+ mtimeMs: number,
81
+ ): string {
82
+ const file = path.join(dir, name);
83
+ fs.writeFileSync(
84
+ file,
85
+ `${entries.map((e) => JSON.stringify({ v: 1, ...e })).join("\n")}\n`,
86
+ "utf8",
87
+ );
88
+ fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs));
89
+ return file;
90
+ }
91
+
92
+ /** Every entry text across the dir's .jsonl files (order unspecified). */
93
+ function dirTexts(dir: string): string[] {
94
+ const out: string[] = [];
95
+ for (const f of fs.readdirSync(dir)) {
96
+ if (!f.endsWith(".jsonl")) continue;
97
+ for (const line of fs.readFileSync(path.join(dir, f), "utf8").split("\n")) {
98
+ if (line.trim().length === 0) continue;
99
+ out.push((JSON.parse(line) as { text: string }).text);
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * Replace fs.rmSync (the shared CJS exports object store.ts resolves at
107
+ * call time) for the duration of fn; the original is always restored.
108
+ * `rmSync` inside the replacement is the captured original, so replacements
109
+ * can observe-or-fail and then call through.
110
+ */
111
+ function withRmSyncPatched(
112
+ replacement: (file: string, rmSync: (file: string) => void) => void,
113
+ fn: () => void,
114
+ ): void {
115
+ type RmSync = (file: string) => void;
116
+ const realRmSync = fs.rmSync.bind(fs) as RmSync;
117
+ const target = fs as unknown as { rmSync: RmSync };
118
+ target.rmSync = (file: string) => {
119
+ replacement(file, realRmSync);
120
+ };
121
+ try {
122
+ fn();
123
+ } finally {
124
+ target.rmSync = realRmSync;
125
+ }
126
+ }
127
+
128
+ test("under both thresholds: GC is a no-op", () => {
129
+ const root = makeRoot();
130
+ const dir = projectRoot(root);
131
+ fs.mkdirSync(dir, { recursive: true });
132
+ writeFile(dir, "a.jsonl", 10, 1000);
133
+ writeFile(dir, "b.jsonl", 10, 2000);
134
+ const result = gcProjectDir(root, CWD, {
135
+ fileThreshold: 10,
136
+ lineThreshold: 10000,
137
+ keepNewest: 1,
138
+ });
139
+ assert.deepEqual(result, { compacted: false, merged: 0 });
140
+ assert.equal(fs.readdirSync(dir).length, 2);
141
+ });
142
+
143
+ test("file-count threshold merges the oldest files into one compact file", () => {
144
+ const root = makeRoot();
145
+ const dir = projectRoot(root);
146
+ fs.mkdirSync(dir, { recursive: true });
147
+ // 12 files (threshold 10) x 10 lines each.
148
+ for (let i = 1; i <= 12; i++) {
149
+ writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000);
150
+ }
151
+ const result = gcProjectDir(root, CWD, {
152
+ fileThreshold: 10,
153
+ lineThreshold: 10000,
154
+ keepNewest: 1,
155
+ });
156
+ assert.deepEqual(result, { compacted: true, merged: 11 });
157
+ // 12 files -> newest 1 kept + 1 compact file = 2 files; all lines kept.
158
+ assert.equal(fs.readdirSync(dir).length, 2);
159
+ assert.equal(totalLines(dir), 120);
160
+ // The compact file is the renamed final artifact, not a staging leftover.
161
+ assert.match(
162
+ fs.readdirSync(dir).find((f) => f.startsWith("compact-")) ?? "",
163
+ /^compact-\d+-\d+\.jsonl$/,
164
+ );
165
+ assert.deepEqual(
166
+ fs.readdirSync(dir).filter((f) => f.includes(".tmp-")),
167
+ [],
168
+ );
169
+ // The newest original file survives untouched by name.
170
+ assert.equal(fs.readdirSync(dir).includes("f12.jsonl"), true);
171
+ });
172
+
173
+ test("line-count threshold triggers compaction too", () => {
174
+ const root = makeRoot();
175
+ const dir = projectRoot(root);
176
+ fs.mkdirSync(dir, { recursive: true });
177
+ // 3 files x 4000 lines = 12000 > 10000 threshold.
178
+ for (let i = 1; i <= 3; i++) {
179
+ writeFile(dir, `g${i}.jsonl`, 4000, i * 1000);
180
+ }
181
+ const result = gcProjectDir(root, CWD, {
182
+ fileThreshold: 10,
183
+ lineThreshold: 10000,
184
+ keepNewest: 1,
185
+ });
186
+ assert.equal(result.compacted, true);
187
+ assert.equal(totalLines(dir), 12000);
188
+ assert.equal(fs.readdirSync(dir).includes("g3.jsonl"), true);
189
+ });
190
+
191
+ test("GC on a missing project dir is a no-op", () => {
192
+ const root = makeRoot();
193
+ const result = gcProjectDir(root, "/does/not/exist");
194
+ assert.deepEqual(result, { compacted: false, merged: 0 });
195
+ });
196
+
197
+ test("compaction keeps the newest 10 files, merges the rest", () => {
198
+ const root = makeRoot();
199
+ const dir = projectRoot(root);
200
+ fs.mkdirSync(dir, { recursive: true });
201
+ for (let i = 1; i <= 15; i++) {
202
+ writeFile(dir, `h${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000);
203
+ }
204
+ const result = gcProjectDir(root, CWD, {
205
+ fileThreshold: 10,
206
+ lineThreshold: 10000,
207
+ keepNewest: 10,
208
+ });
209
+ assert.deepEqual(result, { compacted: true, merged: 5 });
210
+ const names = fs.readdirSync(dir).sort();
211
+ // 10 newest originals + 1 compact file.
212
+ assert.equal(names.length, 11);
213
+ assert.equal(names[0].startsWith("compact-"), true);
214
+ assert.equal(names.includes("h15.jsonl"), true);
215
+ assert.equal(names.includes("h05.jsonl"), false);
216
+ assert.equal(names.includes("h06.jsonl"), true);
217
+ });
218
+
219
+ // node:test has no test.skipIf (Bun-ism): root skips via the options object.
220
+ test(
221
+ "an unreadable file (chmod 000) is left in place; GC still compacts the readable tail",
222
+ { skip: process.getuid?.() === 0 ? "requires non-root" : false },
223
+ () => {
224
+ const root = makeRoot();
225
+ const dir = projectRoot(root);
226
+ fs.mkdirSync(dir, { recursive: true });
227
+ // 3 files, keepNewest 1 -> the two oldest are merge candidates; the
228
+ // sealed one cannot be read, so it must be neither merged nor removed:
229
+ // removing bytes that were never copied would lose them.
230
+ writeFile(dir, "readable-old.jsonl", 5, 1000);
231
+ const sealed = writeFile(dir, "sealed-old.jsonl", 5, 2000);
232
+ writeFile(dir, "newest.jsonl", 5, 3000);
233
+ fs.chmodSync(sealed, 0o000);
234
+ try {
235
+ const result = gcProjectDir(root, CWD, {
236
+ fileThreshold: 2,
237
+ lineThreshold: 100000,
238
+ keepNewest: 1,
239
+ });
240
+ assert.deepEqual(result, { compacted: true, merged: 1 });
241
+ assert.deepEqual(compactTexts(dir), [
242
+ "readable-old.jsonl-0",
243
+ "readable-old.jsonl-1",
244
+ "readable-old.jsonl-2",
245
+ "readable-old.jsonl-3",
246
+ "readable-old.jsonl-4",
247
+ ]);
248
+ assert.equal(fs.existsSync(sealed), true);
249
+ assert.equal(fs.readdirSync(dir).includes("newest.jsonl"), true);
250
+ } finally {
251
+ fs.chmodSync(sealed, 0o644);
252
+ }
253
+ assert.equal(
254
+ fs.readFileSync(sealed, "utf8").trim().split("\n").length,
255
+ 5,
256
+ );
257
+ },
258
+ );
259
+
260
+ test("the compact file lands complete before any original is removed", () => {
261
+ const root = makeRoot();
262
+ const dir = projectRoot(root);
263
+ fs.mkdirSync(dir, { recursive: true });
264
+ for (let i = 1; i <= 12; i++) {
265
+ writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000);
266
+ }
267
+ // Observe, do not replace: at the FIRST cleanup unlink the compact file
268
+ // must already exist on disk with the full merged content (110 lines).
269
+ // That is the crash-safe ordering contract: readers never see the tail
270
+ // gone with no compact file in its place.
271
+ let compactCompleteAtFirstRm: boolean | null = null;
272
+ withRmSyncPatched(
273
+ (file, rmSync) => {
274
+ if (compactCompleteAtFirstRm === null) {
275
+ const parent = path.dirname(file);
276
+ const compact = fs
277
+ .readdirSync(parent)
278
+ .find((f) => f.startsWith("compact-"));
279
+ compactCompleteAtFirstRm =
280
+ compact !== undefined &&
281
+ fs
282
+ .readFileSync(path.join(parent, compact), "utf8")
283
+ .trim()
284
+ .split("\n")
285
+ .filter((l) => l.trim().length > 0).length === 110;
286
+ }
287
+ rmSync(file);
288
+ },
289
+ () => {
290
+ const result = gcProjectDir(root, CWD, {
291
+ fileThreshold: 10,
292
+ lineThreshold: 10000,
293
+ keepNewest: 1,
294
+ });
295
+ assert.deepEqual(result, { compacted: true, merged: 11 });
296
+ },
297
+ );
298
+ assert.equal(compactCompleteAtFirstRm, true);
299
+ });
300
+
301
+ test("rm failure is tolerated: originals survive, GC still reports success", () => {
302
+ const root = makeRoot();
303
+ const dir = projectRoot(root);
304
+ fs.mkdirSync(dir, { recursive: true });
305
+ for (let i = 1; i <= 12; i++) {
306
+ writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000);
307
+ }
308
+ // Simulate every cleanup unlink failing (e.g. originals held by another
309
+ // process): the compact file already landed, so a surviving original is
310
+ // harmless — readers dedupe by identity.
311
+ withRmSyncPatched(
312
+ () => {
313
+ throw new Error("simulated EBUSY: original still held");
314
+ },
315
+ () => {
316
+ const result = gcProjectDir(root, CWD, {
317
+ fileThreshold: 10,
318
+ lineThreshold: 10000,
319
+ keepNewest: 1,
320
+ });
321
+ // The success shape is unchanged even though cleanup failed.
322
+ assert.deepEqual(result, { compacted: true, merged: 11 });
323
+ },
324
+ );
325
+ // The compact file is complete on disk...
326
+ assert.equal(compactTexts(dir).length, 110);
327
+ // ...and every original survived the failed cleanup (12 + 1 compact).
328
+ assert.equal(fs.readdirSync(dir).length, 13);
329
+ });
330
+
331
+ test("an append landing during compaction is never lost (active writer)", () => {
332
+ const root = makeRoot();
333
+ const dir = projectRoot(root);
334
+ fs.mkdirSync(dir, { recursive: true });
335
+ // 12 old files (merge-tail candidates) + one active writer file with the
336
+ // newest mtime. The freshness rule keeps the active file out of the merge
337
+ // tail — that is what makes concurrent appends safe during GC.
338
+ for (let i = 1; i <= 12; i++) {
339
+ writeFile(dir, `t${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000);
340
+ }
341
+ const active = writeFile(dir, "active.jsonl", 5, 99_000);
342
+ // Mid-compaction (first cleanup unlink), the active writer appends a line.
343
+ let appended = false;
344
+ withRmSyncPatched(
345
+ (file, rmSync) => {
346
+ if (!appended) {
347
+ appended = true;
348
+ fs.appendFileSync(
349
+ active,
350
+ `${JSON.stringify({ v: 1, text: "during-gc" })}\n`,
351
+ "utf8",
352
+ );
353
+ }
354
+ rmSync(file);
355
+ },
356
+ () => {
357
+ const result = gcProjectDir(root, CWD, {
358
+ fileThreshold: 10,
359
+ lineThreshold: 100000,
360
+ keepNewest: 10,
361
+ });
362
+ // 13 files > threshold 10; tail = 3 oldest; active writer untouched.
363
+ assert.deepEqual(result, { compacted: true, merged: 3 });
364
+ },
365
+ );
366
+ // The active file survived by name with every line: the pre-GC lines and
367
+ // the line appended mid-compaction.
368
+ const activeTexts = fs
369
+ .readFileSync(active, "utf8")
370
+ .trim()
371
+ .split("\n")
372
+ .map((l) => (JSON.parse(l) as { text: string }).text);
373
+ assert.deepEqual(activeTexts, [
374
+ "active.jsonl-0",
375
+ "active.jsonl-1",
376
+ "active.jsonl-2",
377
+ "active.jsonl-3",
378
+ "active.jsonl-4",
379
+ "during-gc",
380
+ ]);
381
+ // The tail's 15 lines all compacted; nothing from kept files was merged.
382
+ const mergedTexts = compactTexts(dir);
383
+ assert.equal(mergedTexts.length, 15);
384
+ assert.ok(mergedTexts.includes("t01.jsonl-0"));
385
+ assert.ok(mergedTexts.includes("t03.jsonl-4"));
386
+ assert.ok(!mergedTexts.some((t) => t.startsWith("active.")));
387
+ assert.ok(!mergedTexts.some((t) => t.startsWith("t04.")));
388
+ // Whole-dir accounting: 13 x 5 original lines + 1 mid-GC append.
389
+ assert.equal(totalLines(dir), 66);
390
+ });
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Adaptation hardening (PR #1394 on main): seed gate, live writers,
394
+ // chronology, tombstones, and failure tolerance.
395
+ // ---------------------------------------------------------------------------
396
+
397
+ test("compaction never selects seed.jsonl nor touches the global seed", () => {
398
+ const root = makeRoot();
399
+ const dir = projectRoot(root);
400
+ fs.mkdirSync(dir, { recursive: true });
401
+ // The seed is the OLDEST file, so a plain oldest-tail pick would merge it
402
+ // and drop the bootstrap gate (an existing seed is never regenerated).
403
+ const seed = seedFilePath(root, CWD);
404
+ writeEntries(dir, "seed.jsonl", [{ text: "seeded", ts: 10 }], 500);
405
+ const seedBytes = fs.readFileSync(seed, "utf8");
406
+ const globalSeed = globalSeedPath(root);
407
+ fs.writeFileSync(globalSeed, `${JSON.stringify({ v: 1, text: "g" })}\n`);
408
+ const globalBytes = fs.readFileSync(globalSeed, "utf8");
409
+ for (let i = 1; i <= 4; i++) {
410
+ writeFile(dir, `s${i}.jsonl`, 2, i * 1000);
411
+ }
412
+ const result = gcProjectDir(root, CWD, {
413
+ fileThreshold: 2,
414
+ lineThreshold: 100000,
415
+ keepNewest: 1,
416
+ });
417
+ assert.deepEqual(result, { compacted: true, merged: 3 });
418
+ assert.equal(fs.readFileSync(seed, "utf8"), seedBytes);
419
+ assert.equal(fs.readFileSync(globalSeed, "utf8"), globalBytes);
420
+ assert.ok(!compactTexts(dir).includes("seeded"));
421
+ });
422
+
423
+ test("the current instance's own session file is never merged", () => {
424
+ const root = makeRoot();
425
+ const dir = projectRoot(root);
426
+ fs.mkdirSync(dir, { recursive: true });
427
+ // A long-lived instance whose file is the oldest in the dir.
428
+ const own = writeFile(dir, "own-instance.jsonl", 3, 500);
429
+ for (let i = 1; i <= 4; i++) {
430
+ writeFile(dir, `o${i}.jsonl`, 2, i * 1000);
431
+ }
432
+ const result = gcProjectDir(root, CWD, {
433
+ fileThreshold: 2,
434
+ lineThreshold: 100000,
435
+ keepNewest: 1,
436
+ keepFiles: [own],
437
+ });
438
+ assert.deepEqual(result, { compacted: true, merged: 3 });
439
+ assert.equal(fs.existsSync(own), true);
440
+ assert.ok(!compactTexts(dir).some((t) => t.startsWith("own-instance")));
441
+ });
442
+
443
+ test("an append by path to a merged file between read and removal is kept", () => {
444
+ const root = makeRoot();
445
+ const dir = projectRoot(root);
446
+ fs.mkdirSync(dir, { recursive: true });
447
+ // An idle-but-live instance: old mtime, so its file is in the merge tail.
448
+ const idle = writeFile(dir, "idle.jsonl", 2, 1000);
449
+ for (let i = 2; i <= 4; i++) {
450
+ writeFile(dir, `p${i}.jsonl`, 2, i * 1000);
451
+ }
452
+ let appended = false;
453
+ withRmSyncPatched(
454
+ (file, rmSync) => {
455
+ if (!appended) {
456
+ appended = true;
457
+ // The live writer appends by path, exactly as appendSessionCapture.
458
+ fs.appendFileSync(
459
+ idle,
460
+ `${JSON.stringify({ v: 1, text: "late-by-path" })}\n`,
461
+ );
462
+ }
463
+ rmSync(file);
464
+ },
465
+ () => {
466
+ const result = gcProjectDir(root, CWD, {
467
+ fileThreshold: 2,
468
+ lineThreshold: 100000,
469
+ keepNewest: 1,
470
+ });
471
+ assert.equal(result.compacted, true);
472
+ },
473
+ );
474
+ assert.equal(appended, true);
475
+ const texts = dirTexts(dir);
476
+ assert.ok(texts.includes("late-by-path"), "the late append survived");
477
+ assert.ok(texts.includes("idle.jsonl-0"));
478
+ assert.ok(texts.includes("idle.jsonl-1"));
479
+ });
480
+
481
+ test("a write through a descriptor opened before the claim is carried over", () => {
482
+ const root = makeRoot();
483
+ const dir = projectRoot(root);
484
+ fs.mkdirSync(dir, { recursive: true });
485
+ const idle = writeFile(dir, "idle.jsonl", 2, 1000);
486
+ for (let i = 2; i <= 4; i++) {
487
+ writeFile(dir, `q${i}.jsonl`, 2, i * 1000);
488
+ }
489
+ // A writer that opened the file before GC started writes after GC read it.
490
+ const fd = fs.openSync(idle, "a");
491
+ let written = false;
492
+ try {
493
+ withRmSyncPatched(
494
+ (file, rmSync) => {
495
+ if (!written) {
496
+ written = true;
497
+ fs.writeSync(
498
+ fd,
499
+ `${JSON.stringify({ v: 1, text: "late-by-fd" })}\n`,
500
+ );
501
+ }
502
+ rmSync(file);
503
+ },
504
+ () => {
505
+ gcProjectDir(root, CWD, {
506
+ fileThreshold: 2,
507
+ lineThreshold: 100000,
508
+ keepNewest: 1,
509
+ });
510
+ },
511
+ );
512
+ } finally {
513
+ fs.closeSync(fd);
514
+ }
515
+ assert.equal(written, true);
516
+ const texts = dirTexts(dir);
517
+ assert.ok(texts.includes("late-by-fd"), "the in-flight write survived");
518
+ assert.equal(texts.filter((t) => t.startsWith("idle.jsonl-")).length, 2);
519
+ });
520
+
521
+ test("merged output is chronological by entry ts, not by mutable mtime", () => {
522
+ const root = makeRoot();
523
+ const dir = projectRoot(root);
524
+ fs.mkdirSync(dir, { recursive: true });
525
+ // "old" holds the oldest prompts but was rewritten recently (a delete
526
+ // sweep bumps mtime); "mid" is genuinely newer by ts.
527
+ writeEntries(
528
+ dir,
529
+ "old.jsonl",
530
+ [
531
+ { text: "old-1", ts: 1000 },
532
+ { text: "old-2", ts: 1001 },
533
+ ],
534
+ 90_000,
535
+ );
536
+ writeEntries(
537
+ dir,
538
+ "mid.jsonl",
539
+ [
540
+ { text: "mid-1", ts: 2000 },
541
+ { text: "mid-2", ts: 2001 },
542
+ ],
543
+ 2000,
544
+ );
545
+ writeEntries(dir, "new.jsonl", [{ text: "new-1", ts: 3000 }], 3000);
546
+ const result = gcProjectDir(root, CWD, {
547
+ fileThreshold: 2,
548
+ lineThreshold: 100000,
549
+ keepNewest: 1,
550
+ });
551
+ assert.deepEqual(result, { compacted: true, merged: 2 });
552
+ assert.deepEqual(compactTexts(dir), ["old-1", "old-2", "mid-1", "mid-2"]);
553
+ assert.equal(fs.readdirSync(dir).includes("new.jsonl"), true);
554
+ // The drain still reads newest-first after compaction.
555
+ const drained = drainProject(root, CWD, 1000, root);
556
+ assert.equal(drained.status, "ok");
557
+ if (drained.status === "ok") {
558
+ assert.deepEqual(drained.prompts, [
559
+ "new-1",
560
+ "mid-2",
561
+ "mid-1",
562
+ "old-2",
563
+ "old-1",
564
+ ]);
565
+ }
566
+ });
567
+
568
+ test("compaction drops tombstoned prompts instead of copying them", () => {
569
+ const root = makeRoot();
570
+ const dir = projectRoot(root);
571
+ fs.mkdirSync(dir, { recursive: true });
572
+ writeEntries(
573
+ dir,
574
+ "a.jsonl",
575
+ [{ text: "keep me" }, { text: "secret token" }],
576
+ 1000,
577
+ );
578
+ writeEntries(dir, "b.jsonl", [{ text: "legacy hidden" }], 2000);
579
+ writeEntries(dir, "c.jsonl", [{ text: "newest" }], 3000);
580
+ assert.equal(hidePrompt(root, "secret token").status, "written");
581
+ // A legacy plaintext (prefix-format) tombstone stays honored too.
582
+ const hidden = JSON.parse(
583
+ fs.readFileSync(path.join(root, "hidden.json"), "utf8"),
584
+ ) as string[];
585
+ fs.writeFileSync(
586
+ path.join(root, "hidden.json"),
587
+ JSON.stringify([...hidden, "legacy hidden"]),
588
+ );
589
+ const result = gcProjectDir(root, CWD, {
590
+ fileThreshold: 2,
591
+ lineThreshold: 100000,
592
+ keepNewest: 1,
593
+ });
594
+ assert.equal(result.compacted, true);
595
+ const texts = dirTexts(dir);
596
+ assert.ok(texts.includes("keep me"));
597
+ assert.ok(!texts.includes("secret token"));
598
+ assert.ok(!texts.includes("legacy hidden"));
599
+ });
600
+
601
+ test("an untrusted hidden.json blocks compaction (fail closed)", () => {
602
+ const root = makeRoot();
603
+ const dir = projectRoot(root);
604
+ fs.mkdirSync(dir, { recursive: true });
605
+ for (let i = 1; i <= 4; i++) {
606
+ writeFile(dir, `u${i}.jsonl`, 2, i * 1000);
607
+ }
608
+ fs.writeFileSync(path.join(root, "hidden.json"), "{corrupt", "utf8");
609
+ const before = fs.readdirSync(dir).sort();
610
+ const result = gcProjectDir(root, CWD, {
611
+ fileThreshold: 2,
612
+ lineThreshold: 100000,
613
+ keepNewest: 1,
614
+ });
615
+ assert.deepEqual(result, { compacted: false, merged: 0 });
616
+ assert.deepEqual(fs.readdirSync(dir).sort(), before);
617
+ });
618
+
619
+ test("a failed compact write is tolerated and loses nothing", () => {
620
+ const root = makeRoot();
621
+ const dir = projectRoot(root);
622
+ fs.mkdirSync(dir, { recursive: true });
623
+ for (let i = 1; i <= 4; i++) {
624
+ writeFile(dir, `w${i}.jsonl`, 3, i * 1000);
625
+ }
626
+ type RenameSync = (from: string, to: string) => void;
627
+ const target = fs as unknown as { renameSync: RenameSync };
628
+ const realRename = fs.renameSync.bind(fs) as RenameSync;
629
+ target.renameSync = (from: string, to: string) => {
630
+ if (path.basename(to).startsWith("compact-")) {
631
+ throw new Error("simulated ENOSPC");
632
+ }
633
+ realRename(from, to);
634
+ };
635
+ let result: ReturnType<typeof gcProjectDir>;
636
+ try {
637
+ result = gcProjectDir(root, CWD, {
638
+ fileThreshold: 2,
639
+ lineThreshold: 100000,
640
+ keepNewest: 1,
641
+ });
642
+ } finally {
643
+ target.renameSync = realRename;
644
+ }
645
+ assert.deepEqual(result, { compacted: false, merged: 0 });
646
+ assert.equal(dirTexts(dir).length, 12);
647
+ assert.deepEqual(
648
+ fs.readdirSync(dir).filter((f) => f.includes(".tmp-")),
649
+ [],
650
+ );
651
+ });
652
+
653
+ test("a torn last line in a merged file is carried over, not dropped", () => {
654
+ const root = makeRoot();
655
+ const dir = projectRoot(root);
656
+ fs.mkdirSync(dir, { recursive: true });
657
+ // A writer died mid-append: the final line has no newline yet.
658
+ const torn = path.join(dir, "torn.jsonl");
659
+ fs.writeFileSync(
660
+ torn,
661
+ `${JSON.stringify({ v: 1, text: "whole" })}\n${JSON.stringify({ v: 1, text: "torn" })}`,
662
+ );
663
+ fs.utimesSync(torn, new Date(1000), new Date(1000));
664
+ writeFile(dir, "r2.jsonl", 2, 2000);
665
+ writeFile(dir, "r3.jsonl", 2, 3000);
666
+ const result = gcProjectDir(root, CWD, {
667
+ fileThreshold: 2,
668
+ lineThreshold: 100000,
669
+ keepNewest: 1,
670
+ });
671
+ assert.deepEqual(result, { compacted: true, merged: 2 });
672
+ assert.equal(fs.existsSync(torn), false);
673
+ // Every compact line parses, and the torn entry survived.
674
+ const texts = compactTexts(dir);
675
+ assert.ok(texts.includes("whole"));
676
+ assert.ok(texts.includes("torn"));
677
+ assert.ok(texts.includes("r2.jsonl-1"));
678
+ });
679
+
680
+ // Carry-over ordering (#1394 advisory "carryover-after-unlink"): bytes that
681
+ // reach a claim after its merge read are copied into the compact file
682
+ // BEFORE the claim is removed, so a failed copy leaves the claim — and every
683
+ // byte in it — on disk instead of depending on a post-removal fallback.
684
+
685
+ /**
686
+ * Replace fs.renameSync for the duration of fn; `after` runs right after a
687
+ * rename whose destination is a compact-*.jsonl file lands.
688
+ */
689
+ function withAfterCompactRename(after: () => void, fn: () => void): void {
690
+ type RenameSync = (from: string, to: string) => void;
691
+ const target = fs as unknown as { renameSync: RenameSync };
692
+ const realRename = fs.renameSync.bind(fs) as RenameSync;
693
+ target.renameSync = (from: string, to: string) => {
694
+ realRename(from, to);
695
+ if (path.basename(to).startsWith("compact-")) after();
696
+ };
697
+ try {
698
+ fn();
699
+ } finally {
700
+ target.renameSync = realRename;
701
+ }
702
+ }
703
+
704
+ test("late bytes reach the compact file before their claim is removed", () => {
705
+ const root = makeRoot();
706
+ const dir = projectRoot(root);
707
+ fs.mkdirSync(dir, { recursive: true });
708
+ const idle = writeFile(dir, "idle.jsonl", 2, 1000);
709
+ for (let i = 2; i <= 4; i++) {
710
+ writeFile(dir, `k${i}.jsonl`, 2, i * 1000);
711
+ }
712
+ // A writer that opened the file before the claim writes after the merge.
713
+ const fd = fs.openSync(idle, "a");
714
+ let lateInCompactAtRemoval: boolean | null = null;
715
+ try {
716
+ withAfterCompactRename(
717
+ () => {
718
+ fs.writeSync(fd, `${JSON.stringify({ v: 1, text: "late-by-fd" })}\n`);
719
+ },
720
+ () => {
721
+ withRmSyncPatched(
722
+ (file, rmSync) => {
723
+ if (path.basename(file).startsWith("idle.jsonl.gc-")) {
724
+ lateInCompactAtRemoval = compactTexts(dir).includes("late-by-fd");
725
+ }
726
+ rmSync(file);
727
+ },
728
+ () => {
729
+ const result = gcProjectDir(root, CWD, {
730
+ fileThreshold: 2,
731
+ lineThreshold: 100000,
732
+ keepNewest: 1,
733
+ });
734
+ assert.deepEqual(result, { compacted: true, merged: 3 });
735
+ },
736
+ );
737
+ },
738
+ );
739
+ } finally {
740
+ fs.closeSync(fd);
741
+ }
742
+ assert.equal(lateInCompactAtRemoval, true);
743
+ assert.equal(dirTexts(dir).filter((t) => t === "late-by-fd").length, 1);
744
+ });
745
+
746
+ test("a failed carry-over keeps the whole claim on disk", () => {
747
+ const root = makeRoot();
748
+ const dir = projectRoot(root);
749
+ fs.mkdirSync(dir, { recursive: true });
750
+ const idle = writeFile(dir, "idle.jsonl", 2, 1000);
751
+ for (let i = 2; i <= 4; i++) {
752
+ writeFile(dir, `m${i}.jsonl`, 2, i * 1000);
753
+ }
754
+ const fd = fs.openSync(idle, "a");
755
+ type AppendFileSync = (file: string, data: string | Buffer) => void;
756
+ const target = fs as unknown as { appendFileSync: AppendFileSync };
757
+ const realAppend = fs.appendFileSync.bind(fs) as AppendFileSync;
758
+ target.appendFileSync = (file: string, data: string | Buffer) => {
759
+ if (path.basename(String(file)).startsWith("compact-")) {
760
+ throw Object.assign(new Error("simulated ENOSPC"), { code: "ENOSPC" });
761
+ }
762
+ realAppend(file, data);
763
+ };
764
+ try {
765
+ withAfterCompactRename(
766
+ () => {
767
+ fs.writeSync(fd, `${JSON.stringify({ v: 1, text: "late-by-fd" })}\n`);
768
+ },
769
+ () => {
770
+ gcProjectDir(root, CWD, {
771
+ fileThreshold: 2,
772
+ lineThreshold: 100000,
773
+ keepNewest: 1,
774
+ });
775
+ },
776
+ );
777
+ } finally {
778
+ target.appendFileSync = realAppend;
779
+ fs.closeSync(fd);
780
+ }
781
+ // The claim survives intact: its merged lines AND the late line.
782
+ const claim = fs
783
+ .readdirSync(dir)
784
+ .find((f) => f.startsWith("idle.jsonl.gc-"));
785
+ assert.ok(claim, "the claim must stay on disk when its carry-over fails");
786
+ const claimTexts = fs
787
+ .readFileSync(path.join(dir, claim), "utf8")
788
+ .trim()
789
+ .split("\n")
790
+ .map((l) => (JSON.parse(l) as { text: string }).text);
791
+ assert.deepEqual(claimTexts, ["idle.jsonl-0", "idle.jsonl-1", "late-by-fd"]);
792
+ });
793
+
794
+ // Idempotence (#1394 advisory "line-threshold-perpetual"): merging a single
795
+ // file cannot reduce the file count, so GC must not rewrite it — otherwise
796
+ // every shutdown above a threshold rewrites the compact file again.
797
+
798
+ test("a second GC over an already compacted dir rewrites nothing", () => {
799
+ const root = makeRoot();
800
+ const dir = projectRoot(root);
801
+ fs.mkdirSync(dir, { recursive: true });
802
+ for (let i = 1; i <= 12; i++) {
803
+ writeFile(dir, `n${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000);
804
+ }
805
+ const opts = { fileThreshold: 10, lineThreshold: 100000, keepNewest: 10 };
806
+ assert.deepEqual(gcProjectDir(root, CWD, opts), { compacted: true, merged: 2 });
807
+ // 10 newest + 1 compact = 11 files: still above the file threshold.
808
+ const before = fs.readdirSync(dir).sort();
809
+ assert.equal(before.length, 11);
810
+ const compactBefore = compactTexts(dir);
811
+ assert.deepEqual(gcProjectDir(root, CWD, opts), { compacted: false, merged: 0 });
812
+ assert.deepEqual(fs.readdirSync(dir).sort(), before);
813
+ assert.deepEqual(compactTexts(dir), compactBefore);
814
+ });
815
+
816
+ test("the line threshold with a single merge candidate rewrites nothing", () => {
817
+ const root = makeRoot();
818
+ const dir = projectRoot(root);
819
+ fs.mkdirSync(dir, { recursive: true });
820
+ // 3 files x 4000 lines = 12000 > 10000, but keepNewest 2 leaves one file.
821
+ for (let i = 1; i <= 3; i++) {
822
+ writeFile(dir, `l${i}.jsonl`, 4000, i * 1000);
823
+ }
824
+ const before = fs.readdirSync(dir).sort();
825
+ const result = gcProjectDir(root, CWD, {
826
+ fileThreshold: 10,
827
+ lineThreshold: 10000,
828
+ keepNewest: 2,
829
+ });
830
+ assert.deepEqual(result, { compacted: false, merged: 0 });
831
+ assert.deepEqual(fs.readdirSync(dir).sort(), before);
832
+ });