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
@@ -4,7 +4,8 @@ import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { PassThrough } from "node:stream";
7
- import test from "node:test";
7
+ import test, { after, before } from "node:test";
8
+ import { setWindowsAclAuthorityForTesting } from "../lib/review-candidate-view-owner.ts";
8
9
  import { Text, visibleWidth } from "@earendil-works/pi-tui";
9
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
11
  import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
@@ -19,6 +20,16 @@ import {
19
20
  presentReviewConsentUi,
20
21
  } from "../lib/review-consent-ui.ts";
21
22
 
23
+ // Coordination tests use fixture authority; real Windows ACL coverage lives
24
+ // in review-candidate-view.test.ts, in a separate test process.
25
+ let fixtureAclChecks = 0;
26
+ before(() => {
27
+ if (process.platform === "win32") {
28
+ setWindowsAclAuthorityForTesting(() => { fixtureAclChecks++; });
29
+ }
30
+ });
31
+ after(() => setWindowsAclAuthorityForTesting());
32
+
22
33
  function consentFixture(): Record<string, unknown> {
23
34
  const path = join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json");
24
35
  return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
@@ -173,10 +184,12 @@ function siblingWorktree(t: test.TestContext, parentRoot: string): string {
173
184
 
174
185
  function startStatus(cwd: string, intendedUntracked: readonly string[] = []): ReviewStatusV3 {
175
186
  const views = new CandidateViewRegistry();
187
+ const checksBefore = fixtureAclChecks;
176
188
  const candidate = views.create({
177
189
  contributorRoot: cwd,
178
190
  ...(intendedUntracked.length === 0 ? {} : { intendedUntracked }),
179
191
  });
192
+ if (process.platform === "win32") assert.ok(fixtureAclChecks > checksBefore, "coordination candidates must use the fixture ACL authority");
180
193
  try {
181
194
  return {
182
195
  contract: "gentle-ai.review-integration/v2",
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { execFileSync } from "node:child_process";
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
3
  import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
@@ -100,6 +100,32 @@ test("review session Git identity lookups hide every Windows child process", asy
100
100
  }
101
101
  });
102
102
 
103
+ test("synchronous repository discovery stays silent outside Git", (t) => {
104
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-non-git-identity-")));
105
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
106
+ const moduleUrl = new URL("../lib/review-session-standing-permission.ts", import.meta.url).href;
107
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith("GIT_")));
108
+ const child = spawnSync(process.execPath, ["--experimental-strip-types", "--input-type=module", "-e", `
109
+ import { resolveCanonicalGitRepositoryIdentitySync } from ${JSON.stringify(moduleUrl)};
110
+ const identity = resolveCanonicalGitRepositoryIdentitySync(process.cwd());
111
+ console.log(JSON.stringify({ identity: identity ?? null }));
112
+ `], { cwd, env, encoding: "utf8", timeout: 10_000 });
113
+ assert.equal(child.error, undefined);
114
+ assert.equal(child.status, 0, child.stderr);
115
+ assert.deepEqual(JSON.parse(child.stdout), { identity: null });
116
+ assert.equal(child.stderr, "", "a caught Git failure must not write over the TUI editor");
117
+ });
118
+
119
+ test("synchronous repository discovery preserves clone identity across worktrees", (t) => {
120
+ const root = repository(t);
121
+ const sibling = siblingWorktree(t, root);
122
+ const unrelated = repository(t);
123
+ const identity = resolveCanonicalGitRepositoryIdentitySync(root);
124
+ assert.match(identity ?? "", /^sha256:/);
125
+ assert.equal(resolveCanonicalGitRepositoryIdentitySync(sibling), identity);
126
+ assert.notEqual(resolveCanonicalGitRepositoryIdentitySync(unrelated), identity);
127
+ });
128
+
103
129
  test("headless, child, empty-session, and non-Git contexts cannot offer or consume standing permission", async (t) => {
104
130
  const root = repository(t);
105
131
  const manager = {};
@@ -123,6 +123,8 @@ console.log("ENV_PROBE=" + JSON.stringify({
123
123
  home,
124
124
  files: home && existsSync(home) ? readdirSync(home) : null,
125
125
  sentinel: process.env.TEST_ENV_SENTINEL,
126
+ tmp: process.env.TMPDIR ?? null,
127
+ tmpExists: process.env.TMPDIR ? existsSync(process.env.TMPDIR) : null,
126
128
  }));
127
129
  `);
128
130
  writeFileSync(stagesPath, JSON.stringify([
@@ -200,3 +202,16 @@ test("direct invocation rejects a malformed stages file with a non-zero exit", (
200
202
  assert.equal(result.status, 1);
201
203
  assert.match(result.stderr ?? "", /stages file must be an array/);
202
204
  });
205
+
206
+ // Harness roadmap T3: each subagent gets its own TMPDIR, and unit fixtures that
207
+ // leave a fake child unfinished would leave it behind; the stage owns them all.
208
+ test("unit stage gets a private TMPDIR removed after the stage; later stages keep the inherited one", (t) => {
209
+ const inherited = mkdtempSync(join(tmpdir(), "gentle-pi-caller-tmp-"));
210
+ t.after(() => rmSync(inherited, { recursive: true, force: true }));
211
+ const [unit, provider] = probeStageEnvironment(t, { TMPDIR: inherited });
212
+ assert.notEqual(unit.tmp, inherited);
213
+ assert.match(unit.tmp, /gentle-pi-unit-tmp-/);
214
+ assert.equal(unit.tmpExists, true, "the private TMPDIR exists while the stage runs");
215
+ assert.equal(existsSync(unit.tmp), false, "the private TMPDIR is removed after the stage");
216
+ assert.equal(provider.tmp, inherited);
217
+ });
@@ -604,9 +604,19 @@ async function run() {
604
604
  assert.equal(writerDispatch.task, task, "writer guard must not mutate child input");
605
605
  }
606
606
 
607
+ // S16: generic writers are admitted only for a task of an existing feature document (or at the measured
608
+ // context backstop); the accepted cases below name one, and an undocumented launch is told to work inline.
609
+ await mkdir(join(toolCwd, "odd", "tasks"), { recursive: true });
610
+ await writeFile(join(toolCwd, "odd", "tasks", "harness.md"), "# Harness\n");
611
+ const undocumentedWriter = await toolHook(
612
+ { toolName: "subagent_run", input: { agent: "gentle-ai-worker", task: "Implement the requested change.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts", mode: "task" } },
613
+ createCtx(toolCwd),
614
+ );
615
+ assert.equal(undocumentedWriter?.block, true, "an undocumented writer must be blocked before dispatch");
616
+ assert.match(undocumentedWriter?.reason ?? "", /write this unit inline yourself/);
607
617
  const scopedWriterDispatch = {
608
618
  agent: "gentle-ai-worker",
609
- task: "Implement the requested change.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts\ntests/runtime-harness.mjs",
619
+ task: "Spec: odd/tasks/harness.md, T1. Implement the requested change.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts\ntests/runtime-harness.mjs",
610
620
  mode: "task",
611
621
  };
612
622
  assert.equal(
@@ -620,7 +630,7 @@ async function run() {
620
630
  toolName: "subagent_run",
621
631
  input: {
622
632
  agent: "worker",
623
- task: "Implement the requested change.",
633
+ task: "Spec: odd/tasks/harness.md, T2. Implement the requested change.",
624
634
  context: "## Allowed edit surfaces\n- assets/orchestrator.md",
625
635
  mode: "task",
626
636
  },
@@ -717,7 +727,7 @@ async function run() {
717
727
  "valid scope followed by a repository-root scope",
718
728
  {
719
729
  agent: "gentle-ai-worker",
720
- task: "## Allowed edit surfaces\nextensions/gentle-ai.ts\n\n## Allowed edit surfaces\n.",
730
+ task: "Spec: odd/tasks/harness.md, T4.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts\n\n## Allowed edit surfaces\n.",
721
731
  mode: "task",
722
732
  },
723
733
  ],
@@ -725,7 +735,7 @@ async function run() {
725
735
  "valid task scope plus invalid context scope",
726
736
  {
727
737
  agent: "gentle-ai-worker",
728
- task: "## Allowed edit surfaces\nextensions/gentle-ai.ts",
738
+ task: "Spec: odd/tasks/harness.md, T5.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts",
729
739
  context: "## Allowed edit surfaces\n.",
730
740
  mode: "task",
731
741
  },
@@ -734,7 +744,7 @@ async function run() {
734
744
  "conflicting valid task and context scopes",
735
745
  {
736
746
  agent: "gentle-ai-worker",
737
- task: "## Allowed edit surfaces\nextensions/gentle-ai.ts",
747
+ task: "Spec: odd/tasks/harness.md, T6.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts",
738
748
  context: "## Allowed edit surfaces\ntests/runtime-harness.mjs",
739
749
  mode: "task",
740
750
  },
@@ -749,7 +759,7 @@ async function run() {
749
759
  toolName: "subagent_run",
750
760
  input: {
751
761
  agent: "gentle-ai-worker",
752
- task: "## Allowed edit surfaces\nextensions/gentle-ai.ts\ntests/runtime-harness.mjs\n\n## Allowed edit surfaces\n- `tests/runtime-harness.mjs`\n- `extensions/gentle-ai.ts`",
762
+ task: "Spec: odd/tasks/harness.md, T3.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts\ntests/runtime-harness.mjs\n\n## Allowed edit surfaces\n- `tests/runtime-harness.mjs`\n- `extensions/gentle-ai.ts`",
753
763
  mode: "task",
754
764
  },
755
765
  },
@@ -39,7 +39,7 @@ function fixture(entries: any[] = [], options: { root?: string; sessionId?: stri
39
39
  for(let i=0;i<200&&!overlay.view;i++){ if(await Promise.race([opened.then(()=>true),new Promise(r=>setTimeout(()=>r(false),5))])) break; }
40
40
  if(!overlay.view) return undefined;
41
41
  const text=overlay.view.render(200).join("\n");
42
- const trees=(overlay.view.trees as any[]).map(tree=>({root:tree.root as string,branch:tree.branch as string|undefined,files:tree.model.files.map((file:any)=>file.path) as string[],added:tree.model.added as number,deleted:tree.model.deleted as number}));
42
+ const trees=(overlay.view.children[0].trees as any[]).map(tree=>({root:tree.root as string,branch:tree.branch as string|undefined,files:tree.model.files.map((file:any)=>file.path) as string[],added:tree.model.added as number,deleted:tree.model.deleted as number}));
43
43
  overlay.close!(); await opened;
44
44
  return {text,trees,files:trees.flatMap(tree=>tree.files)};
45
45
  };
@@ -4,6 +4,7 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { SessionChanges, readChangeSnapshot, sessionChangeRoots, SESSION_CHANGE_ENTRY, MAX_CHANGE_BYTES } from "../lib/session-changes.ts";
7
+ import { emptyChanges } from "../lib/shell-changes.ts";
7
8
  import { SESSION_WORKTREE_ENTRY } from "../lib/session-worktree-registry.ts";
8
9
 
9
10
  const text = (text: string) => ({ kind: "text" as const, text });
@@ -12,7 +13,7 @@ const evidence = (id = "a", root = "/repo", path = "file.ts", before = text("old
12
13
 
13
14
  test("session changes starts empty without reading the worktree or its untracked contents", () => {
14
15
  const changes = new SessionChanges("session");
15
- assert.deepEqual(changes.model, { files: [], added: 0, deleted: 0 });
16
+ assert.deepEqual(changes.model, { ...emptyChanges(), digest: emptyChanges().digest });
16
17
  assert.deepEqual(changes.worktrees, []);
17
18
  });
18
19
 
@@ -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
+ });