gentle-pi 4.0.0 → 4.2.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 (425) hide show
  1. package/README.md +157 -18
  2. package/assets/agents/gentle-ai-explore.md +11 -4
  3. package/assets/agents/gentle-ai-verify.md +23 -4
  4. package/assets/agents/gentle-ai-worker.md +6 -6
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +70 -163
  9. package/assets/orchestrator-memory.md +11 -4
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-tracking.md +53 -0
  12. package/assets/orchestrator-verification.md +28 -0
  13. package/assets/orchestrator-writer.md +38 -0
  14. package/assets/orchestrator.md +29 -25
  15. package/assets/sounds/LICENSE.md +52 -0
  16. package/assets/sounds/attention.wav +0 -0
  17. package/assets/sounds/error.wav +0 -0
  18. package/assets/sounds/success.wav +0 -0
  19. package/bin/engram-guard +9 -0
  20. package/bin/gentle-shell-install.mjs +302 -0
  21. package/bin/gentle-shell.mjs +708 -22
  22. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  23. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  24. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  25. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +399 -0
  30. package/docs/delegated-verification.md +3 -5
  31. package/docs/gentle-agents-activity.md +513 -0
  32. package/docs/gentle-shell-uninstall.md +136 -0
  33. package/docs/gentle-shell.md +90 -33
  34. package/docs/install-wizard.md +2040 -0
  35. package/docs/native-authority-architecture.md +1 -1
  36. package/docs/permission-modes.md +127 -0
  37. package/docs/prompt-history.md +1 -1
  38. package/docs/readme-reference.md +310 -47
  39. package/docs/render-cache-performance.md +28 -0
  40. package/docs/session-profile-format.md +39 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/yolo-mode.md +11 -10
  44. package/extensions/ask-user-choice.ts +4 -0
  45. package/extensions/ask-user-question.ts +6 -1
  46. package/extensions/child-context.ts +11 -2
  47. package/extensions/child-safety.ts +4 -2
  48. package/extensions/codegraph-tools.ts +18 -5
  49. package/extensions/gentle-agents.ts +718 -87
  50. package/extensions/gentle-ai.ts +991 -263
  51. package/extensions/gentle-herdr-activity.ts +81 -0
  52. package/extensions/gentle-notifications.ts +104 -0
  53. package/extensions/gentle-shell.ts +271 -80
  54. package/extensions/gentle-todo.ts +123 -18
  55. package/extensions/history/index.ts +11 -6
  56. package/extensions/pi-pretty.ts +39 -3
  57. package/extensions/quiet-tools.ts +91 -21
  58. package/extensions/skill-registry.ts +175 -46
  59. package/extensions/startup-banner.ts +11 -2
  60. package/lib/agent-home.ts +10 -1
  61. package/lib/agent-model-resolution.ts +80 -0
  62. package/lib/agent-profiles.ts +13 -3
  63. package/lib/agents-completion-delivery.ts +11 -3
  64. package/lib/agents-messaging.ts +1 -1
  65. package/lib/agents-protocol.ts +40 -1
  66. package/lib/agents-runner.ts +90 -15
  67. package/lib/auto-permission-answers.ts +133 -0
  68. package/lib/auto-permission-classifier.ts +459 -0
  69. package/lib/auto-permission-evidence.ts +88 -0
  70. package/lib/auto-permission-memory.ts +69 -0
  71. package/lib/auto-permission-runtime.ts +484 -0
  72. package/lib/auto-permission-tool-context.ts +61 -0
  73. package/lib/background-jobs-tools.ts +202 -0
  74. package/lib/background-jobs.ts +253 -0
  75. package/lib/background-monitor.ts +154 -0
  76. package/lib/bounded-writer-admission.ts +83 -10
  77. package/lib/builtin-codemode-optout.ts +166 -0
  78. package/lib/card-content-policy.ts +85 -0
  79. package/lib/child-package-injection.ts +62 -0
  80. package/lib/codemode-renderer.ts +14 -4
  81. package/lib/command-palette-catalog.ts +3 -2
  82. package/lib/context-threshold.ts +26 -0
  83. package/lib/destructive-command-guard.ts +104 -28
  84. package/lib/gentle-ai-binary.ts +109 -4
  85. package/lib/gentle-shell-bundled.ts +535 -0
  86. package/lib/gentle-shell-launcher.ts +75 -24
  87. package/lib/gentle-shell-startup-flow.ts +54 -0
  88. package/lib/gentle-shell-startup-update.ts +92 -0
  89. package/lib/gentle-shell-uninstall.ts +840 -0
  90. package/lib/gentle-shell-update-panel.ts +231 -0
  91. package/lib/herdr-activity.ts +183 -0
  92. package/lib/inprocess-reviewer.ts +3 -0
  93. package/lib/jobs-sidebar-state.ts +20 -0
  94. package/lib/jobs-view.ts +161 -0
  95. package/lib/model-price-ratio.ts +83 -0
  96. package/lib/model-routing-authority.ts +6 -1
  97. package/lib/nan-provider.ts +33 -11
  98. package/lib/native-review-cli.ts +194 -73
  99. package/lib/notification-audio-native.ts +162 -0
  100. package/lib/notification-audio-windows.ts +399 -0
  101. package/lib/notification-audio.ts +332 -0
  102. package/lib/notification-customize.ts +241 -0
  103. package/lib/notification-events.ts +113 -0
  104. package/lib/notification-policy.ts +148 -0
  105. package/lib/notification-pulse-client.ts +367 -0
  106. package/lib/notification-pulse-protocol.ts +305 -0
  107. package/lib/notification-pulse-stream.ts +213 -0
  108. package/lib/notification-pulse-worker.ts +66 -0
  109. package/lib/notification-scheduler.ts +173 -0
  110. package/lib/notification-service.ts +202 -0
  111. package/lib/notification-ui.ts +87 -0
  112. package/lib/odd-phase.ts +1 -1
  113. package/lib/orchestrator-alias.ts +20 -0
  114. package/lib/orchestrator-catalog.ts +79 -0
  115. package/lib/orchestrator-consultation.ts +61 -0
  116. package/lib/orchestrator-discovery.ts +49 -0
  117. package/lib/orchestrator-helper-consent.ts +122 -0
  118. package/lib/orchestrator-helper.ts +167 -0
  119. package/lib/orchestrator-message-card.ts +105 -0
  120. package/lib/orchestrator-presence.ts +115 -14
  121. package/lib/orchestrator-scope.ts +80 -0
  122. package/lib/orchestrator-state.ts +107 -0
  123. package/lib/orchestrator-tool-card.ts +280 -0
  124. package/lib/orchestrator-work-search.ts +218 -0
  125. package/lib/orchestrator-work.ts +79 -0
  126. package/lib/permission-mode-policy.ts +59 -0
  127. package/lib/review-candidate-view-owner.ts +8 -6
  128. package/lib/review-candidate-view.ts +3 -2
  129. package/lib/review-compact-contract.ts +2 -2
  130. package/lib/review-host-relay.ts +7 -2
  131. package/lib/review-integration-v2.ts +100 -22
  132. package/lib/review-publication-gate.ts +1 -1
  133. package/lib/review-risk-assessment.ts +77 -51
  134. package/lib/review-sidebar-state.ts +2 -1
  135. package/lib/review-transaction.ts +72 -3
  136. package/lib/runtime-metrics-children.ts +1 -1
  137. package/lib/runtime-metrics-native.ts +3 -2
  138. package/lib/selection-engine.ts +22 -7
  139. package/lib/session-change-capture.ts +5 -4
  140. package/lib/session-changes.ts +27 -22
  141. package/lib/session-profile-binding.ts +83 -0
  142. package/lib/session-profile-freeze.ts +261 -0
  143. package/lib/session-profile-persistence.ts +187 -0
  144. package/lib/session-worktree-registry.ts +74 -8
  145. package/lib/shell-bar.ts +41 -11
  146. package/lib/shell-changes-view.ts +40 -13
  147. package/lib/shell-changes.ts +88 -71
  148. package/lib/shell-prompt.ts +6 -4
  149. package/lib/shell-sidebar-layout.ts +5 -1
  150. package/lib/shell-todo.ts +81 -26
  151. package/lib/shell-usage.ts +4 -3
  152. package/lib/status-timing-diagnostics.ts +201 -0
  153. package/lib/telemetry-trigger.ts +3 -1
  154. package/lib/vim-editor-adapter.ts +27 -5
  155. package/lib/visual-customization-policy.ts +20 -9
  156. package/lib/visual-customize-view.ts +171 -20
  157. package/lib/writer-surfaces.ts +162 -0
  158. package/lib/yolo-session-policy.ts +198 -121
  159. package/package.json +5 -5
  160. package/runtime/child-package-injection.mjs +63 -0
  161. package/runtime/gentle-ai-binary.mjs +109 -4
  162. package/runtime/gentle-shell-bundled.mjs +536 -0
  163. package/runtime/gentle-shell-launcher.mjs +74 -23
  164. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  165. package/runtime/gentle-shell-startup-update.mjs +93 -0
  166. package/runtime/gentle-shell-uninstall.mjs +841 -0
  167. package/runtime/gentle-shell-update-panel.mjs +232 -0
  168. package/runtime/native-review-cli.mjs +191 -70
  169. package/runtime/review-integration-v2.mjs +100 -22
  170. package/runtime/review-risk-assessment.mjs +77 -51
  171. package/runtime/status-timing-diagnostics.mjs +202 -0
  172. package/runtime/telemetry-trigger.mjs +3 -1
  173. package/scripts/bootstrap.cmd +255 -0
  174. package/scripts/bootstrap.sh +168 -0
  175. package/scripts/build-distribution.mjs +157 -0
  176. package/scripts/build-installer-bundles.mjs +136 -0
  177. package/scripts/build-runtime-modules.mjs +7 -0
  178. package/scripts/bundled-install.mjs +1308 -0
  179. package/scripts/bundled-wizard.mjs +399 -0
  180. package/scripts/check-types.mjs +4 -6
  181. package/scripts/gentle-ai-installer.mjs +79 -32
  182. package/scripts/install-gentle-ai.mjs +48 -1
  183. package/scripts/install-tui-mode-setting.mjs +10 -1
  184. package/scripts/install-wizard-preview.mjs +154 -0
  185. package/scripts/installer-downloads.mjs +648 -0
  186. package/scripts/installer-preflight.mjs +387 -0
  187. package/scripts/installer-probes.mjs +564 -0
  188. package/scripts/installer-runner.mjs +1102 -0
  189. package/scripts/installer-server.mjs +881 -0
  190. package/scripts/installer-windows-artifacts.json +15 -0
  191. package/scripts/installer-windows.mjs +752 -0
  192. package/scripts/main-channel.mjs +452 -0
  193. package/scripts/npm-pack-result.mjs +31 -0
  194. package/scripts/pi-update.mjs +223 -0
  195. package/scripts/render-cache-performance-worker.mjs +205 -0
  196. package/scripts/render-cache-performance.mjs +115 -0
  197. package/scripts/run-test-suite.mjs +22 -8
  198. package/scripts/test-installer-acceptance.sh +161 -0
  199. package/scripts/test-packed-runner.mjs +23 -5
  200. package/scripts/types-baseline.json +2 -92
  201. package/scripts/verify-package-files.mjs +82 -18
  202. package/skills/gentle-ai/SKILL.md +12 -11
  203. package/tests/agent-home.test.ts +56 -3
  204. package/tests/agent-model-resolution.test.ts +113 -0
  205. package/tests/agent-profiles.test.ts +12 -0
  206. package/tests/agent-risk-escalation.test.ts +123 -0
  207. package/tests/agents-completion-delivery.test.ts +17 -0
  208. package/tests/agents-protocol.test.ts +57 -0
  209. package/tests/agents-queries.test.ts +9 -9
  210. package/tests/agents-runner-process.test.ts +31 -0
  211. package/tests/agents-runner.test.ts +89 -4
  212. package/tests/append-system-prompt-route.test.ts +18 -1
  213. package/tests/auto-mode-human-answers.test.ts +68 -0
  214. package/tests/auto-mode-invariants.test.ts +276 -0
  215. package/tests/auto-mode-sdk.test.ts +84 -0
  216. package/tests/auto-mode.test.ts +49 -0
  217. package/tests/auto-permission-answers.test.ts +170 -0
  218. package/tests/auto-permission-classifier.test.ts +634 -0
  219. package/tests/auto-permission-compound.test.ts +24 -0
  220. package/tests/auto-permission-evidence.test.ts +88 -0
  221. package/tests/auto-permission-memory.test.ts +46 -0
  222. package/tests/auto-permission-public-regressions.test.ts +111 -0
  223. package/tests/auto-permission-runtime.test.ts +454 -0
  224. package/tests/autonomous-guard.test.ts +86 -16
  225. package/tests/background-cache-warming.test.ts +2 -1
  226. package/tests/background-jobs.test.ts +227 -0
  227. package/tests/background-monitor.test.ts +195 -0
  228. package/tests/background-subagents-default-mode.test.ts +28 -5
  229. package/tests/bounded-stop-contract.test.ts +85 -0
  230. package/tests/bounded-writer-admission.test.ts +52 -1
  231. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  232. package/tests/bridge-wake-rendering.test.ts +56 -0
  233. package/tests/build-distribution.test.ts +269 -0
  234. package/tests/builtin-codemode-optout.test.ts +242 -0
  235. package/tests/bundled-install.test.ts +1319 -0
  236. package/tests/bundled-wizard.test.ts +505 -0
  237. package/tests/card-content-policy.test.ts +68 -0
  238. package/tests/card-style-policy.test.ts +34 -2
  239. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  240. package/tests/child-package-entrypoints.test.ts +65 -0
  241. package/tests/child-package-injection.test.ts +74 -0
  242. package/tests/child-safety.test.ts +12 -0
  243. package/tests/child-web-package.test.ts +224 -0
  244. package/tests/codegraph-tools.test.ts +35 -0
  245. package/tests/codemode-rendering.test.ts +107 -2
  246. package/tests/command-palette.test.ts +5 -5
  247. package/tests/context-threshold.test.ts +31 -0
  248. package/tests/delegate-for-reason-contract.test.ts +327 -0
  249. package/tests/delegated-key-learnings-contract.test.ts +3 -2
  250. package/tests/devbinary/native-review-parity.devtest.ts +1 -1
  251. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  252. package/tests/engram-safety.test.ts +316 -0
  253. package/tests/generic-agent-tools.test.ts +16 -1
  254. package/tests/gentle-agents.test.ts +1895 -76
  255. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  256. package/tests/gentle-ai-binary.test.ts +19 -2
  257. package/tests/gentle-ai-child-guards.test.ts +202 -0
  258. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  259. package/tests/gentle-ai-installer.test.ts +548 -58
  260. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  261. package/tests/gentle-ai-renderer.test.ts +6 -6
  262. package/tests/gentle-ai.test.ts +999 -31
  263. package/tests/gentle-herdr-activity.test.ts +100 -0
  264. package/tests/gentle-notifications.test.ts +427 -0
  265. package/tests/gentle-shell-bin.test.ts +263 -19
  266. package/tests/gentle-shell-bundled.test.ts +1036 -0
  267. package/tests/gentle-shell-launcher.test.ts +251 -25
  268. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  269. package/tests/gentle-shell-startup-update.test.ts +85 -0
  270. package/tests/gentle-shell-uninstall.test.ts +978 -0
  271. package/tests/gentle-shell-update-panel.test.ts +158 -0
  272. package/tests/gentle-shell.test.ts +611 -100
  273. package/tests/gentle-todo.test.ts +317 -14
  274. package/tests/herdr-activity.test.ts +243 -0
  275. package/tests/history-agent-home.test.ts +81 -0
  276. package/tests/history-child-guard.test.ts +70 -0
  277. package/tests/inprocess-reviewer.test.ts +20 -0
  278. package/tests/install-gentle-ai-failure.test.ts +69 -0
  279. package/tests/install-tui-mode-setting.test.ts +15 -0
  280. package/tests/install-wizard.test.ts +1223 -0
  281. package/tests/installer-bundles.test.ts +122 -0
  282. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  283. package/tests/installer-preflight.test.ts +750 -0
  284. package/tests/installer-probes.test.ts +1106 -0
  285. package/tests/installer-runner.test.ts +2159 -0
  286. package/tests/installer-server.test.ts +1567 -0
  287. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  288. package/tests/jobs-view.test.ts +160 -0
  289. package/tests/main-channel-upgrade.test.ts +395 -0
  290. package/tests/main-channel.test.ts +344 -0
  291. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  292. package/tests/model-price-ratio.test.ts +116 -0
  293. package/tests/model-profiles-preservation.test.ts +153 -0
  294. package/tests/model-routing-package.test.ts +10 -0
  295. package/tests/nan-provider-runtime.test.ts +156 -0
  296. package/tests/nan-provider.test.ts +28 -18
  297. package/tests/native-binary-gate.test.ts +2 -0
  298. package/tests/native-review-capability-contract.test.ts +7 -1
  299. package/tests/native-review-cli.test.ts +236 -1
  300. package/tests/native-review-consent.test.ts +2 -2
  301. package/tests/native-review-parity-runtime.test.ts +1 -1
  302. package/tests/native-review-parity.test.ts +12 -48
  303. package/tests/native-risk-evidence-parity.test.ts +21 -0
  304. package/tests/no-self-pause-contract.test.ts +87 -0
  305. package/tests/notification-audio-native.test.ts +281 -0
  306. package/tests/notification-audio-windows.test.ts +581 -0
  307. package/tests/notification-audio.test.ts +548 -0
  308. package/tests/notification-customize.test.ts +655 -0
  309. package/tests/notification-events.test.ts +115 -0
  310. package/tests/notification-policy.test.ts +201 -0
  311. package/tests/notification-pulse-client.test.ts +356 -0
  312. package/tests/notification-pulse-protocol.test.ts +213 -0
  313. package/tests/notification-pulse-stream.test.ts +320 -0
  314. package/tests/notification-scheduler.test.ts +258 -0
  315. package/tests/notification-ui.test.ts +157 -0
  316. package/tests/npm-pack-result.test.ts +41 -0
  317. package/tests/odd-integration.test.ts +14 -5
  318. package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
  319. package/tests/odd-routing-contract.test.ts +80 -23
  320. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  321. package/tests/orchestrator-budget.test.ts +29 -6
  322. package/tests/orchestrator-catalog.test.ts +146 -0
  323. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  324. package/tests/orchestrator-consultation.test.ts +111 -0
  325. package/tests/orchestrator-discovery.test.ts +241 -0
  326. package/tests/orchestrator-helper-consent.test.ts +171 -0
  327. package/tests/orchestrator-helper.test.ts +297 -0
  328. package/tests/orchestrator-message-card.test.ts +190 -0
  329. package/tests/orchestrator-presence.test.ts +23 -0
  330. package/tests/orchestrator-rdd-ownership.test.ts +6 -5
  331. package/tests/orchestrator-scope.test.ts +107 -0
  332. package/tests/orchestrator-state.test.ts +114 -0
  333. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  334. package/tests/orchestrator-tool-card.test.ts +398 -0
  335. package/tests/orchestrator-work-search.test.ts +318 -0
  336. package/tests/orchestrator-work.test.ts +147 -0
  337. package/tests/package-manifest.test.ts +105 -21
  338. package/tests/packed-runner-owned-path.test.ts +35 -1
  339. package/tests/parallel-reason-contract.test.ts +21 -0
  340. package/tests/parallel-writer-admission.test.ts +210 -0
  341. package/tests/permission-mode-policy.test.ts +194 -0
  342. package/tests/persona-single-channel.test.ts +4 -12
  343. package/tests/pi-pretty.test.ts +56 -0
  344. package/tests/pi-update-launcher.test.ts +87 -0
  345. package/tests/pi-update.test.ts +186 -0
  346. package/tests/provider-defect-handoff.test.ts +4 -3
  347. package/tests/quiet-tool-rendering.test.ts +160 -3
  348. package/tests/rdd-aware-verification-contract.test.ts +29 -41
  349. package/tests/rdd-status-line.test.ts +52 -0
  350. package/tests/render-cache-performance.test.ts +48 -0
  351. package/tests/review-agent-end-preflight.test.ts +62 -10
  352. package/tests/review-candidate-view.test.ts +101 -12
  353. package/tests/review-contract-prompt.test.ts +33 -4
  354. package/tests/review-controller-native-recovery.test.ts +327 -17
  355. package/tests/review-controller-native-routing.test.ts +255 -1
  356. package/tests/review-controller-workspace-root.test.ts +43 -2
  357. package/tests/review-controller.test.ts +2 -2
  358. package/tests/review-correction-lifecycle.test.ts +2 -1
  359. package/tests/review-gate.test.ts +7 -4
  360. package/tests/review-graph-schema.test.ts +2 -2
  361. package/tests/review-host-relay-routing.test.ts +5 -3
  362. package/tests/review-host-relay.test.ts +49 -2
  363. package/tests/review-integration-v2-forward.test.ts +21 -1
  364. package/tests/review-json-arguments.test.ts +167 -0
  365. package/tests/review-last-event-closure.test.ts +157 -8
  366. package/tests/review-ledger-contract.test.ts +5 -4
  367. package/tests/review-policy-judgment-day.test.ts +1 -1
  368. package/tests/review-policy-ordinary.test.ts +2 -2
  369. package/tests/review-relay-transport-agent.test.ts +66 -1
  370. package/tests/review-risk-assessment.test.ts +109 -33
  371. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  372. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  373. package/tests/review-test-fixtures.ts +7 -1
  374. package/tests/review-transaction.test.ts +18 -1
  375. package/tests/run-test-suite.test.ts +83 -5
  376. package/tests/runtime-harness.mjs +42 -15
  377. package/tests/runtime-metrics-extension.test.ts +1 -1
  378. package/tests/runtime-metrics-native.test.ts +31 -1
  379. package/tests/selection-engine.test.ts +65 -5
  380. package/tests/session-change-capture.test.ts +10 -10
  381. package/tests/session-changes-shell.test.ts +242 -48
  382. package/tests/session-changes.test.ts +17 -24
  383. package/tests/session-profile-binding.test.ts +163 -0
  384. package/tests/session-profile-freeze.test.ts +394 -0
  385. package/tests/session-profile-persistence.test.ts +334 -0
  386. package/tests/session-subject-routing-contract.test.ts +34 -0
  387. package/tests/session-worktree-registry.test.ts +57 -2
  388. package/tests/shell-bar.test.ts +40 -0
  389. package/tests/shell-changes-view.test.ts +85 -34
  390. package/tests/shell-changes.test.ts +79 -62
  391. package/tests/shell-render-cache-runtime.test.ts +69 -0
  392. package/tests/shell-render-cache.test.ts +129 -0
  393. package/tests/shell-sidebar-layout.test.ts +27 -2
  394. package/tests/shell-todo.test.ts +132 -11
  395. package/tests/shell-usage-view.test.ts +1 -1
  396. package/tests/shell-usage.test.ts +35 -0
  397. package/tests/short-check-routing-contract.test.ts +49 -0
  398. package/tests/skill-registry-watcher.test.ts +119 -0
  399. package/tests/skill-registry.test.ts +311 -5
  400. package/tests/startup-banner.test.ts +117 -3
  401. package/tests/startup-home-repo.test.ts +170 -0
  402. package/tests/status-timing-diagnostics.test.ts +283 -0
  403. package/tests/support/orchestrator-modules.ts +19 -0
  404. package/tests/task-size-routing-contract.test.ts +241 -0
  405. package/tests/telemetry-trigger.test.ts +27 -1
  406. package/tests/test-strength-contract.test.ts +124 -0
  407. package/tests/verify-agent-contract.test.ts +90 -0
  408. package/tests/verify-first-launch-contract.test.ts +139 -0
  409. package/tests/verify-package-files.test.ts +56 -0
  410. package/tests/vim-editor-adapter.test.ts +47 -21
  411. package/tests/visual-customization-policy.test.ts +32 -3
  412. package/tests/visual-customize-view.test.ts +288 -10
  413. package/tests/writer-edit-surface-scope.test.ts +59 -2
  414. package/tests/writer-surfaces.test.ts +199 -0
  415. package/tests/yolo-customize.test.ts +18 -3
  416. package/tests/yolo-mode-runtime.test.ts +37 -10
  417. package/tests/yolo-mode.test.ts +38 -5
  418. package/tests/yolo-session-policy.test.ts +1 -1
  419. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  420. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  421. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  422. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  423. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  424. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  425. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -1,14 +1,18 @@
1
1
  import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
