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
@@ -10,9 +10,9 @@ import { localProfilePinPath, repoProfileDeclarationPath, writeProfilePinSync }
10
10
  import { createProfile, emptyProfilesFile, profilesFilePath, writeProfilesFileSync } from "../lib/agent-profiles.ts";
11
11
  import type { AgentModelConfig } from "../lib/model-routing-authority.ts";
12
12
  import { GENTLE_AI_DEV_BINARY_ENV, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
13
- import type { NativeReviewCli, NativeReviewUnachievableLensCaptureArtifact, NativeReviewUnachievableLensCaptureRequest } from "../lib/native-review-cli.ts";
13
+ import { NativeReviewIntegrationError, type NativeReviewCli, type NativeReviewUnachievableLensCaptureArtifact, type NativeReviewUnachievableLensCaptureRequest } from "../lib/native-review-cli.ts";
14
14
  import { REVIEW_HOST_RELAY_FAILURE, REVIEW_HOST_RELAY_PI_TIMEOUT_ENV, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, type ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
15
- import { decodeReviewNextTransitionV3, decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
15
+ import { decodeReviewFailureV2, decodeReviewNextTransitionV3, decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
16
16
  import type { InProcessReviewerRegistry } from "../lib/inprocess-reviewer.ts";
17
17
 
18
18
  // One-slot capture routing: the host relay runs only when the selected
@@ -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])]);
@@ -444,6 +445,63 @@ async function runCaptureGroup(cwd: string, harness: RoutingHarness, lineageId:
444
445
 
445
446
  function prepared(request: ReviewHostRelayRequest) { return { request, promptByteLength: 64, resultByteLength: 32 }; }
446
447
 
448
+ for (const failAt of [0, 1, 2]) {
449
+ for (const typed of [true, false]) {
450
+ test(`grouped STATUS failure preserves ${typed ? "decoded diagnostics" : "untyped behavior"} at call ${failAt + 1}`, async (t) => {
451
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
452
+ const cwd = repository(t), lineageId = "relay-group-status-failure", inputs = groupInputs(lineageId);
453
+ const failure = decodeReviewFailureV2({
454
+ schema: "gentle-ai.review-integration.failure/v2", contract: "gentle-ai.review-integration/v2",
455
+ operation: "review.status", phase: "native_running", code: "native_operation_timeout",
456
+ message: "The negotiated review operation exceeded its aggregate time budget.",
457
+ mutation_outcome: "not_started", authority_applicability: "current_target", retry_safe: false,
458
+ replayability: "manual_action_required", required_inputs: [], next_action: "explicit-maintainer-action",
459
+ cause: "STATUS budget exhausted", context: { binding_revision: { expected: SHA, current: PHASE_REVISION } },
460
+ });
461
+ const error = typed ? new NativeReviewIntegrationError(failure) : Object.assign(new Error(failure.message), {
462
+ failureEnvelope: { raw: { secret: "must not be projected" } }, stdout: "private stdout", stderr: "private stderr",
463
+ });
464
+ const harness = nativeHarness([]);
465
+ let calls = 0, preparations = 0, submissions = 0;
466
+ harness.native.targetStatus = async () => {
467
+ const index = calls++;
468
+ if (index === failAt) throw error;
469
+ return finalizeStatus(lineageId, index === 0 ? inputs : inputs.slice(index - 1));
470
+ };
471
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => {
472
+ preparations += 1;
473
+ return requests.map(prepared);
474
+ }, async (result) => {
475
+ submissions += 1;
476
+ return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" };
477
+ });
478
+ const result = await runCaptureGroup(cwd, harness, lineageId, inputs);
479
+ assert.equal(result.outcome, "native-status-failed");
480
+ assert.equal(result.status, "blocked");
481
+ assert.equal(result.reason, failure.message);
482
+ assert.equal(calls, failAt + 1, "no STATUS retry");
483
+ assert.equal(preparations, failAt === 0 ? 0 : 1);
484
+ assert.equal(submissions, Math.max(0, failAt - 1), "no submission after STATUS failure");
485
+ assert.equal(result.mutation_performed, submissions > 0);
486
+ assert.equal(result.mutation_outcome, submissions > 0 ? "partial" : "none");
487
+ if (failAt > 0) {
488
+ const progress = result.host_relay as { reviewers: unknown[] };
489
+ assert.equal(progress.reviewers.length, submissions);
490
+ }
491
+ if (typed) {
492
+ assert.deepEqual(result.native_failure, failure.raw);
493
+ assert.equal(result.replayability, failure.replayability);
494
+ assert.equal(result.next_action, failure.nextAction);
495
+ } else {
496
+ assert.equal(result.native_failure, undefined);
497
+ assert.equal(result.replayability, undefined);
498
+ assert.equal(result.next_action, undefined);
499
+ assert.doesNotMatch(JSON.stringify(result), /must not be projected|private stdout|private stderr/);
500
+ }
501
+ });
502
+ }
503
+ }
504
+
447
505
  test("grouped capture forecasts once, reaches a four-reviewer barrier, and reconciles unclosed submissions", async (t) => {
448
506
  t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
449
507
  const cwd = repository(t), lineageId = "relay-group", inputs = groupInputs(lineageId), lenses = inputs.map((input) => input.arguments.find((argument) => argument.name === "lens")!.value);
@@ -1343,3 +1401,89 @@ test("a pinned profile missing a required group role is refused typed before any
1343
1401
  assert.equal(modelLookups, 0, "the typed refusal fires before any model resolution");
1344
1402
  assert.equal(authAttempts, 0, "the typed refusal fires before any provider auth");
1345
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) {
@@ -0,0 +1,258 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import type { AssistantMessage, JsonObject, Tool, ToolCall } from "@earendil-works/pi-ai";
8
+ import type { NativeReviewCli } from "../lib/native-review-cli.ts";
9
+
10
+ // gentle-pi S11 T1: with receipt-driven development off, the orchestrator only
11
+ // ever calls gentle_review ASSESS, so the parent request carries a short ASSESS
12
+ // guideline and leaves the capture, capture-group, and scope declarations out.
13
+ // With RDD on, the request is exactly what the registered tools declare. Both
14
+ // paths run through a real Pi SDK session and inspect the provider request.
15
+
16
+ const HIDDEN_WHEN_OFF = ["gentle_review_capture", "gentle_review_capture_group", "gentle_review_scope"] as const;
17
+
18
+ interface RecordedRequest { systemPrompt: string; tools: Tool[] }
19
+ interface SessionRun {
20
+ requests: RecordedRequest[];
21
+ registered: Map<string, { description: string; promptGuidelines?: readonly string[] }>;
22
+ assessResults: Array<{ isError: boolean; details: unknown }>;
23
+ errors: string[];
24
+ }
25
+
26
+ type RddMode = "on" | "off" | "throws";
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
+
34
+ function nativeCli(mode: () => RddMode): NativeReviewCli {
35
+ return {
36
+ reviewMode: async () => {
37
+ const effective = mode();
38
+ if (effective === "throws") throw new Error("native review mode is unavailable");
39
+ return { operation: "status", scope: "clone", status: { global: effective, cloneLocal: "", effective, source: "global" } };
40
+ },
41
+ assess: async () => ({
42
+ schema: "gentle-ai.review-assessment/v1",
43
+ risk: "high",
44
+ reviewDue: true,
45
+ reviewDueReason: "high_risk",
46
+ nextTransition: NATIVE_CONTINUATION,
47
+ reasons: [],
48
+ changedPaths: 1,
49
+ changedLines: 3,
50
+ candidate: { kind: "current-changes", baseRef: undefined },
51
+ }),
52
+ } as unknown as NativeReviewCli;
53
+ }
54
+
55
+ async function runSession(
56
+ root: string,
57
+ label: string,
58
+ bindings: Record<string, string>,
59
+ mode: () => RddMode,
60
+ beforeSecondPrompt: () => void = () => {},
61
+ ): Promise<SessionRun> {
62
+ const sdk = await import("@earendil-works/pi-coding-agent");
63
+ const ai = await import("@earendil-works/pi-ai");
64
+ const { createGentleAiExtension } = await import("../extensions/gentle-ai.ts");
65
+ const cwd = join(root, `repo-${label}`);
66
+ mkdirSync(cwd);
67
+ const gitEnv = { PATH: process.env.PATH, HOME: root, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: join(root, "gitconfig") };
68
+ writeFileSync(gitEnv.GIT_CONFIG_GLOBAL, "");
69
+ execFileSync("git", ["init", "--initial-branch=fixture"], { cwd, env: gitEnv, stdio: "ignore" });
70
+ execFileSync("git", ["-c", "user.name=SDK Fixture", "-c", "user.email=fixture@example.invalid", "commit", "--allow-empty", "-m", "fixture"], { cwd, env: gitEnv, stdio: "ignore" });
71
+ const run: SessionRun = { requests: [], registered: new Map(), assessResults: [], errors: [] };
72
+ let next: { name: string; arguments: JsonObject } | undefined;
73
+ let calls = 0;
74
+ const model = { id: "driver", name: "Local driver", provider: "loadout-fixture", api: "loadout-fixture", baseUrl: "http://invalid.local", reasoning: false, input: ["text"], contextWindow: 1_000_000, maxTokens: 1024, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } } as const;
75
+ const manager = sdk.SessionManager.inMemory(cwd);
76
+ const settings = sdk.SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false }, cacheWarming: "off", packages: [] });
77
+ const runtime = await sdk.ModelRuntime.create({ credentials: new ai.InMemoryCredentialStore(), modelsPath: null, modelsStorePath: join(root, `models-${label}.json`), refreshOnCreate: false, allowModelNetwork: false });
78
+ const loader = new sdk.DefaultResourceLoader({
79
+ cwd, agentDir: bindings.PI_CODING_AGENT_DIR, settingsManager: settings,
80
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
81
+ extensionFactories: [(pi) => {
82
+ const registerTool = pi.registerTool.bind(pi);
83
+ pi.registerTool = ((definition: { name: string; description: string; promptGuidelines?: readonly string[] }) => {
84
+ run.registered.set(definition.name, definition);
85
+ return registerTool(definition as never);
86
+ }) as typeof pi.registerTool;
87
+ createGentleAiExtension({
88
+ nativeReviewCli: nativeCli(mode),
89
+ candidateViews: null,
90
+ processEnv: { ...bindings, GENTLE_AI_TELEMETRY: "0" },
91
+ resolveTelemetryTriggerBinary: () => { throw new Error("fixture"); },
92
+ })(pi);
93
+ pi.on("tool_result", (event) => {
94
+ if (event.toolName === "gentle_review") run.assessResults.push({ isError: event.isError, details: event.details });
95
+ });
96
+ pi.registerProvider("loadout-fixture", {
97
+ api: "loadout-fixture", apiKey: "fixture-only", baseUrl: "http://invalid.local",
98
+ models: [{ id: model.id, name: model.name, reasoning: false, input: ["text"], cost: model.cost, contextWindow: model.contextWindow, maxTokens: model.maxTokens }],
99
+ streamSimple(_model, context) {
100
+ run.requests.push({ systemPrompt: ai.getCurrentSystemPrompt(context.messages), tools: ai.getCurrentTools(context.messages) });
101
+ const stream = ai.createAssistantMessageEventStream();
102
+ const request = next;
103
+ next = undefined;
104
+ calls += 1;
105
+ const message: AssistantMessage = { role: "assistant", api: model.api, provider: model.provider, model: model.id, content: [], stopReason: "pending" as never, timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
106
+ queueMicrotask(() => {
107
+ stream.push({ type: "start", partial: message });
108
+ if (request) {
109
+ const toolCall: ToolCall = { type: "toolCall", id: `call-${calls}`, ...request };
110
+ message.content.push(toolCall);
111
+ stream.push({ type: "toolcall_start", contentIndex: 0, partial: message });
112
+ stream.push({ type: "toolcall_delta", contentIndex: 0, delta: JSON.stringify(request.arguments), partial: message });
113
+ stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial: message });
114
+ }
115
+ message.stopReason = request ? "toolUse" : "stop";
116
+ stream.push({ type: "done", reason: message.stopReason, message });
117
+ stream.end();
118
+ });
119
+ return stream;
120
+ },
121
+ });
122
+ }],
123
+ });
124
+ await loader.reload();
125
+ assert.deepEqual(loader.getExtensions().errors, []);
126
+ const { session } = await sdk.createAgentSession({
127
+ cwd, agentDir: bindings.PI_CODING_AGENT_DIR, modelRuntime: runtime, model: model as never, thinkingLevel: "off", resourceLoader: loader,
128
+ tools: ["read", "gentle_review", ...HIDDEN_WHEN_OFF], sessionManager: manager, settingsManager: settings,
129
+ });
130
+ try {
131
+ await session.bindExtensions({ mode: "json", onError: (error) => run.errors.push(error.error) });
132
+ next = { name: "gentle_review", arguments: { operation: "assess", input: { escalate: { item: 4, reason: "Fixture process concurrency boundary" } } } };
133
+ await session.prompt("Assess the current change.", { source: "interactive" });
134
+ beforeSecondPrompt();
135
+ await session.prompt("Report the plan.", { source: "interactive" });
136
+ } finally {
137
+ await session.abort();
138
+ session.dispose();
139
+ }
140
+ return run;
141
+ }
142
+
143
+ const LEAN_GUIDELINE = /gentle_review only for \{"operation":"assess"\}/;
144
+
145
+ function fixture(t: { after: (fn: () => void) => void }): { root: string; bindings: Record<string, string> } {
146
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-review-loadout-")));
147
+ const profile = join(root, "profile");
148
+ mkdirSync(profile);
149
+ const bindings = {
150
+ PI_CODING_AGENT_DIR: profile, PI_CODING_AGENT_SESSION_DIR: join(root, "sessions"),
151
+ GENTLE_PI_AGENT_HOME: profile, GENTLE_PI_CONFIG_HOME: join(root, "config"),
152
+ };
153
+ const previous = Object.fromEntries(Object.keys(bindings).map((key) => [key, process.env[key]]));
154
+ Object.assign(process.env, bindings);
155
+ t.after(() => {
156
+ for (const [key, value] of Object.entries(previous)) {
157
+ if (value === undefined) delete process.env[key];
158
+ else process.env[key] = value;
159
+ }
160
+ rmSync(root, { recursive: true, force: true });
161
+ });
162
+ return { root, bindings };
163
+ }
164
+
165
+ function registeredGuidelines(run: SessionRun): string[] {
166
+ const guidelines = ["gentle_review", ...HIDDEN_WHEN_OFF].flatMap((name) => run.registered.get(name)?.promptGuidelines ?? []);
167
+ assert.ok(guidelines.length > 0);
168
+ return guidelines;
169
+ }
170
+
171
+ function assertLeanRequest(request: RecordedRequest, run: SessionRun): void {
172
+ assert.match(request.systemPrompt, /Receipt-driven development: off /);
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/);
179
+ for (const guideline of registeredGuidelines(run)) assert.equal(request.systemPrompt.includes(guideline), false, `RDD off must leave out: ${guideline.slice(0, 80)}`);
180
+ const names = request.tools.map((tool) => tool.name);
181
+ assert.ok(names.includes("gentle_review"));
182
+ for (const hidden of HIDDEN_WHEN_OFF) assert.equal(names.includes(hidden), false, `${hidden} is not declared while RDD is off`);
183
+ assert.doesNotMatch(request.tools.find((tool) => tool.name === "gentle_review")!.description, /gentle_review_capture/);
184
+ }
185
+
186
+ function assertRegisteredRequest(request: RecordedRequest, run: SessionRun): void {
187
+ for (const guideline of registeredGuidelines(run)) assert.ok(request.systemPrompt.includes(guideline), `the registered loadout keeps: ${guideline.slice(0, 80)}`);
188
+ assert.doesNotMatch(request.systemPrompt, LEAN_GUIDELINE);
189
+ for (const name of ["gentle_review", ...HIDDEN_WHEN_OFF]) {
190
+ const declared = request.tools.find((tool) => tool.name === name);
191
+ assert.ok(declared, `${name} is declared`);
192
+ assert.equal(declared.description, run.registered.get(name)?.description);
193
+ }
194
+ }
195
+
196
+ test("RDD off: the parent request carries only the lean ASSESS loadout; RDD on: the registered review loadout", { timeout: 30_000 }, async (t) => {
197
+ const { root, bindings } = fixture(t);
198
+ const off = await runSession(root, "off", bindings, () => "off");
199
+ const on = await runSession(root, "on", bindings, () => "on");
200
+ assert.deepEqual(off.errors, []);
201
+ assert.deepEqual(on.errors, []);
202
+ assert.equal(off.requests.length >= 3, true, "assess turn, tool result turn, follow-up turn");
203
+
204
+ for (const request of off.requests) assertLeanRequest(request, off);
205
+ // The prefix is stable across turns of the same session.
206
+ assert.equal(new Set(off.requests.map((request) => request.systemPrompt)).size, 1);
207
+ assert.equal(new Set(off.requests.map((request) => JSON.stringify(request.tools))).size, 1);
208
+ // ASSESS stays callable and returns its plan.
209
+ assert.equal(off.assessResults.length, 1);
210
+ assert.equal(off.assessResults[0].isError, false);
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);
219
+
220
+ for (const request of on.requests) {
221
+ assert.match(request.systemPrompt, /Receipt-driven development: on /);
222
+ assertRegisteredRequest(request, on);
223
+ }
224
+ assert.equal(on.assessResults.length, 1);
225
+ assert.equal(on.assessResults[0].isError, false);
226
+
227
+ const bytes = (request: RecordedRequest) => ({
228
+ systemPrompt: Buffer.byteLength(request.systemPrompt),
229
+ tools: Buffer.byteLength(JSON.stringify(request.tools)),
230
+ });
231
+ t.diagnostic(`RDD off request bytes: ${JSON.stringify(bytes(off.requests[0]))}`);
232
+ t.diagnostic(`RDD on request bytes: ${JSON.stringify(bytes(on.requests[0]))}`);
233
+ });
234
+
235
+ test("an unknown RDD line keeps the registered loadout; turning RDD on mid-session restores it at the next run", { timeout: 30_000 }, async (t) => {
236
+ const { root, bindings } = fixture(t);
237
+ const { __testing } = await import("../extensions/gentle-ai.ts");
238
+ __testing.clearRddStatusMemoForTesting();
239
+ t.after(() => __testing.clearRddStatusMemoForTesting());
240
+
241
+ const unknown = await runSession(root, "unknown", bindings, () => "throws");
242
+ assert.deepEqual(unknown.errors, []);
243
+ for (const request of unknown.requests) {
244
+ assert.match(request.systemPrompt, /Receipt-driven development: unknown/);
245
+ assertRegisteredRequest(request, unknown);
246
+ }
247
+
248
+ let mode: RddMode = "off";
249
+ const flipped = await runSession(root, "flip", bindings, () => mode, () => {
250
+ mode = "on";
251
+ __testing.clearRddStatusMemoForTesting();
252
+ });
253
+ assert.deepEqual(flipped.errors, []);
254
+ const last = flipped.requests.at(-1)!;
255
+ for (const request of flipped.requests.slice(0, -1)) assertLeanRequest(request, flipped);
256
+ assert.match(last.systemPrompt, /Receipt-driven development: on /);
257
+ assertRegisteredRequest(last, flipped);
258
+ });
@@ -0,0 +1,132 @@
1
+ import assert from "node:assert/strict";
2
+ import childProcess from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import fs from "node:fs";
5
+ import { syncBuiltinESMExports } from "node:module";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import test from "node:test";
8
+ import { createCandidateOwner, prepareCandidateOwnerParent } from "../lib/review-candidate-view-owner.ts";
9
+
10
+ function fixture(t: test.TestContext) {
11
+ fs.mkdirSync(resolve("tmp"), { recursive: true });
12
+ const base = fs.realpathSync(fs.mkdtempSync(join(resolve("tmp"), "owner-identity-")));
13
+ const commonDir = join(base, "common");
14
+ const parent = join(commonDir, "gentle-ai", "candidate-views");
15
+ fs.mkdirSync(parent, { recursive: true });
16
+ const boundary = [commonDir, dirname(parent), parent];
17
+ let user = "S-1-5-21-1-2-3-1001";
18
+ let identityFailure = "";
19
+ let replacedMarker = false;
20
+ const probes = { user: 0, administrator: 0, owner: [] as string[], dacl: [] as string[], enforce: [] as string[] };
21
+ const native = fs.realpathSync.native;
22
+ t.mock.method(fs.realpathSync, "native", (path: string) =>
23
+ path.startsWith("\\\\?\\GLOBALROOT\\SystemRoot\\System32") ? path : native(path));
24
+ t.mock.method(childProcess, "execFileSync", (command: string, args: string[], options: any) => {
25
+ const name = command.split(/[\\/]/).at(-1);
26
+ if (name === "whoami.exe") {
27
+ probes.user++;
28
+ return identityFailure === "user" ? "unavailable" : user;
29
+ }
30
+ if (name === "icacls.exe") {
31
+ probes.dacl.push(args[0]);
32
+ const directory = fs.lstatSync(args[0]).isDirectory();
33
+ const flags = directory ? "OICI" : "ID";
34
+ const dacl = `D:${directory ? "P" : ""}(A;${flags};FA;;;${user})(A;${flags};FA;;;SY)(A;${flags};FA;;;BA)`;
35
+ fs.writeFileSync(join(options.cwd, args[2]), dacl, "utf16le");
36
+ return "";
37
+ }
38
+ if (name === "powershell.exe") {
39
+ const script = Buffer.from(args.at(-1)!, "base64").toString("utf16le");
40
+ if (script.includes("RawSecurityDescriptor")) {
41
+ probes.administrator++;
42
+ return identityFailure === "administrator" ? "unavailable" : "S-1-5-21-1-2-3-500";
43
+ }
44
+ if (script.includes("SetAccessControl")) {
45
+ probes.enforce.push(options.env.GENTLE_PI_CANDIDATE_ACL_PATH);
46
+ return "";
47
+ }
48
+ const path = options.env.GENTLE_PI_CANDIDATE_OWNER_PATH;
49
+ probes.owner.push(path);
50
+ return replacedMarker && path.endsWith(".owner.json") ? "S-1-5-21-9-8-7-1002" : user;
51
+ }
52
+ throw new Error(`Unexpected subprocess: ${name}`);
53
+ });
54
+ syncBuiltinESMExports();
55
+ t.after(() => {
56
+ t.mock.restoreAll();
57
+ syncBuiltinESMExports();
58
+ fs.rmSync(base, { recursive: true, force: true });
59
+ });
60
+ return { commonDir, parent, boundary, probes,
61
+ changeUser: () => { user = "S-1-5-21-4-5-6-1002"; },
62
+ failIdentity: (kind: string) => { identityFailure = kind; },
63
+ replaceMarker: () => { replacedMarker = true; } };
64
+ }
65
+
66
+ test("Windows creation reuses identity only within one operation and preserves every filesystem probe", (t) => {
67
+ const f = fixture(t);
68
+ for (let operation = 1; operation <= 2; operation++) {
69
+ if (operation === 2) f.changeUser();
70
+ const root = join(f.parent, randomUUID());
71
+ const owner = createCandidateOwner(f.commonDir, root, "win32");
72
+ const marker = `${root}.owner.json`;
73
+ assert.equal(owner.root, root);
74
+ assert.deepEqual(JSON.parse(fs.readFileSync(marker, "utf8")), owner);
75
+ assert.equal(fs.existsSync(root), false);
76
+ assert.equal(f.probes.user, operation, "one fresh user SID per creation");
77
+ assert.equal(f.probes.administrator, operation, "one fresh administrator SID per creation");
78
+ assert.deepEqual(f.probes.owner.slice((operation - 1) * 4), [...f.boundary, marker]);
79
+ assert.deepEqual(f.probes.dacl.slice((operation - 1) * 4), [...f.boundary, marker]);
80
+ assert.deepEqual(f.probes.enforce, []);
81
+ }
82
+ });
83
+
84
+ test("Windows preparation retains fresh pre-write and post-write owner/DACL checks", (t) => {
85
+ const f = fixture(t);
86
+ assert.equal(prepareCandidateOwnerParent(f.commonDir, "win32"), f.parent);
87
+ assert.equal(f.probes.user, 1);
88
+ assert.equal(f.probes.administrator, 1);
89
+ assert.deepEqual(f.probes.owner, [...f.boundary, ...f.boundary.flatMap(path => [path, path])]);
90
+ assert.deepEqual(f.probes.dacl, f.boundary);
91
+ assert.deepEqual(f.probes.enforce, f.boundary);
92
+ });
93
+
94
+ for (const kind of ["user", "administrator"]) test(`Windows creation fails closed on fresh ${kind} identity failure`, (t) => {
95
+ const f = fixture(t);
96
+ const first = createCandidateOwner(f.commonDir, join(f.parent, randomUUID()), "win32");
97
+ const marker = `${first.root}.owner.json`;
98
+ const before = fs.readFileSync(marker, "utf8");
99
+ const stored = fs.readdirSync(f.parent);
100
+ f.failIdentity(kind);
101
+ const root = join(f.parent, randomUUID());
102
+ assert.throws(() => createCandidateOwner(f.commonDir, root, "win32"), /SID is unavailable/);
103
+ assert.equal(fs.existsSync(root), false);
104
+ assert.equal(fs.existsSync(`${root}.owner.json`), false);
105
+ assert.equal(fs.readFileSync(marker, "utf8"), before);
106
+ assert.deepEqual(fs.readdirSync(f.parent), stored);
107
+ assert.equal(f.probes.owner.length, 4);
108
+ assert.equal(f.probes.dacl.length, 4);
109
+ });
110
+
111
+ test("Windows creation rollback rechecks a replaced marker owner before removal", (t) => {
112
+ const f = fixture(t);
113
+ const write = fs.writeFileSync;
114
+ const root = join(f.parent, randomUUID());
115
+ const marker = `${root}.owner.json`;
116
+ t.mock.method(fs, "writeFileSync", (...args: any[]) => {
117
+ if (typeof args[0] === "number") {
118
+ f.replaceMarker();
119
+ throw new Error("fixture write failure");
120
+ }
121
+ return (write as any)(...args);
122
+ });
123
+ syncBuiltinESMExports();
124
+ assert.throws(() => createCandidateOwner(f.commonDir, root, "win32"), /fixture write failure/);
125
+ assert.equal(f.probes.user, 1);
126
+ assert.equal(f.probes.administrator, 1);
127
+ assert.deepEqual(f.probes.owner, [...f.boundary, marker, marker]);
128
+ assert.deepEqual(f.probes.dacl, [...f.boundary, marker]);
129
+ assert.equal(fs.existsSync(marker), true, "unproven marker survives rollback");
130
+ assert.equal(fs.readFileSync(marker, "utf8"), "");
131
+ assert.equal(fs.existsSync(root), false);
132
+ });