gentle-pi 4.2.0 → 4.4.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 (334) hide show
  1. package/README.md +340 -138
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,731 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ createSessionProfileAppendController,
5
+ type SessionProfileAppendOutcome,
6
+ } from "../lib/session-profile-append-controller.ts";
7
+ import { normalizeModelConfig } from "../lib/model-routing-authority.ts";
8
+ import { SESSION_PROFILE_CUSTOM_TYPE as customType } from "../lib/session-profile-persistence.ts";
9
+
10
+ function fixture() {
11
+ let id = "session",
12
+ file: string | undefined = "session.jsonl",
13
+ rows: any[] = [],
14
+ text: string | undefined;
15
+ let mode = "normal",
16
+ serial = 0;
17
+ const source = {
18
+ getSessionId: () => id,
19
+ getSessionFile: () => file,
20
+ getBranch: () => rows,
21
+ };
22
+ const readFile = () => {
23
+ if (text === undefined)
24
+ throw Object.assign(new Error("missing"), { code: "ENOENT" });
25
+ if (text === "unreadable")
26
+ throw Object.assign(new Error("denied"), { code: "EACCES" });
27
+ return text;
28
+ };
29
+ const save = () => {
30
+ text = [
31
+ JSON.stringify({ type: "session", id }),
32
+ ...rows.map((row) => JSON.stringify(row)),
33
+ ].join("\n");
34
+ };
35
+ const append = (type: string, data: unknown) => {
36
+ if (mode === "throw-no-advance") throw new Error("append");
37
+ rows.push({
38
+ type: "custom",
39
+ customType: type,
40
+ data,
41
+ id: `entry-${++serial}`,
42
+ parentId: rows.at(-1)?.id ?? null,
43
+ timestamp: "now",
44
+ });
45
+ if (mode === "throw") throw new Error("append");
46
+ if (mode === "change") id = "other";
47
+ if (mode === "disk" || mode === "throw-after-save") save();
48
+ if (mode === "throw-after-save") throw new Error("append");
49
+ };
50
+ const controller = createSessionProfileAppendController(source, append, {
51
+ readFile,
52
+ });
53
+ return {
54
+ source,
55
+ controller,
56
+ save,
57
+ append,
58
+ readFile,
59
+ get rows() {
60
+ return rows;
61
+ },
62
+ set rows(value) {
63
+ rows = value;
64
+ },
65
+ get text() {
66
+ return text;
67
+ },
68
+ set text(value) {
69
+ text = value;
70
+ },
71
+ set mode(value) {
72
+ mode = value;
73
+ },
74
+ set id(value) {
75
+ id = value;
76
+ },
77
+ set file(value) {
78
+ file = value;
79
+ },
80
+ };
81
+ }
82
+ function bound(result: SessionProfileAppendOutcome, name: string) {
83
+ const state = result.state;
84
+ assert.equal(state.status, "bound");
85
+ if (state.status !== "bound") throw new Error("Expected bound authority");
86
+ assert.equal(state.binding.name, name);
87
+ return state.binding;
88
+ }
89
+
90
+ for (const operation of ["bind", "clear"] as const) {
91
+ test(`explicit preflush ${operation} is honest, refresh upgrades only after disk flush`, () => {
92
+ const f = fixture();
93
+ const result =
94
+ operation === "bind"
95
+ ? f.controller.bind("chosen", {})
96
+ : f.controller.clear();
97
+ assert.equal(result.status, "not-persisted");
98
+ assert.equal(result.state.status, operation === "bind" ? "bound" : "cleared");
99
+ assert.equal(f.controller.refresh().status, "not-persisted");
100
+ f.save();
101
+ assert.equal(f.controller.refresh().status, "persisted");
102
+ });
103
+ test(`failed ${operation} advances ghost but preserves corroborated disk selection`, () => {
104
+ const f = fixture();
105
+ f.mode = "disk";
106
+ f.controller.bind("good", {});
107
+ f.mode = "throw";
108
+ const result =
109
+ operation === "bind"
110
+ ? f.controller.bind("failed", {})
111
+ : f.controller.clear();
112
+ assert.equal(result.status, "append-failed-preserved");
113
+ bound(result, "good");
114
+ f.save();
115
+ bound(f.controller.refresh(), "good");
116
+ f.rows.push({
117
+ type: "custom",
118
+ customType: "ordinary",
119
+ data: {},
120
+ id: "ordinary",
121
+ parentId: f.rows.at(-1).id,
122
+ timestamp: "now",
123
+ });
124
+ f.save();
125
+ bound(f.controller.refresh(), "good");
126
+ const rows = f.rows;
127
+ f.rows = rows.slice(0, 1);
128
+ f.controller.refresh();
129
+ f.rows = rows;
130
+ bound(f.controller.refresh(), "good");
131
+ f.mode = "disk";
132
+ const recovery =
133
+ operation === "bind"
134
+ ? f.controller.bind("recovered", {})
135
+ : f.controller.clear();
136
+ assert.equal(recovery.status, "persisted");
137
+ assert.equal(
138
+ recovery.state.status,
139
+ operation === "bind" ? "bound" : "cleared",
140
+ );
141
+ });
142
+ }
143
+ test("restoration does not activate an unwritten public branch", () => {
144
+ const f = fixture();
145
+ f.append(customType, {
146
+ kind: "bind",
147
+ origin: "user",
148
+ name: "unwritten",
149
+ modelProfiles: {},
150
+ });
151
+ assert.equal(f.controller.refresh().status, "indeterminate");
152
+ });
153
+ test("real normalized routes, user schema and detached orchestrator are preserved", () => {
154
+ const f = fixture();
155
+ f.mode = "disk";
156
+ const models = normalizeModelConfig({
157
+ worker: "offline/model",
158
+ reviewer: { effort: "high" },
159
+ orchestrator: "offline/controller",
160
+ })!;
161
+ const result = f.controller.bind("normalized", models);
162
+ assert.equal(result.status, "persisted");
163
+ assert.deepEqual(f.rows.at(-1).data, {
164
+ kind: "bind",
165
+ origin: "user",
166
+ name: "normalized",
167
+ modelProfiles: {
168
+ worker: { model: "offline/model" },
169
+ reviewer: { thinking: "high" },
170
+ orchestrator: { model: "offline/controller" },
171
+ },
172
+ });
173
+ bound(result, "normalized").modelProfiles.worker.model = "mutated";
174
+ assert.equal(
175
+ bound(f.controller.refresh(), "normalized").modelProfiles.worker.model,
176
+ "offline/model",
177
+ );
178
+ });
179
+ for (const fault of [
180
+ "missing",
181
+ "corrupt",
182
+ "unreadable",
183
+ "mismatch",
184
+ "wrong-session",
185
+ ]) {
186
+ test(`successful append cannot adopt against established ${fault} disk`, () => {
187
+ const f = fixture();
188
+ f.mode = "disk";
189
+ f.controller.bind("old", {});
190
+ f.mode = "normal";
191
+ if (fault === "missing") f.text = undefined;
192
+ if (fault === "corrupt") f.text = "{";
193
+ if (fault === "unreadable") f.text = "unreadable";
194
+ if (fault === "wrong-session")
195
+ f.text = JSON.stringify({ type: "session", id: "wrong" });
196
+ const result = f.controller.bind("new", {});
197
+ assert.equal(result.status, "indeterminate");
198
+ assert.equal(f.controller.refresh().status, "indeterminate");
199
+ });
200
+ }
201
+ for (const fault of [
202
+ "missing",
203
+ "corrupt",
204
+ "unreadable",
205
+ "mismatch",
206
+ "wrong-session",
207
+ ]) {
208
+ test(`uncorroborated append stays untrusted after ${fault} disk recovers`, () => {
209
+ const f = fixture();
210
+ f.mode = "disk";
211
+ f.controller.bind("old", {});
212
+ f.mode = "normal";
213
+ if (fault === "missing") f.text = undefined;
214
+ if (fault === "corrupt") f.text = "{";
215
+ if (fault === "unreadable") f.text = "unreadable";
216
+ if (fault === "wrong-session")
217
+ f.text = JSON.stringify({ type: "session", id: "wrong" });
218
+ assert.equal(f.controller.bind("new", {}).status, "indeterminate");
219
+ f.save();
220
+ const later = f.controller.refresh();
221
+ assert.equal(later.status, "indeterminate", "a reported failure is never adopted later");
222
+ assert.equal(later.state.status, "indeterminate");
223
+ f.mode = "disk";
224
+ bound(f.controller.bind("again", {}), "again");
225
+ });
226
+ }
227
+ for (const status of ["bound", "cleared", "invalid", "unsupported"] as const) {
228
+ test(`no-advance exception retains only corroborated ${status} authority`, () => {
229
+ const f = fixture();
230
+ f.mode = "disk";
231
+ if (status === "bound") f.controller.bind("old", {});
232
+ else
233
+ f.append(
234
+ status === "unsupported" ? "gentle-pi.session-profile/v2" : customType,
235
+ status === "cleared" ? { kind: "clear" } : {},
236
+ );
237
+ f.mode = "throw-no-advance";
238
+ const result = f.controller.bind("failed", {});
239
+ assert.equal(result.status, "append-failed-preserved");
240
+ assert.equal(result.state.status, status);
241
+ f.text = "unreadable";
242
+ assert.equal(f.controller.clear().status, "indeterminate");
243
+ });
244
+ }
245
+ for (const previous of ["cleared", "invalid", "unsupported"] as const) {
246
+ for (const failure of ["bind", "clear"] as const) {
247
+ test(`advanced failed ${failure} preserves terminal prior ${previous}, even after ghost reaches disk`, () => {
248
+ const f = fixture();
249
+ f.mode = "disk";
250
+ f.append(
251
+ previous === "unsupported" ? "gentle-pi.session-profile/v2" : customType,
252
+ previous === "cleared" ? { kind: "clear" } : {},
253
+ );
254
+ f.mode = "throw";
255
+ const result =
256
+ failure === "bind" ? f.controller.bind("ghost", {}) : f.controller.clear();
257
+ assert.equal(result.status, "append-failed-preserved");
258
+ assert.equal(result.state.status, previous);
259
+ f.save();
260
+ assert.equal(f.controller.refresh().state.status, previous);
261
+ f.mode = "disk";
262
+ assert.equal(f.controller.clear().status, "persisted");
263
+ assert.equal(f.controller.refresh().state.status, "cleared");
264
+ });
265
+ }
266
+ }
267
+ test("no disk on no-advance failure cannot manufacture previous authority", () => {
268
+ const f = fixture();
269
+ f.mode = "throw-no-advance";
270
+ assert.equal(f.controller.bind("failed", {}).status, "indeterminate");
271
+ assert.equal(f.rows.length, 0);
272
+ assert.equal(f.controller.refresh().status, "indeterminate");
273
+ });
274
+ test("throw after exact disk write is still never success and quarantines failed record", () => {
275
+ const f = fixture();
276
+ f.mode = "disk";
277
+ f.controller.bind("good", {});
278
+ f.mode = "throw-after-save";
279
+ const result = f.controller.clear();
280
+ assert.equal(result.status, "append-failed-preserved");
281
+ bound(result, "good");
282
+ bound(f.controller.refresh(), "good");
283
+ });
284
+ test("changed append scope fails closed without carrying authority across sessions", () => {
285
+ const f = fixture();
286
+ f.mode = "change";
287
+ assert.equal(f.controller.bind("new", {}).status, "indeterminate");
288
+ assert.equal(f.controller.refresh().status, "indeterminate");
289
+ });
290
+ test("pending selection revoked by branch switch cannot return from older memory", () => {
291
+ const f = fixture();
292
+ f.controller.bind("pending", {});
293
+ const rows = f.rows;
294
+ f.rows = [];
295
+ assert.equal(f.controller.refresh().status, "indeterminate");
296
+ f.rows = rows;
297
+ assert.equal(f.controller.refresh().status, "indeterminate");
298
+ });
299
+ for (const terminal of [customType, "gentle-pi.session-profile/v2"]) {
300
+ test(`new terminal ${terminal} never resurrects pending or disk older binding`, () => {
301
+ const f = fixture();
302
+ f.controller.bind("pending", {});
303
+ f.append(terminal, {});
304
+ f.save();
305
+ assert.equal(
306
+ f.controller.refresh().state.status,
307
+ terminal === customType ? "invalid" : "unsupported",
308
+ );
309
+ f.rows = f.rows.slice(0, 1);
310
+ f.text = undefined;
311
+ assert.equal(f.controller.refresh().status, "indeterminate");
312
+ });
313
+ }
314
+ test("quarantine survives repeated exceptions and does not cross session or file", () => {
315
+ const f = fixture();
316
+ f.mode = "disk";
317
+ f.controller.clear();
318
+ f.mode = "throw";
319
+ f.controller.bind("failed-one", {});
320
+ f.controller.bind("failed-two", {});
321
+ f.save();
322
+ assert.equal(f.controller.refresh().state.status, "cleared");
323
+ f.id = "replacement";
324
+ f.save();
325
+ bound(f.controller.refresh(), "failed-two");
326
+ f.id = "session";
327
+ f.file = "replacement.jsonl";
328
+ f.save();
329
+ bound(f.controller.refresh(), "failed-two");
330
+ f.file = "session.jsonl";
331
+ assert.equal(f.controller.refresh().state.status, "cleared");
332
+ });
333
+ test("separate controller cannot restore another controller's volatile selection", () => {
334
+ const f = fixture();
335
+ f.controller.bind("pending", {});
336
+ const other = createSessionProfileAppendController(f.source, f.append, {
337
+ readFile: () => {
338
+ throw Object.assign(new Error(), { code: "ENOENT" });
339
+ },
340
+ });
341
+ assert.equal(other.refresh().status, "indeterminate");
342
+ });
343
+
344
+ test("pending scope is revoked across session/file switches without transferring volatile state", () => {
345
+ const f = fixture();
346
+ f.controller.bind("pending", {});
347
+ f.id = "other";
348
+ assert.equal(f.controller.refresh().status, "indeterminate");
349
+ f.id = "session";
350
+ assert.equal(f.controller.refresh().status, "indeterminate");
351
+ f.controller.clear();
352
+ f.file = undefined;
353
+ assert.equal(f.controller.refresh().status, "indeterminate");
354
+ f.file = "session.jsonl";
355
+ assert.equal(f.controller.refresh().status, "indeterminate");
356
+ });
357
+ test("undefined file permits only a freshly explicit setup-only selection", () => {
358
+ const f = fixture();
359
+ f.file = undefined;
360
+ assert.equal(f.controller.clear().status, "not-persisted");
361
+ assert.equal(f.controller.refresh().status, "not-persisted");
362
+ });
363
+ test("missing-file evidence permits only a NEW explicit selection, not unwritten restoration or first-flush proof", () => {
364
+ const f = fixture();
365
+ f.append(customType, { kind: "clear" });
366
+ assert.equal(f.controller.refresh().status, "indeterminate");
367
+ const result = f.controller.bind("new", {});
368
+ assert.equal(result.status, "not-persisted");
369
+ bound(result, "new");
370
+ assert.equal(f.controller.refresh().status, "not-persisted");
371
+ });
372
+ test("conversation with missing disk cannot be relabelled as preflush", () => {
373
+ const f = fixture();
374
+ f.rows.push({
375
+ type: "message",
376
+ id: "message",
377
+ parentId: null,
378
+ timestamp: "now",
379
+ message: { role: "user" },
380
+ });
381
+ assert.equal(f.controller.clear().status, "indeterminate");
382
+ });
383
+ for (const mutation of ["file", "branch", "payload", "no-append"] as const) {
384
+ test(`successful ${mutation} mutation fails closed and cannot activate by refresh`, () => {
385
+ const f = fixture();
386
+ f.mode = "disk";
387
+ f.controller.bind("good", {});
388
+ const candidate = createSessionProfileAppendController(
389
+ f.source,
390
+ (type, data) => {
391
+ if (mutation === "no-append") return;
392
+ f.append(type, mutation === "payload" ? { kind: "clear" } : data);
393
+ if (mutation === "file") f.file = "changed.jsonl";
394
+ if (mutation === "branch") {
395
+ f.rows = f.rows.slice(-1);
396
+ f.save();
397
+ }
398
+ },
399
+ { readFile: () => f.text! },
400
+ );
401
+ assert.equal(candidate.bind("requested", {}).status, "indeterminate");
402
+ assert.equal(candidate.refresh().status, "indeterminate");
403
+ });
404
+ }
405
+ test("exact selected disk content mismatch and changed source during read are indeterminate", () => {
406
+ const f = fixture();
407
+ f.mode = "disk";
408
+ f.controller.bind("good", {});
409
+ const mismatch = createSessionProfileAppendController(
410
+ f.source,
411
+ (type, data) => {
412
+ f.append(type, data);
413
+ const lines = f.text!.split("\n");
414
+ const row = JSON.parse(lines.at(-1)!);
415
+ row.data.name = "mismatched";
416
+ lines[lines.length - 1] = JSON.stringify(row);
417
+ f.text = lines.join("\n");
418
+ },
419
+ { readFile: () => f.text! },
420
+ );
421
+ assert.equal(mismatch.bind("requested", {}).status, "indeterminate");
422
+ const changing = createSessionProfileAppendController(f.source, f.append, {
423
+ readFile: () => {
424
+ f.id = "changed";
425
+ return f.text!;
426
+ },
427
+ });
428
+ assert.equal(changing.refresh().status, "indeterminate");
429
+ });
430
+ test("no-advance failed clear preserves a prior tombstone and never reports success", () => {
431
+ const f = fixture();
432
+ f.mode = "disk";
433
+ f.controller.clear();
434
+ f.mode = "throw-no-advance";
435
+ const failed = f.controller.clear();
436
+ assert.equal(failed.status, "append-failed-preserved");
437
+ assert.equal(failed.state.status, "cleared");
438
+ });
439
+ test("JSON omission and toJSON semantics match observable disk without leaking references", () => {
440
+ const f = fixture();
441
+ f.mode = "disk";
442
+ f.controller.bind("json", {});
443
+ const row = f.rows[0];
444
+ row.extra = undefined;
445
+ row.toJSON = function () {
446
+ return { ...this, extra: undefined };
447
+ };
448
+ f.save();
449
+ bound(f.controller.refresh(), "json");
450
+ });
451
+ for (const failure of ["cycle", "bigint", "getter", "toJSON"] as const) {
452
+ test(`unserializable ${failure} source cannot manufacture authority`, () => {
453
+ const f = fixture();
454
+ f.mode = "disk";
455
+ f.controller.bind("good", {});
456
+ const row = f.rows[0];
457
+ if (failure === "cycle") row.extra = row;
458
+ if (failure === "bigint") row.extra = 1n;
459
+ if (failure === "getter")
460
+ Object.defineProperty(row, "extra", {
461
+ enumerable: true,
462
+ get() {
463
+ throw new Error("private detail");
464
+ },
465
+ });
466
+ if (failure === "toJSON")
467
+ row.toJSON = () => {
468
+ throw new Error("private detail");
469
+ };
470
+ assert.equal(f.controller.refresh().status, "indeterminate");
471
+ assert.equal(f.controller.clear().status, "indeterminate");
472
+ });
473
+ }
474
+ test("failed snapshot still quarantines observable IDs, explicit recovery cannot resurrect ghost", () => {
475
+ const f = fixture();
476
+ f.mode = "disk";
477
+ f.controller.bind("good", {});
478
+ let fail = false;
479
+ const candidate = createSessionProfileAppendController(
480
+ f.source,
481
+ (type, data) => {
482
+ f.append(type, data);
483
+ if (fail) {
484
+ f.rows.at(-1).extra = 1n;
485
+ throw new Error("append");
486
+ }
487
+ },
488
+ { readFile: () => f.text! },
489
+ );
490
+ assert.equal(candidate.bind("trusted", {}).status, "persisted");
491
+ fail = true;
492
+ assert.equal(candidate.bind("ghost", {}).status, "indeterminate");
493
+ delete f.rows.at(-1).extra;
494
+ f.save();
495
+ assert.equal(candidate.refresh().status, "indeterminate");
496
+ const ghostBranch = f.rows.slice();
497
+ fail = false;
498
+ assert.equal(candidate.clear().status, "persisted");
499
+ f.rows = ghostBranch;
500
+ assert.equal(candidate.refresh().status, "indeterminate");
501
+ });
502
+ // Injected structural-source fault, NOT ordinary Pi ID reuse or EISDIR.
503
+ for (const changed of ["data", "timestamp", "parentId", "extra"] as const) {
504
+ test(`whole-record certificate rejects same-ID ${changed} mutation after throw, recovery and branch return`, () => {
505
+ const f = fixture();
506
+ f.mode = "disk";
507
+ let fail = false;
508
+ const candidate = createSessionProfileAppendController(
509
+ f.source,
510
+ (type, data) => {
511
+ if (!fail) return f.append(type, data);
512
+ const row = f.rows[0];
513
+ if (changed === "data") row.data.name = "FAILED";
514
+ if (changed === "timestamp") row.timestamp = "changed";
515
+ if (changed === "parentId") row.parentId = "injected-missing-parent";
516
+ if (changed === "extra") row.extra = { injected: "FAILED" };
517
+ f.save();
518
+ throw new Error("injected mutation during failed append");
519
+ },
520
+ { readFile: () => f.text! },
521
+ );
522
+ assert.equal(candidate.bind("trusted", {}).status, "persisted");
523
+ const trusted = JSON.parse(JSON.stringify(f.rows));
524
+ fail = true;
525
+ assert.equal(candidate.bind("failed", {}).status, "indeterminate");
526
+ const changedBranch = f.rows.slice();
527
+ fail = false;
528
+ assert.equal(candidate.clear().status, "persisted");
529
+ assert.equal(candidate.refresh().state.status, "cleared");
530
+ f.rows = changedBranch;
531
+ assert.equal(candidate.refresh().status, "indeterminate");
532
+ // Restoring the exact certified fixture content is not history repair.
533
+ f.rows = trusted;
534
+ f.save();
535
+ bound(candidate.refresh(), "trusted");
536
+ });
537
+ }
538
+ test("unobservable newer branch revokes pending selection permanently", () => {
539
+ const f = fixture();
540
+ assert.equal(f.controller.bind("pending", {}).status, "not-persisted");
541
+ const original = f.rows.slice();
542
+ f.append(customType, {});
543
+ f.rows.at(-1).extra = 1n;
544
+ assert.equal(f.controller.refresh().status, "indeterminate");
545
+ f.rows = original;
546
+ assert.equal(f.controller.refresh().status, "indeterminate");
547
+ });
548
+ test("invalid encoder input fails before append without corrupting existing authority", () => {
549
+ const f = fixture();
550
+ f.mode = "disk";
551
+ f.controller.bind("good", {});
552
+ const length = f.rows.length;
553
+ assert.throws(() => f.controller.bind("", {}), TypeError);
554
+ assert.equal(f.rows.length, length);
555
+ bound(f.controller.refresh(), "good");
556
+ });
557
+
558
+ // A stability recheck that throws after a successful append takes the catch
559
+ // path; the record must stay untrusted even though the disk is sound. The
560
+ // fourth branch read after the append is that recheck.
561
+ test("a throwing post-append stability recheck is never adopted later", () => {
562
+ const failingRead = 4;
563
+ let rows: any[] = [],
564
+ text: string | undefined,
565
+ serial = 0,
566
+ armed = false,
567
+ reads = 0;
568
+ const save = () => {
569
+ text = [
570
+ JSON.stringify({ type: "session", id: "session" }),
571
+ ...rows.map((row) => JSON.stringify(row)),
572
+ ].join("\n");
573
+ };
574
+ const source = {
575
+ getSessionId: () => "session",
576
+ getSessionFile: () => "session.jsonl",
577
+ getBranch: () => {
578
+ if (armed && ++reads === failingRead) throw new Error("branch");
579
+ return rows;
580
+ },
581
+ };
582
+ const controller = createSessionProfileAppendController(
583
+ source,
584
+ (type, data) => {
585
+ rows.push({
586
+ type: "custom",
587
+ customType: type,
588
+ data,
589
+ id: `entry-${++serial}`,
590
+ parentId: rows.at(-1)?.id ?? null,
591
+ timestamp: "now",
592
+ });
593
+ save();
594
+ armed = true;
595
+ },
596
+ {
597
+ readFile: () => {
598
+ if (text === undefined)
599
+ throw Object.assign(new Error("missing"), { code: "ENOENT" });
600
+ return text;
601
+ },
602
+ },
603
+ );
604
+ const result = controller.bind("new", {});
605
+ armed = false;
606
+ assert.equal(result.status, "indeterminate");
607
+ assert.equal(
608
+ (result.state as { reason?: string }).reason,
609
+ "append-not-corroborated",
610
+ );
611
+ const later = controller.refresh();
612
+ assert.equal(later.status, "indeterminate", "a reported failure is never adopted later");
613
+ armed = false;
614
+ bound(controller.bind("again", {}), "again");
615
+ });
616
+
617
+ for (const operation of ["bind", "clear"] as const) {
618
+ test(`detach revokes pending ${operation} and initial lease without redirecting callbacks`, () => {
619
+ const f = fixture();
620
+ const owner = f.controller as typeof f.controller & {
621
+ detach(): void;
622
+ attach(
623
+ source: typeof f.source,
624
+ append: typeof f.append,
625
+ context: { reason: string },
626
+ ): typeof f.controller;
627
+ };
628
+ const selected =
629
+ operation === "bind" ? owner.bind("pending", {}) : owner.clear();
630
+ assert.equal(selected.status, "not-persisted");
631
+ assert.equal(typeof owner.detach, "function");
632
+ owner.detach();
633
+ owner.detach();
634
+ const length = f.rows.length;
635
+ assert.equal(owner.refresh().status, "indeterminate");
636
+ assert.equal(owner.bind("stale", {}).status, "indeterminate");
637
+ assert.equal(owner.clear().status, "indeterminate");
638
+ assert.equal(f.rows.length, length);
639
+ let calls = 0;
640
+ const current = owner.attach(
641
+ f.source,
642
+ (t, d) => {
643
+ calls++;
644
+ f.append(t, d);
645
+ },
646
+ { reason: "reload" },
647
+ { readFile: f.readFile },
648
+ );
649
+ assert.equal(current.refresh().status, "indeterminate");
650
+ assert.equal(current.bind("current", {}).status, "not-persisted");
651
+ assert.equal(owner.bind("old-generation", {}).status, "indeterminate");
652
+ assert.equal(calls, 1);
653
+ assert.equal(f.rows.length, length + 1);
654
+ });
655
+ }
656
+ test("detach retains observed established history and failed-ID exclusion after recovery", () => {
657
+ const f = fixture();
658
+ f.mode = "disk";
659
+ const owner = f.controller as typeof f.controller & {
660
+ detach(): void;
661
+ attach(
662
+ source: typeof f.source,
663
+ append: typeof f.append,
664
+ context: { reason: string },
665
+ ): typeof f.controller;
666
+ };
667
+ owner.bind("trusted", {});
668
+ f.mode = "throw";
669
+ owner.bind("failed", {});
670
+ f.save();
671
+ assert.equal(typeof owner.detach, "function");
672
+ owner.detach();
673
+ const current = owner.attach(
674
+ f.source,
675
+ f.append,
676
+ { reason: "reload" },
677
+ { readFile: f.readFile },
678
+ );
679
+ bound(current.refresh(), "trusted");
680
+ f.text = undefined;
681
+ f.rows = [];
682
+ assert.equal(current.refresh().status, "indeterminate");
683
+ f.mode = "normal";
684
+ assert.equal(
685
+ current.bind("unwritten-after-established-loss", {}).status,
686
+ "indeterminate",
687
+ );
688
+ });
689
+ for (const changed of ["data", "timestamp", "parentId", "extra"] as const) {
690
+ test(`same-ID ${changed} ambiguity survives detach, exact recovery and branch return`, () => {
691
+ const f = fixture();
692
+ f.mode = "disk";
693
+ let fail = false;
694
+ const append = (t: string, d: unknown) => {
695
+ if (!fail) return f.append(t, d);
696
+ const row = f.rows[0];
697
+ if (changed === "data") row.data.name = "FAILED";
698
+ if (changed === "timestamp") row.timestamp = "changed";
699
+ if (changed === "parentId") row.parentId = "unexpected-parent";
700
+ if (changed === "extra") row.extra = true;
701
+ f.save();
702
+ throw new Error("injected whole-record mutation");
703
+ };
704
+ const owner = createSessionProfileAppendController(f.source, append, {
705
+ readFile: () => f.text!,
706
+ }) as ReturnType<typeof createSessionProfileAppendController> & {
707
+ detach(): void;
708
+ attach(
709
+ source: typeof f.source,
710
+ appendEntry: (type: string, data: unknown) => void,
711
+ context: { reason: string },
712
+ ): typeof f.controller;
713
+ };
714
+ owner.bind("trusted", {});
715
+ fail = true;
716
+ assert.equal(owner.bind("failed", {}).status, "indeterminate");
717
+ const historical = f.rows.slice();
718
+ assert.equal(typeof owner.detach, "function");
719
+ owner.detach();
720
+ fail = false;
721
+ const current = owner.attach(
722
+ f.source,
723
+ append,
724
+ { reason: "reload" },
725
+ { readFile: f.readFile },
726
+ );
727
+ assert.equal(current.clear().status, "persisted");
728
+ f.rows = historical;
729
+ assert.equal(current.refresh().status, "indeterminate");
730
+ });
731
+ }