gentle-pi 2.4.0 → 2.6.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -1,3 +1,7 @@
1
+ import { consumeReviewMutation, pendingReviewMutation, recordReviewMutation } from "../lib/review-reminder-receipt.ts";
2
+ import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
3
+ import { resolveResearchCapabilities, renderResearchCapabilities } from "../lib/sdd-research-capabilities.ts";
4
+ import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
1
5
  import { execFileSync } from "node:child_process";
2
6
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
3
7
  import {
@@ -28,15 +32,20 @@ import type {
28
32
  ThemeColor,
29
33
  ToolCallEventResult,
30
34
  } from "@earendil-works/pi-coding-agent";
31
- import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
35
+ import { Key, isKeyRelease, matchesKey, truncateToWidth, type KeybindingsManager, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
32
36
  import { resolveGentlePiAgentHome } from "../lib/agent-home.ts";
33
37
  import {
34
38
  ensureSddPreflight,
35
39
  getSddPreflightPreferences,
36
- installSddAssets,
40
+ installPackageAssets,
41
+ getPackageAssetOwner,
42
+ hasPackageAssetOwnerInstallation,
43
+ type PackageAssetOwner,
37
44
  isPackageManagedSddAsset,
38
45
  isSddPreflightTrigger,
39
46
  renderSddPreflightPrompt,
47
+ isParentConfirmedSddPreflightContext,
48
+ SHIPPED_SDD_AGENT_NAMES,
40
49
  SDD_PREFLIGHT_FIELDS,
41
50
  type SddPreflightField,
42
51
  type SddPreflightPreferences,
@@ -54,13 +63,46 @@ import {
54
63
  type ModelConfigFileResult,
55
64
  type ThinkingLevel,
56
65
  } from "../lib/model-routing-authority.ts";
66
+ import {
67
+ bootstrapProfilesFile,
68
+ buildProfileListItems,
69
+ createProfile,
70
+ deleteProfile,
71
+ duplicateProfile,
72
+ formatOrchestratorSelection,
73
+ formatRoutingRow,
74
+ isProfileOrchestratorKey,
75
+ parseProfileExportTextWithDrops,
76
+ PROFILE_ORCHESTRATOR_KEY,
77
+ profileExportPath,
78
+ profileRoutingRows,
79
+ profilesFilePath,
80
+ readProfileOrchestrator,
81
+ readProfilesFileResult,
82
+ renameProfile,
83
+ routingColumnWidths,
84
+ serializeProfileExport,
85
+ setActiveProfile,
86
+ updateProfile,
87
+ writeProfilesFileSync,
88
+ type AgentProfilesFile,
89
+ type ProfileListItem,
90
+ type ProfileRoutingRow,
91
+ type ProfilesParseDrops,
92
+ } from "../lib/agent-profiles.ts";
93
+ import {
94
+ applyOrchestratorSettings,
95
+ readOrchestratorSettings,
96
+ restoreOrchestratorSettings,
97
+ type OrchestratorSettingsReadResult,
98
+ } from "../lib/profiles-orchestrator.ts";
99
+ import { measureAgentsViewLayout, type AgentsViewLayout } from "../lib/agents-view-layout.ts";
100
+ import { NativeChoiceList } from "../lib/native-choice-list.ts";
101
+ import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
57
102
  import {
58
103
  parseSddStatusCommandArgs,
59
104
  renderNativeSddPhasePrompt,
60
- renderSddDispatcherMarkdown,
61
- renderSddStatusMarkdown,
62
105
  resolveSddStatus,
63
- sddStatusSeverity,
64
106
  type SddPhase,
65
107
  } from "../lib/sdd-status.ts";
66
108
  import {
@@ -71,6 +113,8 @@ import {
71
113
  REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
72
114
  ReviewHostRelayError,
73
115
  reviewHostRelaySlots,
116
+ reviewHostRelayUnachievableDetail,
117
+ reviewHostRelayUnachievableReason,
74
118
  reviewProviderRoleVectorSlots,
75
119
  resolveReviewHostRelaySubmission,
76
120
  runReviewHostRelayReviewerGroup,
@@ -106,15 +150,24 @@ import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
106
150
  import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, type CandidateView } from "../lib/review-candidate-view.ts";
107
151
  import {
108
152
  GentleAiDevBinaryOverrideError,
153
+ GENTLE_AI_INSTALL_RECOVERY_COMMAND,
154
+ GENTLE_AI_INSTALL_RECOVERY_INSTRUCTIONS,
109
155
  registerGentleAiDevBinary,
156
+ resolveGentleAiBinary,
110
157
  resolveGentleAiDevBinaryOverride,
111
158
  unregisterGentleAiDevBinary,
112
159
  type GentleAiDevBinaryOverride,
113
160
  } from "../lib/gentle-ai-binary.ts";
161
+ import {
162
+ spawnTelemetryTrigger,
163
+ type TelemetryTriggerSpawn,
164
+ } from "../lib/telemetry-trigger.ts";
114
165
  import {
115
166
  createNativeReviewCli,
116
167
  createNodeExecFileAdapter,
168
+ decodeNativeSddStatusV2,
117
169
  isCanonicalProcessString,
170
+ isNativeReviewUnachievableVerbRefused,
118
171
  nativeReviewAbandonAuthorization,
119
172
  nativeReviewLegacyAliasRepairAuthorization,
120
173
  nativeReviewLegacyQuarantineAuthorization,
@@ -134,15 +187,32 @@ import {
134
187
  NATIVE_REVIEW_RECONCILE_ANOMALIES,
135
188
  sanitizeForeignNativeReviewDiagnostics,
136
189
  type NativeReviewCli,
190
+ type NativeSddStatusV2,
137
191
  type NativeIntendedUntrackedSelectionSubmission,
138
192
  type NativeReviewAcknowledgeApprovedOutcome,
139
193
  type NativeReviewAcknowledgeApprovedRequest,
140
194
  type NativeTargetStatusRequest,
141
195
  type NativeReviewModeOperation,
142
196
  type NativeReviewModeSource,
197
+ type NativeReviewModeStatus,
143
198
  type NativeReviewProcessDiagnostics,
199
+ type NativeReviewUnachievableLensCaptureArtifact,
144
200
  type NativeStartResult,
201
+ type NativeReviewAssessRequest,
202
+ type ExecFileAdapter,
203
+ type ExecFileResult,
145
204
  } from "../lib/native-review-cli.ts";
205
+ import {
206
+ verificationPlan,
207
+ resolveWriterProfile,
208
+ RDD_LINE,
209
+ VERIFICATION_TIER,
210
+ NATIVE_REVIEW_OUTCOME,
211
+ type RddLine,
212
+ type VerificationTier,
213
+ type ReviewAssessmentV1,
214
+ type NativeReviewOutcome,
215
+ } from "../lib/review-risk-assessment.ts";
146
216
  import {
147
217
  assertReviewApprovedAcknowledgementExecuteV1,
148
218
  decodeReviewLastEventClosureV1,
@@ -153,7 +223,19 @@ import {
153
223
  type ReviewStatusV3,
154
224
  } from "../lib/review-integration-v2.ts";
155
225
  import { reconcileUnknownReviewLastEventCapture } from "../lib/review-last-event-controller.ts";
156
- import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
226
+ import { acquireChildStandingReviewPermissionClient, type ChildStandingReviewPermissionClient } from "../lib/review-session-standing-permission-ipc.ts";
227
+ import { isPiConsentV3, presentReviewConsentUi } from "../lib/review-consent-ui.ts";
228
+ import {
229
+ captureReviewSessionIdentity,
230
+ grantReviewSessionPermission,
231
+ hasReviewSessionPermission,
232
+ resolveCanonicalGitRepositoryIdentity,
233
+ revokeReviewSessionPermission,
234
+ reviewSessionPermissionEpoch,
235
+ revokeReviewSessionPermissionsForSession,
236
+ sameReviewSessionIdentity,
237
+ type ReviewSessionIdentity,
238
+ } from "../lib/review-session-standing-permission.ts";
157
239
 
158
240
  const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations";
159
241
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -163,8 +245,9 @@ function gentlePiAgentHome(): string {
163
245
  return resolveGentlePiAgentHome();
164
246
  }
165
247
 
166
- function sddGlobalAssetDriftCount(): number {
248
+ function packageAssetAudit(owner: PackageAssetOwner): { stale: number; overrides: number } {
167
249
  let stale = 0;
250
+ let overrides = 0;
168
251
  for (const [assetSubdir, installedSubdir, ownershipPrefix] of [
169
252
  ["agents", "agents", "agents"],
170
253
  ["chains", "chains", "chains"],
@@ -173,7 +256,7 @@ function sddGlobalAssetDriftCount(): number {
173
256
  const assetDir = join(ASSETS_DIR, assetSubdir);
174
257
  if (!existsSync(assetDir)) continue;
175
258
  for (const entry of readdirSync(assetDir, { withFileTypes: true })) {
176
- if (!entry.isFile()) continue;
259
+ if (!entry.isFile() || getPackageAssetOwner(`${ownershipPrefix}/${entry.name}`) !== owner) continue;
177
260
  const installedPath = join(gentlePiAgentHome(), installedSubdir, entry.name);
178
261
  try {
179
262
  if (!existsSync(installedPath)) {
@@ -186,6 +269,7 @@ function sddGlobalAssetDriftCount(): number {
186
269
  `${ownershipPrefix}/${entry.name}`,
187
270
  )
188
271
  ) {
272
+ overrides += 1;
189
273
  continue;
190
274
  }
191
275
  const packaged = readFileSync(join(assetDir, entry.name), "utf8");
@@ -206,26 +290,47 @@ function sddGlobalAssetDriftCount(): number {
206
290
  }
207
291
  }
208
292
  }
209
- return stale;
293
+ return { stale, overrides };
294
+ }
295
+
296
+ function packageAssetDiagnosticLines(cwd: string): string[] {
297
+ return (["delegation", "review", "sdd"] as const).flatMap((owner) => {
298
+ const label = owner === "sdd" ? "SDD" : owner;
299
+ const onDemand = owner === "sdd" && !hasPackageAssetOwnerInstallation(owner);
300
+ const { stale, overrides } = packageAssetAudit(owner);
301
+ const local = localAgentOverrideCount(cwd, owner);
302
+ const lines = [onDemand
303
+ ? `info: Global ${label} assets: on demand (not installed)`
304
+ : `${stale > 0 ? "warn" : "pass"}: Global ${label} assets stale: ${stale} file(s)`];
305
+ if (!onDemand && stale > 0) {
306
+ lines[0] += ` — run /gentle:install-${owner} --force to refresh managed assets`;
307
+ }
308
+ if (overrides > 0) {
309
+ lines.push(`info: Global ${label} user overrides: ${overrides} file(s); preserved, not package drift`);
310
+ }
311
+ if (local > 0) {
312
+ lines.push(`warn: Active ${label} agent overrides: ${local} file(s) — active non-builtin ${label} agents shadow package assets; keep only intentional overrides`);
313
+ }
314
+ return lines;
315
+ });
210
316
  }
211
317
 
212
- function sddLocalAgentOverrideCount(cwd: string): number {
318
+ function localAgentOverrideCount(cwd: string, owner: PackageAssetOwner): number {
213
319
  const packageSddAgentsDir = join(ASSETS_DIR, "agents");
214
- const packageSddAgentNames = existsSync(packageSddAgentsDir)
215
- ? new Set(
216
- readdirSync(packageSddAgentsDir, { withFileTypes: true })
217
- .filter((entry) => entry.isFile() && /^sdd-.*\.md$/i.test(entry.name))
218
- .map((entry) => entry.name),
320
+ const packageSddAgentNames = new Set(
321
+ listAgentsFromDir(packageSddAgentsDir, "builtin")
322
+ .filter((agent) =>
323
+ getPackageAssetOwner(
324
+ `agents/${relative(packageSddAgentsDir, agent.filePath).split(sep).join("/")}`,
325
+ ) === owner,
219
326
  )
220
- : new Set<string>();
327
+ .map((agent) => agent.name),
328
+ );
221
329
  let count = 0;
222
- for (const installedDir of [
223
- join(cwd, ".pi", "agents"),
224
- join(cwd, ".pi", "subagents"),
225
- ]) {
226
- if (!existsSync(installedDir)) continue;
227
- for (const entry of readdirSync(installedDir, { withFileTypes: true })) {
228
- if (entry.isFile() && packageSddAgentNames.has(entry.name)) count += 1;
330
+ for (const { dir, source, packageManaged } of discoverableNonBuiltinAgentRoots(cwd)) {
331
+ if (packageManaged || !existsSync(dir)) continue;
332
+ for (const agent of listAgentsFromDir(dir, source)) {
333
+ if (packageSddAgentNames.has(agent.name)) count += 1;
229
334
  }
230
335
  }
231
336
  return count;
@@ -465,7 +570,13 @@ function renderBackgroundSubagentsReport(
465
570
 
466
571
  const SUBAGENTS_PACKAGE_NAMES = ["pi-subagents-j0k3r", "pi-subagents"] as const;
467
572
  const SUBAGENT_RUN_TOOL = "subagent_run";
468
- const BOUNDED_WRITER_AGENT_NAMES = ["gentle-ai-worker", "worker"] as const;
573
+ const JUDGMENT_DAY_FIX_AGENT_NAME = "jd-fix-agent";
574
+ const BOUNDED_WRITER_AGENT_NAMES = ["gentle-ai-worker", "worker", JUDGMENT_DAY_FIX_AGENT_NAME] as const;
575
+ const JUDGMENT_DAY_ACTIVATION_HEADING = "## Judgment Day activation";
576
+ const JUDGMENT_DAY_ACTIVATION_SENTENCE = "User explicitly requested Judgment Day.";
577
+ const JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING = "## Exact authorized severe IDs";
578
+ const JUDGMENT_DAY_CORRECTION_BATCH_HEADING = "## Judgment Day correction batch";
579
+ const JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING = "## Exact frozen finding rows";
469
580
  const ALLOWED_EDIT_SURFACES_HEADING = /^## Allowed edit surfaces[ \t]*$/gim;
470
581
  const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6} /;
471
582
  const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)]) +/;
@@ -569,6 +680,15 @@ function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean {
569
680
  return hasSection;
570
681
  }
571
682
 
683
+ function sddDispatchAgentName(input: unknown): string | undefined {
684
+ if (!isRecord(input)) return undefined;
685
+ if (typeof input.agent === "string" && SDD_AGENT_NAME_SET.has(input.agent)) return input.agent;
686
+ if (Array.isArray(input.agent) && input.agent.some((agent) => typeof agent === "string" && SDD_AGENT_NAME_SET.has(agent))) {
687
+ return "invalid";
688
+ }
689
+ return undefined;
690
+ }
691
+
572
692
  function rejectUnscopedBoundedWriterDispatch(input: unknown): { block: true; reason: string } | undefined {
573
693
  if (
574
694
  !isRecord(input) ||
@@ -583,6 +703,165 @@ function rejectUnscopedBoundedWriterDispatch(input: unknown): { block: true; rea
583
703
  return { block: true, reason: WRITER_EDIT_SURFACE_REJECTION };
584
704
  }
585
705
 
706
+ function hasJudgmentDayFixAgentReference(input: Record<string, unknown>): boolean {
707
+ return input.agent === JUDGMENT_DAY_FIX_AGENT_NAME ||
708
+ (Array.isArray(input.agent) && input.agent.includes(JUDGMENT_DAY_FIX_AGENT_NAME)) ||
709
+ input.agents === JUDGMENT_DAY_FIX_AGENT_NAME ||
710
+ (Array.isArray(input.agents) && input.agents.includes(JUDGMENT_DAY_FIX_AGENT_NAME));
711
+ }
712
+
713
+ function canonicalJudgmentDaySectionBodies(value: unknown, heading: string): string[][] {
714
+ if (typeof value !== "string") return [];
715
+ const headingPattern = new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "gm");
716
+ return [...value.matchAll(headingPattern)].map((match) => {
717
+ const body = value.slice((match.index ?? 0) + match[0].length);
718
+ const nextHeading = body.search(/^ {0,3}#{1,6} /m);
719
+ return body
720
+ .slice(0, nextHeading === -1 ? undefined : nextHeading)
721
+ .split(/\r?\n/)
722
+ .filter((line) => line.length > 0);
723
+ });
724
+ }
725
+
726
+ const JUDGMENT_DAY_SEVERE_ID_ENTRY = /^- `(JD-[A-Z][A-Z0-9]*-\d+)`$/;
727
+ const JUDGMENT_DAY_CORRECTION_ROUND_ENTRY = /^Round: [12] of 2\.$/;
728
+ const JUDGMENT_DAY_FROZEN_LEDGER_SHA256_ENTRY = /^Frozen ledger SHA-256: `([0-9a-f]{64})`$/;
729
+ const JUDGMENT_DAY_FROZEN_ROW_FIELDS = [
730
+ "id",
731
+ "lens",
732
+ "location",
733
+ "severity",
734
+ "status_at_freeze",
735
+ "evidence_class",
736
+ "evidence_claim",
737
+ ] as const;
738
+ const JUDGMENT_DAY_FIX_SECTION_HEADINGS = [
739
+ JUDGMENT_DAY_ACTIVATION_HEADING,
740
+ JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING,
741
+ JUDGMENT_DAY_CORRECTION_BATCH_HEADING,
742
+ JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING,
743
+ "## Allowed edit surfaces",
744
+ ] as const;
745
+
746
+ function canonicalJudgmentDaySevereIds(entries: readonly string[]): string[] | undefined {
747
+ const ids = entries.map((entry) => entry.match(JUDGMENT_DAY_SEVERE_ID_ENTRY)?.[1]);
748
+ return ids.length > 0 && ids.every((id): id is string => id !== undefined) && new Set(ids).size === ids.length
749
+ ? ids
750
+ : undefined;
751
+ }
752
+
753
+ function canonicalJudgmentDayCorrectionBatchHash(entries: readonly string[]): string | undefined {
754
+ if (entries.length !== 2 || !JUDGMENT_DAY_CORRECTION_ROUND_ENTRY.test(entries[0]!)) return undefined;
755
+ return entries[1]!.match(JUDGMENT_DAY_FROZEN_LEDGER_SHA256_ENTRY)?.[1];
756
+ }
757
+
758
+ function isNonEmptyJudgmentDayFrozenField(value: unknown): value is string {
759
+ return typeof value === "string" && value.trim().length > 0;
760
+ }
761
+
762
+ function parseCanonicalJudgmentDayFrozenRows(
763
+ entries: readonly string[],
764
+ authorizedIds: readonly string[],
765
+ ): Record<string, unknown>[] | undefined {
766
+ if (entries.length !== authorizedIds.length) return undefined;
767
+ const rows: Record<string, unknown>[] = [];
768
+ const rowIds: string[] = [];
769
+ for (const entry of entries) {
770
+ let row: unknown;
771
+ try {
772
+ row = JSON.parse(entry) as unknown;
773
+ } catch {
774
+ return undefined;
775
+ }
776
+ if (!isRecord(row)) return undefined;
777
+ const keys = Object.keys(row);
778
+ if (keys.length !== JUDGMENT_DAY_FROZEN_ROW_FIELDS.length ||
779
+ !JUDGMENT_DAY_FROZEN_ROW_FIELDS.every((field) => field in row) ||
780
+ !isNonEmptyJudgmentDayFrozenField(row.id) ||
781
+ !JUDGMENT_DAY_SEVERE_ID_ENTRY.test(`- \`${row.id}\``) ||
782
+ row.lens !== "judgment-day" ||
783
+ !isNonEmptyJudgmentDayFrozenField(row.location) ||
784
+ (row.severity !== "BLOCKER" && row.severity !== "CRITICAL") ||
785
+ row.status_at_freeze !== "open" ||
786
+ !isNonEmptyJudgmentDayFrozenField(row.evidence_class) ||
787
+ !isNonEmptyJudgmentDayFrozenField(row.evidence_claim)
788
+ ) return undefined;
789
+ rows.push(row);
790
+ rowIds.push(row.id);
791
+ }
792
+ return new Set(rowIds).size === rowIds.length &&
793
+ rowIds.every((id, index) => id === authorizedIds[index])
794
+ ? rows
795
+ : undefined;
796
+ }
797
+
798
+ function hasCanonicalJudgmentDayFixSectionOrder(...values: unknown[]): boolean {
799
+ const dispatch = values.filter((value): value is string => typeof value === "string").join("\n");
800
+ let previousPosition = -1;
801
+ for (const heading of JUDGMENT_DAY_FIX_SECTION_HEADINGS) {
802
+ const matches = [...dispatch.matchAll(new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "gm"))];
803
+ if (matches.length !== 1 || (matches[0]!.index ?? -1) <= previousPosition) return false;
804
+ previousPosition = matches[0]!.index ?? -1;
805
+ }
806
+ return true;
807
+ }
808
+
809
+ function hasCanonicalJudgmentDayFixActivation(...values: unknown[]): boolean {
810
+ const hasMalformedHeading = values.some((value) =>
811
+ typeof value === "string" && value.split(/\r?\n/).some((line) => {
812
+ const candidate = line.match(/^ {0,3}#{1,6} (Judgment Day activation|Exact authorized severe IDs|Judgment Day correction batch|Exact frozen finding rows)[ \t]*$/i);
813
+ return candidate !== null &&
814
+ line !== JUDGMENT_DAY_ACTIVATION_HEADING &&
815
+ line !== JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING &&
816
+ line !== JUDGMENT_DAY_CORRECTION_BATCH_HEADING &&
817
+ line !== JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING;
818
+ }),
819
+ );
820
+ const activationBodies = values.flatMap((value) =>
821
+ canonicalJudgmentDaySectionBodies(value, JUDGMENT_DAY_ACTIVATION_HEADING),
822
+ );
823
+ const severeIdBodies = values.flatMap((value) =>
824
+ canonicalJudgmentDaySectionBodies(value, JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING),
825
+ );
826
+ const correctionBatchBodies = values.flatMap((value) =>
827
+ canonicalJudgmentDaySectionBodies(value, JUDGMENT_DAY_CORRECTION_BATCH_HEADING),
828
+ );
829
+ const frozenFindingRowBodies = values.flatMap((value) =>
830
+ canonicalJudgmentDaySectionBodies(value, JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING),
831
+ );
832
+ const authorizedIds = severeIdBodies.length === 1
833
+ ? canonicalJudgmentDaySevereIds(severeIdBodies[0]!)
834
+ : undefined;
835
+ const correctionBatchHash = correctionBatchBodies.length === 1
836
+ ? canonicalJudgmentDayCorrectionBatchHash(correctionBatchBodies[0]!)
837
+ : undefined;
838
+ const frozenFindingRows = authorizedIds !== undefined && frozenFindingRowBodies.length === 1
839
+ ? parseCanonicalJudgmentDayFrozenRows(frozenFindingRowBodies[0]!, authorizedIds)
840
+ : undefined;
841
+ return !hasMalformedHeading && hasCanonicalJudgmentDayFixSectionOrder(...values) &&
842
+ activationBodies.length === 1 &&
843
+ activationBodies[0]!.length === 1 &&
844
+ activationBodies[0]![0] === JUDGMENT_DAY_ACTIVATION_SENTENCE &&
845
+ authorizedIds !== undefined && correctionBatchHash !== undefined &&
846
+ frozenFindingRows !== undefined && correctionBatchHash === canonicalHash(frozenFindingRows);
847
+ }
848
+
849
+ const JUDGMENT_DAY_FIX_DISPATCH_REJECTION =
850
+ "Judgment Day fix dispatch requires exactly one `agent: \"jd-fix-agent\"`, one exact `## Judgment Day activation` section containing only `User explicitly requested Judgment Day.`, one non-empty unique canonical `## Exact authorized severe IDs` section, one exact `## Judgment Day correction batch` section with `Round: 1 of 2.` or `Round: 2 of 2.` and the matching canonical lowercase SHA-256 of one exact `## Exact frozen finding rows` section whose BLOCKER/CRITICAL open Judgment Day rows equal the authorized IDs in the same order, and the existing exact `## Allowed edit surfaces` guard. The parent must provide the canonical bounded dispatch; do not infer activation, authorization, or frozen findings.";
851
+
852
+ function rejectInvalidJudgmentDayFixDispatch(input: unknown): { block: true; reason: string } | undefined {
853
+ if (!isRecord(input) || !hasJudgmentDayFixAgentReference(input)) return undefined;
854
+ if (
855
+ input.agent === JUDGMENT_DAY_FIX_AGENT_NAME &&
856
+ !("agents" in input) &&
857
+ hasCanonicalJudgmentDayFixActivation(input.task, input.context) &&
858
+ hasTaskScopedAllowedEditSurfaces(input.task, input.context)
859
+ ) {
860
+ return undefined;
861
+ }
862
+ return { block: true, reason: JUDGMENT_DAY_FIX_DISPATCH_REJECTION };
863
+ }
864
+
586
865
  /**
587
866
  * Roots where an installed subagents package may live. These are the same
588
867
  * roots builtinAgentDirs() walks, minus its `/agents` suffix.
@@ -671,21 +950,288 @@ function renderBackgroundSubagentsStatusLine(
671
950
  return `Background subagent policy: ${background.policy} (capability: ${background.capability})`;
672
951
  }
673
952
 
674
- // Rendered prompts are memoized per background policy/capability key for the
675
- // process lifetime; the assets bytes themselves are read once per key.
953
+ /**
954
+ * A `status` object is only trusted when `effective` is exactly `on`/`off`
955
+ * and `source` is one of the exported `NATIVE_REVIEW_MODE_SOURCE` values.
956
+ * `resolveRddModeStatus` only ever produces a value shaped like this, but
957
+ * `renderRddStatusLine` validates at the render boundary anyway -- a
958
+ * malformed or partial object (a bad decode upstream, a future field
959
+ * change, a hand-built test fixture) must fail closed to the "unknown"
960
+ * line, never render an unrecognized value verbatim.
961
+ */
962
+ function isValidRddModeStatus(
963
+ status: NativeReviewModeStatus | undefined,
964
+ ): status is NativeReviewModeStatus {
965
+ if (status === undefined || status === null || typeof status !== "object") return false;
966
+ if (status.effective !== "on" && status.effective !== "off") return false;
967
+ const validSources: readonly string[] = Object.values(NATIVE_REVIEW_MODE_SOURCE);
968
+ return typeof status.source === "string" && validSources.includes(status.source);
969
+ }
970
+
971
+ /**
972
+ * Renders the receipt-driven-development status line rendered next to
973
+ * `Background subagent policy` (gentle-pi#661). Renders the fail-closed
974
+ * "unknown" line whenever `status` is not a validated on/off status with a
975
+ * recognized source -- `undefined` (the native reader could not answer:
976
+ * binary absent, timed out, aborted, or a native CLI failure) or any
977
+ * malformed/partial object. This is a pure render, never a native call, so
978
+ * it never throws.
979
+ */
980
+ function renderRddStatusLine(
981
+ status: NativeReviewModeStatus | undefined,
982
+ ): string {
983
+ return isValidRddModeStatus(status)
984
+ ? `Receipt-driven development: ${status.effective} (decided by ${status.source})`
985
+ : "Receipt-driven development: unknown (native status unavailable)";
986
+ }
987
+
988
+ // The primary-session prompt awaits this on every non-SDD, non-named agent
989
+ // start, so an unbounded native read would stall session start behind a
990
+ // hung `gentle-ai` child (gentle-pi#661 native-review escalation). The
991
+ // production call site (before_agent_start) passes
992
+ // `AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS)`; resolveRddModeStatus also
993
+ // races the call against that same signal itself (not just the CLI's own
994
+ // signal handling) so an abort is honored even against a stub/mock
995
+ // reviewMode that ignores its `signal` argument, as tests do.
996
+ const RDD_STATUS_TIMEOUT_MS = 3000;
997
+ // Repeated session/agent-start builds within this window reuse the last
998
+ // resolved status instead of respawning the native binary. Deliberately
999
+ // memoizes a failed/undefined resolution too (a sustained outage should not
1000
+ // retry every agent start), trading a slower recovery signal for far fewer
1001
+ // spawns; the one-shot notify below still surfaces a sustained outage.
1002
+ const RDD_STATUS_MEMO_TTL_MS = 30_000;
1003
+ const rddStatusMemo = new Map<string, { readonly status: NativeReviewModeStatus | undefined; readonly expiresAt: number }>();
1004
+
1005
+ /** @internal test seam: clears the per-cwd RDD status memo. */
1006
+ function clearRddStatusMemoForTesting(): void {
1007
+ rddStatusMemo.clear();
1008
+ }
1009
+
1010
+ // gentle-pi#668 (corrected): last-known outcome for ONE candidate, keyed by
1011
+ // repository realpath AND targetIdentity -- never repository alone, or one
1012
+ // candidate's outcome would leak into every other candidate's `assess` call.
1013
+ // Only declined/unavailable are ever written (from ANSWER_CONSENT); `closed`
1014
+ // is never written/derived -- pass it explicitly. A missing entry reads back
1015
+ // `undefined`, treated as `unknown` (fail closed, exactly like `off`).
1016
+ const nativeReviewOutcomeByCandidate = new Map<string, "declined" | "unavailable">();
1017
+
1018
+ function nativeReviewOutcomeMemoKey(cwd: string, targetIdentity: string): string {
1019
+ try {
1020
+ return `${realpathSync(cwd)}\u0000${targetIdentity}`;
1021
+ } catch {
1022
+ return `${cwd}\u0000${targetIdentity}`;
1023
+ }
1024
+ }
1025
+
1026
+ function recordNativeReviewOutcome(cwd: string, targetIdentity: string, outcome: "declined" | "unavailable"): void {
1027
+ nativeReviewOutcomeByCandidate.set(nativeReviewOutcomeMemoKey(cwd, targetIdentity), outcome);
1028
+ }
1029
+
1030
+ /** Returns the recorded outcome for exactly this candidate, or `undefined` when none is recorded. */
1031
+ function readNativeReviewOutcome(cwd: string, targetIdentity: string): "declined" | "unavailable" | undefined {
1032
+ return nativeReviewOutcomeByCandidate.get(nativeReviewOutcomeMemoKey(cwd, targetIdentity));
1033
+ }
1034
+
1035
+ /** @internal test seam: clears the per-candidate native review outcome memo. */
1036
+ function clearNativeReviewOutcomeMemoForTesting(): void {
1037
+ nativeReviewOutcomeByCandidate.clear();
1038
+ }
1039
+
1040
+ // Best-effort current-candidate target identity for `assess` (gentle-pi#668):
1041
+ // reuses `targetStatus`, a native call this tool already makes elsewhere.
1042
+ async function readCurrentTargetIdentityBestEffort(
1043
+ nativeReviewCli: Pick<NativeReviewCli, "targetStatus"> | null | undefined,
1044
+ cwd: string,
1045
+ signal?: AbortSignal,
1046
+ ): Promise<string | undefined> {
1047
+ if (nativeReviewCli?.targetStatus === undefined) return undefined;
1048
+ try {
1049
+ const status = await nativeReviewCli.targetStatus({ cwd, ...(signal === undefined ? {} : { signal }) });
1050
+ return status.applicability === "current_target" ? status.targetIdentity : undefined;
1051
+ } catch {
1052
+ return undefined;
1053
+ }
1054
+ }
1055
+
1056
+ function rddAbortRejection(signal: AbortSignal): Promise<never> {
1057
+ return new Promise((_resolve, reject) => {
1058
+ if (signal.aborted) {
1059
+ reject(signal.reason ?? new Error("aborted"));
1060
+ return;
1061
+ }
1062
+ signal.addEventListener("abort", () => reject(signal.reason ?? new Error("aborted")), { once: true });
1063
+ });
1064
+ }
1065
+
1066
+ async function readRddModeStatusOnce(
1067
+ nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
1068
+ cwd: string,
1069
+ signal?: AbortSignal,
1070
+ ): Promise<NativeReviewModeStatus | undefined> {
1071
+ if (!nativeReviewCli?.reviewMode) return undefined;
1072
+ try {
1073
+ const call = nativeReviewCli.reviewMode({ cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, signal });
1074
+ const result = signal === undefined ? await call : await Promise.race([call, rddAbortRejection(signal)]);
1075
+ return result.status;
1076
+ } catch {
1077
+ return undefined;
1078
+ }
1079
+ }
1080
+
1081
+ // gentle-pi#662: read-only combined native risk assessment plus the computed
1082
+ // verification plan (`lib/review-risk-assessment.ts`), for the `gentle_review`
1083
+ // tool's `assess` operation. Never throws: an unavailable/failed native
1084
+ // assess call (older binary without the verb, timeout, malformed response)
1085
+ // resolves to the `unassessable` tier, which `verificationPlan` treats the
1086
+ // same as `high` -- the fail-closed rule from gentle-pi#662.
1087
+ interface ReviewAssessmentPlanDetails {
1088
+ schema: "gentle-pi.review-assessment-plan/v1";
1089
+ risk: VerificationTier;
1090
+ reasons: readonly { code: string; path: string; detail: string }[];
1091
+ changedPaths: number;
1092
+ changedLines: number;
1093
+ candidate: { kind: string; baseRef: string | undefined } | null;
1094
+ rddLine: RddLine;
1095
+ nativeReviewOutcome: NativeReviewOutcome;
1096
+ // gentle-pi#668: where nativeReviewOutcome came from -- explicit (caller
1097
+ // passed it), derived (matched this exact candidate), or unknown.
1098
+ outcome_source: "explicit" | "derived" | "unknown";
1099
+ writerProfile: "small" | "large";
1100
+ plan: {
1101
+ writerSelfVerification: boolean;
1102
+ structuralReadbackOnly: boolean;
1103
+ independentVerifier: boolean;
1104
+ reason: string;
1105
+ };
1106
+ }
1107
+
1108
+ async function resolveReviewAssessmentPlan(
1109
+ nativeReviewCli: Pick<NativeReviewCli, "reviewMode" | "assess" | "targetStatus"> | null | undefined,
1110
+ cwd: string,
1111
+ input: ReviewAssessInput,
1112
+ signal?: AbortSignal,
1113
+ ): Promise<ReviewAssessmentPlanDetails> {
1114
+ if (input.baseRef !== undefined && input.committedOnly !== true) throw new Error("Review assess baseRef requires committedOnly: true");
1115
+ if (input.baseRef === undefined && input.committedOnly !== undefined) throw new Error("Review assess committedOnly requires an explicit baseRef");
1116
+
1117
+ const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
1118
+ const rddLine: RddLine = isValidRddModeStatus(status) ? status.effective : RDD_LINE.UNKNOWN;
1119
+ const writerProfile = resolveWriterProfile({
1120
+ ...(input.writerModelId === undefined ? {} : { model: { id: input.writerModelId } }),
1121
+ thinking: input.writerEffort,
1122
+ });
1123
+
1124
+ let assessment: ReviewAssessmentV1 | undefined;
1125
+ let unassessableDetail: string | undefined;
1126
+ if (nativeReviewCli?.assess === undefined) {
1127
+ unassessableDetail = "native review assess is unavailable: the installed gentle-ai binary does not expose the assess command.";
1128
+ } else {
1129
+ try {
1130
+ const request: NativeReviewAssessRequest = {
1131
+ cwd,
1132
+ ...(input.baseRef === undefined ? {} : { baseRef: input.baseRef, committedOnly: true as const }),
1133
+ ...(signal === undefined ? {} : { signal }),
1134
+ };
1135
+ assessment = await nativeReviewCli.assess(request);
1136
+ } catch (error) {
1137
+ unassessableDetail = `native review assess failed: ${error instanceof Error ? error.message : String(error)}`;
1138
+ }
1139
+ }
1140
+
1141
+ const risk: VerificationTier = assessment?.risk ?? VERIFICATION_TIER.UNASSESSABLE;
1142
+ // gentle-pi#668: explicit always wins; otherwise derive only for THIS
1143
+ // candidate's own target identity, never repository-only. `closed` is
1144
+ // never derived.
1145
+ const targetIdentity = input.nativeReviewOutcome === undefined ? await readCurrentTargetIdentityBestEffort(nativeReviewCli, cwd, signal) : undefined;
1146
+ const derived = targetIdentity === undefined ? undefined : readNativeReviewOutcome(cwd, targetIdentity);
1147
+ const nativeReviewOutcome: NativeReviewOutcome = input.nativeReviewOutcome ?? derived ?? NATIVE_REVIEW_OUTCOME.UNKNOWN;
1148
+ const outcomeSource: "explicit" | "derived" | "unknown" = input.nativeReviewOutcome !== undefined ? "explicit" : derived === undefined ? "unknown" : "derived";
1149
+ const plan = verificationPlan({ rddLine, risk, writerProfile, nativeReviewOutcome });
1150
+ return {
1151
+ schema: "gentle-pi.review-assessment-plan/v1",
1152
+ risk,
1153
+ reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: "native-assess-unavailable", path: "", detail: unassessableDetail }]),
1154
+ changedPaths: assessment?.changedPaths ?? 0,
1155
+ changedLines: assessment?.changedLines ?? 0,
1156
+ candidate: assessment === undefined ? null : { kind: assessment.candidate.kind, baseRef: assessment.candidate.baseRef },
1157
+ rddLine,
1158
+ nativeReviewOutcome,
1159
+ outcome_source: outcomeSource,
1160
+ writerProfile,
1161
+ plan,
1162
+ };
1163
+ }
1164
+
1165
+ let rddStatusUnavailableWarned = false;
1166
+
1167
+ /**
1168
+ * Best-effort native RDD mode status read for prompt rendering, memoized per
1169
+ * cwd for `RDD_STATUS_MEMO_TTL_MS`. Reuses the `reviewMode` STATUS reader
1170
+ * (`gentle-ai review mode status --json`, decoded to
1171
+ * `NativeReviewModeStatus`) that the `/gentle:review-mode` command also
1172
+ * calls. Never throws and never hangs past `signal`'s deadline when one is
1173
+ * given: an absent binary, a timed-out/aborted process, or a native CLI
1174
+ * failure all resolve to `undefined`. `ctx` is optional and used only for a
1175
+ * one-shot (per process) UI notice when the read is swallowed, so a
1176
+ * sustained native outage is observable beyond the rendered "unknown" line.
1177
+ */
1178
+ async function resolveRddModeStatus(
1179
+ nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
1180
+ cwd: string,
1181
+ signal?: AbortSignal,
1182
+ now: () => number = Date.now,
1183
+ ctx?: Pick<ExtensionContext, "hasUI" | "ui">,
1184
+ ): Promise<NativeReviewModeStatus | undefined> {
1185
+ const nowMs = now();
1186
+ const cached = rddStatusMemo.get(cwd);
1187
+ if (cached !== undefined && cached.expiresAt > nowMs) return cached.status;
1188
+ const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
1189
+ rddStatusMemo.set(cwd, { status, expiresAt: nowMs + RDD_STATUS_MEMO_TTL_MS });
1190
+ if (status === undefined && !rddStatusUnavailableWarned) {
1191
+ rddStatusUnavailableWarned = true;
1192
+ if (ctx?.hasUI) {
1193
+ ctx.ui.notify(
1194
+ "Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt renders \"unknown\" until this recovers; this notice will not repeat this session.",
1195
+ "warning",
1196
+ );
1197
+ }
1198
+ }
1199
+ return status;
1200
+ }
1201
+
1202
+ /** Resolves and renders the RDD status line for a production call site in one call. */
1203
+ async function resolveRddStatusLine(
1204
+ nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
1205
+ cwd: string,
1206
+ signal?: AbortSignal,
1207
+ now: () => number = Date.now,
1208
+ ctx?: Pick<ExtensionContext, "hasUI" | "ui">,
1209
+ ): Promise<string> {
1210
+ return renderRddStatusLine(await resolveRddModeStatus(nativeReviewCli, cwd, signal, now, ctx));
1211
+ }
1212
+
1213
+ // Rendered prompts are memoized per background policy/capability/RDD-status
1214
+ // key for the process lifetime; the assets bytes themselves are read once
1215
+ // per key. `rddStatusLine` defaults to the "unknown" fallback line (the
1216
+ // longest of the three renderable forms), not "", so the default no-argument
1217
+ // render IS the worst case the canonical 8 KiB budget in
1218
+ // tests/orchestrator-budget.test.ts measures; assets/orchestrator.md is
1219
+ // sized with that worst case already included. Production still resolves
1220
+ // and passes the real line (on/off/unknown) via resolveRddStatusLine.
676
1221
  const orchestratorPromptCache = new Map<string, string>();
677
1222
  function getOrchestratorPrompt(
678
1223
  cwd: string = process.cwd(),
679
1224
  activeTools?: readonly string[],
1225
+ rddStatusLine: string = renderRddStatusLine(undefined),
680
1226
  ): string {
681
1227
  const background: BackgroundSubagentsRendering = {
682
1228
  policy: loadBackgroundSubagentsPolicy(cwd),
683
1229
  capability: resolveBackgroundSubagentsCapability(cwd, activeTools),
684
1230
  };
685
- const cacheKey = `${background.policy}:${background.capability}`;
1231
+ const cacheKey = `${background.policy}:${background.capability}:${rddStatusLine}`;
686
1232
  let prompt = orchestratorPromptCache.get(cacheKey);
687
1233
  if (prompt === undefined) {
688
- prompt = renderOrchestratorPrompt(ASSETS_DIR, background);
1234
+ prompt = renderOrchestratorPrompt(ASSETS_DIR, background, rddStatusLine);
689
1235
  orchestratorPromptCache.set(cacheKey, prompt);
690
1236
  }
691
1237
  return prompt;
@@ -694,12 +1240,14 @@ function getOrchestratorPrompt(
694
1240
  function renderOrchestratorPrompt(
695
1241
  assetsDir: string,
696
1242
  background: BackgroundSubagentsRendering = DEFAULT_BACKGROUND_SUBAGENTS_RENDERING,
1243
+ rddStatusLine: string = renderRddStatusLine(undefined),
697
1244
  ): string {
1245
+ const backgroundPolicyBlock = `${renderBackgroundSubagentsStatusLine(background)}\n${rddStatusLine}`;
698
1246
  return readFileSync(join(assetsDir, "orchestrator.md"), "utf8")
699
1247
  .replaceAll("{{GENTLE_PI_ASSETS_ROOT}}", assetsDir)
700
1248
  .replaceAll(
701
1249
  "{{GENTLE_PI_BACKGROUND_POLICY}}",
702
- renderBackgroundSubagentsStatusLine(background),
1250
+ backgroundPolicyBlock,
703
1251
  )
704
1252
  .trim();
705
1253
  }
@@ -798,6 +1346,7 @@ function buildGentlePrompt(
798
1346
  persona: PersonaMode,
799
1347
  cwd: string = process.cwd(),
800
1348
  activeTools?: readonly string[],
1349
+ rddStatusLine: string = renderRddStatusLine(undefined),
801
1350
  ): string {
802
1351
  const personaPrompt =
803
1352
  persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT;
@@ -832,13 +1381,13 @@ Harness principles:
832
1381
  - Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
833
1382
  - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
834
1383
 
835
- ${getOrchestratorPrompt(cwd, activeTools)}`;
1384
+ ${getOrchestratorPrompt(cwd, activeTools, rddStatusLine)}`;
836
1385
  }
837
1386
 
838
1387
  // Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
839
1388
  // between `git` and the subcommand. Short flags may be followed by a separate value token.
840
1389
  const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)* `;
841
- const GIT_PUSH_RE = new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b`);
1390
+ const GIT_PUSH_RE = new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}(push)\b`);
842
1391
 
843
1392
  const DENIED_BASH_PATTERNS: RegExp[] = [
844
1393
  // Block rm -rf targeting /, ~ or ~/subdir, $HOME or $HOME/subdir, .. or .
@@ -865,6 +1414,19 @@ const GUARD_ACTION = {
865
1414
  type GuardAction = (typeof GUARD_ACTION)[keyof typeof GUARD_ACTION];
866
1415
  type GuardClassification = GuardAction | "not-guarded";
867
1416
 
1417
+ interface GuardMatch {
1418
+ key: GuardedCommandKey;
1419
+ action: GuardAction;
1420
+ triggerIndex: number;
1421
+ }
1422
+
1423
+ interface GuardEvaluation {
1424
+ action: GuardClassification;
1425
+ key?: GuardedCommandKey;
1426
+ triggerIndex: number;
1427
+ matches: GuardMatch[];
1428
+ }
1429
+
868
1430
  const GUARDED_COMMAND_KEY = {
869
1431
  GIT_PUSH: "gitPush",
870
1432
  GIT_REBASE: "gitRebase",
@@ -889,10 +1451,10 @@ interface LoadGuardrailsOptions {
889
1451
 
890
1452
  const GUARDED_KEY_PATTERNS: Record<GuardedCommandKey, RegExp> = {
891
1453
  gitPush: GIT_PUSH_RE,
892
- gitRebase: /\bgit\s+rebase\b/,
893
- gitBranchDeleteForce: /\bgit\s+branch\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)/,
894
- npmPublish: /\bnpm\s+publish\b/,
895
- piRemove: /\bpi\s+remove\b/,
1454
+ gitRebase: /\bgit\s+(rebase)\b/,
1455
+ gitBranchDeleteForce: /\bgit\s+(branch)\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\r\n;&|]*--force\b|--force\b[^\r\n;&|]*--delete\b)/,
1456
+ npmPublish: /\bnpm\s+(publish)\b/,
1457
+ piRemove: /\bpi\s+(remove)\b/,
896
1458
  };
897
1459
 
898
1460
  const AUTONOMOUS_DEFAULT_ACTIONS: Record<GuardedCommandKey, GuardAction> = {
@@ -903,6 +1465,14 @@ const AUTONOMOUS_DEFAULT_ACTIONS: Record<GuardedCommandKey, GuardAction> = {
903
1465
  piRemove: "confirm",
904
1466
  };
905
1467
 
1468
+ const GUARDED_COMMAND_LABELS: Record<GuardedCommandKey, string> = {
1469
+ gitPush: "git push",
1470
+ gitRebase: "git rebase",
1471
+ gitBranchDeleteForce: "forced git branch deletion",
1472
+ npmPublish: "npm publish",
1473
+ piRemove: "pi remove",
1474
+ };
1475
+
906
1476
  const SAFE_GUARDRAILS_CONFIG: RuntimeGuardrailsConfig = {
907
1477
  autonomousMode: false,
908
1478
  guardedCommands: {},
@@ -918,31 +1488,83 @@ const SAFE_GUARDRAILS_CONFIG: RuntimeGuardrailsConfig = {
918
1488
  * (applying AUTONOMOUS_DEFAULT_ACTIONS for any key not set in guardedCommands)
919
1489
  * 4. No match → "not-guarded"
920
1490
  */
921
- function classifyGuardedCommand(
1491
+ function collectGuardedMatches(
922
1492
  command: string,
923
1493
  config: RuntimeGuardrailsConfig,
924
- ): GuardClassification {
925
- // Step 1: hard-deny always wins, regardless of any config
1494
+ ): GuardMatch[] {
1495
+ const matches: GuardMatch[] = [];
1496
+ for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [
1497
+ GuardedCommandKey,
1498
+ RegExp,
1499
+ ][]) {
1500
+ const globalPattern = new RegExp(pattern.source, `${pattern.flags}g`);
1501
+ for (const match of command.matchAll(globalPattern)) {
1502
+ const action = config.autonomousMode
1503
+ ? (config.guardedCommands[key] ?? AUTONOMOUS_DEFAULT_ACTIONS[key])
1504
+ : "confirm";
1505
+ matches.push({
1506
+ key,
1507
+ action,
1508
+ triggerIndex: match.index + match[0].lastIndexOf(match[1]),
1509
+ });
1510
+ }
1511
+ }
1512
+ return matches.sort((left, right) => left.triggerIndex - right.triggerIndex);
1513
+ }
1514
+
1515
+ function evaluateGuardedCommand(
1516
+ command: string,
1517
+ config: RuntimeGuardrailsConfig,
1518
+ ): GuardEvaluation {
1519
+ const matches = collectGuardedMatches(command, config);
1520
+
1521
+ // Hard denies override every configured action across the complete command.
926
1522
  for (const pattern of DENIED_BASH_PATTERNS) {
927
- if (pattern.test(command)) return "block";
1523
+ const denied = pattern.exec(command);
1524
+ if (!denied) continue;
1525
+ const matchedAction = matches.find((match) =>
1526
+ match.triggerIndex >= denied.index && match.triggerIndex < denied.index + denied[0].length,
1527
+ );
1528
+ return {
1529
+ action: "block",
1530
+ key: matchedAction?.key,
1531
+ triggerIndex: matchedAction?.triggerIndex ?? denied.index,
1532
+ matches,
1533
+ };
928
1534
  }
929
1535
 
930
- // Step 2 & 3: find which guarded key (if any) this command matches
931
- for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [GuardedCommandKey, RegExp][]) {
932
- if (!pattern.test(command)) continue;
1536
+ // Configured block, then confirmation, then allow win across all matches.
1537
+ const selected = matches.find((match) => match.action === "block")
1538
+ ?? matches.find((match) => match.action === "confirm")
1539
+ ?? matches.find((match) => match.action === "allow");
1540
+ if (selected) return { ...selected, matches };
1541
+ return { action: "not-guarded", triggerIndex: 0, matches };
1542
+ }
933
1543
 
934
- // Matched a guarded key
935
- if (!config.autonomousMode) {
936
- // Legacy behavior: any match → confirm
937
- return "confirm";
938
- }
1544
+ function classifyGuardedCommand(
1545
+ command: string,
1546
+ config: RuntimeGuardrailsConfig,
1547
+ ): GuardClassification {
1548
+ return evaluateGuardedCommand(command, config).action;
1549
+ }
939
1550
 
940
- // Autonomous mode: use configured action, fall back to sensible defaults
941
- const configuredAction = config.guardedCommands[key];
942
- return configuredAction ?? AUTONOMOUS_DEFAULT_ACTIONS[key];
943
- }
1551
+ function guardedCommandPreview(command: string, triggerIndex: number): string {
1552
+ const start = Math.max(0, triggerIndex - 60);
1553
+ const prefix = start > 0 ? "…" : "";
1554
+ return `${prefix}${truncateToWidth(command.slice(start).replace(/\s+/g, " ").trim(), 180 - prefix.length, "…")}`;
1555
+ }
944
1556
 
945
- return "not-guarded";
1557
+ /** Confirmation headline for all guarded actions; generic when no key matched. */
1558
+ function guardedCommandTitle(
1559
+ key?: GuardedCommandKey,
1560
+ matches: readonly GuardMatch[] = [],
1561
+ ): string {
1562
+ if (matches.length > 1) {
1563
+ return `Allow guarded actions: ${matches.map((match) => GUARDED_COMMAND_LABELS[match.key]).join("; ")}?`;
1564
+ }
1565
+ return key === undefined
1566
+ ? "Allow guarded command?"
1567
+ : `Allow guarded ${GUARDED_COMMAND_LABELS[key]}?`;
946
1568
  }
947
1569
 
948
1570
  function parseGuardrailsConfigFile(
@@ -1050,22 +1672,9 @@ const SENSITIVE_PATH_PATTERNS: RegExp[] = [
1050
1672
  /\.(?:pem|key|p12|pfx)$/,
1051
1673
  ];
1052
1674
 
1053
- const SDD_AGENT_NAMES = [
1054
- "sdd-init",
1055
- "sdd-onboard",
1056
- "sdd-explore",
1057
- "sdd-research",
1058
- "sdd-proposal",
1059
- "sdd-spec",
1060
- "sdd-design",
1061
- "sdd-tasks",
1062
- "sdd-status",
1063
- "sdd-apply",
1064
- "sdd-verify",
1065
- "sdd-sync",
1066
- "sdd-archive",
1067
- ] as const;
1068
- const SDD_AGENT_NAME_SET = new Set<string>(SDD_AGENT_NAMES);
1675
+ const SDD_AGENT_NAME_SET = new Set<string>(SHIPPED_SDD_AGENT_NAMES);
1676
+ const SDD_CHANGE_FLAG = "gentle-sdd-change";
1677
+ const SDD_CHANGE_KEYS = ["changeName", "phase", "workspaceRoot"] as const;
1069
1678
 
1070
1679
  const JUDGMENT_DAY_AGENT_NAMES = [
1071
1680
  "jd-judge-a",
@@ -1074,7 +1683,7 @@ const JUDGMENT_DAY_AGENT_NAMES = [
1074
1683
  ] as const;
1075
1684
 
1076
1685
  const CORE_MODEL_AGENT_NAMES = [
1077
- ...SDD_AGENT_NAMES,
1686
+ ...SHIPPED_SDD_AGENT_NAMES,
1078
1687
  ...JUDGMENT_DAY_AGENT_NAMES,
1079
1688
  ] as const;
1080
1689
  const CORE_MODEL_AGENT_NAME_SET = new Set<string>(CORE_MODEL_AGENT_NAMES);
@@ -1116,10 +1725,10 @@ function readStringPath(value: unknown, path: string[]): string | undefined {
1116
1725
 
1117
1726
  function isSddAgentStartEvent(event: unknown): boolean {
1118
1727
  const candidates = readAgentStartNames(event);
1119
- if (candidates.some((value) => SDD_AGENT_NAME_SET.has(value))) return true;
1728
+ if (candidates.some((value) => SDD_AGENT_NAME_SET.has(value)) || sddPhaseFromAgentStartEvent(event) !== undefined) return true;
1120
1729
 
1121
1730
  const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
1122
- return SDD_AGENT_NAMES.some((name) => {
1731
+ return SHIPPED_SDD_AGENT_NAMES.some((name) => {
1123
1732
  const phase = name.replace(/^sdd-/, "");
1124
1733
  return new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt);
1125
1734
  });
@@ -1142,19 +1751,107 @@ function isNamedAgentStartEvent(event: unknown): boolean {
1142
1751
  return readAgentStartNames(event).length > 0;
1143
1752
  }
1144
1753
 
1145
- function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | undefined {
1146
- for (const name of readAgentStartNames(event)) {
1147
- if (name === "sdd-apply") return "apply";
1148
- if (name === "sdd-verify") return "verify";
1149
- if (name === "sdd-sync") return "sync";
1150
- if (name === "sdd-archive") return "archive";
1151
- }
1754
+ function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | "remediate" | undefined {
1755
+ const phases = ["apply", "verify", "sync", "archive", "remediate"] as const;
1756
+ const names = readAgentStartNames(event);
1152
1757
  const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
1153
- if (/\bSDD apply executor\b/i.test(systemPrompt)) return "apply";
1154
- if (/\bSDD verify executor\b/i.test(systemPrompt)) return "verify";
1155
- if (/\bSDD sync executor\b/i.test(systemPrompt)) return "sync";
1156
- if (/\bSDD archive executor\b/i.test(systemPrompt)) return "archive";
1157
- return undefined;
1758
+ const promptPhases = phases.filter((phase) => new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt));
1759
+ if (promptPhases.length > 1) return undefined;
1760
+ if (names.length === 0) return promptPhases[0];
1761
+ return phases.find((phase) => names.every((name) => name === `sdd-${phase}`) &&
1762
+ (promptPhases.length === 0 || promptPhases[0] === phase));
1763
+ }
1764
+
1765
+ function resolveSddChangeSelection(serialized: unknown, cwd: string, agentName: string) {
1766
+ if (typeof serialized !== "string") throw new Error("SDD selection must be a JSON string.");
1767
+ let value: unknown;
1768
+ try {
1769
+ value = JSON.parse(serialized);
1770
+ } catch {
1771
+ throw new Error("SDD selection is malformed.");
1772
+ }
1773
+ if (!isRecord(value) || Object.keys(value).sort().join(",") !== (value.phase === "remediate" ? "changeName,failedEvidenceRevision,phase,workspaceRoot" : SDD_CHANGE_KEYS.join(","))) {
1774
+ throw new Error("SDD selection must contain only changeName, workspaceRoot, and phase.");
1775
+ }
1776
+ const { changeName, workspaceRoot, phase } = value;
1777
+ if (typeof changeName !== "string" || changeName.length === 0 ||
1778
+ typeof workspaceRoot !== "string" || workspaceRoot.length === 0 ||
1779
+ (phase !== "apply" && phase !== "verify" && phase !== "sync" && phase !== "archive" && phase !== "remediate")) {
1780
+ throw new Error("SDD selection has an invalid identity.");
1781
+ }
1782
+ if (agentName !== `sdd-${phase}`) throw new Error("SDD selection phase does not match the child agent.");
1783
+ let canonicalCwd: string;
1784
+ let canonicalSelectionRoot: string;
1785
+ try {
1786
+ canonicalCwd = realpathSync(cwd);
1787
+ canonicalSelectionRoot = realpathSync(workspaceRoot);
1788
+ } catch {
1789
+ throw new Error("SDD selection workspaceRoot cannot be resolved.");
1790
+ }
1791
+ if (canonicalCwd !== canonicalSelectionRoot || workspaceRoot !== canonicalSelectionRoot) {
1792
+ throw new Error("SDD selection workspaceRoot does not match the canonical child root.");
1793
+ }
1794
+ if (phase === "remediate" && (typeof value.failedEvidenceRevision !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.failedEvidenceRevision))) throw new Error("Invalid remediation revision");
1795
+ return { changeName, workspaceRoot: canonicalCwd, phase, ...(phase === "remediate" ? { failedEvidenceRevision: value.failedEvidenceRevision as string } : {}) };
1796
+ }
1797
+
1798
+ function resolveSddChangeStartup(
1799
+ serialized: unknown,
1800
+ cwd: string,
1801
+ agentName: string,
1802
+ resolver: (options: Parameters<typeof resolveSddStatus>[0]) => ReturnType<typeof resolveSddStatus> = resolveSddStatus,
1803
+ ) {
1804
+ const selection = resolveSddChangeSelection(serialized, cwd, agentName);
1805
+ const status = resolver({ cwd: selection.workspaceRoot, workspaceRoot: selection.workspaceRoot, changeName: selection.changeName, includeInstructions: true });
1806
+ if (status.actionContext.workspaceRoot !== selection.workspaceRoot || status.changeName !== selection.changeName) {
1807
+ throw new Error("SDD selection resolver returned a mismatched status.");
1808
+ }
1809
+ return { selection, status };
1810
+ }
1811
+
1812
+ async function resolveSelectedNativeSddChangeStartup(
1813
+ serialized: unknown,
1814
+ cwd: string,
1815
+ agentName: string,
1816
+ native: Pick<NativeReviewCli, "sddStatus"> | null | undefined,
1817
+ localResolver: (options: Parameters<typeof resolveSddStatus>[0]) => ReturnType<typeof resolveSddStatus> = resolveSddStatus,
1818
+ ): Promise<{ selection: { changeName: string; workspaceRoot: string; phase: SddPhase | "remediate"; failedEvidenceRevision?: string }; status: NativeSddStatusV2 | ReturnType<typeof resolveSddStatus> }> {
1819
+ const selection = resolveSddChangeSelection(serialized, cwd, agentName);
1820
+ if (selection.phase === "sync") {
1821
+ const status = localResolver({ cwd: selection.workspaceRoot, workspaceRoot: selection.workspaceRoot, changeName: selection.changeName, includeInstructions: true });
1822
+ if (status.actionContext.workspaceRoot !== selection.workspaceRoot || status.changeName !== selection.changeName) {
1823
+ throw new Error("SDD selection resolver returned a mismatched status.");
1824
+ }
1825
+ return { selection, status };
1826
+ }
1827
+ if (native?.sddStatus === undefined) throw new Error("SDD selection native status is unavailable.");
1828
+ let status: NativeSddStatusV2;
1829
+ try {
1830
+ status = decodeNativeSddStatusV2(
1831
+ await native.sddStatus({ changeName: selection.changeName, workspaceRoot: selection.workspaceRoot }),
1832
+ { changeName: selection.changeName, workspaceRoot: selection.workspaceRoot },
1833
+ );
1834
+ } catch (error) {
1835
+ throw new Error(`SDD selection native status is blocked: ${error instanceof Error ? error.message : String(error)}`);
1836
+ }
1837
+ if ((selection.phase !== "remediate" && !(selection.phase in status.dependencies)) || status.phaseInstructions === undefined || !(selection.phase in status.phaseInstructions)) {
1838
+ throw new Error(`SDD selection native status cannot represent phase ${selection.phase}.`);
1839
+ }
1840
+ if (selection.phase === "remediate") {
1841
+ if (status.nextRecommended !== "remediate" || status.remediationState?.failedEvidenceRevision !== selection.failedEvidenceRevision) throw new Error("Stale remediation selection");
1842
+ } else if (status.nextRecommended !== selection.phase || status.dependencies[selection.phase] !== "ready" || status.blockedReasons.length > 0) {
1843
+ throw new Error(`SDD selection native status blocks phase ${selection.phase}; it cannot execute.`);
1844
+ }
1845
+ return { selection, status };
1846
+ }
1847
+
1848
+ function readSddChangeFlag(pi: ExtensionAPI): unknown {
1849
+ try {
1850
+ const value = (pi as unknown as { getFlag?: (name: string) => unknown }).getFlag?.(SDD_CHANGE_FLAG);
1851
+ return value === false ? undefined : value;
1852
+ } catch {
1853
+ return null;
1854
+ }
1158
1855
  }
1159
1856
 
1160
1857
  function normalizePolicyPath(value: string): string {
@@ -1274,7 +1971,8 @@ async function confirmCommand(
1274
1971
  herdrLifecycle: HerdrConfirmationLifecycle,
1275
1972
  ): Promise<ToolCallEventResult | undefined> {
1276
1973
  const guardrailsConfig = loadRuntimeGuardrailsConfig(ctx.cwd);
1277
- const classification = classifyGuardedCommand(command, guardrailsConfig);
1974
+ const evaluation = evaluateGuardedCommand(command, guardrailsConfig);
1975
+ const { action: classification } = evaluation;
1278
1976
 
1279
1977
  if (classification === "block") {
1280
1978
  return {
@@ -1297,11 +1995,8 @@ async function confirmCommand(
1297
1995
  "Gentle AI safety policy requires interactive confirmation before this command.",
1298
1996
  };
1299
1997
  }
1300
- const preview = truncateToWidth(
1301
- command.replace(/\s+/g, " ").trim(),
1302
- 180,
1303
- "…",
1304
- );
1998
+ const title = guardedCommandTitle(evaluation.key, evaluation.matches);
1999
+ const preview = guardedCommandPreview(command, evaluation.triggerIndex);
1305
2000
  const requestId = randomUUID();
1306
2001
  const emitPermissionRequest = (
1307
2002
  state: "waiting" | "approved" | "denied",
@@ -1320,7 +2015,7 @@ async function confirmCommand(
1320
2015
  emitPermissionRequest("waiting");
1321
2016
  herdrLifecycle.begin();
1322
2017
  try {
1323
- approved = await ctx.ui.confirm("Allow guarded command?", preview);
2018
+ approved = await ctx.ui.confirm(title, preview);
1324
2019
  } catch (error) {
1325
2020
  confirmationFailed = true;
1326
2021
  confirmationError = error;
@@ -1452,7 +2147,10 @@ function parseModelExport(value: unknown): AgentModelConfig | undefined {
1452
2147
  }
1453
2148
 
1454
2149
  async function exportSavedModelConfig(ctx: ExtensionContext): Promise<number> {
1455
- const saved = await readSavedModelConfigAsync(ctx.cwd);
2150
+ const saved = await readModelRoutingAuthorityAsync(
2151
+ modelConfigPath(ctx.cwd),
2152
+ legacyProjectModelConfigPath(ctx.cwd),
2153
+ );
1456
2154
  if (saved.status === "invalid") throw new Error(`Invalid model config: ${saved.path}`);
1457
2155
  const agents = saved.status === "valid" ? saved.config : {};
1458
2156
  const path = modelExportPath(ctx.cwd);
@@ -1603,6 +2301,49 @@ async function listAgentsFromDirAsync(
1603
2301
  return entries;
1604
2302
  }
1605
2303
 
2304
+ interface DiscoverableNonBuiltinAgentRoot {
2305
+ dir: string;
2306
+ source: AgentSource;
2307
+ /** The package installer owns this directory, so packageAssetAudit reports it. */
2308
+ packageManaged: boolean;
2309
+ }
2310
+
2311
+ function discoverableNonBuiltinAgentRoots(cwd: string): DiscoverableNonBuiltinAgentRoot[] {
2312
+ const globalAgentHome = gentlePiAgentHome();
2313
+ const roots: DiscoverableNonBuiltinAgentRoot[] = [
2314
+ { dir: join(globalAgentHome, "agents"), source: "user", packageManaged: true },
2315
+ { dir: join(globalAgentHome, "subagents"), source: "user", packageManaged: false },
2316
+ { dir: join(homedir(), ".agents"), source: "user", packageManaged: false },
2317
+ { dir: join(cwd, ".agents"), source: "project", packageManaged: false },
2318
+ { dir: join(cwd, ".pi", "agents"), source: "project", packageManaged: false },
2319
+ { dir: join(cwd, ".pi", "subagents"), source: "project", packageManaged: false },
2320
+ ];
2321
+ const unique = new Map<string, DiscoverableNonBuiltinAgentRoot>();
2322
+ for (const root of roots) {
2323
+ let canonical: string;
2324
+ try {
2325
+ canonical = realpathSync(root.dir);
2326
+ } catch {
2327
+ canonical = resolve(root.dir);
2328
+ }
2329
+ const existing = unique.get(canonical);
2330
+ if (existing) {
2331
+ // Reinsert so a later alias keeps true later-root precedence even when
2332
+ // another physical root appears between the duplicate entries. A merged
2333
+ // package-managed root must keep its installer-owned path: ownership
2334
+ // updates validate that lexical path against the managed manifest root.
2335
+ const managedRoot = existing.packageManaged ? existing : root.packageManaged ? root : undefined;
2336
+ unique.delete(canonical);
2337
+ unique.set(canonical, {
2338
+ dir: managedRoot?.dir ?? root.dir,
2339
+ source: root.source,
2340
+ packageManaged: managedRoot !== undefined,
2341
+ });
2342
+ } else unique.set(canonical, root);
2343
+ }
2344
+ return [...unique.values()];
2345
+ }
2346
+
1606
2347
  function builtinAgentDirs(cwd: string): string[] {
1607
2348
  return [
1608
2349
  join(PACKAGE_ROOT, "..", "pi-subagents-j0k3r", "agents"),
@@ -1633,16 +2374,12 @@ async function listBuiltinAgentNamesAsync(cwd: string): Promise<Set<string>> {
1633
2374
  }
1634
2375
 
1635
2376
  function listDiscoverableAgents(cwd: string): AgentEntry[] {
1636
- const globalAgentHome = gentlePiAgentHome();
1637
2377
  const builtinDirs = builtinAgentDirs(cwd);
1638
2378
  const agents = [
1639
2379
  ...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")),
1640
- ...listAgentsFromDir(join(globalAgentHome, "agents"), "user"),
1641
- ...listAgentsFromDir(join(globalAgentHome, "subagents"), "user"),
1642
- ...listAgentsFromDir(join(homedir(), ".agents"), "user"),
1643
- ...listAgentsFromDir(join(cwd, ".agents"), "project"),
1644
- ...listAgentsFromDir(join(cwd, ".pi", "agents"), "project"),
1645
- ...listAgentsFromDir(join(cwd, ".pi", "subagents"), "project"),
2380
+ ...discoverableNonBuiltinAgentRoots(cwd).flatMap(({ dir, source }) =>
2381
+ listAgentsFromDir(dir, source),
2382
+ ),
1646
2383
  ];
1647
2384
  const byName = new Map<string, AgentEntry>();
1648
2385
  for (const agent of agents) byName.set(agent.name, agent);
@@ -1650,21 +2387,12 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
1650
2387
  }
1651
2388
 
1652
2389
  async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
1653
- const globalAgentHome = gentlePiAgentHome();
1654
2390
  const builtinDirs = builtinAgentDirs(cwd);
1655
2391
  const agents: AgentEntry[] = [];
1656
2392
  for (const dir of builtinDirs) {
1657
2393
  agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
1658
2394
  }
1659
- const otherDirs: Array<[string, AgentSource]> = [
1660
- [join(globalAgentHome, "agents"), "user"],
1661
- [join(globalAgentHome, "subagents"), "user"],
1662
- [join(homedir(), ".agents"), "user"],
1663
- [join(cwd, ".agents"), "project"],
1664
- [join(cwd, ".pi", "agents"), "project"],
1665
- [join(cwd, ".pi", "subagents"), "project"],
1666
- ];
1667
- for (const [dir, source] of otherDirs) {
2395
+ for (const { dir, source } of discoverableNonBuiltinAgentRoots(cwd)) {
1668
2396
  agents.push(...(await listAgentsFromDirAsync(dir, source)));
1669
2397
  }
1670
2398
  const byName = new Map<string, AgentEntry>();
@@ -1781,6 +2509,15 @@ function projectSettingsPath(cwd: string): string {
1781
2509
  return join(cwd, ".pi", "settings.json");
1782
2510
  }
1783
2511
 
2512
+ /**
2513
+ * Pi's own global settings file, which is where the orchestrator model lives.
2514
+ * Profiles own the three `default*` keys there; nothing else in this extension
2515
+ * reads or writes that file.
2516
+ */
2517
+ function orchestratorSettingsPath(): string {
2518
+ return join(gentlePiAgentHome(), "settings.json");
2519
+ }
2520
+
1784
2521
  function removeLegacyAgentOverridesFromSettings(
1785
2522
  settingsPath: string,
1786
2523
  settings: Record<string, unknown>,
@@ -1805,6 +2542,30 @@ function isValidJsonObjectFileOrMissing(path: string): boolean {
1805
2542
  }
1806
2543
  }
1807
2544
 
2545
+ const PROVIDER_REVIEW_ROLES = ["review-refuter", "review-validator"] as const;
2546
+
2547
+ function isProviderReviewRole(name: string): boolean {
2548
+ return PROVIDER_REVIEW_ROLES.some((role) => role === name);
2549
+ }
2550
+
2551
+ function modelAssignmentNames(cwd: string): string[] {
2552
+ return [...new Set([
2553
+ ...PROVIDER_REVIEW_ROLES,
2554
+ ...listDiscoverableAgents(cwd).map((agent) => agent.name),
2555
+ ])];
2556
+ }
2557
+
2558
+ const PROVIDER_ROUTING_DEFAULT_LABELS = {
2559
+ model: "Pi persisted default model",
2560
+ effort: "Pi persisted default effort",
2561
+ } as const;
2562
+
2563
+ type RoutingDefaultField = keyof typeof PROVIDER_ROUTING_DEFAULT_LABELS;
2564
+
2565
+ function routingDefaultLabel(name: string, field: RoutingDefaultField): string {
2566
+ return isProviderReviewRole(name) ? PROVIDER_ROUTING_DEFAULT_LABELS[field] : "inherit";
2567
+ }
2568
+
1808
2569
  function migrateLegacyProjectModelOverrides(cwd: string): number {
1809
2570
  const settingsPath = projectSettingsPath(cwd);
1810
2571
  if (!existsSync(settingsPath)) return 0;
@@ -1823,6 +2584,7 @@ function migrateLegacyProjectModelOverrides(cwd: string): number {
1823
2584
  if (!agentOverrides) return 0;
1824
2585
  const agentsByName = new Map(listDiscoverableAgents(cwd).map((agent) => [agent.name, agent]));
1825
2586
  const migratableEntries = Object.entries(agentOverrides)
2587
+ .filter(([name]) => !isProviderReviewRole(name))
1826
2588
  .map(([name, value]) => ({ name, entry: normalizeRoutingEntry(value) }))
1827
2589
  .filter((item): item is { name: string; entry: AgentRoutingEntry } =>
1828
2590
  item.entry !== undefined && !isClearRoutingEntry(item.entry),
@@ -1865,30 +2627,40 @@ export function applyModelConfig(
1865
2627
  let skipped = 0;
1866
2628
  const seenAgents = new Set<string>();
1867
2629
  for (const agent of listDiscoverableAgents(cwd)) {
2630
+ if (isProviderReviewRole(agent.name)) continue;
1868
2631
  seenAgents.add(agent.name);
1869
2632
  const entry = config[agent.name];
1870
2633
  if (entry === undefined) {
1871
2634
  skipped += 1;
1872
2635
  continue;
1873
2636
  }
1874
- if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1;
1875
- else skipped += 1;
1876
- if (agent.source === "builtin") continue;
1877
- if (!agent.filePath || !existsSync(agent.filePath)) {
1878
- skipped += 1;
2637
+ if (agent.source === "builtin") {
2638
+ if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1;
2639
+ else skipped += 1;
1879
2640
  continue;
1880
2641
  }
1881
- const original = readFileSync(agent.filePath, "utf8");
1882
- const next = updateFrontmatterRouting(original, entry);
1883
- if (next === original) {
2642
+ if (!agent.filePath || !existsSync(agent.filePath)) {
1884
2643
  skipped += 1;
1885
- continue;
2644
+ } else {
2645
+ const original = readFileSync(agent.filePath, "utf8");
2646
+ const next = updateFrontmatterRouting(original, entry);
2647
+ if (next === original) {
2648
+ skipped += 1;
2649
+ } else {
2650
+ if (!updatePackageManagedSddAgentOwnership(agent.filePath, original, next)) {
2651
+ writeFileSync(agent.filePath, next);
2652
+ }
2653
+ updated += 1;
2654
+ }
1886
2655
  }
1887
- writeFileSync(agent.filePath, next);
1888
- updatePackageManagedSddAgentOwnership(agent.filePath, original, next);
1889
- updated += 1;
2656
+ if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1;
2657
+ else skipped += 1;
1890
2658
  }
1891
2659
  for (const [name, entry] of Object.entries(config)) {
2660
+ if (isProviderReviewRole(name)) continue;
2661
+ // The orchestrator is routing, not an agent: its model lives in Pi's global
2662
+ // settings.json and must never reach subagents.json.
2663
+ if (isProfileOrchestratorKey(name)) continue;
1892
2664
  if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
1893
2665
  if (updateSubagentModelProfile(cwd, "user", name, entry)) updated += 1;
1894
2666
  else skipped += 1;
@@ -1905,31 +2677,40 @@ export async function applyModelConfigAsync(
1905
2677
  let skipped = 0;
1906
2678
  const seenAgents = new Set<string>();
1907
2679
  for (const agent of await listDiscoverableAgentsAsync(cwd)) {
2680
+ if (isProviderReviewRole(agent.name)) continue;
1908
2681
  seenAgents.add(agent.name);
1909
2682
  const entry = config[agent.name];
1910
2683
  if (entry === undefined) {
1911
2684
  skipped += 1;
1912
2685
  continue;
1913
2686
  }
1914
- if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry))
1915
- updated += 1;
1916
- else skipped += 1;
1917
- if (agent.source === "builtin") continue;
1918
- if (!agent.filePath || !(await pathExists(agent.filePath))) {
1919
- skipped += 1;
1920
- continue;
2687
+ if (agent.source === "builtin") {
2688
+ if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry))
2689
+ updated += 1;
2690
+ else skipped += 1;
2691
+ continue;
1921
2692
  }
1922
- const original = await readFile(agent.filePath, "utf8");
1923
- const next = updateFrontmatterRouting(original, entry);
1924
- if (next === original) {
2693
+ if (!agent.filePath || !(await pathExists(agent.filePath))) {
1925
2694
  skipped += 1;
1926
- continue;
2695
+ } else {
2696
+ const original = await readFile(agent.filePath, "utf8");
2697
+ const next = updateFrontmatterRouting(original, entry);
2698
+ if (next === original) {
2699
+ skipped += 1;
2700
+ } else {
2701
+ if (!updatePackageManagedSddAgentOwnership(agent.filePath, original, next)) {
2702
+ await writeFile(agent.filePath, next);
2703
+ }
2704
+ updated += 1;
2705
+ }
1927
2706
  }
1928
- await writeFile(agent.filePath, next);
1929
- updatePackageManagedSddAgentOwnership(agent.filePath, original, next);
1930
- updated += 1;
2707
+ if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry))
2708
+ updated += 1;
2709
+ else skipped += 1;
1931
2710
  }
1932
2711
  for (const [name, entry] of Object.entries(config)) {
2712
+ if (isProviderReviewRole(name)) continue;
2713
+ if (isProfileOrchestratorKey(name)) continue;
1933
2714
  if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
1934
2715
  if (await updateSubagentModelProfileAsync(cwd, "user", name, entry))
1935
2716
  updated += 1;
@@ -1957,11 +2738,11 @@ export async function applySavedModelConfig(
1957
2738
  }
1958
2739
 
1959
2740
  function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
1960
- return listDiscoverableAgents(cwd).map((agent) => {
1961
- const entry = config[agent.name];
1962
- const model = entry?.model ?? "inherit";
1963
- const thinking = entry?.thinking ?? "inherit";
1964
- return `${sanitizeTerminalText(agent.name)}: model=${sanitizeTerminalText(model)}, effort=${sanitizeTerminalText(thinking)}`;
2741
+ return modelAssignmentNames(cwd).map((name) => {
2742
+ const entry = config[name];
2743
+ const model = entry?.model ?? routingDefaultLabel(name, "model");
2744
+ const thinking = entry?.thinking ?? routingDefaultLabel(name, "effort");
2745
+ return `${sanitizeTerminalText(name)}: model=${sanitizeTerminalText(model)}, effort=${sanitizeTerminalText(thinking)}`;
1965
2746
  });
1966
2747
  }
1967
2748
 
@@ -2329,7 +3110,7 @@ class SddModelPanel implements OverlayComponent {
2329
3110
  lines.push("");
2330
3111
  lines.push(
2331
3112
  line(
2332
- "j/k scroll • enter model/save • e effort • i inherit • c custom • x export • r restore • ctrl+s save • esc back",
3113
+ `j/k scroll • enter model/save • e effort • i ${isProviderReviewRole(this.rows[this.cursor] ?? "") ? "Pi persisted defaults" : "inherit"} • c custom • x export • r restore • ctrl+s save • esc back`,
2333
3114
  "muted",
2334
3115
  ),
2335
3116
  );
@@ -2361,7 +3142,9 @@ class SddModelPanel implements OverlayComponent {
2361
3142
  const focused = i === this.modelCursor;
2362
3143
  lines.push(
2363
3144
  `${this.renderCursor(focused)} ${this.renderText(
2364
- options[i] ?? "",
3145
+ options[i] === INHERIT_MODEL && isProviderReviewRole(this.selectedRow)
3146
+ ? routingDefaultLabel(this.selectedRow, "model")
3147
+ : (options[i] ?? ""),
2365
3148
  focused ? "status" : "text",
2366
3149
  )}`,
2367
3150
  );
@@ -2413,7 +3196,9 @@ class SddModelPanel implements OverlayComponent {
2413
3196
  const focused = i === this.effortCursor;
2414
3197
  lines.push(
2415
3198
  `${this.renderCursor(focused)} ${this.renderText(
2416
- THINKING_OPTIONS[i] ?? "",
3199
+ THINKING_OPTIONS[i] === INHERIT_THINKING && isProviderReviewRole(this.selectedRow)
3200
+ ? routingDefaultLabel(this.selectedRow, "effort")
3201
+ : (THINKING_OPTIONS[i] ?? ""),
2417
3202
  focused ? "status" : "text",
2418
3203
  )}`,
2419
3204
  );
@@ -2426,10 +3211,10 @@ class SddModelPanel implements OverlayComponent {
2426
3211
  private renderSetAllLabel(row: string): string {
2427
3212
  const models = this.rows
2428
3213
  .slice(1)
2429
- .map((name) => this.draft[name]?.model ?? "inherit");
3214
+ .map((name) => this.draft[name]?.model ?? routingDefaultLabel(name, "model"));
2430
3215
  const efforts = this.rows
2431
3216
  .slice(1)
2432
- .map((name) => this.draft[name]?.thinking ?? "inherit");
3217
+ .map((name) => this.draft[name]?.thinking ?? routingDefaultLabel(name, "effort"));
2433
3218
  const firstModel = models[0] ?? "inherit";
2434
3219
  const firstEffort = efforts[0] ?? "inherit";
2435
3220
  const modelLabel = models.every((value) => value === firstModel)
@@ -2445,8 +3230,8 @@ class SddModelPanel implements OverlayComponent {
2445
3230
  }
2446
3231
 
2447
3232
  private renderAgentLabel(row: string): string {
2448
- const model = this.draft[row]?.model ?? "inherit";
2449
- const effort = this.draft[row]?.thinking ?? "inherit";
3233
+ const model = this.draft[row]?.model ?? routingDefaultLabel(row, "model");
3234
+ const effort = this.draft[row]?.thinking ?? routingDefaultLabel(row, "effort");
2450
3235
  return `${this.renderText(sanitizeTerminalText(row).padEnd(20), "text")} ${this.renderText("model=", "muted")}${this.renderText(model, "status")}${this.renderText(
2451
3236
  ", effort=",
2452
3237
  "muted",
@@ -2471,7 +3256,7 @@ async function showSddModelPanel(
2471
3256
  config: AgentModelConfig,
2472
3257
  ): Promise<ModelPanelResult> {
2473
3258
  const modelOptions = await getPiModelOptions(ctx);
2474
- const agents = listDiscoverableAgents(ctx.cwd).map((agent) => agent.name);
3259
+ const agents = modelAssignmentNames(ctx.cwd);
2475
3260
  return ctx.ui.custom<ModelPanelResult>(
2476
3261
  (_tui, theme, _keybindings, done) =>
2477
3262
  new SddModelPanel(config, modelOptions, agents, done, theme),
@@ -2489,7 +3274,10 @@ async function showSddModelPanel(
2489
3274
 
2490
3275
  async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
2491
3276
  migrateLegacyProjectModelOverrides(ctx.cwd);
2492
- const savedConfig = await readSavedModelConfigAsync(ctx.cwd);
3277
+ const savedConfig = await readModelRoutingAuthorityAsync(
3278
+ modelConfigPath(ctx.cwd),
3279
+ legacyProjectModelConfigPath(ctx.cwd),
3280
+ );
2493
3281
  if (savedConfig.status === "invalid") {
2494
3282
  ctx.ui.notify(
2495
3283
  `el Gentleman cannot open model config because ${savedConfig.path} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`,
@@ -2569,9 +3357,9 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
2569
3357
  }
2570
3358
  if (result.agent === "all") {
2571
3359
  const next: AgentModelConfig = { ...config };
2572
- for (const agent of listDiscoverableAgents(ctx.cwd)) {
2573
- next[agent.name] = {
2574
- ...(next[agent.name] ?? {}),
3360
+ for (const name of modelAssignmentNames(ctx.cwd)) {
3361
+ next[name] = {
3362
+ ...(next[name] ?? {}),
2575
3363
  model,
2576
3364
  };
2577
3365
  }
@@ -2602,6 +3390,667 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
2602
3390
  );
2603
3391
  }
2604
3392
 
3393
+ type ProfilesPanelResult =
3394
+ | { type: "apply"; name: string }
3395
+ | { type: "create" }
3396
+ | { type: "update"; name: string }
3397
+ | { type: "duplicate"; name: string }
3398
+ | { type: "rename"; name: string }
3399
+ | { type: "delete"; name: string }
3400
+ | { type: "export"; name: string }
3401
+ | { type: "import" }
3402
+ | { type: "close" };
3403
+
3404
+ const PROFILES_PANEL_MIN_BODY_ROWS = 6;
3405
+
3406
+ type ProfilesPanelPointerLayout = AgentsViewLayout & { listTop: number };
3407
+
3408
+ function hasOwnProfile(profiles: Record<string, unknown>, name: string): boolean {
3409
+ return Object.prototype.hasOwnProperty.call(profiles, name);
3410
+ }
3411
+
3412
+ function profilesErrorMessage(error: unknown): string {
3413
+ return error instanceof Error ? error.message : String(error);
3414
+ }
3415
+
3416
+ /** Keep a detail-pane scroll offset inside the bounds of its own content. */
3417
+ function clampDetailScroll(offset: number, lineCount: number, bodyRows: number): number {
3418
+ return Math.max(0, Math.min(offset, Math.max(0, lineCount - bodyRows)));
3419
+ }
3420
+
3421
+ // Left profile list, right detail panes. Rendering and pointer routing share
3422
+ // one measured layout; the list rows keep native keyboard, hover, press,
3423
+ // click, and wheel handling through NativeChoiceList. The frame takes the full
3424
+ // overlay, so its height follows the terminal rows the same way AgentsView does.
3425
+ class ProfilesPanel implements OverlayComponent {
3426
+ private completed = false;
3427
+ private pointerLayout: ProfilesPanelPointerLayout | undefined;
3428
+ private detailScroll = 0;
3429
+ private lastDetailLineCount = 0;
3430
+ private lastDetailRows = 0;
3431
+ private lastSelectedId: string | undefined;
3432
+ readonly list: NativeChoiceList<ProfileListItem>;
3433
+ private readonly file: AgentProfilesFile;
3434
+ private readonly currentConfig: AgentModelConfig;
3435
+ private readonly done: (result: ProfilesPanelResult) => void;
3436
+ private readonly theme: Theme | undefined;
3437
+ private readonly rows: () => number;
3438
+ private readonly orchestratorSettings: OrchestratorSettingsReadResult;
3439
+
3440
+ constructor(
3441
+ file: AgentProfilesFile,
3442
+ currentConfig: AgentModelConfig,
3443
+ done: (result: ProfilesPanelResult) => void,
3444
+ keybindings: KeybindingsManager | undefined,
3445
+ theme: Theme | undefined,
3446
+ selectedName: string | undefined,
3447
+ rows: () => number,
3448
+ orchestratorSettings: OrchestratorSettingsReadResult,
3449
+ ) {
3450
+ this.file = file;
3451
+ this.currentConfig = currentConfig;
3452
+ this.done = done;
3453
+ this.theme = theme;
3454
+ this.rows = rows;
3455
+ this.orchestratorSettings = orchestratorSettings;
3456
+ const items = buildProfileListItems(file);
3457
+ this.list = new NativeChoiceList<ProfileListItem>(
3458
+ items,
3459
+ {
3460
+ selectedPrefix: (text) => this.renderText(text, "accent"),
3461
+ selectedText: (text) => this.renderText(text, "accent"),
3462
+ description: (text) => this.renderText(text, "muted"),
3463
+ hoverBackground: (text) => (this.theme ? this.theme.bg("toolPendingBg", text) : text),
3464
+ },
3465
+ keybindings,
3466
+ );
3467
+ this.list.onSelect = (item) => this.finish({ type: "apply", name: item.id });
3468
+ this.list.onCancel = () => this.finish({ type: "close" });
3469
+ const selected = selectedName ? items.findIndex((item) => item.id === selectedName) : -1;
3470
+ if (selected >= 0) this.list.setSelectedIndex(selected);
3471
+ }
3472
+
3473
+ invalidate(): void {
3474
+ this.pointerLayout = undefined;
3475
+ this.list.invalidate();
3476
+ }
3477
+
3478
+ handleInput(data: string): void {
3479
+ if (isKeyRelease(data)) return;
3480
+ if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
3481
+ this.finish({ type: "close" });
3482
+ return;
3483
+ }
3484
+ const name = this.list.getSelectedItem()?.id;
3485
+ if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("j"))) {
3486
+ this.scrollDetail(this.pageRows());
3487
+ return;
3488
+ }
3489
+ if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("k"))) {
3490
+ this.scrollDetail(-this.pageRows());
3491
+ return;
3492
+ }
3493
+ // Agents-view line scroll: j/k move the routing one line at a time. The
3494
+ // arrow keys stay with the profile list, exactly as the letters below stay
3495
+ // profile actions.
3496
+ if (data === "j") {
3497
+ this.scrollDetail(1);
3498
+ return;
3499
+ }
3500
+ if (data === "k") {
3501
+ this.scrollDetail(-1);
3502
+ return;
3503
+ }
3504
+ if (data === "c") return this.finish({ type: "create" });
3505
+ if (data === "i") return this.finish({ type: "import" });
3506
+ if (!name) return this.list.handleInput(data);
3507
+ if (data === "s") return this.finish({ type: "update", name });
3508
+ if (data === "d") return this.finish({ type: "duplicate", name });
3509
+ if (data === "r") return this.finish({ type: "rename", name });
3510
+ if (data === "x") return this.finish({ type: "delete", name });
3511
+ if (data === "e") return this.finish({ type: "export", name });
3512
+ this.list.handleInput(data);
3513
+ }
3514
+
3515
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
3516
+ const layout = this.pointerLayout;
3517
+ if (!layout) return undefined;
3518
+ const inBody = event.y >= layout.listTop && event.y < layout.listTop + layout.bodyRows;
3519
+ if (
3520
+ inBody &&
3521
+ event.type === "wheel" &&
3522
+ event.wheelDelta &&
3523
+ layout.mode === "panes" &&
3524
+ event.x >= layout.threadX &&
3525
+ event.x < layout.threadX + layout.threadWidth
3526
+ ) {
3527
+ this.scrollDetail(event.wheelDelta);
3528
+ return { handled: true, render: true };
3529
+ }
3530
+ if (!inBody) return undefined;
3531
+ if (event.x < layout.listX || event.x >= layout.listX + layout.listWidth) return undefined;
3532
+ return this.list.handleMouse({
3533
+ ...event,
3534
+ x: event.x - layout.listX,
3535
+ y: event.y - layout.listTop,
3536
+ width: layout.listWidth,
3537
+ height: layout.bodyRows,
3538
+ });
3539
+ }
3540
+
3541
+ render(width: number): string[] {
3542
+ const safeWidth = Math.max(1, width);
3543
+ const listWidth = measureAgentsViewLayout(safeWidth, PROFILES_PANEL_MIN_BODY_ROWS + 3).listWidth;
3544
+ const listLines = this.list.render(listWidth);
3545
+ // The frame fills the overlay, and the overlay fills the terminal, so the
3546
+ // body height comes from the terminal rows rather than from the content.
3547
+ const rows = Math.max(PROFILES_PANEL_MIN_BODY_ROWS + 3, Math.floor(this.rows()));
3548
+ const layout = measureAgentsViewLayout(safeWidth, rows);
3549
+ if (layout.mode === "fallback" || layout.width === 0) {
3550
+ this.pointerLayout = undefined;
3551
+ return [];
3552
+ }
3553
+ const selectedId = this.list.getSelectedItem()?.id;
3554
+ if (selectedId !== this.lastSelectedId) {
3555
+ this.lastSelectedId = selectedId;
3556
+ this.detailScroll = 0;
3557
+ }
3558
+ const detailLines = this.renderDetailLines(layout.threadWidth, layout.mode === "panes");
3559
+ this.lastDetailLineCount = detailLines.length;
3560
+ this.lastDetailRows = layout.bodyRows;
3561
+ this.detailScroll = clampDetailScroll(this.detailScroll, detailLines.length, layout.bodyRows);
3562
+ this.pointerLayout = { ...layout, listTop: 1 };
3563
+ const rule = "─".repeat(Math.max(0, layout.width - 2));
3564
+ const lines: string[] = [this.renderText(`╭${rule}╮`, "border")];
3565
+ for (let row = 0; row < layout.bodyRows; row += 1) {
3566
+ lines.push(this.renderBodyRow(row, layout, listLines, detailLines));
3567
+ }
3568
+ lines.push(this.renderFooterRow(layout.width));
3569
+ lines.push(this.renderText(`╰${rule}╯`, "border"));
3570
+ return lines;
3571
+ }
3572
+
3573
+ private scrollDetail(delta: number): void {
3574
+ this.detailScroll = clampDetailScroll(
3575
+ this.detailScroll + Math.trunc(delta),
3576
+ this.lastDetailLineCount,
3577
+ this.lastDetailRows,
3578
+ );
3579
+ }
3580
+
3581
+ private pageRows(): number {
3582
+ return Math.max(1, this.lastDetailRows - 1);
3583
+ }
3584
+
3585
+ private finish(result: ProfilesPanelResult): void {
3586
+ if (this.completed) return;
3587
+ this.completed = true;
3588
+ this.list.setDisabled(true);
3589
+ this.done(result);
3590
+ }
3591
+
3592
+ private renderBodyRow(
3593
+ row: number,
3594
+ layout: AgentsViewLayout,
3595
+ listLines: string[],
3596
+ detailLines: string[],
3597
+ ): string {
3598
+ if (layout.mode === "panes") {
3599
+ return [
3600
+ this.renderText("│", "border"),
3601
+ " ",
3602
+ this.fitPaneLine(listLines[row] ?? "", layout.listWidth),
3603
+ " ",
3604
+ this.renderText("│", "border"),
3605
+ " ",
3606
+ this.fitPaneLine(detailLines[this.detailScroll + row] ?? "", layout.threadWidth),
3607
+ this.renderText("│", "border"),
3608
+ ].join("");
3609
+ }
3610
+ return [
3611
+ this.renderText("│", "border"),
3612
+ " ",
3613
+ this.fitPaneLine(listLines[row] ?? "", layout.listWidth),
3614
+ " ",
3615
+ this.renderText("│", "border"),
3616
+ ].join("");
3617
+ }
3618
+
3619
+ private renderFooterRow(width: number): string {
3620
+ const hints =
3621
+ "enter apply · c create · s update · d duplicate · r rename · x delete · e export · i import · j/k line · ctrl+j/k page · esc close";
3622
+ return [
3623
+ this.renderText("│", "border"),
3624
+ " ",
3625
+ this.fitPaneLine(this.renderText(hints, "muted"), width - 4),
3626
+ " ",
3627
+ this.renderText("│", "border"),
3628
+ ].join("");
3629
+ }
3630
+
3631
+ private renderDetailLines(width: number, showDetail: boolean): string[] {
3632
+ if (!showDetail) return [];
3633
+ const name = this.list.getSelectedItem()?.id;
3634
+ if (!name || !hasOwnProfile(this.file.profiles, name)) {
3635
+ return [this.renderLine("No profile selected.", width, "muted")];
3636
+ }
3637
+ const config = this.file.profiles[name];
3638
+ const profileRows = profileRoutingRows(config);
3639
+ const currentRows = profileRoutingRows(this.currentConfig);
3640
+ // One shared measurement across both tables, so the same agent sits in the
3641
+ // same column whether it comes from the profile or from models.json.
3642
+ const widths = routingColumnWidths(profileRows, currentRows);
3643
+ return [
3644
+ this.renderLine(name === this.file.active ? `${name} (active)` : name, width, "title"),
3645
+ this.renderLine(
3646
+ `orchestrator ${formatOrchestratorSelection(readProfileOrchestrator(config))}`,
3647
+ width,
3648
+ "text",
3649
+ ),
3650
+ this.renderLine(`now ${this.effectiveOrchestratorLabel()}`, width, "muted"),
3651
+ "",
3652
+ this.renderLine("Profile routing", width, "accent"),
3653
+ ...this.indentLines(this.routingLines(profileRows, widths), width),
3654
+ "",
3655
+ this.renderLine("Current routing (models.json)", width, "accent"),
3656
+ ...this.indentLines(this.routingLines(currentRows, widths), width),
3657
+ ];
3658
+ }
3659
+
3660
+ private effectiveOrchestratorLabel(): string {
3661
+ const settings = this.orchestratorSettings;
3662
+ if (settings.status === "invalid") {
3663
+ return `unreadable (${sanitizeTerminalText(settings.reason)})`;
3664
+ }
3665
+ return formatOrchestratorSelection(settings.status === "valid" ? settings.entry : undefined);
3666
+ }
3667
+
3668
+ private routingLines(
3669
+ rows: ProfileRoutingRow[],
3670
+ widths: { agent: number; model: number },
3671
+ ): string[] {
3672
+ if (rows.length === 0) {
3673
+ return ["No routing entries — every agent inherits its default model."];
3674
+ }
3675
+ return rows.map((row) => formatRoutingRow(row, widths));
3676
+ }
3677
+
3678
+ private indentLines(lines: string[], width: number): string[] {
3679
+ return lines.map((line) => this.renderLine(` ${line}`, width, "text"));
3680
+ }
3681
+
3682
+ private fitPaneLine(line: string, width: number): string {
3683
+ const visible = stripAnsi(line);
3684
+ if (visible.length > width) {
3685
+ return truncateToWidth(visible, Math.max(1, width), "…", true);
3686
+ }
3687
+ return `${line}${" ".repeat(Math.max(0, width - visible.length))}`;
3688
+ }
3689
+
3690
+ private renderLine(text: string, width: number, tone?: PanelTone): string {
3691
+ const safe = truncateToWidth(
3692
+ sanitizeTerminalText(text),
3693
+ Math.max(1, width),
3694
+ "…",
3695
+ true,
3696
+ );
3697
+ return tone ? this.renderText(safe, tone) : safe;
3698
+ }
3699
+
3700
+ private renderText(text: string, tone: PanelTone): string {
3701
+ const safe = sanitizeTerminalText(text);
3702
+ if (!this.theme) return safe;
3703
+ return this.theme.fg(PANEL_TONE_COLOR[tone], safe);
3704
+ }
3705
+ }
3706
+
3707
+ async function showProfilesPanel(
3708
+ ctx: ExtensionContext,
3709
+ file: AgentProfilesFile,
3710
+ currentConfig: AgentModelConfig,
3711
+ selectedName?: string,
3712
+ ): Promise<ProfilesPanelResult> {
3713
+ // Read once, for the panel lifetime. The orchestrator shown as "now" is the
3714
+ // state the panel opened on, not a value that changes mid-panel.
3715
+ const orchestratorSettings = readOrchestratorSettings(orchestratorSettingsPath());
3716
+ return ctx.ui.custom<ProfilesPanelResult>(
3717
+ (tui, theme, keybindings, done) => {
3718
+ const panel = new ProfilesPanel(
3719
+ file,
3720
+ currentConfig,
3721
+ done,
3722
+ keybindings,
3723
+ theme,
3724
+ selectedName,
3725
+ () => Math.max(0, tui.terminal.rows),
3726
+ orchestratorSettings,
3727
+ );
3728
+ const container = createNativeFullscreenInteraction({
3729
+ keyboardTarget: panel,
3730
+ requestRender: () => tui.requestRender(),
3731
+ mouseObserver: panel.list.createMouseObserver(() => tui.requestRender()),
3732
+ });
3733
+ container.addChild(panel);
3734
+ return container;
3735
+ },
3736
+ {
3737
+ overlay: true,
3738
+ overlayOptions: {
3739
+ anchor: "center",
3740
+ width: "100%",
3741
+ maxHeight: "100%",
3742
+ margin: 0,
3743
+ },
3744
+ },
3745
+ );
3746
+ }
3747
+
3748
+ function reportProfilesDrops(ctx: ExtensionContext, path: string, drops: ProfilesParseDrops): void {
3749
+ // Dropped names are by definition the ones that failed validation, so they are
3750
+ // untrusted input reaching the terminal and must be sanitized like any other
3751
+ // externally supplied text.
3752
+ const parts: string[] = [];
3753
+ if (drops.droppedProfiles.length > 0) {
3754
+ parts.push(`profiles: ${drops.droppedProfiles.map((name) => sanitizeTerminalText(name)).join(", ")}`);
3755
+ }
3756
+ if (drops.droppedAgents.length > 0) {
3757
+ parts.push(
3758
+ `routing entries: ${drops.droppedAgents.map(({ profile, agent }) => `${sanitizeTerminalText(profile)}/${sanitizeTerminalText(agent)}`).join(", ")}`,
3759
+ );
3760
+ }
3761
+ if (drops.droppedActive !== undefined) {
3762
+ parts.push(`active marker: ${sanitizeTerminalText(drops.droppedActive)}`);
3763
+ }
3764
+ if (parts.length > 0) {
3765
+ ctx.ui.notify(
3766
+ `el Gentleman dropped invalid entries while loading ${sanitizeTerminalText(path)} — ${parts.join("; ")}.`,
3767
+ "warning",
3768
+ );
3769
+ }
3770
+ }
3771
+
3772
+ async function runProfilesPanelAction(
3773
+ ctx: ExtensionContext,
3774
+ path: string,
3775
+ file: AgentProfilesFile,
3776
+ result: Exclude<ProfilesPanelResult, { type: "close" }>,
3777
+ ): Promise<AgentProfilesFile> {
3778
+ switch (result.type) {
3779
+ case "apply": {
3780
+ if (!hasOwnProfile(file.profiles, result.name)) return file;
3781
+ const normalized = normalizeModelConfig(file.profiles[result.name]) ?? {};
3782
+ const orchestratorEntry = readProfileOrchestrator(normalized);
3783
+ // Applying spans three files — the store, models.json, and Pi's global
3784
+ // settings.json — and there is no cross-file rename, so order the writes to
3785
+ // keep the store truthful and compensate on failure: claim the profile in
3786
+ // the store first, then materialise routing, then the orchestrator. A claim
3787
+ // that fails leaves routing untouched; anything that fails after the claim
3788
+ // restores the previous claim and, when the previously active profile is
3789
+ // known, the routing that profile implies.
3790
+ const claimed = setActiveProfile(file, result.name);
3791
+ try {
3792
+ writeProfilesFileSync(path, claimed);
3793
+ } catch (error) {
3794
+ ctx.ui.notify(
3795
+ `el Gentleman could not update ${sanitizeTerminalText(path)}: ${profilesErrorMessage(error)}`,
3796
+ "warning",
3797
+ );
3798
+ return file;
3799
+ }
3800
+ const previousActiveConfig =
3801
+ file.active !== undefined && hasOwnProfile(file.profiles, file.active)
3802
+ ? normalizeModelConfig(file.profiles[file.active]) ?? {}
3803
+ : undefined;
3804
+ // Set only when the orchestrator write succeeded, so the revert knows it has
3805
+ // something to undo. A rollback closure (not a previous-bytes value) is used
3806
+ // because "the file did not exist before" is a real state that must restore
3807
+ // by removing the file, and `undefined` bytes cannot carry that distinction.
3808
+ let orchestratorRollback: (() => void) | undefined;
3809
+ const revertClaim = async (routingWritten: boolean): Promise<AgentProfilesFile> => {
3810
+ let restored = previousActiveConfig === undefined ? "" : "routing";
3811
+ if (routingWritten && previousActiveConfig !== undefined) {
3812
+ try {
3813
+ await writeModelConfigAsync(ctx.cwd, previousActiveConfig);
3814
+ } catch {
3815
+ restored = "";
3816
+ }
3817
+ }
3818
+ if (orchestratorRollback) {
3819
+ try {
3820
+ orchestratorRollback();
3821
+ restored = restored === "" ? "settings" : `${restored} and settings`;
3822
+ } catch {
3823
+ restored = restored === "" ? "" : restored;
3824
+ }
3825
+ }
3826
+ try {
3827
+ writeProfilesFileSync(path, file);
3828
+ restored = restored === "" ? "active marker" : `${restored} and active marker`;
3829
+ } catch {
3830
+ restored = restored === "" ? "nothing" : restored;
3831
+ }
3832
+ const unresolved =
3833
+ routingWritten && previousActiveConfig === undefined
3834
+ ? ` ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} still holds this profile's routing because no previously active profile was recorded to restore.`
3835
+ : "";
3836
+ ctx.ui.notify(
3837
+ `el Gentleman could not apply profile "${result.name}". Restored: ${restored}.${unresolved}`,
3838
+ "warning",
3839
+ );
3840
+ return file;
3841
+ };
3842
+ try {
3843
+ await writeModelConfigAsync(ctx.cwd, normalized);
3844
+ } catch (error) {
3845
+ ctx.ui.notify(
3846
+ `el Gentleman could not write ${sanitizeTerminalText(modelConfigPath(ctx.cwd))}: ${profilesErrorMessage(error)}`,
3847
+ "warning",
3848
+ );
3849
+ return revertClaim(false);
3850
+ }
3851
+ const applyResult = await applySavedModelConfig(ctx);
3852
+ if (applyResult.invalidPath) {
3853
+ return revertClaim(true);
3854
+ }
3855
+ let orchestratorNote = "";
3856
+ if (orchestratorEntry !== undefined) {
3857
+ const settingsPath = orchestratorSettingsPath();
3858
+ const written = applyOrchestratorSettings(settingsPath, orchestratorEntry);
3859
+ if (written.status === "invalid") {
3860
+ ctx.ui.notify(
3861
+ `el Gentleman could not set the orchestrator from profile "${result.name}": ${sanitizeTerminalText(written.reason)}. ${sanitizeTerminalText(settingsPath)} was left unchanged.`,
3862
+ "warning",
3863
+ );
3864
+ return revertClaim(true);
3865
+ }
3866
+ if (written.status === "written") {
3867
+ const previous = written.previous;
3868
+ orchestratorRollback = () => restoreOrchestratorSettings(settingsPath, previous);
3869
+ orchestratorNote = `\nOrchestrator set to ${formatOrchestratorSelection(orchestratorEntry)} in ${sanitizeTerminalText(settingsPath)}.`;
3870
+ }
3871
+ }
3872
+ ctx.ui.notify(
3873
+ [
3874
+ `el Gentleman applied profile "${result.name}" — ${applyResult.updated} agent${applyResult.updated === 1 ? "" : "s"} updated.`,
3875
+ "New routing takes effect on the next subagent launch.",
3876
+ ].join("\n") + orchestratorNote,
3877
+ "info",
3878
+ );
3879
+ return claimed;
3880
+ }
3881
+ case "create": {
3882
+ const name = await ctx.ui.input("New profile name", "e.g. deep-work");
3883
+ if (name === undefined) return file;
3884
+ try {
3885
+ const next = createProfile(file, name.trim(), {});
3886
+ writeProfilesFileSync(path, next);
3887
+ return next;
3888
+ } catch (error) {
3889
+ ctx.ui.notify(`Profile not created: ${profilesErrorMessage(error)}`, "warning");
3890
+ return file;
3891
+ }
3892
+ }
3893
+ case "update": {
3894
+ const current = await readModelConfigAsync(ctx.cwd);
3895
+ // A profile is a complete snapshot, so capturing the current routing also
3896
+ // captures the orchestrator the routing is running under. A settings file
3897
+ // that cannot be read leaves the snapshot without an orchestrator entry
3898
+ // rather than inventing one.
3899
+ const settings = readOrchestratorSettings(orchestratorSettingsPath());
3900
+ const snapshot: AgentModelConfig = cloneModelConfig(current);
3901
+ if (settings.status === "valid" && settings.entry !== undefined) {
3902
+ snapshot[PROFILE_ORCHESTRATOR_KEY] = { ...settings.entry };
3903
+ }
3904
+ try {
3905
+ const next = updateProfile(file, result.name, snapshot);
3906
+ writeProfilesFileSync(path, next);
3907
+ ctx.ui.notify(
3908
+ `el Gentleman updated profile "${result.name}" from the current routing in ${modelConfigPath(ctx.cwd)}.`,
3909
+ "info",
3910
+ );
3911
+ return next;
3912
+ } catch (error) {
3913
+ ctx.ui.notify(`Profile not updated: ${profilesErrorMessage(error)}`, "warning");
3914
+ return file;
3915
+ }
3916
+ }
3917
+ case "duplicate": {
3918
+ const name = await ctx.ui.input(`Duplicate profile "${result.name}" as`, `${result.name}-copy`);
3919
+ if (name === undefined) return file;
3920
+ try {
3921
+ const next = duplicateProfile(file, result.name, name.trim());
3922
+ writeProfilesFileSync(path, next);
3923
+ return next;
3924
+ } catch (error) {
3925
+ ctx.ui.notify(`Profile not duplicated: ${profilesErrorMessage(error)}`, "warning");
3926
+ return file;
3927
+ }
3928
+ }
3929
+ case "rename": {
3930
+ const name = await ctx.ui.input(`Rename profile "${result.name}" to`, result.name);
3931
+ if (name === undefined) return file;
3932
+ try {
3933
+ const next = renameProfile(file, result.name, name.trim());
3934
+ writeProfilesFileSync(path, next);
3935
+ return next;
3936
+ } catch (error) {
3937
+ ctx.ui.notify(`Profile not renamed: ${profilesErrorMessage(error)}`, "warning");
3938
+ return file;
3939
+ }
3940
+ }
3941
+ case "delete": {
3942
+ const approved = await ctx.ui.confirm(
3943
+ "Delete profile?",
3944
+ `Delete profile "${result.name}" from ${path}? The routing in ${modelConfigPath(ctx.cwd)} is not changed.`,
3945
+ );
3946
+ if (!approved) return file;
3947
+ try {
3948
+ const next = deleteProfile(file, result.name);
3949
+ writeProfilesFileSync(path, next);
3950
+ return next;
3951
+ } catch (error) {
3952
+ ctx.ui.notify(`Profile not deleted: ${profilesErrorMessage(error)}`, "warning");
3953
+ return file;
3954
+ }
3955
+ }
3956
+ case "export": {
3957
+ if (!hasOwnProfile(file.profiles, result.name)) return file;
3958
+ const exportPath = profileExportPath(gentleAiConfigHome());
3959
+ try {
3960
+ const text = serializeProfileExport(result.name, file.profiles[result.name]);
3961
+ await mkdir(dirname(exportPath), { recursive: true });
3962
+ await writeFile(exportPath, text);
3963
+ ctx.ui.notify(`el Gentleman exported profile "${result.name}" to ${exportPath}.`, "info");
3964
+ } catch (error) {
3965
+ ctx.ui.notify(`Profile export failed: ${profilesErrorMessage(error)}`, "warning");
3966
+ }
3967
+ return file;
3968
+ }
3969
+ case "import": {
3970
+ const importPath = profileExportPath(gentleAiConfigHome());
3971
+ let text: string;
3972
+ try {
3973
+ text = await readFile(importPath, "utf8");
3974
+ } catch {
3975
+ ctx.ui.notify(`Profile import failed: ${importPath} is missing or unreadable.`, "warning");
3976
+ return file;
3977
+ }
3978
+ const parsed = parseProfileExportTextWithDrops(text);
3979
+ if (!parsed) {
3980
+ ctx.ui.notify(
3981
+ `Profile import failed: ${importPath} is not a valid agent-model profile export.`,
3982
+ "warning",
3983
+ );
3984
+ return file;
3985
+ }
3986
+ if (parsed.droppedAgents.length > 0) {
3987
+ ctx.ui.notify(
3988
+ `el Gentleman dropped invalid routing entries while importing profile "${parsed.name}": ${parsed.droppedAgents.join(", ")}.`,
3989
+ "warning",
3990
+ );
3991
+ }
3992
+ if (hasOwnProfile(file.profiles, parsed.name)) {
3993
+ const approved = await ctx.ui.confirm(
3994
+ "Replace existing profile?",
3995
+ `Profile "${parsed.name}" already exists in ${path}. Replace its routing with the import?`,
3996
+ );
3997
+ if (!approved) return file;
3998
+ }
3999
+ try {
4000
+ const next = hasOwnProfile(file.profiles, parsed.name)
4001
+ ? updateProfile(file, parsed.name, parsed.config)
4002
+ : createProfile(file, parsed.name, parsed.config);
4003
+ writeProfilesFileSync(path, next);
4004
+ const entries = Object.keys(parsed.config).length;
4005
+ ctx.ui.notify(
4006
+ `el Gentleman imported profile "${parsed.name}" (${entries} routing ${entries === 1 ? "entry" : "entries"}) from ${importPath}.`,
4007
+ "info",
4008
+ );
4009
+ return next;
4010
+ } catch (error) {
4011
+ ctx.ui.notify(`Profile import failed: ${profilesErrorMessage(error)}`, "warning");
4012
+ return file;
4013
+ }
4014
+ }
4015
+ }
4016
+ }
4017
+
4018
+ async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
4019
+ const path = profilesFilePath(gentleAiConfigHome());
4020
+ const read = readProfilesFileResult(path);
4021
+ if (read.status === "invalid") {
4022
+ ctx.ui.notify(
4023
+ `el Gentleman cannot open agent profiles because ${path} is invalid JSON or not a profiles file. Fix or remove the file, then run /gentle:profiles again.`,
4024
+ "warning",
4025
+ );
4026
+ return;
4027
+ }
4028
+ let file: AgentProfilesFile;
4029
+ if (read.status === "missing") {
4030
+ file = bootstrapProfilesFile(await readModelConfigAsync(ctx.cwd));
4031
+ try {
4032
+ writeProfilesFileSync(path, file);
4033
+ } catch (error) {
4034
+ ctx.ui.notify(
4035
+ `el Gentleman could not create ${path}: ${profilesErrorMessage(error)}`,
4036
+ "warning",
4037
+ );
4038
+ return;
4039
+ }
4040
+ ctx.ui.notify(`el Gentleman seeded the "current" profile in ${path} from the saved model routing.`, "info");
4041
+ } else {
4042
+ file = read.file;
4043
+ reportProfilesDrops(ctx, path, read.drops);
4044
+ }
4045
+ let selectedName: string | undefined;
4046
+ let result = await showProfilesPanel(ctx, file, await readModelConfigAsync(ctx.cwd), selectedName);
4047
+ while (result.type !== "close") {
4048
+ selectedName = "name" in result ? result.name : undefined;
4049
+ file = await runProfilesPanelAction(ctx, path, file, result);
4050
+ result = await showProfilesPanel(ctx, file, await readModelConfigAsync(ctx.cwd), selectedName);
4051
+ }
4052
+ }
4053
+
2605
4054
  async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
2606
4055
  const current = readPersonaMode(ctx.cwd);
2607
4056
  const selected = await ctx.ui.select(
@@ -2645,6 +4094,9 @@ const REVIEW_CONTROLLER_OPERATION = {
2645
4094
  RECONCILE_AUTHORITY: "reconcile-authority",
2646
4095
  REPAIR_LEGACY_ALIAS: "repair-legacy-alias",
2647
4096
  REPAIR: "repair",
4097
+ // gentle-pi#662: read-only native risk assessment (gentle-ai#4295). Never
4098
+ // mutates review authority state and never requires a lineageId.
4099
+ ASSESS: "assess",
2648
4100
  } as const;
2649
4101
 
2650
4102
  type ReviewControllerOperation =
@@ -2676,7 +4128,8 @@ const REVIEW_CONTROLLER_PARAMETERS = {
2676
4128
  description: "Bounded review lineage identifier. A failed start creates no lineage; do not use it with status or advance.",
2677
4129
  },
2678
4130
  selectionBinding: { type: "string", description: "Opaque provider-issued pre-lineage intended-untracked selection binding." },
2679
- intendedUntracked: { type: "array", items: { type: "string" }, description: "Repository-relative paths selected from the provider binding." },
4131
+ intendedUntracked: { type: "array", items: { type: "string" }, description: 'Repository-relative paths selected from the provider binding; on inspect they are accepted only with untrackedScope "select".' },
4132
+ untrackedScope: { type: "string", enum: ["exclude", "select"], description: 'Inspect-only: resolve the intended-untracked selection stop in one call. "exclude" excludes every eligible untracked path and forbids intendedUntracked; "select" includes exactly the supplied intendedUntracked paths.' },
2680
4133
  changeName: {
2681
4134
  type: "string",
2682
4135
  description: "Canonical OpenSpec change name required to resolve a recovered authority during lifecycle validate.",
@@ -2691,7 +4144,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
2691
4144
  },
2692
4145
  input: {
2693
4146
  type: "string",
2694
- 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. Legacy controller input remains separate.",
4147
+ 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. 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.",
2695
4148
  },
2696
4149
  outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
2697
4150
  inputPath: { type: "string", description: "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired." },
@@ -2779,11 +4232,57 @@ interface ReviewScopeParameters {
2779
4232
  cursor?: number;
2780
4233
  }
2781
4234
 
4235
+ // gentle-pi#662: read-only native risk assessment, gating the separate
4236
+ // verifier on native risk instead of a task-description judgment when the
4237
+ // rendered `Receipt-driven development:` line is `off` or `unknown`. Exposed
4238
+ // as `gentle_review` operation `assess` (not a dedicated tool), taking its
4239
+ // optional fields through the controller's existing generic `input` JSON
4240
+ // string, exactly like START's `{"mode":...,"baseRef":...}`.
4241
+ interface ReviewAssessInput {
4242
+ baseRef?: string;
4243
+ committedOnly?: boolean;
4244
+ writerModelId?: string;
4245
+ writerEffort?: string;
4246
+ // gentle-pi#668: the caller's own known outcome for this candidate.
4247
+ // Omitted tries to auto-derive declined/unavailable for this EXACT
4248
+ // candidate's own target identity (never a different one); `closed` is
4249
+ // never auto-derived -- pass it explicitly.
4250
+ nativeReviewOutcome?: NativeReviewOutcome;
4251
+ }
4252
+
4253
+ function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome {
4254
+ return typeof value === "string" && (Object.values(NATIVE_REVIEW_OUTCOME) as readonly string[]).includes(value);
4255
+ }
4256
+
4257
+ function parseReviewAssessInput(operation: ReviewControllerOperation, raw: string | undefined): ReviewAssessInput {
4258
+ if (raw === undefined) return {};
4259
+ const value = parseControllerJson(raw, operation);
4260
+ const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome"]);
4261
+ const unexpected = Object.keys(value).find((key) => !allowed.has(key));
4262
+ if (unexpected !== undefined) throw new Error(`Review controller ${operation} input does not accept ${unexpected}`);
4263
+ const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome } = value;
4264
+ if (baseRef !== undefined && typeof baseRef !== "string") throw new Error(`Review controller ${operation} input baseRef must be a string`);
4265
+ if (committedOnly !== undefined && typeof committedOnly !== "boolean") throw new Error(`Review controller ${operation} input committedOnly must be a boolean`);
4266
+ if (writerModelId !== undefined && typeof writerModelId !== "string") throw new Error(`Review controller ${operation} input writerModelId must be a string`);
4267
+ if (writerEffort !== undefined && typeof writerEffort !== "string") throw new Error(`Review controller ${operation} input writerEffort must be a string`);
4268
+ if (nativeReviewOutcome !== undefined && !isNativeReviewOutcome(nativeReviewOutcome)) {
4269
+ throw new Error(`Review controller ${operation} input nativeReviewOutcome must be one of ${Object.values(NATIVE_REVIEW_OUTCOME).join(", ")}`);
4270
+ }
4271
+ return {
4272
+ ...(baseRef === undefined ? {} : { baseRef: baseRef as string }),
4273
+ ...(committedOnly === undefined ? {} : { committedOnly: committedOnly as boolean }),
4274
+ ...(writerModelId === undefined ? {} : { writerModelId: writerModelId as string }),
4275
+ ...(writerEffort === undefined ? {} : { writerEffort: writerEffort as string }),
4276
+ ...(nativeReviewOutcome === undefined ? {} : { nativeReviewOutcome: nativeReviewOutcome as NativeReviewOutcome }),
4277
+ };
4278
+ }
4279
+
2782
4280
  interface ReviewControllerParameters {
2783
4281
  operation: ReviewControllerOperation;
2784
4282
  lineageId?: string;
2785
4283
  selectionBinding?: string;
2786
4284
  intendedUntracked?: readonly string[];
4285
+ untrackedScope?: NativeStartUntrackedScope;
2787
4286
  changeName?: string;
2788
4287
  idempotencyKey?: string;
2789
4288
  transition?: string;
@@ -2823,13 +4322,50 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
2823
4322
  if (unexpected !== undefined || typeof value.selectionBinding !== "string" || !Array.isArray(value.intendedUntracked) || (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string")) throw new Error("Review intended-untracked selection accepts exactly selectionBinding and intendedUntracked, with optional workspaceRoot");
2824
4323
  return { operation: value.operation, selectionBinding: value.selectionBinding, intendedUntracked: value.intendedUntracked, ...(typeof value.workspaceRoot === "string" ? { workspaceRoot: value.workspaceRoot } : {}) };
2825
4324
  }
2826
- const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, REVIEW_CONTROLLER_OPERATION.STATUS, REVIEW_CONTROLLER_OPERATION.EXPORT, REVIEW_CONTROLLER_OPERATION.IMPORT, REVIEW_CONTROLLER_OPERATION.INSPECT, REVIEW_CONTROLLER_OPERATION.RESET, REVIEW_CONTROLLER_OPERATION.RECOVER, REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, REVIEW_CONTROLLER_OPERATION.ABANDON, REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY, REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY, REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS, REVIEW_CONTROLLER_OPERATION.REPAIR].includes(value.operation as ReviewControllerOperation);
4325
+ // gentle-pi#706: top-level untrackedScope/intendedUntracked resolve the
4326
+ // intended-untracked stop through inspect alone. intendedUntracked is not a
4327
+ // standalone selector: inspect accepts it only for an explicit select scope.
4328
+ const hasIntendedUntracked = "intendedUntracked" in value;
4329
+ if (hasIntendedUntracked && value.operation !== REVIEW_CONTROLLER_OPERATION.INSPECT) {
4330
+ throw new Error(
4331
+ `Review controller ${value.operation} does not accept intendedUntracked; it is accepted only by inspect with untrackedScope select`,
4332
+ );
4333
+ }
4334
+ if (value.untrackedScope !== undefined) {
4335
+ if (value.operation !== REVIEW_CONTROLLER_OPERATION.INSPECT)
4336
+ throw new Error(
4337
+ `Review controller ${value.operation} does not accept untrackedScope; it is accepted only by inspect`,
4338
+ );
4339
+ if (
4340
+ value.untrackedScope !== NATIVE_START_UNTRACKED_SCOPE.EXCLUDE &&
4341
+ value.untrackedScope !== NATIVE_START_UNTRACKED_SCOPE.SELECT
4342
+ )
4343
+ throw new Error(
4344
+ "Review controller inspect untrackedScope must be exclude or select",
4345
+ );
4346
+ }
4347
+ if (hasIntendedUntracked) {
4348
+ if (value.untrackedScope !== NATIVE_START_UNTRACKED_SCOPE.SELECT) {
4349
+ throw new Error(
4350
+ "Review controller inspect intendedUntracked requires untrackedScope select",
4351
+ );
4352
+ }
4353
+ if (!Array.isArray(value.intendedUntracked)) {
4354
+ throw new Error(
4355
+ "Review controller inspect intendedUntracked must be an array of repository-relative paths",
4356
+ );
4357
+ }
4358
+ }
4359
+
4360
+ const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, REVIEW_CONTROLLER_OPERATION.STATUS, REVIEW_CONTROLLER_OPERATION.EXPORT, REVIEW_CONTROLLER_OPERATION.IMPORT, REVIEW_CONTROLLER_OPERATION.INSPECT, REVIEW_CONTROLLER_OPERATION.RESET, REVIEW_CONTROLLER_OPERATION.RECOVER, REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, REVIEW_CONTROLLER_OPERATION.ABANDON, REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY, REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY, REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS, REVIEW_CONTROLLER_OPERATION.REPAIR, REVIEW_CONTROLLER_OPERATION.ASSESS].includes(value.operation as ReviewControllerOperation);
2827
4361
  if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
2828
4362
  throw new Error("Review controller requires a lineageId");
2829
4363
  }
2830
4364
  const parameters: ReviewControllerParameters = {
2831
4365
  operation: value.operation,
2832
4366
  ...(typeof value.lineageId === "string" ? { lineageId: value.lineageId } : {}),
4367
+ ...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && value.untrackedScope !== undefined ? { untrackedScope: value.untrackedScope } : {}),
4368
+ ...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && value.intendedUntracked !== undefined ? { intendedUntracked: [...value.intendedUntracked] } : {}),
2833
4369
  };
2834
4370
  for (const key of ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource", "workspaceRoot"] as const) {
2835
4371
  const optional = value[key];
@@ -3107,6 +4643,15 @@ async function resolveReviewModeGate(
3107
4643
  }
3108
4644
  }
3109
4645
 
4646
+ // gentle-pi#185: a native CLI without negotiated STATUS support (no
4647
+ // `targetStatus`, or a version-incompatible provider) hits this boundary
4648
+ // before any candidate-view restoration is attempted, so it can never
4649
+ // reproduce the #176 empty-registry failure — but the boundary's own
4650
+ // `next_action` was a machine token with nothing a human or an agent could
4651
+ // run. `remediation_command` names the exact upstream command that
4652
+ // re-establishes negotiated STATUS for this session's Pi host identity.
4653
+ const NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND = "gentle-ai review status --cwd <repo> --contract gentle-ai.review-integration/v2 --agent pi --next-transition";
4654
+
3110
4655
  function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<string, unknown> {
3111
4656
  return {
3112
4657
  operation,
@@ -3115,6 +4660,7 @@ function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<s
3115
4660
  ...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { mutation_performed: false }),
3116
4661
  inventory_complete: false,
3117
4662
  next_action: "require-upstream-read-only-native-status-inventory",
4663
+ remediation_command: NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND,
3118
4664
  evidence: {
3119
4665
  native_contract: "gentle-ai/2.1.4",
3120
4666
  general_status: "unsupported",
@@ -3126,8 +4672,8 @@ function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<s
3126
4672
  // Bundled and source module instances can coexist, making instanceof insufficient.
3127
4673
  function asNativeReviewCliError(error: unknown): { code: string; diagnostics: NativeReviewProcessDiagnostics } | undefined {
3128
4674
  if (error instanceof NativeReviewCliError) return error;
3129
- if (!(error instanceof Error) || error.name !== "NativeReviewCliError") return undefined;
3130
- const value = error as unknown as { code?: unknown; diagnostics?: unknown };
4675
+ if (!isRecord(error) || error.name !== "NativeReviewCliError") return undefined;
4676
+ const value = error as { code?: unknown; diagnostics?: unknown };
3131
4677
  if (typeof value.code !== "string") return undefined;
3132
4678
  const diagnostics = sanitizeForeignNativeReviewDiagnostics(value.diagnostics);
3133
4679
  return diagnostics === undefined || value.code !== diagnostics.error_code ? undefined : { code: value.code, diagnostics };
@@ -3149,7 +4695,9 @@ function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation,
3149
4695
  ...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { lineage_created: false, mutation_performed: false, mutation_outcome: "none" }),
3150
4696
  inventory_complete: false,
3151
4697
  diagnostics,
3152
- next_action: "reinstall-package-local-gentle-ai",
4698
+ reason: `The verified package-local binary is unavailable. ${GENTLE_AI_INSTALL_RECOVERY_INSTRUCTIONS} This does not prove install lifecycle scripts were disabled.`,
4699
+ recovery_command: GENTLE_AI_INSTALL_RECOVERY_COMMAND,
4700
+ next_action: GENTLE_AI_INSTALL_RECOVERY_INSTRUCTIONS,
3153
4701
  };
3154
4702
  }
3155
4703
 
@@ -3165,6 +4713,23 @@ function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown
3165
4713
  next_action: "require-complete-native-authority-inventory",
3166
4714
  };
3167
4715
  }
4716
+ // gentle-pi#599: a negotiated STATUS/inspect request the native provider
4717
+ // rejects with a decoded failure/v2 envelope (for example a preflight
4718
+ // `invalid_request` refusal for a nested foreign Git repository) used to
4719
+ // fall through to the generic outcome below, discarding the envelope's own
4720
+ // cause, code, retry_safe, and next_action -- the one piece of information
4721
+ // that makes the refusal actionable (pass the intended nested repo as
4722
+ // workspaceRoot). `nativeOperationFailure` already renders this exact
4723
+ // failure-envelope shape faithfully for every mutating operation; reuse it
4724
+ // here instead of masking the refusal as opaque authority-inventory
4725
+ // corruption.
4726
+ if (error instanceof NativeReviewIntegrationError) {
4727
+ return {
4728
+ ...nativeOperationFailure(operation, error),
4729
+ outcome: "native-status-unavailable",
4730
+ inventory_complete: false,
4731
+ };
4732
+ }
3168
4733
  return {
3169
4734
  operation,
3170
4735
  status: "blocked",
@@ -3203,7 +4768,6 @@ function missingNativeMaintenanceInputs(operation: NativeMaintenanceOperation, i
3203
4768
  ...missing,
3204
4769
  ...(Array.isArray(input.capturedLensResults) && input.capturedLensResults.every((entry) => isCanonicalProcessString(entry)) ? [] : ["capturedLensResults"]),
3205
4770
  ...(typeof input.findingsPresent === "boolean" ? [] : ["findingsPresent"]),
3206
- ...(typeof input.evidenceRecordsPresent === "boolean" ? [] : ["evidenceRecordsPresent"]),
3207
4771
  ];
3208
4772
  }
3209
4773
 
@@ -3213,7 +4777,7 @@ function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, in
3213
4777
  }
3214
4778
 
3215
4779
  function nativeMaintenanceAuthorization(operation: NativeMaintenanceOperation, input: Record<string, unknown>): string {
3216
- if (operation === "abandon") return nativeReviewAbandonAuthorization({ lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, evidenceRecordsPresent: input.evidenceRecordsPresent === true, actor: String(input.actor), reason: String(input.reason) });
4780
+ if (operation === "abandon") return nativeReviewAbandonAuthorization({ lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, actor: String(input.actor), reason: String(input.reason) });
3217
4781
  if (operation === "quarantineLegacy") return nativeReviewLegacyQuarantineAuthorization({ repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason) });
3218
4782
  return nativeReviewReconcileAuthorization({ predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }) });
3219
4783
  }
@@ -3240,7 +4804,7 @@ async function executeNativeAuthorityMaintenance(
3240
4804
  }
3241
4805
  try {
3242
4806
  const result = nativeOperation === "abandon"
3243
- ? await nativeReviewCli.abandon!({ cwd, lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, evidenceRecordsPresent: input.evidenceRecordsPresent === true, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
4807
+ ? await nativeReviewCli.abandon!({ cwd, lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
3244
4808
  : nativeOperation === "quarantineLegacy"
3245
4809
  ? await nativeReviewCli.quarantineLegacy!({ cwd, repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
3246
4810
  : await nativeReviewCli.reconcileAuthority!({ cwd, predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) });
@@ -3454,6 +5018,31 @@ function mapNativeTargetStatus(operation: ReviewControllerOperation, status: Rev
3454
5018
  required_status_action: "Use only the provider-selected recovery disposition; do not substitute scope_changed, invalidated, or escalated.",
3455
5019
  };
3456
5020
  }
5021
+ // gentle-pi#627: a stale managed-asset set stops the transition with the
5022
+ // exact `gentle-ai sync` invocation that resolves it. Render that command
5023
+ // as the one actionable next step; every other reason code keeps rendering
5024
+ // as a plain blocked result.
5025
+ if (status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "managed_assets_outdated" && status.nextTransition.continuation !== undefined) {
5026
+ return {
5027
+ operation,
5028
+ status: "blocked",
5029
+ result: status.raw,
5030
+ ...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }),
5031
+ hint: `run ${status.nextTransition.continuation.command}`,
5032
+ };
5033
+ }
5034
+ // gentle-pi#638: an unachievable-lens stop carries the exact withdraw command for every declared slot, mirroring the managed_assets_outdated precedent. A restart that never saw the collect offer still finds its way back from this hint alone.
5035
+ // gentle-pi#822: when the caller asked about one lineage, render only that lineage's withdraw command; the first entry may belong to an unrelated lineage, so an unmatched request omits the hint instead of surfacing a potentially unrelated withdraw command. Without a requested lineage the first entry stays the fallback.
5036
+ if (status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "unachievable_lens_slot" && status.nextTransition.unachievableLensSlots !== undefined) {
5037
+ const withdrawSlot = requestedLineageId === undefined ? status.nextTransition.unachievableLensSlots[0] : status.nextTransition.unachievableLensSlots.find((slot) => slot.withdraw.binding.lineageId === requestedLineageId);
5038
+ return {
5039
+ operation,
5040
+ status: "blocked",
5041
+ result: status.raw,
5042
+ ...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }),
5043
+ ...(withdrawSlot === undefined ? {} : { hint: `run ${withdrawSlot.withdraw.command}` }),
5044
+ };
5045
+ }
3457
5046
  return {
3458
5047
  operation,
3459
5048
  status: status.action === "start" ? "ready" : "blocked",
@@ -3547,6 +5136,12 @@ interface RetainedNativeUntrackedSelection {
3547
5136
  readonly untrackedScope: NativeStartUntrackedScope;
3548
5137
  readonly expectedUntrackedInventory: string;
3549
5138
  readonly intendedUntracked: readonly string[];
5139
+ readonly submission?: NativeIntendedUntrackedSelectionSubmission;
5140
+ }
5141
+
5142
+ interface RetainedPreLineageNativeUntrackedSelection extends RetainedNativeUntrackedSelection {
5143
+ readonly targetIdentity: string;
5144
+ readonly candidateTree: string;
3550
5145
  }
3551
5146
 
3552
5147
  interface RetainedNativeCaptureRoute { readonly workspaceRoot: string; readonly lineageId: string; readonly baseRef?: string; readonly committedOnly?: true; }
@@ -3622,6 +5217,8 @@ function nativeStatusInputRejection(reason: string, field?: string): Record<stri
3622
5217
  }
3623
5218
 
3624
5219
  const PENDING_REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
5220
+ const REVIEW_SESSION_PERMISSION_STATUS_KEY = "gentle-review-session-permission";
5221
+ const REVIEW_SESSION_PERMISSION_STATUS_TEXT = "reviews allowed for this session";
3625
5222
 
3626
5223
  type PendingReviewConsentSessionKey = string | symbol;
3627
5224
 
@@ -3656,7 +5253,13 @@ const PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT = 32;
3656
5253
  */
3657
5254
  export class PendingReviewConsentRegistry {
3658
5255
  private readonly sessions = new Map<PendingReviewConsentSessionKey, Map<string, PendingReviewConsent>>();
3659
- private readonly staleDispositions = new Map<PendingReviewConsentSessionKey, Map<string, PendingReviewConsentDisposition>>();
5256
+ // gentle-pi#455: a binding id is globally unique (randomUUID), so its live
5257
+ // owner and its stale disposition are tracked by binding id alone. This
5258
+ // index lets answer-consent resolve and atomically take exactly once a
5259
+ // binding another active Pi session's START created; the per-session map
5260
+ // above stays authoritative for session-scoped listings and shutdown cleanup.
5261
+ private readonly byBinding = new Map<string, PendingReviewConsentSessionKey>();
5262
+ private readonly staleDispositions = new Map<string, PendingReviewConsentDisposition>();
3660
5263
 
3661
5264
  get(sessionKey: PendingReviewConsentSessionKey): Map<string, PendingReviewConsent> | undefined {
3662
5265
  return this.sessions.get(sessionKey);
@@ -3671,34 +5274,46 @@ export class PendingReviewConsentRegistry {
3671
5274
  return pending;
3672
5275
  }
3673
5276
 
5277
+ // Registers a freshly created pending binding under its owning session and
5278
+ // the cross-session binding index in one step so the two never drift.
5279
+ add(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): void {
5280
+ this.ensure(sessionKey).set(pending.id, pending);
5281
+ this.byBinding.set(pending.id, sessionKey);
5282
+ }
5283
+
5284
+ // Resolves a live binding to its owning session regardless of which
5285
+ // session asks, so answer-consent can reach a binding another session's
5286
+ // START created (gentle-pi#455).
5287
+ resolve(bindingId: string): { sessionKey: PendingReviewConsentSessionKey; pending: PendingReviewConsent } | undefined {
5288
+ const sessionKey = this.byBinding.get(bindingId);
5289
+ const pending = sessionKey === undefined ? undefined : this.sessions.get(sessionKey)?.get(bindingId);
5290
+ return pending === undefined ? undefined : { sessionKey, pending };
5291
+ }
5292
+
3674
5293
  private remove(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
3675
5294
  const session = this.sessions.get(sessionKey);
3676
5295
  if (session?.get(pending.id) !== pending) return false;
3677
5296
  session.delete(pending.id);
3678
5297
  if (session.size === 0) this.sessions.delete(sessionKey);
5298
+ if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id);
3679
5299
  return true;
3680
5300
  }
3681
5301
 
3682
- private rememberDisposition(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent, disposition: PendingReviewConsentDisposition): void {
3683
- let stale = this.staleDispositions.get(sessionKey);
3684
- if (stale === undefined) {
3685
- stale = new Map<string, PendingReviewConsentDisposition>();
3686
- this.staleDispositions.set(sessionKey, stale);
3687
- }
3688
- stale.delete(pending.id);
3689
- stale.set(pending.id, disposition);
3690
- while (stale.size > PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT) stale.delete(stale.keys().next().value!);
5302
+ private rememberDisposition(pending: PendingReviewConsent, disposition: PendingReviewConsentDisposition): void {
5303
+ this.staleDispositions.delete(pending.id);
5304
+ this.staleDispositions.set(pending.id, disposition);
5305
+ while (this.staleDispositions.size > PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT) this.staleDispositions.delete(this.staleDispositions.keys().next().value!);
3691
5306
  }
3692
5307
 
3693
5308
  consume(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
3694
5309
  if (!this.remove(sessionKey, pending)) return false;
3695
- this.rememberDisposition(sessionKey, pending, PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED);
5310
+ this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED);
3696
5311
  return true;
3697
5312
  }
3698
5313
 
3699
5314
  expire(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
3700
5315
  if (!this.remove(sessionKey, pending)) return false;
3701
- this.rememberDisposition(sessionKey, pending, PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED);
5316
+ this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED);
3702
5317
  return true;
3703
5318
  }
3704
5319
 
@@ -3706,14 +5321,14 @@ export class PendingReviewConsentRegistry {
3706
5321
  this.remove(sessionKey, pending);
3707
5322
  }
3708
5323
 
3709
- staleDisposition(sessionKey: PendingReviewConsentSessionKey, binding: string): PendingReviewConsentDisposition | undefined {
3710
- return this.staleDispositions.get(sessionKey)?.get(binding);
5324
+ staleDisposition(binding: string): PendingReviewConsentDisposition | undefined {
5325
+ return this.staleDispositions.get(binding);
3711
5326
  }
3712
5327
 
3713
5328
  take(sessionKey: PendingReviewConsentSessionKey): PendingReviewConsent[] {
3714
5329
  const session = this.sessions.get(sessionKey);
3715
5330
  this.sessions.delete(sessionKey);
3716
- this.staleDispositions.delete(sessionKey);
5331
+ if (session !== undefined) for (const pending of session.values()) if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id);
3717
5332
  return session === undefined ? [] : [...session.values()];
3718
5333
  }
3719
5334
  }
@@ -3729,16 +5344,17 @@ const processRetainedNativeStatusSelections = new Map<PendingReviewConsentSessio
3729
5344
  // and a fresh primary-loop start resets it to 0.
3730
5345
  const processAgentEndSubagentDepth = new Map<PendingReviewConsentSessionKey, number>();
3731
5346
 
3732
- // Target identities already nudged once per session, so the read-only
3733
- // `agent_end` preflight reminder fires at most once per unreviewed candidate.
3734
- const processAgentEndPreflightNudgedTargets = new Map<PendingReviewConsentSessionKey, Set<string>>();
5347
+ // gentle-pi#677: gentle-ai#4309 owns anonymous usage telemetry end to end;
5348
+ // Pi only nudges it once per process. This is a plain process-lifetime
5349
+ // guard, not a session-keyed map, because the nudge is meant to fire at most
5350
+ // once no matter how many primary-session `before_agent_start` events this
5351
+ // process observes.
5352
+ let processTelemetryTriggerAttempted = false;
3735
5353
 
3736
- // gentle-pi#568: the target identity negotiated STATUS reported at
3737
- // `session_start`, before this session touched the worktree. A candidate
3738
- // already present at that point is the user's own pre-session work, not
3739
- // something this session produced, so `agent_end` must not treat it as an
3740
- // unreviewed candidate this session should be reminded about.
3741
- const processAgentEndSessionBaseline = new Map<PendingReviewConsentSessionKey, string>();
5354
+ /** Testing-only reset for the once-per-process telemetry trigger guard. */
5355
+ function resetTelemetryTriggerGuardForTesting(): void {
5356
+ processTelemetryTriggerAttempted = false;
5357
+ }
3742
5358
 
3743
5359
  function pendingReviewConsentSessionKey(context: ExtensionContext | undefined, fallbackKey: symbol): PendingReviewConsentSessionKey {
3744
5360
  try {
@@ -3797,6 +5413,61 @@ function reviewConsentDigest(consent: ReviewConsentEnvelope): string {
3797
5413
  return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
3798
5414
  }
3799
5415
 
5416
+ function reviewSessionManagerAndId(context: ExtensionContext): { manager: object; sessionId: string } | undefined {
5417
+ try {
5418
+ const manager = context.sessionManager as unknown as { getSessionId?: () => unknown };
5419
+ const sessionId = manager.getSessionId?.();
5420
+ if (typeof manager !== "object" || manager === null || typeof sessionId !== "string" || sessionId.length === 0) return undefined;
5421
+ return { manager, sessionId };
5422
+ } catch {
5423
+ return undefined;
5424
+ }
5425
+ }
5426
+
5427
+ function isDirectOrdinaryReviewStart(parametersValue: unknown): boolean {
5428
+ try {
5429
+ const parameters = parseReviewControllerParameters(parametersValue);
5430
+ if (parameters.operation !== REVIEW_CONTROLLER_OPERATION.START) return false;
5431
+ const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
5432
+ return input.mode === REVIEW_MODE.ORDINARY;
5433
+ } catch {
5434
+ return false;
5435
+ }
5436
+ }
5437
+
5438
+ function isHostReviewConsentEligibleOperation(parametersValue: unknown): boolean {
5439
+ if (isDirectOrdinaryReviewStart(parametersValue)) return true;
5440
+ try {
5441
+ return parseReviewControllerParameters(parametersValue).operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED;
5442
+ } catch {
5443
+ return false;
5444
+ }
5445
+ }
5446
+
5447
+ function completedGrantedReviewConsent(outcome: Record<string, unknown>): boolean {
5448
+ if (
5449
+ outcome.operation !== REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT ||
5450
+ outcome.status !== undefined ||
5451
+ outcome.outcome !== undefined ||
5452
+ outcome.native_invocation_attempted === false ||
5453
+ outcome.mutation_performed === false ||
5454
+ outcome.lineage_created === false ||
5455
+ !isCanonicalProcessString(outcome.workspace_root)
5456
+ ) return false;
5457
+ const result = outcome.result;
5458
+ if (!isRecord(result)) return false;
5459
+ const nonnegativeInteger = (value: unknown): boolean => Number.isInteger(value) && Number(value) >= 0;
5460
+ return isCanonicalProcessString(result.lineage_id) &&
5461
+ isCanonicalProcessString(result.state) &&
5462
+ isCanonicalProcessString(result.risk_tier) &&
5463
+ Array.isArray(result.selected_lenses) && result.selected_lenses.every(isCanonicalProcessString) &&
5464
+ nonnegativeInteger(result.changed_files) &&
5465
+ nonnegativeInteger(result.original_changed_lines) &&
5466
+ nonnegativeInteger(result.correction_budget) &&
5467
+ (result.action === "created" || result.action === "resumed" || result.action === "replayed" || result.action === "closed") &&
5468
+ typeof result.lenses_required === "boolean";
5469
+ }
5470
+
3800
5471
  // gentle-pi#516: a binding this session does not hold (already answered,
3801
5472
  // expired, or issued by another Pi session or process) used to fall through
3802
5473
  // to the plain negotiated STATUS, which reads exactly like a healthy pre-start
@@ -3842,6 +5513,50 @@ function staleConsentBindingOutcome(operation: ReviewControllerOperation, bindin
3842
5513
  };
3843
5514
  }
3844
5515
 
5516
+ // gentle-pi#455 correction: cross-session resolution looks a binding up by
5517
+ // its opaque id alone, so it must independently confirm the answering
5518
+ // invocation addresses the same repository its owning START minted it for.
5519
+ // This is a typed, non-bearer refusal in the same shape family as the
5520
+ // stale-binding outcome -- it never runs the mode gate or native
5521
+ // answerConsent, and never consumes the binding, so it stays answerable from
5522
+ // the correct repository afterwards.
5523
+ function consentBindingRepositoryMismatchOutcome(operation: ReviewControllerOperation, binding: string, mintingRepositoryCwd: string, answeringRepositoryCwd: string): Record<string, unknown> {
5524
+ return {
5525
+ operation,
5526
+ status: "blocked",
5527
+ outcome: "consent-binding-repository-mismatch",
5528
+ consent_binding: binding,
5529
+ diagnostics: {
5530
+ code: "consent-binding-repository-mismatch",
5531
+ message: `consent binding ${binding} was minted for repository ${mintingRepositoryCwd}, not ${answeringRepositoryCwd}. Answer this binding from a session addressing ${mintingRepositoryCwd}; do not resend this binding from a different repository.`,
5532
+ },
5533
+ native_invocation_attempted: false,
5534
+ ...nativeStartPreAuthorityRejection(),
5535
+ next_action: "answer-from-minting-repository",
5536
+ };
5537
+ }
5538
+
5539
+ // gentle-pi#874: the committed-range selector a current STATUS owns. A
5540
+ // selectorless STATUS on a clean, fully committed worktree answers with a
5541
+ // review.start execute transition naming the exact merge-base the provider
5542
+ // wants, so a plain START can adopt the route the provider just rendered
5543
+ // instead of making the caller hand-copy `base-ref` into its input. Only the
5544
+ // provider's own offered argument is read -- never a guess, a persisted value,
5545
+ // or a stale transition -- and anything that is not a full commit id paired
5546
+ // with committed-only is refused, so every other STATUS keeps today's
5547
+ // behaviour byte-for-byte.
5548
+ const OFFERED_COMMITTED_RANGE_BASE_REF = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
5549
+
5550
+ function offeredCommittedRangeBaseRef(target: ReviewStatusV3): string | undefined {
5551
+ const execute = target.nextTransition?.kind === "execute" ? target.nextTransition.execute : undefined;
5552
+ if (execute?.operation !== "review.start") return undefined;
5553
+ if (execute.arguments.some((argument) => argument.name === "workspace-overlay")) return undefined;
5554
+ const baseRef = execute.arguments.find((argument) => argument.name === "base-ref")?.value;
5555
+ if (baseRef === undefined || !OFFERED_COMMITTED_RANGE_BASE_REF.test(baseRef)) return undefined;
5556
+ if (execute.arguments.find((argument) => argument.name === "committed-only")?.value !== "true") return undefined;
5557
+ return baseRef;
5558
+ }
5559
+
3845
5560
  function assertNativeStartCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void {
3846
5561
  candidateView.verify();
3847
5562
  if (
@@ -3887,7 +5602,7 @@ function completeNativeStart(
3887
5602
  }
3888
5603
 
3889
5604
  function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_review_capture", error: unknown): Record<string, unknown> {
3890
- const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; auditRecord?: unknown; launchAttempted?: unknown; candidateViewPreNative?: unknown; failureEnvelope?: { raw?: unknown; mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown } };
5605
+ const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; auditRecord?: unknown; launchAttempted?: unknown; candidateViewPreNative?: unknown; failureEnvelope?: { raw?: unknown; mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown; code?: unknown; continuation?: { command?: unknown } } };
3891
5606
  if (isRecord(value.failureEnvelope) && isRecord(value.failureEnvelope.raw)) {
3892
5607
  const mutationOutcome = value.failureEnvelope.mutationOutcome;
3893
5608
  return {
@@ -3901,6 +5616,12 @@ function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_r
3901
5616
  : { mutation_performed: false, mutation_outcome: "none" }),
3902
5617
  ...(typeof value.failureEnvelope.replayability === "string" ? { replayability: value.failureEnvelope.replayability } : {}),
3903
5618
  ...(typeof value.failureEnvelope.nextAction === "string" ? { next_action: value.failureEnvelope.nextAction } : {}),
5619
+ // gentle-pi#627: START's preflight failure envelope for a stale
5620
+ // managed-asset set carries a top-level continuation; render its
5621
+ // `gentle-ai sync` command as the one actionable next step.
5622
+ ...(value.failureEnvelope.code === "managed_assets_outdated" && typeof value.failureEnvelope.continuation?.command === "string"
5623
+ ? { hint: `run ${value.failureEnvelope.continuation.command}` }
5624
+ : {}),
3904
5625
  };
3905
5626
  }
3906
5627
  // Every consent binding guard runs before the provider is launched, so this
@@ -3998,7 +5719,9 @@ async function reconcileNativeMutationFailure(
3998
5719
  try {
3999
5720
  const status = await nativeReviewCli.targetStatus(target);
4000
5721
  syncRetainedNativeStatusSelections(retainedSelections, canonicalRetentionRoot, status, target.baseRef);
4001
- const { required_status_action: staleStatusDirective, ...reconciledBase } = failure;
5722
+ const projectedStatus = mapNativeTargetStatus(operation, status, target.lineageId);
5723
+ const { next_action: staleNextAction, required_status_action: staleStatusDirective, ...reconciledBase } = failure;
5724
+ void staleNextAction;
4002
5725
  void staleStatusDirective;
4003
5726
  // Field defect (fambig, 2026-08-16): an envelope-less mutating failure
4004
5727
  // is stamped mutationOutcome "unknown", but a reconciled authority
@@ -4012,6 +5735,8 @@ async function reconcileNativeMutationFailure(
4012
5735
  void staleReplayability;
4013
5736
  return {
4014
5737
  ...provenBase,
5738
+ ...projectedStatus,
5739
+ status: "blocked",
4015
5740
  outcome: "native-mutation-status-reconciled",
4016
5741
  reconciliation: status.raw,
4017
5742
  authority_applicability: status.applicability,
@@ -4019,17 +5744,19 @@ async function reconcileNativeMutationFailure(
4019
5744
  mutation_performed: false,
4020
5745
  mutation_outcome: "none",
4021
5746
  mutation_outcome_reason: `authority revision unchanged across reconciliation (${preOperationRevision}); the failed operation provably did not mutate`,
4022
- next_action: status.action,
4023
5747
  };
4024
5748
  }
4025
5749
  return {
4026
5750
  ...reconciledBase,
5751
+ ...projectedStatus,
5752
+ status: "blocked",
4027
5753
  outcome: "native-mutation-status-reconciled",
4028
5754
  reconciliation: status.raw,
4029
5755
  authority_applicability: status.applicability,
4030
5756
  provider_action: status.action,
4031
5757
  replayability: status.replayability,
4032
- next_action: status.action,
5758
+ ...(status.action === "start" && projectedStatus.next_action === undefined ? { next_action: "start" } : {}),
5759
+ required_status_action: projectedStatus.required_status_action ?? requiredStatusActionText(target.lineageId),
4033
5760
  };
4034
5761
  } catch (statusError) {
4035
5762
  return {
@@ -4131,6 +5858,41 @@ function readRetainedNativeUntrackedSelection(selections: Map<string, RetainedNa
4131
5858
  };
4132
5859
  }
4133
5860
 
5861
+ // gentle-pi#706: inspect's untrackedScope round trip retains the resolved
5862
+ // selection under the pre-lineage empty-lineage key so the next plain START in
5863
+ // that worktree adopts it without re-deriving the selection.
5864
+ function nativePreLineageCandidateIdentity(
5865
+ status: ReviewStatusV3,
5866
+ ): { targetIdentity: string; candidateTree: string } | undefined {
5867
+ const candidateTree = status.projection.currentCandidateTree;
5868
+ return isCanonicalProcessString(status.targetIdentity) && isCanonicalProcessString(candidateTree)
5869
+ ? { targetIdentity: status.targetIdentity, candidateTree }
5870
+ : undefined;
5871
+ }
5872
+
5873
+ function readRetainedPreLineageNativeUntrackedSelection(
5874
+ selections: Map<string, RetainedNativeStatusSelection>,
5875
+ workspaceRoot: string,
5876
+ ): RetainedPreLineageNativeUntrackedSelection | undefined {
5877
+ const selection = selections.get(reviewLifecycleStorageKey(workspaceRoot, ""));
5878
+ return selection !== undefined &&
5879
+ !("baseRef" in selection) &&
5880
+ typeof (selection as Partial<RetainedPreLineageNativeUntrackedSelection>).targetIdentity === "string" &&
5881
+ typeof (selection as Partial<RetainedPreLineageNativeUntrackedSelection>).candidateTree === "string"
5882
+ ? selection as RetainedPreLineageNativeUntrackedSelection
5883
+ : undefined;
5884
+ }
5885
+
5886
+ function sameNativePreLineageCandidate(
5887
+ selection: RetainedPreLineageNativeUntrackedSelection,
5888
+ status: ReviewStatusV3,
5889
+ ): boolean {
5890
+ const identity = nativePreLineageCandidateIdentity(status);
5891
+ return identity !== undefined &&
5892
+ identity.targetIdentity === selection.targetIdentity &&
5893
+ identity.candidateTree === selection.candidateTree;
5894
+ }
5895
+
4134
5896
  function isRetainedNativeCaptureRoute(selection: RetainedNativeStatusSelection | undefined): selection is RetainedNativeCaptureRoute {
4135
5897
  return selection !== undefined && "workspaceRoot" in selection;
4136
5898
  }
@@ -4201,6 +5963,16 @@ const REVIEW_HOST_RELAY_REFUSED_ACTION =
4201
5963
  "gentle-ai refused this submission at admission and did not consume the lens slot; the reason is in failure.stderr. "
4202
5964
  + "Call fresh STATUS and run only the exact slot it reoffers so the reviewer produces a new result that satisfies that refusal; never resubmit the refused bytes.";
4203
5965
 
5966
+ // gentle-pi#638: the declaration recorded, so fresh STATUS stops the review with one withdraw binding per declared slot instead of reoffering the reviewer. The withdraw command is the only way back to the same slot; everything else needs a smaller candidate and a new review.
5967
+ const REVIEW_HOST_RELAY_UNACHIEVABLE_ACTION =
5968
+ "A deterministic relay failure was declared unachievable for this bound slot, and fresh STATUS now stops this review instead of reoffering the reviewer. "
5969
+ + "If the failure was transient, run the withdraw command in unachievable_lens_slots with the same binding so the review re-offers this exact reviewer; otherwise reduce the candidate scope and start a new review.";
5970
+
5971
+ // gentle-pi#638: the relay failure was deterministic but gentle-ai refused the declaration, so no slot state changed and the review still needs a provider-bound continuation.
5972
+ const REVIEW_HOST_RELAY_DECLARATION_FAILED_ACTION =
5973
+ "The relay failure was deterministic for this slot, but gentle-ai refused the unachievable declaration, so no slot state changed. "
5974
+ + "Call fresh STATUS and follow only its declared action; never replay this capture from transcript inference.";
5975
+
4204
5976
  function reviewHostRelayTimeoutNextAction(error: ReviewHostRelayError): string {
4205
5977
  const measured = error.elapsedMs === null || error.timeoutMs === null
4206
5978
  ? ""
@@ -4290,11 +6062,13 @@ async function reconcileUnknownReviewCaptureFailure(
4290
6062
  selections: Map<string, RetainedNativeStatusSelection>,
4291
6063
  route: RetainedNativeCaptureRoute | undefined,
4292
6064
  expectedReviewCaptureSuffix?: readonly string[],
6065
+ agent?: "pi",
4293
6066
  ): Promise<Record<string, unknown>> {
4294
6067
  const failure = error === undefined ? undefined : nativeOperationFailure("gentle_review_capture", error);
4295
6068
  if (error !== undefined && !nativeMutationRequiresStatus(error)) return failure;
4296
6069
  try {
4297
- const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, route);
6070
+ const selector = agent === undefined ? route : { ...route, agent };
6071
+ const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, selector);
4298
6072
  syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef);
4299
6073
  if (expectedReviewCaptureSuffix !== undefined && !hasExactReviewCaptureSuffix(status, expectedReviewCaptureSuffix)) return captureGroupAuthorityDrift(status);
4300
6074
  return {
@@ -4355,10 +6129,10 @@ async function executeReviewHostRelayCapture(
4355
6129
  },
4356
6130
  };
4357
6131
  } catch (error) {
4358
- if (!(error instanceof ReviewHostRelayError)) return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route);
6132
+ if (!(error instanceof ReviewHostRelayError)) return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT);
4359
6133
  if (error.mutationOutcome === "unknown") {
4360
6134
  return {
4361
- ...(await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route)),
6135
+ ...(await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT)),
4362
6136
  failure: reviewHostRelayFailureReport(error),
4363
6137
  reason: error.message,
4364
6138
  };
@@ -4384,6 +6158,101 @@ async function executeReviewHostRelayCapture(
4384
6158
  mutation_outcome: "none",
4385
6159
  };
4386
6160
  }
