gentle-pi 3.7.0 → 4.0.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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -1,6 +1,11 @@
1
- import { consumeReviewMutation, pendingReviewMutation, recordReviewMutation } from "../lib/review-reminder-receipt.ts";
1
+ import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
2
+ import { recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts";
3
+ 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 { consumeReviewMutation, pendingReviewMutation, pendingReviewMutationProfiles, recordReviewMutation, type ReceiptSession } from "../lib/review-reminder-receipt.ts";
6
+ import { createReviewSidebarPublisher } from "../lib/review-sidebar-state.ts";
7
+ import { isOddPhase, oddPhaseRegistry, ODD_PHASES } from "../lib/odd-phase.ts";
2
8
  import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
3
- import { resolveResearchCapabilities, renderResearchCapabilities } from "../lib/sdd-research-capabilities.ts";
4
9
  import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
5
10
  import { execFileSync } from "node:child_process";
6
11
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
@@ -30,7 +35,7 @@ import type {
30
35
  ThemeColor,
31
36
  ToolCallEventResult,
32
37
  } from "@earendil-works/pi-coding-agent";
33
- import { Key, isKeyRelease, matchesKey, truncateToWidth, type KeybindingsManager, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
38
+ import { Key, Text, isKeyRelease, matchesKey, truncateToWidth, type KeybindingsManager, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
34
39
  import { resolveGentlePiAgentHome, gentlePiConfigHome } from "../lib/agent-home.ts";
35
40
  import {
36
41
  BACKGROUND_SUBAGENTS_FILE,
@@ -41,23 +46,7 @@ import {
41
46
  type BackgroundSubagentsPolicy,
42
47
  type BackgroundSubagentsResolution,
43
48
  } from "../lib/background-subagents-policy.ts";
44
- import {
45
- ensureSddPreflight,
46
- getSddPreflightPreferences,
47
- installPackageAssets,
48
- getPackageAssetOwner,
49
- hasPackageAssetOwnerInstallation,
50
- type PackageAssetOwner,
51
- isPackageManagedSddAsset,
52
- isSddPreflightTrigger,
53
- renderSddPreflightPrompt,
54
- isParentConfirmedSddPreflightContext,
55
- SHIPPED_SDD_AGENT_NAMES,
56
- SDD_PREFLIGHT_FIELDS,
57
- type SddPreflightField,
58
- type SddPreflightPreferences,
59
- updatePackageManagedSddAgentOwnership,
60
- } from "../lib/sdd-preflight.ts";
49
+ import { installPackageAssets, getPackageAssetOwner, hasPackageAssetOwnerInstallation, type PackageAssetOwner, isPackageManagedSddAsset, updatePackageManagedSddAgentOwnership } from "../lib/agent-assets.ts";
61
50
  import {
62
51
  THINKING_LEVELS,
63
52
  normalizeModelConfig,
@@ -119,11 +108,6 @@ import {
119
108
  import { measureAgentsViewLayout, type AgentsViewLayout } from "../lib/agents-view-layout.ts";
120
109
  import { NativeChoiceList } from "../lib/native-choice-list.ts";
121
110
  import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
122
- import {
123
- parseSddStatusCommandArgs,
124
- renderNativeSddPhasePrompt,
125
- type SddPhase,
126
- } from "../lib/sdd-status.ts";
127
111
  import {
128
112
  REVIEW_HOST_RELAY_FAILURE,
129
113
  REVIEW_HOST_RELAY_PI_TIMEOUT_ENV,
@@ -166,9 +150,10 @@ import {
166
150
  type ReviewMode,
167
151
  type ReviewProjectionV1,
168
152
  } from "../lib/review-snapshot.ts";
153
+ import { GentleAiElapsedTimingLedger } from "../lib/gentle-ai-elapsed-store.ts";
169
154
  import { renderGentleAiLifecycleCall, renderGentleAiResult, type GentleAiRenderContext } from "../lib/gentle-ai-renderer.ts";
170
155
  import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
171
- import { BASE_REF_ACCEPTED_FORMS, CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, type CandidateView } from "../lib/review-candidate-view.ts";
156
+ import { BASE_REF_ACCEPTED_FORMS, CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, isProviderCandidateBaseTree, type CandidateView } from "../lib/review-candidate-view.ts";
172
157
  import {
173
158
  GentleAiDevBinaryOverrideError,
174
159
  GENTLE_AI_INSTALL_RECOVERY_COMMAND,
@@ -186,7 +171,6 @@ import {
186
171
  import {
187
172
  createNativeReviewCli,
188
173
  createNodeExecFileAdapter,
189
- decodeNativeSddStatusV2,
190
174
  isCanonicalProcessString,
191
175
  isNativeReviewUnachievableVerbRefused,
192
176
  nativeReviewAbandonAuthorization,
@@ -209,7 +193,6 @@ import {
209
193
  NATIVE_REVIEW_RECONCILE_ANOMALIES,
210
194
  sanitizeForeignNativeReviewDiagnostics,
211
195
  type NativeReviewCli,
212
- type NativeSddStatusV2,
213
196
  type NativeIntendedUntrackedSelectionSubmission,
214
197
  type NativeReviewAcknowledgeApprovedOutcome,
215
198
  type NativeReviewAcknowledgeApprovedRequest,
@@ -226,14 +209,20 @@ import {
226
209
  } from "../lib/native-review-cli.ts";
227
210
  import {
228
211
  verificationPlan,
212
+ isSmallWriterProfile,
229
213
  resolveWriterProfile,
230
214
  RDD_LINE,
215
+ WRITER_PROFILE,
231
216
  VERIFICATION_TIER,
232
217
  NATIVE_REVIEW_OUTCOME,
233
218
  type RddLine,
234
219
  type VerificationTier,
235
220
  type ReviewAssessmentV1,
221
+ type ReviewAssessmentNextTransition,
222
+ type ReviewAssessmentReason,
223
+ type ReviewDueReason,
236
224
  type NativeReviewOutcome,
225
+ type WriterProfile,
237
226
  } from "../lib/review-risk-assessment.ts";
238
227
  import {
239
228
  assertReviewApprovedAcknowledgementExecuteV1,
@@ -245,6 +234,7 @@ import {
245
234
  type ReviewStatusV3,
246
235
  } from "../lib/review-integration-v2.ts";
247
236
  import { reconcileUnknownReviewLastEventCapture } from "../lib/review-last-event-controller.ts";
237
+ import { registerYoloSessionPolicy, updateYoloPrompt } from "../lib/yolo-session-policy.ts";
248
238
  import { acquireChildStandingReviewPermissionClient, type ChildStandingReviewPermissionClient } from "../lib/review-session-standing-permission-ipc.ts";
249
239
  import { isPiConsentV3, presentReviewConsentUi } from "../lib/review-consent-ui.ts";
250
240
  import {
@@ -316,15 +306,12 @@ function packageAssetAudit(owner: PackageAssetOwner): { stale: number; overrides
316
306
  }
317
307
 
318
308
  function packageAssetDiagnosticLines(cwd: string): string[] {
319
- return (["delegation", "review", "sdd"] as const).flatMap((owner) => {
320
- const label = owner === "sdd" ? "SDD" : owner;
321
- const onDemand = owner === "sdd" && !hasPackageAssetOwnerInstallation(owner);
309
+ return (["delegation", "review"] as const).flatMap((owner) => {
310
+ const label = owner;
322
311
  const { stale, overrides } = packageAssetAudit(owner);
323
312
  const local = localAgentOverrideCount(cwd, owner);
324
- const lines = [onDemand
325
- ? `info: Global ${label} assets: on demand (not installed)`
326
- : `${stale > 0 ? "warn" : "pass"}: Global ${label} assets stale: ${stale} file(s)`];
327
- if (!onDemand && stale > 0) {
313
+ const lines = [`${stale > 0 ? "warn" : "pass"}: Global ${label} assets stale: ${stale} file(s)`];
314
+ if (stale > 0) {
328
315
  lines[0] += ` — run /gentle:install-${owner} --force to refresh managed assets`;
329
316
  }
330
317
  if (overrides > 0) {
@@ -469,138 +456,11 @@ function renderBackgroundSubagentsReport(
469
456
  const SUBAGENTS_PACKAGE_NAMES = ["pi-subagents-j0k3r", "pi-subagents"] as const;
470
457
  const SUBAGENT_RUN_TOOL = "subagent_run";
471
458
  const JUDGMENT_DAY_FIX_AGENT_NAME = "jd-fix-agent";
472
- const BOUNDED_WRITER_AGENT_NAMES = ["gentle-ai-worker", "worker", JUDGMENT_DAY_FIX_AGENT_NAME] as const;
473
459
  const JUDGMENT_DAY_ACTIVATION_HEADING = "## Judgment Day activation";
474
460
  const JUDGMENT_DAY_ACTIVATION_SENTENCE = "User explicitly requested Judgment Day.";
475
461
  const JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING = "## Exact authorized severe IDs";
476
462
  const JUDGMENT_DAY_CORRECTION_BATCH_HEADING = "## Judgment Day correction batch";
477
463
  const JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING = "## Exact frozen finding rows";
478
- const ALLOWED_EDIT_SURFACES_HEADING = /^## Allowed edit surfaces[ \t]*$/gim;
479
- const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6} /;
480
- const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)]) +/;
481
- const WRITER_EDIT_SURFACE_REJECTION =
482
- "Writer tasks must include the exact Markdown heading `## Allowed edit surfaces` with narrow repository-relative paths or narrow globs, one per line. Every non-empty line belongs to the section until the next canonical Markdown heading and must be a valid surface entry. Paths containing whitespace require whole-entry backticks; begin explanatory prose under the next Markdown heading. The parent must derive or map that canonical block from the delegated task and relaunch the writer; do not accept aliases, and do not ask the human to author paths or globs.";
483
-
484
- function isTaskScopedRepositoryRelativePath(value: string, isWholeEntryBackticked: boolean): boolean {
485
- const normalized = value.replace(/\\/g, "/");
486
- if (
487
- normalized.length === 0 ||
488
- isAbsolute(value) ||
489
- /^(?:[A-Za-z]:|\/|~)/.test(normalized) ||
490
- /\p{Cc}|\p{Zl}|\p{Zp}/u.test(normalized) ||
491
- (/\p{White_Space}/u.test(normalized) && !isWholeEntryBackticked)
492
- ) {
493
- return false;
494
- }
495
-
496
- const withoutCurrentDirectory = normalized.replace(/^(?:\.\/)+/, "");
497
- if (
498
- withoutCurrentDirectory.length === 0 ||
499
- withoutCurrentDirectory === "." ||
500
- withoutCurrentDirectory.startsWith("/") ||
501
- withoutCurrentDirectory.split("/").some((segment) => segment === "..")
502
- ) {
503
- return false;
504
- }
505
-
506
- return !/[?*\[\]{}]/.test(withoutCurrentDirectory.split("/")[0]);
507
- }
508
-
509
- type AllowedEditSurfaceEntry = {
510
- source: string;
511
- value: string;
512
- isWholeEntryBackticked: boolean;
513
- isValidMarkdownSyntax: boolean;
514
- };
515
-
516
- /** Reads one entry and records whether backticks delimit the whole path. */
517
- function readSurfaceEntry(line: string): AllowedEditSurfaceEntry {
518
- const withoutListMarker = line.replace(MARKDOWN_LIST_MARKER, "");
519
- const backticked = withoutListMarker.match(/^`([^`]+)`$/);
520
- return {
521
- source: line,
522
- value: backticked?.[1] ?? withoutListMarker,
523
- isWholeEntryBackticked: backticked !== null,
524
- isValidMarkdownSyntax:
525
- !/^(?:[-*+]|\d+[.)])$/.test(line) && (!withoutListMarker.includes("`") || backticked !== null),
526
- };
527
- }
528
-
529
- /**
530
- * Reads every non-empty line until the next Markdown heading as an edit surface.
531
- * A prose line cannot terminate this section: it must fail validation instead.
532
- */
533
- function readAllowedEditSurfaceEntries(following: string): AllowedEditSurfaceEntry[] {
534
- const lines = following.split(/\r?\n/);
535
- const headingIndex = lines.findIndex((line) => MARKDOWN_HEADING_LINE.test(line));
536
- return (headingIndex === -1 ? lines : lines.slice(0, headingIndex))
537
- .map((line) => line.replace(/ +$/g, ""))
538
- .filter((line) => line.length > 0)
539
- .map((line) => readSurfaceEntry(line.replace(/^ {0,3}/, "")));
540
- }
541
-
542
- function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean {
543
- let expectedEntries: string[] | undefined;
544
- let hasSection = false;
545
-
546
- for (const value of values) {
547
- if (typeof value !== "string") continue;
548
-
549
- const headings = value.matchAll(ALLOWED_EDIT_SURFACES_HEADING);
550
- for (const heading of headings) {
551
- const bodyStart = (heading.index ?? 0) + heading[0].length;
552
- const entries = readAllowedEditSurfaceEntries(value.slice(bodyStart));
553
- if (
554
- entries.length === 0 ||
555
- !entries.every(
556
- (entry) =>
557
- entry.isValidMarkdownSyntax &&
558
- !/\p{Cc}|\p{Zl}|\p{Zp}/u.test(entry.source) &&
559
- isTaskScopedRepositoryRelativePath(entry.value, entry.isWholeEntryBackticked),
560
- )
561
- ) {
562
- return false;
563
- }
564
-
565
- const uniqueEntries = [...new Set(entries.map((entry) => entry.value))].sort();
566
- if (
567
- expectedEntries &&
568
- (expectedEntries.length !== uniqueEntries.length ||
569
- expectedEntries.some((entry, index) => entry !== uniqueEntries[index]))
570
- ) {
571
- return false;
572
- }
573
- expectedEntries = uniqueEntries;
574
- hasSection = true;
575
- }
576
- }
577
-
578
- return hasSection;
579
- }
580
-
581
- function sddDispatchAgentName(input: unknown): string | undefined {
582
- if (!isRecord(input)) return undefined;
583
- if (typeof input.agent === "string" && SDD_AGENT_NAME_SET.has(input.agent)) return input.agent;
584
- if (Array.isArray(input.agent) && input.agent.some((agent) => typeof agent === "string" && SDD_AGENT_NAME_SET.has(agent))) {
585
- return "invalid";
586
- }
587
- return undefined;
588
- }
589
-
590
- function rejectUnscopedBoundedWriterDispatch(input: unknown): { block: true; reason: string } | undefined {
591
- if (
592
- !isRecord(input) ||
593
- typeof input.agent !== "string" ||
594
- !(BOUNDED_WRITER_AGENT_NAMES as readonly string[]).includes(input.agent)
595
- ) {
596
- return undefined;
597
- }
598
- if (hasTaskScopedAllowedEditSurfaces(input.task, input.context)) {
599
- return undefined;
600
- }
601
- return { block: true, reason: WRITER_EDIT_SURFACE_REJECTION };
602
- }
603
-
604
464
  function hasJudgmentDayFixAgentReference(input: Record<string, unknown>): boolean {
605
465
  return input.agent === JUDGMENT_DAY_FIX_AGENT_NAME ||
606
466
  (Array.isArray(input.agent) && input.agent.includes(JUDGMENT_DAY_FIX_AGENT_NAME)) ||
@@ -883,7 +743,7 @@ function renderRddStatusLine(
883
743
  : "Receipt-driven development: unknown (native status unavailable)";
884
744
  }
885
745
 
886
- // The primary-session prompt awaits this on every non-SDD, non-named agent
746
+ // The primary-session prompt awaits this on every non-named agent
887
747
  // start, so an unbounded native read would stall session start behind a
888
748
  // hung `gentle-ai` child (gentle-pi#661 native-review escalation). The
889
749
  // production call site (before_agent_start) passes
@@ -909,8 +769,9 @@ function clearRddStatusMemoForTesting(): void {
909
769
  // repository realpath AND targetIdentity -- never repository alone, or one
910
770
  // candidate's outcome would leak into every other candidate's `assess` call.
911
771
  // Only declined/unavailable are ever written (from ANSWER_CONSENT); `closed`
912
- // is never written/derived -- pass it explicitly. A missing entry reads back
913
- // `undefined`, treated as `unknown` (fail closed, exactly like `off`).
772
+ // is never written here -- it is derived per assess call only from that
773
+ // call's own native `candidate.consumed` evidence (gentle-pi#1175). A missing
774
+ // entry reads back `undefined`, treated as `unknown` (fail closed, like `off`).
914
775
  const nativeReviewOutcomeByCandidate = new Map<string, "declined" | "unavailable">();
915
776
 
916
777
  function nativeReviewOutcomeMemoKey(cwd: string, targetIdentity: string): string {
@@ -985,16 +846,28 @@ async function readRddModeStatusOnce(
985
846
  interface ReviewAssessmentPlanDetails {
986
847
  schema: "gentle-pi.review-assessment-plan/v1";
987
848
  risk: VerificationTier;
988
- reasons: readonly { code: string; path: string; detail: string }[];
849
+ reasons: readonly ReviewAssessmentReason[];
989
850
  changedPaths: number;
990
851
  changedLines: number;
991
- candidate: { kind: string; baseRef: string | undefined } | null;
852
+ // gentle-pi#1175: `consumed`, `reviewDue`, `reviewDueReason`, and
853
+ // `nextTransition` are projected only when native reported them; an older
854
+ // binary omits them and they are never fabricated here.
855
+ candidate: { kind: string; baseRef: string | undefined; consumed?: boolean } | null;
856
+ reviewDue?: boolean;
857
+ reviewDueReason?: ReviewDueReason;
858
+ // Opaque native continuation, carried verbatim.
859
+ nextTransition?: ReviewAssessmentNextTransition;
992
860
  rddLine: RddLine;
993
861
  nativeReviewOutcome: NativeReviewOutcome;
994
- // gentle-pi#668: where nativeReviewOutcome came from -- explicit (caller
995
- // passed it), derived (matched this exact candidate), or unknown.
996
- outcome_source: "explicit" | "derived" | "unknown";
997
- writerProfile: "small" | "large";
862
+ // gentle-pi#668/#1175: where nativeReviewOutcome came from -- explicit
863
+ // (a caller-supplied declined/unavailable/unknown), derived (native
864
+ // consumed evidence or the per-candidate memo for this exact candidate),
865
+ // or unknown.
866
+ outcome_source: NativeReviewOutcomeSource;
867
+ writerProfile: WriterProfile;
868
+ // gentle-pi#1175: runtime (pending mutation receipts), caller (input,
869
+ // only when no runtime receipt is pending), or fallback (small).
870
+ writerProfileSource: WriterProfileSource;
998
871
  plan: {
999
872
  writerSelfVerification: boolean;
1000
873
  structuralReadbackOnly: boolean;
@@ -1003,21 +876,104 @@ interface ReviewAssessmentPlanDetails {
1003
876
  };
1004
877
  }
1005
878
 
879
+ type NativeReviewOutcomeSource = "explicit" | "derived" | "unknown";
880
+ type WriterProfileSource = "runtime" | "caller" | "fallback";
881
+
882
+ /**
883
+ * gentle-pi#1175: the writer profile for ASSESS. Pending runtime mutation
884
+ * receipts for this root are the evidence: any writer without a recorded
885
+ * model id, or any small writer (`isSmallWriterProfile`), makes the profile
886
+ * small. Caller-declared writerModelId/writerEffort are consulted only when no
887
+ * runtime receipt is pending, and can never override runtime evidence. An
888
+ * unreadable receipt ledger fails closed to small.
889
+ */
890
+ function resolveAssessWriterProfile(
891
+ session: ReceiptSession | undefined,
892
+ root: string,
893
+ input: ReviewAssessInput,
894
+ ): { writerProfile: WriterProfile; writerProfileSource: WriterProfileSource } {
895
+ let pending: ReturnType<typeof pendingReviewMutationProfiles> = [];
896
+ try {
897
+ if (session !== undefined) pending = pendingReviewMutationProfiles(session, root);
898
+ } catch {
899
+ return { writerProfile: WRITER_PROFILE.SMALL, writerProfileSource: "fallback" };
900
+ }
901
+ if (pending.length > 0) {
902
+ const small = pending.some((profile) => profile.writerModelId === undefined ||
903
+ isSmallWriterProfile({ model: { id: profile.writerModelId }, thinking: profile.writerEffort }));
904
+ return { writerProfile: small ? WRITER_PROFILE.SMALL : WRITER_PROFILE.LARGE, writerProfileSource: "runtime" };
905
+ }
906
+ if (input.writerModelId === undefined && input.writerEffort === undefined) {
907
+ return { writerProfile: WRITER_PROFILE.SMALL, writerProfileSource: "fallback" };
908
+ }
909
+ return {
910
+ writerProfile: resolveWriterProfile({
911
+ ...(input.writerModelId === undefined ? {} : { model: { id: input.writerModelId } }),
912
+ thinking: input.writerEffort,
913
+ }),
914
+ writerProfileSource: "caller",
915
+ };
916
+ }
917
+
918
+ /**
919
+ * gentle-pi#1175: the parent session's own current model and thinking level,
920
+ * recorded on its direct mutation receipt. Omits whatever the runtime cannot
921
+ * report, so an unknown writer stays small for ASSESS.
922
+ */
923
+ function directWriterProfile(
924
+ pi: Pick<ExtensionAPI, "getThinkingLevel">,
925
+ ctx: Pick<ExtensionContext, "model">,
926
+ ): { writerModelId?: string; writerEffort?: string } {
927
+ const model = ctx.model;
928
+ const modelId = typeof model?.provider === "string" && model.provider && typeof model.id === "string" && model.id
929
+ ? `${model.provider}/${model.id}`
930
+ : undefined;
931
+ let effort: string | undefined;
932
+ try {
933
+ const level: unknown = pi.getThinkingLevel();
934
+ if (typeof level === "string" && level) effort = level;
935
+ } catch { /* No runtime effort evidence. */ }
936
+ return {
937
+ ...(modelId === undefined ? {} : { writerModelId: modelId }),
938
+ ...(effort === undefined ? {} : { writerEffort: effort }),
939
+ };
940
+ }
941
+
942
+ /**
943
+ * gentle-pi#1175: closure comes only from exact native evidence. `closed` is
944
+ * derived solely from THIS assessment's `candidate.consumed === true`, which
945
+ * native writes only inside the approved-acknowledgement burn for that exact
946
+ * target identity; a caller-declared `closed` is only a claim and, without
947
+ * that corroboration, fails closed to `unknown`. Explicit declined,
948
+ * unavailable, or unknown are honored (they only raise the bar), and a
949
+ * declined/unavailable recorded for this exact candidate beats closure.
950
+ */
951
+ function resolveAssessNativeReviewOutcome(
952
+ input: ReviewAssessInput,
953
+ assessment: ReviewAssessmentV1 | undefined,
954
+ memoOutcome: "declined" | "unavailable" | undefined,
955
+ ): { nativeReviewOutcome: NativeReviewOutcome; outcomeSource: NativeReviewOutcomeSource } {
956
+ if (input.nativeReviewOutcome !== undefined && input.nativeReviewOutcome !== NATIVE_REVIEW_OUTCOME.CLOSED) {
957
+ return { nativeReviewOutcome: input.nativeReviewOutcome, outcomeSource: "explicit" };
958
+ }
959
+ if (memoOutcome !== undefined) return { nativeReviewOutcome: memoOutcome, outcomeSource: "derived" };
960
+ if (assessment?.candidate.consumed === true) return { nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.CLOSED, outcomeSource: "derived" };
961
+ return { nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.UNKNOWN, outcomeSource: "unknown" };
962
+ }
963
+
1006
964
  async function resolveReviewAssessmentPlan(
1007
965
  nativeReviewCli: Pick<NativeReviewCli, "reviewMode" | "assess" | "targetStatus"> | null | undefined,
1008
966
  cwd: string,
1009
967
  input: ReviewAssessInput,
1010
968
  signal?: AbortSignal,
969
+ receiptSession?: ReceiptSession,
1011
970
  ): Promise<ReviewAssessmentPlanDetails> {
1012
971
  if (input.baseRef !== undefined && input.committedOnly !== true) throw new Error("Review assess baseRef requires committedOnly: true");
1013
972
  if (input.baseRef === undefined && input.committedOnly !== undefined) throw new Error("Review assess committedOnly requires an explicit baseRef");
1014
973
 
1015
974
  const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
1016
975
  const rddLine: RddLine = isValidRddModeStatus(status) ? status.effective : RDD_LINE.UNKNOWN;
1017
- const writerProfile = resolveWriterProfile({
1018
- ...(input.writerModelId === undefined ? {} : { model: { id: input.writerModelId } }),
1019
- thinking: input.writerEffort,
1020
- });
976
+ const { writerProfile, writerProfileSource } = resolveAssessWriterProfile(receiptSession, cwd, input);
1021
977
 
1022
978
  let assessment: ReviewAssessmentV1 | undefined;
1023
979
  let unassessableDetail: string | undefined;
@@ -1045,25 +1001,37 @@ async function resolveReviewAssessmentPlan(
1045
1001
  }
1046
1002
 
1047
1003
  const risk: VerificationTier = assessment?.risk ?? VERIFICATION_TIER.UNASSESSABLE;
1048
- // gentle-pi#668: explicit always wins; otherwise derive only for THIS
1049
- // candidate's own target identity, never repository-only. `closed` is
1050
- // never derived.
1051
- const targetIdentity = input.nativeReviewOutcome === undefined ? await readCurrentTargetIdentityBestEffort(nativeReviewCli, cwd, signal) : undefined;
1052
- const derived = targetIdentity === undefined ? undefined : readNativeReviewOutcome(cwd, targetIdentity);
1053
- const nativeReviewOutcome: NativeReviewOutcome = input.nativeReviewOutcome ?? derived ?? NATIVE_REVIEW_OUTCOME.UNKNOWN;
1054
- const outcomeSource: "explicit" | "derived" | "unknown" = input.nativeReviewOutcome !== undefined ? "explicit" : derived === undefined ? "unknown" : "derived";
1004
+ // gentle-pi#668: the memo is read only for THIS candidate's own target
1005
+ // identity, never repository-only. It is still needed when the caller
1006
+ // claims `closed`, because a recorded decline beats closure (gentle-pi#1175).
1007
+ const needsMemo = input.nativeReviewOutcome === undefined || input.nativeReviewOutcome === NATIVE_REVIEW_OUTCOME.CLOSED;
1008
+ const targetIdentity = needsMemo ? await readCurrentTargetIdentityBestEffort(nativeReviewCli, cwd, signal) : undefined;
1009
+ const memoOutcome = targetIdentity === undefined ? undefined : readNativeReviewOutcome(cwd, targetIdentity);
1010
+ const { nativeReviewOutcome, outcomeSource } = resolveAssessNativeReviewOutcome(input, assessment, memoOutcome);
1055
1011
  const plan = verificationPlan({ rddLine, risk, writerProfile, nativeReviewOutcome });
1056
1012
  return {
1057
1013
  schema: "gentle-pi.review-assessment-plan/v1",
1058
1014
  risk,
1059
- reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: unassessableCode, path: "", detail: unassessableDetail }]),
1015
+ // No path is known for an unassessable candidate: omit it rather than
1016
+ // emitting an empty string the native reason shape forbids.
1017
+ reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: unassessableCode, detail: unassessableDetail }]),
1060
1018
  changedPaths: assessment?.changedPaths ?? 0,
1061
1019
  changedLines: assessment?.changedLines ?? 0,
1062
- candidate: assessment === undefined ? null : { kind: assessment.candidate.kind, baseRef: assessment.candidate.baseRef },
1020
+ candidate: assessment === undefined
1021
+ ? null
1022
+ : {
1023
+ kind: assessment.candidate.kind,
1024
+ baseRef: assessment.candidate.baseRef,
1025
+ ...(assessment.candidate.consumed === undefined ? {} : { consumed: assessment.candidate.consumed }),
1026
+ },
1027
+ ...(assessment?.reviewDue === undefined ? {} : { reviewDue: assessment.reviewDue }),
1028
+ ...(assessment?.reviewDueReason === undefined ? {} : { reviewDueReason: assessment.reviewDueReason }),
1029
+ ...(assessment?.nextTransition === undefined ? {} : { nextTransition: assessment.nextTransition }),
1063
1030
  rddLine,
1064
1031
  nativeReviewOutcome,
1065
1032
  outcome_source: outcomeSource,
1066
1033
  writerProfile,
1034
+ writerProfileSource,
1067
1035
  plan,
1068
1036
  };
1069
1037
  }
@@ -1267,9 +1235,9 @@ Current persona mode: ${persona}
1267
1235
  You are el Gentleman: a Pi-specific coding-agent harness for controlled development work.
1268
1236
 
1269
1237
  Identity contract:
1270
- - When the user asks who or what you are, answer as el Gentleman, not as a generic assistant, and never introduce yourself as only "your assistant" or "the default assistant". Convey this meaning, translated into the user's language: "I am el Gentleman: a Pi-specific coding-agent harness for controlled development, with a senior architect persona. I run Organic Driven Development by default and SDD/OpenSpec when explicitly selected, coordinate subagents, use phase artifacts, run commands, and edit files. I am not a generic chatbot."
1238
+ - When the user asks who or what you are, answer as el Gentleman, not as a generic assistant, and never introduce yourself as only "your assistant" or "the default assistant". Convey this meaning, translated into the user's language: "I am el Gentleman: a Pi-specific coding-agent harness for controlled development, with a senior architect persona. I run Organic Driven Development, coordinate subagents, track substantial work, run commands, and edit files. I am not a generic chatbot."
1271
1239
  - Follow the currently selected persona mode.
1272
- - Mention ODD as the default workflow, SDD/OpenSpec phase artifacts, and subagents as core capabilities.
1240
+ - Mention ODD as the development workflow and subagents as a core capability.
1273
1241
  - Mention memory only when memory packages or callable memory tools are actually active; never invent persistent memory.
1274
1242
  - Do not claim portability outside the Pi runtime.
1275
1243
 
@@ -1278,23 +1246,24 @@ ${personaPrompt}
1278
1246
  ${languageBoundary}
1279
1247
 
1280
1248
  Default workflow: Organic Driven Development (MANDATORY)
1281
- 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. SDD is a branch inside ODD, entered only by an explicit request or an accepted proposal. Never describe this workflow only when asked about it: run it. Run these steps in this order on every request:
1249
+ 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:
1282
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.
1283
1251
  2. **Explore.** Explore existing code and requirements first, proportionately to the request, before proposing or writing anything.
1284
1252
  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.
1285
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.
1286
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.
1287
- 6. **Implement task by task.** Route each task through the orchestrator's Work Routing Ladder, honoring its mandatory delegation triggers, with the configured TDD mode and applicable 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.
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.
1288
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.
1257
+ 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.
1289
1258
  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\`.
1290
1259
 
1291
1260
  Harness principles:
1292
1261
  - el Gentleman is not prompt engineering. It is runtime discipline around powerful agents.
1293
- - 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. SDD is explicitly selected.
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.
1294
1263
  - Clarify scope, constraints, acceptance criteria, and non-goals before implementation.
1295
1264
  - Use subagents when available for exploration, planning, implementation, and review, while keeping one parent session responsible for orchestration.
1296
1265
  - Keep writes single-threaded unless the user explicitly approves parallel write isolation.
1297
- - Use configured TDD mode, source, and exact runner; test presence does not enable it. Follow orchestrator-delegation.md for ODD forwarding and evidence.
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.
1298
1267
  - Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
1299
1268
  - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
1300
1269
 
@@ -1312,8 +1281,8 @@ const DENIED_BASH_PATTERNS: RegExp[] = [
1312
1281
  /\bgit\s+reset\s+--hard\b/,
1313
1282
  /\bgit\s+clean\b(?=[^\n]*(?:-[^\n]*f|--force))(?=[^\n]*(?:-[^\n]*d|--directories))/,
1314
1283
  // Force-push deny: tolerates git global flags (e.g. -C /repo) before the subcommand
1315
- new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\n]*\s--force(?:-with-lease)?\b)`),
1316
- new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\n]*\s-[^\s-]*f)`),
1284
+ new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\r\n;&|]*\s--force(?:-with-lease)?\b)`),
1285
+ new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\r\n;&|]*\s-[^\s-]*f)`),
1317
1286
  /\bchmod\s+-R\s+777\b/,
1318
1287
  /\bchown\s+-R\b/,
1319
1288
  ];
@@ -1339,6 +1308,7 @@ interface GuardMatch {
1339
1308
 
1340
1309
  interface GuardEvaluation {
1341
1310
  action: GuardClassification;
1311
+ dataLoss?: boolean;
1342
1312
  key?: GuardedCommandKey;
1343
1313
  triggerIndex: number;
1344
1314
  matches: GuardMatch[];
@@ -1434,6 +1404,9 @@ function evaluateGuardedCommand(
1434
1404
  config: RuntimeGuardrailsConfig,
1435
1405
  ): GuardEvaluation {
1436
1406
  const matches = collectGuardedMatches(command, config);
1407
+ const destructive = recognizeDestructiveCommands(command);
1408
+ const hardDeny = destructive.find((match) => match.hardDeny);
1409
+ if (hardDeny) return { action: "block", triggerIndex: hardDeny.triggerIndex, matches };
1437
1410
 
1438
1411
  // Hard denies override every configured action across the complete command.
1439
1412
  for (const pattern of DENIED_BASH_PATTERNS) {
@@ -1450,7 +1423,13 @@ function evaluateGuardedCommand(
1450
1423
  };
1451
1424
  }
1452
1425
 
1453
- // Configured block, then confirmation, then allow win across all matches.
1426
+ // Explicit configured blocks outrank recognized data-loss confirmation.
1427
+ const configuredBlock = matches.find((match) => match.action === "block");
1428
+ if (configuredBlock) return { ...configuredBlock, matches };
1429
+ const dataLoss = destructive.find((match) => match.kind !== "git");
1430
+ if (dataLoss) return { action: "confirm", dataLoss: true, triggerIndex: dataLoss.triggerIndex, matches };
1431
+
1432
+ // Confirmation, then allow win across remaining matches.
1454
1433
  const selected = matches.find((match) => match.action === "block")
1455
1434
  ?? matches.find((match) => match.action === "confirm")
1456
1435
  ?? matches.find((match) => match.action === "allow");
@@ -1589,20 +1568,13 @@ const SENSITIVE_PATH_PATTERNS: RegExp[] = [
1589
1568
  /\.(?:pem|key|p12|pfx)$/,
1590
1569
  ];
1591
1570
 
1592
- const SDD_AGENT_NAME_SET = new Set<string>(SHIPPED_SDD_AGENT_NAMES);
1593
- const SDD_CHANGE_FLAG = "gentle-sdd-change";
1594
- const SDD_CHANGE_KEYS = ["changeName", "phase", "workspaceRoot"] as const;
1595
-
1596
1571
  const JUDGMENT_DAY_AGENT_NAMES = [
1597
1572
  "jd-judge-a",
1598
1573
  "jd-judge-b",
1599
1574
  "jd-fix-agent",
1600
1575
  ] as const;
1601
1576
 
1602
- const CORE_MODEL_AGENT_NAMES = [
1603
- ...SHIPPED_SDD_AGENT_NAMES,
1604
- ...JUDGMENT_DAY_AGENT_NAMES,
1605
- ] as const;
1577
+ const CORE_MODEL_AGENT_NAMES = JUDGMENT_DAY_AGENT_NAMES;
1606
1578
  const CORE_MODEL_AGENT_NAME_SET = new Set<string>(CORE_MODEL_AGENT_NAMES);
1607
1579
 
1608
1580
  type AgentSource = "project" | "user" | "builtin";
@@ -1631,7 +1603,11 @@ const MODEL_PANEL_MAX_RENDER_ROWS = 20;
1631
1603
  // Rows the agent list does not own: two borders, title, current-profile line,
1632
1604
  // blank, "Current assignments:", blank, both scroll indicators, blank, Continue,
1633
1605
  // Back, blank, and the two footer rows.
1634
- const AGENT_LIST_MAX_VISIBLE_ROWS = MODEL_PANEL_MAX_RENDER_ROWS - 15;
1606
+ const AGENT_LIST_CHROME_ROWS = 15;
1607
+ const AGENT_LIST_MAX_VISIBLE_ROWS = MODEL_PANEL_MAX_RENDER_ROWS - AGENT_LIST_CHROME_ROWS;
1608
+ // Rows the model list does not own: two borders, title, blank, search, blank,
1609
+ // blank, and the footer row.
1610
+ const MODEL_LIST_CHROME_ROWS = 8;
1635
1611
  const MODEL_LIST_MAX_VISIBLE_ROWS = 12;
1636
1612
 
1637
1613
  function readStringPath(value: unknown, path: string[]): string | undefined {
@@ -1643,17 +1619,6 @@ function readStringPath(value: unknown, path: string[]): string | undefined {
1643
1619
  return typeof current === "string" ? current : undefined;
1644
1620
  }
1645
1621
 
1646
- function isSddAgentStartEvent(event: unknown): boolean {
1647
- const candidates = readAgentStartNames(event);
1648
- if (candidates.some((value) => SDD_AGENT_NAME_SET.has(value)) || sddPhaseFromAgentStartEvent(event) !== undefined) return true;
1649
-
1650
- const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
1651
- return SHIPPED_SDD_AGENT_NAMES.some((name) => {
1652
- const phase = name.replace(/^sdd-/, "");
1653
- return new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt);
1654
- });
1655
- }
1656
-
1657
1622
  function readAgentStartNames(event: unknown): string[] {
1658
1623
  return [
1659
1624
  readStringPath(event, ["agentName"]),
@@ -1671,102 +1636,6 @@ function isNamedAgentStartEvent(event: unknown): boolean {
1671
1636
  return readAgentStartNames(event).length > 0;
1672
1637
  }
1673
1638
 
1674
- function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | "remediate" | undefined {
1675
- const phases = ["apply", "verify", "archive", "remediate"] as const;
1676
- const names = readAgentStartNames(event);
1677
- const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
1678
- const promptPhases = phases.filter((phase) => new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt));
1679
- if (promptPhases.length > 1) return undefined;
1680
- if (names.length === 0) return promptPhases[0];
1681
- return phases.find((phase) => names.every((name) => name === `sdd-${phase}`) &&
1682
- (promptPhases.length === 0 || promptPhases[0] === phase));
1683
- }
1684
-
1685
- function resolveSddChangeSelection(serialized: unknown, cwd: string, agentName: string): { changeName: string; workspaceRoot: string; phase: SddPhase | "remediate"; failedEvidenceRevision?: string } {
1686
- if (typeof serialized !== "string") throw new Error("SDD selection must be a JSON string.");
1687
- let value: unknown;
1688
- try {
1689
- value = JSON.parse(serialized);
1690
- } catch {
1691
- throw new Error("SDD selection is malformed.");
1692
- }
1693
- if (!isRecord(value) || Object.keys(value).sort().join(",") !== (value.phase === "remediate" ? "changeName,failedEvidenceRevision,phase,workspaceRoot" : SDD_CHANGE_KEYS.join(","))) {
1694
- throw new Error("SDD selection must contain only changeName, workspaceRoot, and phase.");
1695
- }
1696
- const { changeName, workspaceRoot, phase } = value;
1697
- if (typeof changeName !== "string" || changeName.length === 0 ||
1698
- typeof workspaceRoot !== "string" || workspaceRoot.length === 0 ||
1699
- (phase !== "apply" && phase !== "verify" && phase !== "archive" && phase !== "remediate")) {
1700
- throw new Error("SDD selection has an invalid identity.");
1701
- }
1702
- if (agentName !== `sdd-${phase}`) throw new Error("SDD selection phase does not match the child agent.");
1703
- let canonicalCwd: string;
1704
- let canonicalSelectionRoot: string;
1705
- try {
1706
- canonicalCwd = realpathSync(cwd);
1707
- canonicalSelectionRoot = realpathSync(workspaceRoot);
1708
- } catch {
1709
- throw new Error("SDD selection workspaceRoot cannot be resolved.");
1710
- }
1711
- if (canonicalCwd !== canonicalSelectionRoot || workspaceRoot !== canonicalSelectionRoot) {
1712
- throw new Error("SDD selection workspaceRoot does not match the canonical child root.");
1713
- }
1714
- if (phase === "remediate" && (typeof value.failedEvidenceRevision !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.failedEvidenceRevision))) throw new Error("Invalid remediation revision");
1715
- return { changeName, workspaceRoot: canonicalCwd, phase, ...(phase === "remediate" ? { failedEvidenceRevision: value.failedEvidenceRevision as string } : {}) };
1716
- }
1717
-
1718
- function assertNativeSddPhaseReady(status: NativeSddStatusV2, phase: SddPhase | "remediate"): void {
1719
- if (!status.phaseInstructions || !(phase in status.phaseInstructions)) {
1720
- throw new Error(`Native SDD status cannot represent phase ${phase}.`);
1721
- }
1722
- if (phase === "remediate") {
1723
- if (status.nextRecommended !== phase) throw new Error("Native SDD status does not select remediation.");
1724
- return;
1725
- }
1726
- // Optional verification may be explicitly selected when native recommends
1727
- // apply or archive. Preserve that recommendation; never advance an older provider.
1728
- const optionalVerify = phase === "verify" && (status.nextRecommended === "archive" || status.nextRecommended === "apply") && status.blockedReasons.length === 0;
1729
- if ((!optionalVerify && status.nextRecommended !== phase) || status.dependencies[phase] !== "ready" ||
1730
- (status.blockedReasons.length > 0 && phase !== "verify")) {
1731
- // A native-selected verify can refresh the evidence named by a blocker.
1732
- // Apply/archive never inherit that exception (gentle-pi#972).
1733
- throw new Error(`SDD selection native status blocks phase ${phase}; it cannot execute.`);
1734
- }
1735
- }
1736
-
1737
- async function resolveSelectedNativeSddChangeStartup(
1738
- serialized: unknown,
1739
- cwd: string,
1740
- agentName: string,
1741
- native: Pick<NativeReviewCli, "sddStatus"> | null | undefined,
1742
- ): Promise<{ selection: { changeName: string; workspaceRoot: string; phase: SddPhase | "remediate"; failedEvidenceRevision?: string }; status: NativeSddStatusV2 }> {
1743
- const selection = resolveSddChangeSelection(serialized, cwd, agentName);
1744
- if (native?.sddStatus === undefined) throw new Error("SDD selection native status is unavailable.");
1745
- let status: NativeSddStatusV2;
1746
- try {
1747
- status = decodeNativeSddStatusV2(
1748
- await native.sddStatus({ changeName: selection.changeName, workspaceRoot: selection.workspaceRoot }),
1749
- { changeName: selection.changeName, workspaceRoot: selection.workspaceRoot },
1750
- );
1751
- } catch (error) {
1752
- throw new Error(`SDD selection native status is blocked: ${error instanceof Error ? error.message : String(error)}`);
1753
- }
1754
- assertNativeSddPhaseReady(status, selection.phase);
1755
- if (selection.phase === "remediate" && status.remediationState?.failedEvidenceRevision !== selection.failedEvidenceRevision) {
1756
- throw new Error("Stale remediation selection");
1757
- }
1758
- return { selection, status };
1759
- }
1760
-
1761
- function readSddChangeFlag(pi: ExtensionAPI): unknown {
1762
- try {
1763
- const value = (pi as unknown as { getFlag?: (name: string) => unknown }).getFlag?.(SDD_CHANGE_FLAG);
1764
- return value === false ? undefined : value;
1765
- } catch {
1766
- return null;
1767
- }
1768
- }
1769
-
1770
1639
  function normalizePolicyPath(value: string): string {
1771
1640
  return value.trim().replace(/^~(?=\/|$)/, homedir()).replace(/\\/g, "/").toLowerCase();
1772
1641
  }
@@ -1819,6 +1688,7 @@ function evaluateSensitivePathTool(
1819
1688
  }
1820
1689
 
1821
1690
  const ASK_USER_CHOICE_BLOCKED_EVENT = "gentle-pi:ask-user-choice:blocked";
1691
+ const ASK_USER_QUESTION_BLOCKED_EVENT = "gentle-pi:ask-user-question:blocked";
1822
1692
 
1823
1693
  const HERDR_BLOCKER_LABEL = {
1824
1694
  CHOICE: "Choice awaiting input",
@@ -1836,17 +1706,20 @@ type HerdrConfirmationLifecycle = {
1836
1706
  function createHerdrConfirmationLifecycle(events: ExtensionAPI["events"]): HerdrConfirmationLifecycle {
1837
1707
  let pending = 0;
1838
1708
  let choiceActive = false;
1839
- let questionnaireActive = false;
1709
+ let nativeQuestionnaireActive = false;
1710
+ let legacyQuestionnaireActive = false;
1840
1711
  let emittedLabel: HerdrBlockerLabel | undefined;
1841
1712
  const emitEffectiveBlocker = (): void => {
1842
1713
  const nextLabel = choiceActive
1843
1714
  ? HERDR_BLOCKER_LABEL.CHOICE
1844
- : questionnaireActive
1715
+ : nativeQuestionnaireActive || legacyQuestionnaireActive
1845
1716
  ? HERDR_BLOCKER_LABEL.QUESTIONNAIRE
1846
1717
  : pending > 0
1847
1718
  ? HERDR_BLOCKER_LABEL.GUARDED_CONFIRMATION
1848
1719
  : undefined;
1849
- if (nextLabel === emittedLabel) return;
1720
+ // Herdr bridges count active/inactive edges. Keep the first label until
1721
+ // every source releases; relabel activations would leak a blocked count.
1722
+ if ((nextLabel === undefined) === (emittedLabel === undefined)) return;
1850
1723
  emittedLabel = nextLabel;
1851
1724
  if (nextLabel === undefined) events.emit("herdr:blocked", { active: false });
1852
1725
  else events.emit("herdr:blocked", { active: true, label: nextLabel });
@@ -1858,9 +1731,15 @@ function createHerdrConfirmationLifecycle(events: ExtensionAPI["events"]): Herdr
1858
1731
  emitEffectiveBlocker();
1859
1732
  });
1860
1733
 
1734
+ events?.on?.(ASK_USER_QUESTION_BLOCKED_EVENT, (event) => {
1735
+ if (!isRecord(event) || typeof event.active !== "boolean" || event.active === nativeQuestionnaireActive) return;
1736
+ nativeQuestionnaireActive = event.active;
1737
+ emitEffectiveBlocker();
1738
+ });
1739
+
1861
1740
  events?.on?.("rpiv:ask-user:blocked", (event) => {
1862
- if (!isRecord(event) || typeof event.active !== "boolean" || event.active === questionnaireActive) return;
1863
- questionnaireActive = event.active;
1741
+ if (!isRecord(event) || typeof event.active !== "boolean" || event.active === legacyQuestionnaireActive) return;
1742
+ legacyQuestionnaireActive = event.active;
1864
1743
  emitEffectiveBlocker();
1865
1744
  });
1866
1745
 
@@ -1877,15 +1756,47 @@ function createHerdrConfirmationLifecycle(events: ExtensionAPI["events"]): Herdr
1877
1756
  };
1878
1757
  }
1879
1758
 
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);
1763
+ }
1764
+
1765
+ /** Preserve explicitly configured confirmations even when legacy env/autonomy resolution ignores them. */
1766
+ function yoloPushConfiguredRestriction(cwd: string, options: LoadGuardrailsOptions = {}): GuardAction | undefined {
1767
+ let restriction: GuardAction | undefined;
1768
+ for (const path of [join(options.gentlePiConfigHome ?? gentleAiConfigHome(), "runtime-guardrails.json"), join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) {
1769
+ try {
1770
+ if (!existsSync(path)) continue;
1771
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
1772
+ if (!isRecord(parsed) || (parsed.guardedCommands !== undefined && !isRecord(parsed.guardedCommands))) {
1773
+ restriction ??= "confirm";
1774
+ continue;
1775
+ }
1776
+ const action = isRecord(parsed.guardedCommands) ? parsed.guardedCommands.gitPush : undefined;
1777
+ if (action === "block") return "block";
1778
+ if (action !== undefined && action !== "allow") restriction = "confirm";
1779
+ } catch { restriction ??= "confirm"; }
1780
+ }
1781
+ return restriction;
1782
+ }
1783
+
1880
1784
  async function confirmCommand(
1881
1785
  command: string,
1882
1786
  ctx: ExtensionContext,
1883
1787
  events: ExtensionAPI["events"],
1884
1788
  herdrLifecycle: HerdrConfirmationLifecycle,
1789
+ yoloActive = false,
1885
1790
  ): Promise<ToolCallEventResult | undefined> {
1886
1791
  const guardrailsConfig = loadRuntimeGuardrailsConfig(ctx.cwd);
1887
1792
  const evaluation = evaluateGuardedCommand(command, guardrailsConfig);
1888
- const { action: classification } = evaluation;
1793
+ // Configuration provenance is distinct from legacy autonomy resolution, which
1794
+ // can ignore confirmations when an environment override is present. Activation
1795
+ // 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;
1798
+ const classification = evaluation.action === "block" || configuredRestriction === "block"
1799
+ ? "block" : configuredRestriction ?? evaluation.action;
1889
1800
 
1890
1801
  if (classification === "block") {
1891
1802
  return {
@@ -1900,6 +1811,9 @@ async function confirmCommand(
1900
1811
  // classification is "allow" or "confirm" from this point on
1901
1812
  if (classification === "allow") return undefined;
1902
1813
 
1814
+ // Full T1 evaluation has already run. YOLO never changes persistent autonomy.
1815
+ if (yoloActive && configuredRestriction === undefined && isOrdinaryYoloPush(command, evaluation)) return undefined;
1816
+
1903
1817
  // classification === "confirm"
1904
1818
  if (!ctx.hasUI) {
1905
1819
  return {
@@ -1908,7 +1822,9 @@ async function confirmCommand(
1908
1822
  "Gentle AI safety policy requires interactive confirmation before this command.",
1909
1823
  };
1910
1824
  }
1911
- const title = guardedCommandTitle(evaluation.key, evaluation.matches);
1825
+ const title = evaluation.dataLoss
1826
+ ? "Allow recognized data-loss command?"
1827
+ : guardedCommandTitle(evaluation.key, evaluation.matches);
1912
1828
  const preview = guardedCommandPreview(command, evaluation.triggerIndex);
1913
1829
  const requestId = randomUUID();
1914
1830
  const emitPermissionRequest = (
@@ -1928,7 +1844,7 @@ async function confirmCommand(
1928
1844
  emitPermissionRequest("waiting");
1929
1845
  herdrLifecycle.begin();
1930
1846
  try {
1931
- approved = await ctx.ui.confirm(title, preview);
1847
+ approved = (await ctx.ui.confirm(title, preview)) === true;
1932
1848
  } catch (error) {
1933
1849
  confirmationFailed = true;
1934
1850
  confirmationError = error;
@@ -2845,8 +2761,8 @@ interface OverlayComponent {
2845
2761
 
2846
2762
  type ModelPanelResult =
2847
2763
  | { type: "save"; config: AgentModelConfig }
2848
- // `save`, then snapshot the saved routing into the current profile: the step
2849
- // `/gentle:profiles` performs with `s`, reachable without leaving this panel.
2764
+ // `save`, then snapshot saved agent routing and the live orchestrator into
2765
+ // the current profile without changing the global orchestrator defaults.
2850
2766
  | { type: "save-profile"; config: AgentModelConfig }
2851
2767
  | { type: "custom"; agent: string | "all"; config: AgentModelConfig }
2852
2768
  | { type: "export"; config: AgentModelConfig }
@@ -2889,6 +2805,8 @@ class SddModelPanel implements OverlayComponent {
2889
2805
  private readonly theme: Theme | undefined;
2890
2806
  // The profile `u` writes to, named up front so the key never targets a surprise.
2891
2807
  private readonly profileLabel: string;
2808
+ // Terminal rows, so the card fills the fullscreen overlay like `/gentle:profiles`.
2809
+ private readonly terminalRows: (() => number) | undefined;
2892
2810
 
2893
2811
  constructor(
2894
2812
  initialConfig: AgentModelConfig,
@@ -2897,6 +2815,7 @@ class SddModelPanel implements OverlayComponent {
2897
2815
  done: (result: ModelPanelResult) => void,
2898
2816
  theme?: Theme,
2899
2817
  profileLabel = "none",
2818
+ terminalRows?: () => number,
2900
2819
  ) {
2901
2820
  this.draft = cloneModelConfig(initialConfig);
2902
2821
  this.rows = [SET_ALL_AGENTS, ...agents];
@@ -2904,6 +2823,7 @@ class SddModelPanel implements OverlayComponent {
2904
2823
  this.done = done;
2905
2824
  this.theme = theme;
2906
2825
  this.profileLabel = profileLabel;
2826
+ this.terminalRows = terminalRows;
2907
2827
  }
2908
2828
 
2909
2829
  invalidate(): void {}
@@ -2931,10 +2851,22 @@ class SddModelPanel implements OverlayComponent {
2931
2851
  return this.renderCard(lines, width);
2932
2852
  }
2933
2853
 
2934
- private renderCard(lines: string[], width: number): string[] {
2854
+ // In the fullscreen overlay a list owns every row its chrome leaves free;
2855
+ // without a terminal height it keeps the fixed window.
2856
+ private visibleListRows(fixedRows: number, chromeRows: number): number {
2857
+ if (!this.terminalRows) return fixedRows;
2858
+ return Math.max(1, Math.floor(this.terminalRows()) - chromeRows);
2859
+ }
2860
+
2861
+ private renderCard(body: string[], width: number): string[] {
2862
+ let lines = body;
2935
2863
  const innerWidth = Math.max(1, width - 4);
2936
2864
  const horizontal = "─".repeat(innerWidth + 2);
2937
2865
  const border = (text: string) => this.renderText(text, "border");
2866
+ const bodyRows = this.terminalRows ? Math.floor(this.terminalRows()) - 2 : 0;
2867
+ if (lines.length < bodyRows) {
2868
+ lines = [...lines, ...Array<string>(bodyRows - lines.length).fill("")];
2869
+ }
2938
2870
  return [
2939
2871
  border(`╭${horizontal}╮`),
2940
2872
  ...lines.map(
@@ -3166,7 +3098,10 @@ class SddModelPanel implements OverlayComponent {
3166
3098
  lines.push("");
3167
3099
  lines.push(line("Current assignments:", "muted"));
3168
3100
  lines.push("");
3169
- const visibleRows = Math.min(AGENT_LIST_MAX_VISIBLE_ROWS, this.rows.length);
3101
+ const visibleRows = Math.min(
3102
+ this.visibleListRows(AGENT_LIST_MAX_VISIBLE_ROWS, AGENT_LIST_CHROME_ROWS),
3103
+ this.rows.length,
3104
+ );
3170
3105
  const listCursor = Math.min(this.cursor, this.rows.length - 1);
3171
3106
  const start = Math.max(
3172
3107
  0,
@@ -3212,7 +3147,7 @@ class SddModelPanel implements OverlayComponent {
3212
3147
  );
3213
3148
  lines.push(
3214
3149
  line(
3215
- `x export • r restore • ctrl+s save • u update profile "${this.profileLabel}" • esc back`,
3150
+ `x export • r restore • ctrl+s save • u capture session in "${this.profileLabel}" • esc back`,
3216
3151
  "muted",
3217
3152
  ),
3218
3153
  );
@@ -3232,14 +3167,15 @@ class SddModelPanel implements OverlayComponent {
3232
3167
  `${this.renderText("◎", "accent")} ${this.renderText(this.query || "search...", "muted")}`,
3233
3168
  );
3234
3169
  lines.push("");
3170
+ const visibleRows = this.visibleListRows(MODEL_LIST_MAX_VISIBLE_ROWS, MODEL_LIST_CHROME_ROWS);
3235
3171
  const start = Math.max(
3236
3172
  0,
3237
3173
  Math.min(
3238
- this.modelCursor - Math.floor(MODEL_LIST_MAX_VISIBLE_ROWS / 2),
3239
- Math.max(0, options.length - MODEL_LIST_MAX_VISIBLE_ROWS),
3174
+ this.modelCursor - Math.floor(visibleRows / 2),
3175
+ Math.max(0, options.length - visibleRows),
3240
3176
  ),
3241
3177
  );
3242
- const end = Math.min(options.length, start + MODEL_LIST_MAX_VISIBLE_ROWS);
3178
+ const end = Math.min(options.length, start + visibleRows);
3243
3179
  for (let i = start; i < end; i++) {
3244
3180
  const focused = i === this.modelCursor;
3245
3181
  lines.push(
@@ -3347,10 +3283,20 @@ function renderSddModelPanelForTesting(
3347
3283
  agents: string[],
3348
3284
  width: number,
3349
3285
  theme?: Theme,
3286
+ terminalRows?: number,
3287
+ inputs: string[] = [],
3350
3288
  ): string[] {
3351
- return new SddModelPanel(initialConfig, modelOptions, agents, () => {}, theme).render(
3352
- width,
3289
+ const panel = new SddModelPanel(
3290
+ initialConfig,
3291
+ modelOptions,
3292
+ agents,
3293
+ () => {},
3294
+ theme,
3295
+ undefined,
3296
+ terminalRows === undefined ? undefined : () => terminalRows,
3353
3297
  );
3298
+ for (const data of inputs) panel.handleInput(data);
3299
+ return panel.render(width);
3354
3300
  }
3355
3301
 
3356
3302
  async function showSddModelPanel(
@@ -3361,21 +3307,24 @@ async function showSddModelPanel(
3361
3307
  const modelOptions = await getPiModelOptions(ctx);
3362
3308
  const agents = modelAssignmentNames(ctx.cwd);
3363
3309
  return ctx.ui.custom<ModelPanelResult>(
3364
- (_tui, theme, _keybindings, done) =>
3365
- new SddModelPanel(config, modelOptions, agents, done, theme, profileLabel),
3310
+ (tui, theme, _keybindings, done) =>
3311
+ new SddModelPanel(config, modelOptions, agents, done, theme, profileLabel, () =>
3312
+ Math.max(0, tui.terminal.rows),
3313
+ ),
3366
3314
  {
3367
3315
  overlay: true,
3316
+ // Same fullscreen dimensions as the `/gentle:profiles` panel.
3368
3317
  overlayOptions: {
3369
3318
  anchor: "center",
3370
- width: "70%",
3371
- minWidth: 72,
3372
- maxHeight: "85%",
3319
+ width: "100%",
3320
+ maxHeight: "100%",
3321
+ margin: 0,
3373
3322
  },
3374
3323
  },
3375
3324
  );
3376
3325
  }
3377
3326
 
3378
- async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
3327
+ async function handleModelsCommand(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
3379
3328
  migrateLegacyProjectModelOverrides(ctx.cwd);
3380
3329
  // A pinned repository resolves its subagent routing from the profile at launch,
3381
3330
  // so global routing written here will not reach its subagents. Saying it before
@@ -3497,7 +3446,7 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
3497
3446
  ].join("\n"),
3498
3447
  "info",
3499
3448
  );
3500
- if (result.type === "save-profile") updateCurrentProfileFromSavedRouting(ctx);
3449
+ if (result.type === "save-profile") updateCurrentProfileFromSavedRouting(ctx, pi);
3501
3450
  }
3502
3451
 
3503
3452
  /** The profile `u` in `/gentle:models` writes to, and why it is that one. */
@@ -3528,13 +3477,12 @@ function describeCurrentProfileTarget(target: CurrentProfileTarget | undefined):
3528
3477
  }
3529
3478
 
3530
3479
  /**
3531
- * The second half of `u`: the same snapshot `/gentle:profiles` takes with `s`,
3532
- * aimed at the current profile. The snapshot reads the global routing the save
3533
- * just materialized, never the pin, so a pinned repository does not copy its
3534
- * pinned profile onto itself. The global save has already been reported, so every
3480
+ * The second half of `u` captures saved global agent routing (never the pin)
3481
+ * and the live session orchestrator. Without a live model it falls back to
3482
+ * persisted settings. The global save has already been reported, so every
3535
3483
  * outcome here speaks only about the profile.
3536
3484
  */
3537
- function updateCurrentProfileFromSavedRouting(ctx: ExtensionContext): void {
3485
+ function updateCurrentProfileFromSavedRouting(ctx: ExtensionContext, pi: ExtensionAPI): void {
3538
3486
  const path = profilesFilePath(gentleAiConfigHome());
3539
3487
  const read = readProfilesFileResult(path);
3540
3488
  if (read.status === "invalid") {
@@ -3548,6 +3496,12 @@ function updateCurrentProfileFromSavedRouting(ctx: ExtensionContext): void {
3548
3496
  readGlobalEffectiveModelConfig(ctx.cwd),
3549
3497
  readOrchestratorSettings(orchestratorSettingsPath()),
3550
3498
  );
3499
+ if (ctx.model) {
3500
+ snapshot[PROFILE_ORCHESTRATOR_KEY] = {
3501
+ model: `${ctx.model.provider}/${ctx.model.id}`,
3502
+ thinking: pi.getThinkingLevel(),
3503
+ };
3504
+ }
3551
3505
  if (read.status === "missing") {
3552
3506
  // The same seed `/gentle:profiles` performs on its first open, so pressing
3553
3507
  // `u` before ever opening that panel lands on the same "current" profile.
@@ -3604,6 +3558,16 @@ type ProfilesPanelResult =
3604
3558
 
3605
3559
  type ProfilesSnapshotHandler = (name: string) => AgentProfilesFile;
3606
3560
 
3561
+ /**
3562
+ * What a panel action hands the reopened panel: a one-line status for the footer,
3563
+ * because notifications stay hidden behind the fullscreen overlay until it closes,
3564
+ * and the profile to select when the action created or renamed one.
3565
+ */
3566
+ interface ProfilesPanelReport {
3567
+ status?: string;
3568
+ selectedName?: string;
3569
+ }
3570
+
3607
3571
  const PROFILES_PANEL_MIN_BODY_ROWS = 6;
3608
3572
 
3609
3573
  type ProfilesPanelPointerLayout = AgentsViewLayout & { listTop: number };
@@ -3782,8 +3746,10 @@ class ProfilesPanel implements OverlayComponent {
3782
3746
  saveSnapshot: ProfilesSnapshotHandler,
3783
3747
  requestRender: () => void,
3784
3748
  pinStatus: () => ProfilePinStatus | undefined,
3749
+ feedback?: string,
3785
3750
  ) {
3786
3751
  this.file = file;
3752
+ this.feedback = feedback;
3787
3753
  this.currentConfig = currentConfig;
3788
3754
  this.done = done;
3789
3755
  this.saveSnapshot = saveSnapshot;
@@ -4078,6 +4044,7 @@ async function showProfilesPanel(
4078
4044
  currentConfig: AgentModelConfig,
4079
4045
  selectedName: string | undefined,
4080
4046
  saveSnapshot: ProfilesSnapshotHandler,
4047
+ status?: string,
4081
4048
  ): Promise<ProfilesPanelResult> {
4082
4049
  // Both orchestrator and pin state are snapshots for this panel visit.
4083
4050
  // Actions (including p/P) reopen the panel and read fresh state.
@@ -4096,6 +4063,7 @@ async function showProfilesPanel(
4096
4063
  saveSnapshot,
4097
4064
  () => tui.requestRender(),
4098
4065
  () => readProfilePinStatus(ctx.cwd),
4066
+ status,
4099
4067
  );
4100
4068
  const container = createNativeFullscreenInteraction({
4101
4069
  keyboardTarget: panel,
@@ -4198,6 +4166,7 @@ async function runProfilesPanelAction(
4198
4166
  path: string,
4199
4167
  file: AgentProfilesFile,
4200
4168
  result: Exclude<ProfilesPanelResult, { type: "close" }>,
4169
+ report: ProfilesPanelReport = {},
4201
4170
  ): Promise<AgentProfilesFile> {
4202
4171
  switch (result.type) {
4203
4172
  case "apply": {
@@ -4359,14 +4328,25 @@ async function runProfilesPanelAction(
4359
4328
  return claimed;
4360
4329
  }
4361
4330
  case "create": {
4362
- const name = await ctx.ui.input("New profile name", "e.g. deep-work");
4363
- if (name === undefined) return file;
4331
+ const answer = await ctx.ui.input("New profile name", "e.g. deep-work");
4332
+ if (answer === undefined) {
4333
+ report.status = "Create cancelled.";
4334
+ return file;
4335
+ }
4336
+ const name = answer.trim();
4337
+ if (name === "") {
4338
+ report.status = "No profile created: no name entered.";
4339
+ return file;
4340
+ }
4364
4341
  try {
4365
- const next = createProfile(file, name.trim(), {});
4342
+ const next = createProfile(file, name, {});
4366
4343
  writeProfilesFileSync(path, next);
4344
+ report.status = `Profile "${name}" created.`;
4345
+ report.selectedName = name;
4367
4346
  return next;
4368
4347
  } catch (error) {
4369
- ctx.ui.notify(`Profile not created: ${profilesErrorMessage(error)}`, "warning");
4348
+ report.status = `Profile not created: ${profilesErrorMessage(error)}`;
4349
+ ctx.ui.notify(report.status, "warning");
4370
4350
  return file;
4371
4351
  }
4372
4352
  }
@@ -4393,36 +4373,57 @@ async function runProfilesPanelAction(
4393
4373
  }
4394
4374
  }
4395
4375
  case "duplicate": {
4396
- const name = await ctx.ui.input(`Duplicate profile "${result.name}" as`, `${result.name}-copy`);
4397
- if (name === undefined) return file;
4376
+ // The Pi host starts the field empty and ignores the placeholder, so the title
4377
+ // names the suggestion and Enter on an untouched field accepts it.
4378
+ const suggested = `${result.name}-copy`;
4379
+ const answer = await ctx.ui.input(`Duplicate profile "${result.name}" as (empty = ${suggested})`, suggested);
4380
+ if (answer === undefined) {
4381
+ report.status = "Duplicate cancelled.";
4382
+ return file;
4383
+ }
4384
+ const name = answer.trim() || suggested;
4398
4385
  try {
4399
- const next = duplicateProfile(file, result.name, name.trim());
4386
+ const next = duplicateProfile(file, result.name, name);
4400
4387
  writeProfilesFileSync(path, next);
4388
+ report.status = `Profile "${result.name}" duplicated as "${name}".`;
4389
+ report.selectedName = name;
4401
4390
  return next;
4402
4391
  } catch (error) {
4403
- ctx.ui.notify(`Profile not duplicated: ${profilesErrorMessage(error)}`, "warning");
4392
+ report.status = `Profile not duplicated: ${profilesErrorMessage(error)}`;
4393
+ ctx.ui.notify(report.status, "warning");
4404
4394
  return file;
4405
4395
  }
4406
4396
  }
4407
4397
  case "rename": {
4408
- const name = await ctx.ui.input(`Rename profile "${result.name}" to`, result.name);
4409
- if (name === undefined) return file;
4398
+ const answer = await ctx.ui.input(`Rename profile "${result.name}" to (empty = keep ${result.name})`, result.name);
4399
+ if (answer === undefined) {
4400
+ report.status = "Rename cancelled.";
4401
+ return file;
4402
+ }
4403
+ const name = answer.trim();
4404
+ if (name === "") {
4405
+ report.status = `Profile "${result.name}" unchanged: no new name entered.`;
4406
+ return file;
4407
+ }
4410
4408
  try {
4411
- const next = renameProfile(file, result.name, name.trim());
4409
+ const next = renameProfile(file, result.name, name);
4412
4410
  writeProfilesFileSync(path, next);
4413
4411
  // A pin stores a name, so a rename that did not follow it would leave every
4414
4412
  // pinned repository with a name the store no longer defines, which silently
4415
4413
  // returns those repositories to the global routing.
4416
- const follow = followRenamedPin(ctx.cwd, result.name, name.trim());
4414
+ const follow = followRenamedPin(ctx.cwd, result.name, name);
4417
4415
  ctx.ui.notify(
4418
- `el Gentleman renamed profile "${result.name}" to "${name.trim()}".` +
4416
+ `el Gentleman renamed profile "${result.name}" to "${name}".` +
4419
4417
  (follow.followed.length > 0 ? `\nUpdated the clone pin: ${follow.followed.map((entry) => sanitizeTerminalText(entry)).join(", ")}.` : "") +
4420
- (follow.stillDeclared.length > 0 ? `\nThe committed repository declaration ${follow.stillDeclared.map((entry) => sanitizeTerminalText(entry)).join(", ")} still names "${result.name}"; press P on "${name.trim()}" to republish it.` : ""),
4418
+ (follow.stillDeclared.length > 0 ? `\nThe committed repository declaration ${follow.stillDeclared.map((entry) => sanitizeTerminalText(entry)).join(", ")} still names "${result.name}"; press P on "${name}" to republish it.` : ""),
4421
4419
  "info",
4422
4420
  );
4421
+ report.status = `Profile "${result.name}" renamed to "${name}".`;
4422
+ report.selectedName = name;
4423
4423
  return next;
4424
4424
  } catch (error) {
4425
- ctx.ui.notify(`Profile not renamed: ${profilesErrorMessage(error)}`, "warning");
4425
+ report.status = `Profile not renamed: ${profilesErrorMessage(error)}`;
4426
+ ctx.ui.notify(report.status, "warning");
4426
4427
  return file;
4427
4428
  }
4428
4429
  }
@@ -4635,14 +4636,16 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession):
4635
4636
  saveSnapshot,
4636
4637
  );
4637
4638
  while (result.type !== "close") {
4638
- selectedName = "name" in result ? result.name : undefined;
4639
- file = await runProfilesPanelAction(ctx, live, path, file, result);
4639
+ const report: ProfilesPanelReport = {};
4640
+ file = await runProfilesPanelAction(ctx, live, path, file, result, report);
4641
+ selectedName = report.selectedName ?? ("name" in result ? result.name : undefined);
4640
4642
  result = await showProfilesPanel(
4641
4643
  ctx,
4642
4644
  file,
4643
4645
  await readEffectiveModelConfigAsync(ctx.cwd),
4644
4646
  selectedName,
4645
4647
  saveSnapshot,
4648
+ report.status,
4646
4649
  );
4647
4650
  }
4648
4651
  }
@@ -4740,7 +4743,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
4740
4743
  },
4741
4744
  input: {
4742
4745
  type: "string",
4743
- 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); omitting writerModelId and writerEffort assesses the ambient working tree and fails closed to a small writer profile (never large) because the writer's actual profile is unknown to this call. nativeReviewOutcome (one of closed, declined, unavailable, unknown) tells ASSESS whether the native review actually closed for this candidate: when Receipt-driven development reads on but the review was declined for this candidate, is unavailable, or its outcome is unknown, ASSESS falls back to the exact risk-gated plan it returns when RDD is off, re-enabling the separate verifier -- a decline is candidate-scoped and never lowers the bar below the RDD-off path. Omitting it lets ASSESS try to derive declined/unavailable from what this process itself recorded for this exact candidate (never a different one, and never from repository state alone), failing closed to unknown when it cannot; `closed` is never derived -- pass it explicitly, and only right after acknowledging the approved review for this same candidate. The returned outcome_source (explicit|derived|unknown) says which of these produced the value. Legacy controller input remains separate.",
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.",
4744
4747
  },
4745
4748
  outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
4746
4749
  inputPath: { type: "string", description: "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired." },
@@ -4748,7 +4751,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
4748
4751
  lineageIds: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
4749
4752
  workspaceRoot: {
4750
4753
  type: "string",
4751
- description: "Optional explicit user-authorized absolute path inside the Git worktree that owns this review. It must resolve to an existing Git worktree; nested paths are canonicalized to that worktree root. Pi never invents this selector. Absent, the session cwd is used unless one unambiguous lineage binding already identifies its target root.",
4754
+ description: "Optional explicit user-authorized absolute path to an existing directory that owns this review. Nested Git paths are canonicalized to their worktree root. Only inspect or new ordinary START with validated RDD on may ask native Gentle AI to prepare a non-Git directory; Pi never invents this selector. Absent, the session cwd is used unless one unambiguous lineage binding already identifies its target root.",
4752
4755
  },
4753
4756
  },
4754
4757
  } as const;
@@ -5056,6 +5059,11 @@ function parseControllerJson(input: string, operation: ReviewControllerOperation
5056
5059
  return value;
5057
5060
  }
5058
5061
 
5062
+ // Explicit cancellation uses the AbortError convention so observers can tell it from failure.
5063
+ function reviewCancellation(message: string): Error {
5064
+ return Object.assign(new Error(message), { name: "AbortError" });
5065
+ }
5066
+
5059
5067
  async function authorizeDestructiveReviewOperation(
5060
5068
  parametersValue: unknown,
5061
5069
  ctx: ExtensionContext,
@@ -5216,6 +5224,26 @@ function nativeReviewModeSkipped(operation: ReviewControllerOperation, source: N
5216
5224
  };
5217
5225
  }
5218
5226
 
5227
+ // The legacy START compatibility gate is deliberately permissive when mode is
5228
+ // unavailable. It is not authorization to enter bootstrap-capable native code.
5229
+ async function nativeRepositoryPreparationEnabled(nativeReviewCli: NativeReviewCli | null, cwd: string, signal?: AbortSignal): Promise<boolean> {
5230
+ if (nativeReviewCli?.reviewMode === undefined) return false;
5231
+ try {
5232
+ 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";
5234
+ } catch {
5235
+ return false;
5236
+ }
5237
+ }
5238
+
5239
+ function nativeRepositoryPreparationBlocked(operation: ReviewControllerOperation): Record<string, unknown> {
5240
+ return {
5241
+ operation, status: "blocked", outcome: "native-repository-bootstrap-not-authorized",
5242
+ reason: "A non-Git target requires successfully decoded effective RDD on before native repository preparation. The user owns the RDD switch; Pi does not enable it.",
5243
+ mutation_performed: false, mutation_outcome: "none", lineage_created: false,
5244
+ };
5245
+ }
5246
+
5219
5247
  async function resolveReviewModeGate(
5220
5248
  nativeReviewCli: NativeReviewCli | null,
5221
5249
  operation: ReviewControllerOperation,
@@ -5728,14 +5756,31 @@ interface RetainedNativeUntrackedSelection {
5728
5756
  readonly submission?: NativeIntendedUntrackedSelectionSubmission;
5729
5757
  }
5730
5758
 
5731
- interface RetainedPreLineageNativeUntrackedSelection extends RetainedNativeUntrackedSelection {
5759
+ // The untracked-selection fields are optional here, unlike the lineage-scoped
5760
+ // RetainedNativeUntrackedSelection above: a plain inspect that resolves ready
5761
+ // without ever needing an untrackedScope decision still retains its own
5762
+ // committed-range selector alone, bound only to targetIdentity/candidateTree.
5763
+ interface RetainedPreLineageNativeUntrackedSelection {
5764
+ readonly untrackedScope?: NativeStartUntrackedScope;
5765
+ readonly expectedUntrackedInventory?: string;
5766
+ readonly intendedUntracked?: readonly string[];
5767
+ readonly submission?: NativeIntendedUntrackedSelectionSubmission;
5732
5768
  readonly targetIdentity: string;
5733
5769
  readonly candidateTree: string;
5770
+ // gentle-pi#1192: only set when the inspect that produced this entry was a
5771
+ // committed-range inspect, so a later plain START can replay the same
5772
+ // selector instead of silently falling back to the workspace-diff range.
5773
+ readonly baseRef?: string;
5774
+ readonly committedOnly?: true;
5734
5775
  }
5735
5776
 
5736
5777
  interface RetainedNativeCaptureRoute { readonly workspaceRoot: string; readonly lineageId: string; readonly baseRef?: string; readonly committedOnly?: true; }
5737
5778
 
5738
- type RetainedNativeStatusSelection = RetainedNativeUntrackedSelection | RetainedNativeCaptureRoute;
5779
+ // An inspect that stops before submission has no resolved untracked selection
5780
+ // yet. Keep only its selector, bound to the exact provider collect input.
5781
+ interface RetainedNativeUntrackedStopSelector { readonly selectionBinding: string; readonly targetIdentity: string; readonly baseRef: string; readonly committedOnly: true; }
5782
+
5783
+ type RetainedNativeStatusSelection = RetainedNativeUntrackedSelection | RetainedPreLineageNativeUntrackedSelection | RetainedNativeCaptureRoute | RetainedNativeUntrackedStopSelector;
5739
5784
 
5740
5785
  const MAX_RETAINED_NATIVE_STATUS_SELECTIONS = 64;
5741
5786
  class NativeCaptureRouteRegistrationError extends Error {}
@@ -5790,7 +5835,7 @@ function nativeStartRejection(reason: string, field?: string): Record<string, un
5790
5835
  ? "native-start-committed-only-required"
5791
5836
  : reason === "committed-only-invalid"
5792
5837
  ? "native-start-committed-only-invalid"
5793
- : reason === "unknown-field" || reason === "focus-invalid" || reason === "untracked-selection-invalid"
5838
+ : reason === "unknown-field" || reason === "focus-invalid" || reason === "untracked-selection-invalid" || reason === "provider-base-offer-invalid" || reason === "provider-base-tree-mismatch"
5794
5839
  ? "native-start-input-invalid"
5795
5840
  : "native-start-policy-path-invalid",
5796
5841
  reason,
@@ -5946,7 +5991,7 @@ export class PendingReviewConsentRegistry {
5946
5991
  const processPendingReviewConsentRegistry = new PendingReviewConsentRegistry();
5947
5992
  const processRetainedNativeStatusSelections = new Map<PendingReviewConsentSessionKey, Map<string, RetainedNativeStatusSelection>>();
5948
5993
 
5949
- // gentle-pi#556 / gentle-ai#4051: nesting depth of named-agent (SDD phase
5994
+ // gentle-pi#556 / gentle-ai#4051: nesting depth of named-agent (
5950
5995
  // executor or other subagent) starts vs. ends for a session. Starts and
5951
5996
  // ends are paired so a subagent's own loop end never leaves the primary
5952
5997
  // loop's `agent_end` preflight suppressed for the rest of the session: a
@@ -6152,18 +6197,26 @@ function consentBindingRepositoryMismatchOutcome(operation: ReviewControllerOper
6152
6197
  // wants, so a plain START can adopt the route the provider just rendered
6153
6198
  // instead of making the caller hand-copy `base-ref` into its input. Only the
6154
6199
  // provider's own offered argument is read -- never a guess, a persisted value,
6155
- // or a stale transition -- and anything that is not a full commit id paired
6156
- // with committed-only is refused, so every other STATUS keeps today's
6157
- // behaviour byte-for-byte.
6200
+ // or a stale transition. Full object ids paired with committed-only may name
6201
+ // commits or trees; only provider trees enter the separate frozen tree-base
6202
+ // path. Caller-supplied tree selectors remain invalid.
6203
+ // The worktree anchor is always a commit, but a provider-offered range can
6204
+ // have a tree selector. Transport and lineage replay must use the latter.
6205
+ function nativeCommittedRangeSelector(target: { baseCommit: string; providerBaseTree?: string }): string {
6206
+ return target.providerBaseTree ?? target.baseCommit;
6207
+ }
6208
+
6158
6209
  const OFFERED_COMMITTED_RANGE_BASE_REF = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
6159
6210
 
6160
6211
  function offeredCommittedRangeBaseRef(target: ReviewStatusV3): string | undefined {
6161
6212
  const execute = target.nextTransition?.kind === "execute" ? target.nextTransition.execute : undefined;
6162
6213
  if (execute?.operation !== "review.start") return undefined;
6163
- if (execute.arguments.some((argument) => argument.name === "workspace-overlay")) return undefined;
6164
- const baseRef = execute.arguments.find((argument) => argument.name === "base-ref")?.value;
6165
- if (baseRef === undefined || !OFFERED_COMMITTED_RANGE_BASE_REF.test(baseRef)) return undefined;
6166
- if (execute.arguments.find((argument) => argument.name === "committed-only")?.value !== "true") return undefined;
6214
+ const selectors = execute.arguments.filter((argument) => argument.name === "base-ref" || argument.name === "committed-only");
6215
+ if (selectors.length !== 2 || new Set(selectors.map((argument) => argument.name)).size !== 2) return undefined;
6216
+ if (selectors.some((argument) => argument.token !== undefined && argument.token !== `--${argument.name}=${argument.value}`)) return undefined;
6217
+ const baseRef = selectors.find((argument) => argument.name === "base-ref")!.value;
6218
+ if (!OFFERED_COMMITTED_RANGE_BASE_REF.test(baseRef)) return undefined;
6219
+ if (selectors.find((argument) => argument.name === "committed-only")!.value !== "true") return undefined;
6167
6220
  return baseRef;
6168
6221
  }
6169
6222
 
@@ -6408,8 +6461,11 @@ async function reconcileNativeMutationFailure(
6408
6461
  }
6409
6462
 
6410
6463
  function reviewWorkspaceGitIdentity(cwd: string): { toplevel: string; commonDir: string } {
6464
+ // Discovery must describe the selected directory, never an ambient Git
6465
+ // override pointing at a different repository or worktree.
6466
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
6411
6467
  const git = (...arguments_: string[]): string =>
6412
- execFileSync("git", arguments_, { cwd, encoding: "utf8", windowsHide: true }).trim();
6468
+ execFileSync("git", arguments_, { cwd, env, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }).trim();
6413
6469
  const toplevel = realpathSync(git("rev-parse", "--show-toplevel"));
6414
6470
  const commonDir = realpathSync(resolve(cwd, git("rev-parse", "--git-common-dir")));
6415
6471
  return { toplevel, commonDir };
@@ -6418,14 +6474,18 @@ function reviewWorkspaceGitIdentity(cwd: string): { toplevel: string; commonDir:
6418
6474
  /**
6419
6475
  * Resolves the explicit user-authorized workspace target. An explicit path may
6420
6476
  * be nested and may belong to a repository unrelated to the Pi session cwd;
6421
- * Git resolves it to its canonical worktree top-level. The session cwd remains
6422
- * the legacy default only when no target was selected or remembered.
6477
+ * Git resolves it to its canonical worktree top-level. Only unbound inspect or
6478
+ * ordinary START may retain an existing unresolved directory for a later strict
6479
+ * effective-on gate; native, not this resolver, owns preparation and safeguards.
6480
+ * The session cwd is the default when no target was selected or remembered.
6423
6481
  */
6424
6482
  function resolveReviewControllerWorkspaceRoot(
6425
6483
  requested: string | undefined,
6426
6484
  sessionCwd: string,
6427
6485
  candidateViews: CandidateViewRegistry | null,
6428
6486
  lineageId: string | undefined,
6487
+ allowNativePreparation = false,
6488
+ resolution?: { identity?: { toplevel: string; commonDir: string } },
6429
6489
  ): string {
6430
6490
  const remembered = requested === undefined && lineageId !== undefined
6431
6491
  ? candidateViews?.resolveWorkspaceRoot(lineageId)
@@ -6445,10 +6505,11 @@ function resolveReviewControllerWorkspaceRoot(
6445
6505
  try {
6446
6506
  target = reviewWorkspaceGitIdentity(resolved);
6447
6507
  } catch {
6448
- if (requested === undefined && remembered === undefined) return sessionCwd;
6508
+ if (allowNativePreparation && lineageId === undefined && safeBootstrapDirectory(resolved) === resolved) return resolved;
6449
6509
  throw new Error(`Review controller workspaceRoot ${resolved} is not inside a Git worktree; review operations bind only to real worktrees of the session repository`);
6450
6510
  }
6451
6511
  if (lineageId !== undefined) candidateViews?.assertWorkspaceRoot(lineageId, target.toplevel);
6512
+ if (resolution) resolution.identity = target;
6452
6513
  return target.toplevel;
6453
6514
  }
6454
6515
 
@@ -6486,7 +6547,7 @@ function retainNativeUntrackedSelection(selections: Map<string, RetainedNativeSt
6486
6547
 
6487
6548
  function readRetainedNativeUntrackedSelection(selections: Map<string, RetainedNativeStatusSelection>, workspaceRoot: string, lineageId: string): NativeStartUntrackedSelection {
6488
6549
  const selection = selections.get(reviewLifecycleStorageKey(workspaceRoot, lineageId));
6489
- return selection === undefined || "baseRef" in selection
6550
+ return selection === undefined || "baseRef" in selection || "selectionBinding" in selection
6490
6551
  ? {}
6491
6552
  : {
6492
6553
  untrackedScope: selection.untrackedScope,
@@ -6512,8 +6573,12 @@ function readRetainedPreLineageNativeUntrackedSelection(
6512
6573
  workspaceRoot: string,
6513
6574
  ): RetainedPreLineageNativeUntrackedSelection | undefined {
6514
6575
  const selection = selections.get(reviewLifecycleStorageKey(workspaceRoot, ""));
6576
+ // gentle-pi#1192: this entry may now carry its own optional baseRef, so the
6577
+ // discriminant against RetainedNativeCaptureRoute (a distinct key namespace
6578
+ // that always carries workspaceRoot/lineageId) can no longer be "no baseRef".
6515
6579
  return selection !== undefined &&
6516
- !("baseRef" in selection) &&
6580
+ !isRetainedNativeCaptureRoute(selection) &&
6581
+ !("selectionBinding" in selection) &&
6517
6582
  typeof (selection as Partial<RetainedPreLineageNativeUntrackedSelection>).targetIdentity === "string" &&
6518
6583
  typeof (selection as Partial<RetainedPreLineageNativeUntrackedSelection>).candidateTree === "string"
6519
6584
  ? selection as RetainedPreLineageNativeUntrackedSelection
@@ -6536,7 +6601,7 @@ function isRetainedNativeCaptureRoute(selection: RetainedNativeStatusSelection |
6536
6601
 
6537
6602
  function retainNativeCaptureRoutes(selections: Map<string, RetainedNativeStatusSelection>, workspaceRoot: string, status: ReviewStatusV3, baseRef: string | undefined): void {
6538
6603
  const lineageId = status.authority?.lineageId;
6539
- if (status.applicability !== "current_target" || !isCanonicalProcessString(lineageId) || isTerminalReviewAuthorityState(status.authority?.state)) return;
6604
+ if (!canOfferNativeCaptureBindings(status) || !isCanonicalProcessString(lineageId)) return;
6540
6605
  const routes = (status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : []).map((input) => ({ key: reviewCaptureSelectionStorageKey(canonicalReviewCaptureBinding(input)), route: Object.freeze({ workspaceRoot, lineageId, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true as const }) }) }));
6541
6606
  for (const { key, route } of routes) {
6542
6607
  const existing = selections.get(key);
@@ -6552,6 +6617,12 @@ function readRetainedNativeCaptureRoute(selections: Map<string, RetainedNativeSt
6552
6617
  return isRetainedNativeCaptureRoute(selection) ? selection : undefined;
6553
6618
  }
6554
6619
 
6620
+ function trustedNativeCaptureBaseRef(route: RetainedNativeCaptureRoute | undefined, candidateViews: CandidateViewRegistry | null, cwd: string, lineageId: string): string | undefined {
6621
+ if (route !== undefined) return route.baseRef;
6622
+ const target = candidateViews?.hasProjection(lineageId, cwd) ? candidateViews.resolveProjection(lineageId, cwd) : undefined;
6623
+ return target?.committedOnly === true ? nativeCommittedRangeSelector(target) : undefined;
6624
+ }
6625
+
6555
6626
  function isTerminalReviewAuthorityState(state: string | undefined): boolean { return state === "invalidated" || state === "approved" || state === "escalated"; }
6556
6627
 
6557
6628
  function clearRetainedNativeUntrackedSelection(selections: Map<string, RetainedNativeStatusSelection>, workspaceRoot: string, lineageId: string): void {
@@ -6776,7 +6847,12 @@ async function executeReviewHostRelayCapture(
6776
6847
  // live model registry; the relay validates and refuses a missing
6777
6848
  // registry or a routing entry with no configured model typed before
6778
6849
  // anything launches, never a fallback to a child.
6779
- const launch = reviewHostRelaySelection(slot.routingKey ?? slot.lens, readModelConfig(cwd));
6850
+ // gentle-shell#1544: a repository profile pin re-anchors the whole reviewer
6851
+ // routing, so the relay resolves the pinned profile wholesale before the
6852
+ // global routing. A pin that omits a required role stays omitted: the
6853
+ // typed reviewer-config-invalid refusal below is fail-closed, never a
6854
+ // silent per-role fallback to another account's routing.
6855
+ const launch = reviewHostRelaySelection(slot.routingKey ?? slot.lens, pinnedEffectiveModelConfig(cwd) ?? readModelConfig(cwd));
6780
6856
  return {
6781
6857
  captureArgumentTokens: slot.captureArgumentTokens,
6782
6858
  targetCwd: cwd,
@@ -7143,15 +7219,11 @@ async function resolveNegotiatedReviewStatusForSession(
7143
7219
  sessionKey: PendingReviewConsentSessionKey,
7144
7220
  ): Promise<ReviewStatusV3 | undefined> {
7145
7221
  if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return undefined;
7146
- if (ctx.hasUI !== true) return undefined;
7147
- let modeEffective: "on" | "off";
7148
- try {
7149
- const mode = await nativeReviewCli.reviewMode({ cwd: ctx.cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS });
7150
- modeEffective = mode.status.effective;
7151
- } catch {
7152
- return undefined;
7153
- }
7154
- if (modeEffective === "off") return undefined;
7222
+ if (ctx.hasUI !== true || !resolveSessionWorktree(ctx.cwd, ctx.cwd)) return undefined;
7223
+ // STATUS can prepare an unversioned directory natively. Only a successfully
7224
+ // decoded effective-on mode authorizes that entry; bootstrap never counts as
7225
+ // an own mutation and native owns all metadata/ancestor safety decisions.
7226
+ if (!await nativeRepositoryPreparationEnabled(nativeReviewCli, ctx.cwd)) return undefined;
7155
7227
  try {
7156
7228
  const retainedSelections = ((key: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(key) ?? processRetainedNativeStatusSelections.set(key, new Map()).get(key)!)(sessionKey);
7157
7229
  const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: ctx.cwd }, retainedSelections, ctx.cwd);
@@ -7219,8 +7291,13 @@ const INSPECT_UNTRACKED_SELECTION_NEXT_STEP =
7219
7291
  'The intended-untracked selection is required before START. The expected_untracked_inventory digest covers untracked path names only (git ls-files --others --exclude-standard); nothing is read or hashed at inventory time, and file content is hashed only for selected paths at candidate freeze. Either call gentle_review with operation "select-intended-untracked" passing this selectionBinding and intendedUntracked ([] excludes every eligible path, a subset includes only those paths), or call inspect again with untrackedScope ("exclude", or "select" with intendedUntracked) to resolve the round trip in one call. To keep a path out of the inventory permanently, ignore it through .gitignore or .git/info/exclude.';
7220
7292
 
7221
7293
  interface PublicReviewCaptureBinding { collectBinding: string; }
7294
+ function canOfferNativeCaptureBindings(status: ReviewStatusV3): boolean {
7295
+ return status.applicability === "current_target" && isCanonicalProcessString(status.authority?.lineageId)
7296
+ && isCanonicalProcessString(status.targetIdentity) && !isTerminalReviewAuthorityState(status.authority?.state);
7297
+ }
7298
+
7222
7299
  function publicReviewCaptureBindings(status: ReviewStatusV3): readonly PublicReviewCaptureBinding[] {
7223
- if (status.nextTransition?.kind !== "collect") return [];
7300
+ if (!canOfferNativeCaptureBindings(status) || status.nextTransition?.kind !== "collect") return [];
7224
7301
  return (status.nextTransition.collect?.inputs ?? []).filter((input) => input.captureOperation !== "external.select_intended_untracked").map((input) => ({ collectBinding: canonicalReviewCaptureBinding(input) }));
7225
7302
  }
7226
7303
 
@@ -7251,7 +7328,7 @@ function selectExactReviewCapture(
7251
7328
  !isCanonicalProcessString(lineageId) ||
7252
7329
  !isCanonicalProcessString(statusLineageId) ||
7253
7330
  !isCanonicalProcessString(statusTargetIdentity) ||
7254
- status.applicability !== "current_target" ||
7331
+ !canOfferNativeCaptureBindings(status) ||
7255
7332
  statusLineageId !== lineageId
7256
7333
  ) {
7257
7334
  return captureBindingRejected("current STATUS does not offer one non-empty matching lineage and target identity");
@@ -7404,16 +7481,17 @@ async function executeReviewCaptureOperation(
7404
7481
  }
7405
7482
  const canonicalBinding = parseCanonicalReviewCaptureBinding(parameters.collectBinding);
7406
7483
  const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
7407
- const route = readRetainedNativeCaptureRoute(retainedUntrackedSelections, canonicalBinding);
7408
- if (requireRegisteredRoute && (route === undefined || route.workspaceRoot !== cwd || route.lineageId !== parameters.lineageId)) {
7409
- return captureBindingRejected("collectBinding is unknown, expired, or belongs to a different session route");
7484
+ let route = readRetainedNativeCaptureRoute(retainedUntrackedSelections, canonicalBinding);
7485
+ if (requireRegisteredRoute && route !== undefined && (route.workspaceRoot !== cwd || route.lineageId !== parameters.lineageId)) {
7486
+ return captureBindingRejected("collectBinding belongs to a different registered route");
7410
7487
  }
7488
+ const baseRef = trustedNativeCaptureBaseRef(route, candidateViews, cwd, parameters.lineageId);
7411
7489
  let status: ReviewStatusV3;
7412
7490
  try {
7413
7491
  const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
7414
7492
  cwd,
7415
7493
  lineageId: parameters.lineageId,
7416
- ...(route?.baseRef === undefined ? {} : { baseRef: route.baseRef, committedOnly: true }),
7494
+ ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }),
7417
7495
  ...readRetainedNativeUntrackedSelection(retainedUntrackedSelections, cwd, parameters.lineageId),
7418
7496
  ...(signal === undefined ? {} : { signal }),
7419
7497
  }, retainedUntrackedSelections, cwd);
@@ -7424,6 +7502,9 @@ async function executeReviewCaptureOperation(
7424
7502
  }
7425
7503
  const selected = selectExactReviewCapture(status, parameters.lineageId, canonicalBinding);
7426
7504
  if (!isSelectedReviewCapture(selected)) return selected;
7505
+ // Exact fresh native admission validates this routing snapshot independently
7506
+ // of cache capacity. Carry its trusted selector into every downstream path.
7507
+ route = { workspaceRoot: cwd, lineageId: parameters.lineageId, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }) };
7427
7508
 
7428
7509
  // During correction the flow carries both the original authority target
7429
7510
  // identity and a distinct provider-issued correction target identity
@@ -7567,13 +7648,14 @@ async function executeReviewCaptureGroupOperation(
7567
7648
  const canonicalBindings = parameters.collectBindings.map((binding) => parseCanonicalReviewCaptureBinding(binding));
7568
7649
  const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
7569
7650
  const routes = canonicalBindings.map((binding) => readRetainedNativeCaptureRoute(retainedUntrackedSelections, binding));
7570
- const route = routes[0];
7571
- if (requireRegisteredRoute && (route === undefined || routes.some((candidate) => candidate === undefined || candidate.workspaceRoot !== cwd || candidate.lineageId !== parameters.lineageId || candidate.baseRef !== route.baseRef))) {
7572
- return captureGroupRejected("collectBindings are unknown, expired, or belong to different session routes");
7651
+ let route = routes.find((candidate) => candidate !== undefined);
7652
+ if (requireRegisteredRoute && routes.some((candidate) => candidate !== undefined && (candidate.workspaceRoot !== cwd || candidate.lineageId !== parameters.lineageId || candidate.baseRef !== route?.baseRef))) {
7653
+ return captureGroupRejected("collectBindings belong to different registered routes");
7573
7654
  }
7655
+ const baseRef = trustedNativeCaptureBaseRef(route, candidateViews, cwd, parameters.lineageId);
7574
7656
  const freshStatus = () => negotiatedStatusForHostTransport(nativeReviewCli, {
7575
7657
  cwd, lineageId: parameters.lineageId,
7576
- ...(route?.baseRef === undefined ? {} : { baseRef: route.baseRef, committedOnly: true }),
7658
+ ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }),
7577
7659
  ...readRetainedNativeUntrackedSelection(retainedUntrackedSelections, cwd, parameters.lineageId),
7578
7660
  ...(signal === undefined ? {} : { signal }),
7579
7661
  }, retainedUntrackedSelections, cwd);
@@ -7587,6 +7669,7 @@ async function executeReviewCaptureGroupOperation(
7587
7669
  }
7588
7670
  const group = selectExactReviewCaptureGroup(status, parameters.lineageId, canonicalBindings);
7589
7671
  if (!("slots" in group && "binding" in group)) return group;
7672
+ route = { workspaceRoot: cwd, lineageId: parameters.lineageId, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }) };
7590
7673
  if (parameters.reviewerRunAcknowledged !== true) {
7591
7674
  return {
7592
7675
  tool: "gentle_review_capture_group",
@@ -7597,11 +7680,15 @@ async function executeReviewCaptureGroupOperation(
7597
7680
  mutation_outcome: "none",
7598
7681
  };
7599
7682
  }
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);
7600
7687
  const requests: readonly ReviewHostRelayRequest[] = group.slots.map((slot) => ({
7601
7688
  captureArgumentTokens: slot.captureArgumentTokens,
7602
7689
  targetCwd: cwd,
7603
7690
  submission: slot.submission!,
7604
- ...reviewHostRelaySelection(slot.lens, readModelConfig(cwd)),
7691
+ ...reviewHostRelaySelection(slot.lens, reviewerRouting),
7605
7692
  ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
7606
7693
  ...(reviewerSessionId === undefined ? {} : { reviewerSessionId }),
7607
7694
  ...(signal === undefined ? {} : { signal }),
@@ -7655,6 +7742,9 @@ function hydrateDispatchBindingFromStatus(candidateViews: CandidateViewRegistry
7655
7742
  const lenses = pendingReviewerLenses(status);
7656
7743
  if (lenses.length === 0) return undefined;
7657
7744
  try {
7745
+ // STATUS proves the current target, not the historical START offer.
7746
+ // Resolve an existing commit-backed range by its reachable frozen tree;
7747
+ // never pass descriptor.baseTree as a provider-owned tree selector.
7658
7748
  candidateViews.restoreCurrentForDispatchFromNative(lineageId, contributorRoot, status.projection, lenses);
7659
7749
  return { hydrated: true, lineage_id: lineageId, lenses };
7660
7750
  } catch (error) {
@@ -7685,15 +7775,8 @@ async function executeReviewControllerOperation(
7685
7775
  intendedUntrackedSelection?: NativeIntendedUntrackedSelectionSubmission,
7686
7776
  ): Promise<Record<string, unknown>> {
7687
7777
  const parameters = parseReviewControllerParameters(parametersValue);
7688
- const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
7689
- const pendingReviewConsentSession = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey);
7690
- const _useTargetLifecycleRoot = requiresExplicitTargetLifecycleRoot(parameters.workspaceRoot, sessionCwd, defaultCwd);
7691
- const includeWorkspaceRoot = parameters.workspaceRoot !== undefined || defaultCwd !== sessionCwd;
7692
7778
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.EXPORT || parameters.operation === REVIEW_CONTROLLER_OPERATION.IMPORT) {
7693
- // Legacy bundle transport rode on the retired pre-integration graph/compact
7694
- // stores. The native v2.1.11 CLI exposes no bundle equivalent, so both
7695
- // operations return a structured retirement envelope; the enum members are
7696
- // kept so the tool schema stays stable for existing callers.
7779
+ // Retired transport needs no repository or lifecycle authority.
7697
7780
  return {
7698
7781
  operation: parameters.operation,
7699
7782
  status: "blocked",
@@ -7704,13 +7787,34 @@ async function executeReviewControllerOperation(
7704
7787
  next_action: "Use the native `gentle-ai review` CLI (start/finalize/validate/status/recover) against the repository review authority; receipts and canonical artifacts live in the Git common-directory store at .git/gentle-ai/reviews and travel with the repository through normal Git replication.",
7705
7788
  };
7706
7789
  }
7790
+ const allowsPreparation = parameters.lineageId === undefined && (
7791
+ parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT ||
7792
+ (parameters.operation === REVIEW_CONTROLLER_OPERATION.START &&
7793
+ parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation).mode === REVIEW_MODE.ORDINARY)
7794
+ );
7795
+ const workspaceResolution: { identity?: { toplevel: string; commonDir: string } } = {};
7796
+ const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId, allowsPreparation, workspaceResolution);
7797
+ const needsPreparation = allowsPreparation && workspaceResolution.identity === undefined;
7798
+ const preparationCurrent = allowsPreparation && context?.sessionManager
7799
+ ? captureBoundSessionRepositoryAuthority(context.sessionManager, context.sessionManager.getCwd?.() ?? sessionCwd)
7800
+ : undefined;
7801
+ const assertPreparationAuthority = () => {
7802
+ if (allowsPreparation && signal?.aborted) throw reviewCancellation("Review controller operation was cancelled");
7803
+ if (preparationCurrent && !preparationCurrent()) {
7804
+ throw new Error("Session Git authority or lifecycle changed; cannot prepare its review repository.");
7805
+ }
7806
+ };
7807
+ assertPreparationAuthority();
7808
+ const pendingReviewConsentSession = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey);
7809
+ const _useTargetLifecycleRoot = requiresExplicitTargetLifecycleRoot(parameters.workspaceRoot, sessionCwd, defaultCwd);
7810
+ const includeWorkspaceRoot = parameters.workspaceRoot !== undefined || defaultCwd !== sessionCwd;
7707
7811
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ASSESS) {
7708
7812
  // Read-only native risk assessment (gentle-ai#4295, gentle-pi#662). Never
7709
7813
  // mutates, never requires a lineageId, and never routes through
7710
7814
  // authorizeDestructiveReviewOperation (it returns early for any
7711
7815
  // operation that is neither RESET nor a maintenance operation).
7712
7816
  const input = parseReviewAssessInput(parameters.operation, parameters.input);
7713
- const details = await resolveReviewAssessmentPlan(nativeReviewCli, defaultCwd, input, signal);
7817
+ const details = await resolveReviewAssessmentPlan(nativeReviewCli, defaultCwd, input, signal, context?.sessionManager as ReceiptSession | undefined);
7714
7818
  return { operation: parameters.operation, ...details, ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
7715
7819
  }
7716
7820
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS) {
@@ -7747,6 +7851,10 @@ async function executeReviewControllerOperation(
7747
7851
  return nativeInspectInputRejection("base-ref-unresolvable");
7748
7852
  }
7749
7853
  }
7854
+ if (needsPreparation && !await nativeRepositoryPreparationEnabled(nativeReviewCli, defaultCwd, signal)) {
7855
+ return nativeRepositoryPreparationBlocked(parameters.operation);
7856
+ }
7857
+ assertPreparationAuthority();
7750
7858
  const inspectSelector = canonicalBaseRef === undefined
7751
7859
  ? {}
7752
7860
  : { baseRef: canonicalBaseRef, committedOnly: true as const };
@@ -7779,6 +7887,18 @@ async function executeReviewControllerOperation(
7779
7887
  undefined,
7780
7888
  );
7781
7889
  if (parameters.untrackedScope === undefined) {
7890
+ if (canonicalBaseRef !== undefined && typeof plainMapped.selectionBinding === "string") {
7891
+ retainNativeStatusSelection(retainedUntrackedSelections, reviewLifecycleStorageKey(defaultCwd, ""), Object.freeze({ selectionBinding: plainMapped.selectionBinding, targetIdentity: status.targetIdentity, baseRef: canonicalBaseRef, committedOnly: true as const }));
7892
+ } else if (canonicalBaseRef !== undefined && plainMapped.status === "ready") {
7893
+ // A plain inspect that resolves ready with no untracked decision still
7894
+ // retains its own committed-range selector, so the following plain
7895
+ // START replays this exact inspected range instead of adopting the
7896
+ // native default base-ref (gentle-pi#874).
7897
+ const readyCandidateIdentity = nativePreLineageCandidateIdentity(status);
7898
+ if (readyCandidateIdentity !== undefined) {
7899
+ retainNativeStatusSelection(retainedUntrackedSelections, reviewLifecycleStorageKey(defaultCwd, ""), Object.freeze({ ...readyCandidateIdentity, baseRef: canonicalBaseRef, committedOnly: true as const }));
7900
+ }
7901
+ }
7782
7902
  // gentle-pi#706: the stop alone never tells the caller what to do next.
7783
7903
  return {
7784
7904
  ...plainMapped,
@@ -7865,6 +7985,10 @@ async function executeReviewControllerOperation(
7865
7985
  intendedUntracked: Object.freeze([...selected.intendedUntracked!]),
7866
7986
  submission,
7867
7987
  ...candidateIdentity,
7988
+ // gentle-pi#1192: remember the committed-range selector this
7989
+ // inspect used, so a later plain START replays it instead of
7990
+ // recomputing an unrelated workspace-diff candidate.
7991
+ ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true as const }),
7868
7992
  }),
7869
7993
  );
7870
7994
  }
@@ -7927,7 +8051,7 @@ async function executeReviewControllerOperation(
7927
8051
  const statusRequest = {
7928
8052
  cwd: defaultCwd,
7929
8053
  lineageId: String(input.predecessorLineage),
7930
- ...(frozenTarget?.committedOnly === true ? { baseRef: frozenTarget.baseCommit, committedOnly: true } : {}),
8054
+ ...(frozenTarget?.committedOnly === true ? { baseRef: nativeCommittedRangeSelector(frozenTarget), committedOnly: true } : {}),
7931
8055
  ...(signal === undefined ? {} : { signal }),
7932
8056
  };
7933
8057
  let status: ReviewStatusV3;
@@ -8001,11 +8125,11 @@ async function executeReviewControllerOperation(
8001
8125
  const frozenTarget = parameters.lineageId === undefined || !candidateViews?.hasProjection(parameters.lineageId, defaultCwd) ? undefined : candidateViews.resolveProjection(parameters.lineageId, defaultCwd);
8002
8126
  let status: ReviewStatusV3;
8003
8127
  try {
8004
- status = await nativeReviewCli.targetStatus({ cwd: defaultCwd, ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), ...(frozenTarget?.committedOnly === true ? { baseRef: frozenTarget.baseCommit, committedOnly: true } : {}), ...(signal === undefined ? {} : { signal }) });
8128
+ status = await nativeReviewCli.targetStatus({ cwd: defaultCwd, ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), ...(frozenTarget?.committedOnly === true ? { baseRef: nativeCommittedRangeSelector(frozenTarget), committedOnly: true } : {}), ...(signal === undefined ? {} : { signal }) });
8005
8129
  } catch (error) {
8006
8130
  return nativeStatusFailed(parameters.operation, error);
8007
8131
  }
8008
- clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state); retainNativeCaptureRoutes(retainedUntrackedSelections, defaultCwd, status, frozenTarget?.committedOnly === true ? frozenTarget.baseCommit : undefined);
8132
+ clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state); retainNativeCaptureRoutes(retainedUntrackedSelections, defaultCwd, status, frozenTarget?.committedOnly === true ? nativeCommittedRangeSelector(frozenTarget) : undefined);
8009
8133
  if (status.authority?.version === "compact-v2") return { operation: parameters.operation, repaired: false, compact_authority: "immutable-untouched", status: mapNativeTargetStatus(parameters.operation, status, parameters.lineageId) };
8010
8134
  if (status.authority?.version !== "legacy-v1") return mapNativeTargetStatus(parameters.operation, status, parameters.lineageId);
8011
8135
  const store = ReviewTransactionStore.forRepository(defaultCwd);
@@ -8045,7 +8169,7 @@ async function executeReviewControllerOperation(
8045
8169
  const target = {
8046
8170
  cwd: defaultCwd,
8047
8171
  lineageId: parameters.lineageId,
8048
- ...(frozenTarget?.committedOnly === true ? { baseRef: frozenTarget.baseCommit, committedOnly: true } : {}),
8172
+ ...(frozenTarget?.committedOnly === true ? { baseRef: nativeCommittedRangeSelector(frozenTarget), committedOnly: true } : {}),
8049
8173
  ...readRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId),
8050
8174
  ...(signal === undefined ? {} : { signal }),
8051
8175
  };
@@ -8109,9 +8233,10 @@ async function executeReviewControllerOperation(
8109
8233
  // The registry owns restoring writability of its 0555 views before
8110
8234
  // removal; a terminal approved cleanup keeps the lineage projection.
8111
8235
  const candidateViewCleanup = deferredPostBurnCleanup(POST_BURN_CLEANUP.candidateView, () => candidateViews?.cleanupTerminal(parameters.lineageId, "approved", defaultCwd));
8112
- // gentle-pi#668: `closed` is never auto-derived or recorded here --
8113
- // a parent that wants the on-path passes nativeReviewOutcome:
8114
- // "closed" explicitly on its next assess call for this candidate.
8236
+ // gentle-pi#668/#1175: `closed` is never recorded here -- the burn this
8237
+ // acknowledgement completed makes native report candidate.consumed for
8238
+ // this exact candidate, and the next assess call derives closure from
8239
+ // that evidence alone.
8115
8240
  return {
8116
8241
  operation: parameters.operation,
8117
8242
  status: "closed",
@@ -8225,7 +8350,7 @@ async function executeReviewControllerOperation(
8225
8350
  if (value.mutationOutcome === "none") pending.cleanupCandidate();
8226
8351
  return await reconcileNativeMutationFailure(parameters.operation, error, nativeReviewCli, {
8227
8352
  cwd: pending.authorityCwd,
8228
- ...(pending.candidateView.committedOnly ? { baseRef: pending.candidateView.baseCommit, committedOnly: true } : {}),
8353
+ ...(pending.candidateView.committedOnly ? { baseRef: nativeCommittedRangeSelector(pending.candidateView), committedOnly: true } : {}),
8229
8354
  projection: "workspace",
8230
8355
  }, retainedUntrackedSelections);
8231
8356
  }
@@ -8234,9 +8359,13 @@ async function executeReviewControllerOperation(
8234
8359
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED) {
8235
8360
  if (nativeReviewCli?.targetStatus === undefined || nativeReviewCli.start === undefined) return nativeStatusUnsupported(parameters.operation);
8236
8361
  const canonicalBinding = parseCanonicalReviewCaptureBinding(parameters.selectionBinding!);
8362
+ const retainedStop = retainedUntrackedSelections.get(reviewLifecycleStorageKey(defaultCwd, ""));
8363
+ const stopSelector = retainedStop !== undefined && "selectionBinding" in retainedStop ? retainedStop : undefined;
8364
+ if (stopSelector !== undefined && stopSelector.selectionBinding !== canonicalBinding) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" };
8365
+ const committedSelector = stopSelector === undefined ? {} : { baseRef: stopSelector.baseRef, committedOnly: true as const };
8237
8366
  let status: ReviewStatusV3;
8238
8367
  try {
8239
- const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: defaultCwd, ...(signal === undefined ? {} : { signal }) }, retainedUntrackedSelections, defaultCwd);
8368
+ const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: defaultCwd, ...committedSelector, ...(signal === undefined ? {} : { signal }) }, retainedUntrackedSelections, defaultCwd);
8240
8369
  if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport);
8241
8370
  status = negotiated.status!;
8242
8371
  } catch (error) { return nativeStatusFailed(parameters.operation, error); }
@@ -8245,10 +8374,10 @@ async function executeReviewControllerOperation(
8245
8374
  try { eligible = JSON.parse(eligibleJson ?? ""); } catch { eligible = undefined; }
8246
8375
  const scope = parameters.intendedUntracked!.length === 0 ? NATIVE_START_UNTRACKED_SCOPE.EXCLUDE : NATIVE_START_UNTRACKED_SCOPE.SELECT;
8247
8376
  const selected = validateNativeStartUntrackedSelection({ untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: parameters.intendedUntracked });
8248
- const rejected = 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));
8377
+ 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));
8249
8378
  if (rejected) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" };
8250
8379
  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 }) };
8251
- const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, reviewConsentNow, reviewConsentScheduleTimer, submission);
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);
8252
8381
  return { ...result, operation: parameters.operation };
8253
8382
  }
8254
8383
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
@@ -8277,7 +8406,7 @@ async function executeReviewControllerOperation(
8277
8406
  // gentle-pi#706: a plain START adopts the selection an inspect
8278
8407
  // untrackedScope round trip retained pre-lineage; explicit input or a
8279
8408
  // carried submission always wins over the retained entry.
8280
- const retainedPreLineageSelection =
8409
+ const rawRetainedPreLineageSelection =
8281
8410
  explicitUntrackedSelection.untrackedScope === undefined &&
8282
8411
  intendedUntrackedSelection === undefined
8283
8412
  ? readRetainedPreLineageNativeUntrackedSelection(
@@ -8285,21 +8414,8 @@ async function executeReviewControllerOperation(
8285
8414
  defaultCwd,
8286
8415
  )
8287
8416
  : undefined;
8288
- const untrackedSelection: NativeStartUntrackedSelection =
8289
- retainedPreLineageSelection === undefined
8290
- ? explicitUntrackedSelection
8291
- : {
8292
- untrackedScope: retainedPreLineageSelection.untrackedScope,
8293
- expectedUntrackedInventory:
8294
- retainedPreLineageSelection.expectedUntrackedInventory,
8295
- intendedUntracked: [...retainedPreLineageSelection.intendedUntracked],
8296
- };
8297
- const untrackedSubmission =
8298
- intendedUntrackedSelection ?? retainedPreLineageSelection?.submission;
8299
- const retainedUntrackedSelection =
8300
- retainedPreLineageSelection ??
8301
- cloneRetainedNativeUntrackedSelection(explicitUntrackedSelection);
8302
8417
  let canonicalBaseRef: string | undefined;
8418
+ let providerBaseTree: string | undefined;
8303
8419
  if (baseRef !== undefined) {
8304
8420
  try {
8305
8421
  canonicalBaseRef = resolveCanonicalCandidateBase(defaultCwd, baseRef).commit;
@@ -8309,12 +8425,62 @@ async function executeReviewControllerOperation(
8309
8425
  return nativeStartRejection("base-ref-unresolvable");
8310
8426
  }
8311
8427
  }
8312
- try {
8313
- const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
8314
- if (gated !== undefined) return gated;
8315
- } catch (error) {
8316
- return nativeOperationFailure(parameters.operation, error);
8428
+ // gentle-pi#1192: an explicit baseRef always wins over a conflicting
8429
+ // retained one (same precedent as explicit untrackedScope/submission
8430
+ // above); drop the retained entry from *this* START rather than reject
8431
+ // outright, so a differently-scoped explicit START still proceeds. Any
8432
+ // STATUS/START this call still runs against the "" key clears it below
8433
+ // on success regardless (gentle-pi#706), same as any other START.
8434
+ const retainedBaseRefConflicts =
8435
+ canonicalBaseRef !== undefined &&
8436
+ rawRetainedPreLineageSelection?.baseRef !== undefined &&
8437
+ rawRetainedPreLineageSelection.baseRef !== canonicalBaseRef;
8438
+ const retainedPreLineageSelection = retainedBaseRefConflicts ? undefined : rawRetainedPreLineageSelection;
8439
+ // A plain START (no explicit baseRef) adopts the retained committed-range
8440
+ // selector verbatim; it was already canonicalized when the inspect stored
8441
+ // it, so no second resolveCanonicalCandidateBase round trip is needed.
8442
+ if (canonicalBaseRef === undefined && retainedPreLineageSelection?.baseRef !== undefined) canonicalBaseRef = retainedPreLineageSelection.baseRef;
8443
+ // A retained pre-lineage entry may carry only a committed-range selector
8444
+ // with no untracked decision at all (a plain inspect that resolved ready
8445
+ // without ever needing untrackedScope); only adopt its untracked fields
8446
+ // when it actually recorded one.
8447
+ const untrackedSelection: NativeStartUntrackedSelection =
8448
+ retainedPreLineageSelection === undefined || retainedPreLineageSelection.untrackedScope === undefined
8449
+ ? explicitUntrackedSelection
8450
+ : {
8451
+ untrackedScope: retainedPreLineageSelection.untrackedScope,
8452
+ expectedUntrackedInventory:
8453
+ retainedPreLineageSelection.expectedUntrackedInventory!,
8454
+ intendedUntracked: [...retainedPreLineageSelection.intendedUntracked!],
8455
+ };
8456
+ const untrackedSubmission =
8457
+ intendedUntrackedSelection ??
8458
+ (retainedPreLineageSelection?.untrackedScope === undefined ? undefined : retainedPreLineageSelection.submission);
8459
+ // The stored value must stay a plain RetainedNativeUntrackedSelection
8460
+ // (no baseRef/targetIdentity/candidateTree): it is re-keyed under the
8461
+ // lineage-scoped entry below, and readRetainedNativeUntrackedSelection
8462
+ // discriminates that entry from a RetainedNativeCaptureRoute by the
8463
+ // absence of a baseRef field.
8464
+ const retainedUntrackedSelection: RetainedNativeUntrackedSelection | undefined =
8465
+ retainedPreLineageSelection === undefined || retainedPreLineageSelection.untrackedScope === undefined
8466
+ ? cloneRetainedNativeUntrackedSelection(explicitUntrackedSelection)
8467
+ : Object.freeze({
8468
+ untrackedScope: retainedPreLineageSelection.untrackedScope,
8469
+ expectedUntrackedInventory: retainedPreLineageSelection.expectedUntrackedInventory,
8470
+ intendedUntracked: retainedPreLineageSelection.intendedUntracked,
8471
+ ...(retainedPreLineageSelection.submission === undefined ? {} : { submission: retainedPreLineageSelection.submission }),
8472
+ });
8473
+ if (needsPreparation) {
8474
+ if (!await nativeRepositoryPreparationEnabled(nativeReviewCli, defaultCwd, signal)) return nativeRepositoryPreparationBlocked(parameters.operation);
8475
+ } else {
8476
+ try {
8477
+ const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
8478
+ if (gated !== undefined) return gated;
8479
+ } catch (error) {
8480
+ return nativeOperationFailure(parameters.operation, error);
8481
+ }
8317
8482
  }
8483
+ assertPreparationAuthority();
8318
8484
  if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
8319
8485
  let target: ReviewStatusV3;
8320
8486
  try {
@@ -8330,7 +8496,7 @@ async function executeReviewControllerOperation(
8330
8496
  target = negotiated.status!;
8331
8497
  // gentle-pi#874: the STATUS this START already fetched can itself
8332
8498
  // offer the committed-range START for an empty workspace candidate.
8333
- // Adopt its own base commit *here*, before the candidate view and the
8499
+ // Adopt its own base selector *here*, before the candidate view and the
8334
8500
  // native START are resolved, and re-derive the target for that range,
8335
8501
  // so all three agree on one base-diff identity. Adopting the offer
8336
8502
  // later left the workspace target and the base-diff candidate view
@@ -8342,6 +8508,7 @@ async function executeReviewControllerOperation(
8342
8508
  // the base-less view that tripped candidate-target-projection-drift.
8343
8509
  if (canonicalBaseRef === undefined) {
8344
8510
  const offeredBaseRef = offeredCommittedRangeBaseRef(target);
8511
+ if (offeredBaseRef === undefined && target.nextTransition?.kind === "execute" && target.nextTransition.execute.operation === "review.start" && target.nextTransition.execute.arguments.some((argument) => argument.name === "base-ref")) return nativeStartRejection("provider-base-offer-invalid");
8345
8512
  if (offeredBaseRef !== undefined) {
8346
8513
  const renegotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
8347
8514
  cwd: defaultCwd,
@@ -8353,7 +8520,12 @@ async function executeReviewControllerOperation(
8353
8520
  ...(signal === undefined ? {} : { signal }),
8354
8521
  }, retainedUntrackedSelections, defaultCwd);
8355
8522
  if (renegotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, renegotiated.transport);
8356
- canonicalBaseRef = offeredBaseRef;
8523
+ if (isProviderCandidateBaseTree(defaultCwd, offeredBaseRef)) {
8524
+ if (renegotiated.status!.projection.baseTree !== offeredBaseRef) return nativeStartRejection("provider-base-tree-mismatch");
8525
+ providerBaseTree = offeredBaseRef;
8526
+ } else {
8527
+ canonicalBaseRef = offeredBaseRef;
8528
+ }
8357
8529
  target = renegotiated.status!;
8358
8530
  }
8359
8531
  }
@@ -8384,7 +8556,7 @@ async function executeReviewControllerOperation(
8384
8556
  // recovery. Folding in currentCandidateTree makes a content change
8385
8557
  // mint a fresh replay key -- and therefore a fresh candidate view --
8386
8558
  // instead of reusing the stale one.
8387
- const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null, candidateTree: target.projection.currentCandidateTree });
8559
+ 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 });
8388
8560
  // Synchronously drop any binding whose TTL has already elapsed
8389
8561
  // before reusing its retained candidate view, so a fresh-candidate
8390
8562
  // retry cannot reuse a view tied to an expired binding and trip
@@ -8395,7 +8567,7 @@ async function executeReviewControllerOperation(
8395
8567
  let candidateView: ReturnType<CandidateViewRegistry["create"]> | undefined;
8396
8568
  let nativeStartAttempted = false;
8397
8569
  try {
8398
- const candidateRequest = { contributorRoot: defaultCwd, replayKey, ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }) };
8570
+ const candidateRequest = { contributorRoot: defaultCwd, replayKey, ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }), ...(providerBaseTree === undefined ? {} : { providerBaseTree, committedOnly: true }) };
8399
8571
  candidateView = candidateViews?.createOrReuse({ ...candidateRequest, intendedUntracked: candidateIntendedUntracked });
8400
8572
  if (candidateView !== undefined) assertNativeStartCandidateBinding(candidateView, target);
8401
8573
  let result: NativeStartResult;
@@ -8406,6 +8578,7 @@ async function executeReviewControllerOperation(
8406
8578
  ...(canonicalBaseRef === undefined
8407
8579
  ? {}
8408
8580
  : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef, committedOnly: true }),
8581
+ ...(providerBaseTree === undefined ? {} : { baseRef: providerBaseTree, committedOnly: true }),
8409
8582
  targetIdentity: target.targetIdentity,
8410
8583
  projection: target.projection.projection,
8411
8584
  ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
@@ -8495,6 +8668,7 @@ async function executeReviewControllerOperation(
8495
8668
  cwd: defaultCwd,
8496
8669
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
8497
8670
  ...(canonicalBaseRef === undefined ? {} : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef, committedOnly: true }),
8671
+ ...(providerBaseTree === undefined ? {} : { baseRef: providerBaseTree, committedOnly: true }),
8498
8672
  ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
8499
8673
  projection: "workspace",
8500
8674
  }, retainedUntrackedSelections);
@@ -8602,7 +8776,7 @@ async function executeReviewControllerOperation(
8602
8776
  const retainedCommittedTarget = rawStatus === undefined && parameters.lineageId !== undefined && candidateViews?.hasProjection(parameters.lineageId, defaultCwd)
8603
8777
  ? candidateViews.resolveProjection(parameters.lineageId, defaultCwd)
8604
8778
  : undefined;
8605
- const effectiveBaseRef = baseRef ?? (retainedCommittedTarget?.committedOnly === true ? retainedCommittedTarget.baseCommit : undefined);
8779
+ const effectiveBaseRef = baseRef ?? (retainedCommittedTarget?.committedOnly === true ? nativeCommittedRangeSelector(retainedCommittedTarget) : undefined);
8606
8780
  if (nativeReviewCli?.targetStatus !== undefined) {
8607
8781
  try {
8608
8782
  const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
@@ -8653,6 +8827,8 @@ export const __testing = {
8653
8827
  guardedCommandPreview,
8654
8828
  guardedCommandTitle,
8655
8829
  loadRuntimeGuardrailsConfig,
8830
+ isOrdinaryYoloPush,
8831
+ yoloPushConfiguredRestriction,
8656
8832
  buildGentlePrompt,
8657
8833
  nativeStatusUnsupported,
8658
8834
  nativeStartRejection,
@@ -8686,8 +8862,6 @@ export const __testing = {
8686
8862
  readNativeReviewOutcome,
8687
8863
  recordNativeReviewOutcome,
8688
8864
  clearNativeReviewOutcomeMemoForTesting,
8689
- resolveSelectedNativeSddChangeStartup,
8690
- readSddChangeFlag,
8691
8865
  resetTelemetryTriggerGuardForTesting,
8692
8866
  createGentleAiExtension: createGentleAiExtensionForTesting,
8693
8867
  getPiModelOptions,
@@ -8743,16 +8917,12 @@ function createGentleAiExtensionForTesting(
8743
8917
  const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary;
8744
8918
  const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter();
8745
8919
  return function gentleAi(pi: ExtensionAPI): void {
8746
- const flags = pi as unknown as { registerFlag?: (name: string, definition: { description: string; type: "string"; default?: string }) => void };
8747
- flags.registerFlag?.(SDD_CHANGE_FLAG, {
8748
- description: "Internal launch-local selected SDD change identity for package-owned child agents.",
8749
- type: "string",
8750
- });
8751
8920
  declareReviewRelayHandshake(dependencies.processEnv ?? process.env);
8752
8921
  const pendingReviewConsentFallbackKey = Symbol("pending-review-consent-fallback");
8753
8922
  const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
8754
8923
  const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events);
8755
8924
  const permissionEnvironment = dependencies.processEnv ?? process.env;
8925
+ const yolo = registerYoloSessionPolicy(pi, permissionEnvironment);
8756
8926
 
8757
8927
  const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => {
8758
8928
  try {
@@ -8782,11 +8952,20 @@ function createGentleAiExtensionForTesting(
8782
8952
  return revoked;
8783
8953
  };
8784
8954
 
8955
+ const reviewSidebar = createReviewSidebarPublisher(pi);
8956
+ pi.on("session_tree", (_event, ctx) => reviewSidebar.reset(ctx));
8785
8957
  let reminderSessionActive = true;
8786
8958
  let reminderEpoch = 0;
8959
+ let reminderManager: ExtensionContext["sessionManager"] | undefined;
8960
+ let unbindPreparation: (() => void) | undefined;
8787
8961
  pi.on("session_shutdown", (event, context) => {
8962
+ yolo.reset(context);
8963
+ reviewSidebar.reset();
8788
8964
  reminderSessionActive = false;
8789
8965
  reminderEpoch += 1;
8966
+ unbindPreparation?.();
8967
+ unbindPreparation = undefined;
8968
+ reminderManager = undefined;
8790
8969
  // Pi tears down this registry on reload as well as session replacement/quit.
8791
8970
  try { candidateViews?.cleanupAll(); } catch { /* Preserve failed owned views for later recovery. */ }
8792
8971
  const reason = (event as { reason?: unknown }).reason;
@@ -8801,6 +8980,83 @@ function createGentleAiExtensionForTesting(
8801
8980
  processAgentEndSubagentDepth.delete(sessionKey);
8802
8981
  });
8803
8982
 
8983
+ // gentle-pi ODD input phase labels: the explicit half of the bounded ODD
8984
+ // phase signal for the Gentle prompt's working label. Gentle Shell infers
8985
+ // the phase from the primary session's tool activity
8986
+ // (lib/odd-phase-inference.ts); this tool lets the orchestrator refine it
8987
+ // at ODD protocol transitions tools cannot show, and its report is
8988
+ // "explicit" so a following read-only tool call never downgrades it. Never
8989
+ // inferred from prose. It is session-scoped in lib/odd-phase.ts: a
8990
+ // background/child agent runs as its own OS process with its own module
8991
+ // state, so it can never see or override the primary session's phase.
8992
+ const hiddenOddPhaseToolComponent = { render: (_width: number): string[] => [], invalidate() {} };
8993
+ pi.registerTool({
8994
+ name: "gentle_odd_phase",
8995
+ renderShell: "self",
8996
+ label: "Gentle ODD Phase",
8997
+ description: "Refine the primary session's current ODD phase for the Gentle prompt's working label, which is otherwise inferred automatically from tool activity. Best-effort UI only; never a source of truth for orchestration logic.",
8998
+ promptSnippet: "Refine the automatically inferred working label with authorizing/exploring/researching/deciding/planning/implementing/checking/closing only at real ODD phase transitions of the primary turn; never poll or report per tool call.",
8999
+ promptGuidelines: [
9000
+ `phase must be exactly one of ${ODD_PHASES.join(", ")}, or "clear" to leave the current phase before its turn ends. Call this only when the ODD phase actually changes for the primary session's active turn, not on every tool call or thought.`,
9001
+ "Never call this from a subagent or background/child task; it reports only the primary orchestrator's own phase, and a child's session id can never override the parent's label.",
9002
+ ],
9003
+ parameters: {
9004
+ type: "object",
9005
+ additionalProperties: false,
9006
+ required: ["phase"],
9007
+ properties: {
9008
+ phase: { type: "string", enum: [...ODD_PHASES, "clear"], description: "The reported ODD phase, or 'clear' to leave the current phase." },
9009
+ },
9010
+ } as const,
9011
+ executionMode: "parallel",
9012
+ // The prompt editor owns the success indicator. An empty self-rendered
9013
+ // call avoids Pi's default transcript card while preserving error output.
9014
+ renderCall() {
9015
+ return hiddenOddPhaseToolComponent;
9016
+ },
9017
+ renderResult(result, _options, theme, context) {
9018
+ if (!context.isError) return hiddenOddPhaseToolComponent;
9019
+ const message = result.content.filter((item) => item.type === "text").map((item) => item.text).join("\n");
9020
+ return new Text(theme.fg("error", sanitizeTerminalText(message || "ODD phase report failed.")), 0, 0);
9021
+ },
9022
+ async execute(_toolCallId, parameters, _signal, _onUpdate, ctx) {
9023
+ const phase = (parameters as { phase?: unknown }).phase;
9024
+ const sessionId = ctx.sessionManager.getSessionId();
9025
+ // Pi's own contract (docs/extensions.md, Signaling errors): throw to
9026
+ // mark a tool execution as failed (sets isError: true on the result);
9027
+ // returning a value -- including an isError property on it -- never
9028
+ // sets that flag.
9029
+ if (!sessionId) throw new Error("No active session to report an ODD phase for; nothing changed.");
9030
+ // An invalid token never resets state: only the explicit "clear" token
9031
+ // leaves the current phase. This keeps a malformed report from wiping
9032
+ // out an otherwise-accurate label for the rest of the turn.
9033
+ if (phase === "clear") {
9034
+ oddPhaseRegistry.clear(sessionId);
9035
+ return { content: [{ type: "text", text: "ODD phase cleared." }], details: { phase: undefined } };
9036
+ }
9037
+ if (!isOddPhase(phase)) {
9038
+ throw new Error(`Invalid ODD phase; use one of ${ODD_PHASES.join(", ")}, or "clear". The previously reported phase, if any, is unchanged.`);
9039
+ }
9040
+ const reported = oddPhaseRegistry.report(sessionId, phase, "explicit");
9041
+ return { content: [{ type: "text", text: `ODD phase reported: ${reported}` }], details: { phase: reported } };
9042
+ },
9043
+ });
9044
+
9045
+ const GENTLE_TIMED_TOOLS = new Set(["gentle_review_scope", "gentle_review_capture_group", "gentle_review_capture", "gentle_review"]);
9046
+ let elapsedTiming: GentleAiElapsedTimingLedger | undefined;
9047
+ const recordReviewTiming = (event: { toolCallId: string; toolName: string }, endedAt?: number): void => {
9048
+ const ledger = elapsedTiming;
9049
+ if (!ledger || !GENTLE_TIMED_TOOLS.has(event.toolName)) return;
9050
+ try {
9051
+ if (endedAt === undefined) ledger.recordStart(event.toolCallId, Date.now());
9052
+ else ledger.recordEnd(event.toolCallId, endedAt);
9053
+ } catch { /* Timing persistence is best-effort and never breaks the tool event. */ }
9054
+ };
9055
+ pi.on("tool_execution_start", (event) => recordReviewTiming(event));
9056
+ pi.on("tool_execution_end", (event) => recordReviewTiming(event, Date.now()));
9057
+ const timingContext = (context: GentleAiRenderContext | undefined): GentleAiRenderContext | undefined =>
9058
+ elapsedTiming && context ? { ...context, elapsedTiming } : context;
9059
+
8804
9060
  pi.registerTool({
8805
9061
  name: "gentle_review_scope",
8806
9062
  renderShell: "self",
@@ -8812,11 +9068,11 @@ function createGentleAiExtensionForTesting(
8812
9068
  return renderGentleAiLifecycleCall(
8813
9069
  "review scope",
8814
9070
  theme,
8815
- context as GentleAiRenderContext | undefined,
9071
+ timingContext(context as GentleAiRenderContext | undefined),
8816
9072
  );
8817
9073
  },
8818
9074
  renderResult(result, options, theme, context) {
8819
- return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
9075
+ return renderGentleAiResult(result, options, theme, timingContext(context as GentleAiRenderContext | undefined));
8820
9076
  },
8821
9077
  async execute(_toolCallId, parameters) {
8822
9078
  const input = parameters as ReviewScopeParameters;
@@ -8844,7 +9100,7 @@ function createGentleAiExtensionForTesting(
8844
9100
  return named.length === 0 ? operation : `${operation} · ${named.join(" · ")}`;
8845
9101
  };
8846
9102
 
8847
- pi.registerTool({
9103
+ pi.registerTool(reviewSidebar.tool({
8848
9104
  name: "gentle_review_capture_group",
8849
9105
  renderShell: "self",
8850
9106
  label: "Gentle Review Capture Group",
@@ -8859,13 +9115,13 @@ function createGentleAiExtensionForTesting(
8859
9115
  renderCall(args, theme, context) {
8860
9116
  const bindings = (args as { collectBindings?: unknown }).collectBindings;
8861
9117
  const lenses = Array.isArray(bindings) ? bindings.map(collectBindingLens) : [];
8862
- return renderGentleAiLifecycleCall(withLenses("review capture group", lenses), theme, context as GentleAiRenderContext | undefined);
9118
+ return renderGentleAiLifecycleCall(withLenses("review capture group", lenses), theme, timingContext(context as GentleAiRenderContext | undefined));
8863
9119
  },
8864
9120
  renderResult(result, options, theme, context) {
8865
- return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
9121
+ return renderGentleAiResult(result, options, theme, timingContext(context as GentleAiRenderContext | undefined));
8866
9122
  },
8867
9123
  async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
8868
- if (signal?.aborted) throw new Error("Review capture group was cancelled");
9124
+ if (signal?.aborted) throw reviewCancellation("Review capture group was cancelled");
8869
9125
  const details = await executeReviewCaptureGroupOperation(
8870
9126
  parameters,
8871
9127
  ctx.cwd,
@@ -8882,9 +9138,9 @@ function createGentleAiExtensionForTesting(
8882
9138
  );
8883
9139
  return { content: [{ type: "text", text: JSON.stringify(details) }], details };
8884
9140
  },
8885
- });
9141
+ }));
8886
9142
 
8887
- pi.registerTool({
9143
+ pi.registerTool(reviewSidebar.tool({
8888
9144
  name: "gentle_review_capture",
8889
9145
  renderShell: "self",
8890
9146
  label: "Gentle Review Capture",
@@ -8901,14 +9157,14 @@ function createGentleAiExtensionForTesting(
8901
9157
  return renderGentleAiLifecycleCall(
8902
9158
  withLenses("review capture", [collectBindingLens((args as { collectBinding?: unknown }).collectBinding)]),
8903
9159
  theme,
8904
- context as GentleAiRenderContext | undefined,
9160
+ timingContext(context as GentleAiRenderContext | undefined),
8905
9161
  );
8906
9162
  },
8907
9163
  renderResult(result, options, theme, context) {
8908
- return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
9164
+ return renderGentleAiResult(result, options, theme, timingContext(context as GentleAiRenderContext | undefined));
8909
9165
  },
8910
9166
  async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
8911
- if (signal?.aborted) throw new Error("Review capture was cancelled");
9167
+ if (signal?.aborted) throw reviewCancellation("Review capture was cancelled");
8912
9168
  const details = await executeReviewCaptureOperation(
8913
9169
  parameters,
8914
9170
  ctx.cwd,
@@ -8928,9 +9184,9 @@ function createGentleAiExtensionForTesting(
8928
9184
  details,
8929
9185
  };
8930
9186
  },
8931
- });
9187
+ }));
8932
9188
 
8933
- pi.registerTool({
9189
+ pi.registerTool(reviewSidebar.tool({
8934
9190
  name: "gentle_review",
8935
9191
  renderShell: "self",
8936
9192
  label: "Gentle Review Controller",
@@ -8946,7 +9202,7 @@ function createGentleAiExtensionForTesting(
8946
9202
  "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.",
8947
9203
  "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.",
8948
9204
  "Use gentle_review only for native review authority operations; delivery commands follow ordinary repository policy.",
8949
- '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 record the writer profile ({"writerModelId":"...", "writerEffort":"..."}), or to state the native review\'s outcome for this candidate ({"nativeReviewOutcome":"closed|declined|unavailable|unknown"}). Omitting writerModelId and writerEffort is treated as a small writer profile (fail closed), never large, because the writer\'s actual profile is then unknown to this call; pass the writer\'s real model id/effort to get credit for a known large profile. The on-path (writer self-verification is the record, no separate verifier) holds only when nativeReviewOutcome is "closed" for this candidate; a decline, an unavailable review, or an omitted/unknown outcome falls back to the exact risk-gated plan RDD off would return, re-enabling the separate verifier -- a decline is candidate-scoped and never lowers the bar below RDD off. "closed" is never inferred: pass it only right after this same caller acknowledged the approved review for this same candidate; omitting nativeReviewOutcome only ever auto-derives declined/unavailable, bound to that exact candidate\'s own target identity, never to a different candidate or to bare repository state. The result\'s outcome_source (explicit|derived|unknown) states which. A failed or unavailable native assessment reports risk "unassessable", verified exactly like "high". This never mutates review authority state.',
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.',
8950
9206
  ],
8951
9207
  parameters: REVIEW_CONTROLLER_PARAMETERS,
8952
9208
  executionMode: "sequential",
@@ -8954,14 +9210,14 @@ function createGentleAiExtensionForTesting(
8954
9210
  return renderGentleAiLifecycleCall(
8955
9211
  reviewToolOperationPath(args),
8956
9212
  theme,
8957
- context as GentleAiRenderContext | undefined,
9213
+ timingContext(context as GentleAiRenderContext | undefined),
8958
9214
  );
8959
9215
  },
8960
9216
  renderResult(result, options, theme, context) {
8961
- return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
9217
+ return renderGentleAiResult(result, options, theme, timingContext(context as GentleAiRenderContext | undefined));
8962
9218
  },
8963
9219
  async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
8964
- if (signal?.aborted) throw new Error("Review controller operation was cancelled");
9220
+ if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled");
8965
9221
  await authorizeDestructiveReviewOperation(parameters, ctx);
8966
9222
  const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
8967
9223
  const retainedSelections = processRetainedNativeStatusSelections.get(sessionKey)
@@ -9076,15 +9332,32 @@ function createGentleAiExtensionForTesting(
9076
9332
  details,
9077
9333
  };
9078
9334
  },
9079
- });
9080
-
9081
- function runSddPreflight(ctx: ExtensionContext, promptFields: readonly SddPreflightField[] = []): Promise<SddPreflightPreferences> {
9082
- return ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installPackageAssets(cwd, true, ["sdd"]), applyModelConfig: async () => applySavedModelConfig(ctx) }, { promptFields });
9083
- }
9335
+ }));
9084
9336
 
9085
9337
  pi.on("session_start", async (event, ctx) => {
9338
+ yolo.reset(ctx);
9339
+ reviewSidebar.reset(ctx);
9340
+ elapsedTiming = new GentleAiElapsedTimingLedger(ctx.sessionManager, pi);
9086
9341
  reminderSessionActive = true;
9087
9342
  reminderEpoch += 1;
9343
+ unbindPreparation?.();
9344
+ reminderManager = ctx.sessionManager;
9345
+ const epoch = reminderEpoch;
9346
+ const manager = ctx.sessionManager;
9347
+ const originalCwd = manager?.getCwd?.() ?? ctx.cwd;
9348
+ unbindPreparation = manager ? bindSessionRepositoryPreparation(manager, originalCwd, async (root, current, signal) => {
9349
+ if (!current() || nativeReviewCli?.targetStatus === undefined || safeBootstrapDirectory(originalCwd) !== root) return false;
9350
+ if (!await nativeRepositoryPreparationEnabled(nativeReviewCli, root, signal) || !current() || safeBootstrapDirectory(originalCwd) !== root) return false;
9351
+ try {
9352
+ const existing = resolveSessionWorktree(originalCwd, originalCwd);
9353
+ if (existing) return existing.root === root;
9354
+ const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
9355
+ const retained = processRetainedNativeStatusSelections.get(sessionKey) ?? new Map();
9356
+ const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: root, signal }, retained, root);
9357
+ const identity = resolveSessionWorktree(originalCwd, originalCwd);
9358
+ return negotiated.status !== undefined && current() && safeBootstrapDirectory(originalCwd) === root && identity?.root === root;
9359
+ } catch { return false; }
9360
+ }, () => reminderSessionActive && reminderEpoch === epoch && reminderManager === manager) : undefined;
9088
9361
  try { candidateViews?.sweepOrphans(ctx.cwd); } catch { /* Ownership sweeping must not block startup. */ }
9089
9362
  const reason = (event as { reason?: unknown }).reason;
9090
9363
  if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx);
@@ -9135,42 +9408,23 @@ function createGentleAiExtensionForTesting(
9135
9408
  }
9136
9409
  });
9137
9410
 
9138
- pi.on("input", async (event, ctx) => {
9139
- if (typeof event.text !== "string" || !isSddPreflightTrigger(event.text)) {
9140
- return { action: "continue" };
9141
- }
9142
- // An RPC child consumes the parent-rendered preflight block transported in
9143
- // its task context; it never originates preflight. Re-entering the
9144
- // parent-only resolver here would reject, and consuming the rejection
9145
- // would swallow the delegated prompt before the agent ever starts.
9146
- if (ctx.mode === "rpc") return { action: "continue" };
9147
- try { await runSddPreflight(ctx); }
9148
- catch (error) {
9149
- if (ctx.hasUI) ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
9150
- return { action: "handled" };
9151
- }
9152
- return { action: "continue" };
9153
- });
9154
-
9155
- let nativeSddStartupBlock: string | undefined;
9156
9411
  pi.on("before_agent_start", async (event, ctx) => {
9157
- nativeSddStartupBlock = undefined;
9158
- const retiredSync = readAgentStartNames(event).includes("sdd-sync") || /\bSDD sync executor\b/i.test(event.systemPrompt ?? "");
9159
- const isSddAgent = retiredSync || isSddAgentStartEvent(event);
9160
9412
  const isNamedAgent = isNamedAgentStartEvent(event);
9413
+ const isChildSession = permissionEnvironment.GENTLE_PI_AGENTS_CHILD === "1";
9414
+ const isPrimarySession = !isNamedAgent && !isChildSession;
9161
9415
  const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
9162
- if (isSddAgent || isNamedAgent) {
9416
+ if (isNamedAgent || isChildSession) {
9163
9417
  processAgentEndSubagentDepth.set(subagentDepthKey, (processAgentEndSubagentDepth.get(subagentDepthKey) ?? 0) + 1);
9164
9418
  } else {
9165
9419
  processAgentEndSubagentDepth.set(subagentDepthKey, 0);
9166
9420
  }
9167
9421
  // gentle-pi#677: nudge gentle-ai's own telemetry trigger for a primary
9168
- // session only, reusing the exact isNamedAgent/isSddAgent predicate that
9422
+ // session only, reusing the exact isNamedAgent predicate that
9169
9423
  // decides the orchestrator prompt below. At most one attempt per
9170
9424
  // process regardless of how many primary-session before_agent_start
9171
9425
  // events this process observes; a missing/old binary or a spawn error
9172
9426
  // must never affect activation, so every failure is swallowed silently.
9173
- if (!isNamedAgent && !isSddAgent && !processTelemetryTriggerAttempted) {
9427
+ if (isPrimarySession && !processTelemetryTriggerAttempted) {
9174
9428
  processTelemetryTriggerAttempted = true;
9175
9429
  try {
9176
9430
  const executable = resolveTelemetryTriggerBinary();
@@ -9184,60 +9438,14 @@ function createGentleAiExtensionForTesting(
9184
9438
  // Best-effort only; never surfaced and never affects activation.
9185
9439
  }
9186
9440
  }
9187
- try {
9188
- if (isSddAgent && !getSddPreflightPreferences(ctx) && ctx.mode !== "rpc") {
9189
- await runSddPreflight(ctx);
9190
- }
9191
- } catch (error) {
9192
- // Pi logs thrown before_agent_start errors and continues. Return an
9193
- // unresolved gate instead of silently losing the preflight instructions.
9194
- return { systemPrompt: `${event.systemPrompt}\n\nSDD preflight unresolved: ${error instanceof Error ? error.message : String(error)}\nSTOP: Do not initialize the project, launch phases, write artifacts, or infer consent. Request session preflight confirmation before continuing.` };
9195
- }
9196
- const prefs = getSddPreflightPreferences(ctx);
9197
- // RPC children never resolve or persist defaults. The parent dispatch gate
9198
- // transports the rendered block in the existing task context, and Gentle
9199
- // Agents refuses a missing/malformed payload before spawning the child.
9200
- const sddPrompt =
9201
- prefs && (!isNamedAgent || isSddAgent)
9202
- ? `\n\n${renderSddPreflightPrompt(prefs)}`
9203
- : "";
9204
- const phase = isSddAgent ? sddPhaseFromAgentStartEvent(event) : undefined;
9205
- const launchSddChange = readSddChangeFlag(pi);
9206
- if (retiredSync) nativeSddStartupBlock = "Standalone sync is retired. Return to the parent for native archive selection; archive composes applicable specs.";
9207
- else if (launchSddChange !== undefined && !phase) nativeSddStartupBlock = "Receiving agent has no recognized SDD phase";
9208
- const nativeStatusPrompt = phase
9209
- ? await (async () => {
9210
- try {
9211
- if (launchSddChange === undefined) {
9212
- const { status } = await readCommandSddStatus("", ctx);
9213
- if (status.changeName === null) throw new Error(`Native SDD discovery cannot run ${phase}.`);
9214
- assertNativeSddPhaseReady(status, phase);
9215
- return `\n\n${renderNativeSddPhasePrompt(status, phase)}`;
9216
- }
9217
- const agentName = `sdd-${phase}`;
9218
- const startup = await resolveSelectedNativeSddChangeStartup(
9219
- launchSddChange,
9220
- ctx.cwd,
9221
- agentName,
9222
- nativeReviewCli,
9223
- );
9224
- return `\n\n${renderNativeSddPhasePrompt(startup.status, phase)}`;
9225
- } catch (error) {
9226
- nativeSddStartupBlock = error instanceof Error ? error.message : String(error);
9227
- return `\n\n## Native SDD Status Engine\nSDD selection blocked: ${nativeSddStartupBlock}\nDo not run phase work; return this blocker to the parent.`;
9228
- }
9229
- })()
9230
- : nativeSddStartupBlock === undefined
9231
- ? ""
9232
- : `\n\n## Native SDD Status Engine\nSDD selection blocked: ${nativeSddStartupBlock}\nDo not run phase work; return this blocker to the parent.`;
9233
9441
  // gentle-pi#661: the RDD status line (and the rest of the gentle prompt)
9234
9442
  // is built only for the primary session, mirrored on the
9235
- // reviewContractPrompt condition below -- named/SDD agents never reach
9443
+ // reviewContractPrompt condition below -- named agents never reach
9236
9444
  // this branch, so no line is resolved or computed for them.
9237
9445
  // resolveRddStatusLine never throws and never hangs past
9238
9446
  // RDD_STATUS_TIMEOUT_MS: an absent/timed-out/aborted/failing native
9239
9447
  // binary renders the fail-closed "unknown" line instead.
9240
- const gentlePrompt = isNamedAgent || isSddAgent
9448
+ const gentlePrompt = !isPrimarySession
9241
9449
  ? ""
9242
9450
  : `\n\n${buildGentlePrompt(
9243
9451
  readPersonaMode(ctx.cwd),
@@ -9249,15 +9457,18 @@ function createGentleAiExtensionForTesting(
9249
9457
  // contract bundle's review execution contract for the primary session
9250
9458
  // only, and only when a native review CLI is actually present.
9251
9459
  const reviewContractPrompt =
9252
- !isNamedAgent && !isSddAgent && nativeReviewCli !== null
9460
+ isPrimarySession && nativeReviewCli !== null
9253
9461
  ? (() => {
9254
9462
  const fragment = loadReviewContractPromptFragment(ctx);
9255
9463
  return fragment === null ? "" : `\n\n${fragment}`;
9256
9464
  })()
9257
9465
  : "";
9258
- return {
9259
- systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}${!isNamedAgent && !isSddAgent ? `\n\n${renderResearchCapabilities(resolveResearchCapabilities(pi))}` : ""}`,
9260
- };
9466
+ // gentle-shell#1485: pi-claude-bridge drops a handler-returned systemPrompt
9467
+ // and forwards only systemPromptOptions, so the harness is delivered
9468
+ // through the mutable appendSystemPrompt section instead of a replacement.
9469
+ appendSystemPromptOnce(event.systemPromptOptions, `${gentlePrompt}${reviewContractPrompt}`);
9470
+ updateYoloPrompt(event.systemPromptOptions, isPrimarySession && await yolo.active(ctx));
9471
+ return undefined;
9261
9472
  });
9262
9473
 
9263
9474
  // gentle-pi#556 / gentle-ai#4051: with RDD enabled, the agent could finish
@@ -9267,6 +9478,7 @@ function createGentleAiExtensionForTesting(
9267
9478
  // consent, or chooses a partial candidate. Durable own-mutation receipts
9268
9479
  // gate STATUS and consume only the generation captured before that await.
9269
9480
  pi.on("agent_end", async (_event, ctx) => {
9481
+ if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD === "1") return;
9270
9482
  if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return;
9271
9483
  if (ctx.hasUI !== true || !reminderSessionActive) return;
9272
9484
  const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
@@ -9299,65 +9511,29 @@ function createGentleAiExtensionForTesting(
9299
9511
  consumeReviewMutation(pi, ctx.sessionManager, root, mutation, "nudged", targetIdentity);
9300
9512
  });
9301
9513
 
9302
- pi.on("tool_result", (event, ctx) => {
9303
- if (!reminderSessionActive || event.isError !== false || (event.toolName !== "write" && event.toolName !== "edit")) return;
9514
+ pi.on("tool_result", async (event, ctx) => {
9515
+ if (!reminderSessionActive || (reminderManager && reminderManager !== ctx.sessionManager) || event.isError !== false || (event.toolName !== "write" && event.toolName !== "edit")) return;
9304
9516
  if (!isRecord(event.input) || typeof event.input.path !== "string" || !event.input.path.trim()) return;
9305
9517
  try {
9306
- const root = resolveSessionWorktree(event.input.path, ctx.cwd)?.root;
9307
- if (root) recordReviewMutation(pi, ctx.sessionManager, root, { source: "direct", toolName: event.toolName, toolCallId: event.toolCallId });
9308
- } catch { /* Receipt persistence must not change a successful tool result. */ }
9518
+ const identity = resolveSessionWorktree(event.input.path, ctx.cwd);
9519
+ const prospectiveRoot = identity ? undefined : sourcePathWithinProject(event.input.path, ctx.cwd);
9520
+ const root = identity?.root ?? prospectiveRoot;
9521
+ // Persist the observed own write before any await. Preparation is not
9522
+ // mutation evidence, and cannot invent a pre-write Changes baseline.
9523
+ 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);
9525
+ } catch { /* Preparation and receipt persistence cannot change a successful tool result. */ }
9309
9526
  });
9310
9527
 
9311
9528
  pi.on("tool_call", async (event, ctx) => {
9312
- if (nativeSddStartupBlock && event.toolName !== "subagent_parent_message") return { block: true, reason: `SDD selection blocked: ${nativeSddStartupBlock}` };
9529
+ const primaryToolCall = (processAgentEndSubagentDepth.get(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)) ?? 0) === 0;
9530
+ const yoloActive = primaryToolCall && await yolo.active(ctx);
9313
9531
  const sensitivePathDenied = evaluateSensitivePathTool(
9314
9532
  event.toolName,
9315
9533
  event.input,
9316
9534
  );
9317
9535
  if (sensitivePathDenied) return sensitivePathDenied;
9318
9536
  if (event.toolName === "subagent_run") {
9319
- const sddAgent = sddDispatchAgentName(event.input);
9320
- if (sddAgent === "invalid") {
9321
- return { block: true, reason: "SDD dispatch requires exactly one shipped SDD agent name." };
9322
- }
9323
- if (sddAgent !== undefined) {
9324
- // An RPC child is a delegated actor, never an authority originator. It
9325
- // must receive the already-confirmed block from its interactive parent.
9326
- if (ctx.mode === "rpc") {
9327
- return { block: true, reason: "SDD dispatch refused: an RPC child cannot originate or persist SDD preflight defaults." };
9328
- }
9329
- try {
9330
- const prefs = getSddPreflightPreferences(ctx) ?? await runSddPreflight(ctx);
9331
- const rendered = renderSddPreflightPrompt(prefs);
9332
- if (!prefs.prompted && ctx.hasUI) {
9333
- return { block: true, reason: "SDD dispatch refused: interactive parent preflight lacks current-session confirmation." };
9334
- }
9335
- if (!isRecord(event.input)) {
9336
- return { block: true, reason: "SDD dispatch refused: child input is malformed." };
9337
- }
9338
- if (event.input.context !== undefined && typeof event.input.context !== "string") {
9339
- return { block: true, reason: "SDD dispatch refused: child context must be text." };
9340
- }
9341
- // The existing context payload is the sole parent-to-child transport.
9342
- // Refuse a caller-authored lookalike so the child receives one exact,
9343
- // parent-rendered authority block rather than an ambiguous mixture.
9344
- const context = typeof event.input.context === "string" ? event.input.context.trim() : "";
9345
- if (/^## SDD Session Preflight[ \t]*$/m.test(context)) {
9346
- return { block: true, reason: "SDD dispatch refused: child context already contains an untrusted preflight block." };
9347
- }
9348
- event.input.context = context.length === 0
9349
- ? rendered
9350
- : `${rendered}\n\n${context}`;
9351
- if (!isParentConfirmedSddPreflightContext(event.input.context)) {
9352
- return { block: true, reason: "SDD dispatch refused: rendered preflight transport is malformed." };
9353
- }
9354
- } catch (error) {
9355
- return {
9356
- block: true,
9357
- reason: `SDD dispatch refused before child launch: ${error instanceof Error ? error.message : String(error)}`,
9358
- };
9359
- }
9360
- }
9361
9537
  const judgmentDayFixDenied = rejectInvalidJudgmentDayFixDispatch(event.input);
9362
9538
  if (judgmentDayFixDenied) return judgmentDayFixDenied;
9363
9539
  const writerScopeDenied = rejectUnscopedBoundedWriterDispatch(event.input);
@@ -9376,11 +9552,15 @@ function createGentleAiExtensionForTesting(
9376
9552
  if (!isRecord(event.input) || typeof event.input.command !== "string") {
9377
9553
  return undefined;
9378
9554
  }
9379
- return await confirmCommand(event.input.command, ctx, pi.events, herdrLifecycle);
9555
+ if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD === "1") {
9556
+ const childDenied = blockChildDestructiveCommand(event.input.command);
9557
+ if (childDenied) return childDenied;
9558
+ }
9559
+ return await confirmCommand(event.input.command, ctx, pi.events, herdrLifecycle, yoloActive);
9380
9560
  });
9381
9561
 
9382
- for (const owner of ["delegation", "review", "sdd"] as const) {
9383
- const label = owner === "sdd" ? "SDD" : owner;
9562
+ for (const owner of ["delegation", "review"] as const) {
9563
+ const label = owner;
9384
9564
  pi.registerCommand(`gentle:install-${owner}`, {
9385
9565
  description: `Repair or refresh only global Gentle AI ${label} assets.`,
9386
9566
  handler: async (args, ctx) => {
@@ -9394,86 +9574,10 @@ function createGentleAiExtensionForTesting(
9394
9574
  });
9395
9575
  }
9396
9576
 
9397
- pi.registerCommand("gentle:sdd-preflight", {
9398
- description:
9399
- "Run or reuse session SDD preflight; use --edit to change preferences.",
9400
- handler: async (args, ctx) => {
9401
- if (args.trim() !== "" && args.trim() !== "--edit") {
9402
- ctx.ui.notify("Usage: /gentle:sdd-preflight [--edit]", "warning");
9403
- return;
9404
- }
9405
- try {
9406
- await runSddPreflight(ctx, args.trim() === "--edit" ? SDD_PREFLIGHT_FIELDS : []);
9407
- } catch (error) {
9408
- ctx.ui?.notify(error instanceof Error ? error.message : String(error), "warning");
9409
- }
9410
- },
9411
- });
9412
-
9413
- const readCommandSddStatus = async (args: string, ctx: ExtensionContext) => {
9414
- const parsed = parseSddStatusCommandArgs(args);
9415
- const request = { changeName: parsed.changeName, workspaceRoot: realpathSync(ctx.cwd) };
9416
- if (!nativeReviewCli?.sddStatus) throw new Error("Native SDD status capability unavailable; no local fallback.");
9417
- const status = decodeNativeSddStatusV2(await nativeReviewCli.sddStatus(request), request);
9418
- return { parsed, request, status };
9419
- };
9420
- const showCommandSddStatus = (status: NativeSddStatusV2, json: boolean, ctx: ExtensionContext) => {
9421
- ctx.ui?.notify(json ? JSON.stringify(status, null, 2) : renderNativeSddPhasePrompt(status), "info");
9422
- };
9423
- const handleSddStatusCommand = async (args: string, ctx: ExtensionContext) => {
9424
- const { parsed, status } = await readCommandSddStatus(args, ctx);
9425
- showCommandSddStatus(status, parsed.json, ctx);
9426
- };
9427
-
9428
- pi.registerCommand("gentle-sdd-status", {
9429
- description: "Show deterministic SDD change status and instructions.",
9430
- handler: async (args, ctx) => {
9431
- await handleSddStatusCommand(args, ctx);
9432
- },
9433
- });
9434
-
9435
- const handleSddContinueCommand = async (args: string, ctx: ExtensionContext) => {
9436
- const { parsed, request, status } = await readCommandSddStatus(args, ctx);
9437
- const planning = status.planningHome;
9438
- const changeRoot = status.changeRoot;
9439
- // Native context is an upper bound, never the human's per-call grant.
9440
- if (status.changeName === null || !ctx.hasUI || typeof ctx.ui?.confirm !== "function" || !nativeReviewCli?.sddContinue) {
9441
- showCommandSddStatus(status, parsed.json, ctx);
9442
- return;
9443
- }
9444
- if (typeof planning !== "object" || planning === null || !("path" in planning) || typeof planning.path !== "string" || typeof changeRoot !== "string") throw new Error("Native SDD continuation lacks an exact planning path.");
9445
- if (!["openspec", "both"].includes(String(status.artifactStore)) || !["repo-local", "workspace-planning"].includes(String(status.actionContext.mode))) throw new Error("Native SDD continuation has unsupported planning context.");
9446
- const marker = join(changeRoot, ".gentle-ai-instance");
9447
- const checkMarker = () => {
9448
- try {
9449
- if (!lstatSync(marker).isFile() || realpathSync(marker) !== marker) throw new Error("Native SDD marker is not a canonical regular file.");
9450
- } catch (error) {
9451
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
9452
- }
9453
- };
9454
- checkMarker();
9455
- if (realpathSync(changeRoot) !== changeRoot || realpathSync(planning.path) !== planning.path || changeRoot !== join(planning.path, "changes", status.changeName) || !isStrictDescendantPath(request.workspaceRoot, marker)) throw new Error("Native SDD continuation workspace or planning path mismatch.");
9456
- if (await ctx.ui.confirm("Prepare SDD marker?", `Authorize only continuation-time preparation of ${marker}? This grants no source roots and no persistent authority.`) !== true) {
9457
- showCommandSddStatus(status, parsed.json, ctx);
9458
- return;
9459
- }
9460
- if (realpathSync(ctx.cwd) !== request.workspaceRoot || realpathSync(changeRoot) !== changeRoot) throw new Error("Native SDD continuation workspace changed during confirmation.");
9461
- checkMarker();
9462
- const selected = { ...request, changeName: status.changeName };
9463
- showCommandSddStatus(decodeNativeSddStatusV2(await nativeReviewCli.sddContinue(selected), selected), parsed.json, ctx);
9464
- };
9465
-
9466
- pi.registerCommand("gentle-sdd-continue", {
9467
- description: "Resolve SDD status and route the next phase deterministically.",
9468
- handler: async (args, ctx) => {
9469
- await handleSddContinueCommand(args, ctx);
9470
- },
9471
- });
9472
-
9473
9577
  pi.registerCommand("gentle:models", {
9474
9578
  description: "Configure global per-agent models for el Gentleman.",
9475
9579
  handler: async (_args, ctx) => {
9476
- await handleModelsCommand(ctx);
9580
+ await handleModelsCommand(ctx, pi);
9477
9581
  },
9478
9582
  });
9479
9583
 
@@ -9555,9 +9659,6 @@ function createGentleAiExtensionForTesting(
9555
9659
  description: "Run read-only Gentle AI diagnostics for this Pi workspace.",
9556
9660
  handler: async (_args, ctx) => {
9557
9661
  const assetLines = packageAssetDiagnosticLines(ctx.cwd);
9558
- const openspecConfigured = existsSync(
9559
- join(ctx.cwd, "openspec", "config.yaml"),
9560
- );
9561
9662
  const skillRegistryPresent = existsSync(
9562
9663
  join(ctx.cwd, ".atl", "skill-registry.md"),
9563
9664
  );
@@ -9567,7 +9668,7 @@ function createGentleAiExtensionForTesting(
9567
9668
  const lines = [
9568
9669
  "el Gentleman doctor",
9569
9670
  ...assetLines,
9570
- `${openspecConfigured ? "pass" : "warn"}: OpenSpec config ${openspecConfigured ? "present" : "missing"}`,
9671
+ "pass: Organic Driven Development (ODD): active",
9571
9672
  `${skillRegistryPresent ? "pass" : "warn"}: Skill registry ${skillRegistryPresent ? "present" : "missing"}`,
9572
9673
  `${modelConfig.status === "invalid" ? "fail" : "pass"}: Global model config ${modelConfig.status}`,
9573
9674
  "pass: Sensitive-path guard active for read/write/edit tools",
@@ -9740,9 +9841,6 @@ function createGentleAiExtensionForTesting(
9740
9841
  description: "Show Gentle AI package status for this project.",
9741
9842
  handler: async (_args, ctx) => {
9742
9843
  const assetLines = packageAssetDiagnosticLines(ctx.cwd);
9743
- const openspecConfigured = existsSync(
9744
- join(ctx.cwd, "openspec", "config.yaml"),
9745
- );
9746
9844
  const savedConfig = await readModelRoutingAuthorityAsync(
9747
9845
  modelConfigPath(ctx.cwd),
9748
9846
  legacyProjectModelConfigPath(ctx.cwd),
@@ -9754,7 +9852,7 @@ function createGentleAiExtensionForTesting(
9754
9852
  ...(devBinary.state === "inactive" ? [] : [devBinary.line]),
9755
9853
  `Persona: ${readPersonaMode(ctx.cwd)}`,
9756
9854
  ...assetLines,
9757
- `OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
9855
+ "Organic Driven Development (ODD): active",
9758
9856
  `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
9759
9857
  `Saved model routing: ${savedConfig.status}${savedConfig.status === "invalid" ? ` (${savedConfig.path})` : ""}`,
9760
9858
  ...(savedConfig.status === "invalid" ? [] : describeModelConfig(ctx.cwd, savedConfig.status === "valid" ? savedConfig.config : {})),