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,93 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const sourcePath = fileURLToPath(
7
+ new URL("../extensions/history/index.ts", import.meta.url),
8
+ );
9
+ const source = fs.readFileSync(sourcePath, "utf8");
10
+
11
+ test("preview rows are bottom-padded so the panel shrinks from the bottom", () => {
12
+ const rebuildStart = source.indexOf(
13
+ "private rebuildPreviewWithWidth(width: number): void {",
14
+ );
15
+ assert.notStrictEqual(
16
+ rebuildStart,
17
+ -1,
18
+ "rebuildPreviewWithWidth() should exist",
19
+ );
20
+
21
+ const rebuildEnd = source.indexOf(
22
+ "\n // -- Selection actions",
23
+ rebuildStart,
24
+ );
25
+ assert.notStrictEqual(
26
+ rebuildEnd,
27
+ -1,
28
+ "rebuildPreview section boundary should exist",
29
+ );
30
+
31
+ const rebuildPreviewSource = source.slice(rebuildStart, rebuildEnd);
32
+
33
+ const rowLoopIndex = rebuildPreviewSource.indexOf(
34
+ "for (let i = 0; i < PREVIEW_ROWS; i++)",
35
+ );
36
+ assert.ok(
37
+ rowLoopIndex >= 0,
38
+ "fixed-height PREVIEW_ROWS row loop should exist",
39
+ );
40
+
41
+ const emptyRowPadIndex = rebuildPreviewSource.indexOf(
42
+ "this.previewContainer.addChild(new FixedRowText());",
43
+ rowLoopIndex,
44
+ );
45
+ assert.ok(
46
+ emptyRowPadIndex >= 0,
47
+ "rows past the wrapped content should be added as empty bottom padding",
48
+ );
49
+
50
+ assert.ok(
51
+ !rebuildPreviewSource.includes(
52
+ "const topPadding = PREVIEW_ROWS - visible.length;",
53
+ ),
54
+ "preview should not compute top padding",
55
+ );
56
+ });
57
+
58
+ test("row padding measures visible width, stripping SGR escapes", () => {
59
+ // Colored list rows carry SGR escape sequences that occupy no terminal
60
+ // cells; padding must use the VISIBLE width or the row falls short of
61
+ // the overlay width and leaves ghost characters on dismiss.
62
+ const renderStart = source.indexOf(" render(width: number): string[] {");
63
+ assert.notStrictEqual(renderStart, -1, "FixedRowText.render should exist");
64
+
65
+ const renderSource = source.slice(renderStart, renderStart + 2200);
66
+ const padLine = renderSource
67
+ .split("\n")
68
+ .find((l) => l.includes('" ".repeat(Math.max(0, width -'));
69
+ assert.ok(padLine !== undefined, "final full-width pad should exist");
70
+ assert.ok(
71
+ padLine.includes("visible"),
72
+ "pad must measure the SGR-stripped visible width, not rendered.length",
73
+ );
74
+ assert.ok(
75
+ /visible = rendered\.replace\(/.test(renderSource),
76
+ "visible width must be derived by stripping escape sequences",
77
+ );
78
+ });
79
+
80
+ test("sanitizeForDisplay appends the full astral code point, not a lone surrogate", () => {
81
+ const fnStart = source.indexOf("function sanitizeForDisplay(");
82
+ assert.notStrictEqual(fnStart, -1, "sanitizeForDisplay should exist");
83
+
84
+ const fnSource = source.slice(fnStart, fnStart + 1200);
85
+ assert.ok(
86
+ fnSource.includes("String.fromCodePoint(cp)"),
87
+ "astral code points must be re-appended whole (emoji survive)",
88
+ );
89
+ assert.ok(
90
+ fnSource.includes("if (cp > 0xffff) i++"),
91
+ "the low surrogate of the pair must still be skipped",
92
+ );
93
+ });
@@ -0,0 +1,143 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createHash } from "node:crypto";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import {
8
+ ensureRegistryEntry,
9
+ projectHash,
10
+ registryPath,
11
+ } from "../extensions/history/store.ts";
12
+
13
+ // Slice-1 port note: the dev suite asserted lookups through the dead
14
+ // `lookupCwd` export, which slice 1 drops. Every lookup assertion is
15
+ // re-expressed against the persisted registry.json content.
16
+
17
+ function makeRoot(): string {
18
+ return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-registry-"));
19
+ }
20
+
21
+ function readRegistryFile(root: string): Record<string, string> {
22
+ return JSON.parse(
23
+ fs.readFileSync(registryPath(root), "utf8"),
24
+ ) as Record<string, string>;
25
+ }
26
+
27
+ test("creates the registry with the first entry (idempotent)", () => {
28
+ const root = makeRoot();
29
+ const result = ensureRegistryEntry(root, "/pi-history-test/project-a");
30
+ assert.deepEqual(result, { hash: "4be15ec687e9df85", created: true });
31
+ ensureRegistryEntry(root, "/pi-history-test/project-a");
32
+ assert.deepEqual(readRegistryFile(root), {
33
+ "4be15ec687e9df85": "/pi-history-test/project-a",
34
+ });
35
+ });
36
+
37
+ test("second project appends without touching the first", () => {
38
+ const root = makeRoot();
39
+ ensureRegistryEntry(root, "/pi-history-test/project-a");
40
+ const b = ensureRegistryEntry(root, "/pi-history-test/project-b");
41
+ assert.equal(b.created, true);
42
+ const raw = readRegistryFile(root);
43
+ assert.equal(Object.keys(raw).length, 2);
44
+ assert.equal(raw[b.hash], "/pi-history-test/project-b");
45
+ });
46
+
47
+ test("registry.json maps known hashes and omits unknown ones", () => {
48
+ const root = makeRoot();
49
+ const { hash } = ensureRegistryEntry(root, "/pi-history-test/project-a");
50
+ const raw = readRegistryFile(root);
51
+ assert.equal(raw[hash], "/pi-history-test/project-a");
52
+ assert.equal(raw["0000000000000000"], undefined);
53
+ // A fresh root has no registry file until its first entry lands.
54
+ assert.equal(fs.existsSync(registryPath(makeRoot())), false);
55
+ });
56
+
57
+ test("corrupt registry json is treated as empty and rebuilt on next entry", () => {
58
+ const root = makeRoot();
59
+ fs.writeFileSync(registryPath(root), "{not-json", "utf8");
60
+ const result = ensureRegistryEntry(root, "/pi-history-test/project-a");
61
+ assert.equal(result.created, true);
62
+ // The corrupt content was discarded (fail-open to empty), so the rebuilt
63
+ // registry contains exactly the new entry and nothing else.
64
+ assert.deepEqual(readRegistryFile(root), {
65
+ "4be15ec687e9df85": "/pi-history-test/project-a",
66
+ });
67
+ });
68
+
69
+ test("no leftover tmp files after writes", () => {
70
+ const root = makeRoot();
71
+ ensureRegistryEntry(root, "/a");
72
+ ensureRegistryEntry(root, "/b");
73
+ const leftovers = fs.readdirSync(root).filter((f) => f.includes(".tmp-"));
74
+ assert.deepEqual(leftovers, []);
75
+ });
76
+
77
+ test("hash collision re-keys the existing occupant; the new cwd keeps the short hash", () => {
78
+ const root = makeRoot();
79
+ const cwd = "/pi-history-test/project-a";
80
+ const hash = projectHash(cwd);
81
+ // Simulate a collision: the short hash is pre-mapped to a different cwd.
82
+ fs.mkdirSync(root, { recursive: true });
83
+ fs.writeFileSync(
84
+ registryPath(root),
85
+ JSON.stringify({ [hash]: "/some/other/project" }),
86
+ "utf8",
87
+ );
88
+ const result = ensureRegistryEntry(root, cwd);
89
+ assert.deepEqual(result, { hash, created: true });
90
+ const raw = readRegistryFile(root);
91
+ assert.equal(raw[hash], cwd);
92
+ const longKeys = Object.keys(raw).filter((k) => k.length === 24);
93
+ assert.equal(longKeys.length, 1);
94
+ assert.equal(raw[longKeys[0]], "/some/other/project");
95
+ });
96
+
97
+ test("a re-keyed cwd keeps its long key on later calls (stable collision mappings)", () => {
98
+ // projectHashLong is private: derive the documented 24-char key here —
99
+ // the literals never exist, so canonicalization falls back to the raw
100
+ // string on every platform.
101
+ const longKey = (cwd: string) =>
102
+ createHash("sha256").update(cwd).digest("hex").slice(0, 24);
103
+ const root = makeRoot();
104
+ const a = "/pi-history-test/registry-collide-a";
105
+ const b = "/pi-history-test/registry-collide-b";
106
+ // Simulate the collision: b's short hash is pre-mapped to a different
107
+ // cwd, so entering b re-keys that occupant to a 24-char key.
108
+ const shortHash = projectHash(b);
109
+ fs.mkdirSync(root, { recursive: true });
110
+ fs.writeFileSync(
111
+ registryPath(root),
112
+ JSON.stringify({ [shortHash]: a }),
113
+ "utf8",
114
+ );
115
+ ensureRegistryEntry(root, b); // collision: a re-keyed to 24 chars
116
+ const before = readRegistryFile(root);
117
+ // Re-entering the re-keyed cwd must return its EXISTING long key and
118
+ // leave the other occupant's short-hash mapping untouched.
119
+ const again = ensureRegistryEntry(root, a);
120
+ assert.equal(again.created, false);
121
+ assert.equal(again.hash, longKey(a));
122
+ const after = readRegistryFile(root);
123
+ assert.deepEqual(after, before);
124
+ // And re-entering the short-hash holder keeps the short key.
125
+ const holder = ensureRegistryEntry(root, b);
126
+ assert.equal(holder.hash, projectHash(b));
127
+ assert.deepEqual(readRegistryFile(root), before);
128
+ });
129
+
130
+ test("wrong-shaped registry (array / scalar / null) fails open and is rebuilt on the next entry", () => {
131
+ // Valid JSON, wrong shape: the readRegistry shape guard treats each as an
132
+ // empty registry, and the next entry rebuilds a valid object-mapped
133
+ // registry around itself.
134
+ const shapes: unknown[] = [["an", "array"], "scalar-string", null];
135
+ const cwd = "/pi-history-test/project-a";
136
+ for (const shape of shapes) {
137
+ const root = makeRoot();
138
+ fs.writeFileSync(registryPath(root), JSON.stringify(shape), "utf8");
139
+ const result = ensureRegistryEntry(root, cwd);
140
+ assert.deepEqual(result, { hash: projectHash(cwd), created: true });
141
+ assert.deepEqual(readRegistryFile(root), { [projectHash(cwd)]: cwd });
142
+ }
143
+ });
@@ -0,0 +1,411 @@
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 {
7
+ appendSessionCapture,
8
+ deleteFromGlobal,
9
+ deleteFromProject,
10
+ drainGlobal,
11
+ drainProject,
12
+ globalSeedPath,
13
+ openSessionWriter,
14
+ parseStoreLine,
15
+ projectHash,
16
+ } from "../extensions/history/store.ts";
17
+ import { hidePrompt } from "../extensions/history/hide-prompts.ts";
18
+
19
+ // Scope delete (design v2): sweepFiles' atomic per-file rewrite semantics
20
+ // plus the project/global delete entry points. Synthetic project cwds —
21
+ // never real directories on any machine (identity only feeds projectHash;
22
+ // the fixtures live in tmpdirs and never touch the user's real ~/.pi).
23
+ const PROJECT_A = "/fixtures/pi-history/project-a";
24
+ const PROJECT_B = "/fixtures/pi-history/project-b";
25
+
26
+ function makeRoot(): string {
27
+ return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-del-"));
28
+ }
29
+
30
+ function writeLines(file: string, texts: string[]): void {
31
+ fs.mkdirSync(path.dirname(file), { recursive: true });
32
+ fs.writeFileSync(
33
+ file,
34
+ `${texts.map((t) => JSON.stringify({ v: 1, text: t })).join("\n")}\n`,
35
+ "utf8",
36
+ );
37
+ }
38
+
39
+ function fileTexts(file: string): string[] {
40
+ return fs
41
+ .readFileSync(file, "utf8")
42
+ .trim()
43
+ .split("\n")
44
+ .filter((l) => l.length > 0)
45
+ .map((l) => (JSON.parse(l) as { text: string }).text);
46
+ }
47
+
48
+ test("project delete removes every copy across the project's files", () => {
49
+ const root = makeRoot();
50
+ const dir = path.join(root, "projects", projectHash(PROJECT_A));
51
+ writeLines(path.join(dir, "s1.jsonl"), ["keep", "victim"]);
52
+ writeLines(path.join(dir, "s2.jsonl"), ["VICTIM ", "also-keep"]);
53
+ const result = deleteFromProject(root, PROJECT_A, "victim");
54
+ assert.deepEqual(result, { filesAffected: 2, removed: 2, failed: 0 });
55
+ assert.deepEqual(fileTexts(path.join(dir, "s1.jsonl")), ["keep"]);
56
+ assert.deepEqual(fileTexts(path.join(dir, "s2.jsonl")), ["also-keep"]);
57
+ });
58
+
59
+ test("project delete leaves other projects untouched", () => {
60
+ const root = makeRoot();
61
+ const dirA = path.join(root, "projects", projectHash(PROJECT_A));
62
+ const dirB = path.join(root, "projects", projectHash(PROJECT_B));
63
+ writeLines(path.join(dirA, "s.jsonl"), ["victim"]);
64
+ writeLines(path.join(dirB, "s.jsonl"), ["victim", "b-keep"]);
65
+ deleteFromProject(root, PROJECT_A, "victim");
66
+ assert.deepEqual(fileTexts(path.join(dirB, "s.jsonl")), ["victim", "b-keep"]);
67
+ });
68
+
69
+ test("project delete of unknown prompt is a no-op", () => {
70
+ const root = makeRoot();
71
+ const dir = path.join(root, "projects", projectHash(PROJECT_A));
72
+ writeLines(path.join(dir, "s.jsonl"), ["a"]);
73
+ const result = deleteFromProject(root, PROJECT_A, "missing");
74
+ assert.deepEqual(result, { filesAffected: 0, removed: 0, failed: 0 });
75
+ assert.deepEqual(fileTexts(path.join(dir, "s.jsonl")), ["a"]);
76
+ });
77
+
78
+ test("project delete on a missing dir is a no-op", () => {
79
+ const root = makeRoot();
80
+ const result = deleteFromProject(root, PROJECT_A, "x");
81
+ assert.deepEqual(result, { filesAffected: 0, removed: 0, failed: 0 });
82
+ });
83
+
84
+ test("global delete on a root without a projects dir is a zero-delete no-op", () => {
85
+ const root = makeRoot();
86
+ const result = deleteFromGlobal(root, "x");
87
+ assert.deepEqual(result, { filesAffected: 0, removed: 0, failed: 0 });
88
+ });
89
+
90
+ test("global delete sweeps every project dir plus the legacy seed", () => {
91
+ const root = makeRoot();
92
+ const dirA = path.join(root, "projects", projectHash(PROJECT_A));
93
+ const dirB = path.join(root, "projects", projectHash(PROJECT_B));
94
+ writeLines(path.join(dirA, "s.jsonl"), ["victim", "a-keep"]);
95
+ writeLines(path.join(dirB, "s.jsonl"), ["victim"]);
96
+ writeLines(globalSeedPath(root), ["victim", "legacy-keep"]);
97
+ const result = deleteFromGlobal(root, "victim");
98
+ assert.deepEqual(result, { filesAffected: 3, removed: 3, failed: 0 });
99
+ assert.deepEqual(fileTexts(path.join(dirA, "s.jsonl")), ["a-keep"]);
100
+ assert.deepEqual(fileTexts(path.join(dirB, "s.jsonl")), []);
101
+ assert.deepEqual(fileTexts(globalSeedPath(root)), ["legacy-keep"]);
102
+ });
103
+
104
+ test("delete leaves no tmp files behind", () => {
105
+ const root = makeRoot();
106
+ const dir = path.join(root, "projects", projectHash(PROJECT_A));
107
+ writeLines(path.join(dir, "s.jsonl"), ["victim"]);
108
+ deleteFromProject(root, PROJECT_A, "victim");
109
+ const leftovers = fs.readdirSync(dir).filter((f) => f.includes(".tmp-"));
110
+ assert.deepEqual(leftovers, []);
111
+ });
112
+
113
+ // node:test has no test.skipIf (Bun-ism): root skips via the options object.
114
+ test(
115
+ "an unreadable store file (chmod 000) is counted as failed; readable copies still swept",
116
+ { skip: process.getuid?.() === 0 ? "requires non-root" : false },
117
+ () => {
118
+ const root = makeRoot();
119
+ const dir = path.join(root, "projects", projectHash(PROJECT_A));
120
+ const readable = path.join(dir, "readable.jsonl");
121
+ const sealed = path.join(dir, "sealed.jsonl");
122
+ writeLines(readable, ["victim", "keep"]);
123
+ writeLines(sealed, ["victim"]);
124
+ fs.chmodSync(sealed, 0o000);
125
+ try {
126
+ const result = deleteFromProject(root, PROJECT_A, "victim");
127
+ // The unreadable file may still hold a copy: it is counted as a
128
+ // failure (never reported as a clean delete); the readable copy is
129
+ // removed and the sweep is never fatal.
130
+ assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 1 });
131
+ assert.deepEqual(fileTexts(readable), ["keep"]);
132
+ assert.equal(fs.existsSync(sealed), true);
133
+ } finally {
134
+ fs.chmodSync(sealed, 0o644); // restore before cleanup
135
+ }
136
+ },
137
+ );
138
+
139
+ test("a file whose every line is deleted becomes empty (kept, not removed)", () => {
140
+ const root = makeRoot();
141
+ const dir = path.join(root, "projects", projectHash(PROJECT_A));
142
+ const file = path.join(dir, "s.jsonl");
143
+ writeLines(file, ["only-victim"]);
144
+ deleteFromProject(root, PROJECT_A, "only-victim");
145
+ assert.equal(fs.existsSync(file), true);
146
+ assert.equal(fs.readFileSync(file, "utf8"), "");
147
+ });
148
+
149
+ // Active-writer safety (design v2): the sweep rewrites the writer's own
150
+ // file IN PLACE (tmp + rename, never a removal — emptied files are kept),
151
+ // so a concurrently live writer keeps working by path: its next capture
152
+ // appends into the swept file, and the surviving + new lines parse fine.
153
+ test("a sweep with a concurrent live writer keeps the writer's file functional", () => {
154
+ const root = makeRoot();
155
+ const state = openSessionWriter(root, PROJECT_A, "instance-1");
156
+ appendSessionCapture(state, "victim");
157
+ appendSessionCapture(state, "keeper");
158
+
159
+ const result = deleteFromProject(root, PROJECT_A, "victim");
160
+ assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 0 });
161
+
162
+ // The same writer state keeps appending after the sweep — the file was
163
+ // rewritten under the writer's feet, not removed.
164
+ appendSessionCapture(state, "after-delete");
165
+ assert.equal(state.lineCount, 3);
166
+ assert.equal(fs.existsSync(state.filePath), true);
167
+ assert.deepEqual(fileTexts(state.filePath), ["keeper", "after-delete"]);
168
+ });
169
+
170
+ // Concurrent appends (PR #1393 adaptation): another pi instance appends to
171
+ // its own file by path at any time. A line that lands between the sweep's
172
+ // read and its rename must survive the rewrite instead of vanishing with
173
+ // the replaced inode. The hook simulates that instance: it appends right
174
+ // before the sweep's rename of the swept file.
175
+ function withAppendBeforeRename(
176
+ target: string,
177
+ appended: string,
178
+ run: () => void,
179
+ ): void {
180
+ const realRename = fs.renameSync;
181
+ let fired = false;
182
+ fs.renameSync = ((from: fs.PathLike, to: fs.PathLike) => {
183
+ if (!fired && String(to) === target) {
184
+ fired = true;
185
+ fs.appendFileSync(target, appended, "utf8");
186
+ }
187
+ return realRename(from, to);
188
+ }) as typeof fs.renameSync;
189
+ try {
190
+ run();
191
+ } finally {
192
+ fs.renameSync = realRename;
193
+ }
194
+ assert.ok(fired, "the simulated concurrent append must fire");
195
+ }
196
+
197
+ test("a line appended between the sweep's read and rename survives", () => {
198
+ const root = makeRoot();
199
+ const state = openSessionWriter(root, PROJECT_A, "other-instance");
200
+ appendSessionCapture(state, "victim");
201
+ appendSessionCapture(state, "keeper");
202
+ withAppendBeforeRename(
203
+ state.filePath,
204
+ `${JSON.stringify({ v: 1, text: "raced in" })}\n`,
205
+ () => {
206
+ const result = deleteFromProject(root, PROJECT_A, "victim");
207
+ assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 0 });
208
+ },
209
+ );
210
+ assert.deepEqual(fileTexts(state.filePath), ["keeper", "raced in"]);
211
+ });
212
+
213
+ test("a torn last line completed during the sweep is preserved whole", () => {
214
+ const root = makeRoot();
215
+ const dir = path.join(root, "projects", projectHash(PROJECT_A));
216
+ const file = path.join(dir, "other.jsonl");
217
+ const torn = JSON.stringify({ v: 1, text: "in flight" });
218
+ writeLines(file, ["victim", "keeper"]);
219
+ fs.appendFileSync(file, torn.slice(0, 10), "utf8");
220
+ withAppendBeforeRename(file, `${torn.slice(10)}\n`, () => {
221
+ deleteFromProject(root, PROJECT_A, "victim");
222
+ });
223
+ assert.deepEqual(fileTexts(file), ["keeper", "in flight"]);
224
+ });
225
+
226
+ test("a failed rewrite is counted, keeps the original, and leaves no tmp file", () => {
227
+ const root = makeRoot();
228
+ const dir = path.join(root, "projects", projectHash(PROJECT_A));
229
+ const broken = path.join(dir, "broken.jsonl");
230
+ const healthy = path.join(dir, "healthy.jsonl");
231
+ writeLines(broken, ["victim", "broken-keep"]);
232
+ writeLines(healthy, ["victim", "healthy-keep"]);
233
+ const realRename = fs.renameSync;
234
+ fs.renameSync = ((from: fs.PathLike, to: fs.PathLike) => {
235
+ if (String(to) === broken) {
236
+ throw Object.assign(new Error("simulated EIO"), { code: "EIO" });
237
+ }
238
+ return realRename(from, to);
239
+ }) as typeof fs.renameSync;
240
+ let result;
241
+ try {
242
+ result = deleteFromProject(root, PROJECT_A, "victim");
243
+ } finally {
244
+ fs.renameSync = realRename;
245
+ }
246
+ assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 1 });
247
+ assert.deepEqual(fileTexts(broken), ["victim", "broken-keep"]);
248
+ assert.deepEqual(fileTexts(healthy), ["healthy-keep"]);
249
+ const leftovers = fs.readdirSync(dir).filter((f) => f.includes(".tmp-"));
250
+ assert.deepEqual(leftovers, []);
251
+ });
252
+
253
+ // A failed carry-over append (#1393 advisory): the rewrite already replaced
254
+ // the file, so the raced lines exist only in the kept descriptor. They must
255
+ // land in a sibling store file instead of vanishing with the old inode.
256
+ test("raced lines survive a failed carry-over append in a sibling store file", () => {
257
+ const root = makeRoot();
258
+ const state = openSessionWriter(root, PROJECT_A, "other-instance");
259
+ appendSessionCapture(state, "victim");
260
+ appendSessionCapture(state, "keeper");
261
+ const realAppend = fs.appendFileSync;
262
+ let carryFailed = false;
263
+ fs.appendFileSync = ((
264
+ file: fs.PathOrFileDescriptor,
265
+ data: string | Uint8Array,
266
+ options?: fs.WriteFileOptions,
267
+ ) => {
268
+ // The sweep carries a Buffer; the simulated instance appends a string.
269
+ if (String(file) === state.filePath && Buffer.isBuffer(data)) {
270
+ carryFailed = true;
271
+ throw Object.assign(new Error("simulated EIO"), { code: "EIO" });
272
+ }
273
+ return realAppend(file, data, options);
274
+ }) as typeof fs.appendFileSync;
275
+ let result;
276
+ try {
277
+ withAppendBeforeRename(
278
+ state.filePath,
279
+ `${JSON.stringify({ v: 1, text: "raced in" })}\n`,
280
+ () => {
281
+ result = deleteFromProject(root, PROJECT_A, "victim");
282
+ },
283
+ );
284
+ } finally {
285
+ fs.appendFileSync = realAppend;
286
+ }
287
+ assert.equal(carryFailed, true);
288
+ assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 0 });
289
+ assert.deepEqual(fileTexts(state.filePath), ["keeper"]);
290
+ const drained = drainProject(root, PROJECT_A);
291
+ assert.equal(drained.status, "ok");
292
+ if (drained.status === "ok") {
293
+ assert.deepEqual([...drained.prompts].sort(), ["keeper", "raced in"]);
294
+ }
295
+ const dir = path.dirname(state.filePath);
296
+ assert.deepEqual(
297
+ fs.readdirSync(dir).filter((f) => f.includes(".tmp-")),
298
+ [],
299
+ );
300
+ });
301
+
302
+ /** Fail the sweep's Buffer carry-over append to `target`; `partial` bytes land first. */
303
+ function withFailedCarry(target: string, partial: number, run: () => void): void {
304
+ const realAppend = fs.appendFileSync;
305
+ let failed = false;
306
+ fs.appendFileSync = ((
307
+ file: fs.PathOrFileDescriptor,
308
+ data: string | Uint8Array,
309
+ options?: fs.WriteFileOptions,
310
+ ) => {
311
+ if (String(file) === target && Buffer.isBuffer(data)) {
312
+ failed = true;
313
+ if (partial > 0) realAppend(file, data.subarray(0, partial));
314
+ throw Object.assign(new Error("simulated ENOSPC"), { code: "ENOSPC" });
315
+ }
316
+ return realAppend(file, data, options);
317
+ }) as typeof fs.appendFileSync;
318
+ try {
319
+ run();
320
+ } finally {
321
+ fs.appendFileSync = realAppend;
322
+ }
323
+ assert.ok(failed, "the simulated carry-over failure must fire");
324
+ }
325
+
326
+ // Review advisory A1 (#1477): a failed carry-over on the GLOBAL seed writes
327
+ // `history-global.jsonl.carry-*.jsonl` next to it, outside every project
328
+ // dir. The global drain and delete must reach it like any store file.
329
+ test("the global scope drains and deletes the global seed's carry sibling", () => {
330
+ const root = makeRoot();
331
+ const stateDir = makeRoot();
332
+ const seed = globalSeedPath(root);
333
+ writeLines(seed, ["victim", "legacy-keep"]);
334
+ const dir = path.join(root, "projects", projectHash(PROJECT_A));
335
+ writeLines(path.join(dir, "s.jsonl"), ["project-newest"]);
336
+ withFailedCarry(seed, 0, () => {
337
+ withAppendBeforeRename(
338
+ seed,
339
+ `${JSON.stringify({ v: 1, text: "hidden raced" })}\n` +
340
+ `${JSON.stringify({ v: 1, text: "raced in" })}\n`,
341
+ () => {
342
+ const result = deleteFromGlobal(root, "victim");
343
+ assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 0 });
344
+ },
345
+ );
346
+ });
347
+ const carries = fs
348
+ .readdirSync(root)
349
+ .filter((f) => f.startsWith("history-global.jsonl.carry-"));
350
+ assert.equal(carries.length, 1, "the raced lines live in one carry sibling");
351
+ assert.equal(hidePrompt(stateDir, "hidden raced").status, "written");
352
+
353
+ // Tombstones apply; the carry holds bytes newer than the seed, so it
354
+ // drains right before it, after every project file (legacy last).
355
+ const drained = drainGlobal(root, 1000, stateDir);
356
+ assert.equal(drained.status, "ok");
357
+ if (drained.status === "ok") {
358
+ assert.deepEqual(drained.prompts, [
359
+ "project-newest",
360
+ "raced in",
361
+ "legacy-keep",
362
+ ]);
363
+ }
364
+
365
+ const result = deleteFromGlobal(root, "raced in");
366
+ assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 0 });
367
+ const after = drainGlobal(root, 1000, stateDir);
368
+ assert.equal(after.status, "ok");
369
+ if (after.status === "ok") {
370
+ assert.deepEqual(after.prompts, ["project-newest", "legacy-keep"]);
371
+ }
372
+ });
373
+
374
+ // Review advisory A3 (#1477): a carry-over append that fails after writing
375
+ // part of its bytes must not leave a newline-less fragment in the rewritten
376
+ // file, where the owner's next append would merge into one corrupt line.
377
+ test("a partially written carry-over leaves no fragment for the next append", () => {
378
+ const root = makeRoot();
379
+ const state = openSessionWriter(root, PROJECT_A, "other-instance");
380
+ appendSessionCapture(state, "victim");
381
+ appendSessionCapture(state, "keeper");
382
+ withFailedCarry(state.filePath, 7, () => {
383
+ withAppendBeforeRename(
384
+ state.filePath,
385
+ `${JSON.stringify({ v: 1, text: "raced in" })}\n`,
386
+ () => {
387
+ const result = deleteFromProject(root, PROJECT_A, "victim");
388
+ assert.deepEqual(result, { filesAffected: 1, removed: 1, failed: 0 });
389
+ },
390
+ );
391
+ });
392
+ appendSessionCapture(state, "after-carry");
393
+ const lines = fs
394
+ .readFileSync(state.filePath, "utf8")
395
+ .split("\n")
396
+ .filter((l) => l.length > 0);
397
+ assert.deepEqual(
398
+ lines.map((l) => parseStoreLine(l)?.text ?? null),
399
+ ["keeper", "after-carry"],
400
+ "every line of the rewritten file parses",
401
+ );
402
+ const drained = drainProject(root, PROJECT_A);
403
+ assert.equal(drained.status, "ok");
404
+ if (drained.status === "ok") {
405
+ assert.deepEqual([...drained.prompts].sort(), [
406
+ "after-carry",
407
+ "keeper",
408
+ "raced in",
409
+ ]);
410
+ }
411
+ });