6161
+ // gentle-pi#638: the two deterministic relay failure classes end the slot, not the transport. Declaring the slot unachievable through the native verb records the provider-owned fact that this reviewer cannot complete under current conditions, then exactly one bound STATUS re-query renders the typed stop with its withdraw binding instead of reoffering the same slot. The declaration binding is re-derived from the slot's own provider-issued `--name=value` tokens, never from transcript state.
6162
+ const unachievableReason = reviewHostRelayUnachievableReason(error);
6163
+ const declarationBinding = unachievableSlotDeclarationBinding(slot);
6164
+ if (unachievableReason !== undefined && declarationBinding !== undefined && nativeReviewCli.captureUnachievableLens !== undefined) {
6165
+ let declared: NativeReviewUnachievableLensCaptureArtifact | undefined;
6166
+ try {
6167
+ declared = await nativeReviewCli.captureUnachievableLens({ cwd, ...declarationBinding, reason: unachievableReason, ...(reviewHostRelayUnachievableDetail(error) === undefined ? {} : { detail: reviewHostRelayUnachievableDetail(error)! }), ...(signal === undefined ? {} : { signal }) });
6168
+ } catch (declarationError) {
6169
+ // Fail open only on the unknown-verb capability refusal: an older binary without `capture-unachievable` keeps today's transport-failure behavior below. Every other declaration failure is surfaced, never hidden behind the relay failure it followed.
6170
+ if (!isNativeReviewUnachievableVerbRefused(declarationError)) {
6171
+ // gentle-pi#822 (outside-diff): the declaration failure's own envelope carries the mutation truth — the process may have recorded the declaration before failing — so the mutation fields are derived from it instead of hardcoded none, and an unknown outcome is proven or disproven by one bound STATUS re-query without ever changing the failure outcome.
6172
+ const declarationFailureReport = nativeOperationFailure("gentle_review_capture", declarationError);
6173
+ let declarationMutationPerformed = declarationFailureReport.mutation_performed === true;
6174
+ let declarationMutationOutcome: "none" | "unknown" | "committed" = declarationFailureReport.mutation_outcome === "committed" ? "committed" : declarationFailureReport.mutation_outcome === "unknown" ? "unknown" : "none";
6175
+ if (declarationMutationOutcome === "unknown") {
6176
+ try {
6177
+ const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, route === undefined ? { agent: REVIEW_HOST_AGENT } : { ...route, agent: REVIEW_HOST_AGENT });
6178
+ syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef);
6179
+ const stop = status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "unachievable_lens_slot" ? status.nextTransition : undefined;
6180
+ const declaredSlot = stop?.unachievableLensSlots?.find((candidate) => candidate.lens === slot.lens && String(candidate.selectedOrder) === slot.order && candidate.subjectHash === declarationBinding.requestHash && candidate.withdraw.binding.targetIdentity === declarationBinding.targetIdentity && candidate.withdraw.binding.lineageId === declarationBinding.lineageId && candidate.withdraw.binding.revision === declarationBinding.expectedRevision);
6181
+ if (stop !== undefined && declaredSlot !== undefined) {
6182
+ declarationMutationPerformed = true;
6183
+ declarationMutationOutcome = "committed";
6184
+ }
6185
+ } catch {
6186
+ // Without proof the mutation stays unknown; the declaration failure is already the reported outcome.
6187
+ }
6188
+ }
6189
+ return {
6190
+ tool: "gentle_review_capture",
6191
+ status: "blocked",
6192
+ outcome: "unachievable-lens-declaration-failed",
6193
+ reason: error.message,
6194
+ failure: reviewHostRelayFailureReport(error),
6195
+ declaration_failure: declarationFailureReport,
6196
+ mutation_performed: declarationMutationPerformed,
6197
+ mutation_outcome: declarationMutationOutcome,
6198
+ next_action: REVIEW_HOST_RELAY_DECLARATION_FAILED_ACTION,
6199
+ };
6200
+ }
6201
+ }
6202
+ if (declared !== undefined) {
6203
+ const declaration = { lens: declared.lens, selected_order: declared.selectedOrder, subject_hash: declarationBinding.requestHash, reason: declared.reason };
6204
+ try {
6205
+ const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, route === undefined ? { agent: REVIEW_HOST_AGENT } : { ...route, agent: REVIEW_HOST_AGENT });
6206
+ syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef);
6207
+ const stop = status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "unachievable_lens_slot" ? status.nextTransition : undefined;
6208
+ // gentle-pi#822: the stop may also carry slots declared by other runs, so expose only the entry matching the identity this session just declared. A stop with slots but no matching entry is a reconciliation failure, never a success rendering someone else's withdraw command.
6209
+ const declaredSlot = stop?.unachievableLensSlots?.find((slot) => slot.lens === declaration.lens && slot.selectedOrder === declaration.selected_order && slot.subjectHash === declaration.subject_hash && slot.withdraw.binding.targetIdentity === declarationBinding.targetIdentity && slot.withdraw.binding.lineageId === declarationBinding.lineageId && slot.withdraw.binding.revision === declarationBinding.expectedRevision);
6210
+ // gentle-pi#822: success is proven, never assumed — a STATUS with no unachievable_lens_slot stop at all (no transition, a different reason code, or a collect reoffer) is a reconciliation failure exactly like a stop whose entries do not match the declared identity.
6211
+ if (declaredSlot === undefined) {
6212
+ return {
6213
+ tool: "gentle_review_capture",
6214
+ status: "blocked",
6215
+ outcome: "unachievable-lens-declaration-reconciliation-failed",
6216
+ reason: error.message,
6217
+ failure: reviewHostRelayFailureReport(error),
6218
+ declaration,
6219
+ reconciliation_failure: { operation: "gentle_review_capture", status: "blocked", outcome: "unachievable-lens-slot-declaration-unmatched", reason: stop === undefined ? "the bound STATUS did not return the unachievable_lens_slot stop" : "no unachievable_lens_slots entry matches the declared slot identity", declared_slot: { lens: declaration.lens, selected_order: declaration.selected_order, subject_hash: declaration.subject_hash }, mutation_performed: true, mutation_outcome: "committed" },
6220
+ mutation_performed: true,
6221
+ mutation_outcome: "committed",
6222
+ next_action: REVIEW_HOST_RELAY_DECLARATION_FAILED_ACTION,
6223
+ };
6224
+ }
6225
+ return {
6226
+ tool: "gentle_review_capture",
6227
+ status: "blocked",
6228
+ outcome: "unachievable-lens-slot-declared",
6229
+ reason: error.message,
6230
+ failure: reviewHostRelayFailureReport(error),
6231
+ declaration,
6232
+ provider_action: status.action,
6233
+ ...(status.nextTransition === undefined ? {} : { next_transition: status.nextTransition }),
6234
+ unachievable_lens_slots: [{ lens: declaredSlot.lens, selected_order: declaredSlot.selectedOrder, subject_hash: declaredSlot.subjectHash, reason: declaredSlot.reason, ...(declaredSlot.detail === undefined ? {} : { detail: declaredSlot.detail }), withdraw: declaredSlot.withdraw.command }],
6235
+ result: status.raw,
6236
+ next_action: REVIEW_HOST_RELAY_UNACHIEVABLE_ACTION,
6237
+ mutation_performed: true,
6238
+ mutation_outcome: "committed",
6239
+ };
6240
+ } catch (statusError) {
6241
+ return {
6242
+ tool: "gentle_review_capture",
6243
+ status: "blocked",
6244
+ outcome: "unachievable-lens-declaration-reconciliation-failed",
6245
+ reason: error.message,
6246
+ failure: reviewHostRelayFailureReport(error),
6247
+ declaration,
6248
+ reconciliation_failure: nativeOperationFailure("gentle_review_capture", statusError),
6249
+ mutation_performed: true,
6250
+ mutation_outcome: "committed",
6251
+ next_action: REVIEW_HOST_RELAY_DECLARATION_FAILED_ACTION,
6252
+ };
6253
+ }
6254
+ }
6255
+ }
4387
6256
  return {
4388
6257
  tool: "gentle_review_capture",
4389
6258
  status: "blocked",
@@ -4404,6 +6273,21 @@ async function executeReviewHostRelayCapture(
4404
6273
  const REVIEW_PROVIDER_ROLE_RETRY_ACTION =
4405
6274
  "Call fresh STATUS and execute only the exact one-slot role vector it reoffers; never relaunch from transcript inference.";
4406
6275
 
6276
+ // gentle-pi#638: re-derives the capture-unachievable declaration binding from one materialize slot's own provider-issued tokens. The provider renders those tokens as `--name=value` pairs (review-host-relay.ts renderToken), and Go verifies every value against the frozen authority before recording, so a missing required value or subject hash means the slot cannot be declared and the caller keeps its fall-back behavior.
6277
+ function unachievableSlotDeclarationBinding(slot: ReviewHostRelaySlot): { lineageId: string; targetIdentity: string; expectedRevision: string; requestHash: string; repositoryContext?: string } | undefined {
6278
+ const tokenValue = (name: string): string | undefined => {
6279
+ const prefix = `--${name}=`;
6280
+ const token = slot.captureArgumentTokens.find((candidate) => candidate.startsWith(prefix));
6281
+ return token === undefined ? undefined : token.slice(prefix.length);
6282
+ };
6283
+ const lineageId = tokenValue("lineage");
6284
+ const targetIdentity = tokenValue("target");
6285
+ const expectedRevision = tokenValue("expected-revision");
6286
+ const repositoryContext = tokenValue("repository-context");
6287
+ if (lineageId === undefined || targetIdentity === undefined || expectedRevision === undefined || slot.subjectHash === undefined) return undefined;
6288
+ return { lineageId, targetIdentity, expectedRevision, requestHash: slot.subjectHash, ...(repositoryContext === undefined ? {} : { repositoryContext }) };
6289
+ }
6290
+
4407
6291
  async function executeProviderRoleVectorCapture(
4408
6292
  slot: ReviewProviderRoleVectorSlot,
4409
6293
  nativeReviewCli: NativeReviewCli,
@@ -4573,9 +6457,9 @@ async function negotiatedStatusForHostTransport(
4573
6457
  // under the exact guards `agent_end` uses to decide whether to nudge: a
4574
6458
  // native review CLI with both `reviewMode` and `targetStatus`, a UI-bearing
4575
6459
  // context, and RDD effectively on. Returns `undefined` on any missing guard,
4576
- // an effective-off mode, or any STATUS error or transport refusal, so both
4577
- // `session_start` (recording a baseline) and `agent_end` (deciding whether to
4578
- // nudge) resolve the same target identity through the same path.
6460
+ // an effective-off mode, or any STATUS error or transport refusal. Startup
6461
+ // negotiation and mutation-gated `agent_end` use the same native whole-target
6462
+ // path; neither derives candidate scope from local mutation receipts.
4579
6463
  async function resolveNegotiatedReviewStatusForSession(
4580
6464
  nativeReviewCli: NativeReviewCli | null,
4581
6465
  ctx: ExtensionContext,
@@ -4600,12 +6484,12 @@ async function resolveNegotiatedReviewStatusForSession(
4600
6484
  }
4601
6485
  }
4602
6486
 
4603
- // gentle-pi#556 / gentle-ai#4051: the exact once-per-candidate reminder sent
6487
+ // gentle-pi#556 / gentle-ai#4051: the mutation-gated reminder sent
4604
6488
  // through `agent_end`. It never runs START itself, so it names the one
4605
6489
  // supported continuation (gentle_review inspect) and defers the resulting
4606
6490
  // consent envelope to the human.
4607
6491
  function renderAgentEndReviewPreflightMessage(targetIdentity: string): string {
4608
- return `Receipt-driven development is enabled, and this worktree holds an unreviewed candidate (target ${targetIdentity}). By the review contract entry rule, run the review preflight before reporting completion.\n\nCall the gentle_review tool with {"operation":"inspect"} and follow the transition it returns; it currently offers review.start for this target. Relay the resulting gentle-ai.review-integration.consent/v3 envelope to the human losslessly, and never answer it on the human's behalf.\n\nThis extension never runs START itself. This reminder is sent once per candidate.`;
6492
+ return `Receipt-driven development is enabled, and this worktree holds an unreviewed candidate (target ${targetIdentity}). First determine whether the user explicitly left this exact target unreviewed. If yes, do not invoke review; report that disposition and continue. Only otherwise, call the gentle_review tool with {"operation":"inspect"} and follow the transition it returns; it currently offers review.start for this target. An eligible interactive Pi host may resolve consent directly with its own three-action UI. If gentle_review instead returns an unresolved gentle-ai.review-integration.consent/v3 envelope, relay that original two-choice provider envelope to the human losslessly. Never answer consent from model prose or tool arguments.\n\nThis extension never runs START itself. This reminder consumes only this session's observed mutation generation.`;
4609
6493
  }
4610
6494
 
4611
6495
  function canonicalReviewCaptureBinding(value: unknown): string {
@@ -4631,8 +6515,18 @@ function exactCollectArgument(input: ReviewCollectInputV3, name: string): string
4631
6515
  return matches.length === 1 ? matches[0]!.value : undefined;
4632
6516
  }
4633
6517
 
6518
+ // The intended-untracked collect input arrived with status/v6 and every later
6519
+ // status version keeps it; matching one exact version left every workspace
6520
+ // with untracked files unable to start a review once gentle-ai answered v7
6521
+ // (gentle-pi#610, gentle-ai#4187).
6522
+ const INTENDED_UNTRACKED_STATUS_SCHEMA = /^gentle-ai\.review-integration\.status\/v(\d+)$/;
6523
+ function statusCarriesIntendedUntrackedSelection(schema: unknown): boolean {
6524
+ const match = typeof schema === "string" ? INTENDED_UNTRACKED_STATUS_SCHEMA.exec(schema) : null;
6525
+ return match !== null && Number(match[1]) >= 6;
6526
+ }
6527
+
4634
6528
  function reviewIntendedUntrackedInput(status: ReviewStatusV3): ReviewCollectInputV3 | undefined {
4635
- if (status.raw.schema !== "gentle-ai.review-integration.status/v6" || status.nextTransition?.kind !== "collect") return undefined;
6529
+ if (!statusCarriesIntendedUntrackedSelection(status.raw.schema) || status.nextTransition?.kind !== "collect") return undefined;
4636
6530
  const matches = (status.nextTransition.collect?.inputs ?? []).filter((input) => {
4637
6531
  const value = input.submission?.values[0];
4638
6532
  return input.name === "intended_untracked_selection" && input.schema === "gentle-ai.review-intended-untracked-selection/v1" && input.captureOperation === "external.select_intended_untracked" && input.submission?.operationToken === "status" && input.submission.values.length === 1 && value?.slot === "intended_untracked_selection" && value.domain === "schema_bound_json";
@@ -4640,6 +6534,13 @@ function reviewIntendedUntrackedInput(status: ReviewStatusV3): ReviewCollectInpu
4640
6534
  return matches.length === 1 ? matches[0] : undefined;
4641
6535
  }
4642
6536
 
6537
+ // gentle-pi#706: the inspect stop on the intended-untracked selection carries no
6538
+ // continuation, so the blocked result names it exactly: the inventory digest
6539
+ // covers path names only, and the round trip resolves either through the select
6540
+ // operation or through inspect's own top-level untrackedScope.
6541
+ const INSPECT_UNTRACKED_SELECTION_NEXT_STEP =
6542
+ '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.';
6543
+
4643
6544
  interface PublicReviewCaptureBinding { collectBinding: string; }
4644
6545
  function publicReviewCaptureBindings(status: ReviewStatusV3): readonly PublicReviewCaptureBinding[] {
4645
6546
  if (status.nextTransition?.kind !== "collect") return [];
@@ -5012,11 +6913,11 @@ async function executeReviewCaptureGroupOperation(
5012
6913
  }
5013
6914
  } catch (error) {
5014
6915
  if (error instanceof ReviewHostRelayError && error.mutationOutcome !== "unknown") return reviewHostRelayGroupFailure(error, group.slots, prepared, index);
5015
- const reconciled = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, current.binding, retainedUntrackedSelections, route);
6916
+ const reconciled = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, current.binding, retainedUntrackedSelections, route, undefined, REVIEW_HOST_AGENT);
5016
6917
  return { ...reconciled, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index, true), ...(error instanceof ReviewHostRelayError ? { failure: reviewHostRelayFailureReport(error), reason: error.message } : {}) };
5017
6918
  }
5018
6919
  }
5019
- const reconciled = await reconcileUnknownReviewCaptureFailure(undefined, nativeReviewCli, cwd, group.binding, retainedUntrackedSelections, route, canonicalBindings.slice(prepared.length));
6920
+ const reconciled = await reconcileUnknownReviewCaptureFailure(undefined, nativeReviewCli, cwd, group.binding, retainedUntrackedSelections, route, canonicalBindings.slice(prepared.length), REVIEW_HOST_AGENT);
5020
6921
  return { ...reconciled, tool: "gentle_review_capture_group", outcome: reconciled.outcome === "capture-group-authority-drift" ? reconciled.outcome : reconciled.status === "reconciled" ? "native-reviewer-group-status-reconciled" : "native-reviewer-group-status-reconciliation-failed", ...reviewHostRelayGroupProgress(group.slots, prepared, prepared.length) };
5021
6922
  }
5022
6923
 
@@ -5057,7 +6958,6 @@ async function executeReviewControllerOperation(
5057
6958
  retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
5058
6959
  pendingReviewConsentRegistry: PendingReviewConsentRegistry = processPendingReviewConsentRegistry,
5059
6960
  pendingReviewConsentFallbackKey: symbol = Symbol("pending-review-consent-fallback"),
5060
- writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
5061
6961
  reviewConsentNow: () => number = Date.now,
5062
6962
  reviewConsentScheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void } = setTimeout,
5063
6963
  intendedUntrackedSelection?: NativeIntendedUntrackedSelectionSubmission,
@@ -5065,7 +6965,7 @@ async function executeReviewControllerOperation(
5065
6965
  const parameters = parseReviewControllerParameters(parametersValue);
5066
6966
  const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
5067
6967
  const pendingReviewConsentSession = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey);
5068
- const useTargetLifecycleRoot = requiresExplicitTargetLifecycleRoot(parameters.workspaceRoot, sessionCwd, defaultCwd);
6968
+ const _useTargetLifecycleRoot = requiresExplicitTargetLifecycleRoot(parameters.workspaceRoot, sessionCwd, defaultCwd);
5069
6969
  const includeWorkspaceRoot = parameters.workspaceRoot !== undefined || defaultCwd !== sessionCwd;
5070
6970
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.EXPORT || parameters.operation === REVIEW_CONTROLLER_OPERATION.IMPORT) {
5071
6971
  // Legacy bundle transport rode on the retired pre-integration graph/compact
@@ -5082,6 +6982,15 @@ async function executeReviewControllerOperation(
5082
6982
  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.",
5083
6983
  };
5084
6984
  }
