gentle-pi 4.3.0 → 4.5.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 (218) hide show
  1. package/README.md +10 -7
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +138 -155
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +82 -0
  16. package/docs/gentle-shell.md +80 -10
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +72 -55
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +187 -40
  25. package/extensions/gentle-ai.ts +1765 -1185
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +354 -0
  28. package/extensions/gentle-shell.ts +418 -79
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +3 -2
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +141 -57
  52. package/lib/gentle-shell-launcher.ts +11 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/jobs-sidebar-state.ts +22 -3
  58. package/lib/jobs-widget.ts +112 -0
  59. package/lib/nan-provider.ts +3 -0
  60. package/lib/native-review-cli.ts +11 -4
  61. package/lib/notification-audio-native.ts +149 -13
  62. package/lib/notification-audio.ts +4 -0
  63. package/lib/notification-pulse-worker.ts +82 -18
  64. package/lib/notification-service.ts +5 -1
  65. package/lib/orchestrator-presence.ts +78 -8
  66. package/lib/orchestrator-scope.ts +10 -0
  67. package/lib/orchestrator-state.ts +11 -3
  68. package/lib/permission-mode-policy.ts +2 -2
  69. package/lib/review-host-relay.ts +11 -2
  70. package/lib/review-reminder-receipt.ts +24 -1
  71. package/lib/session-profile-append-controller.ts +713 -0
  72. package/lib/session-profile-disk-reader.ts +182 -0
  73. package/lib/session-profile-freeze.ts +2 -2
  74. package/lib/session-profile-persistence.ts +15 -7
  75. package/lib/session-scratch.ts +52 -3
  76. package/lib/session-worktree-registry.ts +7 -0
  77. package/lib/shell-bar.ts +6 -7
  78. package/lib/shell-changes.ts +81 -5
  79. package/lib/shell-prompt.ts +4 -4
  80. package/lib/shell-sidebar-layout.ts +27 -7
  81. package/lib/shell-todo.ts +1 -1
  82. package/lib/verifier-admission.ts +5 -4
  83. package/lib/vim-editor-adapter.ts +14 -10
  84. package/lib/voice-artifacts.ts +188 -0
  85. package/lib/voice-command.ts +576 -0
  86. package/lib/voice-install.ts +455 -0
  87. package/lib/voice-platform.ts +99 -0
  88. package/lib/voice-push-to-talk.ts +262 -0
  89. package/lib/voice-session.ts +460 -0
  90. package/lib/voice-settings.ts +50 -0
  91. package/lib/voice-ui.ts +158 -0
  92. package/lib/yolo-session-policy.ts +58 -22
  93. package/package.json +1 -1
  94. package/runtime/gentle-shell-bundled.mjs +140 -56
  95. package/runtime/gentle-shell-launcher.mjs +11 -4
  96. package/runtime/gentle-shell-startup-update.mjs +61 -9
  97. package/runtime/gentle-shell-uninstall.mjs +33 -1
  98. package/runtime/native-review-cli.mjs +11 -4
  99. package/scripts/bundled-engram-migration.mjs +0 -6
  100. package/scripts/bundled-install.mjs +209 -13
  101. package/scripts/gentle-ai-installer.mjs +10 -10
  102. package/scripts/installer-downloads.mjs +31 -7
  103. package/scripts/installer-preflight.mjs +6 -4
  104. package/scripts/installer-windows.mjs +37 -7
  105. package/scripts/main-channel.mjs +91 -17
  106. package/scripts/pi-update.mjs +37 -7
  107. package/scripts/verify-package-files.mjs +26 -2
  108. package/scripts/voice-worker.cjs +476 -0
  109. package/skills/design/SKILL.md +110 -0
  110. package/tests/agent-profiles.test.ts +24 -0
  111. package/tests/agents-config.test.ts +61 -0
  112. package/tests/agents-fake-child.ts +8 -1
  113. package/tests/agents-protocol.test.ts +32 -0
  114. package/tests/agents-runner.test.ts +226 -5
  115. package/tests/agents-session-transport.test.ts +53 -3
  116. package/tests/ask-user-choice.test.ts +30 -0
  117. package/tests/auto-mode.test.ts +17 -0
  118. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  119. package/tests/auto-permission-runtime.test.ts +85 -1
  120. package/tests/autonomous-guard.test.ts +20 -0
  121. package/tests/bundled-install.test.ts +276 -5
  122. package/tests/clipboard-image.test.ts +114 -0
  123. package/tests/command-palette.test.ts +4 -4
  124. package/tests/context-backstop.test.ts +70 -4
  125. package/tests/delegate-for-reason-contract.test.ts +2 -1
  126. package/tests/design-canvas-assets.test.ts +710 -0
  127. package/tests/design-canvas-server.test.ts +1096 -0
  128. package/tests/design-comments.test.ts +201 -0
  129. package/tests/design-handoffs.test.ts +282 -0
  130. package/tests/design-open-browser.test.ts +70 -0
  131. package/tests/design-sources.test.ts +132 -0
  132. package/tests/engram-safety.test.ts +132 -57
  133. package/tests/external-editor.test.ts +84 -0
  134. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  135. package/tests/gentle-agents.test.ts +369 -24
  136. package/tests/gentle-ai-binary.test.ts +1 -1
  137. package/tests/gentle-ai-installer.test.ts +51 -51
  138. package/tests/gentle-ai.test.ts +928 -448
  139. package/tests/gentle-design.test.ts +1313 -0
  140. package/tests/gentle-music.test.ts +452 -0
  141. package/tests/gentle-shell-bundled.test.ts +321 -248
  142. package/tests/gentle-shell-launcher.test.ts +29 -2
  143. package/tests/gentle-shell-startup-update.test.ts +77 -1
  144. package/tests/gentle-shell-uninstall.test.ts +59 -0
  145. package/tests/gentle-shell.test.ts +227 -27
  146. package/tests/gentle-todo.test.ts +11 -0
  147. package/tests/image-attachments.test.ts +119 -0
  148. package/tests/image-paste-chips.test.ts +236 -0
  149. package/tests/inprocess-reviewer.test.ts +55 -2
  150. package/tests/installer-bundles.test.ts +12 -6
  151. package/tests/installer-downloads.test.ts +32 -0
  152. package/tests/installer-posix-bootstrap.test.ts +8 -8
  153. package/tests/installer-preflight.test.ts +16 -0
  154. package/tests/installer-server.test.ts +2 -2
  155. package/tests/installer-windows-bootstrap.test.ts +61 -3
  156. package/tests/installer-windows-diagnostics.test.ts +111 -0
  157. package/tests/jobs-widget.test.ts +138 -0
  158. package/tests/main-channel-upgrade.test.ts +232 -2
  159. package/tests/main-channel.test.ts +140 -21
  160. package/tests/model-profiles-preservation.test.ts +2 -2
  161. package/tests/nan-provider.test.ts +36 -0
  162. package/tests/native-review-capability-contract.test.ts +11 -4
  163. package/tests/native-review-parity.test.ts +1 -1
  164. package/tests/notification-audio-native.test.ts +128 -9
  165. package/tests/notification-customize.test.ts +17 -5
  166. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  167. package/tests/orchestrator-budget.test.ts +5 -2
  168. package/tests/orchestrator-presence.test.ts +100 -2
  169. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  170. package/tests/orchestrator-scope.test.ts +14 -0
  171. package/tests/orchestrator-state.test.ts +29 -0
  172. package/tests/package-files.test.ts +34 -0
  173. package/tests/package-manifest.test.ts +6 -6
  174. package/tests/permission-mode-policy.test.ts +10 -1
  175. package/tests/pi-update.test.ts +104 -4
  176. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  177. package/tests/review-host-relay-routing.test.ts +88 -1
  178. package/tests/review-host-relay.test.ts +9 -2
  179. package/tests/review-json-arguments.test.ts +20 -3
  180. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  181. package/tests/review-relay-transport-agent.test.ts +26 -0
  182. package/tests/review-reminder-receipt.test.ts +31 -1
  183. package/tests/runtime-harness.mjs +188 -211
  184. package/tests/session-changes.test.ts +2 -1
  185. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  186. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  187. package/tests/session-profile-append-controller.test.ts +731 -0
  188. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  189. package/tests/session-profile-disk-reader.test.ts +311 -0
  190. package/tests/session-profile-freeze.test.ts +6 -4
  191. package/tests/session-scratch.test.ts +68 -2
  192. package/tests/session-worktree-registry.test.ts +13 -1
  193. package/tests/shell-bar.test.ts +18 -6
  194. package/tests/shell-changes.test.ts +148 -1
  195. package/tests/shell-prompt.test.ts +3 -3
  196. package/tests/shell-render-cache.test.ts +134 -1
  197. package/tests/shell-sidebar-layout.test.ts +67 -0
  198. package/tests/short-check-routing-contract.test.ts +1 -1
  199. package/tests/skill-collision-prefixes.test.ts +3 -1
  200. package/tests/skill-registry-watcher.test.ts +17 -5
  201. package/tests/skill-registry.test.ts +47 -3
  202. package/tests/task-size-routing-contract.test.ts +1 -1
  203. package/tests/test-strength-contract.test.ts +1 -1
  204. package/tests/verifier-admission.test.ts +18 -1
  205. package/tests/vim-editor-adapter.test.ts +2 -2
  206. package/tests/voice-command.test.ts +773 -0
  207. package/tests/voice-install.test.ts +323 -0
  208. package/tests/voice-platform.test.ts +176 -0
  209. package/tests/voice-push-to-talk.test.ts +444 -0
  210. package/tests/voice-session.test.ts +434 -0
  211. package/tests/voice-settings.test.ts +49 -0
  212. package/tests/voice-ui.test.ts +97 -0
  213. package/tests/voice-worker.test.ts +445 -0
  214. package/tests/weak-model-economy-prompt.test.ts +32 -0
  215. package/tests/windows-hidden-processes.test.ts +25 -2
  216. package/tests/yolo-customize.test.ts +5 -1
  217. package/tests/yolo-mode.test.ts +1 -1
  218. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -0,0 +1,458 @@
