gentle-pi 4.3.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 (208) hide show
  1. package/README.md +8 -5
  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 +101 -151
  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 +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  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 +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  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 +2 -0
  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 +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -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/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -11,8 +11,12 @@ const CLI = 'dist/bundle/cli.js';
11
11
  // peer is a sibling of the Shell package; a separate global Pi links to the
12
12
  // same store entry. npm nests the peer under the Shell package.
13
13
  function fixture({windows = false, npm = false, runtime = 'adjacent', groups = 'both', peerAfter = '1.2.0', shellVersion = '4.1.0',
14
- badManifest = false, failed = false, mismatch = false, linked = false, readback = true, unrelatedPeer = false, shim = ''} = {}) {
14
+ badManifest = false, failed = false, mismatch = false, linked = false, readback = true, unrelatedPeer = false, shim = '',
15
+ artifact = true, configHome = '', installFailure = undefined as undefined | {stdout?: string, stderrTail?: string}} = {}) {
15
16
  const base = windows ? 'C:/pnpm' : '/pnpm';
17
+ const home = windows ? 'C:/Users/u' : '/home/u';
18
+ // The tarball `gentle-shell upgrade --channel main` packs, named after its version.
19
+ const tarball = `${configHome || `${home}/.pi/gentle-ai`}/main/packages/gentle-pi-${shellVersion}.tgz`;
16
20
  const storePi = (version: string) => `${base}/store/links/@earendil-works/pi-coding-agent/${version}/h/node_modules/${PI}`;
17
21
  const shellModules = npm ? `${base}/npm/lib/node_modules` : `${base}/store/links/@/gentle-pi/4.1.0/h1/node_modules`;
18
22
  const shell = `${shellModules}/gentle-pi`;
@@ -31,6 +35,7 @@ function fixture({windows = false, npm = false, runtime = 'adjacent', groups = '
31
35
  const mutations: string[][] = [];
32
36
  const out: string[] = [];
33
37
  const cwds: unknown[] = [];
38
+ const runOptions: unknown[] = [];
34
39
  const entry = (pkg: string, modules: string, extra = {}) => ({[pkg]: {path: `${modules}/${pkg}`, ...extra}});
35
40
  const shellEntry = entry('gentle-pi', g1, linked ? {link: true} : {});
36
41
  const piEntry = entry(PI, g2, linked ? {resolved: 'file:/src/pi'} : {});
@@ -41,7 +46,7 @@ function fixture({windows = false, npm = false, runtime = 'adjacent', groups = '
41
46
  return npm ? group : [group];
42
47
  };
43
48
  const exists = (n: string) => [g1, g2, g3, shellModules, shell, storePi(state.peer), storePi(state.group), storePi('1.1.0'), nestedPeer]
44
- .some(root => n === root || n.startsWith(root + '/'));
49
+ .some(root => n === root || n.startsWith(root + '/')) || (artifact && n === tarball);
45
50
  const realpath = async (value: string) => {
46
51
  const n = value.replaceAll('\\', '/');
47
52
  if (n === shimFile) return n;
@@ -69,11 +74,14 @@ function fixture({windows = false, npm = false, runtime = 'adjacent', groups = '
69
74
  cwds.push(options?.cwd);
70
75
  if (args.includes('list') || args.includes('ls')) return {code: failed ? 1 : 0, stdout: readback || mutations.length === 0 ? JSON.stringify(listing()) : '{'};
71
76
  mutations.push(args);
72
- if (args.some(arg => arg.startsWith('gentle-pi@'))) state.peer = peerAfter;
77
+ runOptions.push(options);
78
+ if (installFailure) return {code: 1, stdout: installFailure.stdout ?? '', ...('stderrTail' in installFailure ? {stderrTail: installFailure.stderrTail} : {})};
79
+ if (args.some(arg => arg.startsWith('gentle-pi@') || arg.endsWith('.tgz'))) state.peer = peerAfter;
73
80
  if (args.some(arg => arg.startsWith(PI + '@'))) state.group = peerAfter;
74
81
  return {code: 0};
75
82
  };
76
- return {mutations, out, cwds, state, shell, selectedCli, input: {platform: windows ? 'win32' : 'linux', packageRoot: `${g1}/gentle-pi`, currentVersion: '1.1.0', cwd: '/neutral',
83
+ return {mutations, out, cwds, runOptions, state, shell, selectedCli, tarball, input: {platform: windows ? 'win32' : 'linux', packageRoot: `${g1}/gentle-pi`, currentVersion: '1.1.0', cwd: '/neutral',
84
+ home, env: configHome ? {GENTLE_PI_CONFIG_HOME: configHome} : {},
77
85
  runtime: runtime === 'adjacent' ? {kind: 'bundled', command: 'node', args: [selectedCli]} : {kind: runtime, command: shim ? shimFile : selectedCli, args: []},
78
86
  out: (line: string) => out.push(line),
79
87
  adapters: {which: async (name: string) => name === (npm ? 'npm' : 'pnpm') ? `/fake/${name}` : null, fs: {realpath, readFile}, run}}};
@@ -117,6 +125,60 @@ test('npm nests the peer under Shell and reinstalls Shell for it', async () => {
117
125
  assert.deepEqual(f.out, ['npm installed gentle-pi@4.1.0; the selected Pi is now 1.2.0 (was 1.1.0).']);
118
126
  });
119
127
 
128
+ // #2324: main-channel Shell versions are never published; only the tarball
129
+ // `gentle-shell upgrade --channel main` packed can reinstall that exact build.
130
+ const MAIN = '4.2.0-main.fb5e731119a7';
131
+ const native = (path: string, windows: boolean) => windows ? path.replaceAll('/', '\\') : path;
132
+
133
+ for (const windows of [false, true]) test(`a main-channel Shell is reinstalled from its recorded tarball, never from the registry (windows: ${windows})`, async () => {
134
+ const f = fixture({windows, shellVersion: MAIN});
135
+ assert.equal(await updateSelectedPi(f.input), 0);
136
+ assert.deepEqual(f.mutations, [['add', '-g', native(f.tarball, windows), '--allow-build=gentle-pi']]);
137
+ assert.deepEqual(f.out, [`pnpm installed ${native(f.tarball, windows)}; the selected Pi is now 1.2.0 (was 1.1.0).`]);
138
+ assert.equal(f.state.group, '1.1.0');
139
+ });
140
+
141
+ test('npm reinstalls a main-channel Shell from its recorded tarball', async () => {
142
+ const f = fixture({npm: true, shellVersion: MAIN});
143
+ assert.equal(await updateSelectedPi(f.input), 0);
144
+ assert.deepEqual(f.mutations, [['install', '-g', f.tarball]]);
145
+ });
146
+
147
+ test('a main-channel tarball is found under GENTLE_PI_CONFIG_HOME', async () => {
148
+ const f = fixture({shellVersion: MAIN, configHome: '/custom/config'});
149
+ assert.equal(await updateSelectedPi(f.input), 0);
150
+ assert.deepEqual(f.mutations, [['add', '-g', '/custom/config/main/packages/gentle-pi-4.2.0-main.fb5e731119a7.tgz', '--allow-build=gentle-pi']]);
151
+ });
152
+
153
+ test('a missing main-channel tarball names the rebuild command and installs nothing', async () => {
154
+ const f = fixture({shellVersion: MAIN, artifact: false});
155
+ await assert.rejects(updateSelectedPi(f.input), {message: `gentle-shell update: the main-channel build ${f.tarball} is missing, and this unpublished build cannot be reinstalled from the registry; run 'gentle-shell upgrade --channel release' and then 'gentle-shell upgrade --channel main' to rebuild it. No installation changed.`});
156
+ assert.deepEqual(f.mutations, []);
157
+ assert.deepEqual(f.out, []);
158
+ });
159
+
160
+ // A relative config home names different folders for `upgrade` and `update`
161
+ // run from different directories, and npm/pnpm resolve it from the neutral cwd.
162
+ test('a relative GENTLE_PI_CONFIG_HOME is refused for a main-channel Shell without mutation', async () => {
163
+ const f = fixture({shellVersion: MAIN, configHome: 'relative/config'});
164
+ await assert.rejects(updateSelectedPi(f.input), /ownership cannot locate the main-channel build without an absolute config home/);
165
+ assert.deepEqual(f.mutations, []);
166
+ assert.deepEqual(f.out, []);
167
+ });
168
+
169
+ test('a main-channel Shell without a home directory is refused without mutation', async () => {
170
+ const f = fixture({shellVersion: MAIN});
171
+ delete (f.input as {home?: string}).home;
172
+ await assert.rejects(updateSelectedPi(f.input), /ownership cannot locate the main-channel build without an absolute config home/);
173
+ assert.deepEqual(f.mutations, []);
174
+ });
175
+
176
+ for (const runtime of ['path', 'env']) test(`a main-channel Shell never redirects the update of a selected ${runtime} Pi`, async () => {
177
+ const f = fixture({runtime, shellVersion: MAIN, artifact: false});
178
+ assert.equal(await updateSelectedPi(f.input), 0);
179
+ assert.deepEqual(f.mutations, [['add', '-g', `${PI}@latest`]]);
180
+ });
181
+
120
182
  test('an npm PATH runtime updates the global Pi without its scripts', async () => {
121
183
  const f = fixture({npm: true, runtime: 'path'});
122
184
  assert.equal(await updateSelectedPi(f.input), 0);
@@ -184,3 +246,36 @@ test('an unrelated selected override cannot authorize the adjacent Shell group',
184
246
  await assert.rejects(updateSelectedPi(f.input), /ownership/);
185
247
  assert.deepEqual(f.mutations, []);
186
248
  });
249
+
250
+ // #2371: a failed install shows the package manager it ran and one sanitized
251
+ // line of its own output; pnpm 11 prints ERR_PNPM_* on stdout, npm on stderr.
252
+ test('a failed pnpm install names the pnpm it ran and its ERR_PNPM line from stdout', async () => {
253
+ const f = fixture({shellVersion: MAIN, installFailure: {stderrTail: '',
254
+ stdout: `Progress: resolved 1\n\u001b[31m[ERR_PNPM_INVALID_VERSION_UNION]\u001b[39m Invalid versions union. Found: "gentle-pi@file:/home/u/x.tgz". Use exact versions only.\nDone\n`}});
255
+ await assert.rejects(updateSelectedPi(f.input), {message: `gentle-shell update: pnpm (/fake/pnpm) could not install ${f.tarball}\n[ERR_PNPM_INVALID_VERSION_UNION] Invalid versions union. Found: "gentle-pi@file:~/x.tgz". Use exact versions only.`});
256
+ assert.deepEqual(f.runOptions, [{deadlineMs: 20 * 60_000, cwd: '/neutral', stderrTail: 4096}]);
257
+ assert.deepEqual(f.out, []);
258
+ });
259
+
260
+ test('a failed npm install shows its stderr error line before stdout', async () => {
261
+ const f = fixture({npm: true, installFailure: {stdout: 'unrelated', stderrTail: 'npm error code EACCES\nnpm error Error: EACCES: permission denied\nnpm error A complete log is in /home/u/.npm/x.log\n'}});
262
+ await assert.rejects(updateSelectedPi(f.input), {message: 'gentle-shell update: npm (/fake/npm) could not install gentle-pi@4.1.0\nnpm error Error: EACCES: permission denied'});
263
+ assert.deepEqual(f.out, []);
264
+ });
265
+
266
+ test('a failed install without output keeps the single line, naming the command it ran', async () => {
267
+ const f = fixture({installFailure: {}});
268
+ await assert.rejects(updateSelectedPi(f.input), {message: 'gentle-shell update: pnpm (/fake/pnpm) could not install gentle-pi@4.1.0'});
269
+ assert.deepEqual(f.mutations, [['add', '-g', 'gentle-pi@4.1.0', '--allow-build=gentle-pi']]);
270
+ });
271
+
272
+ test('a stderr warning never hides the ERR_PNPM line pnpm printed on stdout', async () => {
273
+ const f = fixture({installFailure: {stderrTail: '(node:1) [DEP0040] DeprecationWarning: The `punycode` module is deprecated.\n',
274
+ stdout: '[ERR_PNPM_INVALID_VERSION_UNION] Invalid versions union.\n'}});
275
+ await assert.rejects(updateSelectedPi(f.input), {message: 'gentle-shell update: pnpm (/fake/pnpm) could not install gentle-pi@4.1.0\n[ERR_PNPM_INVALID_VERSION_UNION] Invalid versions union.'});
276
+ });
277
+
278
+ test('without any error line the last stderr line is shown', async () => {
279
+ const f = fixture({installFailure: {stderrTail: 'killed\n', stdout: 'Progress: resolved 1\n'}});
280
+ await assert.rejects(updateSelectedPi(f.input), {message: 'gentle-shell update: pnpm (/fake/pnpm) could not install gentle-pi@4.1.0\nkilled'});
281
+ });
@@ -59,7 +59,7 @@ const HIGH_TIER_ROW = "| high | only through `escalate` naming a Task Size high-
59
59
  const UNASSESSABLE_TIER_ROW = "| native high, unknown, or assess failed | informs only: writer self-verification stands; escalate when a named high-risk item applies |";
60
60
  // odd/tasks/verifier-admission.md S6: the sentence names the runtime admission of the verifier launch.
61
61
  const NAMED_RISK_SENTENCE =
62
- "The independent verifier runs only when the change touches a named Task Size high-risk item, passed as `escalate` (the runtime admits its launch on a `Risk: item N` line, a feature-document task line, or past the context backstop); never for size, a native tier alone, a failed assessment, or the model or effort that wrote the change. An unknown `Receipt-driven development:` line never lowers a tier below `off`.";
62
+ "The independent verifier runs only when the change touches a named Task Size high-risk item, passed as `escalate` (runtime admits its launch on a `Risk: item N` line or sentence, a feature-document task line, or past the context backstop); never for size, a native tier alone, a failed assessment, or the model or effort that wrote the change. An unknown `Receipt-driven development:` line never lowers a tier below `off`.";
63
63
  const SPOT_CHECK_SENTENCE =
64
64
  "The parent spot check (re-running one reported command before delivery) stays required in every tier.";
65
65
  test("trigger 5 (Verification rule) routes every RDD line through assess and the risk tier", () => {
@@ -405,7 +405,8 @@ test("a v9 host-mediated refuter slot routes through the host relay with its fix
405
405
  // review-refuter entry is refused typed, naming that exact key, never a
406
406
  // mid-relay transport failure.
407
407
  test("a missing review-refuter routing model is refused typed, naming the key, before any relay launch", async (t) => {
408
- const cwd = repository(t);
408
+ const cwd = pinFixture(t);
409
+ isolatedAgentHome(t);
409
410
  const lineageId = "relay-role-missing-model";
410
411
  const input = hostMediatedRefuterCollectInput(lineageId);
411
412
  const harness = nativeHarness([finalizeStatus(lineageId, [input])]);
@@ -1400,3 +1401,89 @@ test("a pinned profile missing a required group role is refused typed before any
1400
1401
  assert.equal(modelLookups, 0, "the typed refusal fires before any model resolution");
1401
1402
  assert.equal(authAttempts, 0, "the typed refusal fires before any provider auth");
1402
1403
  });
1404
+
1405
+ // gentle-pi#2271: review-refuter and review-validator are provider review
1406
+ // roles, not installable agents, so nothing seeds their routing entries and
1407
+ // profiles never carry them. When the routing snapshot in effect (saved
1408
+ // routing, pin, or session binding) has no model for one of them, the relay
1409
+ // uses Pi's persisted default model, as the routing panel already promises.
1410
+ // Lens reviewers keep their fail-closed rule.
1411
+ function isolatedAgentHome(t: test.TestContext, settings?: Record<string, unknown>): void {
1412
+ const agentHome = mkdtempSync(join(tmpdir(), "gentle-pi-relay-agent-home-"));
1413
+ const previous = process.env.GENTLE_PI_AGENT_HOME;
1414
+ process.env.GENTLE_PI_AGENT_HOME = agentHome;
1415
+ t.after(() => {
1416
+ if (previous === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
1417
+ else process.env.GENTLE_PI_AGENT_HOME = previous;
1418
+ rmSync(agentHome, { recursive: true, force: true });
1419
+ });
1420
+ if (settings !== undefined) writeFileSync(join(agentHome, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
1421
+ }
1422
+
1423
+ const PI_DEFAULT_SETTINGS = { defaultProvider: "pidefault", defaultModel: "default-model", defaultThinkingLevel: "medium" };
1424
+
1425
+ async function relayedRefuterRequest(t: test.TestContext, cwd: string, lineageId: string): Promise<ReviewHostRelayRequest[]> {
1426
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
1427
+ const harness = nativeHarness([finalizeStatus(lineageId, [hostMediatedRefuterCollectInput(lineageId)])]);
1428
+ const relayed: ReviewHostRelayRequest[] = [];
1429
+ __testing.setReviewHostRelayRunnerForTesting(async (request) => {
1430
+ relayed.push(request);
1431
+ return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
1432
+ });
1433
+ await runCapture(cwd, harness, lineageId);
1434
+ return relayed;
1435
+ }
1436
+
1437
+ test("a refuter slot with no routing entry uses Pi's persisted default model and effort", async (t) => {
1438
+ const cwd = pinFixture(t);
1439
+ isolatedAgentHome(t, PI_DEFAULT_SETTINGS);
1440
+
1441
+ const relayed = await relayedRefuterRequest(t, cwd, "relay-role-default");
1442
+
1443
+ assert.equal(relayed.length, 1);
1444
+ assert.equal(relayed[0]!.routingKey, "review-refuter");
1445
+ assert.equal(relayed[0]!.selection, "pidefault/default-model");
1446
+ assert.equal(relayed[0]!.thinking, "medium");
1447
+ });
1448
+
1449
+ test("a pinned profile that omits the refuter role uses Pi's persisted default model instead of refusing", async (t) => {
1450
+ const cwd = pinFixture(t);
1451
+ isolatedAgentHome(t, PI_DEFAULT_SETTINGS);
1452
+ pinLocal(cwd, "pinned-complete");
1453
+
1454
+ const relayed = await relayedRefuterRequest(t, cwd, "relay-role-pin-default");
1455
+
1456
+ assert.equal(relayed.length, 1);
1457
+ assert.equal(relayed[0]!.selection, "pidefault/default-model");
1458
+ });
1459
+
1460
+ test("a configured review-refuter routing entry still wins over Pi's persisted default", async (t) => {
1461
+ const cwd = pinFixture(t);
1462
+ isolatedAgentHome(t, PI_DEFAULT_SETTINGS);
1463
+ writeFileSync(join(process.env.GENTLE_PI_CONFIG_HOME!, "models.json"), `${JSON.stringify({ ...GLOBAL_MODELS, "review-refuter": { model: "global/refuter-model", thinking: "high" } }, null, 2)}\n`);
1464
+
1465
+ const relayed = await relayedRefuterRequest(t, cwd, "relay-role-configured");
1466
+
1467
+ assert.equal(relayed.length, 1);
1468
+ assert.equal(relayed[0]!.selection, "global/refuter-model");
1469
+ assert.equal(relayed[0]!.thinking, "high");
1470
+ });
1471
+
1472
+ test("a lens slot with no routing entry never borrows Pi's persisted default model", async (t) => {
1473
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
1474
+ const cwd = pinFixture(t);
1475
+ isolatedAgentHome(t, PI_DEFAULT_SETTINGS);
1476
+ pinLocal(cwd, "pinned-partial");
1477
+ const lineageId = "relay-lens-no-default";
1478
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-reliability", 0)])]);
1479
+ const relayed: ReviewHostRelayRequest[] = [];
1480
+ __testing.setReviewHostRelayRunnerForTesting(async (request) => {
1481
+ relayed.push(request);
1482
+ return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
1483
+ });
1484
+
1485
+ await runCapture(cwd, harness, lineageId);
1486
+
1487
+ assert.equal(relayed.length, 1);
1488
+ assert.equal(relayed[0]!.selection, undefined);
1489
+ });
@@ -539,7 +539,11 @@ test("every in-process reviewer refusal maps to its typed relay failure code, ca
539
539
  { code: INPROCESS_REVIEWER_FAILURE.AUTH_UNAVAILABLE, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_AUTH_UNAVAILABLE },
540
540
  { code: INPROCESS_REVIEWER_FAILURE.THINKING_INVALID, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_THINKING_INVALID },
541
541
  { code: INPROCESS_REVIEWER_FAILURE.TOOL_CALL_ATTEMPTED, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_TOOL_CALL },
542
- { code: INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_EMPTY_OUTPUT, evidence: { stopReason: "stop" } },
542
+ { code: INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_EMPTY_OUTPUT, evidence: {
543
+ reviewerModel: "nan/deepseek-v4-flash", stopReason: "length",
544
+ usage: { input: 12, output: 64, reasoning: 64, cacheRead: 0, cacheWrite: 0, totalTokens: 76 },
545
+ content: { blocks: 1, textBlocks: 0, thinkingBlocks: 1, textChars: 0, thinkingChars: 16 },
546
+ } },
543
547
  { code: INPROCESS_REVIEWER_FAILURE.OUTPUT_TOO_LARGE, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_OUTPUT_TOO_LARGE },
544
548
  { code: INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED, kind: REVIEW_HOST_RELAY_FAILURE.PI_FAILED },
545
549
  ];
@@ -550,7 +554,10 @@ test("every in-process reviewer refusal maps to its typed relay failure code, ca
550
554
  assert.equal(error.message, message);
551
555
  assert.equal(error.mutationOutcome, "none");
552
556
  assert.equal(existsSync(fixture.submitCapturePath), false, `${code} must never reach submission`);
553
- if (evidence !== undefined) assert.deepEqual(error.reviewerEvidence, evidence, `${code} must carry its outcome evidence`);
557
+ if (evidence !== undefined) {
558
+ assert.deepEqual(error.reviewerEvidence, evidence, `${code} must carry its outcome evidence`);
559
+ assert.doesNotMatch(JSON.stringify(error.reviewerEvidence), /errorMessage|thinkingSignature|rawStopReason|authorization|providerPayload/);
560
+ }
554
561
  }
555
562
  });
556
563
 
@@ -53,7 +53,7 @@ test("public Anthropic non-strict adapter preserves the complete review tool dec
53
53
  assert.deepEqual(controller.required, ["operation"]);
54
54
  assert.deepEqual(Object.keys(controller.properties).sort(), ["operation", "lineageId", "selectionBinding", "intendedUntracked", "untrackedScope", "changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "workspaceRoot"].sort());
55
55
  assert.ok(controller.properties.input.anyOf.some((schema: any) => schema.type === "object"));
56
- assert.match(controller.properties.input.description, /START\/ASSESS only/);
56
+ assert.match(controller.properties.input.description, /START\/ASSESS\/answer-consent/);
57
57
  assert.deepEqual(controller.properties.operation.enum, tools.get("gentle_review").parameters.properties.operation.enum);
58
58
  for (const name of ["gentle_review_capture", "gentle_review_capture_group"]) {
59
59
  const schema = emitted.get(name);
@@ -66,7 +66,7 @@ test("public Anthropic non-strict adapter preserves the complete review tool dec
66
66
  let nativeCalls = 0;
67
67
  const native = new Proxy({}, { get() { return async () => { nativeCalls++; }; } }) as NativeReviewCli;
68
68
  for (const operation of ["start", "assess", "answer-consent", "inspect", "reset", "recover"]) {
69
- const inputs = operation === "start" || operation === "assess" ? [null] : [null, { consentBinding: "opaque", answer: "granted" }];
69
+ const inputs = operation === "start" || operation === "assess" || operation === "answer-consent" ? [null] : [null, { consentBinding: "opaque", answer: "granted" }];
70
70
  for (const input of inputs) {
71
71
  const args = validateToolArguments({ ...tools.get("gentle_review"), parameters: controller }, { type: "toolCall", id: "provider-shell", name: "gentle_review", arguments: { operation, input } });
72
72
  assert.deepEqual(args.input, input, "provider-shell validation accepts and retains this input");
@@ -89,6 +89,23 @@ test("registered review schemas admit START/ASSESS objects and ordered capture o
89
89
  assert.deepEqual(__testing.parseReviewCaptureGroupParameters(args).collectBindings, [JSON.stringify(binding), ordered[1]]);
90
90
  });
91
91
 
92
+ test("answer-consent admits JSON object input and coerces it cleanly (#2375)", async () => {
93
+ const input = { consentBinding: "bound-1", answer: "granted" };
94
+ assert.deepEqual(validate("gentle_review", { operation: "answer-consent", input }).input, input);
95
+ assert.equal(
96
+ __testing.parseReviewControllerParameters(validate("gentle_review", { operation: "answer-consent", input })).input,
97
+ JSON.stringify(input),
98
+ );
99
+ const native = { targetStatus: async () => ({ targetIdentity: "t1" }) } as unknown as NativeReviewCli;
100
+ const outcome = await __testing.executeReviewControllerOperation(
101
+ validate("gentle_review", { operation: "answer-consent", input }),
102
+ process.cwd(),
103
+ native,
104
+ );
105
+ assert.equal(outcome.operation, "answer-consent");
106
+ assert.match(String(outcome.outcome), /consent-binding-stale/);
107
+ });
108
+
92
109
  test("validated ASSESS objects reach the mocked native facade with parsed fields", async () => {
93
110
  const requests: unknown[] = [];
94
111
  const native = { assess: async (request: unknown) => { requests.push(request); throw new Error("mock assessment unavailable"); } } as unknown as NativeReviewCli;
@@ -132,7 +149,7 @@ test("actual validation rejects arrays, null and coerced primitives rather than
132
149
  });
133
150
 
134
151
  test("all other controller operations keep object input fail-closed at the facade boundary (#1698)", async () => {
135
- const operations = (tools.get("gentle_review").parameters.properties.operation.enum as string[]).filter((operation) => operation !== "start" && operation !== "assess" && operation !== "select-intended-untracked");
152
+ const operations = (tools.get("gentle_review").parameters.properties.operation.enum as string[]).filter((operation) => operation !== "start" && operation !== "assess" && operation !== "answer-consent" && operation !== "select-intended-untracked");
136
153
  let calls = 0;
137
154
  const native = new Proxy({}, { get() { return async () => { calls++; throw new Error("unexpected native execution"); }; } }) as NativeReviewCli;
138
155
  for (const operation of operations) {
@@ -25,6 +25,12 @@ interface SessionRun {
25
25
 
26
26
  type RddMode = "on" | "off" | "throws";
27
27
 
28
+ const NATIVE_CONTINUATION = {
29
+ operation: "review.status",
30
+ command: "gentle-ai review status --agent pi --next-transition",
31
+ arguments: [{ name: "--agent", value: "pi" }, { name: "--next-transition" }],
32
+ };
33
+
28
34
  function nativeCli(mode: () => RddMode): NativeReviewCli {
29
35
  return {
30
36
  reviewMode: async () => {
@@ -34,7 +40,10 @@ function nativeCli(mode: () => RddMode): NativeReviewCli {
34
40
  },
35
41
  assess: async () => ({
36
42
  schema: "gentle-ai.review-assessment/v1",
37
- risk: "medium",
43
+ risk: "high",
44
+ reviewDue: true,
45
+ reviewDueReason: "high_risk",
46
+ nextTransition: NATIVE_CONTINUATION,
38
47
  reasons: [],
39
48
  changedPaths: 1,
40
49
  changedLines: 3,
@@ -120,7 +129,7 @@ async function runSession(
120
129
  });
121
130
  try {
122
131
  await session.bindExtensions({ mode: "json", onError: (error) => run.errors.push(error.error) });
123
- next = { name: "gentle_review", arguments: { operation: "assess" } };
132
+ next = { name: "gentle_review", arguments: { operation: "assess", input: { escalate: { item: 4, reason: "Fixture process concurrency boundary" } } } };
124
133
  await session.prompt("Assess the current change.", { source: "interactive" });
125
134
  beforeSecondPrompt();
126
135
  await session.prompt("Report the plan.", { source: "interactive" });
@@ -162,6 +171,11 @@ function registeredGuidelines(run: SessionRun): string[] {
162
171
  function assertLeanRequest(request: RecordedRequest, run: SessionRun): void {
163
172
  assert.match(request.systemPrompt, /Receipt-driven development: off /);
164
173
  assert.match(request.systemPrompt, LEAN_GUIDELINE);
174
+ assert.match(request.systemPrompt, /relay it unchanged but do not execute it while RDD is off/);
175
+ assert.match(request.systemPrompt, /assessment metadata does not authorize STATUS, another review lifecycle operation, or enabling RDD/);
176
+ assert.doesNotMatch(request.systemPrompt, /relay it unchanged and follow it/);
177
+ assert.match(request.systemPrompt, /Continue the returned verification plan/);
178
+ assert.match(request.systemPrompt, /tests, builds, and functional checks still run/);
165
179
  for (const guideline of registeredGuidelines(run)) assert.equal(request.systemPrompt.includes(guideline), false, `RDD off must leave out: ${guideline.slice(0, 80)}`);
166
180
  const names = request.tools.map((tool) => tool.name);
167
181
  assert.ok(names.includes("gentle_review"));
@@ -195,6 +209,13 @@ test("RDD off: the parent request carries only the lean ASSESS loadout; RDD on:
195
209
  assert.equal(off.assessResults.length, 1);
196
210
  assert.equal(off.assessResults[0].isError, false);
197
211
  assert.equal((off.assessResults[0].details as { schema?: string }).schema, "gentle-pi.review-assessment-plan/v1");
212
+ const plan = off.assessResults[0].details as Record<string, unknown>;
213
+ assert.equal(plan.rddLine, "off");
214
+ assert.equal(plan.reviewDue, true);
215
+ assert.equal(plan.reviewDueReason, "high_risk");
216
+ assert.deepEqual(plan.nextTransition, NATIVE_CONTINUATION);
217
+ assert.equal((plan.plan as Record<string, unknown>).writerSelfVerification, true);
218
+ assert.equal((plan.plan as Record<string, unknown>).independentVerifier, true);
198
219
 
199
220
  for (const request of on.requests) {
200
221
  assert.match(request.systemPrompt, /Receipt-driven development: on /);
@@ -7,6 +7,7 @@ import test from "node:test";
7
7
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
8
  import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
9
9
  import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError } from "../lib/review-host-relay.ts";
10
+ import { runInProcessReviewer } from "../lib/inprocess-reviewer.ts";
10
11
  import { NativeReviewIntegrationError, type NativeReviewCli } from "../lib/native-review-cli.ts";
11
12
  import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
12
13
  import type { ReviewArtifactSubjectV2, ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
@@ -375,6 +376,31 @@ test("a relayed empty-output failure carries the child's own evidence in the fai
375
376
  assert.equal(failure.reviewer?.toolCallAttempted, true);
376
377
  });
377
378
 
379
+ test("public empty-output report preserves only safe in-process completion facts", async (t) => {
380
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
381
+ const cwd = repository(t);
382
+ const { native } = transportAwareNative();
383
+ const outcome = await runInProcessReviewer({ selection: "nan/deepseek-v4-flash", prompt: Buffer.from("offline"), timeoutMs: 1_000, routingKey: "review-risk" }, {
384
+ registry: {
385
+ find: () => ({ provider: "nan", id: "deepseek-v4-flash" }) as never,
386
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "PRIVATE AUTH" }),
387
+ },
388
+ complete: async () => ({ content: [{ type: "thinking", thinking: "PRIVATE THINKING" }], stopReason: "length", errorMessage: "PRIVATE ERROR", usage: { output: 64, reasoning: 64, unknown: "PRIVATE USAGE" } }) as never,
389
+ });
390
+ assert.equal(outcome.kind, "refused");
391
+ if (outcome.kind !== "refused") return;
392
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
393
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_EMPTY_OUTPUT, "pi", outcome.message, { reviewerEvidence: outcome.evidence });
394
+ });
395
+ const result = await runCapture(cwd, native, "safe-evidence-lineage");
396
+ assert.equal(result.outcome, "pi-host-relay-transport-failure");
397
+ assert.deepEqual((result.failure as { reviewer: unknown }).reviewer, {
398
+ reviewerModel: "nan/deepseek-v4-flash", stopReason: "length", usage: { output: 64, reasoning: 64 },
399
+ content: { blocks: 1, textBlocks: 0, thinkingBlocks: 1, textChars: 0, thinkingChars: 16 },
400
+ });
401
+ assert.doesNotMatch(JSON.stringify(result), /PRIVATE|errorMessage|unknown|authorization/);
402
+ });
403
+
378
404
  test("capture forecasts the reviewer model run once and spends nothing until it is acknowledged", async (t) => {
379
405
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
380
406
  const cwd = repository(t);
@@ -5,7 +5,7 @@ import { consumeReviewMutation, pendingReviewMutation, pendingReviewMutationProf
5
5
  function fixture() {
6
6
  let sessionId = "parent";
7
7
  const entries: Array<{ type: string; customType: string; data: unknown }> = [];
8
- const session = { getSessionId: () => sessionId, getBranch: () => entries };
8
+ const session = { getSessionId: () => sessionId, getBranch: () => entries, getLeafId: () => `leaf-${entries.length}`, getEntryCount: () => entries.length };
9
9
  const host = { appendEntry: (customType: string, data: unknown) => { entries.push({ type: "custom", customType, data }); } };
10
10
  const write = (toolCallId: string, root = "/repo") => recordReviewMutation(host, session, root, { source: "direct", toolName: "write", toolCallId });
11
11
  return { host, session, entries, write, switchSession: (id: string) => { sessionId = id; } };
@@ -61,6 +61,36 @@ test("malformed, unknown-version, and unmatched consumption entries do not estab
61
61
  assert.equal(pendingReviewMutation(f.session, "/repo"), captured);
62
62
  });
63
63
 
64
+ test("the ledger scan is memoized until the next append and hosts without leaf identity stay uncached", () => {
65
+ let scans = 0;
66
+ const entries: Array<{ type: string; customType: string; data: unknown }> = [];
67
+ const host = { appendEntry: (customType: string, data: unknown) => { entries.push({ type: "custom", customType, data }); } };
68
+ const branch = () => { scans++; return entries; };
69
+ const session = {
70
+ getSessionId: () => "parent",
71
+ getBranch: branch,
72
+ getLeafId: () => `leaf-${entries.length}`,
73
+ getEntryCount: () => entries.length,
74
+ };
75
+ recordReviewMutation(host, session, "/repo", { source: "direct", toolName: "write", toolCallId: "a" });
76
+ const captured = pendingReviewMutation(session, "/repo");
77
+ assert.ok(captured);
78
+ const afterFirstScan = scans;
79
+ assert.equal(pendingReviewMutation(session, "/repo"), captured);
80
+ assert.deepEqual(pendingReviewMutationProfiles(session, "/repo"), [{}]);
81
+ assert.equal(scans, afterFirstScan, "an unchanged (leafId, entryCount) identity must not rescan the branch");
82
+ recordReviewMutation(host, session, "/repo", { source: "direct", toolName: "edit", toolCallId: "b" });
83
+ const afterAppend = scans;
84
+ const next = pendingReviewMutation(session, "/repo");
85
+ assert.ok(next && next !== captured, "an append invalidates the memo, so the next call sees the new mutation");
86
+ assert.equal(scans, afterAppend + 1);
87
+ const uncached = { getSessionId: () => "parent", getBranch: branch };
88
+ const beforeUncached = scans;
89
+ assert.ok(pendingReviewMutation(uncached, "/repo"));
90
+ assert.ok(pendingReviewMutation(uncached, "/repo"));
91
+ assert.equal(scans, beforeUncached + 2, "a host without leaf identity stays uncached");
92
+ });
93
+
64
94
  // gentle-pi#1175 (T2): mutation receipts optionally carry the writer profile
65
95
  // the runtime resolved, so ASSESS never has to trust a model re-declaration.
66
96
  test("mutation receipts validate with or without runtime writer profile fields, never with empty ones", () => {
@@ -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