6985
+ if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ASSESS) {
6986
+ // Read-only native risk assessment (gentle-ai#4295, gentle-pi#662). Never
6987
+ // mutates, never requires a lineageId, and never routes through
6988
+ // authorizeDestructiveReviewOperation (it returns early for any
6989
+ // operation that is neither RESET nor a maintenance operation).
6990
+ const input = parseReviewAssessInput(parameters.operation, parameters.input);
6991
+ const details = await resolveReviewAssessmentPlan(nativeReviewCli, defaultCwd, input, signal);
6992
+ return { operation: parameters.operation, ...details, ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
6993
+ }
5085
6994
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS) {
5086
6995
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
5087
6996
  return await executeNativeLegacyAliasRepair(input, defaultCwd, nativeReviewCli, signal, context);
@@ -5091,20 +7000,138 @@ async function executeReviewControllerOperation(
5091
7000
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
5092
7001
  return await executeNativeAuthorityMaintenance(parameters.operation, maintenance, input, defaultCwd, nativeReviewCli, signal);
5093
7002
  }
5094
- if (parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && nativeReviewCli !== null) {
7003
+ if (
7004
+ parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT &&
7005
+ nativeReviewCli !== null
7006
+ ) {
7007
+ // A new inspect supersedes every pre-lineage selection before its first
7008
+ // STATUS attempt. A failed or changed-candidate inspect cannot leave an
7009
+ // older selection available for a later START.
7010
+ clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, "");
5095
7011
  try {
5096
7012
  if (nativeReviewCli.targetStatus !== undefined) {
5097
- const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
5098
- cwd: defaultCwd,
5099
- ...(signal === undefined ? {} : { signal }),
5100
- }, retainedUntrackedSelections, defaultCwd);
7013
+ const negotiated = await negotiatedStatusForHostTransport(
7014
+ nativeReviewCli,
7015
+ {
7016
+ cwd: defaultCwd,
7017
+ ...(signal === undefined ? {} : { signal }),
7018
+ },
7019
+ retainedUntrackedSelections,
7020
+ defaultCwd,
7021
+ );
5101
7022
  if (negotiated.transport !== undefined) {
5102
7023
  return {
5103
7024
  ...hostTransportUnavailable(parameters.operation, negotiated.transport),
5104
7025
  ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
5105
7026
  };
5106
7027
  }
5107
- return { ...mapNativeTargetStatus(parameters.operation, negotiated.status!, undefined, defaultCwd), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
7028
+ const status = negotiated.status!;
7029
+ const plainMapped = mapNativeTargetStatus(
7030
+ parameters.operation,
7031
+ status,
7032
+ undefined,
7033
+ );
7034
+ if (parameters.untrackedScope === undefined) {
7035
+ // gentle-pi#706: the stop alone never tells the caller what to do next.
7036
+ return {
7037
+ ...plainMapped,
7038
+ ...("selectionBinding" in plainMapped
7039
+ ? { nextStep: INSPECT_UNTRACKED_SELECTION_NEXT_STEP }
7040
+ : {}),
7041
+ ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
7042
+ };
7043
+ }
7044
+ const input = reviewIntendedUntrackedInput(status);
7045
+ if (input === undefined)
7046
+ return {
7047
+ ...plainMapped,
7048
+ untracked_selection: "not-required",
7049
+ ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
7050
+ };
7051
+ const eligibleJson = exactCollectArgument(input, "eligible_paths_json"),
7052
+ inventory = exactCollectArgument(input, "expected_untracked_inventory");
7053
+ let eligible: unknown;
7054
+ try {
7055
+ eligible = JSON.parse(eligibleJson ?? "");
7056
+ } catch {
7057
+ eligible = undefined;
7058
+ }
7059
+ const selected = validateNativeStartUntrackedSelection({
7060
+ untrackedScope: parameters.untrackedScope,
7061
+ expectedUntrackedInventory: inventory,
7062
+ intendedUntracked: parameters.intendedUntracked,
7063
+ });
7064
+ if (
7065
+ !Array.isArray(eligible) ||
7066
+ selected.reason !== undefined ||
7067
+ selected.intendedUntracked!.some((path) => !eligible.includes(path))
7068
+ ) {
7069
+ return {
7070
+ operation: parameters.operation,
7071
+ status: "blocked",
7072
+ outcome: "inspect-untracked-scope-invalid",
7073
+ reason:
7074
+ "The requested untrackedScope/intendedUntracked combination is invalid for the current intended-untracked stop: paths must be members of the eligible inventory, exclude selects none, and select selects at least one.",
7075
+ mutation_performed: false,
7076
+ mutation_outcome: "none",
7077
+ };
7078
+ }
7079
+ const submission: NativeIntendedUntrackedSelectionSubmission = {
7080
+ argumentTokens: input.submission!.argumentTokens,
7081
+ value: JSON.stringify({
7082
+ schema: "gentle-ai.review-intended-untracked-selection/v1",
7083
+ untracked_scope: parameters.untrackedScope,
7084
+ expected_untracked_inventory: inventory,
7085
+ intended_untracked: selected.intendedUntracked,
7086
+ }),
7087
+ };
7088
+ const resolved = await negotiatedStatusForHostTransport(
7089
+ nativeReviewCli,
7090
+ {
7091
+ cwd: defaultCwd,
7092
+ untrackedScope: parameters.untrackedScope,
7093
+ expectedUntrackedInventory: inventory,
7094
+ intendedUntracked: selected.intendedUntracked,
7095
+ intendedUntrackedSelection: submission,
7096
+ ...(signal === undefined ? {} : { signal }),
7097
+ },
7098
+ retainedUntrackedSelections,
7099
+ defaultCwd,
7100
+ );
7101
+ if (resolved.transport !== undefined) {
7102
+ return {
7103
+ ...hostTransportUnavailable(parameters.operation, resolved.transport),
7104
+ ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
7105
+ };
7106
+ }
7107
+ const resolvedStatus = resolved.status!;
7108
+ const candidateIdentity = nativePreLineageCandidateIdentity(resolvedStatus);
7109
+ if (candidateIdentity !== undefined) {
7110
+ retainNativeUntrackedSelection(
7111
+ retainedUntrackedSelections,
7112
+ defaultCwd,
7113
+ "",
7114
+ Object.freeze({
7115
+ untrackedScope: parameters.untrackedScope,
7116
+ expectedUntrackedInventory: inventory!,
7117
+ intendedUntracked: Object.freeze([...selected.intendedUntracked!]),
7118
+ submission,
7119
+ ...candidateIdentity,
7120
+ }),
7121
+ );
7122
+ }
7123
+ const resolvedMapped = mapNativeTargetStatus(
7124
+ parameters.operation,
7125
+ resolvedStatus,
7126
+ undefined,
7127
+ );
7128
+ return {
7129
+ ...resolvedMapped,
7130
+ ...("selectionBinding" in resolvedMapped
7131
+ ? { nextStep: INSPECT_UNTRACKED_SELECTION_NEXT_STEP }
7132
+ : {}),
7133
+ ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
7134
+ };
5108
7135
  }
5109
7136
  return nativeStatusUnsupported(parameters.operation);
5110
7137
  } catch (error) {
@@ -5324,6 +7351,9 @@ async function executeReviewControllerOperation(
5324
7351
  // The registry owns restoring writability of its 0555 views before
5325
7352
  // removal; a terminal approved cleanup keeps the lineage projection.
5326
7353
  candidateViews?.cleanupTerminal(parameters.lineageId, "approved", defaultCwd);
7354
+ // gentle-pi#668: `closed` is never auto-derived or recorded here --
7355
+ // a parent that wants the on-path passes nativeReviewOutcome:
7356
+ // "closed" explicitly on its next assess call for this candidate.
5327
7357
  return {
5328
7358
  operation: parameters.operation,
5329
7359
  status: "closed",
@@ -5347,13 +7377,18 @@ async function executeReviewControllerOperation(
5347
7377
  if (Object.keys(input).some((key) => key !== "consentBinding" && key !== "answer") || Object.keys(input).length !== 2) throw new Error("Review controller answer-consent input must contain exactly consentBinding and answer");
5348
7378
  if (typeof input.consentBinding !== "string" || input.consentBinding.length === 0) throw new Error("Review controller answer-consent requires an opaque consentBinding");
5349
7379
  if (input.answer !== "granted" && input.answer !== "declined") throw new Error("Review controller answer-consent answer must be granted or declined");
5350
- const pending = pendingReviewConsentRegistry.get(pendingReviewConsentSession)?.get(input.consentBinding);
7380
+ // gentle-pi#455: resolve the binding by its opaque id alone, so a
7381
+ // binding one active Pi session's START created is answerable from any
7382
+ // active session presenting it -- not only the session that created it.
7383
+ const resolved = pendingReviewConsentRegistry.resolve(input.consentBinding);
7384
+ const pending = resolved?.pending;
7385
+ const owningSession = resolved?.sessionKey ?? pendingReviewConsentSession;
5351
7386
  if (pending === undefined || pending.expiresAt <= reviewConsentNow()) {
5352
7387
  const disposition = pending === undefined
5353
- ? pendingReviewConsentRegistry.staleDisposition(pendingReviewConsentSession, input.consentBinding)
7388
+ ? pendingReviewConsentRegistry.staleDisposition(input.consentBinding)
5354
7389
  : PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED;
5355
7390
  const stale = staleConsentBindingDiagnostics(input.consentBinding, disposition);
5356
- if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession);
7391
+ if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
5357
7392
  if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
5358
7393
  try {
5359
7394
  const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
@@ -5366,14 +7401,15 @@ async function executeReviewControllerOperation(
5366
7401
  return nativeStatusFailed(parameters.operation, error);
5367
7402
  }
5368
7403
  }
5369
- if (realpathSync(defaultCwd) !== pending.repositoryCwd) throw new Error("Review controller consent repository binding changed");
7404
+ const answeringRepositoryCwd = realpathSync(defaultCwd);
7405
+ if (answeringRepositoryCwd !== pending.repositoryCwd) return consentBindingRepositoryMismatchOutcome(parameters.operation, input.consentBinding, pending.repositoryCwd, answeringRepositoryCwd);
5370
7406
  if (reviewConsentDigest(pending.consent) !== pending.consentDigest) throw new Error("Review controller consent envelope binding changed");
5371
7407
  pending.verifyCandidate();
5372
7408
  if (nativeReviewCli?.answerConsent === undefined) throw new Error("Native review consent follow-up is unavailable");
5373
- if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession)) {
7409
+ if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession)) {
5374
7410
  const stale = staleConsentBindingDiagnostics(
5375
7411
  input.consentBinding,
5376
- pendingReviewConsentRegistry.staleDisposition(pendingReviewConsentSession, input.consentBinding),
7412
+ pendingReviewConsentRegistry.staleDisposition(input.consentBinding),
5377
7413
  );
5378
7414
  if (nativeReviewCli.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
5379
7415
  try {
@@ -5390,11 +7426,14 @@ async function executeReviewControllerOperation(
5390
7426
  try {
5391
7427
  const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
5392
7428
  if (gated !== undefined) {
5393
- cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession);
7429
+ // gentle-pi#668: mode disabled for this exact candidate -- keyed by
7430
+ // its targetIdentity, never by repository alone.
7431
+ recordNativeReviewOutcome(pending.authorityCwd, pending.consent.targetIdentity, NATIVE_REVIEW_OUTCOME.UNAVAILABLE);
7432
+ cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
5394
7433
  return gated;
5395
7434
  }
5396
7435
  } catch (error) {
5397
- cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession);
7436
+ cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
5398
7437
  return nativeOperationFailure(parameters.operation, error);
5399
7438
  }
5400
7439
  // The one-shot binding is consumed before the first answer-path await. Any
@@ -5408,6 +7447,9 @@ async function executeReviewControllerOperation(
5408
7447
  ...(signal === undefined ? {} : { signal }),
5409
7448
  });
5410
7449
  if (answered.kind === "declined") {
7450
+ // gentle-pi#668: candidate-scoped decline, keyed by this exact
7451
+ // candidate's targetIdentity, never by repository alone.
7452
+ recordNativeReviewOutcome(pending.authorityCwd, pending.consent.targetIdentity, NATIVE_REVIEW_OUTCOME.DECLINED);
5411
7453
  pending.cleanupCandidate();
5412
7454
  return {
5413
7455
  operation: parameters.operation,
@@ -5418,6 +7460,9 @@ async function executeReviewControllerOperation(
5418
7460
  };
5419
7461
  }
5420
7462
  retainNativeUntrackedSelection(retainedUntrackedSelections, pending.authorityCwd, answered.start.lineageId, pending.untrackedSelection);
7463
+ // gentle-pi#706: a START completed through answer-consent consumed the
7464
+ // adopted pre-lineage selection too; clear it like the direct path.
7465
+ clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, pending.authorityCwd, "");
5421
7466
  completed = completeNativeStart(parameters.operation, answered.start, pending.repositoryCwd, pending.candidateView, pending.candidateViews);
5422
7467
  } catch (error) {
5423
7468
  const value = error as { mutationOutcome?: unknown };
@@ -5428,15 +7473,6 @@ async function executeReviewControllerOperation(
5428
7473
  projection: "workspace",
5429
7474
  }, retainedUntrackedSelections);
5430
7475
  }
5431
- if (input.answer === "granted") {
5432
- try {
5433
- writeReviewConsentLatch(pending.repositoryCwd);
5434
- } catch (error) {
5435
- try {
5436
- context?.ui.notify(`Native review start completed, but Pi could not record the local consent latch: ${error instanceof Error ? error.message : String(error)}`, "warning");
5437
- } catch { /* Reporting is best effort; native completion remains authoritative. */ }
5438
- }
5439
- }
5440
7476
  return completed;
5441
7477
  }