1
+ // Fork lifecycle seams against public Pi SessionManager fixtures in temp dirs.
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, mkdtempSync, readFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { test } from "node:test";
7
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ createSessionProfileAppendController,
10
+ type SessionProfileAppendOptions,
11
+ type SessionProfileAppendOutcome,
12
+ type SessionProfileForkTarget,
13
+ } from "../lib/session-profile-append-controller.ts";
14
+
15
+ type Controller = ReturnType<typeof createSessionProfileAppendController>;
16
+
17
+ function fixture(memory = false) {
18
+ const root = mkdtempSync(join(tmpdir(), "profile-fork-"));
19
+ const manager = memory
20
+ ? SessionManager.inMemory(root)
21
+ : SessionManager.create(root, join(root, "sessions"));
22
+ let fail = false,
23
+ calls = 0;
24
+ const controller = createSessionProfileAppendController(
25
+ manager,
26
+ (type, data) => {
27
+ calls++;
28
+ manager.appendCustomEntry(type, data);
29
+ if (fail) throw new Error("injected callback failure after public append");
30
+ },
31
+ );
32
+ return {
33
+ manager,
34
+ controller,
35
+ failing(value: boolean) {
36
+ fail = value;
37
+ },
38
+ calls: () => calls,
39
+ };
40
+ }
41
+ function flush(manager: SessionManager) {
42
+ manager.appendMessage({
43
+ role: "user",
44
+ content: "isolated fork fixture",
45
+ timestamp: 1,
46
+ });
47
+ }
48
+ function name(outcome: SessionProfileAppendOutcome, expected: string) {
49
+ const state = outcome.state as { status: string; binding?: { name: string } };
50
+ assert.equal(state.status, "bound");
51
+ assert.equal(state.binding?.name, expected);
52
+ }
53
+ function persistedCopy(manager: SessionManager, leaf: string) {
54
+ const sourceFile = manager.getSessionFile();
55
+ assert.ok(sourceFile);
56
+ const copy = SessionManager.open(sourceFile, manager.getSessionDir());
57
+ copy.createBranchedSession(leaf);
58
+ return copy;
59
+ }
60
+ // Mirrors the host order: capture, copy, detach with the preparation, then
61
+ // attach the destination with the handoff and its own public view. An
62
+ // in-memory fork rewrites the same manager, so it copies after detach.
63
+ function fork(
64
+ parent: Controller,
65
+ manager: SessionManager,
66
+ target: SessionProfileForkTarget,
67
+ copy: (prepared: boolean) => SessionManager,
68
+ options: SessionProfileAppendOptions = {},
69
+ ) {
70
+ const captured = parent.captureForkEvidence(target, manager);
71
+ const previousSessionFile = manager.getSessionFile();
72
+ const inPlace = manager.getSessionFile() === undefined;
73
+ let child = inPlace ? manager : copy(captured.status === "captured");
74
+ const handoff = parent.detach({
75
+ reason: "fork",
76
+ targetSessionFile: inPlace ? undefined : child.getSessionFile(),
77
+ preparation:
78
+ captured.status === "captured" ? captured.evidence : undefined,
79
+ });
80
+ if (inPlace) child = copy(captured.status === "captured");
81
+ let appends = 0;
82
+ const append = (type: string, data: unknown) => {
83
+ appends++;
84
+ child.appendCustomEntry(type, data);
85
+ };
86
+ // The host keeps one controller per manager; an in-memory fork reuses it.
87
+ const controller =
88
+ child === manager
89
+ ? parent
90
+ : createSessionProfileAppendController(child, append);
91
+ const attachment = controller.attach(
92
+ child,
93
+ append,
94
+ {
95
+ reason: "fork",
96
+ previousSessionFile,
97
+ ...(handoff ? { fork: { handoff, publicView: child } } : {}),
98
+ },
99
+ options,
100
+ );
101
+ return {
102
+ captured,
103
+ handoff,
104
+ child,
105
+ controller,
106
+ attachment,
107
+ outcome: attachment.refresh(),
108
+ appends: () => appends,
109
+ reattach: () =>
110
+ createSessionProfileAppendController(child, append).attach(child, append, {
111
+ reason: "fork",
112
+ previousSessionFile,
113
+ ...(handoff ? { fork: { handoff, publicView: child } } : {}),
114
+ }),
115
+ };
116
+ }
117
+
118
+ test("disk-first fork retains copied failure quarantine but does not inherit binding", () => {
119
+ const f = fixture();
120
+ f.failing(true);
121
+ assert.equal(f.controller.bind("failed", {}).status, "indeterminate");
122
+ f.failing(false);
123
+ const failed = f.manager.getLeafId();
124
+ assert.ok(failed);
125
+ assert.equal(existsSync(f.manager.getSessionFile()!), false);
126
+ // Genuine public first flush now writes the known-failed record as well.
127
+ flush(f.manager);
128
+ const forked = fork(
129
+ f.controller,
130
+ f.manager,
131
+ { entryId: failed, position: "at" },
132
+ () => persistedCopy(f.manager, failed),
133
+ );
134
+ assert.equal(forked.captured.status, "captured");
135
+ assert.ok(forked.handoff);
136
+ // No conversation on the copied path: the child file need not exist yet.
137
+ assert.equal(forked.outcome.status, "indeterminate");
138
+ assert.equal(existsSync(forked.child.getSessionFile()!), false);
139
+ flush(forked.child);
140
+ assert.equal(forked.attachment.refresh().state.status, "absent");
141
+ assert.equal(forked.attachment.clear().status, "persisted");
142
+ // A consumed handoff cannot be replayed into a second attachment.
143
+ assert.equal(forked.reattach().refresh().status, "indeterminate");
144
+ });
145
+
146
+ test("actual copied cut beyond the prepared ancestor fails closed", () => {
147
+ const f = fixture();
148
+ flush(f.manager);
149
+ assert.equal(f.controller.bind("trusted", {}).status, "persisted");
150
+ const preparedCut = f.manager.getLeafId();
151
+ assert.ok(preparedCut);
152
+ f.failing(true);
153
+ const failed = f.controller.bind("known-failed", {});
154
+ f.failing(false);
155
+ assert.equal(failed.status, "append-failed-preserved");
156
+ name(failed, "trusted");
157
+ const actualCut = f.manager.getLeafId();
158
+ assert.ok(actualCut);
159
+ assert.notEqual(actualCut, preparedCut);
160
+ const sourceFile = f.manager.getSessionFile()!;
161
+ const sourceEntries = JSON.stringify(f.manager.getEntries());
162
+ const sourceDisk = readFileSync(sourceFile, "utf8");
163
+ let childEntries = "",
164
+ childDisk = "";
165
+ const forked = fork(
166
+ f.controller,
167
+ f.manager,
168
+ { entryId: preparedCut, position: "at" },
169
+ () => {
170
+ const child = persistedCopy(f.manager, actualCut);
171
+ assert.ok(child.getBranch().some((entry) => entry.id === preparedCut));
172
+ childEntries = JSON.stringify(child.getEntries());
173
+ childDisk = readFileSync(child.getSessionFile()!, "utf8");
174
+ assert.ok(childDisk.includes(actualCut), "public copy stores the failure");
175
+ return child;
176
+ },
177
+ );
178
+ assert.deepEqual(forked.outcome, {
179
+ status: "indeterminate",
180
+ state: { status: "indeterminate", reason: "ambiguous-append" },
181
+ });
182
+ assert.deepEqual(forked.attachment.refresh(), forked.outcome);
183
+ assert.equal(forked.appends(), 0);
184
+ assert.equal(f.calls(), 2);
185
+ assert.equal(JSON.stringify(forked.child.getEntries()), childEntries);
186
+ assert.equal(readFileSync(forked.child.getSessionFile()!, "utf8"), childDisk);
187
+ assert.equal(JSON.stringify(f.manager.getEntries()), sourceEntries);
188
+ assert.equal(readFileSync(sourceFile, "utf8"), sourceDisk);
189
+ });
190
+
191
+ for (const position of ["before", "at"] as const) {
192
+ test(`cancelled ${position} capture leaves entries, activation and callbacks unchanged`, () => {
193
+ const f = fixture();
194
+ assert.equal(f.controller.bind("pending", {}).status, "not-persisted");
195
+ const leaf = f.manager.getLeafId();
196
+ assert.ok(leaf);
197
+ const before = JSON.stringify(f.manager.getEntries());
198
+ const captured = f.controller.captureForkEvidence(
199
+ { entryId: position === "at" ? leaf : "missing", position },
200
+ f.manager,
201
+ );
202
+ assert.equal(
203
+ captured.status,
204
+ position === "at" ? "captured" : "indeterminate",
205
+ );
206
+ assert.equal(JSON.stringify(f.manager.getEntries()), before);
207
+ assert.equal(f.controller.refresh().status, "not-persisted");
208
+ assert.equal(f.controller.bind("next", {}).status, "not-persisted");
209
+ assert.equal(f.calls(), 2);
210
+ });
211
+ }
212
+
213
+ test("valid cancelled before-user capture leaves disk and explicit binding unchanged", () => {
214
+ const f = fixture();
215
+ flush(f.manager);
216
+ const user = f.manager.getLeafId();
217
+ assert.ok(user);
218
+ f.controller.bind("current", {});
219
+ const entries = JSON.stringify(f.manager.getEntries());
220
+ const disk = readFileSync(f.manager.getSessionFile()!, "utf8");
221
+ assert.equal(
222
+ f.controller.captureForkEvidence(
223
+ { entryId: user, position: "before" },
224
+ f.manager,
225
+ ).status,
226
+ "captured",
227
+ );
228
+ assert.equal(JSON.stringify(f.manager.getEntries()), entries);
229
+ assert.equal(readFileSync(f.manager.getSessionFile()!, "utf8"), disk);
230
+ name(f.controller.refresh(), "current");
231
+ assert.equal(f.calls(), 1);
232
+ });
233
+
234
+ test("capture rejects a public view other than the attached source", () => {
235
+ const f = fixture();
236
+ flush(f.manager);
237
+ const other = fixture();
238
+ assert.deepEqual(
239
+ f.controller.captureForkEvidence(
240
+ { entryId: f.manager.getLeafId()!, position: "at" },
241
+ other.manager,
242
+ ),
243
+ { status: "indeterminate", reason: "unobservable-fork" },
244
+ );
245
+ });
246
+
247
+ test("label removal permits only the expected copied parentId rechain", () => {
248
+ const f = fixture();
249
+ f.controller.bind("base", {});
250
+ flush(f.manager);
251
+ const base = f.manager.getLeafId();
252
+ assert.ok(base);
253
+ f.manager.appendLabelChange(base, "fixture-label");
254
+ f.controller.bind("selected", {});
255
+ const leaf = f.manager.getLeafId();
256
+ assert.ok(leaf);
257
+ const original = f.manager.getEntry(leaf)!;
258
+ const forked = fork(
259
+ f.controller,
260
+ f.manager,
261
+ { entryId: leaf, position: "at" },
262
+ () => persistedCopy(f.manager, leaf),
263
+ );
264
+ assert.notEqual(forked.child.getEntry(leaf)?.parentId, original.parentId);
265
+ name(forked.outcome, "selected");
266
+ });
267
+
268
+ for (const position of ["before", "at"] as const) {
269
+ test(`fork boundary label supports ${position} without requiring its removed ID`, () => {
270
+ const f = fixture();
271
+ flush(f.manager);
272
+ f.controller.bind("retained", {});
273
+ const retained = f.manager.getLeafId();
274
+ assert.ok(retained);
275
+ f.manager.appendLabelChange(retained, "boundary");
276
+ const label = f.manager.getLeafId();
277
+ assert.ok(label);
278
+ let entryId = label;
279
+ if (position === "before") {
280
+ flush(f.manager);
281
+ entryId = f.manager.getLeafId()!;
282
+ }
283
+ const forked = fork(f.controller, f.manager, { entryId, position }, () => {
284
+ const copy = persistedCopy(f.manager, label);
285
+ assert.equal(copy.getEntry(label), undefined);
286
+ return copy;
287
+ });
288
+ assert.equal(forked.captured.status, "captured");
289
+ name(forked.outcome, "retained");
290
+ });
291
+ }
292
+
293
+ test("actual fork path excludes failed sibling and never imports its quarantine", () => {
294
+ const f = fixture();
295
+ f.controller.bind("base", {});
296
+ flush(f.manager);
297
+ const base = f.manager.getLeafId();
298
+ assert.ok(base);
299
+ f.failing(true);
300
+ f.controller.bind("failed-left", {});
301
+ f.failing(false);
302
+ const failed = f.manager.getLeafId();
303
+ f.manager.branch(base);
304
+ f.controller.bind("right", {});
305
+ const right = f.manager.getLeafId();
306
+ assert.ok(right);
307
+ assert.notEqual(right, failed);
308
+ const forked = fork(
309
+ f.controller,
310
+ f.manager,
311
+ { entryId: right, position: "at" },
312
+ () => persistedCopy(f.manager, right),
313
+ );
314
+ assert.equal(
315
+ forked.child.getBranch().some((entry) => entry.id === failed),
316
+ false,
317
+ );
318
+ name(forked.outcome, "right");
319
+ });
320
+
321
+ for (const changed of ["data", "timestamp", "parentId", "extra"] as const) {
322
+ test(`copied same-ID ${changed} mutation fails closed without blessing destination disk`, () => {
323
+ const f = fixture();
324
+ flush(f.manager);
325
+ f.controller.bind("trusted", {});
326
+ const leaf = f.manager.getLeafId();
327
+ assert.ok(leaf);
328
+ let copy: SessionManager | undefined;
329
+ const forked = fork(
330
+ f.controller,
331
+ f.manager,
332
+ { entryId: leaf, position: "at" },
333
+ () => {
334
+ copy = persistedCopy(f.manager, leaf);
335
+ const row = copy.getEntry(leaf)! as unknown as Record<string, any>;
336
+ if (changed === "data") row.data.name = "FAILED";
337
+ if (changed === "timestamp") row.timestamp = "changed";
338
+ if (changed === "parentId") row.parentId = "unexpected-parent";
339
+ if (changed === "extra") row.extra = { changed: true };
340
+ return copy;
341
+ },
342
+ {
343
+ // Inject matching disk content, not ordinary Pi ID reuse.
344
+ readFile: (path) =>
345
+ copy && path === copy.getSessionFile()
346
+ ? [copy.getHeader(), ...copy.getEntries()]
347
+ .map((entry) => JSON.stringify(entry))
348
+ .join("\n")
349
+ : readFileSync(path, "utf8"),
350
+ },
351
+ );
352
+ assert.equal(forked.outcome.status, "indeterminate");
353
+ assert.equal(forked.attachment.clear().status, "persisted");
354
+ forked.child.branch(leaf);
355
+ assert.equal(forked.attachment.refresh().status, "indeterminate");
356
+ });
357
+ }
358
+
359
+ test("in-memory fork keeps same-manager evidence but never restores pending state", () => {
360
+ const f = fixture(true);
361
+ assert.equal(f.controller.bind("pending", {}).status, "not-persisted");
362
+ const leaf = f.manager.getLeafId();
363
+ assert.ok(leaf);
364
+ const forked = fork(
365
+ f.controller,
366
+ f.manager,
367
+ { entryId: leaf, position: "at" },
368
+ () => {
369
+ f.manager.createBranchedSession(leaf);
370
+ return f.manager;
371
+ },
372
+ );
373
+ assert.equal(forked.captured.status, "captured");
374
+ assert.equal(forked.outcome.status, "indeterminate");
375
+ assert.equal(
376
+ forked.attachment.bind("new-explicit", {}).status,
377
+ "not-persisted",
378
+ );
379
+ // The parent attachment was revoked by detach and cannot append.
380
+ assert.equal(f.controller.clear().status, "indeterminate");
381
+ });
382
+
383
+ test("fork before root creates empty child without parent binding", () => {
384
+ const f = fixture();
385
+ flush(f.manager);
386
+ const firstUser = f.manager.getLeafId();
387
+ assert.ok(firstUser);
388
+ f.controller.bind("parent", {});
389
+ const previousSessionFile = f.manager.getSessionFile();
390
+ const forked = fork(
391
+ f.controller,
392
+ f.manager,
393
+ { entryId: firstUser, position: "before" },
394
+ () => {
395
+ const child = SessionManager.create(
396
+ f.manager.getCwd(),
397
+ f.manager.getSessionDir(),
398
+ );
399
+ child.newSession({ parentSession: previousSessionFile });
400
+ return child;
401
+ },
402
+ );
403
+ assert.equal(forked.captured.status, "captured");
404
+ assert.equal(forked.outcome.status, "indeterminate");
405
+ assert.equal(forked.attachment.bind("child", {}).status, "not-persisted");
406
+ });
407
+
408
+ test("detach rejects a preparation captured by another controller", () => {
409
+ const f = fixture();
410
+ flush(f.manager);
411
+ const leaf = f.manager.getLeafId();
412
+ assert.ok(leaf);
413
+ const other = createSessionProfileAppendController(f.manager, () => {});
414
+ const captured = other.captureForkEvidence(
415
+ { entryId: leaf, position: "at" },
416
+ f.manager,
417
+ );
418
+ assert.equal(captured.status, "captured");
419
+ assert.equal(
420
+ f.controller.detach({
421
+ reason: "fork",
422
+ targetSessionFile: join(f.manager.getSessionDir(), "child.jsonl"),
423
+ preparation: captured.status === "captured" ? captured.evidence : undefined,
424
+ }),
425
+ undefined,
426
+ );
427
+ // Even a rejected proof still revokes the old capability.
428
+ assert.equal(f.controller.refresh().status, "indeterminate");
429
+ });
430
+
431
+ test("empty destination cannot claim a prepared non-empty copied path", () => {
432
+ const f = fixture();
433
+ flush(f.manager);
434
+ f.controller.bind("parent", {});
435
+ const leaf = f.manager.getLeafId();
436
+ assert.ok(leaf);
437
+ const previousSessionFile = f.manager.getSessionFile();
438
+ const forked = fork(
439
+ f.controller,
440
+ f.manager,
441
+ { entryId: leaf, position: "at" },
442
+ () => {
443
+ const child = SessionManager.create(
444
+ f.manager.getCwd(),
445
+ f.manager.getSessionDir(),
446
+ );
447
+ child.newSession({ parentSession: previousSessionFile });
448
+ return child;
449
+ },
450
+ );
451
+ assert.equal(forked.captured.status, "captured");
452
+ assert.equal(forked.outcome.status, "indeterminate");
453
+ // A rejected destination cut cannot activate even a fresh explicit setup.
454
+ assert.deepEqual(forked.attachment.bind("child", {}), {
455
+ status: "indeterminate",
456
+ state: { status: "indeterminate", reason: "append-not-corroborated" },
457
+ });
458
+ });