2
- import { recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts";
2
+ import { dataArgumentRanges, recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts";
3
+ import { SHELL_COMMAND_TOOLS } from "../lib/background-jobs.ts";
3
4
  import { blockChildDestructiveCommand } from "./child-safety.ts";
4
- import { allowedEditSurfaces as hasTaskScopedAllowedEditSurfaces, bindSessionRepositoryPreparation, captureBoundSessionRepositoryAuthority, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sourcePathWithinProject } from "../lib/bounded-writer-admission.ts";
5
+ import { allowedEditSurfaces as hasTaskScopedAllowedEditSurfaces, bindSessionRepositoryPreparation, captureBoundSessionRepositoryAuthority, isHomeOrBroadUserDirectory, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sourcePathWithinProject } from "../lib/bounded-writer-admission.ts";
5
6
  import { consumeReviewMutation, pendingReviewMutation, pendingReviewMutationProfiles, recordReviewMutation, type ReceiptSession } from "../lib/review-reminder-receipt.ts";
6
7
  import { createReviewSidebarPublisher } from "../lib/review-sidebar-state.ts";
8
+ import { StatusTimingDiagnostics } from "../lib/status-timing-diagnostics.ts";
7
9
  import { isOddPhase, oddPhaseRegistry, ODD_PHASES } from "../lib/odd-phase.ts";
10
+ import { shellEnabled } from "../lib/shell-bar.ts";
8
11
  import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
9
12
  import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
10
13
  import { execFileSync } from "node:child_process";
11
14
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
15
+ import { isProxy } from "node:util/types";
12
16
  import {
13
17
  existsSync,
14
18
  lstatSync,
@@ -50,6 +54,7 @@ import { installPackageAssets, getPackageAssetOwner, hasPackageAssetOwnerInstall
50
54
  import {
51
55
  THINKING_LEVELS,
52
56
  normalizeModelConfig,
57
+ isThinkingLevel,
53
58
  normalizeModelId,
54
59
  normalizeRoutingEntry,
55
60
  readSavedModelConfig as readModelRoutingAuthority,
@@ -98,6 +103,7 @@ import {
98
103
  type ProfilePinSource,
99
104
  type ProfilePinStatus,
100
105
  } from "../lib/agent-profile-pin.ts";
106
+ import { bindSessionProfile, readSessionProfileBinding } from "../lib/session-profile-binding.ts";
101
107
  import {
102
108
  applyOrchestratorSettings,
103
109
  readOrchestratorSettings,
@@ -172,12 +178,16 @@ import {
172
178
  createNativeReviewCli,
173
179
  createNodeExecFileAdapter,
174
180
  isCanonicalProcessString,
181
+ isNativeReviewAbandonReason,
182
+ NATIVE_REVIEW_ABANDON_REASONS,
175
183
  isNativeReviewUnachievableVerbRefused,
176
184
  nativeReviewAbandonAuthorization,
177
185
  nativeReviewLegacyAliasRepairAuthorization,
178
186
  nativeReviewLegacyQuarantineAuthorization,
179
187
  nativeReviewReconcileAuthorization,
180
188
  nativeReviewRecoverAuthorization,
189
+ NATIVE_REVIEW_AUTHORITY_STATUS,
190
+ type NativeReviewAuthorityEntry,
181
191
 
182
192
  NativeReviewCliError,
183
193
  nativeUntrackedSelection,
@@ -211,6 +221,9 @@ import {
211
221
  verificationPlan,
212
222
  isSmallWriterProfile,
213
223
  resolveWriterProfile,
224
+ decodeAgentRiskEscalation,
225
+ escalatedRisk,
226
+ HIGH_RISK_ITEMS,
214
227
  RDD_LINE,
215
228
  WRITER_PROFILE,
216
229
  VERIFICATION_TIER,
@@ -223,6 +236,7 @@ import {
223
236
  type ReviewDueReason,
224
237
  type NativeReviewOutcome,
225
238
  type WriterProfile,
239
+ type AgentRiskEscalation,
226
240
  } from "../lib/review-risk-assessment.ts";
227
241
  import {
228
242
  assertReviewApprovedAcknowledgementExecuteV1,
@@ -235,6 +249,8 @@ import {
235
249
  } from "../lib/review-integration-v2.ts";
236
250
  import { reconcileUnknownReviewLastEventCapture } from "../lib/review-last-event-controller.ts";
237
251
  import { registerYoloSessionPolicy, updateYoloPrompt } from "../lib/yolo-session-policy.ts";
252
+ import { AutoPermissionRuntime, updateAutoPrompt, type AutoRuntimeClassifier } from "../lib/auto-permission-runtime.ts";
253
+ import { AutoPermissionClassifier } from "../lib/auto-permission-classifier.ts";
238
254
  import { acquireChildStandingReviewPermissionClient, type ChildStandingReviewPermissionClient } from "../lib/review-session-standing-permission-ipc.ts";
239
255
  import { isPiConsentV3, presentReviewConsentUi } from "../lib/review-consent-ui.ts";
240
256
  import {
@@ -362,6 +378,7 @@ type BackgroundSubagentsCapability = "ready" | "absent";
362
378
  interface BackgroundSubagentsRendering {
363
379
  policy: BackgroundSubagentsPolicy;
364
380
  capability: BackgroundSubagentsCapability;
381
+ singleShot?: boolean;
365
382
  }
366
383
 
367
384
  const DEFAULT_BACKGROUND_SUBAGENTS_RENDERING: BackgroundSubagentsRendering = {
@@ -705,9 +722,17 @@ function resolveBackgroundSubagentsCapability(
705
722
  function renderBackgroundSubagentsStatusLine(
706
723
  background: BackgroundSubagentsRendering,
707
724
  ): string {
725
+ if (background.singleShot) return `Background subagent policy: off (single-shot mode)`;
708
726
  return `Background subagent policy: ${background.policy} (capability: ${background.capability})`;
709
727
  }
710
728
 
729
+ // gentle-shell#1731 T27: `pi -p` and `pi --mode json` run one prompt and then
730
+ // dispose the runtime, so background results can never arrive. Mirrors
731
+ // isSingleShotMode in extensions/gentle-agents.ts, which rejects the launch.
732
+ function isSingleShotHostMode(mode: string | undefined): boolean {
733
+ return mode === "print" || mode === "json";
734
+ }
735
+
711
736
  /**
712
737
  * A `status` object is only trusted when `effective` is exactly `on`/`off`
713
738
  * and `source` is one of the exported `NATIVE_REVIEW_MODE_SOURCE` values.
@@ -846,6 +871,11 @@ async function readRddModeStatusOnce(
846
871
  interface ReviewAssessmentPlanDetails {
847
872
  schema: "gentle-pi.review-assessment-plan/v1";
848
873
  risk: VerificationTier;
874
+ // gentle-shell#1494: the native tier before any agent escalation. `risk`
875
+ // differs from it only when the agent raised the candidate to high.
876
+ nativeRisk: VerificationTier;
877
+ // `applied` is false when the native tier was already high or unassessable.
878
+ agentEscalation?: { item: number; label: string; reason: string; applied: boolean };
849
879
  reasons: readonly ReviewAssessmentReason[];
850
880
  changedPaths: number;
851
881
  changedLines: number;
@@ -1000,7 +1030,10 @@ async function resolveReviewAssessmentPlan(
1000
1030
  }
1001
1031
  }
1002
1032
 
1003
- const risk: VerificationTier = assessment?.risk ?? VERIFICATION_TIER.UNASSESSABLE;
1033
+ const nativeRisk: VerificationTier = assessment?.risk ?? VERIFICATION_TIER.UNASSESSABLE;
1034
+ // gentle-shell#1494: an agent escalation raises passive/medium to high and
1035
+ // never lowers; the native tier stays visible as `nativeRisk`.
1036
+ const risk = escalatedRisk(nativeRisk, input.escalate);
1004
1037
  // gentle-pi#668: the memo is read only for THIS candidate's own target
1005
1038
  // identity, never repository-only. It is still needed when the caller
1006
1039
  // claims `closed`, because a recorded decline beats closure (gentle-pi#1175).
@@ -1012,6 +1045,10 @@ async function resolveReviewAssessmentPlan(
1012
1045
  return {
1013
1046
  schema: "gentle-pi.review-assessment-plan/v1",
1014
1047
  risk,
1048
+ nativeRisk,
1049
+ ...(input.escalate === undefined
1050
+ ? {}
1051
+ : { agentEscalation: { item: input.escalate.item, label: HIGH_RISK_ITEMS[input.escalate.item], reason: input.escalate.reason, applied: risk !== nativeRisk } }),
1015
1052
  // No path is known for an unassessable candidate: omit it rather than
1016
1053
  // emitting an empty string the native reason shape forbids.
1017
1054
  reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: unassessableCode, detail: unassessableDetail }]),
@@ -1038,6 +1075,22 @@ async function resolveReviewAssessmentPlan(
1038
1075
 
1039
1076
  let rddStatusUnavailableWarned = false;
1040
1077
 
1078
+ // S2 (startup-home-repo-and-changes): a repository rooted at HOME, an ancestor
1079
+ // of it or a broad user folder makes every Git call scan the user's whole
1080
+ // tree. Warn once per process and repository, never per session restart.
1081
+ const HOME_REPOSITORY_WARNING = "Gentle Shell is running inside a Git repository rooted at your home folder (or a broad user folder). Every Git operation scans that whole tree, which makes startup and reviews very slow. Open Gentle Shell inside a project folder, or remove the stray .git if you did not create it on purpose.";
1082
+ const homeRepositoryWarnings = new Set<string>();
1083
+
1084
+ // An unresolvable HOME or a resolver failure is unknown, not a match: the
1085
+ // bootstrap guard fails closed there, but startup must not warn or skip work.
1086
+ function homeRootedRepository(cwd: string): string | undefined {
1087
+ try {
1088
+ const home = realpathSync.native(homedir());
1089
+ const root = resolveSessionWorktree(cwd, cwd)?.root;
1090
+ return root !== undefined && isHomeOrBroadUserDirectory(root, { home }) ? root : undefined;
1091
+ } catch { return undefined; }
1092
+ }
1093
+
1041
1094
  /**
1042
1095
  * Best-effort native RDD mode status read for prompt rendering, memoized per
1043
1096
  * cwd for `RDD_STATUS_MEMO_TTL_MS`. Reuses the `reviewMode` STATUS reader
@@ -1045,9 +1098,11 @@ let rddStatusUnavailableWarned = false;
1045
1098
  * `NativeReviewModeStatus`) that the `/gentle:review-mode` command also
1046
1099
  * calls. Never throws and never hangs past `signal`'s deadline when one is
1047
1100
  * given: an absent binary, a timed-out/aborted process, or a native CLI
1048
- * failure all resolve to `undefined`. `ctx` is optional and used only for a
1049
- * one-shot (per process) UI notice when the read is swallowed, so a
1050
- * sustained native outage is observable beyond the rendered "unknown" line.
1101
+ * failure all resolve to `undefined` unless an earlier read for the same cwd
1102
+ * succeeded, in which case that last known status is kept (gentle-shell#1029:
1103
+ * the rendered line sits in the cached prompt prefix). `ctx` is optional and
1104
+ * used only for a one-shot (per process) UI notice when the read is
1105
+ * swallowed, so a sustained native outage is observable beyond the prompt.
1051
1106
  */
1052
1107
  async function resolveRddModeStatus(
1053
1108
  nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
@@ -1059,13 +1114,19 @@ async function resolveRddModeStatus(
1059
1114
  const nowMs = now();
1060
1115
  const cached = rddStatusMemo.get(cwd);
1061
1116
  if (cached !== undefined && cached.expiresAt > nowMs) return cached.status;
1062
- const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
1117
+ // gentle-shell#1029: the rendered line is part of the cached system-prompt
1118
+ // prefix. A re-read that fails or times out keeps the last successfully
1119
+ // read status instead of flipping the prompt to "unknown" and back, which
1120
+ // would invalidate the provider's prefix cache for the whole conversation.
1121
+ // Only a status the native reader actually reports replaces a known one.
1122
+ const read = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
1123
+ const status = read ?? cached?.status;
1063
1124
  rddStatusMemo.set(cwd, { status, expiresAt: nowMs + RDD_STATUS_MEMO_TTL_MS });
1064
- if (status === undefined && !rddStatusUnavailableWarned) {
1125
+ if (read === undefined && !rddStatusUnavailableWarned) {
1065
1126
  rddStatusUnavailableWarned = true;
1066
1127
  if (ctx?.hasUI) {
1067
1128
  ctx.ui.notify(
1068
- "Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt renders \"unknown\" until this recovers; this notice will not repeat this session.",
1129
+ "Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt keeps the last known status, or renders \"unknown\" when none was read; this notice will not repeat this session.",
1069
1130
  "warning",
1070
1131
  );
1071
1132
  }
@@ -1073,6 +1134,11 @@ async function resolveRddModeStatus(
1073
1134
  return status;
1074
1135
  }
1075
1136
 
1137
+ /** True only for a validated `off` line; `unknown` and `on` are never off. */
1138
+ function isRddStatusLineOff(line: string | undefined): boolean {
1139
+ return line !== undefined && line.startsWith("Receipt-driven development: off ");
1140
+ }
1141
+
1076
1142
  /** Resolves and renders the RDD status line for a production call site in one call. */
1077
1143
  async function resolveRddStatusLine(
1078
1144
  nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
@@ -1097,12 +1163,14 @@ function getOrchestratorPrompt(
1097
1163
  cwd: string = process.cwd(),
1098
1164
  activeTools?: readonly string[],
1099
1165
  rddStatusLine: string = renderRddStatusLine(undefined),
1166
+ hostMode?: string,
1100
1167
  ): string {
1101
1168
  const background: BackgroundSubagentsRendering = {
1102
1169
  policy: loadBackgroundSubagentsPolicy(cwd),
1103
1170
  capability: resolveBackgroundSubagentsCapability(cwd, activeTools),
1171
+ singleShot: isSingleShotHostMode(hostMode),
1104
1172
  };
1105
- const cacheKey = `${background.policy}:${background.capability}:${rddStatusLine}`;
1173
+ const cacheKey = `${background.policy}:${background.capability}:${background.singleShot}:${rddStatusLine}`;
1106
1174
  let prompt = orchestratorPromptCache.get(cacheKey);
1107
1175
  if (prompt === undefined) {
1108
1176
  prompt = renderOrchestratorPrompt(ASSETS_DIR, background, rddStatusLine);
@@ -1221,6 +1289,7 @@ function buildGentlePrompt(
1221
1289
  cwd: string = process.cwd(),
1222
1290
  activeTools?: readonly string[],
1223
1291
  rddStatusLine: string = renderRddStatusLine(undefined),
1292
+ hostMode?: string,
1224
1293
  ): string {
1225
1294
  const personaPrompt =
1226
1295
  persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT;
@@ -1245,34 +1314,44 @@ ${personaPrompt}
1245
1314
 
1246
1315
  ${languageBoundary}
1247
1316
 
1317
+ ## Authorized work continuity
1318
+
1319
+ An authorized outcome includes its necessary in-scope implementation, documentation and verification steps. Continue those steps without asking the human to approve each transition. Never end a turn merely announcing an immediately executable authorized next step; execute it instead. A progress update is not a request for permission.
1320
+
1321
+ Stop only for an unresolved consequential scope/product decision, required explicit consent, a denied action, unavailable required proof, or the human's explicit stop. Do not infer permission for publishing, destructive actions, unrelated scope or native consent from this continuity rule. Report a concrete blocker and the exact decision needed rather than requesting a generic "continue".
1322
+
1323
+ For background work, launch the bounded action and end the turn when no independent in-scope work remains. Retain its identity and resume the already-authorized continuation automatically on its completion event; do not ask the human to say "continue", poll, or claim completion while checks remain pending.
1324
+
1248
1325
  Default workflow: Organic Driven Development (MANDATORY)
1249
1326
  Organic Driven Development (ODD) is the predefined workflow of this orchestrator. Every request enters it, without the user asking for a workflow, a plan, or task tracking. Never describe this workflow only when asked about it: run it. Run these steps in this order on every request:
1250
- 1. **Authorize.** Investigation, explanation, review, comparison, and proposal-only requests stay read-only: no writer, apply, or implementation artifacts. Ambiguous or conditional change intent gets one clarification; stop and wait.
1251
- 2. **Explore.** Explore existing code and requirements first, proportionately to the request, before proposing or writing anything.
1327
+ 1. **Authorize.** Investigation, explanation, review, comparison, and proposal-only requests stay read-only: no writer, apply, or implementation artifacts. Ambiguous or conditional change intent (unclear whether a change is authorized at all) gets one clarification; stop and wait. A user saying they may stop you or resume later asks for notes and separate commits, not a stop.
1328
+ 2. **Explore.** Explore existing code and requirements first, proportionately to the request, before proposing or writing anything. Do not delegate exploration of files you will read anyway to work inline; explore only for a map you need to decide or route.
1252
1329
  3. **Resolve uncertainty.** Recommend optional research only for a named uncertainty; ask one focused user question only for a real unresolved product decision, then stop and wait; use at most one scoped read-only assumption challenge for a high-consequence unproven premise.
1253
- 4. **Classify.** The work is substantial when exploration yields two or more meaningful implementation steps, or progress worth recovering after an interruption. Small, understood work stays small and creates no durable task artifacts.
1254
- 5. **Track before the first write.** For substantial authorized implementation, create \`odd/tasks/<feature-name>.md\` and its Engram mirror \`odd/<feature-name>/tasks\` automatically, then create or rebuild the visible \`todo\` list from the reconciled feature tasks, all before the first source write and without asking permission for tasks or storage. Tell the user in one line which feature document was created and how many tasks it holds.
1255
- 6. **Implement task by task.** Route each task through the orchestrator's Work Routing Ladder, honoring its mandatory delegation triggers, with applicable test-first development and checks. These triggers are mandatory, not advisory: executing past a fired trigger inline is a routing defect even if the work succeeds. Check an item off only after its outcome and checks were observed; update the file, mirror, and visible \`todo\` projection after every task transition and material plan change. Every task closes with at least one work-unit commit on the feature branch, branch first when on the default branch, with tests and docs alongside the behavior, using a Conventional Commit message; record the commit identity in the feature document as evidence. Work-unit commits on the feature branch are part of authorized substantial ODD implementation; push, pull request creation, and merge remain the user's decisions.
1256
- 7. **Close.** Report the verified outcome, every failed, skipped, or pending check, and the next step. The native review candidate is a work-unit commit or a PR slice, never a TODO checkbox and never the accumulated feature branch; native review runs only under the user-owned RDD switch.
1330
+ 4. **Classify.** Size the task by the orchestrator's Task Size section: small when understood, risk is contained, and the work could be resumed from the original request and \`git diff\` alone; large only when that resume test fails. Never classify by counting files, commands, tests, fixes, or a requested todo list. Small work stays inline and creates no durable task artifacts.
1331
+ 5. **Track before the first write.** For large authorized implementation, create \`odd/tasks/<feature-name>.md\` and its Engram mirror \`odd/<feature-name>/tasks\` automatically, then create or rebuild the visible \`todo\` list from the reconciled feature tasks, all before the first source write and without asking permission for tasks or storage. Tell the user in one line which feature document was created and how many tasks it holds. The document is the specification subagents read, in this order: a two- or three-line header; \`## Specs\` with numbered \`S#\` that quote the user's exact strings, error messages, and examples verbatim, never summarized and never adding unrequested requirements; \`## Tasks\` with one line per task (ID, linked \`S#\`, route, commit); \`## Log\` last, where \`L1\` is the user's original request verbatim and later user corrections, evidence, and decisions are appended. A requirement change appends its verbatim Log entry, rewrites only the affected \`S#\`, and reopens only its task.
1332
+ 6. **Implement task by task.** Hand off by reference, never by paraphrase: name the document, task, and specs (for example \`Spec: odd/tasks/<feature>.md, T2, S3-S4\`), tell workers to read until \`## Log\`, and ask which \`S#\` were covered. Without a feature document, include the user's request verbatim. Verify reads the whole document, runs the spec's examples the parent authorized, against isolated state when they mutate data, and returns a verdict per \`S#\`. When the user reports a failure, reproduce it before deciding it already works. Each test asserts every observable effect of the rule it covers (exit code, exact stdout and stderr, and that rejected input leaves stored data and counters unchanged), covers the cases the rule itself names (its examples, boundaries, and errors), and checks through the public interface, never internal storage. When you add or change a command, option, or message, update the help text and docs that describe it. Route each task through the orchestrator's Mechanisms, honoring its mandatory delegation triggers, with applicable test-first development and checks. These triggers are mandatory, not advisory: executing past a fired trigger inline is a routing defect even if the work succeeds. Check an item off only after its outcome and checks were observed; update the file, mirror, and visible \`todo\` projection after every task transition and material plan change. Every tracked task closes with at least one work-unit commit on the feature branch, branch first when on the default branch, with tests and docs alongside the behavior, using a Conventional Commit message; record the commit identity in the feature document as evidence. Work-unit commits on the feature branch are part of authorized large ODD implementation; push, pull request creation, and merge remain the user's decisions.
1333
+ 7. **Close.** Report the verified outcome, every failed, skipped, or pending check, and the next step. Before writing \`Risk: none\`, check whether your diff changes code that existing behavior the request did not mention also uses (shared options, parsers, helpers); if it does, that is item 3. Never end with a tracked task pending unless you quote the user's explicit stop. An applicable quick check runs once; an unavailable verifier or subagent is reported as unavailable, never retried or escalated into extra ceremony. Partial, blocked, unavailable, or exhausted proof becomes one **Needs your decision** result naming the open blockers or missing proof, never more verification; that result is a valid stop, hedged wording is not. The native review candidate is a work-unit commit or a PR slice, never a TODO checkbox and never the accumulated feature branch; native review runs only under the user-owned RDD switch.
1257
1334
  Phase reporting: the Gentle Shell prompt label is inferred automatically from the primary session's tool activity (reads show \`exploring\`, edits \`implementing\`, test runs \`checking\`, user questions \`deciding\`). When the \`gentle_odd_phase\` tool is available, use it to refine that label with phases tools cannot show (\`authorizing\`, \`researching\`, \`deciding\`, \`closing\`): call \`gentle_odd_phase\` only when the primary session's ODD phase actually changes, never per tool call or on a fixed cadence, and never from a subagent. It drives the Gentle Shell prompt label only.
1258
1335
  Resume an interrupted feature with \`mem_context\`, then project- and feature-scoped \`mem_search\`, then \`mem_get_observation\` for the full document, then the task file itself; reconcile before continuing the next unfinished task. Detail for steps 3–7: \`orchestrator-delegation.md\` and \`orchestrator-memory.md\`.
1259
1336
 
1260
1337
  Harness principles:
1261
1338
  - el Gentleman is not prompt engineering. It is runtime discipline around powerful agents.
1262
- - Organic Driven Development (ODD) is the predefined workflow for every request: authorize, explore, resolve uncertainty, classify, track substantial work before the first write, implement task by task with proportionate checks, close each task with a work-unit commit, and close.
1339
+ - Organic Driven Development (ODD) is the predefined workflow for every request: authorize, explore, resolve uncertainty, classify, track large work before the first write, implement task by task with proportionate checks, close each tracked task with a work-unit commit, and close.
1263
1340
  - Clarify scope, constraints, acceptance criteria, and non-goals before implementation.
1264
1341
  - Use subagents when available for exploration, planning, implementation, and review, while keeping one parent session responsible for orchestration.
1265
- - Keep writes single-threaded unless the user explicitly approves parallel write isolation.
1266
- - For behavior changes with applicable runnable deterministic tests and a clear expected outcome, use test-first by default: observe RED, GREEN, then refactor with focused checks. Test presence alone does not establish applicability; no chat or TUI toggle activates it. For passive documentation, non-testable changes, an unavailable runner, or no meaningful RED, explain why and run proportionate ordinary functional or structural verification. Never invent lifecycle evidence or skip checks. Follow orchestrator-delegation.md for ODD forwarding and evidence.
1342
+ - Parallel writers only with disjoint Allowed edit surfaces (runtime-enforced) or isolated worktrees.
1343
+ - For behavior changes with applicable runnable deterministic tests and a clear expected outcome, use test-first by default: observe RED, GREEN, then refactor with focused checks. Write one RED test per requested rule. For every existing command or option the change touches, add one test proving its previous behavior still holds; add no other cases. An existing behavior counts as touched when it shares the code you changed (options, parsers, helpers, validation). Test presence alone does not establish applicability; no chat or TUI toggle activates it. For passive documentation, non-testable changes, an unavailable runner, or no meaningful RED, explain why and run proportionate ordinary functional or structural verification. Never invent lifecycle evidence or skip checks. Follow orchestrator-delegation.md for ODD forwarding and evidence.
1267
1344
  - Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
1268
1345
  - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
1269
1346
 
1270
- ${getOrchestratorPrompt(cwd, activeTools, rddStatusLine)}`;
1347
+ ${getOrchestratorPrompt(cwd, activeTools, rddStatusLine, hostMode)}`;
1271
1348
  }
1272
1349
 
1273
1350
  // Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
1274
1351
  // between `git` and the subcommand. Short flags may be followed by a separate value token.
1275
- const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)* `;
1352
+ // gentle-shell#2172: `\s+` rather than a literal space, so a tab-separated
1353
+ // `git\tpush` is guarded like any other spelling.
1354
+ const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)*\s+`;
1276
1355
  const GIT_PUSH_RE = new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}(push)\b`);
1277
1356
 
1278
1357
  const DENIED_BASH_PATTERNS: RegExp[] = [
@@ -1338,7 +1417,7 @@ interface LoadGuardrailsOptions {
1338
1417
 
1339
1418
  const GUARDED_KEY_PATTERNS: Record<GuardedCommandKey, RegExp> = {
1340
1419
  gitPush: GIT_PUSH_RE,
1341
- gitRebase: /\bgit\s+(rebase)\b/,
1420
+ gitRebase: new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}(rebase)\b`),
1342
1421
  gitBranchDeleteForce: /\bgit\s+(branch)\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\r\n;&|]*--force\b|--force\b[^\r\n;&|]*--delete\b)/,
1343
1422
  npmPublish: /\bnpm\s+(publish)\b/,
1344
1423
  piRemove: /\bpi\s+(remove)\b/,
@@ -1380,12 +1459,15 @@ function collectGuardedMatches(
1380
1459
  config: RuntimeGuardrailsConfig,
1381
1460
  ): GuardMatch[] {
1382
1461
  const matches: GuardMatch[] = [];
1462
+ const dataRanges = dataArgumentRanges(command);
1383
1463
  for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [
1384
1464
  GuardedCommandKey,
1385
1465
  RegExp,
1386
1466
  ][]) {
1387
1467
  const globalPattern = new RegExp(pattern.source, `${pattern.flags}g`);
1388
1468
  for (const match of command.matchAll(globalPattern)) {
1469
+ // gentle-shell#2174: text inside a data command's argument is not an operation.
1470
+ if (inDataArgument(dataRanges, match.index)) continue;
1389
1471
  const action = config.autonomousMode
1390
1472
  ? (config.guardedCommands[key] ?? AUTONOMOUS_DEFAULT_ACTIONS[key])
1391
1473
  : "confirm";
@@ -1399,6 +1481,10 @@ function collectGuardedMatches(
1399
1481
  return matches.sort((left, right) => left.triggerIndex - right.triggerIndex);
1400
1482
  }
1401
1483
 
1484
+ function inDataArgument(ranges: ReadonlyArray<readonly [number, number]>, index: number): boolean {
1485
+ return ranges.some(([start, end]) => index >= start && index < end);
1486
+ }
1487
+
1402
1488
  function evaluateGuardedCommand(
1403
1489
  command: string,
1404
1490
  config: RuntimeGuardrailsConfig,
@@ -1409,8 +1495,10 @@ function evaluateGuardedCommand(
1409
1495
  if (hardDeny) return { action: "block", triggerIndex: hardDeny.triggerIndex, matches };
1410
1496
 
1411
1497
  // Hard denies override every configured action across the complete command.
1498
+ const dataRanges = dataArgumentRanges(command);
1412
1499
  for (const pattern of DENIED_BASH_PATTERNS) {
1413
- const denied = pattern.exec(command);
1500
+ const denied = [...command.matchAll(new RegExp(pattern.source, `${pattern.flags.replace("g", "")}g`))]
1501
+ .find((candidate) => !inDataArgument(dataRanges, candidate.index));
1414
1502
  if (!denied) continue;
1415
1503
  const matchedAction = matches.find((match) =>
1416
1504
  match.triggerIndex >= denied.index && match.triggerIndex < denied.index + denied[0].length,
@@ -1756,14 +1844,60 @@ function createHerdrConfirmationLifecycle(events: ExtensionAPI["events"]): Herdr
1756
1844
  };
1757
1845
  }
1758
1846
 
1759
- /** Conservative waiver surface: one plain push in the current repository, no shell/wrapper or destination-changing options. */
1760
- function isOrdinaryYoloPush(command: string, evaluation: GuardEvaluation): boolean {
1761
- if (evaluation.action !== "confirm" || evaluation.dataLoss || evaluation.matches.length !== 1 || evaluation.key !== "gitPush") return false;
1762
- return /^git\s+push(?:\s+(?:-u|--set-upstream|[A-Za-z0-9_][A-Za-z0-9_./-]*))*\s*$/.test(command);
1847
+ // gentle-shell#2172: YOLO waives reversible delivery, not only one bare push.
1848
+ // A guarded segment is judged from its trigger word (`push`/`rebase`) to the
1849
+ // next command separator; a plain token or allow-listed option list is the
1850
+ // whole eligible grammar, so quotes, wrappers and unknown options fall back
1851
+ // to the ordinary confirmation. Everything that deletes or rewrites a remote
1852
+ // ref, publishes, or runs arbitrary commands is deliberately absent:
1853
+ // `--delete`/`-d`, `:dst` and `+src` refspecs, `--mirror`, `--prune`,
1854
+ // `--all`, every force spelling, `--receive-pack`/`--exec`, `--repo`, `-o`.
1855
+ const YOLO_WAIVABLE_KEYS: ReadonlySet<GuardedCommandKey> = new Set(["gitPush", "gitRebase"]);
1856
+ const YOLO_SEGMENT_END_RE = /&&|\|\||;|\||\n|\r/;
1857
+ // Text between the segment start and the `push`/`rebase` word: optional
1858
+ // variable assignments, `env`/`command` prefixes and the `git` word with at
1859
+ // most a `-C <path>`. Other global options (`-c key=value`, `--exec-path`,
1860
+ // `--git-dir`) can run or redirect code, so they keep the confirmation.
1861
+ const YOLO_GIT_PREFIX_RE = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=[^\s'"`$]*\s+|env\s+(?:-i\s+)?|command\s+)*git(?:\s+-C\s+[^\s'"`$;&|]+)?\s+$/;
1862
+ const YOLO_PUSH_SEGMENT_RE = /^push(?:\s+(?:-u|--set-upstream|-q|--quiet|-v|--verbose|--tags|--follow-tags|--no-verify|--porcelain|[A-Za-z0-9_][A-Za-z0-9_./-]*(?::[A-Za-z0-9_][A-Za-z0-9_./-]*)?))*\s*$/;
1863
+ const YOLO_REBASE_SEGMENT_RE = /^rebase(?:\s+(?:--onto\s+[A-Za-z0-9_][A-Za-z0-9_./~^-]*|--continue|--abort|--skip|--autostash|--no-autostash|[A-Za-z0-9_][A-Za-z0-9_./~^-]*))*\s*$/;
1864
+
1865
+ function yoloGuardedSegment(command: string, triggerIndex: number): string {
1866
+ const rest = command.slice(triggerIndex);
1867
+ const end = rest.search(YOLO_SEGMENT_END_RE);
1868
+ return end < 0 ? rest : rest.slice(0, end);
1869
+ }
1870
+
1871
+ function yoloSegmentPrefix(command: string, triggerIndex: number): string {
1872
+ const head = command.slice(0, triggerIndex);
1873
+ const separators = [...head.matchAll(new RegExp(YOLO_SEGMENT_END_RE.source, "g"))];
1874
+ const last = separators.at(-1);
1875
+ return last === undefined ? head : head.slice(last.index + last[0].length);
1876
+ }
1877
+
1878
+ /** Every guarded match and every recognized Git side effect must be a reversible push or non-interactive rebase. */
1879
+ function isReversibleYoloCommand(command: string, evaluation: GuardEvaluation): boolean {
1880
+ if (evaluation.action !== "confirm" || evaluation.dataLoss || evaluation.matches.length === 0) return false;
1881
+ for (const match of evaluation.matches) {
1882
+ if (!YOLO_WAIVABLE_KEYS.has(match.key)) return false;
1883
+ if (!YOLO_GIT_PREFIX_RE.test(yoloSegmentPrefix(command, match.triggerIndex))) return false;
1884
+ const segment = yoloGuardedSegment(command, match.triggerIndex);
1885
+ if (!(match.key === "gitPush" ? YOLO_PUSH_SEGMENT_RE : YOLO_REBASE_SEGMENT_RE).test(segment)) return false;
1886
+ }
1887
+ // The data-loss recognizer leaves non-hard Git effects (reset, stash drop,
1888
+ // restore, checkout --) to the primary policy; under YOLO only a rebase that
1889
+ // a guarded match already vetted may remain.
1890
+ const rebaseTriggers = new Set(evaluation.matches.filter((match) => match.key === "gitRebase").map((match) => match.triggerIndex));
1891
+ for (const effect of recognizeDestructiveCommands(command)) {
1892
+ if (effect.hardDeny || effect.kind !== "git") return false;
1893
+ const rebase = /^git(?:\s+(?:-C|-c|--git-dir|--work-tree|--namespace)\s+\S+|\s+-\S+)*\s+(rebase)\b/.exec(command.slice(effect.triggerIndex));
1894
+ if (rebase === null || !rebaseTriggers.has(effect.triggerIndex + rebase.index + rebase[0].lastIndexOf("rebase"))) return false;
1895
+ }
1896
+ return true;
1763
1897
  }
1764
1898
 
1765
- /** Preserve explicitly configured confirmations even when legacy env/autonomy resolution ignores them. */
1766
- function yoloPushConfiguredRestriction(cwd: string, options: LoadGuardrailsOptions = {}): GuardAction | undefined {
1899
+ /** Preserve explicitly configured confirmations for the waivable keys present, even when legacy env/autonomy resolution ignores them. */
1900
+ function yoloConfiguredRestriction(cwd: string, keys: readonly GuardedCommandKey[], options: LoadGuardrailsOptions = {}): GuardAction | undefined {
1767
1901
  let restriction: GuardAction | undefined;
1768
1902
  for (const path of [join(options.gentlePiConfigHome ?? gentleAiConfigHome(), "runtime-guardrails.json"), join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) {
1769
1903
  try {
@@ -1773,9 +1907,11 @@ function yoloPushConfiguredRestriction(cwd: string, options: LoadGuardrailsOptio
1773
1907
  restriction ??= "confirm";
1774
1908
  continue;
1775
1909
  }
1776
- const action = isRecord(parsed.guardedCommands) ? parsed.guardedCommands.gitPush : undefined;
1777
- if (action === "block") return "block";
1778
- if (action !== undefined && action !== "allow") restriction = "confirm";
1910
+ for (const key of keys) {
1911
+ const action = isRecord(parsed.guardedCommands) ? parsed.guardedCommands[key] : undefined;
1912
+ if (action === "block") return "block";
1913
+ if (action !== undefined && action !== "allow") restriction = "confirm";
1914
+ }
1779
1915
  } catch { restriction ??= "confirm"; }
1780
1916
  }
1781
1917
  return restriction;
@@ -1787,14 +1923,16 @@ async function confirmCommand(
1787
1923
  events: ExtensionAPI["events"],
1788
1924
  herdrLifecycle: HerdrConfirmationLifecycle,
1789
1925
  yoloActive = false,
1926
+ autoGate?: () => Promise<ToolCallEventResult | undefined>,
1790
1927
  ): Promise<ToolCallEventResult | undefined> {
1791
1928
  const guardrailsConfig = loadRuntimeGuardrailsConfig(ctx.cwd);
1792
1929
  const evaluation = evaluateGuardedCommand(command, guardrailsConfig);
1793
1930
  // Configuration provenance is distinct from legacy autonomy resolution, which
1794
1931
  // can ignore confirmations when an environment override is present. Activation
1795
1932
  // cannot waive a restriction from either user or project configuration layer.
1796
- const configuredRestriction = yoloActive && evaluation.matches.some((match) => match.key === "gitPush")
1797
- ? yoloPushConfiguredRestriction(ctx.cwd) : undefined;
1933
+ const waivableKeys = evaluation.matches.map((match) => match.key).filter((key) => YOLO_WAIVABLE_KEYS.has(key));
1934
+ const configuredRestriction = yoloActive && waivableKeys.length > 0
1935
+ ? yoloConfiguredRestriction(ctx.cwd, waivableKeys) : undefined;
1798
1936
  const classification = evaluation.action === "block" || configuredRestriction === "block"
1799
1937
  ? "block" : configuredRestriction ?? evaluation.action;
1800
1938
 
@@ -1806,13 +1944,31 @@ async function confirmCommand(
1806
1944
  };
1807
1945
  }
1808
1946
 
1809
- if (classification === "not-guarded") return undefined;
1947
+ if (classification === "not-guarded") return autoGate?.();
1810
1948
 
1811
1949
  // classification is "allow" or "confirm" from this point on
1812
- if (classification === "allow") return undefined;
1950
+ if (classification === "allow") return autoGate?.();
1951
+
1952
+ // Auto may replace only a default confirmation, never an explicit user/project
1953
+ // ask/block rule or the independent data-loss confirmation.
1954
+ if (autoGate && !evaluation.dataLoss) {
1955
+ let explicit = false;
1956
+ for (const path of [join(gentleAiConfigHome(), "runtime-guardrails.json"), join(ctx.cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) {
1957
+ try {
1958
+ if (!existsSync(path)) continue;
1959
+ const settings: unknown = JSON.parse(readFileSync(path, "utf8"));
1960
+ if (!isRecord(settings) || (settings.guardedCommands !== undefined && !isRecord(settings.guardedCommands))) { explicit = true; continue; }
1961
+ if (isRecord(settings.guardedCommands) && evaluation.matches.some(match => {
1962
+ const action = (settings.guardedCommands as Record<string, unknown>)[match.key];
1963
+ return action !== undefined && action !== "allow";
1964
+ })) explicit = true;
1965
+ } catch { explicit = true; }
1966
+ }
1967
+ if (!explicit) return autoGate();
1968
+ }
1813
1969
 
1814
1970
  // Full T1 evaluation has already run. YOLO never changes persistent autonomy.
1815
- if (yoloActive && configuredRestriction === undefined && isOrdinaryYoloPush(command, evaluation)) return undefined;
1971
+ if (yoloActive && configuredRestriction === undefined && isReversibleYoloCommand(command, evaluation)) return undefined;
1816
1972
 
1817
1973
  // classification === "confirm"
1818
1974
  if (!ctx.hasUI) {
@@ -2157,6 +2313,11 @@ function readEffectiveModelConfig(cwd: string): AgentModelConfig {
2157
2313
  return pinnedEffectiveModelConfig(cwd) ?? readGlobalEffectiveModelConfig(cwd);
2158
2314
  }
2159
2315
 
2316
+ /** Resolve one complete reviewer routing snapshot without per-role fallback. */
2317
+ function readReviewerModelConfig(cwd: string, sessionId: string | undefined): AgentModelConfig {
2318
+ return readSessionProfileBinding(sessionId)?.modelProfiles ?? pinnedEffectiveModelConfig(cwd) ?? readModelConfig(cwd);
2319
+ }
2320
+
2160
2321
  /** The routing in effect once the pin is set aside: what a global save materializes. */
2161
2322
  function readGlobalEffectiveModelConfig(cwd: string): AgentModelConfig {
2162
2323
  const effective = cloneModelConfig(readModelConfig(cwd));
@@ -2172,7 +2333,21 @@ function readGlobalEffectiveModelConfig(cwd: string): AgentModelConfig {
2172
2333
  async function readEffectiveModelConfigAsync(cwd: string): Promise<AgentModelConfig> {
2173
2334
  const pinned = pinnedEffectiveModelConfig(cwd);
2174
2335
  if (pinned) return pinned;
2175
- const effective = cloneModelConfig(await readModelConfigAsync(cwd));
2336
+ return readGlobalEffectiveModelConfigFromAsync(cwd, await readModelConfigAsync(cwd));
2337
+ }
2338
+
2339
+ /**
2340
+ * The saved global routing merged with the materialized stores of every
2341
+ * discoverable agent it is silent about — the same effective view
2342
+ * `readEffectiveModelConfigAsync` builds, but starting from an already-read
2343
+ * saved routing so callers that must distinguish an unreadable authority can
2344
+ * keep that distinction while still seeing materialized routes.
2345
+ */
2346
+ async function readGlobalEffectiveModelConfigFromAsync(
2347
+ cwd: string,
2348
+ base: AgentModelConfig,
2349
+ ): Promise<AgentModelConfig> {
2350
+ const effective = cloneModelConfig(base);
2176
2351
  const profilesByPath = new Map<string, Record<string, unknown>>();
2177
2352
  for (const agent of await listDiscoverableAgentsAsync(cwd)) {
2178
2353
  if (isProviderReviewRole(agent.name) || agent.name in effective) continue;
@@ -2716,10 +2891,14 @@ export async function applySavedModelConfig(
2716
2891
  if (result.status === "invalid") {
2717
2892
  return { updated: 0, skipped: 0, invalidPath: result.path };
2718
2893
  }
2719
- return applyConfig(
2720
- ctx.cwd,
2721
- result.status === "valid" ? result.config : {},
2894
+ // Startup/reload has no deletion approval. Persisted inherit entries are
2895
+ // not instructions to erase routing, even if an agent is no longer present.
2896
+ // Explicit panel saves and confirmed profile replacement still apply clears.
2897
+ const config = result.status === "valid" ? result.config : {};
2898
+ const activationConfig = Object.fromEntries(
2899
+ Object.entries(config).filter(([, entry]) => !isClearRoutingEntry(entry)),
2722
2900
  );
2901
+ return applyConfig(ctx.cwd, activationConfig);
2723
2902
  }
2724
2903
 
2725
2904
  function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
@@ -3546,6 +3725,7 @@ function updateCurrentProfileFromSavedRouting(ctx: ExtensionContext, pi: Extensi
3546
3725
 
3547
3726
  type ProfilesPanelResult =
3548
3727
  | { type: "apply"; name: string }
3728
+ | { type: "apply-global"; name: string }
3549
3729
  | { type: "create" }
3550
3730
  | { type: "update"; name: string }
3551
3731
  | { type: "duplicate"; name: string }
@@ -3733,6 +3913,8 @@ class ProfilesPanel implements OverlayComponent {
3733
3913
  private readonly orchestratorSettings: OrchestratorSettingsReadResult;
3734
3914
  // Actions reopen the panel, refreshing this snapshot without disk reads during rendering.
3735
3915
  private readonly pinStatus: ProfilePinStatus | undefined;
3916
+ private readonly sessionBoundName: string | undefined;
3917
+ private readonly liveOrchestrator: (() => AgentRoutingEntry | undefined) | undefined;
3736
3918
 
3737
3919
  constructor(
3738
3920
  file: AgentProfilesFile,
@@ -3746,6 +3928,8 @@ class ProfilesPanel implements OverlayComponent {
3746
3928
  saveSnapshot: ProfilesSnapshotHandler,
3747
3929
  requestRender: () => void,
3748
3930
  pinStatus: () => ProfilePinStatus | undefined,
3931
+ sessionBound: () => string | undefined,
3932
+ liveOrchestrator?: () => AgentRoutingEntry | undefined,
3749
3933
  feedback?: string,
3750
3934
  ) {
3751
3935
  this.file = file;
@@ -3758,7 +3942,9 @@ class ProfilesPanel implements OverlayComponent {
3758
3942
  this.rows = rows;
3759
3943
  this.orchestratorSettings = orchestratorSettings;
3760
3944
  this.pinStatus = pinStatus();
3761
- const items = buildProfileListItems(file, evaluateProfilePin(this.pinStatus, file.profiles).winner?.profile);
3945
+ this.sessionBoundName = sessionBound();
3946
+ this.liveOrchestrator = liveOrchestrator;
3947
+ const items = buildProfileListItems(file, evaluateProfilePin(this.pinStatus, file.profiles).winner?.profile, this.sessionBoundName);
3762
3948
  this.listItems = items;
3763
3949
  this.list = new NativeChoiceList<ProfileListItem>(
3764
3950
  items,
@@ -3821,6 +4007,7 @@ class ProfilesPanel implements OverlayComponent {
3821
4007
  this.requestRender();
3822
4008
  return;
3823
4009
  }
4010
+ if (data === "a") return this.finish({ type: "apply-global", name });
3824
4011
  if (data === "d") return this.finish({ type: "duplicate", name });
3825
4012
  if (data === "r") return this.finish({ type: "rename", name });
3826
4013
  if (data === "x") return this.finish({ type: "delete", name });
@@ -3899,7 +4086,7 @@ class ProfilesPanel implements OverlayComponent {
3899
4086
  private refreshListItems(): void {
3900
4087
  // Keep the list instance (and its pointer observer) alive while refreshing the
3901
4088
  // mutable item records that NativeChoiceList already holds by reference.
3902
- for (const item of buildProfileListItems(this.file, evaluateProfilePin(this.pinStatus, this.file.profiles).winner?.profile)) {
4089
+ for (const item of buildProfileListItems(this.file, evaluateProfilePin(this.pinStatus, this.file.profiles).winner?.profile, this.sessionBoundName)) {
3903
4090
  const current = this.listItems.find((candidate) => candidate.id === item.id);
3904
4091
  if (current) Object.assign(current, item);
3905
4092
  }
@@ -3946,7 +4133,7 @@ class ProfilesPanel implements OverlayComponent {
3946
4133
 
3947
4134
  private renderFooterRow(width: number): string {
3948
4135
  const hints =
3949
- "enter apply · c create · s snapshot · d duplicate · r rename · x delete · e export · i import · p pin · P share · j/k line · ctrl+j/k page · esc close";
4136
+ "enter use in this session · a set as global default · c create · s snapshot · d duplicate · r rename · x delete · e export · i import · p pin · P share · j/k line · ctrl+j/k page · esc close";
3950
4137
  const text = this.feedback ?? hints;
3951
4138
  return [
3952
4139
  this.renderText("│", "border"),
@@ -3982,6 +4169,13 @@ class ProfilesPanel implements OverlayComponent {
3982
4169
  // any invalid or stale layer, and the scope sentence all come from the shared
3983
4170
  // precedence rule the launch resolver uses.
3984
4171
  ...profilePinDetailLines(this.pinStatus, this.file.profiles).map((line) => this.renderLine(line, width, "muted")),
4172
+ // gentle-shell#1064: the binding is stored for this session and outranks
4173
+ // the pin in the panel list, so it is named right after the pin layers.
4174
+ // Since #1558 launches resolve it ahead of pins and the global default,
4175
+ // and nothing was written to make that true.
4176
+ ...(this.sessionBoundName === undefined
4177
+ ? []
4178
+ : [this.renderLine(`session ${sanitizeTerminalText(this.sessionBoundName)} (session) — launches resolve it ahead of pins and the global default; nothing was written`, width, "muted")]),
3985
4179
  "",
3986
4180
  this.renderLine("Profile routing", width, "accent"),
3987
4181
  ...this.indentLines(this.routingLines(profileRows, widths), width),
@@ -3992,6 +4186,13 @@ class ProfilesPanel implements OverlayComponent {
3992
4186
  }
3993
4187
 
3994
4188
  private effectiveOrchestratorLabel(): string {
4189
+ // The now line answers what this session runs: the live orchestrator wins
4190
+ // when one exists, and the settings read only stands in for sessions
4191
+ // without a live model (and still surfaces an unreadable settings file).
4192
+ const live = this.liveOrchestrator?.();
4193
+ if (live !== undefined) {
4194
+ return formatOrchestratorSelection(live);
4195
+ }
3995
4196
  const settings = this.orchestratorSettings;
3996
4197
  if (settings.status === "invalid") {
3997
4198
  return `unreadable (${sanitizeTerminalText(settings.reason)})`;
@@ -4038,13 +4239,16 @@ class ProfilesPanel implements OverlayComponent {
4038
4239
  }
4039
4240
  }
4040
4241
 
4242
+ /** The full-screen profile picker: one visit per action, so every reopen reads fresh store, pin, orchestrator, and session-binding state. */
4041
4243
  async function showProfilesPanel(
4042
4244
  ctx: ExtensionContext,
4043
4245
  file: AgentProfilesFile,
4044
4246
  currentConfig: AgentModelConfig,
4045
4247
  selectedName: string | undefined,
4046
4248
  saveSnapshot: ProfilesSnapshotHandler,
4249
+ live: LiveSession,
4047
4250
  status?: string,
4251
+ sessionBoundName?: string,
4048
4252
  ): Promise<ProfilesPanelResult> {
4049
4253
  // Both orchestrator and pin state are snapshots for this panel visit.
4050
4254
  // Actions (including p/P) reopen the panel and read fresh state.
@@ -4063,6 +4267,8 @@ async function showProfilesPanel(
4063
4267
  saveSnapshot,
4064
4268
  () => tui.requestRender(),
4065
4269
  () => readProfilePinStatus(ctx.cwd),
4270
+ () => sessionBoundName,
4271
+ () => liveOrchestratorSnapshot(ctx, live)?.entry,
4066
4272
  status,
4067
4273
  );
4068
4274
  const container = createNativeFullscreenInteraction({
@@ -4110,15 +4316,15 @@ function reportProfilesDrops(ctx: ExtensionContext, path: string, drops: Profile
4110
4316
  }
4111
4317
 
4112
4318
  /** Pi's own live-session controls: the ExtensionAPI's setModel/setThinkingLevel. */
4113
- type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel">;
4319
+ type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel" | "getThinkingLevel">;
4114
4320
 
4115
4321
  /**
4116
4322
  * Switch the running session to the profile's orchestrator. `settings.json`
4117
4323
  * is the default for new sessions only; Pi's `setModel`/`setThinkingLevel`
4118
- * are what move the live one. Failures never undo the persisted default: the
4119
- * profile is applied for the next session either way, and the note says what
4120
- * this session did. Nothing here may throw — settings.json is already written
4121
- * and the apply must finish reporting.
4324
+ * are what move the live one without persisting global defaults. This helper
4325
+ * also serves explicit global apply, but never writes settings itself. Failures
4326
+ * keep the current live model and report what happened; callers retain their
4327
+ * routing snapshot or persisted default independently.
4122
4328
  */
4123
4329
  async function switchLiveOrchestrator(ctx: ExtensionContext, live: LiveSession, entry: AgentRoutingEntry): Promise<string> {
4124
4330
  const reference = parseOrchestratorModelRef(entry.model);
@@ -4149,17 +4355,37 @@ async function switchLiveOrchestrator(ctx: ExtensionContext, live: LiveSession,
4149
4355
  return `\nThis session now runs on ${label} · ${entry.thinking}.`;
4150
4356
  }
4151
4357
 
4358
+ /**
4359
+ * The live session's orchestrator entry, when a model is running. `s` captures
4360
+ * what the session actually runs rather than the default new sessions would
4361
+ * get from settings.json; an unreadable thinking level still snapshots the
4362
+ * model, just without a thinking claim.
4363
+ */
4364
+ /** The live session's orchestrator: a snapshot result shaped for `s`, not a settings-file read. */
4365
+ interface LiveOrchestratorSnapshot {
4366
+ readonly status: "valid";
4367
+ readonly entry: AgentRoutingEntry;
4368
+ }
4369
+
4370
+ function liveOrchestratorSnapshot(ctx: ExtensionContext, live: LiveSession): LiveOrchestratorSnapshot | undefined {
4371
+ if (ctx.model === undefined || typeof ctx.model.provider !== "string" || typeof ctx.model.id !== "string") return undefined;
4372
+ let thinking: unknown;
4373
+ try { thinking = live.getThinkingLevel(); } catch { thinking = undefined; }
4374
+ return { status: "valid", entry: { model: `${ctx.model.provider}/${ctx.model.id}`, thinking: isThinkingLevel(thinking) ? thinking : undefined } };
4375
+ }
4376
+
4152
4377
  function profileSnapshotFrom(
4153
4378
  current: AgentModelConfig,
4154
- settings: OrchestratorSettingsReadResult,
4379
+ orchestrator: LiveOrchestratorSnapshot | OrchestratorSettingsReadResult,
4155
4380
  ): AgentModelConfig {
4156
4381
  const snapshot = cloneModelConfig(current);
4157
- if (settings.status === "valid" && settings.entry !== undefined) {
4158
- snapshot[PROFILE_ORCHESTRATOR_KEY] = { ...settings.entry };
4382
+ if (orchestrator.status === "valid" && orchestrator.entry !== undefined) {
4383
+ snapshot[PROFILE_ORCHESTRATOR_KEY] = { ...orchestrator.entry };
4159
4384
  }
4160
4385
  return snapshot;
4161
4386
  }
4162
4387
 
4388
+ /** Runs one finished panel action against the store and the live session, returning the file the reopened panel should show. */
4163
4389
  async function runProfilesPanelAction(
4164
4390
  ctx: ExtensionContext,
4165
4391
  live: LiveSession,
@@ -4170,6 +4396,29 @@ async function runProfilesPanelAction(
4170
4396
  ): Promise<AgentProfilesFile> {
4171
4397
  switch (result.type) {
4172
4398
  case "apply": {
4399
+ if (!hasOwnProfile(file.profiles, result.name)) return file;
4400
+ // Enter applies a complete routing snapshot to this parent session and
4401
+ // switches only its live orchestrator. No shared defaults, materialized
4402
+ // stores, agent frontmatter, pins, or declarations are written.
4403
+ const sessionId = ctx.sessionManager?.getSessionId?.();
4404
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
4405
+ ctx.ui.notify(
4406
+ `el Gentleman cannot bind profile "${result.name}" to this session: no parent session id is available here. Set it as the global default with a instead.`,
4407
+ "warning",
4408
+ );
4409
+ return file;
4410
+ }
4411
+ const snapshot = normalizeModelConfig(file.profiles[result.name]) ?? {};
4412
+ bindSessionProfile(sessionId, result.name, snapshot);
4413
+ const orchestrator = readProfileOrchestrator(snapshot);
4414
+ const liveNote = orchestrator === undefined ? "" : await switchLiveOrchestrator(ctx, live, orchestrator);
4415
+ ctx.ui.notify(
4416
+ `el Gentleman bound profile "${result.name}" to this session — shown as "${result.name} (session)". Subagents and reviewers use this session's routing snapshot. Shared defaults were not written: the global routing, pins, and materialized stores are untouched. Set as global default with a.${liveNote}`,
4417
+ "info",
4418
+ );
4419
+ return file;
4420
+ }
4421
+ case "apply-global": {
4173
4422
  if (!hasOwnProfile(file.profiles, result.name)) return file;
4174
4423
  // A pinned repository resolves its subagent routing from the profile at launch,
4175
4424
  // so a global apply would move global state this repository never reads. When a
@@ -4198,6 +4447,128 @@ async function runProfilesPanelAction(
4198
4447
  }
4199
4448
  const normalized = normalizeModelConfig(file.profiles[result.name]) ?? {};
4200
4449
  const orchestratorEntry = readProfileOrchestrator(normalized);
4450
+ const hasAgentRoutes = Object.keys(normalized).some((name) => !isProfileOrchestratorKey(name));
4451
+ // Every global apply confirms before anything is written: applying replaces
4452
+ // the whole routing map in models.json and clears materialized routes the
4453
+ // profile omits, so the dialog must name the concrete diff. The current
4454
+ // routing is read from the same authority every other consumer uses; an
4455
+ // unreadable config is tolerated as empty, exactly like readModelConfigAsync.
4456
+ const savedRouting = await readModelRoutingAuthorityAsync(
4457
+ modelConfigPath(ctx.cwd),
4458
+ legacyProjectModelConfigPath(ctx.cwd),
4459
+ );
4460
+ // The apply pads omitted discoverable agents with clear entries, so the
4461
+ // diff must run against the effective current routing: the saved global
4462
+ // routing plus the materialized routes (frontmatter, subagents.json) of
4463
+ // agents the saved routing is silent about. models.json alone would hide
4464
+ // materialized-only routes the approval actually clears.
4465
+ const currentRouting = savedRouting.status === "valid"
4466
+ ? await readGlobalEffectiveModelConfigFromAsync(ctx.cwd, savedRouting.config)
4467
+ : {};
4468
+ const agentNames = [
4469
+ ...new Set([
4470
+ ...Object.keys(currentRouting).filter((name) => !isProfileOrchestratorKey(name)),
4471
+ ...Object.keys(normalized).filter((name) => !isProfileOrchestratorKey(name)),
4472
+ ]),
4473
+ ].sort();
4474
+ const replacedRoutes: string[] = [];
4475
+ const clearedRoutes: string[] = [];
4476
+ const addedRoutes: string[] = [];
4477
+ for (const name of agentNames) {
4478
+ const from = currentRouting[name];
4479
+ const to = normalized[name];
4480
+ if (to === undefined) {
4481
+ clearedRoutes.push(`${name}: ${formatOrchestratorSelection(from)} → inherit (cleared)`);
4482
+ } else if (from === undefined) {
4483
+ addedRoutes.push(`${name}: ${formatOrchestratorSelection(to)} (added)`);
4484
+ } else if (from.model !== to.model || from.thinking !== to.thinking) {
4485
+ replacedRoutes.push(`${name}: ${formatOrchestratorSelection(from)} → ${formatOrchestratorSelection(to)}`);
4486
+ }
4487
+ }
4488
+ if (!hasAgentRoutes) {
4489
+ // A genuinely empty profile and an orchestrator-only profile both wipe
4490
+ // every materialized agent route, but they read very differently to the
4491
+ // user: the orchestrator entry survives the apply and reconfigures the
4492
+ // orchestrator, so the dialog must not promise an entirely empty config.
4493
+ const [confirmTitle, confirmMessage] = orchestratorEntry !== undefined
4494
+ ? [
4495
+ "Apply orchestrator-only profile?",
4496
+ `Profile "${result.name}" has an orchestrator entry but no agent routing entries. Applying it will replace global routing in ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} with the orchestrator entry alone, clear every materialized agent route so every agent returns to inherit its default model, set the configured orchestrator entry in settings.json, and attempt to switch this session to that orchestrator model. Continue?`,
4497
+ ]
4498
+ : [
4499
+ "Apply empty profile?",
4500
+ `Profile "${result.name}" has no routing entries. Applying it will replace global routing in ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} with an empty configuration and return every agent to inherit its default model. Continue?`,
4501
+ ];
4502
+ const approved = await ctx.ui.confirm(confirmTitle, confirmMessage);
4503
+ if (!approved) return file;
4504
+ } else {
4505
+ // A populated profile keeps the same abort semantics as the empty and
4506
+ // orchestrator-only dialogs: declining leaves every surface untouched.
4507
+ // When the routing authority is unreadable the diff above was computed
4508
+ // against an empty map, so the dialog must disclose the unreadable
4509
+ // routing and the replace/clear-to-inherit effect instead of presenting
4510
+ // existing routes as merely "(added)" (the #1349 wipe-bug class).
4511
+ // When the profile carries an orchestrator entry, approval also writes
4512
+ // settings.json and tries to move the live session, so both variants
4513
+ // must disclose those effects in the same words the orchestrator-only
4514
+ // dialog uses. An unconditional "will switch" is never claimed: the
4515
+ // registry/auth can refuse the live move.
4516
+ const orchestratorEffects = orchestratorEntry !== undefined
4517
+ ? ", set the configured orchestrator entry in settings.json, and attempt to switch this session to that orchestrator model"
4518
+ : "";
4519
+ // A profile whose agent routes already match the effective current routing
4520
+ // and that moves no orchestrator changes nothing: re-selecting the active
4521
+ // profile or verifying state would otherwise train users to approve a
4522
+ // dialog without reading it, weakening the guard on the destructive cases
4523
+ // (issue #1683). Any routing change, any orchestrator change, or an
4524
+ // unreadable routing authority (the diff above cannot prove a no-op)
4525
+ // keeps the confirmation exactly as #1349/#1384 defined it.
4526
+ // `applyOrchestratorSettings` treats an entry without a model as "leave
4527
+ // settings.json alone", so such an entry is a no-op too, not a change.
4528
+ // The live session is a fourth surface: applying re-asserts the profile's
4529
+ // orchestrator on it, so a session already moved to another model or
4530
+ // thinking level mid-session is a real change the user must approve,
4531
+ // even when settings.json and the profile agree.
4532
+ const liveOrchestrator = (() => {
4533
+ if (ctx.model === undefined || typeof ctx.model.provider !== "string" || typeof ctx.model.id !== "string") return undefined;
4534
+ let thinking: unknown;
4535
+ try { thinking = live.getThinkingLevel(); } catch { return undefined; }
4536
+ return { model: `${ctx.model.provider}/${ctx.model.id}`, thinking: isThinkingLevel(thinking) ? thinking : undefined };
4537
+ })();
4538
+ const orchestratorUnchanged = (orchestratorEntry === undefined || orchestratorEntry.model === undefined) || (() => {
4539
+ const current = readOrchestratorSettings(orchestratorSettingsPath());
4540
+ // An invalid stored defaultThinkingLevel is dropped from the entry but
4541
+ // applyOrchestratorSettings would delete the key, so the file would
4542
+ // change: a no-op cannot be proven and the dialog must stay.
4543
+ if (current.status === "valid" && "defaultThinkingLevel" in current.value && current.entry?.thinking === undefined) return false;
4544
+ return current.status === "valid" && current.entry !== undefined
4545
+ && current.entry.model === orchestratorEntry.model
4546
+ && current.entry.thinking === orchestratorEntry.thinking
4547
+ && liveOrchestrator !== undefined
4548
+ && liveOrchestrator.model === orchestratorEntry.model
4549
+ && liveOrchestrator.thinking === orchestratorEntry.thinking;
4550
+ })();
4551
+ if (savedRouting.status === "valid" && replacedRoutes.length === 0 && clearedRoutes.length === 0 && addedRoutes.length === 0 && orchestratorUnchanged) {
4552
+ ctx.ui.notify(
4553
+ `Profile "${result.name}" already matches the current global routing${orchestratorEntry !== undefined ? " and orchestrator" : ""}; applying it changed nothing.`,
4554
+ "info",
4555
+ );
4556
+ } else {
4557
+ let confirmMessage: string;
4558
+ if (savedRouting.status !== "valid") {
4559
+ const modelsPath = sanitizeTerminalText(modelConfigPath(ctx.cwd));
4560
+ confirmMessage = `Profile "${result.name}" has agent routing entries, but the current global routing in ${modelsPath} could not be read, so existing routes are not listed. Applying it will replace global routing in ${modelsPath} with this profile's routes, so every existing agent route may be replaced or cleared back to inherit${orchestratorEffects}. Continue?`;
4561
+ } else {
4562
+ const changes = [...replacedRoutes, ...clearedRoutes, ...addedRoutes];
4563
+ const changeSummary = changes.length > 0
4564
+ ? changes.join("; ")
4565
+ : "its agent routes already match the current global routing";
4566
+ confirmMessage = `Profile "${result.name}" has agent routing entries. Applying it will replace global routing in ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} with this profile's routes: ${changeSummary}${orchestratorEffects}. Continue?`;
4567
+ }
4568
+ const approved = await ctx.ui.confirm(`Apply profile "${result.name}"?`, confirmMessage);
4569
+ if (!approved) return file;
4570
+ }
4571
+ }
4201
4572
  // Applying spans three files — the store, models.json, and Pi's global
4202
4573
  // settings.json — and there is no cross-file rename, so order the writes to
4203
4574
  // keep the store truthful and compensate on failure: claim the profile in
@@ -4587,6 +4958,7 @@ async function runProfilesPanelAction(
4587
4958
  }
4588
4959
  }
4589
4960
 
4961
+ /** `/gentle:profiles`: seed or open the store, then loop the panel over one action at a time until it closes. */
4590
4962
  async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): Promise<void> {
4591
4963
  const path = profilesFilePath(gentleAiConfigHome());
4592
4964
  const read = readProfilesFileResult(path);
@@ -4619,8 +4991,15 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession):
4619
4991
  file,
4620
4992
  name,
4621
4993
  profileSnapshotFrom(
4622
- readEffectiveModelConfig(ctx.cwd),
4623
- readOrchestratorSettings(orchestratorSettingsPath()),
4994
+ // The snapshot captures the same routing the panel shows as current,
4995
+ // so a session binding outranks the shared layers here too.
4996
+ readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles
4997
+ ?? readEffectiveModelConfig(ctx.cwd),
4998
+ // The orchestrator follows the same honesty rule: capture what the
4999
+ // session actually runs (ctx.model plus the live thinking level)
5000
+ // when a live model exists; settings.json defaults stand in only
5001
+ // when it does not (LCubero's finding on #1064).
5002
+ liveOrchestratorSnapshot(ctx, live) ?? readOrchestratorSettings(orchestratorSettingsPath()),
4624
5003
  ),
4625
5004
  );
4626
5005
  writeProfilesFileSync(path, next);
@@ -4628,12 +5007,23 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession):
4628
5007
  return next;
4629
5008
  };
4630
5009
  let selectedName: string | undefined;
5010
+ const sessionBoundName = () => readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.name;
5011
+ // The panel's "Current routing (effective)" table shows what this session's
5012
+ // launches resolve right now, and a session binding outranks every shared
5013
+ // layer, so a bound session reads its snapshot as the current routing while an
5014
+ // unbound session keeps reading the effective config exactly as before.
5015
+ const currentRoutingForPanel = async () =>
5016
+ readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles
5017
+ ?? await readEffectiveModelConfigAsync(ctx.cwd);
4631
5018
  let result = await showProfilesPanel(
4632
5019
  ctx,
4633
5020
  file,
4634
- await readEffectiveModelConfigAsync(ctx.cwd),
5021
+ await currentRoutingForPanel(),
4635
5022
  selectedName,
4636
5023
  saveSnapshot,
5024
+ live,
5025
+ undefined,
5026
+ sessionBoundName(),
4637
5027
  );
4638
5028
  while (result.type !== "close") {
4639
5029
  const report: ProfilesPanelReport = {};
@@ -4642,10 +5032,12 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession):
4642
5032
  result = await showProfilesPanel(
4643
5033
  ctx,
4644
5034
  file,
4645
- await readEffectiveModelConfigAsync(ctx.cwd),
5035
+ await currentRoutingForPanel(),
4646
5036
  selectedName,
4647
5037
  saveSnapshot,
5038
+ live,
4648
5039
  report.status,
5040
+ sessionBoundName(),
4649
5041
  );
4650
5042
  }
4651
5043
  }
@@ -4712,7 +5104,12 @@ function reviewToolOperationPath(args: unknown): string {
4712
5104
  return `review ${operation.replaceAll("-", " ")}`;
4713
5105
  }
4714
5106
 
4715
- const REVIEW_CONTROLLER_PARAMETERS = {
5107
+ // A JSON-object string pattern prevents Pi primitive coercion from admitting
5108
+ // numbers/booleans as strings. Facade parsers still own JSON and authority checks.
5109
+ const REVIEW_JSON_STRING = { type: "string", pattern: "^\\s*\\{" } as const;
5110
+ const REVIEW_JSON_ARGUMENT = { anyOf: [REVIEW_JSON_STRING, { type: "object" }] } as const;
5111
+
5112
+ const REVIEW_CONTROLLER_PARAMETER_FIELDS = {
4716
5113
  type: "object",
4717
5114
  additionalProperties: false,
4718
5115
  required: ["operation"],
@@ -4743,7 +5140,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
4743
5140
  },
4744
5141
  input: {
4745
5142
  type: "string",
4746
- description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. baseRef must be HEAD, a full 40- or 64-character commit id, or a ref name; abbreviated commit ids are rejected as base-ref-unresolvable. ASSESS accepts an optional object with baseRef, committedOnly, writerModelId, writerEffort, and nativeReviewOutcome (gentle-pi#662/#668/#1175). The writer profile comes from the runtime-recorded model and effort of the pending mutations for this root (writerProfileSource runtime); writerModelId and writerEffort are only a fallback when no runtime evidence exists (caller), and with neither the profile fails closed to small (fallback). A missing model, a mini model token (gemini is not mini), or low effort keeps the conservative small-model bias. ASSESS derives `closed` only from the native candidate.consumed fact for this exact candidate, which native records only when the approved review for that candidate is acknowledged; a caller-supplied closed is not authority and, without that fact, resolves to unknown. A declined, unavailable, or unknown outcome falls back to the exact risk-gated plan ASSESS returns when RDD is off, re-enabling the separate verifier; unknown is never treated as closed, and a decline is candidate-scoped and never lowers the bar below the RDD-off path. A declined or unavailable outcome, stated explicitly or recorded by this process for this exact candidate (never a different one, and never from repository state alone), wins over closure. The returned outcome_source (explicit|derived|unknown) says which of these produced the value. Legacy controller input remains separate.",
5143
+ description: "A JSON object or serialized object string for START/ASSESS only; every other operation requires a serialized object string. New native ordinary START uses {\"mode\":\"ordinary\"}, plus the agent lens selection {\"lenses\":[\"risk\",...],\"lensesReason\":\"<what you touched and how>\"} (both or neither, 1-4 of risk, resilience, readability, reliability; never with focus; providers that predate it keep the tier default); answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. baseRef must be HEAD, a full 40- or 64-character commit id, or a ref name; abbreviated commit ids are rejected as base-ref-unresolvable. ASSESS accepts an optional object with baseRef, committedOnly, writerModelId, writerEffort, and nativeReviewOutcome (gentle-pi#662/#668/#1175). The writer profile comes from the runtime-recorded model and effort of the pending mutations for this root (writerProfileSource runtime); writerModelId and writerEffort are only a fallback when no runtime evidence exists (caller), and with neither the profile fails closed to small (fallback). A missing model, a mini model token (gemini is not mini), or low effort keeps the conservative small-model bias. ASSESS derives `closed` only from the native candidate.consumed fact for this exact candidate, which native records only when the approved review for that candidate is acknowledged; a caller-supplied closed is not authority and, without that fact, resolves to unknown. Every outcome yields the exact risk-gated plan ASSESS returns when RDD is off: a closed review never removes the separate verifier the tier requires (verify-always-rdd-high S1), unknown is never treated as closed, and a decline is candidate-scoped and never lowers the bar below the RDD-off path. A declined or unavailable outcome, stated explicitly or recorded by this process for this exact candidate (never a different one, and never from repository state alone), wins over closure. The returned outcome_source (explicit|derived|unknown) says which of these produced the value. Legacy controller input remains separate.",
4747
5144
  },
4748
5145
  outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
4749
5146
  inputPath: { type: "string", description: "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired." },
@@ -4756,6 +5153,22 @@ const REVIEW_CONTROLLER_PARAMETERS = {
4756
5153
  },
4757
5154
  } as const;
4758
5155
 
5156
+ // Root-level union combinators (anyOf/oneOf/allOf) are forbidden by the Anthropic
5157
+ // tool definition spec and cause Claude Code/Agent SDK to drop the tool (#1698).
5158
+ // The root schema is a plain object; the nullable root shell prevents Pi from
5159
+ // deleting an optional supplied null, while runtime branches in the facade
5160
+ // enforce that null and non-START/ASSESS objects are rejected fail-closed.
5161
+ const REVIEW_CONTROLLER_PARAMETERS = {
5162
+ ...REVIEW_CONTROLLER_PARAMETER_FIELDS,
5163
+ properties: {
5164
+ ...REVIEW_CONTROLLER_PARAMETER_FIELDS.properties,
5165
+ input: {
5166
+ anyOf: [...REVIEW_JSON_ARGUMENT.anyOf, { type: "null" }],
5167
+ description: `${REVIEW_CONTROLLER_PARAMETER_FIELDS.properties.input.description} Null is invalid; omit input when optional.`,
5168
+ },
5169
+ },
5170
+ } as const;
5171
+
4759
5172
  const REVIEW_CAPTURE_PARAMETERS = {
4760
5173
  type: "object",
4761
5174
  additionalProperties: false,
@@ -4767,9 +5180,8 @@ const REVIEW_CAPTURE_PARAMETERS = {
4767
5180
  description: "Exact lineage from the current provider-issued collect transition.",
4768
5181
  },
4769
5182
  collectBinding: {
4770
- type: "string",
4771
- minLength: 1,
4772
- description: "JSON-serialized exact copy of one decoded provider-owned next_transition.collect input from current STATUS.",
5183
+ ...REVIEW_JSON_ARGUMENT,
5184
+ description: "JSON object or serialized exact copy of one complete provider-owned collect input from current STATUS; never compose or alter it.",
4773
5185
  },
4774
5186
  reviewerRunAcknowledged: {
4775
5187
  type: "boolean",
@@ -4801,7 +5213,7 @@ const REVIEW_CAPTURE_GROUP_PARAMETERS = {
4801
5213
  required: ["lineageId", "collectBindings"],
4802
5214
  properties: {
4803
5215
  lineageId: { type: "string", minLength: 1, description: "Exact lineage from the current provider-issued collect transition." },
4804
- collectBindings: { type: "array", minItems: 1, items: { type: "string", minLength: 1 }, description: "Ordered JSON-serialized exact copies of the complete current materialize reviewer collect set." },
5216
+ collectBindings: { type: "array", minItems: 1, items: REVIEW_JSON_ARGUMENT, description: "Ordered JSON objects or serialized exact copies of the complete current STATUS materialize reviewer collect set; never mix, reorder, or alter bindings." },
4805
5217
  reviewerRunAcknowledged: { type: "boolean", description: "Required after the one group forecast; authorizes exactly the forecast reviewer runs." },
4806
5218
  workspaceRoot: { type: "string", description: "Optional explicit existing Git worktree root, resolved with the controller's worktree confinement semantics." },
4807
5219
  },
@@ -4847,6 +5259,9 @@ interface ReviewAssessInput extends Pick<NativeReviewAssessRequest, "untrackedSc
4847
5259
  // candidate's own target identity (never a different one); `closed` is
4848
5260
  // never auto-derived -- pass it explicitly.
4849
5261
  nativeReviewOutcome?: NativeReviewOutcome;
5262
+ // gentle-shell#1494: the agent raises this candidate to high by citing a
5263
+ // high-risk item; it can never lower the native tier.
5264
+ escalate?: AgentRiskEscalation;
4850
5265
  }
4851
5266
 
4852
5267
  function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome {
@@ -4856,10 +5271,10 @@ function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome {
4856
5271
  function parseReviewAssessInput(operation: ReviewControllerOperation, raw: string | undefined): ReviewAssessInput {
4857
5272
  if (raw === undefined) return {};
4858
5273
  const value = parseControllerJson(raw, operation);
4859
- const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"]);
5274
+ const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome", "escalate", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"]);
4860
5275
  const unexpected = Object.keys(value).find((key) => !allowed.has(key));
4861
5276
  if (unexpected !== undefined) throw new Error(`Review controller ${operation} input does not accept ${unexpected}`);
4862
- const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome } = value;
5277
+ const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome, escalate } = value;
4863
5278
  if (baseRef !== undefined && typeof baseRef !== "string") throw new Error(`Review controller ${operation} input baseRef must be a string`);
4864
5279
  if (committedOnly !== undefined && typeof committedOnly !== "boolean") throw new Error(`Review controller ${operation} input committedOnly must be a boolean`);
4865
5280
  if (writerModelId !== undefined && typeof writerModelId !== "string") throw new Error(`Review controller ${operation} input writerModelId must be a string`);
@@ -4874,6 +5289,7 @@ function parseReviewAssessInput(operation: ReviewControllerOperation, raw: strin
4874
5289
  ...(writerModelId === undefined ? {} : { writerModelId: writerModelId as string }),
4875
5290
  ...(writerEffort === undefined ? {} : { writerEffort: writerEffort as string }),
4876
5291
  ...(nativeReviewOutcome === undefined ? {} : { nativeReviewOutcome: nativeReviewOutcome as NativeReviewOutcome }),
5292
+ ...(escalate === undefined ? {} : { escalate: decodeAgentRiskEscalation(escalate) }),
4877
5293
  };
4878
5294
  }
4879
5295
 
@@ -4912,6 +5328,32 @@ function isReviewControllerOperation(value: string): value is ReviewControllerOp
4912
5328
  return Object.values(REVIEW_CONTROLLER_OPERATION).some((operation) => operation === value);
4913
5329
  }
4914
5330
 
5331
+ function serializeReviewJsonArgument(value: unknown): string {
5332
+ if (typeof value === "string") return value;
5333
+ if (isProxy(value) || !isRecord(value)) throw new Error("Review JSON argument must be a serialized object string or JSON object");
5334
+ const ancestors = new Set<object>();
5335
+ const check = (item: unknown): void => {
5336
+ if (item === null || typeof item === "string" || typeof item === "boolean") return;
5337
+ if (typeof item === "number" && Number.isFinite(item) && !Object.is(item, -0)) return;
5338
+ if (typeof item !== "object" || item === null || isProxy(item) || ancestors.has(item)) throw new Error("Review JSON argument contains a non-JSON or cyclic value");
5339
+ const array = Array.isArray(item);
5340
+ if (array && Object.getPrototypeOf(item) !== Array.prototype) throw new Error("Review JSON argument must contain only plain JSON arrays");
5341
+ if (!array && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) throw new Error("Review JSON argument must contain only plain JSON objects");
5342
+ ancestors.add(item);
5343
+ const keys = Reflect.ownKeys(item);
5344
+ if (array && keys.length !== item.length + 1) throw new Error("Review JSON argument contains a sparse or extended array");
5345
+ for (const key of keys) {
5346
+ if (array && key === "length") continue;
5347
+ const descriptor = Object.getOwnPropertyDescriptor(item, key)!;
5348
+ if (typeof key !== "string" || !descriptor.enumerable || !("value" in descriptor) || (array && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= item.length))) throw new Error("Review JSON argument contains an ambiguous property");
5349
+ check(descriptor.value);
5350
+ }
5351
+ ancestors.delete(item);
5352
+ };
5353
+ check(value);
5354
+ return JSON.stringify(value);
5355
+ }
5356
+
4915
5357
  function parseReviewControllerParameters(value: unknown): ReviewControllerParameters {
4916
5358
  if (!isRecord(value)) throw new Error("Review controller parameters must be an object");
4917
5359
  if (typeof value.operation !== "string" || !isReviewControllerOperation(value.operation)) {
@@ -4957,22 +5399,23 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
4957
5399
  }
4958
5400
  }
4959
5401
 
4960
- const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, REVIEW_CONTROLLER_OPERATION.STATUS, REVIEW_CONTROLLER_OPERATION.EXPORT, REVIEW_CONTROLLER_OPERATION.IMPORT, REVIEW_CONTROLLER_OPERATION.INSPECT, REVIEW_CONTROLLER_OPERATION.RESET, REVIEW_CONTROLLER_OPERATION.RECOVER, REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, REVIEW_CONTROLLER_OPERATION.ABANDON, REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY, REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY, REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS, REVIEW_CONTROLLER_OPERATION.REPAIR, REVIEW_CONTROLLER_OPERATION.ASSESS].includes(value.operation as ReviewControllerOperation);
5402
+ const needsLineage = !([REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, REVIEW_CONTROLLER_OPERATION.STATUS, REVIEW_CONTROLLER_OPERATION.EXPORT, REVIEW_CONTROLLER_OPERATION.IMPORT, REVIEW_CONTROLLER_OPERATION.INSPECT, REVIEW_CONTROLLER_OPERATION.RESET, REVIEW_CONTROLLER_OPERATION.RECOVER, REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, REVIEW_CONTROLLER_OPERATION.ABANDON, REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY, REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY, REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS, REVIEW_CONTROLLER_OPERATION.REPAIR, REVIEW_CONTROLLER_OPERATION.ASSESS] as readonly ReviewControllerOperation[]).includes(value.operation);
4961
5403
  if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
4962
5404
  throw new Error("Review controller requires a lineageId");
4963
5405
  }
4964
5406
  const parameters: ReviewControllerParameters = {
4965
5407
  operation: value.operation,
4966
5408
  ...(typeof value.lineageId === "string" ? { lineageId: value.lineageId } : {}),
4967
- ...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && value.untrackedScope !== undefined ? { untrackedScope: value.untrackedScope } : {}),
4968
- ...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && value.intendedUntracked !== undefined ? { intendedUntracked: [...value.intendedUntracked] } : {}),
5409
+ ...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && (value.untrackedScope === NATIVE_START_UNTRACKED_SCOPE.EXCLUDE || value.untrackedScope === NATIVE_START_UNTRACKED_SCOPE.SELECT) ? { untrackedScope: value.untrackedScope } : {}),
5410
+ ...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && Array.isArray(value.intendedUntracked) ? { intendedUntracked: [...value.intendedUntracked] } : {}),
4969
5411
  };
4970
5412
  for (const key of ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource", "workspaceRoot"] as const) {
4971
5413
  const optional = value[key];
5414
+ if (key === "input" && key in value && (value.operation === REVIEW_CONTROLLER_OPERATION.START || value.operation === REVIEW_CONTROLLER_OPERATION.ASSESS)) {
5415
+ parameters.input = serializeReviewJsonArgument(optional);
5416
+ continue;
5417
+ }
4972
5418
  if (optional !== undefined && typeof optional !== "string") {
4973
- if (value.operation === REVIEW_CONTROLLER_OPERATION.START && key === "input") {
4974
- throw new Error("Review controller START input must be a JSON string encoding an object, not a nested object. No lineage was created; do not call STATUS or ADVANCE for this attempted lineage.");
4975
- }
4976
5419
  throw new Error(`Review controller ${key} must be a string`);
4977
5420
  }
4978
5421
  if (typeof optional === "string") parameters[key] = optional;
@@ -4986,16 +5429,18 @@ function parseReviewCaptureParameters(value: unknown): ReviewCaptureParameters {
4986
5429
  const unexpected = Object.keys(value).find((key) => !allowed.has(key));
4987
5430
  if (unexpected !== undefined) throw new Error(`Review capture does not accept ${unexpected}`);
4988
5431
  if (!isCanonicalProcessString(value.lineageId)) throw new Error("Review capture requires an exact non-empty lineageId");
4989
- if (typeof value.collectBinding !== "string" || value.collectBinding.length === 0) throw new Error("Review capture requires a JSON-serialized collectBinding");
5432
+ const { reviewerRunAcknowledged, correctionLines, workspaceRoot } = value;
5433
+ const collectBinding = serializeReviewJsonArgument(value.collectBinding);
5434
+ if (collectBinding.length === 0) throw new Error("Review capture requires a non-empty collectBinding");
4990
5435
  if (value.reviewerRunAcknowledged !== undefined && typeof value.reviewerRunAcknowledged !== "boolean") throw new Error("Review capture reviewerRunAcknowledged must be boolean");
4991
- if (value.correctionLines !== undefined && (!Number.isSafeInteger(value.correctionLines) || value.correctionLines < 1)) throw new Error("Review capture correctionLines must be a positive integer");
5436
+ if (value.correctionLines !== undefined && (typeof value.correctionLines !== "number" || !Number.isSafeInteger(value.correctionLines) || value.correctionLines < 1)) throw new Error("Review capture correctionLines must be a positive integer");
4992
5437
  if (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string") throw new Error("Review capture workspaceRoot must be a string");
4993
5438
  return {
4994
5439
  lineageId: value.lineageId,
4995
- collectBinding: value.collectBinding,
4996
- ...(value.reviewerRunAcknowledged === undefined ? {} : { reviewerRunAcknowledged: value.reviewerRunAcknowledged }),
4997
- ...(value.correctionLines === undefined ? {} : { correctionLines: value.correctionLines }),
4998
- ...(value.workspaceRoot === undefined ? {} : { workspaceRoot: value.workspaceRoot }),
5440
+ collectBinding,
5441
+ ...(typeof reviewerRunAcknowledged === "boolean" ? { reviewerRunAcknowledged } : {}),
5442
+ ...(typeof correctionLines === "number" ? { correctionLines } : {}),
5443
+ ...(typeof workspaceRoot === "string" ? { workspaceRoot } : {}),
4999
5444
  };
5000
5445
  }
5001
5446
 
@@ -5005,14 +5450,17 @@ function parseReviewCaptureGroupParameters(value: unknown): ReviewCaptureGroupPa
5005
5450
  const unexpected = Object.keys(value).find((key) => !allowed.has(key));
5006
5451
  if (unexpected !== undefined) throw new Error(`Review capture group does not accept ${unexpected}`);
5007
5452
  if (!isCanonicalProcessString(value.lineageId)) throw new Error("Review capture group requires an exact non-empty lineageId");
5008
- if (!Array.isArray(value.collectBindings) || value.collectBindings.length === 0 || value.collectBindings.some((binding) => typeof binding !== "string" || binding.length === 0)) throw new Error("Review capture group requires one or more JSON-serialized collectBindings");
5453
+ if (!Array.isArray(value.collectBindings) || value.collectBindings.length === 0) throw new Error("Review capture group requires one or more collectBindings");
5454
+ const { reviewerRunAcknowledged, workspaceRoot } = value;
5455
+ const collectBindings = value.collectBindings.map(serializeReviewJsonArgument);
5456
+ if (collectBindings.some((binding) => binding.length === 0)) throw new Error("Review capture group requires non-empty collectBindings");
5009
5457
  if (value.reviewerRunAcknowledged !== undefined && typeof value.reviewerRunAcknowledged !== "boolean") throw new Error("Review capture group reviewerRunAcknowledged must be boolean");
5010
5458
  if (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string") throw new Error("Review capture group workspaceRoot must be a string");
5011
5459
  return {
5012
5460
  lineageId: value.lineageId,
5013
- collectBindings: [...value.collectBindings],
5014
- ...(value.reviewerRunAcknowledged === undefined ? {} : { reviewerRunAcknowledged: value.reviewerRunAcknowledged }),
5015
- ...(value.workspaceRoot === undefined ? {} : { workspaceRoot: value.workspaceRoot }),
5461
+ collectBindings,
5462
+ ...(typeof reviewerRunAcknowledged === "boolean" ? { reviewerRunAcknowledged } : {}),
5463
+ ...(typeof workspaceRoot === "string" ? { workspaceRoot } : {}),
5016
5464
  };
5017
5465
  }
5018
5466
 
@@ -5078,7 +5526,10 @@ async function authorizeDestructiveReviewOperation(
5078
5526
  // (issue #212).
5079
5527
  // RECOVER authorizes itself in `executeReviewControllerOperation`, the way
5080
5528
  // REPAIR_LEGACY_ALIAS does, because its binding can only be derived from a
5081
- // fresh native target-status read.
5529
+ // fresh native target-status read. ABANDON joins that self-authorizing
5530
+ // family for the same reason (issue #1159): its binding is derived from a
5531
+ // fresh native authority inventory read.
5532
+ if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ABANDON) return;
5082
5533
  const isReset = parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET;
5083
5534
  const maintenance = nativeMaintenanceOperation(parameters.operation);
5084
5535
  if (!isReset && maintenance === undefined) return;
@@ -5097,7 +5548,7 @@ async function authorizeDestructiveReviewOperation(
5097
5548
  const approved = await ctx.ui.confirm(
5098
5549
  maintenance !== undefined ? `Authorize review authority ${parameters.operation.toUpperCase()}?` : `Authorize destructive review authority ${parameters.operation.toUpperCase()}?`,
5099
5550
  maintenance !== undefined
5100
- ? [`Operation: ${parameters.operation.toUpperCase()}`, "Exact published authorization binding:", maintenanceAuthorization!, maintenance === "abandon" ? "The native command may quarantine only an eligible pristine compact-v2 lineage." : maintenance === "quarantineLegacy" ? "The native command may quarantine only the published malformed freeze-findings legacy diagnostic." : "The native command may quarantine only the bound invalid recovery successor; the predecessor stays untouched."].join("\n")
5551
+ ? ["Operation: " + parameters.operation.toUpperCase(), "Exact published authorization binding:", maintenanceAuthorization!, maintenance === "quarantineLegacy" ? "The native command may quarantine only the published malformed freeze-findings legacy diagnostic." : "The native command may quarantine only the bound invalid recovery successor; the predecessor stays untouched."].join("\n")
5101
5552
  : [`Operation: ${parameters.operation.toUpperCase()}`, `Repository: ${input.repositoryId}`, `Exact challenge: ${input.confirmation}`, "This invalidates all prior review authority for this repository."].join("\n"),
5102
5553
  );
5103
5554
  if (!approved) throw new Error(`Review controller ${parameters.operation.toUpperCase()} was not explicitly authorized`);
@@ -5226,11 +5677,15 @@ function nativeReviewModeSkipped(operation: ReviewControllerOperation, source: N
5226
5677
 
5227
5678
  // The legacy START compatibility gate is deliberately permissive when mode is
5228
5679
  // unavailable. It is not authorization to enter bootstrap-capable native code.
5229
- async function nativeRepositoryPreparationEnabled(nativeReviewCli: NativeReviewCli | null, cwd: string, signal?: AbortSignal): Promise<boolean> {
5680
+ // `rememberForPrompt` lets a decoded read serve the next prompt's RDD status
5681
+ // line from the memo instead of spawning the same STATUS again.
5682
+ async function nativeRepositoryPreparationEnabled(nativeReviewCli: NativeReviewCli | null, cwd: string, signal?: AbortSignal, rememberForPrompt = false): Promise<boolean> {
5230
5683
  if (nativeReviewCli?.reviewMode === undefined) return false;
5231
5684
  try {
5232
5685
  const mode = await nativeReviewCli.reviewMode({ cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, ...(signal === undefined ? {} : { signal }) });
5233
- return mode.operation === NATIVE_REVIEW_MODE_OPERATION.STATUS && isValidRddModeStatus(mode.status) && mode.status.effective === "on";
5686
+ const decoded = mode.operation === NATIVE_REVIEW_MODE_OPERATION.STATUS && isValidRddModeStatus(mode.status);
5687
+ if (decoded && rememberForPrompt) rddStatusMemo.set(cwd, { status: mode.status, expiresAt: Date.now() + RDD_STATUS_MEMO_TTL_MS });
5688
+ return decoded && mode.status.effective === "on";
5234
5689
  } catch {
5235
5690
  return false;
5236
5691
  }
@@ -5304,7 +5759,7 @@ function asNativeReviewConsentBindingError(error: unknown): { reason: string; me
5304
5759
  return typeof reason !== "string" || reason.length === 0 ? undefined : { reason, message: error.message };
5305
5760
  }
5306
5761
 
5307
- function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation, diagnostics: NativeReviewProcessDiagnostics): Record<string, unknown> {
5762
+ function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation | "gentle_review_capture", diagnostics: NativeReviewProcessDiagnostics): Record<string, unknown> {
5308
5763
  return {
5309
5764
  operation,
5310
5765
  status: "blocked",
@@ -5365,27 +5820,24 @@ const NATIVE_RECOVERY_INPUT = {
5365
5820
  } as const;
5366
5821
 
5367
5822
  const NATIVE_MAINTENANCE_INPUT = {
5368
- abandon: ["lineage", "expectedRevision", "snapshotIdentity", "actor", "reason"],
5369
5823
  quarantineLegacy: ["repository", "lineage", "expectedRevision", "diagnostic", "disposition", "actor", "reason"],
5370
5824
  reconcileAuthority: ["predecessorLineage", "expectedPredecessorRevision", "successorLineage", "expectedSuccessorRevision", "actor", "reason"],
5371
5825
  } as const;
5372
5826
  type NativeMaintenanceOperation = keyof typeof NATIVE_MAINTENANCE_INPUT;
5373
5827
 
5828
+ // ABANDON left this caller-supplied family: its binding can only be derived
5829
+ // from a fresh native authority inventory read (issue #1159), so it takes the
5830
+ // self-authorizing route RECOVER and REPAIR_LEGACY_ALIAS use.
5831
+ const NATIVE_ABANDON_INPUT = ["lineage", "actor", "reason"] as const;
5832
+
5374
5833
  function nativeMaintenanceOperation(operation: ReviewControllerOperation): NativeMaintenanceOperation | undefined {
5375
- if (operation === REVIEW_CONTROLLER_OPERATION.ABANDON) return "abandon";
5376
5834
  if (operation === REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY) return "quarantineLegacy";
5377
5835
  if (operation === REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY) return "reconcileAuthority";
5378
5836
  return undefined;
5379
5837
  }
5380
5838
 
5381
5839
  function missingNativeMaintenanceInputs(operation: NativeMaintenanceOperation, input: Record<string, unknown>): readonly string[] {
5382
- const missing = NATIVE_MAINTENANCE_INPUT[operation].filter((key) => !isCanonicalProcessString(input[key]));
5383
- if (operation !== "abandon") return missing;
5384
- return [
5385
- ...missing,
5386
- ...(Array.isArray(input.capturedLensResults) && input.capturedLensResults.every((entry) => isCanonicalProcessString(entry)) ? [] : ["capturedLensResults"]),
5387
- ...(typeof input.findingsPresent === "boolean" ? [] : ["findingsPresent"]),
5388
- ];
5840
+ return NATIVE_MAINTENANCE_INPUT[operation].filter((key) => !isCanonicalProcessString(input[key]));
5389
5841
  }
5390
5842
 
5391
5843
  function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, input: Record<string, unknown>): boolean {
@@ -5394,7 +5846,6 @@ function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, in
5394
5846
  }
5395
5847
 
5396
5848
  function nativeMaintenanceAuthorization(operation: NativeMaintenanceOperation, input: Record<string, unknown>): string {
5397
- if (operation === "abandon") return nativeReviewAbandonAuthorization({ lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, actor: String(input.actor), reason: String(input.reason) });
5398
5849
  if (operation === "quarantineLegacy") return nativeReviewLegacyQuarantineAuthorization({ repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason) });
5399
5850
  return nativeReviewReconcileAuthorization({ predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }) });
5400
5851
  }
@@ -5407,8 +5858,8 @@ async function executeNativeAuthorityMaintenance(
5407
5858
  nativeReviewCli: NativeReviewCli | null,
5408
5859
  signal: AbortSignal | undefined,
5409
5860
  ): Promise<Record<string, unknown>> {
5410
- const method = nativeOperation === "abandon" ? nativeReviewCli?.abandon : nativeOperation === "quarantineLegacy" ? nativeReviewCli?.quarantineLegacy : nativeReviewCli?.reconcileAuthority;
5411
- const nativeCommand = nativeOperation === "quarantineLegacy" ? "review quarantine-legacy" : nativeOperation === "reconcileAuthority" ? "review reconcile-authority" : "review abandon";
5861
+ const method = nativeOperation === "quarantineLegacy" ? nativeReviewCli?.quarantineLegacy : nativeReviewCli?.reconcileAuthority;
5862
+ const nativeCommand = nativeOperation === "quarantineLegacy" ? "review quarantine-legacy" : "review reconcile-authority";
5412
5863
  if (method === undefined) {
5413
5864
  return { operation, status: "blocked", outcome: "native-maintenance-unavailable", native_operation: nativeCommand, mutation_performed: false, mutation_outcome: "none", next_action: "install-package-local-gentle-ai-or-run-native-review-cli-directly" };
5414
5865
  }
@@ -5420,17 +5871,145 @@ async function executeNativeAuthorityMaintenance(
5420
5871
  return { operation, status: "blocked", outcome: "native-input-invalid", native_operation: nativeCommand, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-the-exact-published-native-maintenance-binding" };
5421
5872
  }
5422
5873
  try {
5423
- const result = nativeOperation === "abandon"
5424
- ? await nativeReviewCli.abandon!({ cwd, lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
5425
- : nativeOperation === "quarantineLegacy"
5426
- ? await nativeReviewCli.quarantineLegacy!({ cwd, repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
5427
- : await nativeReviewCli.reconcileAuthority!({ cwd, predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) });
5874
+ const result = nativeOperation === "quarantineLegacy"
5875
+ ? await nativeReviewCli.quarantineLegacy!({ cwd, repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
5876
+ : await nativeReviewCli.reconcileAuthority!({ cwd, predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) });
5428
5877
  return { operation, native_operation: nativeCommand, result: result.record, mutation_performed: true, mutation_outcome: "committed", next_action: "inspect" };
5429
5878
  } catch (error) {
5430
5879
  return nativeOperationFailure(operation, error);
5431
5880
  }
5432
5881
  }
5433
5882
 
5883
+ // Terminal authority states observed in the native lifecycle (closure,
5884
+ // retirement, quarantine, abandonment records). Native re-derives the real
5885
+ // eligibility; this pre-check only fails fast on clearly dead lineages so a
5886
+ // terminal entry never reaches the approval path.
5887
+ const NATIVE_ABANDON_TERMINAL_STATES = new Set(["approved", "closed", "superseded", "quarantined", "abandoned"]);
5888
+
5889
+ /**
5890
+ * The single eligible ABANDON candidate for a lineage, or undefined. Requires
5891
+ * an authoritative inventory, unique lineage identity across ALL entries
5892
+ * before any eligibility filter (an incomplete duplicate must not be hidden by
5893
+ * the selection), and a live compact-v2 entry (active status, non-terminal
5894
+ * state) carrying the discarded-work projection (reviewer input from dnlrsls
5895
+ * and CodeRabbit on #1668: complete:true authoritative:false, hidden duplicates,
5896
+ * and terminal lineages must not reach the approval path).
5897
+ */
5898
+ function nativeAbandonCandidate(inventory: { authoritative: boolean; entries: readonly NativeReviewAuthorityEntry[] }, lineage: string): NativeReviewAuthorityEntry | undefined {
5899
+ if (!inventory.authoritative) return undefined;
5900
+ const matches = inventory.entries.filter((entry) => entry.lineageId === lineage);
5901
+ if (matches.length !== 1) return undefined;
5902
+ const entry = matches[0]!;
5903
+ if (entry.version !== "compact-v2"
5904
+ || entry.status !== NATIVE_REVIEW_AUTHORITY_STATUS.ACTIVE
5905
+ || (entry.state !== undefined && NATIVE_ABANDON_TERMINAL_STATES.has(entry.state))
5906
+ || entry.discardedWork === undefined
5907
+ || !isCanonicalProcessString(entry.revision)
5908
+ || entry.snapshotIdentity === undefined) return undefined;
5909
+ return entry;
5910
+ }
5911
+
5912
+ function sameOrderedLensList(actual: readonly string[], expected: readonly string[]): boolean {
5913
+ return actual.length === expected.length && actual.every((lens, index) => lens === expected[index]);
5914
+ }
5915
+
5916
+ /**
5917
+ * ABANDON freshly reads the native authority inventory, locates the single
5918
+ * eligible compact-v2 entry for the caller-specified lineage, derives its
5919
+ * revision, snapshot identity, and discarded-work summary, and renders the
5920
+ * exact eight-line `gentle-ai.review-abandon-authorization/v2` binding for
5921
+ * interactive approval before any mutation (issue #1159). The caller supplies
5922
+ * only lineage, actor, and reason; every inventory-derived field is derived,
5923
+ * never requested, because no negotiated facade operation publishes the
5924
+ * discarded-work projection (the encoding the native gate recomputes carries
5925
+ * the ordinal-prefixed lens order, which a caller cannot reconstruct).
5926
+ */
5927
+ async function executeNativeAbandon(
5928
+ input: Record<string, unknown>,
5929
+ lineageParameter: string | undefined,
5930
+ cwd: string,
5931
+ nativeReviewCli: NativeReviewCli | null,
5932
+ signal: AbortSignal | undefined,
5933
+ context: ExtensionContext | undefined,
5934
+ ): Promise<Record<string, unknown>> {
5935
+ const operation = REVIEW_CONTROLLER_OPERATION.ABANDON;
5936
+ const nativeOperation = "review abandon";
5937
+ if (nativeReviewCli?.abandon === undefined || nativeReviewCli.reviewStatus === undefined) {
5938
+ return { operation, status: "blocked", outcome: "native-maintenance-unavailable", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "install-package-local-gentle-ai-or-run-native-review-cli-directly" };
5939
+ }
5940
+ const unknown = Object.keys(input).filter((key) => !(NATIVE_ABANDON_INPUT as readonly string[]).includes(key));
5941
+ if (unknown.length > 0 || (input.lineage !== undefined && lineageParameter !== undefined && String(input.lineage) !== lineageParameter)) {
5942
+ return { operation, status: "blocked", outcome: "native-input-invalid", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-lineage-actor-and-reason-only-pi-derives-the-discarded-work-summary-from-fresh-native-inventory" };
5943
+ }
5944
+ const lineage = input.lineage !== undefined ? String(input.lineage) : lineageParameter;
5945
+ const missing = [
5946
+ ...(isCanonicalProcessString(lineage) ? [] : ["lineage"]),
5947
+ ...(isCanonicalProcessString(input.actor) ? [] : ["actor"]),
5948
+ ...(isCanonicalProcessString(input.reason) ? [] : ["reason"]),
5949
+ ];
5950
+ if (missing.length > 0) {
5951
+ return { operation, status: "blocked", outcome: "native-input-required", native_operation: nativeOperation, missing_input: missing, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-lineage-actor-and-reason" };
5952
+ }
5953
+ if (!isNativeReviewAbandonReason(input.reason)) {
5954
+ return { operation, status: "blocked", outcome: "native-input-invalid", native_operation: nativeOperation, allowed_reasons: NATIVE_REVIEW_ABANDON_REASONS, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-reason-operator_disposition-or-retired_schema" };
5955
+ }
5956
+ let inventory;
5957
+ try {
5958
+ inventory = await nativeReviewCli.reviewStatus({ cwd, ...(signal === undefined ? {} : { signal }) });
5959
+ } catch (error) {
5960
+ return nativeOperationFailure(operation, error);
5961
+ }
5962
+ if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled");
5963
+ const entry = nativeAbandonCandidate(inventory, lineage);
5964
+ if (entry === undefined) {
5965
+ return { operation, status: "blocked", outcome: "native-abandon-ineligible", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "inspect-complete-native-authority-inventory" };
5966
+ }
5967
+ const request = {
5968
+ cwd,
5969
+ lineage: entry.lineageId!,
5970
+ expectedRevision: entry.revision!,
5971
+ snapshotIdentity: entry.snapshotIdentity!,
5972
+ capturedLensResults: entry.discardedWork!.capturedLensResults,
5973
+ findingsPresent: entry.discardedWork!.findingsPresent,
5974
+ actor: String(input.actor),
5975
+ reason: input.reason,
5976
+ };
5977
+ const authorization = nativeReviewAbandonAuthorization(request);
5978
+ if (context?.hasUI !== true) throw new Error("Review controller ABANDON requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed");
5979
+ const approved = await context.ui.confirm(
5980
+ "Authorize review authority ABANDON?",
5981
+ ["Operation: ABANDON", "Exact published authorization binding:", authorization, "The native command may quarantine only an eligible pristine compact-v2 lineage."].join("\n"),
5982
+ );
5983
+ if (!approved) throw new Error("Review controller ABANDON was not explicitly authorized");
5984
+ // The approval binds the exact derived authority. Re-read the inventory and
5985
+ // fail closed on any drift before mutating, the way RECOVER rechecks its
5986
+ // provider-bound authorization (same TOCTOU window: another actor may have
5987
+ // advanced the lineage between the deriving read and the approval).
5988
+ if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled");
5989
+ let recheck;
5990
+ try {
5991
+ recheck = await nativeReviewCli.reviewStatus({ cwd, ...(signal === undefined ? {} : { signal }) });
5992
+ } catch (error) {
5993
+ return nativeOperationFailure(operation, error);
5994
+ }
5995
+ if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled");
5996
+ const reconfirmed = nativeAbandonCandidate(recheck, request.lineage);
5997
+ if (reconfirmed === undefined
5998
+ || reconfirmed.revision !== request.expectedRevision
5999
+ || reconfirmed.snapshotIdentity !== request.snapshotIdentity
6000
+ || reconfirmed.discardedWork!.findingsPresent !== request.findingsPresent
6001
+ || !sameOrderedLensList(reconfirmed.discardedWork!.capturedLensResults, request.capturedLensResults)) {
6002
+ return { operation, status: "blocked", outcome: "native-abandon-authority-changed", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "inspect-and-restart-abandon-from-fresh-inventory" };
6003
+ }
6004
+ if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled");
6005
+ try {
6006
+ const result = await nativeReviewCli.abandon({ ...request, maintainerAuthorization: authorization, ...(signal === undefined ? {} : { signal }) });
6007
+ return { operation, native_operation: nativeOperation, result: result.record, mutation_performed: true, mutation_outcome: "committed", next_action: "inspect" };
6008
+ } catch (error) {
6009
+ return nativeOperationFailure(operation, error);
6010
+ }
6011
+ }
6012
+
5434
6013
  const NATIVE_LEGACY_ALIAS_REPAIR_INPUT = ["lineage", "actor", "reason"] as const;
5435
6014
 
5436
6015
  async function executeNativeLegacyAliasRepair(
@@ -5513,6 +6092,7 @@ async function executeNativeRecoveryRoute(
5513
6092
  cwd: string,
5514
6093
  nativeReviewCli: NativeReviewCli | null,
5515
6094
  signal: AbortSignal | undefined,
6095
+ targetSelectors: { baseRef?: string; committedOnly?: boolean } = {},
5516
6096
  ): Promise<Record<string, unknown>> {
5517
6097
  const nativeCommand = `review ${nativeOperation}`;
5518
6098
  const method = nativeOperation === "reclaim" ? nativeReviewCli?.reclaim : nativeReviewCli?.recover;
@@ -5549,6 +6129,7 @@ async function executeNativeRecoveryRoute(
5549
6129
  ? await nativeReviewCli.reclaim!({ cwd, lineage: String(input.lineage), actor: String(input.actor), reason: String(input.reason), ...(signal === undefined ? {} : { signal }) })
5550
6130
  : await nativeReviewCli.recover!({
5551
6131
  cwd,
6132
+ ...targetSelectors,
5552
6133
  predecessorLineage: String(input.predecessorLineage),
5553
6134
  expectedPredecessorRevision: String(input.expectedPredecessorRevision),
5554
6135
  successorLineage: String(input.successorLineage),
@@ -5609,7 +6190,7 @@ function withoutRawCollectInputs(raw: Record<string, unknown>): Record<string, u
5609
6190
  return { ...raw, next_transition: transition };
5610
6191
  }
5611
6192
 
5612
- function mapNativeTargetStatus(operation: ReviewControllerOperation, status: ReviewStatusV3, requestedLineageId?: string): Record<string, unknown> {
6193
+ function mapNativeTargetStatus(operation: ReviewControllerOperation, status: ReviewStatusV3, requestedLineageId?: string, workspaceRoot?: string): Record<string, unknown> {
5613
6194
  if (
5614
6195
  status.nextTransition?.kind === "collect" &&
5615
6196
  (operation === REVIEW_CONTROLLER_OPERATION.START || operation === REVIEW_CONTROLLER_OPERATION.INSPECT || operation === REVIEW_CONTROLLER_OPERATION.STATUS)
@@ -5660,6 +6241,22 @@ function mapNativeTargetStatus(operation: ReviewControllerOperation, status: Rev
5660
6241
  ...(withdrawSlot === undefined ? {} : { hint: `run ${withdrawSlot.withdraw.command}` }),
5661
6242
  };
5662
6243
  }
6244
+ if (
6245
+ status.nextTransition?.kind === "execute" &&
6246
+ status.nextTransition.execute.operation === "review.acknowledge-approved"
6247
+ ) {
6248
+ const lineageId = status.authority?.lineageId ?? requestedLineageId;
6249
+ const nextAction = lineageId === undefined
6250
+ ? undefined
6251
+ : `gentle_review {"operation":"acknowledge-approved","lineageId":"${lineageId}"${workspaceRoot ? `,"workspaceRoot":${JSON.stringify(workspaceRoot)}` : ""}}`;
6252
+ return {
6253
+ operation,
6254
+ status: "blocked",
6255
+ result: status.raw,
6256
+ ...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }),
6257
+ ...(nextAction === undefined ? {} : { next_action: nextAction }),
6258
+ };
6259
+ }
5663
6260
  return {
5664
6261
  operation,
5665
6262
  status: status.action === "start" ? "ready" : "blocked",
@@ -5732,6 +6329,31 @@ const NATIVE_START_FOCUS = {
5732
6329
  } as const;
5733
6330
  type NativeStartFocus = (typeof NATIVE_START_FOCUS)[keyof typeof NATIVE_START_FOCUS];
5734
6331
 
6332
+ const NATIVE_START_LENSES = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
6333
+ const NATIVE_START_LENS_REASON_MAX = 500;
6334
+
6335
+ /**
6336
+ * Validates the optional agent lens selection of an ordinary START input
6337
+ * (gentle-ai START --lenses): both fields or neither, one to four known lens
6338
+ * names (short or review- form) without repeats, a non-empty single-line
6339
+ * reason of at most 500 bytes, and never together with focus. Returns the
6340
+ * canonical request fields, {} without a selection, or undefined when invalid.
6341
+ */
6342
+ function validateNativeStartLensSelection(rawStart: Record<string, unknown>): { lenses?: readonly string[]; lensesReason?: string } | undefined {
6343
+ const { lenses, lensesReason } = rawStart;
6344
+ if (lenses === undefined && lensesReason === undefined) return {};
6345
+ if (!Array.isArray(lenses) || lenses.length === 0 || typeof lensesReason !== "string" || rawStart.focus !== undefined) return undefined;
6346
+ if (lensesReason.trim() === "" || /[\r\n]/.test(lensesReason) || Buffer.byteLength(lensesReason, "utf8") > NATIVE_START_LENS_REASON_MAX) return undefined;
6347
+ const chosen = new Set<string>();
6348
+ for (const lens of lenses) {
6349
+ if (typeof lens !== "string") return undefined;
6350
+ const canonical = lens.startsWith("review-") ? lens : `review-${lens}`;
6351
+ if (!(NATIVE_START_LENSES as readonly string[]).includes(canonical) || chosen.has(canonical)) return undefined;
6352
+ chosen.add(canonical);
6353
+ }
6354
+ return { lenses: NATIVE_START_LENSES.filter((lens) => chosen.has(lens)), lensesReason };
6355
+ }
6356
+
5735
6357
  function isNativeStartFocus(value: unknown): value is NativeStartFocus {
5736
6358
  return typeof value === "string" && (Object.values(NATIVE_START_FOCUS) as readonly string[]).includes(value);
5737
6359
  }
@@ -5804,12 +6426,13 @@ function validateNativeStartUntrackedSelection(value: Record<string, unknown>):
5804
6426
  !isCanonicalProcessString(expectedUntrackedInventory) ||
5805
6427
  (intendedUntracked !== undefined && (!Array.isArray(intendedUntracked) || intendedUntracked.some((path) => !isNativeStartUntrackedPath(path) || intendedUntracked.indexOf(path) !== intendedUntracked.lastIndexOf(path))))
5806
6428
  ) return { reason: "untracked-selection-invalid" };
5807
- if (scope === NATIVE_START_UNTRACKED_SCOPE.EXCLUDE && (intendedUntracked?.length ?? 0) > 0) return { reason: "untracked-selection-invalid" };
5808
- if (scope === NATIVE_START_UNTRACKED_SCOPE.SELECT && (intendedUntracked?.length ?? 0) === 0) return { reason: "untracked-selection-invalid" };
6429
+ const paths = Array.isArray(intendedUntracked) ? intendedUntracked : [];
6430
+ if (scope === NATIVE_START_UNTRACKED_SCOPE.EXCLUDE && paths.length > 0) return { reason: "untracked-selection-invalid" };
6431
+ if (scope === NATIVE_START_UNTRACKED_SCOPE.SELECT && paths.length === 0) return { reason: "untracked-selection-invalid" };
5809
6432
  return {
5810
6433
  untrackedScope: scope,
5811
6434
  expectedUntrackedInventory,
5812
- intendedUntracked: intendedUntracked === undefined ? [] : [...intendedUntracked],
6435
+ intendedUntracked: [...paths],
5813
6436
  };
5814
6437
  }
5815
6438
 
@@ -5835,7 +6458,7 @@ function nativeStartRejection(reason: string, field?: string): Record<string, un
5835
6458
  ? "native-start-committed-only-required"
5836
6459
  : reason === "committed-only-invalid"
5837
6460
  ? "native-start-committed-only-invalid"
5838
- : reason === "unknown-field" || reason === "focus-invalid" || reason === "untracked-selection-invalid" || reason === "provider-base-offer-invalid" || reason === "provider-base-tree-mismatch"
6461
+ : reason === "unknown-field" || reason === "focus-invalid" || reason === "lenses-invalid" || reason === "untracked-selection-invalid" || reason === "provider-base-offer-invalid" || reason === "provider-base-tree-mismatch"
5839
6462
  ? "native-start-input-invalid"
5840
6463
  : "native-start-policy-path-invalid",
5841
6464
  reason,
@@ -5871,7 +6494,6 @@ function nativeInspectInputRejection(reason: string, field?: string): Record<str
5871
6494
  };
5872
6495
  }
5873
6496
 
5874
- const PENDING_REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
5875
6497
  const REVIEW_SESSION_PERMISSION_STATUS_KEY = "gentle-review-session-permission";
5876
6498
  const REVIEW_SESSION_PERMISSION_STATUS_TEXT = "reviews allowed for this session";
5877
6499
 
@@ -5888,12 +6510,9 @@ interface PendingReviewConsent {
5888
6510
  untrackedSelection?: RetainedNativeUntrackedSelection;
5889
6511
  consent: ReviewConsentEnvelope;
5890
6512
  consentDigest: string;
5891
- expiresAt: number;
5892
- expiry?: ReturnType<typeof setTimeout>;
5893
6513
  }
5894
6514
 
5895
6515
  const PENDING_REVIEW_CONSENT_DISPOSITION = {
5896
- EXPIRED: "expired",
5897
6516
  CONSUMED: "consumed",
5898
6517
  } as const;
5899
6518
 
@@ -5966,12 +6585,6 @@ export class PendingReviewConsentRegistry {
5966
6585
  return true;
5967
6586
  }
5968
6587
 
5969
- expire(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
5970
- if (!this.remove(sessionKey, pending)) return false;
5971
- this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED);
5972
- return true;
5973
- }
5974
-
5975
6588
  discard(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): void {
5976
6589
  this.remove(sessionKey, pending);
5977
6590
  }
@@ -5999,6 +6612,11 @@ const processRetainedNativeStatusSelections = new Map<PendingReviewConsentSessio
5999
6612
  // and a fresh primary-loop start resets it to 0.
6000
6613
  const processAgentEndSubagentDepth = new Map<PendingReviewConsentSessionKey, number>();
6001
6614
 
6615
+ // gentle-shell#1064 slice 1: the parent-session profile binding store lives in
6616
+ // lib/session-profile-binding.ts (in-process, keyed by parent session id). The
6617
+ // panel binds on Enter; the launch resolver, the shell status reader, and the
6618
+ // usage provider scope read the same store through the lib.
6619
+
6002
6620
  // gentle-pi#677: gentle-ai#4309 owns anonymous usage telemetry end to end;
6003
6621
  // Pi only nudges it once per process. This is a plain process-lifetime
6004
6622
  // guard, not a session-keyed map, because the nudge is meant to fire at most
@@ -6021,24 +6639,13 @@ function pendingReviewConsentSessionKey(context: ExtensionContext | undefined, f
6021
6639
  }
6022
6640
 
6023
6641
  function consumePendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): boolean {
6024
- if (!registry.consume(sessionKey, pending)) return false;
6025
- if (pending.expiry !== undefined) clearTimeout(pending.expiry);
6026
- pending.expiry = undefined;
6027
- return true;
6642
+ return registry.consume(sessionKey, pending);
6028
6643
  }
6029
6644
 
6030
6645
  function discardPendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void {
6031
- if (pending.expiry !== undefined) clearTimeout(pending.expiry);
6032
- pending.expiry = undefined;
6033
6646
  registry.discard(sessionKey, pending);
6034
6647
  }
6035
6648
 
6036
- function expirePendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void {
6037
- if (pending.expiry !== undefined) clearTimeout(pending.expiry);
6038
- pending.expiry = undefined;
6039
- if (registry.expire(sessionKey, pending)) pending.cleanupCandidate();
6040
- }
6041
-
6042
6649
  function cleanupPendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void {
6043
6650
  discardPendingReviewConsent(pending, registry, sessionKey);
6044
6651
  pending.cleanupCandidate();
@@ -6048,21 +6655,8 @@ function cleanupAllPendingReviewConsents(registry: PendingReviewConsentRegistry,
6048
6655
  for (const pending of registry.take(sessionKey)) cleanupPendingReviewConsent(pending, registry, sessionKey);
6049
6656
  }
6050
6657
 
6051
- // An unused consent binding and the candidate view retained exclusively for
6052
- // that binding expire as one lifecycle unit. TTL expiry is observable the
6053
- // moment synchronous time says `expiresAt <= now`, so cleanup must be
6054
- // synchronous with respect to that observation — the queued cleanup
6055
- // macrotask is a safety net, not the authority. Pruning here (before any
6056
- // later START may reuse the retained view) keeps timer order from deciding
6057
- // correctness: a fresh candidate retry never reuses a view whose binding
6058
- // already expired, so it cannot trip `candidate-target-projection-drift`.
6059
- function pruneExpiredReviewConsents(registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey, now: () => number): void {
6060
- const pendingReviewConsents = registry.get(sessionKey);
6061
- if (pendingReviewConsents === undefined) return;
6062
- for (const pending of [...pendingReviewConsents.values()]) {
6063
- if (pending.expiresAt <= now()) expirePendingReviewConsent(pending, registry, sessionKey);
6064
- }
6065
- }
6658
+ // Pending consent waits for the human without a deadline. Candidate verification,
6659
+ // one-shot consumption, replacement, and session teardown still own its lifecycle.
6066
6660
 
6067
6661
  function reviewConsentDigest(consent: ReviewConsentEnvelope): string {
6068
6662
  return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
@@ -6124,13 +6718,12 @@ function completedGrantedReviewConsent(outcome: Record<string, unknown>): boolea
6124
6718
  }
6125
6719
 
6126
6720
  // gentle-pi#516: a binding this session does not hold (already answered,
6127
- // expired, or issued by another Pi session or process) used to fall through
6721
+ // or issued by another Pi process) used to fall through
6128
6722
  // to the plain negotiated STATUS, which reads exactly like a healthy pre-start
6129
6723
  // "ready" and sent the model back into START for a second consent prompt. The
6130
6724
  // fact is local and proven before any provider call, so the outcome names the
6131
6725
  // binding and the exit; the current STATUS rides along as context only.
6132
6726
  const STALE_CONSENT_BINDING_DIAGNOSTIC_CODE = {
6133
- EXPIRED: "consent-binding-expired",
6134
6727
  ALREADY_CONSUMED: "consent-binding-already-consumed",
6135
6728
  UNKNOWN: "consent-binding-unknown",
6136
6729
  } as const;
@@ -6144,17 +6737,14 @@ interface StaleConsentBindingDiagnostics {
6144
6737
 
6145
6738
  function staleConsentBindingDiagnostics(binding: string, disposition: PendingReviewConsentDisposition | undefined): StaleConsentBindingDiagnostics {
6146
6739
  const exit = "Run START again for this candidate to obtain a fresh consent envelope and answer that envelope's binding once; do not resend this binding.";
6147
- if (disposition === PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED) {
6148
- return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.EXPIRED, message: `consent binding ${binding} expired after ${PENDING_REVIEW_CONSENT_TTL_MS / 60_000} minutes without an answer. ${exit}` };
6149
- }
6150
6740
  if (disposition === PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED) {
6151
6741
  return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.ALREADY_CONSUMED, message: `consent binding ${binding} was already consumed by an earlier answer. ${exit}` };
6152
6742
  }
6153
6743
  return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.UNKNOWN, message: `consent binding ${binding} is not held by this Pi session. ${exit}` };
6154
6744
  }
6155
6745
 
6156
- function staleConsentBindingOutcome(operation: ReviewControllerOperation, binding: string, diagnostics: ReturnType<typeof staleConsentBindingDiagnostics>, status: ReviewStatusV3): Record<string, unknown> {
6157
- const mapped = mapNativeTargetStatus(operation, status);
6746
+ function staleConsentBindingOutcome(operation: ReviewControllerOperation, binding: string, diagnostics: ReturnType<typeof staleConsentBindingDiagnostics>, status: ReviewStatusV3, workspaceRoot?: string): Record<string, unknown> {
6747
+ const mapped = mapNativeTargetStatus(operation, status, undefined, workspaceRoot);
6158
6748
  return {
6159
6749
  ...mapped,
6160
6750
  status: "blocked",
@@ -6344,10 +6934,10 @@ function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_r
6344
6934
  const nativeCliError = asNativeReviewCliError(error);
6345
6935
  if (nativeCliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) return nativeStatusPackageBinaryMissing(operation, nativeCliError.diagnostics);
6346
6936
  const nativeDiagnostics = nativeCliError?.diagnostics;
6347
- // A target-status probe verifies `version` before it invokes `review/status`.
6348
- // Preserve either already-sanitized diagnostic on every controller route rather
6349
- // than relabeling an actionable failure as an opaque controller failure.
6350
- const preservesNativeTargetStatusDiagnostic = nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.VERSION || nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.STATUS;
6937
+ // Target-status probes use the adapter's review/status diagnostic domain.
6938
+ // Preserve that sanitized diagnostic on every controller route rather than
6939
+ // relabeling an actionable failure as an opaque controller failure.
6940
+ const preservesNativeTargetStatusDiagnostic = nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.STATUS;
6351
6941
  const preservesAnswerConsentStartDiagnostic = operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT && nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.START;
6352
6942
  const diagnostics = operation === REVIEW_CONTROLLER_OPERATION.START && error instanceof CandidateViewError && value.candidateViewPreNative === true
6353
6943
  ? error.diagnostics ?? { code: error.reason, message: "candidate view rejected before native START" }
@@ -6547,7 +7137,7 @@ function retainNativeUntrackedSelection(selections: Map<string, RetainedNativeSt
6547
7137
 
6548
7138
  function readRetainedNativeUntrackedSelection(selections: Map<string, RetainedNativeStatusSelection>, workspaceRoot: string, lineageId: string): NativeStartUntrackedSelection {
6549
7139
  const selection = selections.get(reviewLifecycleStorageKey(workspaceRoot, lineageId));
6550
- return selection === undefined || "baseRef" in selection || "selectionBinding" in selection
7140
+ return selection === undefined || !("untrackedScope" in selection) || "targetIdentity" in selection
6551
7141
  ? {}
6552
7142
  : {
6553
7143
  untrackedScope: selection.untrackedScope,
@@ -6729,11 +7319,16 @@ function reviewHostRelaySelection(lens: string | undefined, config: AgentModelCo
6729
7319
  function mapLastEventClosure(
6730
7320
  closure: ReviewLastEventClosureV1,
6731
7321
  binding: ReviewLastEventClosureBinding,
7322
+ workspaceRoot?: string,
7323
+ implicitWorkspaceRoot: string = process.cwd(),
6732
7324
  ): Record<string, unknown> {
6733
7325
  if (closure.lineageId !== binding.lineageId) throw new CandidateViewError("last-event closure returned a different lineage", "last-event-closure-binding-drift");
6734
7326
  if (binding.targetIdentity !== undefined && closure.targetIdentity !== undefined && closure.targetIdentity !== binding.targetIdentity) {
6735
7327
  throw new CandidateViewError("last-event closure returned a different target", "last-event-closure-binding-drift");
6736
7328
  }
7329
+ const nextAction = closure.acknowledgement !== undefined
7330
+ ? `gentle_review {"operation":"acknowledge-approved","lineageId":"${closure.lineageId}"${workspaceRoot && (workspaceRoot !== implicitWorkspaceRoot || workspaceRoot !== process.cwd()) ? `,"workspaceRoot":${JSON.stringify(workspaceRoot)}` : ""}}`
7331
+ : undefined;
6737
7332
  return {
6738
7333
  tool: "gentle_review_capture",
6739
7334
  status: "closed",
@@ -6749,6 +7344,12 @@ function mapLastEventClosure(
6749
7344
  ...(closure.requestHash === undefined ? {} : { request_hash: closure.requestHash }),
6750
7345
  ...(closure.correctionLines === undefined ? {} : { correction_lines: closure.correctionLines }),
6751
7346
  ...(closure.advisoryFindings === undefined ? {} : { advisory_findings: closure.advisoryFindings }),
7347
+ ...(closure.escalation === undefined ? {} : { escalation: {
7348
+ cause: closure.escalation.cause,
7349
+ finding_ids: closure.escalation.findingIds,
7350
+ ...(closure.escalation.refuterOutcomes === undefined ? {} : { refuter_outcomes: closure.escalation.refuterOutcomes.map(({ findingId, ...outcome }) => ({ finding_id: findingId, ...outcome })) }),
7351
+ } }),
7352
+ ...(closure.targetedValidatorEvidence === undefined ? {} : { targeted_validator_evidence: closure.targetedValidatorEvidence.raw }),
6752
7353
  ...(closure.statusContinuation === undefined ? {} : { status_continuation: closure.statusContinuation.raw }),
6753
7354
  // The host has to see the acknowledgement to run it: approval now
6754
7355
  // waits for that exact invocation instead of burning on its own, so
@@ -6758,10 +7359,12 @@ function mapLastEventClosure(
6758
7359
  // host is approved and cannot end it here, and silence would read as
6759
7360
  // nothing left to do.
6760
7361
  ...(closure.acknowledgementUndecodable === undefined ? {} : { acknowledgement_undecodable: true }),
7362
+ ...(nextAction === undefined ? {} : { next_action: nextAction }),
6761
7363
  },
6762
7364
  lineage_id: closure.lineageId,
6763
7365
  state: closure.state,
6764
7366
  store_revision: closure.storeRevision,
7367
+ ...(nextAction === undefined ? {} : { next_action: nextAction }),
6765
7368
  };
6766
7369
  }
6767
7370
 
@@ -6770,8 +7373,9 @@ function mapAndClearLastEventClosure(
6770
7373
  binding: ReviewLastEventClosureBinding,
6771
7374
  selections: Map<string, RetainedNativeStatusSelection>,
6772
7375
  workspaceRoot: string,
7376
+ implicitWorkspaceRoot?: string,
6773
7377
  ): Record<string, unknown> {
6774
- const mapped = mapLastEventClosure(closure, binding);
7378
+ const mapped = mapLastEventClosure(closure, binding, workspaceRoot, implicitWorkspaceRoot);
6775
7379
  clearRetainedNativeStatusSelectionsOnTerminal(selections, workspaceRoot, closure.lineageId, closure.state);
6776
7380
  return mapped;
6777
7381
  }
@@ -6831,6 +7435,8 @@ async function executeReviewHostRelayCapture(
6831
7435
  // attribution header. Appended last so every existing positional call site
6832
7436
  // keeps compiling unchanged.
6833
7437
  reviewerSessionId?: string,
7438
+ implicitWorkspaceRoot?: string,
7439
+ getReviewerTransport?: () => ReviewHostRelayRequest["reviewerTransport"],
6834
7440
  ): Promise<Record<string, unknown>> {
6835
7441
  try {
6836
7442
  if (slot.submission === undefined) {
@@ -6840,6 +7446,7 @@ async function executeReviewHostRelayCapture(
6840
7446
  REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
6841
7447
  );
6842
7448
  }
7449
+ const reviewerTransport = getReviewerTransport?.();
6843
7450
  const result = await activeReviewHostRelayRunner((() => {
6844
7451
  // gentle-pi#311 P2 / P3: the lens's (or, for a v9 host-mediated role
6845
7452
  // slot, the fixed review-refuter/review-validator routing key's)
@@ -6852,7 +7459,7 @@ async function executeReviewHostRelayCapture(
6852
7459
  // global routing. A pin that omits a required role stays omitted: the
6853
7460
  // typed reviewer-config-invalid refusal below is fail-closed, never a
6854
7461
  // silent per-role fallback to another account's routing.
6855
- const launch = reviewHostRelaySelection(slot.routingKey ?? slot.lens, pinnedEffectiveModelConfig(cwd) ?? readModelConfig(cwd));
7462
+ const launch = reviewHostRelaySelection(slot.routingKey ?? slot.lens, readReviewerModelConfig(cwd, reviewerSessionId));
6856
7463
  return {
6857
7464
  captureArgumentTokens: slot.captureArgumentTokens,
6858
7465
  targetCwd: cwd,
@@ -6860,11 +7467,12 @@ async function executeReviewHostRelayCapture(
6860
7467
  ...launch,
6861
7468
  ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
6862
7469
  ...(reviewerSessionId === undefined ? {} : { reviewerSessionId }),
7470
+ ...(reviewerTransport === undefined ? {} : { reviewerTransport }),
6863
7471
  ...(signal === undefined ? {} : { signal }),
6864
7472
  };
6865
7473
  })());
6866
7474
  const closure = decodeRelayLastEventClosure(result.submission);
6867
- if (closure !== undefined) return mapAndClearLastEventClosure(closure, binding, selections, cwd);
7475
+ if (closure !== undefined) return mapAndClearLastEventClosure(closure, binding, selections, cwd, implicitWorkspaceRoot);
6868
7476
  return {
6869
7477
  tool: "gentle_review_capture",
6870
7478
  status: "captured",
@@ -7049,6 +7657,7 @@ async function executeProviderRoleVectorCapture(
7049
7657
  selections: Map<string, RetainedNativeStatusSelection>,
7050
7658
  route: RetainedNativeCaptureRoute | undefined,
7051
7659
  signal?: AbortSignal,
7660
+ implicitWorkspaceRoot?: string,
7052
7661
  ): Promise<Record<string, unknown>> {
7053
7662
  if (nativeReviewCli.captureProviderRole === undefined) {
7054
7663
  return {
@@ -7067,7 +7676,7 @@ async function executeProviderRoleVectorCapture(
7067
7676
  cwd,
7068
7677
  ...(signal === undefined ? {} : { signal }),
7069
7678
  });
7070
- if ("operation" in artifact) return mapAndClearLastEventClosure(artifact, binding, selections, cwd);
7679
+ if ("operation" in artifact) return mapAndClearLastEventClosure(artifact, binding, selections, cwd, implicitWorkspaceRoot);
7071
7680
  return {
7072
7681
  tool: "gentle_review_capture",
7073
7682
  status: "captured",
@@ -7213,20 +7822,32 @@ async function negotiatedStatusForHostTransport(
7213
7822
  // an effective-off mode, or any STATUS error or transport refusal. Startup
7214
7823
  // negotiation and mutation-gated `agent_end` use the same native whole-target
7215
7824
  // path; neither derives candidate scope from local mutation receipts.
7825
+ // S5 (startup-home-repo-and-changes): the startup negotiation runs after
7826
+ // session_start returns, bounded by this deadline. Its in-flight review mode
7827
+ // read is kept per cwd so the first prompt waits for it instead of spawning
7828
+ // the same STATUS again.
7829
+ const STARTUP_NEGOTIATION_TIMEOUT_MS = 10_000;
7830
+ const startupModeReads = new Map<string, Promise<unknown>>();
7831
+ const startupNegotiations = new Set<Promise<void>>();
7832
+
7216
7833
  async function resolveNegotiatedReviewStatusForSession(
7217
7834
  nativeReviewCli: NativeReviewCli | null,
7218
7835
  ctx: ExtensionContext,
7219
7836
  sessionKey: PendingReviewConsentSessionKey,
7837
+ signal?: AbortSignal,
7220
7838
  ): Promise<ReviewStatusV3 | undefined> {
7221
7839
  if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return undefined;
7222
7840
  if (ctx.hasUI !== true || !resolveSessionWorktree(ctx.cwd, ctx.cwd)) return undefined;
7223
7841
  // STATUS can prepare an unversioned directory natively. Only a successfully
7224
7842
  // decoded effective-on mode authorizes that entry; bootstrap never counts as
7225
7843
  // an own mutation and native owns all metadata/ancestor safety decisions.
7226
- if (!await nativeRepositoryPreparationEnabled(nativeReviewCli, ctx.cwd)) return undefined;
7844
+ const modeRead = nativeRepositoryPreparationEnabled(nativeReviewCli, ctx.cwd, signal, true);
7845
+ startupModeReads.set(ctx.cwd, modeRead);
7846
+ const enabled = await modeRead.finally(() => { if (startupModeReads.get(ctx.cwd) === modeRead) startupModeReads.delete(ctx.cwd); });
7847
+ if (!enabled) return undefined;
7227
7848
  try {
7228
7849
  const retainedSelections = ((key: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(key) ?? processRetainedNativeStatusSelections.set(key, new Map()).get(key)!)(sessionKey);
7229
- const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: ctx.cwd }, retainedSelections, ctx.cwd);
7850
+ const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: ctx.cwd, ...(signal === undefined ? {} : { signal }) }, retainedSelections, ctx.cwd);
7230
7851
  return negotiated.status;
7231
7852
  } catch {
7232
7853
  return undefined;
@@ -7381,10 +8002,15 @@ function captureGroupAuthorityDrift(status: ReviewStatusV3): Record<string, unkn
7381
8002
  }
7382
8003
 
7383
8004
  interface SelectedReviewCaptureGroup {
8005
+ selected: true;
7384
8006
  slots: readonly ReviewHostRelaySlot[];
7385
8007
  binding: ReviewLastEventClosureBinding;
7386
8008
  }
7387
8009
 
8010
+ function isSelectedReviewCaptureGroup(value: SelectedReviewCaptureGroup | Record<string, unknown>): value is SelectedReviewCaptureGroup {
8011
+ return value.selected === true;
8012
+ }
8013
+
7388
8014
  function selectExactReviewCaptureGroup(
7389
8015
  status: ReviewStatusV3,
7390
8016
  lineageId: string,
@@ -7430,7 +8056,7 @@ function selectExactReviewCaptureGroup(
7430
8056
  }
7431
8057
  lenses.add(lens); orders.add(order); subjectHashes.add(subject.subjectHash);
7432
8058
  }
7433
- return { slots, binding: first.binding };
8059
+ return { selected: true, slots, binding: first.binding };
7434
8060
  }
7435
8061
 
7436
8062
  function reviewHostRelayGroupFailure(
@@ -7468,6 +8094,8 @@ async function executeReviewCaptureOperation(
7468
8094
  // x-opencode-session attribution header. Appended last for the same
7469
8095
  // positional-call-site reason as modelRegistry above.
7470
8096
  reviewerSessionId?: string,
8097
+ // Read effective settings only when the acknowledged capture actually runs.
8098
+ getReviewerTransport?: () => ReviewHostRelayRequest["reviewerTransport"],
7471
8099
  ): Promise<Record<string, unknown>> {
7472
8100
  const parameters = parseReviewCaptureParameters(parametersValue);
7473
8101
  if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) {
@@ -7481,6 +8109,7 @@ async function executeReviewCaptureOperation(
7481
8109
  }
7482
8110
  const canonicalBinding = parseCanonicalReviewCaptureBinding(parameters.collectBinding);
7483
8111
  const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
8112
+ const implicitWorkspaceRoot = candidateViews?.resolveWorkspaceRoot(parameters.lineageId) ?? sessionCwd;
7484
8113
  let route = readRetainedNativeCaptureRoute(retainedUntrackedSelections, canonicalBinding);
7485
8114
  if (requireRegisteredRoute && route !== undefined && (route.workspaceRoot !== cwd || route.lineageId !== parameters.lineageId)) {
7486
8115
  return captureBindingRejected("collectBinding belongs to a different registered route");
@@ -7531,7 +8160,7 @@ async function executeReviewCaptureOperation(
7531
8160
  mutation_outcome: "none",
7532
8161
  };
7533
8162
  }
7534
- return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId));
8163
+ return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId, implicitWorkspaceRoot, getReviewerTransport));
7535
8164
  }
7536
8165
 
7537
8166
  // gentle-pi#311 P3: gentle-ai's v9 contract renders the refuter and
@@ -7557,7 +8186,7 @@ async function executeReviewCaptureOperation(
7557
8186
  mutation_outcome: "none",
7558
8187
  };
7559
8188
  }
7560
- return withCorrectionTarget(await executeReviewHostRelayCapture(hostMediatedRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId));
8189
+ return withCorrectionTarget(await executeReviewHostRelayCapture(hostMediatedRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId, implicitWorkspaceRoot, getReviewerTransport));
7561
8190
  }
7562
8191
 
7563
8192
  if (selected.input.captureOperation === "review.capture-correction-plan") {
@@ -7587,7 +8216,7 @@ async function executeReviewCaptureOperation(
7587
8216
  cwd,
7588
8217
  ...(signal === undefined ? {} : { signal }),
7589
8218
  });
7590
- return withCorrectionTarget(mapAndClearLastEventClosure(closure, selected.binding, retainedUntrackedSelections, cwd));
8219
+ return withCorrectionTarget(mapAndClearLastEventClosure(closure, selected.binding, retainedUntrackedSelections, cwd, implicitWorkspaceRoot));
7591
8220
  } catch (error) {
7592
8221
  return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route);
7593
8222
  }
@@ -7598,7 +8227,7 @@ async function executeReviewCaptureOperation(
7598
8227
  if (parameters.reviewerRunAcknowledged !== undefined || parameters.correctionLines !== undefined) {
7599
8228
  return captureBindingRejected("reviewerRunAcknowledged and correctionLines are not valid for a provider role capture");
7600
8229
  }
7601
- return withCorrectionTarget(await executeProviderRoleVectorCapture(providerRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal));
8230
+ return withCorrectionTarget(await executeProviderRoleVectorCapture(providerRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, implicitWorkspaceRoot));
7602
8231
  }
7603
8232
  return captureBindingRejected(`unsupported provider capture operation: ${selected.input.captureOperation}`);
7604
8233
  }
@@ -7642,11 +8271,13 @@ async function executeReviewCaptureGroupOperation(
7642
8271
  // OpenCode-routed reviewer model carries its x-opencode-session attribution
7643
8272
  // header. Appended last for the same positional-call-site reason as above.
7644
8273
  reviewerSessionId?: string,
8274
+ getReviewerTransport?: () => ReviewHostRelayRequest["reviewerTransport"],
7645
8275
  ): Promise<Record<string, unknown>> {
7646
8276
  const parameters = parseReviewCaptureGroupParameters(parametersValue);
7647
8277
  if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) return { ...captureGroupRejected("native target STATUS is unavailable"), outcome: "native-status-unsupported" };
7648
8278
  const canonicalBindings = parameters.collectBindings.map((binding) => parseCanonicalReviewCaptureBinding(binding));
7649
8279
  const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
8280
+ const implicitWorkspaceRoot = candidateViews?.resolveWorkspaceRoot(parameters.lineageId) ?? sessionCwd;
7650
8281
  const routes = canonicalBindings.map((binding) => readRetainedNativeCaptureRoute(retainedUntrackedSelections, binding));
7651
8282
  let route = routes.find((candidate) => candidate !== undefined);
7652
8283
  if (requireRegisteredRoute && routes.some((candidate) => candidate !== undefined && (candidate.workspaceRoot !== cwd || candidate.lineageId !== parameters.lineageId || candidate.baseRef !== route?.baseRef))) {
@@ -7668,7 +8299,7 @@ async function executeReviewCaptureGroupOperation(
7668
8299
  return { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "native-status-failed" };
7669
8300
  }
7670
8301
  const group = selectExactReviewCaptureGroup(status, parameters.lineageId, canonicalBindings);
7671
- if (!("slots" in group && "binding" in group)) return group;
8302
+ if (!isSelectedReviewCaptureGroup(group)) return group;
7672
8303
  route = { workspaceRoot: cwd, lineageId: parameters.lineageId, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }) };
7673
8304
  if (parameters.reviewerRunAcknowledged !== true) {
7674
8305
  return {
@@ -7680,10 +8311,10 @@ async function executeReviewCaptureGroupOperation(
7680
8311
  mutation_outcome: "none",
7681
8312
  };
7682
8313
  }
7683
- // One routing snapshot for the whole group: the pin-over-global precedence
7684
- // is identical for every slot, so resolving it once before the map avoids
7685
- // re-running the pin resolver and config reads per slot.
7686
- const reviewerRouting = pinnedEffectiveModelConfig(cwd) ?? readModelConfig(cwd);
8314
+ // Resolve session-over-pin-over-global once for the entire reviewer group,
8315
+ // so every slot uses the same complete routing snapshot.
8316
+ const reviewerRouting = readReviewerModelConfig(cwd, reviewerSessionId);
8317
+ const reviewerTransport = getReviewerTransport?.();
7687
8318
  const requests: readonly ReviewHostRelayRequest[] = group.slots.map((slot) => ({
7688
8319
  captureArgumentTokens: slot.captureArgumentTokens,
7689
8320
  targetCwd: cwd,
@@ -7691,6 +8322,7 @@ async function executeReviewCaptureGroupOperation(
7691
8322
  ...reviewHostRelaySelection(slot.lens, reviewerRouting),
7692
8323
  ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
7693
8324
  ...(reviewerSessionId === undefined ? {} : { reviewerSessionId }),
8325
+ ...(reviewerTransport === undefined ? {} : { reviewerTransport }),
7694
8326
  ...(signal === undefined ? {} : { signal }),
7695
8327
  }));
7696
8328
  let prepared: readonly ReviewHostRelayPreparedResult[];
@@ -7717,7 +8349,7 @@ async function executeReviewCaptureGroupOperation(
7717
8349
  const result = await activeReviewHostRelaySubmissionRunner(prepared[index]!);
7718
8350
  const closure = decodeRelayLastEventClosure(result.submission);
7719
8351
  if (closure !== undefined) {
7720
- const closed = mapAndClearLastEventClosure(closure, current.binding, retainedUntrackedSelections, cwd);
8352
+ const closed = mapAndClearLastEventClosure(closure, current.binding, retainedUntrackedSelections, cwd, implicitWorkspaceRoot);
7721
8353
  return { ...closed, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index + 1) };
7722
8354
  }
7723
8355
  } catch (error) {
@@ -7770,8 +8402,6 @@ async function executeReviewControllerOperation(
7770
8402
  retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
7771
8403
  pendingReviewConsentRegistry: PendingReviewConsentRegistry = processPendingReviewConsentRegistry,
7772
8404
  pendingReviewConsentFallbackKey: symbol = Symbol("pending-review-consent-fallback"),
7773
- reviewConsentNow: () => number = Date.now,
7774
- reviewConsentScheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void } = setTimeout,
7775
8405
  intendedUntrackedSelection?: NativeIntendedUntrackedSelectionSubmission,
7776
8406
  ): Promise<Record<string, unknown>> {
7777
8407
  const parameters = parseReviewControllerParameters(parametersValue);
@@ -7821,6 +8451,10 @@ async function executeReviewControllerOperation(
7821
8451
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
7822
8452
  return await executeNativeLegacyAliasRepair(input, defaultCwd, nativeReviewCli, signal, context);
7823
8453
  }
8454
+ if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ABANDON) {
8455
+ const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
8456
+ return await executeNativeAbandon(input, parameters.lineageId, defaultCwd, nativeReviewCli, signal, context);
8457
+ }
7824
8458
  const maintenance = nativeMaintenanceOperation(parameters.operation);
7825
8459
  if (maintenance !== undefined) {
7826
8460
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
@@ -7885,6 +8519,7 @@ async function executeReviewControllerOperation(
7885
8519
  parameters.operation,
7886
8520
  status,
7887
8521
  undefined,
8522
+ includeWorkspaceRoot ? defaultCwd : undefined,
7888
8523
  );
7889
8524
  if (parameters.untrackedScope === undefined) {
7890
8525
  if (canonicalBaseRef !== undefined && typeof plainMapped.selectionBinding === "string") {
@@ -7996,6 +8631,7 @@ async function executeReviewControllerOperation(
7996
8631
  parameters.operation,
7997
8632
  resolvedStatus,
7998
8633
  undefined,
8634
+ includeWorkspaceRoot ? defaultCwd : undefined,
7999
8635
  );
8000
8636
  return {
8001
8637
  ...resolvedMapped,
@@ -8020,7 +8656,7 @@ async function executeReviewControllerOperation(
8020
8656
  // A stuck legacy mutation lock is an incomplete in-flight entry; the
8021
8657
  // audited native quarantine owns its removal. Lock recovery is not a
8022
8658
  // destructive authority reset, so pending authorizations survive.
8023
- return await executeNativeRecoveryRoute(parameters.operation, "reclaim", input, defaultCwd, nativeReviewCli, undefined, signal);
8659
+ return await executeNativeRecoveryRoute(parameters.operation, "reclaim", input, defaultCwd, nativeReviewCli, signal);
8024
8660
  }
8025
8661
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RECOVER) {
8026
8662
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
@@ -8114,7 +8750,7 @@ async function executeReviewControllerOperation(
8114
8750
  next_action: "reinspect-and-reauthorize-recovery",
8115
8751
  };
8116
8752
  }
8117
- return await executeNativeRecoveryRoute(parameters.operation, "recover", { ...input, disposition: status.actionDisposition, maintainerAuthorization: recoverAuthorization }, defaultCwd, nativeReviewCli, signal);
8753
+ return await executeNativeRecoveryRoute(parameters.operation, "recover", { ...input, disposition: status.actionDisposition, maintainerAuthorization: recoverAuthorization }, defaultCwd, nativeReviewCli, signal, statusRequest.baseRef === undefined ? {} : { baseRef: statusRequest.baseRef, committedOnly: true });
8118
8754
  }
8119
8755
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET) {
8120
8756
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
@@ -8130,8 +8766,8 @@ async function executeReviewControllerOperation(
8130
8766
  return nativeStatusFailed(parameters.operation, error);
8131
8767
  }
8132
8768
  clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state); retainNativeCaptureRoutes(retainedUntrackedSelections, defaultCwd, status, frozenTarget?.committedOnly === true ? nativeCommittedRangeSelector(frozenTarget) : undefined);
8133
- if (status.authority?.version === "compact-v2") return { operation: parameters.operation, repaired: false, compact_authority: "immutable-untouched", status: mapNativeTargetStatus(parameters.operation, status, parameters.lineageId) };
8134
- if (status.authority?.version !== "legacy-v1") return mapNativeTargetStatus(parameters.operation, status, parameters.lineageId);
8769
+ if (status.authority?.version === "compact-v2") return { operation: parameters.operation, repaired: false, compact_authority: "immutable-untouched", status: mapNativeTargetStatus(parameters.operation, status, parameters.lineageId, includeWorkspaceRoot ? defaultCwd : undefined) };
8770
+ if (status.authority?.version !== "legacy-v1") return mapNativeTargetStatus(parameters.operation, status, parameters.lineageId, includeWorkspaceRoot ? defaultCwd : undefined);
8135
8771
  const store = ReviewTransactionStore.forRepository(defaultCwd);
8136
8772
  store.repairCurrentAuthority();
8137
8773
  return { operation: parameters.operation, repaired: true };
@@ -8140,6 +8776,11 @@ async function executeReviewControllerOperation(
8140
8776
  const controllerOnlyInput = ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource"]
8141
8777
  .find((key) => parameters[key as keyof ReviewControllerParameters] !== undefined);
8142
8778
  if (controllerOnlyInput !== undefined || !isCanonicalProcessString(parameters.lineageId)) {
8779
+ const implicitRoot = resolveReviewControllerWorkspaceRoot(undefined, sessionCwd, candidateViews, parameters.lineageId);
8780
+ const needsExplicitWorkspaceRoot = parameters.workspaceRoot !== undefined && parameters.workspaceRoot !== implicitRoot;
8781
+ const nextAction = isCanonicalProcessString(parameters.lineageId)
8782
+ ? `gentle_review {"operation":"acknowledge-approved","lineageId":"${parameters.lineageId}"${needsExplicitWorkspaceRoot ? `,"workspaceRoot":${JSON.stringify(parameters.workspaceRoot)}` : ""}}`
8783
+ : "resubmit-the-exact-lineage-without-controller-only-input";
8143
8784
  return {
8144
8785
  operation: parameters.operation,
8145
8786
  status: "blocked",
@@ -8148,7 +8789,7 @@ async function executeReviewControllerOperation(
8148
8789
  ...(controllerOnlyInput === undefined ? {} : { field: controllerOnlyInput }),
8149
8790
  mutation_performed: false,
8150
8791
  mutation_outcome: "none",
8151
- next_action: "resubmit-the-exact-lineage-without-controller-only-input",
8792
+ next_action: nextAction,
8152
8793
  };
8153
8794
  }
8154
8795
  const acknowledgementCli = nativeReviewCli as NativeReviewAcknowledgementCli | null;
@@ -8243,9 +8884,9 @@ async function executeReviewControllerOperation(
8243
8884
  outcome: "native-approved-acknowledgement-completed",
8244
8885
  lineage_id: parameters.lineageId,
8245
8886
  target_identity: status.targetIdentity,
8246
- ...(acknowledged === undefined ? {} : { consumed_revision: acknowledged.consumedRevision }),
8887
+ ...(acknowledged ? { consumed_revision: acknowledged.consumedRevision } : {}),
8247
8888
  authority: "burned",
8248
- ...(acknowledged === undefined ? {} : { burn_evidence: acknowledged.schema }),
8889
+ ...(acknowledged ? { burn_evidence: acknowledged.schema } : {}),
8249
8890
  delivery: "ordinary-repository-policy",
8250
8891
  mutation_performed: true,
8251
8892
  mutation_outcome: "committed",
@@ -8264,12 +8905,8 @@ async function executeReviewControllerOperation(
8264
8905
  const resolved = pendingReviewConsentRegistry.resolve(input.consentBinding);
8265
8906
  const pending = resolved?.pending;
8266
8907
  const owningSession = resolved?.sessionKey ?? pendingReviewConsentSession;
8267
- if (pending === undefined || pending.expiresAt <= reviewConsentNow()) {
8268
- const disposition = pending === undefined
8269
- ? pendingReviewConsentRegistry.staleDisposition(input.consentBinding)
8270
- : PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED;
8271
- const stale = staleConsentBindingDiagnostics(input.consentBinding, disposition);
8272
- if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
8908
+ if (pending === undefined) {
8909
+ const stale = staleConsentBindingDiagnostics(input.consentBinding, pendingReviewConsentRegistry.staleDisposition(input.consentBinding));
8273
8910
  if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
8274
8911
  try {
8275
8912
  const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
@@ -8277,7 +8914,7 @@ async function executeReviewControllerOperation(
8277
8914
  ...(signal === undefined ? {} : { signal }),
8278
8915
  }, retainedUntrackedSelections, defaultCwd);
8279
8916
  if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport);
8280
- return staleConsentBindingOutcome(parameters.operation, input.consentBinding, stale, negotiated.status!);
8917
+ return staleConsentBindingOutcome(parameters.operation, input.consentBinding, stale, negotiated.status!, includeWorkspaceRoot ? defaultCwd : undefined);
8281
8918
  } catch (error) {
8282
8919
  return nativeStatusFailed(parameters.operation, error);
8283
8920
  }
@@ -8299,7 +8936,7 @@ async function executeReviewControllerOperation(
8299
8936
  ...(signal === undefined ? {} : { signal }),
8300
8937
  }, retainedUntrackedSelections, defaultCwd);
8301
8938
  if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport);
8302
- return staleConsentBindingOutcome(parameters.operation, input.consentBinding, stale, negotiated.status!);
8939
+ return staleConsentBindingOutcome(parameters.operation, input.consentBinding, stale, negotiated.status!, includeWorkspaceRoot ? defaultCwd : undefined);
8303
8940
  } catch (error) {
8304
8941
  return nativeStatusFailed(parameters.operation, error);
8305
8942
  }
@@ -8377,7 +9014,7 @@ async function executeReviewControllerOperation(
8377
9014
  const rejected = (stopSelector !== undefined && stopSelector.targetIdentity !== status.targetIdentity) || input === undefined || canonicalReviewCaptureBinding(input) !== canonicalBinding || exactCollectArgument(input, "target_identity") !== status.targetIdentity || exactCollectArgument(input, "projection") !== status.projection.projection || exactCollectArgument(input, "base_tree") !== status.projection.baseTree || exactCollectArgument(input, "candidate_tree") !== status.projection.currentCandidateTree || !Array.isArray(eligible) || selected.reason !== undefined || selected.intendedUntracked!.some((path) => !eligible.includes(path));
8378
9015
  if (rejected) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" };
8379
9016
  const submission = { argumentTokens: input.submission!.argumentTokens, value: JSON.stringify({ schema: "gentle-ai.review-intended-untracked-selection/v1", untracked_scope: scope, expected_untracked_inventory: inventory, intended_untracked: selected.intendedUntracked }) };
8380
- const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, ...committedSelector, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, reviewConsentNow, reviewConsentScheduleTimer, submission);
9017
+ const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, ...committedSelector, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, submission);
8381
9018
  return { ...result, operation: parameters.operation };
8382
9019
  }
8383
9020
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
@@ -8387,10 +9024,12 @@ async function executeReviewControllerOperation(
8387
9024
  );
8388
9025
  if (rawStart.mode === REVIEW_MODE.ORDINARY) {
8389
9026
  if ("policyHash" in rawStart) return nativeStartRejection("legacy-policy-hash-unsupported");
8390
- const unknownField = Object.keys(rawStart).find((field) => !["mode", "baseRef", "committedOnly", "policyPath", "focus", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"].includes(field));
9027
+ const unknownField = Object.keys(rawStart).find((field) => !["mode", "baseRef", "committedOnly", "policyPath", "focus", "lenses", "lensesReason", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"].includes(field));
8391
9028
  if (unknownField !== undefined) return nativeStartRejection("unknown-field", unknownField);
8392
9029
  const focus = rawStart.focus;
8393
9030
  if (focus !== undefined && !isNativeStartFocus(focus)) return nativeStartRejection("focus-invalid");
9031
+ const lensSelection = validateNativeStartLensSelection(rawStart);
9032
+ if (lensSelection === undefined) return nativeStartRejection("lenses-invalid");
8394
9033
  const policy: NativeStartPolicyValidation = rawStart.policyPath === undefined
8395
9034
  ? {}
8396
9035
  : validateNativeStartPolicyPath(defaultCwd, rawStart.policyPath);
@@ -8416,7 +9055,7 @@ async function executeReviewControllerOperation(
8416
9055
  : undefined;
8417
9056
  let canonicalBaseRef: string | undefined;
8418
9057
  let providerBaseTree: string | undefined;
8419
- if (baseRef !== undefined) {
9058
+ if (typeof baseRef === "string") {
8420
9059
  try {
8421
9060
  canonicalBaseRef = resolveCanonicalCandidateBase(defaultCwd, baseRef).commit;
8422
9061
  } catch (error) {
@@ -8543,26 +9182,20 @@ async function executeReviewControllerOperation(
8543
9182
  next_action: "inspect-and-resolve-the-current-intended-untracked-selection",
8544
9183
  };
8545
9184
  }
8546
- if (target.nextTransition?.kind === "collect" || target.applicability !== "unrelated" || target.action !== "start") return mapNativeTargetStatus(parameters.operation, target, parameters.lineageId);
9185
+ if (target.nextTransition?.kind === "collect" || target.applicability !== "unrelated" || target.action !== "start") return mapNativeTargetStatus(parameters.operation, target, parameters.lineageId, includeWorkspaceRoot ? defaultCwd : undefined);
8547
9186
  } catch (error) {
8548
9187
  return nativeOperationFailure(parameters.operation, error);
8549
9188
  }
8550
9189
  // gentle-pi#323: the replay key must fold in the current candidate
8551
9190
  // content identity. Without it, a second START with identical
8552
9191
  // {cwd, lineageId, input, inputPath} reuses a still-live (never
8553
- // lineage-bound) frozen candidate view from within the consent TTL
8554
- // window even after the live candidate content changed underneath
9192
+ // lineage-bound) frozen candidate view retained for pending consent
9193
+ // even after the live candidate content changed underneath
8555
9194
  // it, and dead-ends at candidate-target-projection-drift with no
8556
9195
  // recovery. Folding in currentCandidateTree makes a content change
8557
9196
  // mint a fresh replay key -- and therefore a fresh candidate view --
8558
9197
  // instead of reusing the stale one.
8559
9198
  const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null, candidateTree: target.projection.currentCandidateTree, providerBaseTree: providerBaseTree ?? null });
8560
- // Synchronously drop any binding whose TTL has already elapsed
8561
- // before reusing its retained candidate view, so a fresh-candidate
8562
- // retry cannot reuse a view tied to an expired binding and trip
8563
- // candidate-target-projection-drift. Timer order must not decide
8564
- // correctness: the queued cleanup macrotask may not have fired yet.
8565
- pruneExpiredReviewConsents(pendingReviewConsentRegistry, pendingReviewConsentSession, reviewConsentNow);
8566
9199
  const candidateIntendedUntracked = target.projection.intendedUntracked;
8567
9200
  let candidateView: ReturnType<CandidateViewRegistry["create"]> | undefined;
8568
9201
  let nativeStartAttempted = false;
@@ -8585,7 +9218,8 @@ async function executeReviewControllerOperation(
8585
9218
  ...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
8586
9219
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
8587
9220
  ...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
8588
- ...(focus === undefined ? {} : { focus }),
9221
+ ...(isNativeStartFocus(focus) ? { focus } : {}),
9222
+ ...lensSelection,
8589
9223
  ...(signal === undefined ? {} : { signal }),
8590
9224
  });
8591
9225
  } catch (error) {
@@ -8595,7 +9229,7 @@ async function executeReviewControllerOperation(
8595
9229
  const repositoryCwd = realpathSync(defaultCwd);
8596
9230
  const consentDigest = reviewConsentDigest(error.consent);
8597
9231
  const pendingReviewConsents = pendingReviewConsentRegistry.get(pendingReviewConsentSession);
8598
- const existing = [...(pendingReviewConsents?.values() ?? [])].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest && pending.expiresAt > reviewConsentNow());
9232
+ const existing = [...(pendingReviewConsents?.values() ?? [])].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest);
8599
9233
  if (existing === undefined) {
8600
9234
  for (const pending of [...(pendingReviewConsents?.values() ?? [])]) {
8601
9235
  if (pending.candidateView.token === consentCandidateView.token) {
@@ -8616,19 +9250,13 @@ async function executeReviewControllerOperation(
8616
9250
  cleanupCandidate: () => {
8617
9251
  if (candidateCleaned) return;
8618
9252
  candidateCleaned = true;
8619
- try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent expiry/teardown still completes. */ }
9253
+ try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent teardown still completes. */ }
8620
9254
  },
8621
9255
  ...(retainedUntrackedSelection === undefined ? {} : { untrackedSelection: retainedUntrackedSelection }),
8622
9256
  consent: error.consent,
8623
9257
  consentDigest,
8624
- expiresAt: reviewConsentNow() + PENDING_REVIEW_CONSENT_TTL_MS,
8625
9258
  };
8626
9259
  pendingReviewConsentRegistry.add(pendingReviewConsentSession, pending);
8627
- pending.expiry = reviewConsentScheduleTimer(
8628
- () => expirePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession),
8629
- PENDING_REVIEW_CONSENT_TTL_MS,
8630
- );
8631
- pending.expiry.unref();
8632
9260
  }
8633
9261
  return {
8634
9262
  operation: parameters.operation,
@@ -8776,7 +9404,7 @@ async function executeReviewControllerOperation(
8776
9404
  const retainedCommittedTarget = rawStatus === undefined && parameters.lineageId !== undefined && candidateViews?.hasProjection(parameters.lineageId, defaultCwd)
8777
9405
  ? candidateViews.resolveProjection(parameters.lineageId, defaultCwd)
8778
9406
  : undefined;
8779
- const effectiveBaseRef = baseRef ?? (retainedCommittedTarget?.committedOnly === true ? nativeCommittedRangeSelector(retainedCommittedTarget) : undefined);
9407
+ const effectiveBaseRef = typeof baseRef === "string" ? baseRef : (retainedCommittedTarget?.committedOnly === true ? nativeCommittedRangeSelector(retainedCommittedTarget) : undefined);
8780
9408
  if (nativeReviewCli?.targetStatus !== undefined) {
8781
9409
  try {
8782
9410
  const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
@@ -8801,7 +9429,7 @@ async function executeReviewControllerOperation(
8801
9429
  ) retainNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId, retainedUntrackedSelection);
8802
9430
  clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state);
8803
9431
  hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, status);
8804
- return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
9432
+ return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId, includeWorkspaceRoot ? defaultCwd : undefined), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
8805
9433
  } catch (error) {
8806
9434
  return nativeOperationFailure(parameters.operation, error);
8807
9435
  }
@@ -8813,8 +9441,13 @@ async function executeReviewControllerOperation(
8813
9441
 
8814
9442
  /** @internal */
8815
9443
  export const __testing = {
9444
+ parseReviewControllerParameters,
9445
+ parseReviewCaptureParameters,
9446
+ parseReviewCaptureGroupParameters,
9447
+ runProfilesPanelAction,
8816
9448
  resolveReviewModeGate,
8817
9449
  readEffectiveModelConfig,
9450
+ readReviewerModelConfig,
8818
9451
  readEffectiveModelConfigAsync,
8819
9452
  followRenamedPin,
8820
9453
  profilePinScopeNote,
@@ -8827,8 +9460,8 @@ export const __testing = {
8827
9460
  guardedCommandPreview,
8828
9461
  guardedCommandTitle,
8829
9462
  loadRuntimeGuardrailsConfig,
8830
- isOrdinaryYoloPush,
8831
- yoloPushConfiguredRestriction,
9463
+ isReversibleYoloCommand,
9464
+ yoloConfiguredRestriction,
8832
9465
  buildGentlePrompt,
8833
9466
  nativeStatusUnsupported,
8834
9467
  nativeStartRejection,
@@ -8859,6 +9492,9 @@ export const __testing = {
8859
9492
  RDD_STATUS_TIMEOUT_MS,
8860
9493
  RDD_STATUS_MEMO_TTL_MS,
8861
9494
  clearRddStatusMemoForTesting,
9495
+ async startupNegotiationSettled(): Promise<void> {
9496
+ await Promise.all([...startupNegotiations]);
9497
+ },
8862
9498
  readNativeReviewOutcome,
8863
9499
  recordNativeReviewOutcome,
8864
9500
  clearNativeReviewOutcomeMemoForTesting,
@@ -8875,12 +9511,6 @@ export interface GentleAiRuntimeDependencies {
8875
9511
  // An injected registry gives tests and host integrations explicit ownership;
8876
9512
  // normal package registrations share the module-local process-memory registry.
8877
9513
  pendingReviewConsentRegistry?: PendingReviewConsentRegistry;
8878
- // Deterministic test seam for the consent-binding TTL clock. Production
8879
- // leaves both undefined so the consent path observes real wall-clock time;
8880
- // tests inject a fake clock so expiry is observable without a 10-minute
8881
- // sleep and without relying on the queued cleanup macrotask firing.
8882
- now?: () => number;
8883
- scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
8884
9514
  // The environment the session's child processes inherit; tests inject a
8885
9515
  // plain object so the handshake declaration is observable without
8886
9516
  // touching the test runner's own process.env.
@@ -8897,6 +9527,8 @@ export interface GentleAiRuntimeDependencies {
8897
9527
  // bounded exec; production leaves this undefined and uses the real node
8898
9528
  // exec-file adapter shared with the rest of the extension.
8899
9529
  telemetryExecFileAdapter?: ExecFileAdapter;
9530
+ /** Test-only inference seam; production always uses the active session model. */
9531
+ autoPermissionClassifier?: AutoRuntimeClassifier;
8900
9532
  }
8901
9533
 
8902
9534
  export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void {
@@ -8911,8 +9543,6 @@ function createGentleAiExtensionForTesting(
8911
9543
  ? acquireChildStandingReviewPermissionClient(dependencies.processEnv ?? process.env)
8912
9544
  : undefined;
8913
9545
  const childStandingReviewPermission = dependencies.childStandingReviewPermissionClient ?? childStandingReviewPermissionLease?.client;
8914
- const reviewConsentNow = dependencies.now ?? (() => Date.now());
8915
- const reviewConsentScheduleTimer = dependencies.scheduleTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
8916
9546
  const pendingReviewConsentRegistry = dependencies.pendingReviewConsentRegistry ?? processPendingReviewConsentRegistry;
8917
9547
  const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary;
8918
9548
  const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter();
@@ -8922,7 +9552,24 @@ function createGentleAiExtensionForTesting(
8922
9552
  const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
8923
9553
  const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events);
8924
9554
  const permissionEnvironment = dependencies.processEnv ?? process.env;
8925
- const yolo = registerYoloSessionPolicy(pi, permissionEnvironment);
9555
+ let autoPermissions: AutoPermissionRuntime | undefined;
9556
+ const yolo = registerYoloSessionPolicy(pi, permissionEnvironment, { onChange: () => autoPermissions?.cancelPending() });
9557
+ let classifier: AutoPermissionClassifier | undefined;
9558
+ autoPermissions = new AutoPermissionRuntime(pi, yolo, dependencies.autoPermissionClassifier ?? {
9559
+ run: request => {
9560
+ classifier ??= new AutoPermissionClassifier(request.modelRegistry);
9561
+ return classifier.run(request);
9562
+ },
9563
+ cancel: () => classifier?.cancel(),
9564
+ }, { configHome: gentlePiConfigHome(permissionEnvironment) });
9565
+ const auto = autoPermissions;
9566
+ pi.on("tool_result", (event, ctx) => {
9567
+ if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") auto.toolResult(event, ctx);
9568
+ });
9569
+ pi.on("input", (event, ctx) => {
9570
+ if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") auto.input(event, ctx);
9571
+ return { action: "continue" };
9572
+ });
8926
9573
 
8927
9574
  const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => {
8928
9575
  try {
@@ -8952,15 +9599,23 @@ function createGentleAiExtensionForTesting(
8952
9599
  return revoked;
8953
9600
  };
8954
9601
 
9602
+ const statusTiming = new StatusTimingDiagnostics();
8955
9603
  const reviewSidebar = createReviewSidebarPublisher(pi);
8956
- pi.on("session_tree", (_event, ctx) => reviewSidebar.reset(ctx));
9604
+ const observedReviewTool: typeof reviewSidebar.tool = (definition) => statusTiming.tool(reviewSidebar.tool(definition));
9605
+ pi.on("session_tree", (_event, ctx) => {
9606
+ auto.reset();
9607
+ reviewSidebar.reset(ctx);
9608
+ statusTiming.reset(ctx);
9609
+ });
8957
9610
  let reminderSessionActive = true;
8958
9611
  let reminderEpoch = 0;
8959
9612
  let reminderManager: ExtensionContext["sessionManager"] | undefined;
8960
9613
  let unbindPreparation: (() => void) | undefined;
8961
9614
  pi.on("session_shutdown", (event, context) => {
9615
+ auto.reset();
8962
9616
  yolo.reset(context);
8963
9617
  reviewSidebar.reset();
9618
+ statusTiming.reset();
8964
9619
  reminderSessionActive = false;
8965
9620
  reminderEpoch += 1;
8966
9621
  unbindPreparation?.();
@@ -9100,7 +9755,7 @@ function createGentleAiExtensionForTesting(
9100
9755
  return named.length === 0 ? operation : `${operation} · ${named.join(" · ")}`;
9101
9756
  };
9102
9757
 
9103
- pi.registerTool(reviewSidebar.tool({
9758
+ pi.registerTool(observedReviewTool({
9104
9759
  name: "gentle_review_capture_group",
9105
9760
  renderShell: "self",
9106
9761
  label: "Gentle Review Capture Group",
@@ -9135,12 +9790,13 @@ function createGentleAiExtensionForTesting(
9135
9790
  // OpenCode-routed completion carries its attribution headers
9136
9791
  // (pi adds those inside the main agent loop; this is not that loop).
9137
9792
  reviewSessionManagerAndId(ctx)?.sessionId,
9793
+ () => pi.getSettings?.().transport,
9138
9794
  );
9139
9795
  return { content: [{ type: "text", text: JSON.stringify(details) }], details };
9140
9796
  },
9141
9797
  }));
9142
9798
 
9143
- pi.registerTool(reviewSidebar.tool({
9799
+ pi.registerTool(observedReviewTool({
9144
9800
  name: "gentle_review_capture",
9145
9801
  renderShell: "self",
9146
9802
  label: "Gentle Review Capture",
@@ -9178,6 +9834,7 @@ function createGentleAiExtensionForTesting(
9178
9834
  // OpenCode-routed completion carries its attribution headers
9179
9835
  // (pi adds those inside the main agent loop; this is not that loop).
9180
9836
  reviewSessionManagerAndId(ctx)?.sessionId,
9837
+ () => pi.getSettings?.().transport,
9181
9838
  );
9182
9839
  return {
9183
9840
  content: [{ type: "text", text: JSON.stringify(details) }],
@@ -9186,7 +9843,7 @@ function createGentleAiExtensionForTesting(
9186
9843
  },
9187
9844
  }));
9188
9845
 
9189
- pi.registerTool(reviewSidebar.tool({
9846
+ pi.registerTool(observedReviewTool({
9190
9847
  name: "gentle_review",
9191
9848
  renderShell: "self",
9192
9849
  label: "Gentle Review Controller",
@@ -9194,15 +9851,15 @@ function createGentleAiExtensionForTesting(
9194
9851
  "Inspect and recover review authority and start native ordinary review. Ordinary capture is available only through the separate gentle_review_capture tool. Review outcomes never authorize delivery: commit, push, pull-request, and release commands follow ordinary repository policy. RESET/RECOVER remain destructive and are executed by the audited native CLI.",
9195
9852
  promptSnippet: "Inspect authority, then start native ordinary review; use gentle_review_capture for one current collect slot",
9196
9853
  promptGuidelines: [
9197
- 'Call {"operation":"inspect"} before START. New native ordinary START uses a JSON string such as "{\\"mode\\":\\"ordinary\\"}"; an explicit baseRef must be paired with committedOnly: true to request a committed range, while policyPath remains repository-local. policyHash is legacy compact-only. The controller derives lineage, Git/untracked scope, tier, lenses, authored lines, and budget; the frozen correction budget counts logical corrections, while correction-plan correctionLines count diff lines (one replaced source line is one deletion plus one addition).',
9854
+ 'Call {"operation":"inspect"} before START. New native ordinary START uses a JSON string such as "{\\"mode\\":\\"ordinary\\",\\"lenses\\":[\\"reliability\\"],\\"lensesReason\\":\\"<what you touched and how>\\"}": pick every pertinent 4R lens (risk, resilience, readability, reliability) for what you touched and how; an explicit baseRef must be paired with committedOnly: true to request a committed range, while policyPath remains repository-local. policyHash is legacy compact-only. The controller derives lineage, Git/untracked scope, tier, lenses, authored lines, and budget; the frozen correction budget counts logical corrections, while correction-plan correctionLines count diff lines (one replaced source line is one deletion plus one addition).',
9198
9855
  'An inspect blocked on the intended-untracked selection returns nextStep naming the exact continuation: call select-intended-untracked with the returned selectionBinding, or call inspect again with top-level untrackedScope ("exclude", or "select" with intendedUntracked) to resolve the round trip in one call; the retained selection is adopted by the next plain START.',
9199
9856
  "Use RECONCILE_AUTHORITY only to quarantine one invalid native recovery successor. Supply exact predecessorLineage, expectedPredecessorRevision, successorLineage, expectedSuccessorRevision, actor, and reason values; Pi derives and displays the seven-line native authorization binding for fresh UI approval. The predecessor stays untouched, native returns the durable audit record, and Pi never falls back to RESET or RECOVER.",
9200
- "Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, capturedLensResults, findingsPresent, actor, and reason; QUARANTINE_LEGACY accepts only the published malformed freeze-findings diagnostic/disposition. A dual reconciliation may supply only anomalies `unchanged_target,malformed_recovery_authorization` in that exact order. Use REPAIR_LEGACY_ALIAS only with lineage, actor, and reason: Pi freshly reads native inventory and derives repository, revision, diagnostic, disposition, and the exact eight-line binding before interactive approval. `review dispose-result` is unsupported pending design.",
9857
+ "Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision. ABANDON accepts only lineage, actor, and reason (reason must be the native enum `operator_disposition` or `retired_schema`): Pi freshly reads the native authority inventory, derives the lineage's revision, snapshot identity, and discarded-work summary, and renders the exact eight-line binding before interactive approval; caller-supplied inventory-derived fields are rejected. QUARANTINE_LEGACY accepts only the published malformed freeze-findings diagnostic/disposition. A dual reconciliation may supply only anomalies `unchanged_target,malformed_recovery_authorization` in that exact order. Use REPAIR_LEGACY_ALIAS only with lineage, actor, and reason: Pi freshly reads native inventory and derives repository, revision, diagnostic, disposition, and the exact eight-line binding before interactive approval. `review dispose-result` is unsupported pending design.",
9201
9858
  "Lens, refuter, and validator verdicts are admitted natively, never Pi-authored. Use gentle_review_capture with exactly one current provider-owned collectBinding for ordinary native capture; it never follows another transition.",
9202
9859
  "For blocked-legacy or blocked-mixed, do not call START repeatedly. Explain invalidation, request explicit user authorization, then call RESET or RECOVER only after authorization. RESET and RECOVER_LOCK route to audited native `gentle-ai review reclaim`; only RESET carries the legacy repositoryId, commonDirHash, inventoryHash, and confirmation challenge. RECOVER routes to native `gentle-ai review recover` with exactly six inputs: predecessorLineage, expectedPredecessorRevision, successorLineage, disposition, actor, and reason. Never send RECOVER the reset challenge and never send it a maintainerAuthorization: Pi reads fresh native target status, pins the predecessor lineage, revision, provider-selected disposition, and target identity, derives the exact six-line native authorization binding, displays it for fresh UI approval, and re-reads status before mutating. Negotiated target status supplies the sole accepted recovery disposition, and a caller-supplied substitute is rejected. Treat a native-input-required envelope as a request for exact values, never as permission to invent them. After a committed native recovery record, INSPECT before any fresh ordinary START.",
9203
9860
  "A consent-required START may be resolved inside the eligible interactive Pi host. Its third UI action is host-owned: it runs this envelope's exact provider grant once and allows later fresh validated envelopes only for the same live SessionManager, nonempty session ID, and canonical Git common-directory identity, including sibling worktrees; an unrelated repository requires a new explicit human grant. Revoke removes the current repository grant, while nonreload replacement, quit, and process exit remove all session grants; reload preserves them. It grants no provider mode, verdict, acknowledgement, maintenance, delivery, or cross-repository authority. A package-owned child may ask its parent only with the canonical digest of its exact pending target; the parent binds that digest to the task repository and fails closed otherwise. If the tool returns an unresolved envelope, present the original two provider choices without changing machine tokens, commands, target IDs, or invocations; never add the host action to the decoded provider envelope. After one explicit relayed human answer, call answer-consent exactly once with only consentBinding and answer (`granted` or `declined`). Never create host permission from tool arguments, model prose, child/headless responses, or an uncertain native result. A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START output, the controller calls target-scoped native status once and returns only its declared action. An ambiguous gentle_review_capture outcome independently reconciles once and never replays the capture.",
9204
9861
  "Use gentle_review only for native review authority operations; delivery commands follow ordinary repository policy.",
9205
- 'ASSESS (gentle-pi#662/#668) is read-only and needs no lineageId: after a delegated writer returns, call {"operation":"assess"} over its diff and follow the returned plan (writerSelfVerification, structuralReadbackOnly, independentVerifier, reason) instead of judging non-triviality from the task description. Pass input as JSON only to assess a committed range ({"baseRef":"<ref>","committedOnly":true}), to supply a fallback writer profile ({"writerModelId":"...", "writerEffort":"..."}), or to state that the native review was declined or unavailable for this candidate ({"nativeReviewOutcome":"declined|unavailable|unknown"}). The writer profile comes from the runtime-recorded model and effort of the pending mutations for this root (writerProfileSource runtime); caller writerModelId/writerEffort are only a fallback when no runtime evidence exists (caller), otherwise the profile is small (fallback). A missing model, a mini model token (gemini is not mini), or low effort keeps the conservative small-model bias. The on-path (writer self-verification is the record, no separate verifier) holds only when RDD reads on and nativeReviewOutcome resolves to "closed" for this candidate. ASSESS derives closed only from the native candidate.consumed fact for this exact candidate, written natively when the approved review for that candidate is acknowledged; a caller-supplied closed is not authority and, without that fact, resolves to unknown. A declined, unavailable, or unknown outcome falls back to the exact risk-gated plan RDD off would return, re-enabling the separate verifier; unknown is never treated as closed, and a decline is candidate-scoped and never lowers the bar below RDD off. A decline or unavailable review recorded by this process is bound to that exact candidate\'s own target identity, never to a different candidate or to bare repository state, and wins over closure. The result\'s outcome_source (explicit|derived|unknown) and writerProfileSource (runtime|caller|fallback) state which evidence produced each value. When native reports them, ASSESS projects reviewDue, reviewDueReason, candidate.consumed, and the native continuation verbatim; older binaries omit them and nothing is invented. Relay that continuation unchanged; never rebuild it. A native code review is not a substitute for applicable functional checks: tests, builds, and functional verification such as browser checks for UI changes still run when applicable. A failed or unavailable native assessment reports risk "unassessable", verified exactly like "high". This never mutates review authority state.',
9862
+ 'ASSESS (gentle-pi#662/#668) is read-only and needs no lineageId: after a delegated writer returns, call {"operation":"assess"} over its diff and follow the returned plan (writerSelfVerification, structuralReadbackOnly, independentVerifier, reason) instead of judging non-triviality from the task description. Pass input as JSON only to assess a committed range ({"baseRef":"<ref>","committedOnly":true}), to supply a fallback writer profile ({"writerModelId":"...", "writerEffort":"..."}), or to state that the native review was declined or unavailable for this candidate ({"nativeReviewOutcome":"declined|unavailable|unknown"}), or to raise a candidate you know is high risk ({"escalate":{"item":1-6,"reason":"<one line>"}}, item from the Task Size high-risk list; it raises passive or medium to high, keeps nativeRisk, returns agentEscalation, and can never lower a tier). The writer profile comes from the runtime-recorded model and effort of the pending mutations for this root (writerProfileSource runtime); caller writerModelId/writerEffort are only a fallback when no runtime evidence exists (caller), otherwise the profile is small (fallback). A missing model, a mini model token (gemini is not mini), or low effort keeps the conservative small-model bias. The plan follows the native risk tier whatever RDD reads and whatever the native review outcome: the native review is an additional outside view and never removes a verifier the tier requires (verify-always-rdd-high S1). ASSESS derives closed only from the native candidate.consumed fact for this exact candidate, written natively when the approved review for that candidate is acknowledged; a caller-supplied closed is not authority and, without that fact, resolves to unknown; the outcome only explains the plan. A decline or unavailable review recorded by this process is bound to that exact candidate\'s own target identity, never to a different candidate or to bare repository state, and wins over closure. The result\'s outcome_source (explicit|derived|unknown) and writerProfileSource (runtime|caller|fallback) state which evidence produced each value. When native reports them, ASSESS projects reviewDue, reviewDueReason, candidate.consumed, and the native continuation verbatim; older binaries omit them and nothing is invented. Relay that continuation unchanged; never rebuild it. A native code review is not a substitute for applicable functional checks: tests, builds, and functional verification such as browser checks for UI changes still run when applicable. A failed or unavailable native assessment reports risk "unassessable", verified exactly like "high". This never mutates review authority state.',
9206
9863
  ],
9207
9864
  parameters: REVIEW_CONTROLLER_PARAMETERS,
9208
9865
  executionMode: "sequential",
@@ -9243,8 +9900,6 @@ function createGentleAiExtensionForTesting(
9243
9900
  retainedSelections,
9244
9901
  pendingReviewConsentRegistry,
9245
9902
  pendingReviewConsentFallbackKey,
9246
- reviewConsentNow,
9247
- reviewConsentScheduleTimer,
9248
9903
  );
9249
9904
  if (details.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED &&
9250
9905
  details.outcome === "native-approved-acknowledgement-completed" &&
@@ -9280,8 +9935,6 @@ function createGentleAiExtensionForTesting(
9280
9935
  retainedSelections,
9281
9936
  pendingReviewConsentRegistry,
9282
9937
  pendingReviewConsentFallbackKey,
9283
- reviewConsentNow,
9284
- reviewConsentScheduleTimer,
9285
9938
  );
9286
9939
  let permissionWorkspaceRoot: string | undefined;
9287
9940
  try {
@@ -9335,13 +9988,46 @@ function createGentleAiExtensionForTesting(
9335
9988
  }));
9336
9989
 
9337
9990
  pi.on("session_start", async (event, ctx) => {
9991
+ auto.reset();
9338
9992
  yolo.reset(ctx);
9993
+ // Package-owned children reset local state but never initialize primary defaults.
9994
+ if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") await yolo.mode(ctx);
9339
9995
  reviewSidebar.reset(ctx);
9996
+ statusTiming.reset(ctx);
9340
9997
  elapsedTiming = new GentleAiElapsedTimingLedger(ctx.sessionManager, pi);
9341
9998
  reminderSessionActive = true;
9342
9999
  reminderEpoch += 1;
9343
10000
  unbindPreparation?.();
9344
10001
  reminderManager = ctx.sessionManager;
10002
+ // gentle-shell#1690: a delegated child runs in the parent's resolved
10003
+ // worktree. Repository preparation, review negotiation, asset install and
10004
+ // model config belong to the parent session and write shared state. The
10005
+ // standing review grant is host-only (a child never captures an identity),
10006
+ // so revoke/refresh have nothing to act on; the child relay is load-time.
10007
+ // Only that parent-owned work is skipped: the session-local resets above,
10008
+ // the dev-binary notice, and any step added after this call, still run in
10009
+ // children.
10010
+ if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") await startParentSession(event, ctx);
10011
+ else await surfaceDevBinaryOverride(ctx);
10012
+ });
10013
+
10014
+ // Loud, every session: an active dev-binary override means this session
10015
+ // runs an unpinned gentle-ai. One visible startup notice: the gentle-shell
10016
+ // 🌹 card owns the announcement when it can render (shell enabled with UI);
10017
+ // this toast is only the fallback for when the card is unavailable. The
10018
+ // hasUI guard stays: headless contexts have no toast to show.
10019
+ const surfaceDevBinaryOverride = async (ctx: ExtensionContext): Promise<void> => {
10020
+ const devBinaryToastFallback = ctx.hasUI && !shellEnabled();
10021
+ try {
10022
+ const devBinary = await describeDevBinaryOverride();
10023
+ if (devBinaryToastFallback && devBinary.state === "active") ctx.ui.notify(devBinary.line, "warning");
10024
+ if (devBinaryToastFallback && devBinary.state === "invalid") ctx.ui.notify(devBinary.line, "error");
10025
+ } catch (error) {
10026
+ if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
10027
+ }
10028
+ };
10029
+
10030
+ const startParentSession = async (event: unknown, ctx: ExtensionContext): Promise<void> => {
9345
10031
  const epoch = reminderEpoch;
9346
10032
  const manager = ctx.sessionManager;
9347
10033
  const originalCwd = manager?.getCwd?.() ?? ctx.cwd;
@@ -9358,19 +10044,16 @@ function createGentleAiExtensionForTesting(
9358
10044
  return negotiated.status !== undefined && current() && safeBootstrapDirectory(originalCwd) === root && identity?.root === root;
9359
10045
  } catch { return false; }
9360
10046
  }, () => reminderSessionActive && reminderEpoch === epoch && reminderManager === manager) : undefined;
10047
+ const homeRoot = homeRootedRepository(ctx.cwd);
10048
+ if (homeRoot !== undefined && ctx.hasUI && !homeRepositoryWarnings.has(homeRoot)) {
10049
+ homeRepositoryWarnings.add(homeRoot);
10050
+ try { ctx.ui.notify(HOME_REPOSITORY_WARNING, "warning"); } catch { /* Nonblocking notice only. */ }
10051
+ }
9361
10052
  try { candidateViews?.sweepOrphans(ctx.cwd); } catch { /* Ownership sweeping must not block startup. */ }
9362
10053
  const reason = (event as { reason?: unknown }).reason;
9363
10054
  if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx);
9364
10055
  await refreshReviewSessionPermissionStatus(ctx);
9365
- // Loud, every session: an active dev-binary override means this session
9366
- // runs an unpinned gentle-ai. Announce which one before anything else.
9367
- try {
9368
- const devBinary = await describeDevBinaryOverride();
9369
- if (ctx.hasUI && devBinary.state === "active") ctx.ui.notify(devBinary.line, "warning");
9370
- if (ctx.hasUI && devBinary.state === "invalid") ctx.ui.notify(devBinary.line, "error");
9371
- } catch (error) {
9372
- if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
9373
- }
10056
+ await surfaceDevBinaryOverride(ctx);
9374
10057
  try {
9375
10058
  const installResult = installPackageAssets(ctx.cwd, true, ["delegation", "review"]);
9376
10059
  migrateLegacyProjectModelOverrides(ctx.cwd);
@@ -9400,13 +10083,16 @@ function createGentleAiExtensionForTesting(
9400
10083
  }
9401
10084
  // Keep the startup transport negotiation, but do not treat its target as
9402
10085
  // an ownership baseline: reload may have outstanding durable receipts.
9403
- try {
9404
- const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
9405
- await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
9406
- } catch {
9407
- // Startup negotiation is best-effort only; never surface or throw.
9408
- }
9409
- });
10086
+ // A home-rooted repository skips it: native STATUS would scan the whole home.
10087
+ if (homeRoot !== undefined) return;
10088
+ // Pi awaits session_start handlers serially, so this runs after startup
10089
+ // returns: two native spawns no longer hold the shell's first render.
10090
+ const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
10091
+ const negotiation = resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey, AbortSignal.timeout(STARTUP_NEGOTIATION_TIMEOUT_MS))
10092
+ .then(() => undefined, () => undefined) // Best-effort only; never surface or throw.
10093
+ .finally(() => { startupNegotiations.delete(negotiation); });
10094
+ startupNegotiations.add(negotiation);
10095
+ };
9410
10096
 
9411
10097
  pi.on("before_agent_start", async (event, ctx) => {
9412
10098
  const isNamedAgent = isNamedAgentStartEvent(event);
@@ -9444,20 +10130,32 @@ function createGentleAiExtensionForTesting(
9444
10130
  // this branch, so no line is resolved or computed for them.
9445
10131
  // resolveRddStatusLine never throws and never hangs past
9446
10132
  // RDD_STATUS_TIMEOUT_MS: an absent/timed-out/aborted/failing native
9447
- // binary renders the fail-closed "unknown" line instead.
9448
- const gentlePrompt = !isPrimarySession
10133
+ // binary keeps the last known line for this cwd, or renders the
10134
+ // fail-closed "unknown" line when none was ever read (gentle-shell#1029).
10135
+ const rddStatusSignal = AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS);
10136
+ // A startup read still in flight fills the memo: wait for it within the
10137
+ // same deadline instead of spawning the same STATUS a second time.
10138
+ const inFlightModeRead = isPrimarySession ? startupModeReads.get(ctx.cwd) : undefined;
10139
+ if (inFlightModeRead !== undefined) await Promise.race([inFlightModeRead, rddAbortRejection(rddStatusSignal)]).catch(() => undefined);
10140
+ const rddStatusLine = !isPrimarySession
10141
+ ? undefined
10142
+ : await resolveRddStatusLine(nativeReviewCli, ctx.cwd, rddStatusSignal, undefined, ctx);
10143
+ const gentlePrompt = rddStatusLine === undefined
9449
10144
  ? ""
9450
10145
  : `\n\n${buildGentlePrompt(
9451
10146
  readPersonaMode(ctx.cwd),
9452
10147
  ctx.cwd,
9453
10148
  readActiveToolNames(pi),
9454
- await resolveRddStatusLine(nativeReviewCli, ctx.cwd, AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS), undefined, ctx),
10149
+ rddStatusLine,
10150
+ ctx.mode,
9455
10151
  )}`;
9456
10152
  // gentle-pi#560 / gentle-ai#4056, #4057: inject the mirrored provider
9457
10153
  // contract bundle's review execution contract for the primary session
9458
10154
  // only, and only when a native review CLI is actually present.
10155
+ // gentle-shell#1494: skip it while RDD reads off, since no review can
10156
+ // start; on and unknown keep it so the reviewed path never loses it.
9459
10157
  const reviewContractPrompt =
9460
- isPrimarySession && nativeReviewCli !== null
10158
+ isPrimarySession && nativeReviewCli !== null && !isRddStatusLineOff(rddStatusLine)
9461
10159
  ? (() => {
9462
10160
  const fragment = loadReviewContractPromptFragment(ctx);
9463
10161
  return fragment === null ? "" : `\n\n${fragment}`;
@@ -9468,6 +10166,7 @@ function createGentleAiExtensionForTesting(
9468
10166
  // through the mutable appendSystemPrompt section instead of a replacement.
9469
10167
  appendSystemPromptOnce(event.systemPromptOptions, `${gentlePrompt}${reviewContractPrompt}`);
9470
10168
  updateYoloPrompt(event.systemPromptOptions, isPrimarySession && await yolo.active(ctx));
10169
+ updateAutoPrompt(event.systemPromptOptions, isPrimarySession && await yolo.mode(ctx) === "auto");
9471
10170
  return undefined;
9472
10171
  });
9473
10172
 
@@ -9499,6 +10198,18 @@ function createGentleAiExtensionForTesting(
9499
10198
  // Another concurrent end or ACK may already have consumed this prefix.
9500
10199
  if (!pendingReviewMutation(ctx.sessionManager, root, mutation)) return;
9501
10200
  if (status.nextTransition?.kind !== "execute" || status.nextTransition.execute.operation !== "review.start") return;
10201
+ // verify-always-rdd-high S2: automatic review is for high risk only, so a
10202
+ // candidate the native assessment reports not review_due earns no nudge.
10203
+ // An assessment that cannot answer, or an older binary without
10204
+ // review_due, keeps the nudge.
10205
+ if (nativeReviewCli.assess !== undefined) {
10206
+ let reviewDue: boolean | undefined;
10207
+ try { reviewDue = (await nativeReviewCli.assess({ cwd: root })).reviewDue; }
10208
+ catch { reviewDue = undefined; }
10209
+ if (reviewDue === false) return;
10210
+ if (!reminderSessionActive || epoch !== reminderEpoch || pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) !== sessionKey) return;
10211
+ if (!pendingReviewMutation(ctx.sessionManager, root, mutation)) return;
10212
+ }
9502
10213
  const targetIdentity = status.targetIdentity;
9503
10214
  pi.sendMessage(
9504
10215
  {
@@ -9521,12 +10232,12 @@ function createGentleAiExtensionForTesting(
9521
10232
  // Persist the observed own write before any await. Preparation is not
9522
10233
  // mutation evidence, and cannot invent a pre-write Changes baseline.
9523
10234
  if (root) recordReviewMutation(pi, ctx.sessionManager, root, { source: "direct", toolName: event.toolName, toolCallId: event.toolCallId, ...directWriterProfile(pi, ctx) });
9524
- if (prospectiveRoot && !resolveSessionWorktree(ctx.cwd, ctx.cwd)) await prepareBoundSessionRepository(ctx.sessionManager, ctx.sessionManager.getCwd?.() ?? ctx.cwd, ctx.signal);
10235
+ if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1" && prospectiveRoot && !resolveSessionWorktree(ctx.cwd, ctx.cwd)) await prepareBoundSessionRepository(ctx.sessionManager, ctx.sessionManager.getCwd?.() ?? ctx.cwd, ctx.signal);
9525
10236
  } catch { /* Preparation and receipt persistence cannot change a successful tool result. */ }
9526
10237
  });
9527
10238
 
9528
10239
  pi.on("tool_call", async (event, ctx) => {
9529
- const primaryToolCall = (processAgentEndSubagentDepth.get(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)) ?? 0) === 0;
10240
+ const primaryToolCall = permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1" && (processAgentEndSubagentDepth.get(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)) ?? 0) === 0;
9530
10241
  const yoloActive = primaryToolCall && await yolo.active(ctx);
9531
10242
  const sensitivePathDenied = evaluateSensitivePathTool(
9532
10243
  event.toolName,
@@ -9548,15 +10259,27 @@ function createGentleAiExtensionForTesting(
9548
10259
  };
9549
10260
  }
9550
10261
  }
9551
- if (event.toolName !== "bash") return undefined;
9552
- if (!isRecord(event.input) || typeof event.input.command !== "string") {
9553
- return undefined;
9554
- }
10262
+ const modeConfigDenied = auto.guard(event, ctx);
10263
+ if (modeConfigDenied) return modeConfigDenied;
10264
+ const gate = async (): Promise<ToolCallEventResult | undefined> => {
10265
+ try { return await auto.check(event, ctx, primaryToolCall); }
10266
+ catch { return { block: true, reason: "Auto permission checking failed. This action was not run; do not bypass the check." }; }
10267
+ };
10268
+ if (!SHELL_COMMAND_TOOLS.has(event.toolName) || !isRecord(event.input)) return gate();
10269
+ const command = (event.input as { command?: unknown }).command;
10270
+ if (typeof command !== "string") return gate();
9555
10271
  if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD === "1") {
9556
- const childDenied = blockChildDestructiveCommand(event.input.command);
10272
+ const childDenied = blockChildDestructiveCommand(command);
9557
10273
  if (childDenied) return childDenied;
9558
10274
  }
9559
- return await confirmCommand(event.input.command, ctx, pi.events, herdrLifecycle, yoloActive);
10275
+ const modeEpoch = yolo.epoch();
10276
+ const permissionMode = primaryToolCall ? await yolo.mode(ctx) : "normal";
10277
+ // A superseded lookup returns Normal for display, not permission to skip Auto.
10278
+ if (primaryToolCall && modeEpoch !== yolo.epoch()) {
10279
+ return { block: true, reason: "Permission mode changed during tool admission. This action was not run; retry under the current mode." };
10280
+ }
10281
+ const autoGate = permissionMode === "auto" ? gate : undefined;
10282
+ return await confirmCommand(command, ctx, pi.events, herdrLifecycle, yoloActive, autoGate);
9560
10283
  });
9561
10284
 
9562
10285
  for (const owner of ["delegation", "review"] as const) {
@@ -9710,6 +10433,11 @@ function createGentleAiExtensionForTesting(
9710
10433
  },
9711
10434
  });
9712
10435
 
10436
+ pi.registerCommand("gentle:status-timing", {
10437
+ description: "Arm session-only timing for the next authorized STATUS-bearing tool call, clear it, or show the last summary (enable|disable|show). Never invokes or retries STATUS.",
10438
+ handler: async (args, ctx) => { statusTiming.command(args, ctx); },
10439
+ });
10440
+
9713
10441
  pi.registerCommand("gentle:review-mode", {
9714
10442
  description: "Show or set the Gentle AI receipt-driven development kill switch (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.",
9715
10443
  handler: async (args, ctx) => {