5442
7478
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED) {
@@ -5456,7 +7492,7 @@ async function executeReviewControllerOperation(
5456
7492
  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));
5457
7493
  if (rejected) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" };
5458
7494
  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 }) };
5459
- 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, writeReviewConsentLatch, reviewConsentNow, reviewConsentScheduleTimer, submission);
7495
+ 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);
5460
7496
  return { ...result, operation: parameters.operation };
5461
7497
  }
5462
7498
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
@@ -5478,9 +7514,35 @@ async function executeReviewControllerOperation(
5478
7514
  if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) return nativeStartRejection("base-ref-invalid");
5479
7515
  if (baseRef !== undefined && rawStart.committedOnly !== true) return nativeStartRejection("committed-only-required");
5480
7516
  if (baseRef === undefined && "committedOnly" in rawStart) return nativeStartRejection("committed-only-invalid");
5481
- const untrackedSelection = validateNativeStartUntrackedSelection(rawStart);
5482
- if (untrackedSelection.reason !== undefined) return nativeStartRejection(untrackedSelection.reason);
5483
- const retainedUntrackedSelection = cloneRetainedNativeUntrackedSelection(untrackedSelection);
7517
+ const explicitUntrackedSelection =
7518
+ validateNativeStartUntrackedSelection(rawStart);
7519
+ if (explicitUntrackedSelection.reason !== undefined)
7520
+ return nativeStartRejection(explicitUntrackedSelection.reason);
7521
+ // gentle-pi#706: a plain START adopts the selection an inspect
7522
+ // untrackedScope round trip retained pre-lineage; explicit input or a
7523
+ // carried submission always wins over the retained entry.
7524
+ const retainedPreLineageSelection =
7525
+ explicitUntrackedSelection.untrackedScope === undefined &&
7526
+ intendedUntrackedSelection === undefined
7527
+ ? readRetainedPreLineageNativeUntrackedSelection(
7528
+ retainedUntrackedSelections,
7529
+ defaultCwd,
7530
+ )
7531
+ : undefined;
7532
+ const untrackedSelection: NativeStartUntrackedSelection =
7533
+ retainedPreLineageSelection === undefined
7534
+ ? explicitUntrackedSelection
7535
+ : {
7536
+ untrackedScope: retainedPreLineageSelection.untrackedScope,
7537
+ expectedUntrackedInventory:
7538
+ retainedPreLineageSelection.expectedUntrackedInventory,
7539
+ intendedUntracked: [...retainedPreLineageSelection.intendedUntracked],
7540
+ };
7541
+ const untrackedSubmission =
7542
+ intendedUntrackedSelection ?? retainedPreLineageSelection?.submission;
7543
+ const retainedUntrackedSelection =
7544
+ retainedPreLineageSelection ??
7545
+ cloneRetainedNativeUntrackedSelection(explicitUntrackedSelection);
5484
7546
  let canonicalBaseRef: string | undefined;
5485
7547
  if (baseRef !== undefined) {
5486
7548
  try {
@@ -5505,16 +7567,64 @@ async function executeReviewControllerOperation(
5505
7567
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5506
7568
  ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }),
5507
7569
  ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
5508
- ...(intendedUntrackedSelection === undefined ? {} : { intendedUntrackedSelection }),
7570
+ ...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
5509
7571
  ...(signal === undefined ? {} : { signal }),
5510
7572
  }, retainedUntrackedSelections, defaultCwd);
5511
7573
  if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport);
5512
7574
  target = negotiated.status!;
7575
+ // gentle-pi#874: the STATUS this START already fetched can itself
7576
+ // offer the committed-range START for an empty workspace candidate.
7577
+ // Adopt its own base commit *here*, before the candidate view and the
7578
+ // native START are resolved, and re-derive the target for that range,
7579
+ // so all three agree on one base-diff identity. Adopting the offer
7580
+ // later left the workspace target and the base-diff candidate view
7581
+ // disagreeing, and START failed with identity-mismatch. Both an
7582
+ // explicit caller baseRef and any START with an untracked selection in
7583
+ // play keep today's single-STATUS flow; only an adopted offer pays the
7584
+ // second read-only STATUS.
7585
+ if (canonicalBaseRef === undefined && untrackedSelection.untrackedScope === undefined && untrackedSubmission === undefined) {
7586
+ const offeredBaseRef = offeredCommittedRangeBaseRef(target);
7587
+ if (offeredBaseRef !== undefined) {
7588
+ const renegotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
7589
+ cwd: defaultCwd,
7590
+ ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
7591
+ baseRef: offeredBaseRef,
7592
+ committedOnly: true,
7593
+ ...(signal === undefined ? {} : { signal }),
7594
+ }, retainedUntrackedSelections, defaultCwd);
7595
+ if (renegotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, renegotiated.transport);
7596
+ canonicalBaseRef = offeredBaseRef;
7597
+ target = renegotiated.status!;
7598
+ }
7599
+ }
7600
+ if (
7601
+ retainedPreLineageSelection !== undefined &&
7602
+ !sameNativePreLineageCandidate(retainedPreLineageSelection, target)
7603
+ ) {
7604
+ clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, "");
7605
+ return {
7606
+ operation: parameters.operation,
7607
+ status: "blocked",
7608
+ outcome: "native-start-retained-selection-candidate-mismatch",
7609
+ mutation_performed: false,
7610
+ mutation_outcome: "none",
7611
+ next_action: "inspect-and-resolve-the-current-intended-untracked-selection",
7612
+ };
7613
+ }
5513
7614
  if (target.nextTransition?.kind === "collect" || target.applicability !== "unrelated" || target.action !== "start") return mapNativeTargetStatus(parameters.operation, target, parameters.lineageId);
5514
7615
  } catch (error) {
5515
7616
  return nativeOperationFailure(parameters.operation, error);
5516
7617
  }
5517
- const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null });
7618
+ // gentle-pi#323: the replay key must fold in the current candidate
7619
+ // content identity. Without it, a second START with identical
7620
+ // {cwd, lineageId, input, inputPath} reuses a still-live (never
7621
+ // lineage-bound) frozen candidate view from within the consent TTL
7622
+ // window even after the live candidate content changed underneath
7623
+ // it, and dead-ends at candidate-target-projection-drift with no
7624
+ // recovery. Folding in currentCandidateTree makes a content change
7625
+ // mint a fresh replay key -- and therefore a fresh candidate view --
7626
+ // instead of reusing the stale one.
7627
+ const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null, candidateTree: target.projection.currentCandidateTree });
5518
7628
  // Synchronously drop any binding whose TTL has already elapsed
5519
7629
  // before reusing its retained candidate view, so a fresh-candidate
5520
7630
  // retry cannot reuse a view tied to an expired binding and trip
@@ -5543,7 +7653,7 @@ async function executeReviewControllerOperation(
5543
7653
  targetIdentity: target.targetIdentity,
5544
7654
  projection: target.projection.projection,
5545
7655
  ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
5546
- ...(intendedUntrackedSelection === undefined ? {} : { intendedUntrackedSelection }),
7656
+ ...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
5547
7657
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5548
7658
  ...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
5549
7659
  ...(focus === undefined ? {} : { focus }),
@@ -5577,14 +7687,14 @@ async function executeReviewControllerOperation(
5577
7687
  cleanupCandidate: () => {
5578
7688
  if (candidateCleaned) return;
5579
7689
  candidateCleaned = true;
5580
- consentCandidateView.cleanup();
7690
+ try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent expiry/teardown still completes. */ }
5581
7691
  },
5582
7692
  ...(retainedUntrackedSelection === undefined ? {} : { untrackedSelection: retainedUntrackedSelection }),
5583
7693
  consent: error.consent,
5584
7694
  consentDigest,
5585
7695
  expiresAt: reviewConsentNow() + PENDING_REVIEW_CONSENT_TTL_MS,
5586
7696
  };
5587
- pendingReviewConsentRegistry.ensure(pendingReviewConsentSession).set(id, pending);
7697
+ pendingReviewConsentRegistry.add(pendingReviewConsentSession, pending);
5588
7698
  pending.expiry = reviewConsentScheduleTimer(
5589
7699
  () => expirePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession),
5590
7700
  PENDING_REVIEW_CONSENT_TTL_MS,
@@ -5601,22 +7711,30 @@ async function executeReviewControllerOperation(
5601
7711
  };
5602
7712
  }
5603
7713
  retainNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, result.lineageId, retainedUntrackedSelection);
7714
+ // gentle-pi#706: the adopted pre-lineage selection dies with the START
7715
+ // that consumed it; it must never leak to the next candidate.
7716
+ clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, "");
5604
7717
  return completeNativeStart(parameters.operation, result, defaultCwd, candidateView, candidateViews);
5605
7718
  } catch (error) {
5606
- if (error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true }));
7719
+ if (!nativeStartAttempted && error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true }));
5607
7720
  if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason);
5608
7721
  const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
5609
7722
  const provenNoMutation = value.mutationOutcome === "none";
5610
- const preNativeCandidateFailure = !nativeStartAttempted && error instanceof CandidateViewError;
5611
- if (candidateView && candidateViews && (provenNoMutation || preNativeCandidateFailure)) candidateViews.cleanup(candidateView.token);
7723
+ const preNativeFailure = !nativeStartAttempted;
7724
+ if (candidateView && candidateViews && (provenNoMutation || preNativeFailure)) candidateViews.cleanup(candidateView.token);
7725
+ const nativeCliError = asNativeReviewCliError(error);
5612
7726
  const failure = provenNoMutation
5613
7727
  ? error
5614
- : preNativeCandidateFailure
5615
- ? Object.assign(error, { candidateViewPreNative: true })
5616
- : Object.assign(error instanceof Error ? error : new Error(String(error)), {
5617
- mutationOutcome: "unknown",
5618
- nextAction: "review.status",
5619
- });
7728
+ : preNativeFailure
7729
+ ? error instanceof CandidateViewError ? Object.assign(error, { candidateViewPreNative: true }) : error
7730
+ : Object.assign(
7731
+ error instanceof Error
7732
+ ? error
7733
+ : nativeCliError === undefined
7734
+ ? new Error(String(error))
7735
+ : { name: "NativeReviewCliError", code: nativeCliError.code, diagnostics: nativeCliError.diagnostics },
7736
+ { mutationOutcome: "unknown", nextAction: "review.status" },
7737
+ );
5620
7738
  return reconcileNativeMutationFailure(parameters.operation, failure, nativeReviewCli, {
5621
7739
  cwd: defaultCwd,
5622
7740
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
@@ -5725,12 +7843,16 @@ async function executeReviewControllerOperation(
5725
7843
  const effectiveUntrackedSelection = rawStatus === undefined && parameters.lineageId !== undefined
5726
7844
  ? readRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId)
5727
7845
  : untrackedSelection;
7846
+ const retainedCommittedTarget = rawStatus === undefined && parameters.lineageId !== undefined && candidateViews?.hasProjection(parameters.lineageId, defaultCwd)
7847
+ ? candidateViews.resolveProjection(parameters.lineageId, defaultCwd)
7848
+ : undefined;
7849
+ const effectiveBaseRef = baseRef ?? (retainedCommittedTarget?.committedOnly === true ? retainedCommittedTarget.baseCommit : undefined);
5728
7850
  if (nativeReviewCli?.targetStatus !== undefined) {
5729
7851
  try {
5730
7852
  const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
5731
7853
  cwd: defaultCwd,
5732
7854
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5733
- ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }),
7855
+ ...(effectiveBaseRef === undefined ? {} : { baseRef: effectiveBaseRef, committedOnly: true }),
5734
7856
  ...(effectiveUntrackedSelection.untrackedScope === undefined ? {} : effectiveUntrackedSelection),
5735
7857
  ...(signal === undefined ? {} : { signal }),
5736
7858
  }, retainedUntrackedSelections, defaultCwd);
@@ -5749,7 +7871,7 @@ async function executeReviewControllerOperation(
5749
7871
  ) retainNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId, retainedUntrackedSelection);
5750
7872
  clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state);
5751
7873
  hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, status);
5752
- return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId, defaultCwd), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
7874
+ return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
5753
7875
  } catch (error) {
5754
7876
  return nativeOperationFailure(parameters.operation, error);
5755
7877
  }
@@ -5767,6 +7889,9 @@ export const __testing = {
5767
7889
  listDiscoverableAgents,
5768
7890
  orderDiscoverableAgents,
5769
7891
  classifyGuardedCommand,
7892
+ evaluateGuardedCommand,
7893
+ guardedCommandPreview,
7894
+ guardedCommandTitle,
5770
7895
  loadRuntimeGuardrailsConfig,
5771
7896
  buildGentlePrompt,
5772
7897
  nativeStatusUnsupported,
@@ -5789,8 +7914,22 @@ export const __testing = {
5789
7914
  resolveBackgroundSubagentsCapability,
5790
7915
  readActiveToolNames,
5791
7916
  renderBackgroundSubagentsStatusLine,
7917
+ renderRddStatusLine,
7918
+ isValidRddModeStatus,
7919
+ resolveRddModeStatus,
7920
+ resolveRddStatusLine,
7921
+ RDD_STATUS_TIMEOUT_MS,
7922
+ RDD_STATUS_MEMO_TTL_MS,
7923
+ clearRddStatusMemoForTesting,
7924
+ readNativeReviewOutcome,
7925
+ recordNativeReviewOutcome,
7926
+ clearNativeReviewOutcomeMemoForTesting,
5792
7927
  resolveControllerSddStatus,
5793
7928
  resolveStartupControllerSddStatus,
7929
+ resolveSddChangeStartup,
7930
+ resolveSelectedNativeSddChangeStartup,
7931
+ readSddChangeFlag,
7932
+ resetTelemetryTriggerGuardForTesting,
5794
7933
  createGentleAiExtension: createGentleAiExtensionForTesting,
5795
7934
  };
5796
7935
 
@@ -5824,6 +7963,22 @@ export interface GentleAiRuntimeDependencies {
5824
7963
  // sleep and without relying on the queued cleanup macrotask firing.
5825
7964
  now?: () => number;
5826
7965
  scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
7966
+ // The environment the session's child processes inherit; tests inject a
7967
+ // plain object so the handshake declaration is observable without
7968
+ // touching the test runner's own process.env.
7969
+ processEnv?: NodeJS.ProcessEnv;
7970
+ // Package-owned children use this parent-bound channel only to ask whether
7971
+ // their own pending ordinary START may replay a grant locally.
7972
+ childStandingReviewPermissionClient?: Pick<ChildStandingReviewPermissionClient, "requestAuthorization" | "close">;
7973
+ // gentle-pi#677: test-only seams for the telemetry trigger. Production
7974
+ // leaves both undefined: the real package-local resolveGentleAiBinary()
7975
+ // and the real detached child_process spawn run.
7976
+ resolveTelemetryTriggerBinary?: () => string;
7977
+ telemetryTriggerSpawn?: TelemetryTriggerSpawn;
7978
+ // Test-only seam for the foreground `/gentle:telemetry` slash command's
7979
+ // bounded exec; production leaves this undefined and uses the real node
7980
+ // exec-file adapter shared with the rest of the extension.
7981
+ telemetryExecFileAdapter?: ExecFileAdapter;
5827
7982
  }
5828
7983
 
5829
7984
  export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void {
@@ -5832,28 +7987,79 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
5832
7987
 
5833
7988
  function createGentleAiExtensionForTesting(
5834
7989
  dependencies: GentleAiRuntimeDependencies = {},
5835
- writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
5836
7990
  ): (pi: ExtensionAPI) => void {
5837
7991
  const nativeReviewCli = dependencies.nativeReviewCli === undefined ? createNativeReviewCli() : dependencies.nativeReviewCli;
7992
+ const childStandingReviewPermissionLease = dependencies.childStandingReviewPermissionClient === undefined
7993
+ ? acquireChildStandingReviewPermissionClient(dependencies.processEnv ?? process.env)
7994
+ : undefined;
7995
+ const childStandingReviewPermission = dependencies.childStandingReviewPermissionClient ?? childStandingReviewPermissionLease?.client;
5838
7996
  const reviewConsentNow = dependencies.now ?? (() => Date.now());
5839
7997
  const reviewConsentScheduleTimer = dependencies.scheduleTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
5840
7998
  const pendingReviewConsentRegistry = dependencies.pendingReviewConsentRegistry ?? processPendingReviewConsentRegistry;
7999
+ const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary;
8000
+ const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter();
5841
8001
  return function gentleAi(pi: ExtensionAPI): void {
8002
+ const flags = pi as unknown as { registerFlag?: (name: string, definition: { description: string; type: "string"; default?: string }) => void };
8003
+ flags.registerFlag?.(SDD_CHANGE_FLAG, {
8004
+ description: "Internal launch-local selected SDD change identity for package-owned child agents.",
8005
+ type: "string",
8006
+ });
8007
+ declareReviewRelayHandshake(dependencies.processEnv ?? process.env);
5842
8008
  const pendingReviewConsentFallbackKey = Symbol("pending-review-consent-fallback");
5843
8009
  const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
5844
8010
  const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events);
8011
+ const permissionEnvironment = dependencies.processEnv ?? process.env;
8012
+
8013
+ const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => {
8014
+ try {
8015
+ (context.ui as unknown as { setStatus?: (key: string, text?: string) => void }).setStatus?.(
8016
+ REVIEW_SESSION_PERMISSION_STATUS_KEY,
8017
+ active ? REVIEW_SESSION_PERMISSION_STATUS_TEXT : undefined,
8018
+ );
8019
+ } catch { /* Status is nonblocking and never permission authority. */ }
8020
+ };
8021
+ const capturePermissionIdentity = (context: ExtensionContext, cwd: string = context.cwd): Promise<ReviewSessionIdentity | undefined> =>
8022
+ captureReviewSessionIdentity({ ...context, cwd }, permissionEnvironment);
8023
+ const refreshReviewSessionPermissionStatus = async (context: ExtensionContext): Promise<ReviewSessionIdentity | undefined> => {
8024
+ const identity = await capturePermissionIdentity(context);
8025
+ setReviewSessionPermissionStatus(context, identity !== undefined && hasReviewSessionPermission(identity));
8026
+ return identity;
8027
+ };
8028
+ const revokeCurrentReviewSessionPermission = (context: ExtensionContext): boolean => {
8029
+ const coordinates = reviewSessionManagerAndId(context);
8030
+ const revoked = coordinates === undefined ? false : revokeReviewSessionPermissionsForSession(coordinates.manager, coordinates.sessionId);
8031
+ setReviewSessionPermissionStatus(context, false);
8032
+ return revoked;
8033
+ };
8034
+ const revokeCurrentRepositoryReviewSessionPermission = async (context: ExtensionContext): Promise<boolean> => {
8035
+ const identity = await capturePermissionIdentity(context);
8036
+ const revoked = identity === undefined ? false : revokeReviewSessionPermission(identity);
8037
+ setReviewSessionPermissionStatus(context, false);
8038
+ return revoked;
8039
+ };
5845
8040
 
5846
- pi.on("session_shutdown", (_event, context) => {
8041
+ let reminderSessionActive = true;
8042
+ let reminderEpoch = 0;
8043
+ pi.on("session_shutdown", (event, context) => {
8044
+ reminderSessionActive = false;
8045
+ reminderEpoch += 1;
8046
+ // Pi tears down this registry on reload as well as session replacement/quit.
8047
+ try { candidateViews?.cleanupAll(); } catch { /* Preserve failed owned views for later recovery. */ }
8048
+ const reason = (event as { reason?: unknown }).reason;
8049
+ if (reason !== "reload") {
8050
+ if (childStandingReviewPermissionLease !== undefined) childStandingReviewPermissionLease.closeIfCurrent();
8051
+ else childStandingReviewPermission?.close();
8052
+ revokeCurrentReviewSessionPermission(context);
8053
+ }
5847
8054
  const sessionKey = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey);
5848
8055
  cleanupAllPendingReviewConsents(pendingReviewConsentRegistry, sessionKey);
5849
8056
  processRetainedNativeStatusSelections.delete(sessionKey);
5850
8057
  processAgentEndSubagentDepth.delete(sessionKey);
5851
- processAgentEndPreflightNudgedTargets.delete(sessionKey);
5852
- processAgentEndSessionBaseline.delete(sessionKey);
5853
8058
  });
5854
8059
 
5855
8060
  pi.registerTool({
5856
8061
  name: "gentle_review_scope",
8062
+ renderShell: "self",
5857
8063
  label: "Gentle Review Scope",
5858
8064
  description: "Read one bounded, integrity-checked page of the controller-owned frozen changed scope. This read-only tool never inspects the ambient or candidate tree.",
5859
8065
  parameters: REVIEW_SCOPE_PARAMETERS,
@@ -5865,8 +8071,8 @@ function createGentleAiExtensionForTesting(
5865
8071
  context as GentleAiRenderContext | undefined,
5866
8072
  );
5867
8073
  },
5868
- renderResult(result, options) {
5869
- return renderGentleAiResult(result, options);
8074
+ renderResult(result, options, theme, context) {
8075
+ return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
5870
8076
  },
5871
8077
  async execute(_toolCallId, parameters) {
5872
8078
  const input = parameters as ReviewScopeParameters;
@@ -5875,8 +8081,28 @@ function createGentleAiExtensionForTesting(
5875
8081
  },
5876
8082
  });
5877
8083
 
8084
+ // The lens a reviewer capture runs is inside its collect binding, so the
8085
+ // card can say "review capture · risk" instead of a bare operation name.
8086
+ const lensLabel = (lens: unknown): string | undefined =>
8087
+ typeof lens === "string" && lens.length > 0 ? lens.replace(/^review-/, "") : undefined;
8088
+ const collectBindingLens = (binding: unknown): string | undefined => {
8089
+ if (typeof binding !== "string") return undefined;
8090
+ try {
8091
+ const parsed = JSON.parse(binding) as Record<string, unknown>;
8092
+ const subject = (parsed.artifactSubject ?? parsed.artifact_subject) as Record<string, unknown> | undefined;
8093
+ return lensLabel(subject?.lens);
8094
+ } catch {
8095
+ return undefined;
8096
+ }
8097
+ };
8098
+ const withLenses = (operation: string, lenses: readonly (string | undefined)[]): string => {
8099
+ const named = lenses.filter((lens): lens is string => lens !== undefined);
8100
+ return named.length === 0 ? operation : `${operation} · ${named.join(" · ")}`;
8101
+ };
8102
+
5878
8103
  pi.registerTool({
5879
8104
  name: "gentle_review_capture_group",
8105
+ renderShell: "self",
5880
8106
  label: "Gentle Review Capture Group",
5881
8107
  description: "Capture one complete provider-issued materialize reviewer group. It validates the exact ordered current collect set, forecasts its bounded model cost, runs reviewers concurrently, and admits outputs one at a time in provider order.",
5882
8108
  promptSnippet: "Use one complete exact current STATUS materialize reviewer group; acknowledge its forecast before the grouped run.",
@@ -5886,11 +8112,13 @@ function createGentleAiExtensionForTesting(
5886
8112
  ],
5887
8113
  parameters: REVIEW_CAPTURE_GROUP_PARAMETERS,
5888
8114
  executionMode: "sequential",
5889
- renderCall(_args, theme, context) {
5890
- return renderGentleAiLifecycleCall("review capture group", theme, context as GentleAiRenderContext | undefined);
8115
+ renderCall(args, theme, context) {
8116
+ const bindings = (args as { collectBindings?: unknown }).collectBindings;
8117
+ const lenses = Array.isArray(bindings) ? bindings.map(collectBindingLens) : [];
8118
+ return renderGentleAiLifecycleCall(withLenses("review capture group", lenses), theme, context as GentleAiRenderContext | undefined);
5891
8119
  },
5892
- renderResult(result, options) {
5893
- return renderGentleAiResult(result, options);
8120
+ renderResult(result, options, theme, context) {
8121
+ return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
5894
8122
  },
5895
8123
  async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
5896
8124
  if (signal?.aborted) throw new Error("Review capture group was cancelled");
@@ -5909,6 +8137,7 @@ function createGentleAiExtensionForTesting(
5909
8137
 
5910
8138
  pi.registerTool({
5911
8139
  name: "gentle_review_capture",
8140
+ renderShell: "self",
5912
8141
  label: "Gentle Review Capture",
5913
8142
  description: "Capture exactly one provider-issued ordinary native review collect slot. This is not a controller operation: it validates one opaque collect binding against current target-scoped STATUS, executes at most one capture, and never follows a transition.",
5914
8143
  promptSnippet: "Use one exact current STATUS collectBinding for one ordinary native capture; call fresh STATUS before every additional capture.",
@@ -5919,15 +8148,15 @@ function createGentleAiExtensionForTesting(
5919
8148
  ],
5920
8149
  parameters: REVIEW_CAPTURE_PARAMETERS,
5921
8150
  executionMode: "sequential",
5922
- renderCall(_args, theme, context) {
8151
+ renderCall(args, theme, context) {
5923
8152
  return renderGentleAiLifecycleCall(
5924
- "review capture",
8153
+ withLenses("review capture", [collectBindingLens((args as { collectBinding?: unknown }).collectBinding)]),
5925
8154
  theme,
5926
8155
  context as GentleAiRenderContext | undefined,
5927
8156
  );
5928
8157
  },
5929
- renderResult(result, options) {
5930
- return renderGentleAiResult(result, options);
8158
+ renderResult(result, options, theme, context) {
8159
+ return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
5931
8160
  },
5932
8161
  async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
5933
8162
  if (signal?.aborted) throw new Error("Review capture was cancelled");
@@ -5949,18 +8178,21 @@ function createGentleAiExtensionForTesting(
5949
8178
 
5950
8179
  pi.registerTool({
5951
8180
  name: "gentle_review",
8181
+ renderShell: "self",
5952
8182
  label: "Gentle Review Controller",
5953
8183
  description:
5954
8184
  "Inspect and recover review authority and start native ordinary review. Ordinary capture is available only through the separate gentle_review_capture tool. Review outcomes never authorize delivery: commit, push, pull-request, and release commands follow ordinary repository policy. RESET/RECOVER remain destructive and are executed by the audited native CLI.",
5955
8185
  promptSnippet: "Inspect authority, then start native ordinary review; use gentle_review_capture for one current collect slot",
5956
8186
  promptGuidelines: [
5957
8187
  'Call {"operation":"inspect"} before START. New native ordinary START uses a JSON string such as "{\\"mode\\":\\"ordinary\\"}"; an explicit baseRef must be paired with committedOnly: true to request a committed range, while policyPath remains repository-local. policyHash is legacy compact-only. The controller derives lineage, Git/untracked scope, tier, lenses, authored lines, and budget; the frozen correction budget counts logical corrections, while correction-plan correctionLines count diff lines (one replaced source line is one deletion plus one addition).',
8188
+ 'An inspect blocked on the intended-untracked selection returns nextStep naming the exact continuation: call select-intended-untracked with the returned selectionBinding, or call inspect again with top-level untrackedScope ("exclude", or "select" with intendedUntracked) to resolve the round trip in one call; the retained selection is adopted by the next plain START.',
5958
8189
  "Use RECONCILE_AUTHORITY only to quarantine one invalid native recovery successor. Supply exact predecessorLineage, expectedPredecessorRevision, successorLineage, expectedSuccessorRevision, actor, and reason values; Pi derives and displays the seven-line native authorization binding for fresh UI approval. The predecessor stays untouched, native returns the durable audit record, and Pi never falls back to RESET or RECOVER.",
5959
- "Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, capturedLensResults, findingsPresent, evidenceRecordsPresent, actor, and reason; QUARANTINE_LEGACY accepts only the published malformed freeze-findings diagnostic/disposition. A dual reconciliation may supply only anomalies `unchanged_target,malformed_recovery_authorization` in that exact order. Use REPAIR_LEGACY_ALIAS only with lineage, actor, and reason: Pi freshly reads native inventory and derives repository, revision, diagnostic, disposition, and the exact eight-line binding before interactive approval. `review dispose-result` is unsupported pending design.",
8190
+ "Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, capturedLensResults, findingsPresent, actor, and reason; QUARANTINE_LEGACY accepts only the published malformed freeze-findings diagnostic/disposition. A dual reconciliation may supply only anomalies `unchanged_target,malformed_recovery_authorization` in that exact order. Use REPAIR_LEGACY_ALIAS only with lineage, actor, and reason: Pi freshly reads native inventory and derives repository, revision, diagnostic, disposition, and the exact eight-line binding before interactive approval. `review dispose-result` is unsupported pending design.",
5960
8191
  "Lens, refuter, and validator verdicts are admitted natively, never Pi-authored. Use gentle_review_capture with exactly one current provider-owned collectBinding for ordinary native capture; it never follows another transition.",
5961
8192
  "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.",
5962
- "A consent-required START returns the complete provider envelope and an opaque consent_binding, then stops. The parent presents and localizes that envelope without changing machine tokens, commands, target IDs, or invocations. After one explicit human answer, call answer-consent exactly once with a JSON string containing only consentBinding and answer (`granted` or `declined`). 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.",
8193
+ "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.",
5963
8194
  "Use gentle_review only for native review authority operations; delivery commands follow ordinary repository policy.",
8195
+ '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.',
5964
8196
  ],
5965
8197
  parameters: REVIEW_CONTROLLER_PARAMETERS,
5966
8198
  executionMode: "sequential",
@@ -5971,26 +8203,120 @@ function createGentleAiExtensionForTesting(
5971
8203
  context as GentleAiRenderContext | undefined,
5972
8204
  );
5973
8205
  },
5974
- renderResult(result, options) {
5975
- return renderGentleAiResult(result, options);
8206
+ renderResult(result, options, theme, context) {
8207
+ return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
5976
8208
  },
5977
8209
  async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
5978
8210
  if (signal?.aborted) throw new Error("Review controller operation was cancelled");
5979
8211
  await authorizeDestructiveReviewOperation(parameters, ctx);
5980
- const details = await executeReviewControllerOperation(
8212
+ const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
8213
+ const retainedSelections = processRetainedNativeStatusSelections.get(sessionKey)
8214
+ ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!;
8215
+ // Snapshot before native awaits: a concurrent own write is a new generation.
8216
+ const acknowledgementEpoch = reminderEpoch;
8217
+ let acknowledgementRoot: string | undefined;
8218
+ let acknowledgementMutation: string | undefined;
8219
+ try {
8220
+ const parsed = parseReviewControllerParameters(parameters);
8221
+ if (parsed.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED) {
8222
+ acknowledgementRoot = resolveReviewControllerWorkspaceRoot(parsed.workspaceRoot, ctx.cwd, candidateViews, parsed.lineageId);
8223
+ acknowledgementMutation = pendingReviewMutation(ctx.sessionManager, acknowledgementRoot);
8224
+ }
8225
+ } catch { /* Controller validation owns invalid parameters and unavailable roots. */ }
8226
+ let details = await executeReviewControllerOperation(
5981
8227
  parameters,
5982
8228
  ctx.cwd,
5983
8229
  nativeReviewCli,
5984
8230
  signal,
5985
8231
  candidateViews,
5986
8232
  ctx,
5987
- ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
8233
+ retainedSelections,
5988
8234
  pendingReviewConsentRegistry,
5989
8235
  pendingReviewConsentFallbackKey,
5990
- writeReviewConsentLatch,
5991
8236
  reviewConsentNow,
5992
8237
  reviewConsentScheduleTimer,
5993
8238
  );
8239
+ if (details.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED &&
8240
+ details.outcome === "native-approved-acknowledgement-completed" &&
8241
+ details.status === "closed" && details.authority === "burned" &&
8242
+ typeof details.target_identity === "string") {
8243
+ try {
8244
+ if (reminderSessionActive && acknowledgementEpoch === reminderEpoch && acknowledgementRoot && pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) === sessionKey) {
8245
+ consumeReviewMutation(pi, ctx.sessionManager, acknowledgementRoot, acknowledgementMutation, "acknowledged", details.target_identity);
8246
+ }
8247
+ } catch { /* Bookkeeping cannot hide a confirmed native burn. */ }
8248
+ }
8249
+ if (
8250
+ isHostReviewConsentEligibleOperation(parameters) &&
8251
+ details.outcome === "native-review-consent-required" &&
8252
+ typeof details.consent_binding === "string"
8253
+ ) {
8254
+ const resolved = pendingReviewConsentRegistry.resolve(details.consent_binding);
8255
+ const pending = resolved?.pending;
8256
+ const eligiblePending = pending !== undefined && isPiConsentV3(pending.consent)
8257
+ ? pending
8258
+ : undefined;
8259
+ const answerPendingConsent = async (answer: "granted" | "declined") => executeReviewControllerOperation(
8260
+ {
8261
+ operation: REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT,
8262
+ input: JSON.stringify({ consentBinding: eligiblePending!.id, answer }),
8263
+ workspaceRoot: eligiblePending!.authorityCwd,
8264
+ },
8265
+ ctx.cwd,
8266
+ nativeReviewCli,
8267
+ signal,
8268
+ candidateViews,
8269
+ ctx,
8270
+ retainedSelections,
8271
+ pendingReviewConsentRegistry,
8272
+ pendingReviewConsentFallbackKey,
8273
+ reviewConsentNow,
8274
+ reviewConsentScheduleTimer,
8275
+ );
8276
+ let permissionWorkspaceRoot: string | undefined;
8277
+ try {
8278
+ const parsed = parseReviewControllerParameters(parameters);
8279
+ permissionWorkspaceRoot = resolveReviewControllerWorkspaceRoot(parsed.workspaceRoot, ctx.cwd, candidateViews, parsed.lineageId);
8280
+ } catch {
8281
+ // The successful native operation above remains authoritative; an
8282
+ // unresolvable local binding simply cannot consume host permission.
8283
+ }
8284
+ const initialIdentity = permissionWorkspaceRoot === undefined
8285
+ ? undefined
8286
+ : await capturePermissionIdentity(ctx, permissionWorkspaceRoot);
8287
+ if (eligiblePending !== undefined && initialIdentity === undefined) {
8288
+ // A package child has no local standing grant. It may ask its
8289
+ // inherited parent only for the canonical repository identity of
8290
+ // this exact pending target, then replay this local binding once.
8291
+ const repositoryIdentity = permissionWorkspaceRoot === undefined
8292
+ ? undefined
8293
+ : await resolveCanonicalGitRepositoryIdentity(permissionWorkspaceRoot);
8294
+ if (repositoryIdentity !== undefined && await childStandingReviewPermission?.requestAuthorization(repositoryIdentity) === true) details = await answerPendingConsent("granted");
8295
+ } else if (eligiblePending !== undefined && initialIdentity !== undefined) {
8296
+ const initialEpoch = reviewSessionPermissionEpoch(initialIdentity);
8297
+ const permissionAlreadyActive = hasReviewSessionPermission(initialIdentity);
8298
+ const selection = initialEpoch === undefined
8299
+ ? undefined
8300
+ : permissionAlreadyActive
8301
+ ? { kind: "host-session" as const }
8302
+ : await presentReviewConsentUi(ctx, eligiblePending.consent);
8303
+ if (selection !== undefined) {
8304
+ const confirmedIdentity = await capturePermissionIdentity(ctx, permissionWorkspaceRoot);
8305
+ if (initialEpoch !== undefined && confirmedIdentity !== undefined && sameReviewSessionIdentity(initialIdentity, confirmedIdentity) && reviewSessionPermissionEpoch(confirmedIdentity) === initialEpoch) {
8306
+ const answer = selection.kind === "provider" ? selection.answer : "granted";
8307
+ details = await answerPendingConsent(answer);
8308
+ if (!permissionAlreadyActive && selection.kind === "host-session" && completedGrantedReviewConsent(details)) {
8309
+ if (grantReviewSessionPermission(confirmedIdentity, initialEpoch)) {
8310
+ setReviewSessionPermissionStatus(ctx, true);
8311
+ try { ctx.ui.notify("Reviews are allowed for this Pi session and this Git repository.", "info"); } catch { /* Nonblocking indication only. */ }
8312
+ } else {
8313
+ try { ctx.ui.notify("This review started, but the in-memory session permission registry was incompatible, so later candidates will ask again.", "warning"); } catch { /* Best effort. */ }
8314
+ }
8315
+ }
8316
+ }
8317
+ }
8318
+ }
8319
+ }
5994
8320
  return {
5995
8321
  content: [{ type: "text", text: JSON.stringify(details) }],
5996
8322
  details,
@@ -5999,10 +8325,16 @@ function createGentleAiExtensionForTesting(
5999
8325
  });
6000
8326
 
6001
8327
  function runSddPreflight(ctx: ExtensionContext, promptFields: readonly SddPreflightField[] = []): Promise<SddPreflightPreferences> {
6002
- return ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installSddAssets(cwd, false), applyModelConfig: async () => applySavedModelConfig(ctx) }, { promptFields });
8328
+ return ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installPackageAssets(cwd, true, ["sdd"]), applyModelConfig: async () => applySavedModelConfig(ctx) }, { promptFields });
6003
8329
  }
6004
8330
 
6005
- pi.on("session_start", async (_event, ctx) => {
8331
+ pi.on("session_start", async (event, ctx) => {
8332
+ reminderSessionActive = true;
8333
+ reminderEpoch += 1;
8334
+ try { candidateViews?.sweepOrphans(ctx.cwd); } catch { /* Ownership sweeping must not block startup. */ }
8335
+ const reason = (event as { reason?: unknown }).reason;
8336
+ if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx);
8337
+ await refreshReviewSessionPermissionStatus(ctx);
6006
8338
  // Loud, every session: an active dev-binary override means this session
6007
8339
  // runs an unpinned gentle-ai. Announce which one before anything else.
6008
8340
  try {
@@ -6013,7 +8345,7 @@ function createGentleAiExtensionForTesting(
6013
8345
  if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
6014
8346
  }
6015
8347
  try {
6016
- const installResult = installSddAssets(ctx.cwd, true);
8348
+ const installResult = installPackageAssets(ctx.cwd, true, ["delegation", "review"]);
6017
8349
  migrateLegacyProjectModelOverrides(ctx.cwd);
6018
8350
  const modelResult = await applySavedModelConfig(ctx);
6019
8351
  if (ctx.hasUI && modelResult.invalidPath) {
@@ -6025,7 +8357,7 @@ function createGentleAiExtensionForTesting(
6025
8357
  }
6026
8358
  if (ctx.hasUI && modelResult.updated > 0) {
6027
8359
  ctx.ui.notify(
6028
- `el Gentleman applied SDD model config to ${modelResult.updated} agent(s). Global SDD assets ready: ${installResult.agents} new agent(s), ${installResult.chains} new chain(s), ${installResult.support} new support file(s).`,
8360
+ `el Gentleman applied saved model config to ${modelResult.updated} agent(s). Global delegation/review assets ready: ${installResult.agents} new agent(s), ${installResult.chains} new chain(s), ${installResult.support} new support file(s).`,
6029
8361
  "info",
6030
8362
  );
6031
8363
  }
@@ -6039,18 +8371,13 @@ function createGentleAiExtensionForTesting(
6039
8371
  );
6040
8372
  }
6041
8373
  }
6042
- // gentle-pi#568: record the target identity STATUS reports right now,
6043
- // before this session does anything, as the baseline `agent_end` skips
6044
- // later. Best-effort and silent: it never notifies and never lets a
6045
- // STATUS failure fail session start.
8374
+ // Keep the startup transport negotiation, but do not treat its target as
8375
+ // an ownership baseline: reload may have outstanding durable receipts.
6046
8376
  try {
6047
8377
  const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
6048
- const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
6049
- if (status?.targetIdentity !== undefined) {
6050
- processAgentEndSessionBaseline.set(sessionKey, status.targetIdentity);
6051
- }
8378
+ await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
6052
8379
  } catch {
6053
- // Baseline recording is best-effort only; never surface or throw.
8380
+ // Startup negotiation is best-effort only; never surface or throw.
6054
8381
  }
6055
8382
  });
6056
8383
 
@@ -6058,11 +8385,17 @@ function createGentleAiExtensionForTesting(
6058
8385
  if (typeof event.text !== "string" || !isSddPreflightTrigger(event.text)) {
6059
8386
  return { action: "continue" };
6060
8387
  }
6061
- await runSddPreflight(ctx);
8388
+ try { await runSddPreflight(ctx); }
8389
+ catch (error) {
8390
+ if (ctx.hasUI) ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
8391
+ return { action: "handled" };
8392
+ }
6062
8393
  return { action: "continue" };
6063
8394
  });
6064
8395
 
8396
+ let nativeSddStartupBlock: string | undefined;
6065
8397
  pi.on("before_agent_start", async (event, ctx) => {
8398
+ nativeSddStartupBlock = undefined;
6066
8399
  const isSddAgent = isSddAgentStartEvent(event);
6067
8400
  const isNamedAgent = isNamedAgentStartEvent(event);
6068
8401
  const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
@@ -6071,26 +8404,92 @@ function createGentleAiExtensionForTesting(
6071
8404
  } else {
6072
8405
  processAgentEndSubagentDepth.set(subagentDepthKey, 0);
6073
8406
  }
6074
- if (isSddAgent && !getSddPreflightPreferences(ctx)) {
6075
- await runSddPreflight(ctx);
8407
+ // gentle-pi#677: nudge gentle-ai's own telemetry trigger for a primary
8408
+ // session only, reusing the exact isNamedAgent/isSddAgent predicate that
8409
+ // decides the orchestrator prompt below. At most one attempt per
8410
+ // process regardless of how many primary-session before_agent_start
8411
+ // events this process observes; a missing/old binary or a spawn error
8412
+ // must never affect activation, so every failure is swallowed silently.
8413
+ if (!isNamedAgent && !isSddAgent && !processTelemetryTriggerAttempted) {
8414
+ processTelemetryTriggerAttempted = true;
8415
+ try {
8416
+ const executable = resolveTelemetryTriggerBinary();
8417
+ spawnTelemetryTrigger({
8418
+ executable,
8419
+ cwd: ctx.cwd,
8420
+ env: dependencies.processEnv ?? process.env,
8421
+ spawn: dependencies.telemetryTriggerSpawn,
8422
+ });
8423
+ } catch {
8424
+ // Best-effort only; never surfaced and never affects activation.
8425
+ }
8426
+ }
8427
+ try {
8428
+ if (isSddAgent && !getSddPreflightPreferences(ctx) && ctx.mode !== "rpc") {
8429
+ await runSddPreflight(ctx);
8430
+ }
8431
+ } catch (error) {
8432
+ // Pi logs thrown before_agent_start errors and continues. Return an
8433
+ // unresolved gate instead of silently losing the preflight instructions.
8434
+ 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.` };
6076
8435
  }
6077
8436
  const prefs = getSddPreflightPreferences(ctx);
8437
+ // RPC children never resolve or persist defaults. The parent dispatch gate
8438
+ // transports the rendered block in the existing task context, and Gentle
8439
+ // Agents refuses a missing/malformed payload before spawning the child.
6078
8440
  const sddPrompt =
6079
8441
  prefs && (!isNamedAgent || isSddAgent)
6080
8442
  ? `\n\n${renderSddPreflightPrompt(prefs)}`
6081
8443
  : "";
6082
8444
  const phase = isSddAgent ? sddPhaseFromAgentStartEvent(event) : undefined;
8445
+ const launchSddChange = readSddChangeFlag(pi);
8446
+ if (launchSddChange !== undefined && !phase) nativeSddStartupBlock = "Receiving agent has no recognized SDD phase";
6083
8447
  const nativeStatusPrompt = phase
6084
- ? `\n\n${renderNativeSddPhasePrompt(resolveStartupControllerSddStatus(
6085
- ctx.cwd,
6086
- undefined,
6087
- true,
6088
- prefs?.artifactStore,
6089
- ), phase)}`
6090
- : "";
8448
+ ? await (async () => {
8449
+ try {
8450
+ if (launchSddChange === undefined) {
8451
+ if (phase === "sync") return `\n\n${renderNativeSddPhasePrompt(resolveStartupControllerSddStatus(ctx.cwd, undefined, true, prefs?.artifactStore), phase)}`;
8452
+ const { status } = await readCommandSddStatus("", ctx);
8453
+ if (status.changeName === null || !status.phaseInstructions || status.nextRecommended !== phase) throw new Error(`Native SDD discovery cannot run ${phase}.`);
8454
+ return `\n\n${renderNativeSddPhasePrompt(status, phase)}`;
8455
+ }
8456
+ const agentName = `sdd-${phase}`;
8457
+ const startup = await resolveSelectedNativeSddChangeStartup(
8458
+ launchSddChange,
8459
+ ctx.cwd,
8460
+ agentName,
8461
+ nativeReviewCli,
8462
+ (options) => resolveControllerSddStatus(
8463
+ options.cwd,
8464
+ options.changeName,
8465
+ true,
8466
+ prefs?.artifactStore,
8467
+ ),
8468
+ );
8469
+ return `\n\n${renderNativeSddPhasePrompt(startup.status, phase)}`;
8470
+ } catch (error) {
8471
+ nativeSddStartupBlock = error instanceof Error ? error.message : String(error);
8472
+ return `\n\n## Native SDD Status Engine\nSDD selection blocked: ${nativeSddStartupBlock}\nDo not run phase work; return this blocker to the parent.`;
8473
+ }
8474
+ })()
8475
+ : launchSddChange === undefined
8476
+ ? ""
8477
+ : "\n\n## Native SDD Status Engine\nSDD selection blocked: the receiving agent has no recognized SDD phase.\nDo not run phase work; return this blocker to the parent.";
8478
+ // gentle-pi#661: the RDD status line (and the rest of the gentle prompt)
8479
+ // is built only for the primary session, mirrored on the
8480
+ // reviewContractPrompt condition below -- named/SDD agents never reach
8481
+ // this branch, so no line is resolved or computed for them.
8482
+ // resolveRddStatusLine never throws and never hangs past
8483
+ // RDD_STATUS_TIMEOUT_MS: an absent/timed-out/aborted/failing native
8484
+ // binary renders the fail-closed "unknown" line instead.
6091
8485
  const gentlePrompt = isNamedAgent || isSddAgent
6092
8486
  ? ""
6093
- : `\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd), ctx.cwd, readActiveToolNames(pi))}`;
8487
+ : `\n\n${buildGentlePrompt(
8488
+ readPersonaMode(ctx.cwd),
8489
+ ctx.cwd,
8490
+ readActiveToolNames(pi),
8491
+ await resolveRddStatusLine(nativeReviewCli, ctx.cwd, AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS), undefined, ctx),
8492
+ )}`;
6094
8493
  // gentle-pi#560 / gentle-ai#4056, #4057: inject the mirrored provider
6095
8494
  // contract bundle's review execution contract for the primary session
6096
8495
  // only, and only when a native review CLI is actually present.
@@ -6102,7 +8501,7 @@ function createGentleAiExtensionForTesting(
6102
8501
  })()
6103
8502
  : "";
6104
8503
  return {
6105
- systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}`,
8504
+ systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}${!isNamedAgent && !isSddAgent ? `\n\n${renderResearchCapabilities(resolveResearchCapabilities(pi))}` : ""}`,
6106
8505
  };
6107
8506
  });
6108
8507
 
@@ -6110,33 +8509,30 @@ function createGentleAiExtensionForTesting(
6110
8509
  // an authorized implementation and report completion without ever running
6111
8510
  // the review STATUS preflight or offering the consent question. This
6112
8511
  // handler is read-only and idempotent: it never runs START, never answers
6113
- // consent, and never writes a file. It only sends one turn-triggering
6114
- // reminder, at most once per unreviewed target identity per session.
6115
- // gentle-pi#568: a candidate matching the baseline `session_start`
6116
- // recorded predates this session's own work and is skipped rather than
6117
- // nudged, so a worktree already dirty from the user's own edits does not
6118
- // draw a reminder about work this session never produced.
8512
+ // consent, or chooses a partial candidate. Durable own-mutation receipts
8513
+ // gate STATUS and consume only the generation captured before that await.
6119
8514
  pi.on("agent_end", async (_event, ctx) => {
6120
8515
  if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return;
6121
- if (ctx.hasUI !== true) return;
8516
+ if (ctx.hasUI !== true || !reminderSessionActive) return;
6122
8517
  const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
6123
8518
  const subagentDepth = processAgentEndSubagentDepth.get(sessionKey) ?? 0;
6124
8519
  if (subagentDepth > 0) {
6125
8520
  processAgentEndSubagentDepth.set(sessionKey, subagentDepth - 1);
6126
8521
  return;
6127
8522
  }
8523
+ const root = resolveSessionWorktree(ctx.cwd, ctx.cwd)?.root;
8524
+ if (!root) return;
8525
+ let mutation: string | undefined;
8526
+ try { mutation = pendingReviewMutation(ctx.sessionManager, root); }
8527
+ catch { return; }
8528
+ if (!mutation) return;
8529
+ const epoch = reminderEpoch;
6128
8530
  const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
6129
- if (status === undefined) return;
8531
+ if (status === undefined || !reminderSessionActive || epoch !== reminderEpoch || pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) !== sessionKey) return;
8532
+ // Another concurrent end or ACK may already have consumed this prefix.
8533
+ if (!pendingReviewMutation(ctx.sessionManager, root, mutation)) return;
6130
8534
  if (status.nextTransition?.kind !== "execute" || status.nextTransition.execute.operation !== "review.start") return;
6131
8535
  const targetIdentity = status.targetIdentity;
6132
- if (processAgentEndSessionBaseline.get(sessionKey) === targetIdentity) return;
6133
- let nudged = processAgentEndPreflightNudgedTargets.get(sessionKey);
6134
- if (nudged === undefined) {
6135
- nudged = new Set<string>();
6136
- processAgentEndPreflightNudgedTargets.set(sessionKey, nudged);
6137
- }
6138
- if (nudged.has(targetIdentity)) return;
6139
- nudged.add(targetIdentity);
6140
8536
  pi.sendMessage(
6141
8537
  {
6142
8538
  customType: "gentle-pi.review-preflight",
@@ -6145,15 +8541,70 @@ function createGentleAiExtensionForTesting(
6145
8541
  },
6146
8542
  { triggerTurn: true, deliverAs: "followUp" },
6147
8543
  );
8544
+ consumeReviewMutation(pi, ctx.sessionManager, root, mutation, "nudged", targetIdentity);
8545
+ });
8546
+
8547
+ pi.on("tool_result", (event, ctx) => {
8548
+ if (!reminderSessionActive || event.isError !== false || (event.toolName !== "write" && event.toolName !== "edit")) return;
8549
+ if (!isRecord(event.input) || typeof event.input.path !== "string" || !event.input.path.trim()) return;
8550
+ try {
8551
+ const root = resolveSessionWorktree(event.input.path, ctx.cwd)?.root;
8552
+ if (root) recordReviewMutation(pi, ctx.sessionManager, root, { source: "direct", toolName: event.toolName, toolCallId: event.toolCallId });
8553
+ } catch { /* Receipt persistence must not change a successful tool result. */ }
6148
8554
  });
6149
8555
 
6150
8556
  pi.on("tool_call", async (event, ctx) => {
8557
+ if (nativeSddStartupBlock && event.toolName !== "subagent_parent_message") return { block: true, reason: `SDD selection blocked: ${nativeSddStartupBlock}` };
6151
8558
  const sensitivePathDenied = evaluateSensitivePathTool(
6152
8559
  event.toolName,
6153
8560
  event.input,
6154
8561
  );
6155
8562
  if (sensitivePathDenied) return sensitivePathDenied;
6156
8563
  if (event.toolName === "subagent_run") {
8564
+ const sddAgent = sddDispatchAgentName(event.input);
8565
+ if (sddAgent === "invalid") {
8566
+ return { block: true, reason: "SDD dispatch requires exactly one shipped SDD agent name." };
8567
+ }
8568
+ if (sddAgent !== undefined) {
8569
+ // An RPC child is a delegated actor, never an authority originator. It
8570
+ // must receive the already-confirmed block from its interactive parent.
8571
+ if (ctx.mode === "rpc") {
8572
+ return { block: true, reason: "SDD dispatch refused: an RPC child cannot originate or persist SDD preflight defaults." };
8573
+ }
8574
+ try {
8575
+ const prefs = getSddPreflightPreferences(ctx) ?? await runSddPreflight(ctx);
8576
+ const rendered = renderSddPreflightPrompt(prefs);
8577
+ if (!prefs.prompted && ctx.hasUI) {
8578
+ return { block: true, reason: "SDD dispatch refused: interactive parent preflight lacks current-session confirmation." };
8579
+ }
8580
+ if (!isRecord(event.input)) {
8581
+ return { block: true, reason: "SDD dispatch refused: child input is malformed." };
8582
+ }
8583
+ if (event.input.context !== undefined && typeof event.input.context !== "string") {
8584
+ return { block: true, reason: "SDD dispatch refused: child context must be text." };
8585
+ }
8586
+ // The existing context payload is the sole parent-to-child transport.
8587
+ // Refuse a caller-authored lookalike so the child receives one exact,
8588
+ // parent-rendered authority block rather than an ambiguous mixture.
8589
+ const context = typeof event.input.context === "string" ? event.input.context.trim() : "";
8590
+ if (/^## SDD Session Preflight[ \t]*$/m.test(context)) {
8591
+ return { block: true, reason: "SDD dispatch refused: child context already contains an untrusted preflight block." };
8592
+ }
8593
+ event.input.context = context.length === 0
8594
+ ? rendered
8595
+ : `${rendered}\n\n${context}`;
8596
+ if (!isParentConfirmedSddPreflightContext(event.input.context)) {
8597
+ return { block: true, reason: "SDD dispatch refused: rendered preflight transport is malformed." };
8598
+ }
8599
+ } catch (error) {
8600
+ return {
8601
+ block: true,
8602
+ reason: `SDD dispatch refused before child launch: ${error instanceof Error ? error.message : String(error)}`,
8603
+ };
8604
+ }
8605
+ }
8606
+ const judgmentDayFixDenied = rejectInvalidJudgmentDayFixDispatch(event.input);
8607
+ if (judgmentDayFixDenied) return judgmentDayFixDenied;
6157
8608
  const writerScopeDenied = rejectUnscopedBoundedWriterDispatch(event.input);
6158
8609
  if (writerScopeDenied) return writerScopeDenied;
6159
8610
  try {
@@ -6173,39 +8624,50 @@ function createGentleAiExtensionForTesting(
6173
8624
  return await confirmCommand(event.input.command, ctx, pi.events, herdrLifecycle);
6174
8625
  });
6175
8626
 
6176
- pi.registerCommand("gentle:install-sdd", {
6177
- description:
6178
- "Repair or refresh global Gentle AI SDD subagent and chain assets.",
6179
- handler: async (args, ctx) => {
6180
- const force = args.includes("--force");
6181
- const result = installSddAssets(ctx.cwd, force);
6182
- ctx.ui.notify(
6183
- `Global Gentle AI SDD assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`,
6184
- "info",
6185
- );
6186
- },
6187
- });
8627
+ for (const owner of ["delegation", "review", "sdd"] as const) {
8628
+ const label = owner === "sdd" ? "SDD" : owner;
8629
+ pi.registerCommand(`gentle:install-${owner}`, {
8630
+ description: `Repair or refresh only global Gentle AI ${label} assets.`,
8631
+ handler: async (args, ctx) => {
8632
+ const force = args.includes("--force");
8633
+ const result = installPackageAssets(ctx.cwd, force, [owner]);
8634
+ ctx.ui.notify(
8635
+ `Global Gentle AI ${label} assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`,
8636
+ "info",
8637
+ );
8638
+ },
8639
+ });
8640
+ }
6188
8641
 
6189
8642
  pi.registerCommand("gentle:sdd-preflight", {
6190
8643
  description:
6191
- "Run or reuse the lazy SDD preflight for this Pi session.",
6192
- handler: async (_args, ctx) => {
6193
- await runSddPreflight(ctx, SDD_PREFLIGHT_FIELDS);
8644
+ "Run or reuse session SDD preflight; use --edit to change preferences.",
8645
+ handler: async (args, ctx) => {
8646
+ if (args.trim() !== "" && args.trim() !== "--edit") {
8647
+ ctx.ui.notify("Usage: /gentle:sdd-preflight [--edit]", "warning");
8648
+ return;
8649
+ }
8650
+ try {
8651
+ await runSddPreflight(ctx, args.trim() === "--edit" ? SDD_PREFLIGHT_FIELDS : []);
8652
+ } catch (error) {
8653
+ ctx.ui?.notify(error instanceof Error ? error.message : String(error), "warning");
8654
+ }
6194
8655
  },
6195
8656
  });
6196
8657
 
6197
- const handleSddStatusCommand = async (args: string, ctx: ExtensionContext) => {
8658
+ const readCommandSddStatus = async (args: string, ctx: ExtensionContext) => {
6198
8659
  const parsed = parseSddStatusCommandArgs(args);
6199
- const status = resolveControllerSddStatus(
6200
- ctx.cwd,
6201
- parsed.changeName,
6202
- true,
6203
- getSddPreflightPreferences(ctx)?.artifactStore,
6204
- );
6205
- ctx.ui.notify(
6206
- parsed.json ? JSON.stringify(status, null, 2) : renderSddStatusMarkdown(status),
6207
- sddStatusSeverity(status),
6208
- );
8660
+ const request = { changeName: parsed.changeName, workspaceRoot: realpathSync(ctx.cwd) };
8661
+ if (!nativeReviewCli?.sddStatus) throw new Error("Native SDD status capability unavailable; no local fallback.");
8662
+ const status = decodeNativeSddStatusV2(await nativeReviewCli.sddStatus(request), request);
8663
+ return { parsed, request, status };
8664
+ };
8665
+ const showCommandSddStatus = (status: NativeSddStatusV2, json: boolean, ctx: ExtensionContext) => {
8666
+ ctx.ui?.notify(json ? JSON.stringify(status, null, 2) : renderNativeSddPhasePrompt(status), "info");
8667
+ };
8668
+ const handleSddStatusCommand = async (args: string, ctx: ExtensionContext) => {
8669
+ const { parsed, status } = await readCommandSddStatus(args, ctx);
8670
+ showCommandSddStatus(status, parsed.json, ctx);
6209
8671
  };
6210
8672
 
6211
8673
  pi.registerCommand("gentle-sdd-status", {
@@ -6216,17 +8678,34 @@ function createGentleAiExtensionForTesting(
6216
8678
  });
6217
8679
 
6218
8680
  const handleSddContinueCommand = async (args: string, ctx: ExtensionContext) => {
6219
- const parsed = parseSddStatusCommandArgs(args);
6220
- const status = resolveControllerSddStatus(
6221
- ctx.cwd,
6222
- parsed.changeName,
6223
- true,
6224
- getSddPreflightPreferences(ctx)?.artifactStore,
6225
- );
6226
- ctx.ui.notify(
6227
- parsed.json ? JSON.stringify(status, null, 2) : renderSddDispatcherMarkdown(status),
6228
- sddStatusSeverity(status),
6229
- );
8681
+ const { parsed, request, status } = await readCommandSddStatus(args, ctx);
8682
+ const planning = status.planningHome;
8683
+ const changeRoot = status.changeRoot;
8684
+ // Native context is an upper bound, never the human's per-call grant.
8685
+ if (status.changeName === null || !ctx.hasUI || typeof ctx.ui?.confirm !== "function" || !nativeReviewCli?.sddContinue) {
8686
+ showCommandSddStatus(status, parsed.json, ctx);
8687
+ return;
8688
+ }
8689
+ 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.");
8690
+ 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.");
8691
+ const marker = join(changeRoot, ".gentle-ai-instance");
8692
+ const checkMarker = () => {
8693
+ try {
8694
+ if (!lstatSync(marker).isFile() || realpathSync(marker) !== marker) throw new Error("Native SDD marker is not a canonical regular file.");
8695
+ } catch (error) {
8696
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
8697
+ }
8698
+ };
8699
+ checkMarker();
8700
+ 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.");
8701
+ 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) {
8702
+ showCommandSddStatus(status, parsed.json, ctx);
8703
+ return;
8704
+ }
8705
+ if (realpathSync(ctx.cwd) !== request.workspaceRoot || realpathSync(changeRoot) !== changeRoot) throw new Error("Native SDD continuation workspace changed during confirmation.");
8706
+ checkMarker();
8707
+ const selected = { ...request, changeName: status.changeName };
8708
+ showCommandSddStatus(decodeNativeSddStatusV2(await nativeReviewCli.sddContinue(selected), selected), parsed.json, ctx);
6230
8709
  };
6231
8710
 
6232
8711
  pi.registerCommand("gentle-sdd-continue", {
@@ -6243,6 +8722,13 @@ function createGentleAiExtensionForTesting(
6243
8722
  },
6244
8723
  });
6245
8724
 
8725
+ pi.registerCommand("gentle:profiles", {
8726
+ description: "Create, switch, and manage global agent-model profiles for el Gentleman.",
8727
+ handler: async (_args, ctx) => {
8728
+ await handleProfilesCommand(ctx);
8729
+ },
8730
+ });
8731
+
6246
8732
  pi.registerCommand("gentle:persona", {
6247
8733
  description: "Switch el Gentleman persona between gentleman and neutral.",
6248
8734
  handler: async (_args, ctx) => {
@@ -6313,29 +8799,19 @@ function createGentleAiExtensionForTesting(
6313
8799
  pi.registerCommand("gentle:doctor", {
6314
8800
  description: "Run read-only Gentle AI diagnostics for this Pi workspace.",
6315
8801
  handler: async (_args, ctx) => {
6316
- const agentsInstalled = existsSync(
6317
- join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
6318
- );
6319
- const chainsInstalled = existsSync(
6320
- join(gentlePiAgentHome(), "chains", "sdd-full.chain.md"),
6321
- );
8802
+ const assetLines = packageAssetDiagnosticLines(ctx.cwd);
6322
8803
  const openspecConfigured = existsSync(
6323
8804
  join(ctx.cwd, "openspec", "config.yaml"),
6324
8805
  );
6325
8806
  const skillRegistryPresent = existsSync(
6326
8807
  join(ctx.cwd, ".atl", "skill-registry.md"),
6327
8808
  );
6328
- const staleSddAssets = sddGlobalAssetDriftCount();
6329
- const localSddAgentOverrides = sddLocalAgentOverrideCount(ctx.cwd);
6330
8809
  const modelConfig = await readSavedModelConfigAsync(ctx.cwd);
6331
8810
  const engramActive = hasWritableEngramTool(pi);
6332
8811
  const devBinary = await describeDevBinaryOverride();
6333
8812
  const lines = [
6334
8813
  "el Gentleman doctor",
6335
- `${agentsInstalled ? "pass" : "fail"}: Global SDD agents ${agentsInstalled ? "installed" : "missing"}`,
6336
- `${chainsInstalled ? "pass" : "fail"}: Global SDD chains ${chainsInstalled ? "installed" : "missing"}`,
6337
- `${staleSddAssets === 0 ? "pass" : "warn"}: Global SDD asset drift ${staleSddAssets} file(s)`,
6338
- `${localSddAgentOverrides === 0 ? "pass" : "warn"}: Project-local SDD agent overrides ${localSddAgentOverrides} file(s)`,
8814
+ ...assetLines,
6339
8815
  `${openspecConfigured ? "pass" : "warn"}: OpenSpec config ${openspecConfigured ? "present" : "missing"}`,
6340
8816
  `${skillRegistryPresent ? "pass" : "warn"}: Skill registry ${skillRegistryPresent ? "present" : "missing"}`,
6341
8817
  `${modelConfig.status === "invalid" ? "fail" : "pass"}: Global model config ${modelConfig.status}`,
@@ -6344,24 +8820,42 @@ function createGentleAiExtensionForTesting(
6344
8820
  ...(devBinary.state === "active" ? [`warn: ${devBinary.line}`] : []),
6345
8821
  ...(devBinary.state === "invalid" ? [`fail: ${devBinary.line}`, "remedy: fix the dev binary override or clear it with /gentle:dev-binary off (or unset GENTLE_PI_GENTLE_AI_DEV_BINARY)"] : []),
6346
8822
  ];
6347
- if (!agentsInstalled || !chainsInstalled) {
6348
- lines.push("remedy: run /gentle:install-sdd --force to refresh global SDD assets intentionally");
6349
- }
6350
8823
  if (modelConfig.status === "invalid") {
6351
8824
  lines.push(`remedy: fix or remove ${modelConfig.path}`);
6352
8825
  }
6353
- if (localSddAgentOverrides > 0) {
6354
- lines.push("remedy: remove project-local SDD agent overrides unless intentionally debugging package assets");
6355
- }
6356
8826
  ctx.ui.notify(
6357
8827
  lines.join("\n"),
6358
- lines.some((line) => line.startsWith("fail:")) ? "warning" : "info",
8828
+ lines.some((line) => line.startsWith("fail:")) || assetLines.some((line) => line.startsWith("warn:")) ? "warning" : "info",
6359
8829
  );
6360
8830
  },
6361
8831
  });
6362
8832
 
8833
+ pi.registerCommand("gentle:review-session-permission", {
8834
+ description: "Show or revoke the process-memory review permission for this exact Pi session and Git repository (status|revoke).",
8835
+ handler: async (args, ctx) => {
8836
+ const subAction = args.trim().length === 0 ? "status" : args.trim();
8837
+ if (subAction !== "status" && subAction !== "revoke") {
8838
+ ctx.ui.notify(`Unknown /gentle:review-session-permission sub-action "${subAction}". Use status or revoke.`, "warning");
8839
+ return;
8840
+ }
8841
+ if (subAction === "revoke") {
8842
+ const revoked = await revokeCurrentRepositoryReviewSessionPermission(ctx);
8843
+ ctx.ui.notify(revoked ? "Review permission revoked for this Git repository in this Pi session. Provider review mode and authority were not changed." : "No review permission is active for this Git repository in this Pi session. Provider review mode and authority were not changed.", "info");
8844
+ return;
8845
+ }
8846
+ const identity = await refreshReviewSessionPermissionStatus(ctx);
8847
+ if (identity === undefined) {
8848
+ ctx.ui.notify("Review session permission is unavailable: it requires the interactive Pi TUI, a non-child session, a nonempty session ID, and a canonical Git worktree.", "info");
8849
+ return;
8850
+ }
8851
+ ctx.ui.notify(hasReviewSessionPermission(identity)
8852
+ ? "Reviews are allowed for this Pi session and Git repository. Use /gentle:review-session-permission revoke to ask again."
8853
+ : "Reviews are not pre-authorized for this Pi session; each medium- or high-risk candidate asks normally.", "info");
8854
+ },
8855
+ });
8856
+
6363
8857
  pi.registerCommand("gentle:review-mode", {
6364
- description: "Show or set the Gentle AI review-driven-development kill switch (status|disable|enable). Every sub-action is user-initiated only; Pi automation never toggles it.",
8858
+ description: "Show or set the Gentle AI receipt-driven development kill switch (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.",
6365
8859
  handler: async (args, ctx) => {
6366
8860
  const subAction = args.trim().length === 0 ? NATIVE_REVIEW_MODE_OPERATION.STATUS : args.trim();
6367
8861
  if (subAction !== NATIVE_REVIEW_MODE_OPERATION.STATUS && subAction !== NATIVE_REVIEW_MODE_OPERATION.ENABLE && subAction !== NATIVE_REVIEW_MODE_OPERATION.DISABLE) {
@@ -6405,6 +8899,56 @@ function createGentleAiExtensionForTesting(
6405
8899
  },
6406
8900
  });
6407
8901
 
8902
+ // gentle-pi#677: gentle-ai owns telemetry end to end (status, the opt-out
8903
+ // switches, and rate limiting); this command only runs the corresponding
8904
+ // `gentle-ai telemetry <op> --json` in the foreground and relays its
8905
+ // output, so a Pi user never has to leave Pi to check or change it.
8906
+ pi.registerCommand("gentle:telemetry", {
8907
+ description: "Show or change the local Gentle AI telemetry trigger (status|enable|disable|preview); gentle-ai owns the data and the opt-out.",
8908
+ handler: async (args, ctx) => {
8909
+ const subAction = args.trim().length === 0 ? "status" : args.trim();
8910
+ if (subAction !== "status" && subAction !== "enable" && subAction !== "disable" && subAction !== "preview") {
8911
+ ctx.ui.notify(`Unknown /gentle:telemetry sub-action "${subAction}". Use status, enable, disable, or preview.`, "warning");
8912
+ return;
8913
+ }
8914
+ let executable: string;
8915
+ try {
8916
+ executable = resolveTelemetryTriggerBinary();
8917
+ } catch (error) {
8918
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
8919
+ return;
8920
+ }
8921
+ let result: ExecFileResult;
8922
+ try {
8923
+ result = await telemetryExecFileAdapter({
8924
+ file: executable,
8925
+ arguments: ["telemetry", subAction, "--json"],
8926
+ cwd: ctx.cwd,
8927
+ timeoutMs: 5_000,
8928
+ maxBufferBytes: 1024 * 1024,
8929
+ });
8930
+ } catch (error) {
8931
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
8932
+ return;
8933
+ }
8934
+ if (result.exitCode !== 0) {
8935
+ ctx.ui.notify(`gentle-ai telemetry ${subAction} failed (exit ${result.exitCode}): ${(result.stderr || result.stdout || "no output").trim()}`, "error");
8936
+ return;
8937
+ }
8938
+ let relayed: string;
8939
+ try {
8940
+ relayed = JSON.stringify(JSON.parse(result.stdout), null, 2);
8941
+ } catch {
8942
+ relayed = result.stdout.trim();
8943
+ }
8944
+ if (subAction === "disable") {
8945
+ ctx.ui.notify("Gentle AI telemetry disabled.", "info");
8946
+ return;
8947
+ }
8948
+ ctx.ui.notify(relayed, "info");
8949
+ },
8950
+ });
8951
+
6408
8952
  // Mirrors gentle:review-mode: a user-owned switch, never an automated one.
6409
8953
  // It matters more here than there, because this policy governs whether
6410
8954
  // background subagents may be launched at all, so nothing in Pi may write
@@ -6433,41 +8977,27 @@ function createGentleAiExtensionForTesting(
6433
8977
  pi.registerCommand("gentle:status", {
6434
8978
  description: "Show Gentle AI package status for this project.",
6435
8979
  handler: async (_args, ctx) => {
6436
- const agentsInstalled = existsSync(
6437
- join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
6438
- );
6439
- const chainsInstalled = existsSync(
6440
- join(gentlePiAgentHome(), "chains", "sdd-full.chain.md"),
6441
- );
8980
+ const assetLines = packageAssetDiagnosticLines(ctx.cwd);
6442
8981
  const openspecConfigured = existsSync(
6443
8982
  join(ctx.cwd, "openspec", "config.yaml"),
6444
8983
  );
6445
- const staleSddAssets = sddGlobalAssetDriftCount();
6446
- const localSddAgentOverrides = sddLocalAgentOverrideCount(ctx.cwd);
6447
- const modelConfig = await readModelConfigAsync(ctx.cwd);
8984
+ const savedConfig = await readModelRoutingAuthorityAsync(
8985
+ modelConfigPath(ctx.cwd),
8986
+ legacyProjectModelConfigPath(ctx.cwd),
8987
+ );
6448
8988
  const devBinary = await describeDevBinaryOverride();
6449
8989
  ctx.ui.notify(
6450
8990
  [
6451
8991
  "el Gentleman package is active.",
6452
8992
  ...(devBinary.state === "inactive" ? [] : [devBinary.line]),
6453
8993
  `Persona: ${readPersonaMode(ctx.cwd)}`,
6454
- `Global SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
6455
- `Global SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
6456
- `Global SDD assets stale: ${staleSddAssets} file(s)${
6457
- staleSddAssets > 0
6458
- ? " — run /gentle:install-sdd --force to refresh intentionally"
6459
- : ""
6460
- }`,
6461
- `Project-local SDD agent overrides: ${localSddAgentOverrides} file(s)${
6462
- localSddAgentOverrides > 0
6463
- ? " — local SDD agents shadow package assets; remove them unless intentionally debugging"
6464
- : ""
6465
- }`,
8994
+ ...assetLines,
6466
8995
  `OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
6467
8996
  `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
6468
- ...describeModelConfig(ctx.cwd, modelConfig),
8997
+ `Saved model routing: ${savedConfig.status}${savedConfig.status === "invalid" ? ` (${savedConfig.path})` : ""}`,
8998
+ ...(savedConfig.status === "invalid" ? [] : describeModelConfig(ctx.cwd, savedConfig.status === "valid" ? savedConfig.config : {})),
6469
8999
  ].join("\n"),
6470
- staleSddAssets > 0 || localSddAgentOverrides > 0 || devBinary.state !== "inactive" ? "warning" : "info",
9000
+ savedConfig.status === "invalid" || assetLines.some((line) => line.startsWith("warn:")) || devBinary.state !== "inactive" ? "warning" : "info",
6471
9001
  );
6472
9002
  },
6473
9003
  });