gentle-pi 2.5.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 (189) hide show
  1. package/README.md +139 -30
  2. package/assets/agents/gentle-ai-worker.md +4 -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 +21 -3
  25. package/assets/sdd-orchestrator-workflow.md +54 -21
  26. package/assets/support/sdd-status-contract.md +34 -90
  27. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  28. package/docs/telemetry.md +57 -1
  29. package/docs/windows-startup-console-visibility.md +18 -0
  30. package/extensions/ask-user-choice.ts +143 -15
  31. package/extensions/codegraph-tools.ts +1 -0
  32. package/extensions/gentle-agents.ts +799 -50
  33. package/extensions/gentle-ai.ts +2033 -322
  34. package/extensions/gentle-shell.ts +145 -42
  35. package/extensions/gentle-todo.ts +47 -12
  36. package/extensions/quiet-tools.ts +1 -0
  37. package/extensions/runtime-metrics.ts +130 -0
  38. package/extensions/sdd-init.ts +2 -2
  39. package/extensions/startup-banner.ts +52 -75
  40. package/lib/agent-profiles.ts +550 -0
  41. package/lib/agents-completion-delivery.ts +72 -0
  42. package/lib/agents-config.ts +7 -10
  43. package/lib/agents-history.ts +9 -1
  44. package/lib/agents-messaging.ts +187 -0
  45. package/lib/agents-protocol.ts +77 -5
  46. package/lib/agents-runner.ts +548 -26
  47. package/lib/agents-thread-view.ts +57 -0
  48. package/lib/agents-view-layout.ts +40 -0
  49. package/lib/agents-view.ts +548 -191
  50. package/lib/agents-widget.ts +33 -14
  51. package/lib/gentle-ai-binary.ts +3 -1
  52. package/lib/gentle-ai-renderer.ts +8 -6
  53. package/lib/native-review-cli.ts +283 -1
  54. package/lib/orchestrator-presence.ts +337 -0
  55. package/lib/profiles-orchestrator.ts +203 -0
  56. package/lib/review-candidate-view-owner.ts +296 -46
  57. package/lib/review-candidate-view.ts +30 -20
  58. package/lib/review-consent-component.ts +247 -0
  59. package/lib/review-consent-ui.ts +53 -8
  60. package/lib/review-host-relay.ts +28 -0
  61. package/lib/review-integration-v2.ts +187 -5
  62. package/lib/review-last-event-controller.ts +7 -4
  63. package/lib/review-reminder-receipt.ts +74 -0
  64. package/lib/review-session-standing-permission.ts +27 -6
  65. package/lib/runtime-metrics-children.ts +199 -0
  66. package/lib/runtime-metrics-delivery.ts +68 -0
  67. package/lib/runtime-metrics-native.ts +166 -0
  68. package/lib/runtime-metrics-pi-identity.ts +113 -0
  69. package/lib/runtime-metrics-policy.ts +51 -0
  70. package/lib/runtime-metrics.ts +255 -0
  71. package/lib/sdd-preflight.ts +362 -81
  72. package/lib/sdd-research-capabilities.ts +228 -0
  73. package/lib/sdd-status.ts +29 -7
  74. package/lib/session-worktree-registry.ts +118 -0
  75. package/lib/shell-bar.ts +47 -1
  76. package/lib/shell-card.ts +1 -4
  77. package/lib/shell-changes-view.ts +362 -37
  78. package/lib/shell-changes.ts +81 -1
  79. package/lib/shell-prompt.ts +11 -15
  80. package/lib/shell-sidebar-banner.ts +11 -0
  81. package/lib/shell-sidebar-layout.ts +213 -0
  82. package/lib/shell-sidebar.ts +41 -0
  83. package/lib/shell-todo.ts +28 -11
  84. package/lib/telemetry-trigger.ts +2 -0
  85. package/package.json +6 -3
  86. package/runtime/gentle-ai-binary.mjs +3 -1
  87. package/runtime/native-review-cli.mjs +283 -1
  88. package/runtime/review-integration-v2.mjs +187 -5
  89. package/runtime/telemetry-trigger.mjs +2 -0
  90. package/scripts/build-runtime-modules.mjs +9 -1
  91. package/scripts/check-types.mjs +125 -0
  92. package/scripts/gentle-ai-installer.mjs +10 -10
  93. package/scripts/install-gentle-ai.mjs +12 -0
  94. package/scripts/install-tui-mode-setting.mjs +114 -0
  95. package/scripts/test-packed-runner.mjs +16 -2
  96. package/scripts/types-baseline.json +99 -0
  97. package/scripts/verify-package-files.mjs +4 -2
  98. package/skills/_shared/review-ledger-contract.md +17 -1
  99. package/skills/issue-creation/SKILL.md +3 -3
  100. package/skills/judgment-day/SKILL.md +17 -3
  101. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  102. package/tests/agent-profiles.test.ts +722 -0
  103. package/tests/agents-completion-delivery.test.ts +94 -0
  104. package/tests/agents-config.test.ts +62 -0
  105. package/tests/agents-fake-child.ts +15 -1
  106. package/tests/agents-grouping.test.ts +179 -0
  107. package/tests/agents-integration.test.ts +100 -0
  108. package/tests/agents-messaging.test.ts +94 -0
  109. package/tests/agents-protocol.test.ts +45 -0
  110. package/tests/agents-queries.test.ts +190 -0
  111. package/tests/agents-responsive.test.ts +43 -0
  112. package/tests/agents-runner.test.ts +571 -14
  113. package/tests/agents-thread-view.test.ts +45 -0
  114. package/tests/agents-view.test.ts +476 -65
  115. package/tests/agents-widget.test.ts +31 -1
  116. package/tests/artifact-language.test.ts +25 -2
  117. package/tests/ask-user-choice.test.ts +169 -3
  118. package/tests/asset-installation-runtime.test.ts +108 -0
  119. package/tests/autonomous-guard.test.ts +116 -1
  120. package/tests/codegraph-tools.test.ts +2 -1
  121. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  122. package/tests/devbinary/native-review-parity.devtest.ts +2 -0
  123. package/tests/feature-request-form.test.ts +67 -0
  124. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  125. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  126. package/tests/gentle-agents.test.ts +1460 -33
  127. package/tests/gentle-ai-binary.test.ts +7 -2
  128. package/tests/gentle-ai-installer.test.ts +47 -47
  129. package/tests/gentle-ai-renderer.test.ts +38 -0
  130. package/tests/gentle-ai.test.ts +944 -4
  131. package/tests/gentle-shell.test.ts +303 -12
  132. package/tests/gentle-todo.test.ts +54 -10
  133. package/tests/install-tui-mode-setting.test.ts +324 -0
  134. package/tests/issue-creation-skill.test.ts +22 -0
  135. package/tests/model-routing-authority.test.ts +12 -0
  136. package/tests/native-review-capability-contract.test.ts +12 -1
  137. package/tests/native-review-cli.test.ts +277 -3
  138. package/tests/native-review-parity.test.ts +14 -7
  139. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  140. package/tests/orchestrator-presence.test.ts +389 -0
  141. package/tests/package-manifest.test.ts +232 -7
  142. package/tests/profiles-orchestrator.test.ts +208 -0
  143. package/tests/quiet-tool-rendering.test.ts +1 -0
  144. package/tests/rdd-aware-verification-contract.test.ts +10 -0
  145. package/tests/review-agent-end-preflight.test.ts +332 -24
  146. package/tests/review-candidate-view.test.ts +304 -6
  147. package/tests/review-consent-ui.test.ts +352 -0
  148. package/tests/review-contract-prompt.test.ts +14 -0
  149. package/tests/review-controller-native-routing.test.ts +563 -3
  150. package/tests/review-controller.test.ts +1 -1
  151. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  152. package/tests/review-host-relay-routing.test.ts +364 -4
  153. package/tests/review-host-relay.test.ts +29 -0
  154. package/tests/review-integration-v2-forward.test.ts +44 -0
  155. package/tests/review-integration-v2.test.ts +164 -0
  156. package/tests/review-last-event-closure.test.ts +105 -1
  157. package/tests/review-ledger-contract.test.ts +61 -6
  158. package/tests/review-reminder-receipt.test.ts +62 -0
  159. package/tests/review-session-standing-permission-controller.test.ts +52 -4
  160. package/tests/review-session-standing-permission.test.ts +30 -0
  161. package/tests/runtime-harness.mjs +447 -39
  162. package/tests/runtime-metrics-children.test.ts +206 -0
  163. package/tests/runtime-metrics-delivery.test.ts +85 -0
  164. package/tests/runtime-metrics-extension.test.ts +187 -0
  165. package/tests/runtime-metrics-native.test.ts +209 -0
  166. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  167. package/tests/runtime-metrics-policy.test.ts +62 -0
  168. package/tests/runtime-metrics.test.ts +184 -0
  169. package/tests/sdd-agent-tools.test.ts +10 -1
  170. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  171. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  172. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  173. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  174. package/tests/sdd-preflight.test.ts +252 -8
  175. package/tests/sdd-research-capabilities.test.ts +256 -0
  176. package/tests/sdd-research-live.test.ts +241 -0
  177. package/tests/sdd-selection-transport.test.ts +504 -0
  178. package/tests/sdd-status.test.ts +51 -0
  179. package/tests/session-worktree-registry.test.ts +135 -0
  180. package/tests/shell-card.test.ts +24 -3
  181. package/tests/shell-changes-view.test.ts +471 -8
  182. package/tests/shell-changes.test.ts +168 -0
  183. package/tests/shell-prompt.test.ts +28 -6
  184. package/tests/shell-sidebar-banner.test.ts +23 -0
  185. package/tests/shell-sidebar-layout.test.ts +387 -0
  186. package/tests/shell-sidebar.test.ts +50 -0
  187. package/tests/shell-todo.test.ts +100 -11
  188. package/tests/startup-banner.test.ts +126 -0
  189. package/tests/telemetry-trigger.test.ts +3 -1
@@ -1,3 +1,6 @@
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";
1
4
  import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
2
5
  import { execFileSync } from "node:child_process";
3
6
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
@@ -29,15 +32,20 @@ import type {
29
32
  ThemeColor,
30
33
  ToolCallEventResult,
31
34
  } from "@earendil-works/pi-coding-agent";
32
- 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";
33
36
  import { resolveGentlePiAgentHome } from "../lib/agent-home.ts";
34
37
  import {
35
38
  ensureSddPreflight,
36
39
  getSddPreflightPreferences,
37
- installSddAssets,
40
+ installPackageAssets,
41
+ getPackageAssetOwner,
42
+ hasPackageAssetOwnerInstallation,
43
+ type PackageAssetOwner,
38
44
  isPackageManagedSddAsset,
39
45
  isSddPreflightTrigger,
40
46
  renderSddPreflightPrompt,
47
+ isParentConfirmedSddPreflightContext,
48
+ SHIPPED_SDD_AGENT_NAMES,
41
49
  SDD_PREFLIGHT_FIELDS,
42
50
  type SddPreflightField,
43
51
  type SddPreflightPreferences,
@@ -55,13 +63,46 @@ import {
55
63
  type ModelConfigFileResult,
56
64
  type ThinkingLevel,
57
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";
58
102
  import {
59
103
  parseSddStatusCommandArgs,
60
104
  renderNativeSddPhasePrompt,
61
- renderSddDispatcherMarkdown,
62
- renderSddStatusMarkdown,
63
105
  resolveSddStatus,
64
- sddStatusSeverity,
65
106
  type SddPhase,
66
107
  } from "../lib/sdd-status.ts";
67
108
  import {
@@ -72,6 +113,8 @@ import {
72
113
  REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
73
114
  ReviewHostRelayError,
74
115
  reviewHostRelaySlots,
116
+ reviewHostRelayUnachievableDetail,
117
+ reviewHostRelayUnachievableReason,
75
118
  reviewProviderRoleVectorSlots,
76
119
  resolveReviewHostRelaySubmission,
77
120
  runReviewHostRelayReviewerGroup,
@@ -107,6 +150,8 @@ import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
107
150
  import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, type CandidateView } from "../lib/review-candidate-view.ts";
108
151
  import {
109
152
  GentleAiDevBinaryOverrideError,
153
+ GENTLE_AI_INSTALL_RECOVERY_COMMAND,
154
+ GENTLE_AI_INSTALL_RECOVERY_INSTRUCTIONS,
110
155
  registerGentleAiDevBinary,
111
156
  resolveGentleAiBinary,
112
157
  resolveGentleAiDevBinaryOverride,
@@ -120,7 +165,9 @@ import {
120
165
  import {
121
166
  createNativeReviewCli,
122
167
  createNodeExecFileAdapter,
168
+ decodeNativeSddStatusV2,
123
169
  isCanonicalProcessString,
170
+ isNativeReviewUnachievableVerbRefused,
124
171
  nativeReviewAbandonAuthorization,
125
172
  nativeReviewLegacyAliasRepairAuthorization,
126
173
  nativeReviewLegacyQuarantineAuthorization,
@@ -140,13 +187,16 @@ import {
140
187
  NATIVE_REVIEW_RECONCILE_ANOMALIES,
141
188
  sanitizeForeignNativeReviewDiagnostics,
142
189
  type NativeReviewCli,
190
+ type NativeSddStatusV2,
143
191
  type NativeIntendedUntrackedSelectionSubmission,
144
192
  type NativeReviewAcknowledgeApprovedOutcome,
145
193
  type NativeReviewAcknowledgeApprovedRequest,
146
194
  type NativeTargetStatusRequest,
147
195
  type NativeReviewModeOperation,
148
196
  type NativeReviewModeSource,
197
+ type NativeReviewModeStatus,
149
198
  type NativeReviewProcessDiagnostics,
199
+ type NativeReviewUnachievableLensCaptureArtifact,
150
200
  type NativeStartResult,
151
201
  type NativeReviewAssessRequest,
152
202
  type ExecFileAdapter,
@@ -195,8 +245,9 @@ function gentlePiAgentHome(): string {
195
245
  return resolveGentlePiAgentHome();
196
246
  }
197
247
 
198
- function sddGlobalAssetDriftCount(): number {
248
+ function packageAssetAudit(owner: PackageAssetOwner): { stale: number; overrides: number } {
199
249
  let stale = 0;
250
+ let overrides = 0;
200
251
  for (const [assetSubdir, installedSubdir, ownershipPrefix] of [
201
252
  ["agents", "agents", "agents"],
202
253
  ["chains", "chains", "chains"],
@@ -205,7 +256,7 @@ function sddGlobalAssetDriftCount(): number {
205
256
  const assetDir = join(ASSETS_DIR, assetSubdir);
206
257
  if (!existsSync(assetDir)) continue;
207
258
  for (const entry of readdirSync(assetDir, { withFileTypes: true })) {
208
- if (!entry.isFile()) continue;
259
+ if (!entry.isFile() || getPackageAssetOwner(`${ownershipPrefix}/${entry.name}`) !== owner) continue;
209
260
  const installedPath = join(gentlePiAgentHome(), installedSubdir, entry.name);
210
261
  try {
211
262
  if (!existsSync(installedPath)) {
@@ -218,6 +269,7 @@ function sddGlobalAssetDriftCount(): number {
218
269
  `${ownershipPrefix}/${entry.name}`,
219
270
  )
220
271
  ) {
272
+ overrides += 1;
221
273
  continue;
222
274
  }
223
275
  const packaged = readFileSync(join(assetDir, entry.name), "utf8");
@@ -238,26 +290,47 @@ function sddGlobalAssetDriftCount(): number {
238
290
  }
239
291
  }
240
292
  }
241
- return stale;
293
+ return { stale, overrides };
242
294
  }
243
295
 
244
- function sddLocalAgentOverrideCount(cwd: string): number {
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
+ });
316
+ }
317
+
318
+ function localAgentOverrideCount(cwd: string, owner: PackageAssetOwner): number {
245
319
  const packageSddAgentsDir = join(ASSETS_DIR, "agents");
246
- const packageSddAgentNames = existsSync(packageSddAgentsDir)
247
- ? new Set(
248
- readdirSync(packageSddAgentsDir, { withFileTypes: true })
249
- .filter((entry) => entry.isFile() && /^sdd-.*\.md$/i.test(entry.name))
250
- .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,
251
326
  )
252
- : new Set<string>();
327
+ .map((agent) => agent.name),
328
+ );
253
329
  let count = 0;
254
- for (const installedDir of [
255
- join(cwd, ".pi", "agents"),
256
- join(cwd, ".pi", "subagents"),
257
- ]) {
258
- if (!existsSync(installedDir)) continue;
259
- for (const entry of readdirSync(installedDir, { withFileTypes: true })) {
260
- 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;
261
334
  }
262
335
  }
263
336
  return count;
@@ -497,7 +570,13 @@ function renderBackgroundSubagentsReport(
497
570
 
498
571
  const SUBAGENTS_PACKAGE_NAMES = ["pi-subagents-j0k3r", "pi-subagents"] as const;
499
572
  const SUBAGENT_RUN_TOOL = "subagent_run";
500
- 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";
501
580
  const ALLOWED_EDIT_SURFACES_HEADING = /^## Allowed edit surfaces[ \t]*$/gim;
502
581
  const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6} /;
503
582
  const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)]) +/;
@@ -601,6 +680,15 @@ function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean {
601
680
  return hasSection;
602
681
  }
603
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
+
604
692
  function rejectUnscopedBoundedWriterDispatch(input: unknown): { block: true; reason: string } | undefined {
605
693
  if (
606
694
  !isRecord(input) ||
@@ -615,6 +703,165 @@ function rejectUnscopedBoundedWriterDispatch(input: unknown): { block: true; rea
615
703
  return { block: true, reason: WRITER_EDIT_SURFACE_REJECTION };
616
704
  }
617
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
+
618
865
  /**
619
866
  * Roots where an installed subagents package may live. These are the same
620
867
  * roots builtinAgentDirs() walks, minus its `/agents` suffix.
@@ -1140,7 +1387,7 @@ ${getOrchestratorPrompt(cwd, activeTools, rddStatusLine)}`;
1140
1387
  // Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
1141
1388
  // between `git` and the subcommand. Short flags may be followed by a separate value token.
1142
1389
  const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)* `;
1143
- 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`);
1144
1391
 
1145
1392
  const DENIED_BASH_PATTERNS: RegExp[] = [
1146
1393
  // Block rm -rf targeting /, ~ or ~/subdir, $HOME or $HOME/subdir, .. or .
@@ -1167,6 +1414,19 @@ const GUARD_ACTION = {
1167
1414
  type GuardAction = (typeof GUARD_ACTION)[keyof typeof GUARD_ACTION];
1168
1415
  type GuardClassification = GuardAction | "not-guarded";
1169
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
+
1170
1430
  const GUARDED_COMMAND_KEY = {
1171
1431
  GIT_PUSH: "gitPush",
1172
1432
  GIT_REBASE: "gitRebase",
@@ -1191,10 +1451,10 @@ interface LoadGuardrailsOptions {
1191
1451
 
1192
1452
  const GUARDED_KEY_PATTERNS: Record<GuardedCommandKey, RegExp> = {
1193
1453
  gitPush: GIT_PUSH_RE,
1194
- gitRebase: /\bgit\s+rebase\b/,
1195
- 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)/,
1196
- npmPublish: /\bnpm\s+publish\b/,
1197
- 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/,
1198
1458
  };
1199
1459
 
1200
1460
  const AUTONOMOUS_DEFAULT_ACTIONS: Record<GuardedCommandKey, GuardAction> = {
@@ -1205,6 +1465,14 @@ const AUTONOMOUS_DEFAULT_ACTIONS: Record<GuardedCommandKey, GuardAction> = {
1205
1465
  piRemove: "confirm",
1206
1466
  };
1207
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
+
1208
1476
  const SAFE_GUARDRAILS_CONFIG: RuntimeGuardrailsConfig = {
1209
1477
  autonomousMode: false,
1210
1478
  guardedCommands: {},
@@ -1220,31 +1488,83 @@ const SAFE_GUARDRAILS_CONFIG: RuntimeGuardrailsConfig = {
1220
1488
  * (applying AUTONOMOUS_DEFAULT_ACTIONS for any key not set in guardedCommands)
1221
1489
  * 4. No match → "not-guarded"
1222
1490
  */
1223
- function classifyGuardedCommand(
1491
+ function collectGuardedMatches(
1224
1492
  command: string,
1225
1493
  config: RuntimeGuardrailsConfig,
1226
- ): GuardClassification {
1227
- // 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.
1228
1522
  for (const pattern of DENIED_BASH_PATTERNS) {
1229
- 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
+ };
1230
1534
  }
1231
1535
 
1232
- // Step 2 & 3: find which guarded key (if any) this command matches
1233
- for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [GuardedCommandKey, RegExp][]) {
1234
- 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
+ }
1235
1543
 
1236
- // Matched a guarded key
1237
- if (!config.autonomousMode) {
1238
- // Legacy behavior: any match → confirm
1239
- return "confirm";
1240
- }
1544
+ function classifyGuardedCommand(
1545
+ command: string,
1546
+ config: RuntimeGuardrailsConfig,
1547
+ ): GuardClassification {
1548
+ return evaluateGuardedCommand(command, config).action;
1549
+ }
1241
1550
 
1242
- // Autonomous mode: use configured action, fall back to sensible defaults
1243
- const configuredAction = config.guardedCommands[key];
1244
- return configuredAction ?? AUTONOMOUS_DEFAULT_ACTIONS[key];
1245
- }
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
+ }
1246
1556
 
1247
- 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]}?`;
1248
1568
  }
1249
1569
 
1250
1570
  function parseGuardrailsConfigFile(
@@ -1352,22 +1672,9 @@ const SENSITIVE_PATH_PATTERNS: RegExp[] = [
1352
1672
  /\.(?:pem|key|p12|pfx)$/,
1353
1673
  ];
1354
1674
 
1355
- const SDD_AGENT_NAMES = [
1356
- "sdd-init",
1357
- "sdd-onboard",
1358
- "sdd-explore",
1359
- "sdd-research",
1360
- "sdd-proposal",
1361
- "sdd-spec",
1362
- "sdd-design",
1363
- "sdd-tasks",
1364
- "sdd-status",
1365
- "sdd-apply",
1366
- "sdd-verify",
1367
- "sdd-sync",
1368
- "sdd-archive",
1369
- ] as const;
1370
- 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;
1371
1678
 
1372
1679
  const JUDGMENT_DAY_AGENT_NAMES = [
1373
1680
  "jd-judge-a",
@@ -1376,7 +1683,7 @@ const JUDGMENT_DAY_AGENT_NAMES = [
1376
1683
  ] as const;
1377
1684
 
1378
1685
  const CORE_MODEL_AGENT_NAMES = [
1379
- ...SDD_AGENT_NAMES,
1686
+ ...SHIPPED_SDD_AGENT_NAMES,
1380
1687
  ...JUDGMENT_DAY_AGENT_NAMES,
1381
1688
  ] as const;
1382
1689
  const CORE_MODEL_AGENT_NAME_SET = new Set<string>(CORE_MODEL_AGENT_NAMES);
@@ -1418,10 +1725,10 @@ function readStringPath(value: unknown, path: string[]): string | undefined {
1418
1725
 
1419
1726
  function isSddAgentStartEvent(event: unknown): boolean {
1420
1727
  const candidates = readAgentStartNames(event);
1421
- 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;
1422
1729
 
1423
1730
  const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
1424
- return SDD_AGENT_NAMES.some((name) => {
1731
+ return SHIPPED_SDD_AGENT_NAMES.some((name) => {
1425
1732
  const phase = name.replace(/^sdd-/, "");
1426
1733
  return new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt);
1427
1734
  });
@@ -1444,19 +1751,107 @@ function isNamedAgentStartEvent(event: unknown): boolean {
1444
1751
  return readAgentStartNames(event).length > 0;
1445
1752
  }
1446
1753
 
1447
- function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | undefined {
1448
- for (const name of readAgentStartNames(event)) {
1449
- if (name === "sdd-apply") return "apply";
1450
- if (name === "sdd-verify") return "verify";
1451
- if (name === "sdd-sync") return "sync";
1452
- if (name === "sdd-archive") return "archive";
1453
- }
1754
+ function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | "remediate" | undefined {
1755
+ const phases = ["apply", "verify", "sync", "archive", "remediate"] as const;
1756
+ const names = readAgentStartNames(event);
1454
1757
  const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
1455
- if (/\bSDD apply executor\b/i.test(systemPrompt)) return "apply";
1456
- if (/\bSDD verify executor\b/i.test(systemPrompt)) return "verify";
1457
- if (/\bSDD sync executor\b/i.test(systemPrompt)) return "sync";
1458
- if (/\bSDD archive executor\b/i.test(systemPrompt)) return "archive";
1459
- 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
+ }
1460
1855
  }
1461
1856
 
1462
1857
  function normalizePolicyPath(value: string): string {
@@ -1576,7 +1971,8 @@ async function confirmCommand(
1576
1971
  herdrLifecycle: HerdrConfirmationLifecycle,
1577
1972
  ): Promise<ToolCallEventResult | undefined> {
1578
1973
  const guardrailsConfig = loadRuntimeGuardrailsConfig(ctx.cwd);
1579
- const classification = classifyGuardedCommand(command, guardrailsConfig);
1974
+ const evaluation = evaluateGuardedCommand(command, guardrailsConfig);
1975
+ const { action: classification } = evaluation;
1580
1976
 
1581
1977
  if (classification === "block") {
1582
1978
  return {
@@ -1599,11 +1995,8 @@ async function confirmCommand(
1599
1995
  "Gentle AI safety policy requires interactive confirmation before this command.",
1600
1996
  };
1601
1997
  }
1602
- const preview = truncateToWidth(
1603
- command.replace(/\s+/g, " ").trim(),
1604
- 180,
1605
- "…",
1606
- );
1998
+ const title = guardedCommandTitle(evaluation.key, evaluation.matches);
1999
+ const preview = guardedCommandPreview(command, evaluation.triggerIndex);
1607
2000
  const requestId = randomUUID();
1608
2001
  const emitPermissionRequest = (
1609
2002
  state: "waiting" | "approved" | "denied",
@@ -1622,7 +2015,7 @@ async function confirmCommand(
1622
2015
  emitPermissionRequest("waiting");
1623
2016
  herdrLifecycle.begin();
1624
2017
  try {
1625
- approved = await ctx.ui.confirm("Allow guarded command?", preview);
2018
+ approved = await ctx.ui.confirm(title, preview);
1626
2019
  } catch (error) {
1627
2020
  confirmationFailed = true;
1628
2021
  confirmationError = error;
@@ -1754,7 +2147,10 @@ function parseModelExport(value: unknown): AgentModelConfig | undefined {
1754
2147
  }
1755
2148
 
1756
2149
  async function exportSavedModelConfig(ctx: ExtensionContext): Promise<number> {
1757
- const saved = await readSavedModelConfigAsync(ctx.cwd);
2150
+ const saved = await readModelRoutingAuthorityAsync(
2151
+ modelConfigPath(ctx.cwd),
2152
+ legacyProjectModelConfigPath(ctx.cwd),
2153
+ );
1758
2154
  if (saved.status === "invalid") throw new Error(`Invalid model config: ${saved.path}`);
1759
2155
  const agents = saved.status === "valid" ? saved.config : {};
1760
2156
  const path = modelExportPath(ctx.cwd);
@@ -1905,6 +2301,49 @@ async function listAgentsFromDirAsync(
1905
2301
  return entries;
1906
2302
  }
1907
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
+
1908
2347
  function builtinAgentDirs(cwd: string): string[] {
1909
2348
  return [
1910
2349
  join(PACKAGE_ROOT, "..", "pi-subagents-j0k3r", "agents"),
@@ -1935,16 +2374,12 @@ async function listBuiltinAgentNamesAsync(cwd: string): Promise<Set<string>> {
1935
2374
  }
1936
2375
 
1937
2376
  function listDiscoverableAgents(cwd: string): AgentEntry[] {
1938
- const globalAgentHome = gentlePiAgentHome();
1939
2377
  const builtinDirs = builtinAgentDirs(cwd);
1940
2378
  const agents = [
1941
2379
  ...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")),
1942
- ...listAgentsFromDir(join(globalAgentHome, "agents"), "user"),
1943
- ...listAgentsFromDir(join(globalAgentHome, "subagents"), "user"),
1944
- ...listAgentsFromDir(join(homedir(), ".agents"), "user"),
1945
- ...listAgentsFromDir(join(cwd, ".agents"), "project"),
1946
- ...listAgentsFromDir(join(cwd, ".pi", "agents"), "project"),
1947
- ...listAgentsFromDir(join(cwd, ".pi", "subagents"), "project"),
2380
+ ...discoverableNonBuiltinAgentRoots(cwd).flatMap(({ dir, source }) =>
2381
+ listAgentsFromDir(dir, source),
2382
+ ),
1948
2383
  ];
1949
2384
  const byName = new Map<string, AgentEntry>();
1950
2385
  for (const agent of agents) byName.set(agent.name, agent);
@@ -1952,21 +2387,12 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
1952
2387
  }
1953
2388
 
1954
2389
  async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
1955
- const globalAgentHome = gentlePiAgentHome();
1956
2390
  const builtinDirs = builtinAgentDirs(cwd);
1957
2391
  const agents: AgentEntry[] = [];
1958
2392
  for (const dir of builtinDirs) {
1959
2393
  agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
1960
2394
  }
1961
- const otherDirs: Array<[string, AgentSource]> = [
1962
- [join(globalAgentHome, "agents"), "user"],
1963
- [join(globalAgentHome, "subagents"), "user"],
1964
- [join(homedir(), ".agents"), "user"],
1965
- [join(cwd, ".agents"), "project"],
1966
- [join(cwd, ".pi", "agents"), "project"],
1967
- [join(cwd, ".pi", "subagents"), "project"],
1968
- ];
1969
- for (const [dir, source] of otherDirs) {
2395
+ for (const { dir, source } of discoverableNonBuiltinAgentRoots(cwd)) {
1970
2396
  agents.push(...(await listAgentsFromDirAsync(dir, source)));
1971
2397
  }
1972
2398
  const byName = new Map<string, AgentEntry>();
@@ -2083,6 +2509,15 @@ function projectSettingsPath(cwd: string): string {
2083
2509
  return join(cwd, ".pi", "settings.json");
2084
2510
  }
2085
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
+
2086
2521
  function removeLegacyAgentOverridesFromSettings(
2087
2522
  settingsPath: string,
2088
2523
  settings: Record<string, unknown>,
@@ -2107,6 +2542,30 @@ function isValidJsonObjectFileOrMissing(path: string): boolean {
2107
2542
  }
2108
2543
  }
2109
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
+
2110
2569
  function migrateLegacyProjectModelOverrides(cwd: string): number {
2111
2570
  const settingsPath = projectSettingsPath(cwd);
2112
2571
  if (!existsSync(settingsPath)) return 0;
@@ -2125,6 +2584,7 @@ function migrateLegacyProjectModelOverrides(cwd: string): number {
2125
2584
  if (!agentOverrides) return 0;
2126
2585
  const agentsByName = new Map(listDiscoverableAgents(cwd).map((agent) => [agent.name, agent]));
2127
2586
  const migratableEntries = Object.entries(agentOverrides)
2587
+ .filter(([name]) => !isProviderReviewRole(name))
2128
2588
  .map(([name, value]) => ({ name, entry: normalizeRoutingEntry(value) }))
2129
2589
  .filter((item): item is { name: string; entry: AgentRoutingEntry } =>
2130
2590
  item.entry !== undefined && !isClearRoutingEntry(item.entry),
@@ -2167,30 +2627,40 @@ export function applyModelConfig(
2167
2627
  let skipped = 0;
2168
2628
  const seenAgents = new Set<string>();
2169
2629
  for (const agent of listDiscoverableAgents(cwd)) {
2630
+ if (isProviderReviewRole(agent.name)) continue;
2170
2631
  seenAgents.add(agent.name);
2171
2632
  const entry = config[agent.name];
2172
2633
  if (entry === undefined) {
2173
2634
  skipped += 1;
2174
2635
  continue;
2175
2636
  }
2176
- if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1;
2177
- else skipped += 1;
2178
- if (agent.source === "builtin") continue;
2179
- if (!agent.filePath || !existsSync(agent.filePath)) {
2180
- skipped += 1;
2637
+ if (agent.source === "builtin") {
2638
+ if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1;
2639
+ else skipped += 1;
2181
2640
  continue;
2182
2641
  }
2183
- const original = readFileSync(agent.filePath, "utf8");
2184
- const next = updateFrontmatterRouting(original, entry);
2185
- if (next === original) {
2642
+ if (!agent.filePath || !existsSync(agent.filePath)) {
2186
2643
  skipped += 1;
2187
- 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
+ }
2188
2655
  }
2189
- writeFileSync(agent.filePath, next);
2190
- updatePackageManagedSddAgentOwnership(agent.filePath, original, next);
2191
- updated += 1;
2656
+ if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1;
2657
+ else skipped += 1;
2192
2658
  }
2193
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;
2194
2664
  if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
2195
2665
  if (updateSubagentModelProfile(cwd, "user", name, entry)) updated += 1;
2196
2666
  else skipped += 1;
@@ -2207,31 +2677,40 @@ export async function applyModelConfigAsync(
2207
2677
  let skipped = 0;
2208
2678
  const seenAgents = new Set<string>();
2209
2679
  for (const agent of await listDiscoverableAgentsAsync(cwd)) {
2680
+ if (isProviderReviewRole(agent.name)) continue;
2210
2681
  seenAgents.add(agent.name);
2211
2682
  const entry = config[agent.name];
2212
2683
  if (entry === undefined) {
2213
2684
  skipped += 1;
2214
2685
  continue;
2215
2686
  }
2216
- if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry))
2217
- updated += 1;
2218
- else skipped += 1;
2219
- if (agent.source === "builtin") continue;
2220
- if (!agent.filePath || !(await pathExists(agent.filePath))) {
2221
- skipped += 1;
2687
+ if (agent.source === "builtin") {
2688
+ if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry))
2689
+ updated += 1;
2690
+ else skipped += 1;
2222
2691
  continue;
2223
2692
  }
2224
- const original = await readFile(agent.filePath, "utf8");
2225
- const next = updateFrontmatterRouting(original, entry);
2226
- if (next === original) {
2693
+ if (!agent.filePath || !(await pathExists(agent.filePath))) {
2227
2694
  skipped += 1;
2228
- 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
+ }
2229
2706
  }
2230
- await writeFile(agent.filePath, next);
2231
- updatePackageManagedSddAgentOwnership(agent.filePath, original, next);
2232
- updated += 1;
2707
+ if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry))
2708
+ updated += 1;
2709
+ else skipped += 1;
2233
2710
  }
2234
2711
  for (const [name, entry] of Object.entries(config)) {
2712
+ if (isProviderReviewRole(name)) continue;
2713
+ if (isProfileOrchestratorKey(name)) continue;
2235
2714
  if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
2236
2715
  if (await updateSubagentModelProfileAsync(cwd, "user", name, entry))
2237
2716
  updated += 1;
@@ -2259,11 +2738,11 @@ export async function applySavedModelConfig(
2259
2738
  }
2260
2739
 
2261
2740
  function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
2262
- return listDiscoverableAgents(cwd).map((agent) => {
2263
- const entry = config[agent.name];
2264
- const model = entry?.model ?? "inherit";
2265
- const thinking = entry?.thinking ?? "inherit";
2266
- 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)}`;
2267
2746
  });
2268
2747
  }
2269
2748
 
@@ -2631,7 +3110,7 @@ class SddModelPanel implements OverlayComponent {
2631
3110
  lines.push("");
2632
3111
  lines.push(
2633
3112
  line(
2634
- "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`,
2635
3114
  "muted",
2636
3115
  ),
2637
3116
  );
@@ -2663,7 +3142,9 @@ class SddModelPanel implements OverlayComponent {
2663
3142
  const focused = i === this.modelCursor;
2664
3143
  lines.push(
2665
3144
  `${this.renderCursor(focused)} ${this.renderText(
2666
- options[i] ?? "",
3145
+ options[i] === INHERIT_MODEL && isProviderReviewRole(this.selectedRow)
3146
+ ? routingDefaultLabel(this.selectedRow, "model")
3147
+ : (options[i] ?? ""),
2667
3148
  focused ? "status" : "text",
2668
3149
  )}`,
2669
3150
  );
@@ -2715,7 +3196,9 @@ class SddModelPanel implements OverlayComponent {
2715
3196
  const focused = i === this.effortCursor;
2716
3197
  lines.push(
2717
3198
  `${this.renderCursor(focused)} ${this.renderText(
2718
- THINKING_OPTIONS[i] ?? "",
3199
+ THINKING_OPTIONS[i] === INHERIT_THINKING && isProviderReviewRole(this.selectedRow)
3200
+ ? routingDefaultLabel(this.selectedRow, "effort")
3201
+ : (THINKING_OPTIONS[i] ?? ""),
2719
3202
  focused ? "status" : "text",
2720
3203
  )}`,
2721
3204
  );
@@ -2728,10 +3211,10 @@ class SddModelPanel implements OverlayComponent {
2728
3211
  private renderSetAllLabel(row: string): string {
2729
3212
  const models = this.rows
2730
3213
  .slice(1)
2731
- .map((name) => this.draft[name]?.model ?? "inherit");
3214
+ .map((name) => this.draft[name]?.model ?? routingDefaultLabel(name, "model"));
2732
3215
  const efforts = this.rows
2733
3216
  .slice(1)
2734
- .map((name) => this.draft[name]?.thinking ?? "inherit");
3217
+ .map((name) => this.draft[name]?.thinking ?? routingDefaultLabel(name, "effort"));
2735
3218
  const firstModel = models[0] ?? "inherit";
2736
3219
  const firstEffort = efforts[0] ?? "inherit";
2737
3220
  const modelLabel = models.every((value) => value === firstModel)
@@ -2747,8 +3230,8 @@ class SddModelPanel implements OverlayComponent {
2747
3230
  }
2748
3231
 
2749
3232
  private renderAgentLabel(row: string): string {
2750
- const model = this.draft[row]?.model ?? "inherit";
2751
- 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");
2752
3235
  return `${this.renderText(sanitizeTerminalText(row).padEnd(20), "text")} ${this.renderText("model=", "muted")}${this.renderText(model, "status")}${this.renderText(
2753
3236
  ", effort=",
2754
3237
  "muted",
@@ -2773,7 +3256,7 @@ async function showSddModelPanel(
2773
3256
  config: AgentModelConfig,
2774
3257
  ): Promise<ModelPanelResult> {
2775
3258
  const modelOptions = await getPiModelOptions(ctx);
2776
- const agents = listDiscoverableAgents(ctx.cwd).map((agent) => agent.name);
3259
+ const agents = modelAssignmentNames(ctx.cwd);
2777
3260
  return ctx.ui.custom<ModelPanelResult>(
2778
3261
  (_tui, theme, _keybindings, done) =>
2779
3262
  new SddModelPanel(config, modelOptions, agents, done, theme),
@@ -2791,7 +3274,10 @@ async function showSddModelPanel(
2791
3274
 
2792
3275
  async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
2793
3276
  migrateLegacyProjectModelOverrides(ctx.cwd);
2794
- const savedConfig = await readSavedModelConfigAsync(ctx.cwd);
3277
+ const savedConfig = await readModelRoutingAuthorityAsync(
3278
+ modelConfigPath(ctx.cwd),
3279
+ legacyProjectModelConfigPath(ctx.cwd),
3280
+ );
2795
3281
  if (savedConfig.status === "invalid") {
2796
3282
  ctx.ui.notify(
2797
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.`,
@@ -2871,9 +3357,9 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
2871
3357
  }
2872
3358
  if (result.agent === "all") {
2873
3359
  const next: AgentModelConfig = { ...config };
2874
- for (const agent of listDiscoverableAgents(ctx.cwd)) {
2875
- next[agent.name] = {
2876
- ...(next[agent.name] ?? {}),
3360
+ for (const name of modelAssignmentNames(ctx.cwd)) {
3361
+ next[name] = {
3362
+ ...(next[name] ?? {}),
2877
3363
  model,
2878
3364
  };
2879
3365
  }
@@ -2888,20 +3374,681 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
2888
3374
  };
2889
3375
  }
2890
3376
  }
2891
- result = await showSddModelPanel(ctx, config);
3377
+ result = await showSddModelPanel(ctx, config);
3378
+ }
3379
+ if (result.type !== "save") return;
3380
+ writeModelConfig(ctx.cwd, result.config);
3381
+ const applyResult = await applyModelConfigAsync(ctx.cwd, result.config);
3382
+ ctx.ui.notify(
3383
+ [
3384
+ "el Gentleman global model config saved.",
3385
+ `Global config: ${modelConfigPath(ctx.cwd)}`,
3386
+ `Agents updated: ${applyResult.updated}`,
3387
+ ...describeModelConfig(ctx.cwd, result.config),
3388
+ ].join("\n"),
3389
+ "info",
3390
+ );
3391
+ }
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);
2892
4051
  }
2893
- if (result.type !== "save") return;
2894
- writeModelConfig(ctx.cwd, result.config);
2895
- const applyResult = await applyModelConfigAsync(ctx.cwd, result.config);
2896
- ctx.ui.notify(
2897
- [
2898
- "el Gentleman global model config saved.",
2899
- `Global config: ${modelConfigPath(ctx.cwd)}`,
2900
- `Agents updated: ${applyResult.updated}`,
2901
- ...describeModelConfig(ctx.cwd, result.config),
2902
- ].join("\n"),
2903
- "info",
2904
- );
2905
4052
  }
2906
4053
 
2907
4054
  async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
@@ -2981,7 +4128,8 @@ const REVIEW_CONTROLLER_PARAMETERS = {
2981
4128
  description: "Bounded review lineage identifier. A failed start creates no lineage; do not use it with status or advance.",
2982
4129
  },
2983
4130
  selectionBinding: { type: "string", description: "Opaque provider-issued pre-lineage intended-untracked selection binding." },
2984
- 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.' },
2985
4133
  changeName: {
2986
4134
  type: "string",
2987
4135
  description: "Canonical OpenSpec change name required to resolve a recovered authority during lifecycle validate.",
@@ -3134,6 +4282,7 @@ interface ReviewControllerParameters {
3134
4282
  lineageId?: string;
3135
4283
  selectionBinding?: string;
3136
4284
  intendedUntracked?: readonly string[];
4285
+ untrackedScope?: NativeStartUntrackedScope;
3137
4286
  changeName?: string;
3138
4287
  idempotencyKey?: string;
3139
4288
  transition?: string;
@@ -3173,6 +4322,41 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
3173
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");
3174
4323
  return { operation: value.operation, selectionBinding: value.selectionBinding, intendedUntracked: value.intendedUntracked, ...(typeof value.workspaceRoot === "string" ? { workspaceRoot: value.workspaceRoot } : {}) };
3175
4324
  }
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
+
3176
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);
3177
4361
  if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
3178
4362
  throw new Error("Review controller requires a lineageId");
@@ -3180,6 +4364,8 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
3180
4364
  const parameters: ReviewControllerParameters = {
3181
4365
  operation: value.operation,
3182
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] } : {}),
3183
4369
  };
3184
4370
  for (const key of ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource", "workspaceRoot"] as const) {
3185
4371
  const optional = value[key];
@@ -3486,8 +4672,8 @@ function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<s
3486
4672
  // Bundled and source module instances can coexist, making instanceof insufficient.
3487
4673
  function asNativeReviewCliError(error: unknown): { code: string; diagnostics: NativeReviewProcessDiagnostics } | undefined {
3488
4674
  if (error instanceof NativeReviewCliError) return error;
3489
- if (!(error instanceof Error) || error.name !== "NativeReviewCliError") return undefined;
3490
- 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 };
3491
4677
  if (typeof value.code !== "string") return undefined;
3492
4678
  const diagnostics = sanitizeForeignNativeReviewDiagnostics(value.diagnostics);
3493
4679
  return diagnostics === undefined || value.code !== diagnostics.error_code ? undefined : { code: value.code, diagnostics };
@@ -3509,7 +4695,9 @@ function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation,
3509
4695
  ...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { lineage_created: false, mutation_performed: false, mutation_outcome: "none" }),
3510
4696
  inventory_complete: false,
3511
4697
  diagnostics,
3512
- 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,
3513
4701
  };
3514
4702
  }
3515
4703
 
@@ -3843,6 +5031,18 @@ function mapNativeTargetStatus(operation: ReviewControllerOperation, status: Rev
3843
5031
  hint: `run ${status.nextTransition.continuation.command}`,
3844
5032
  };
3845
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
+ }
3846
5046
  return {
3847
5047
  operation,
3848
5048
  status: status.action === "start" ? "ready" : "blocked",
@@ -3936,6 +5136,12 @@ interface RetainedNativeUntrackedSelection {
3936
5136
  readonly untrackedScope: NativeStartUntrackedScope;
3937
5137
  readonly expectedUntrackedInventory: string;
3938
5138
  readonly intendedUntracked: readonly string[];
5139
+ readonly submission?: NativeIntendedUntrackedSelectionSubmission;
5140
+ }
5141
+
5142
+ interface RetainedPreLineageNativeUntrackedSelection extends RetainedNativeUntrackedSelection {
5143
+ readonly targetIdentity: string;
5144
+ readonly candidateTree: string;
3939
5145
  }
3940
5146
 
3941
5147
  interface RetainedNativeCaptureRoute { readonly workspaceRoot: string; readonly lineageId: string; readonly baseRef?: string; readonly committedOnly?: true; }
@@ -4138,17 +5344,6 @@ const processRetainedNativeStatusSelections = new Map<PendingReviewConsentSessio
4138
5344
  // and a fresh primary-loop start resets it to 0.
4139
5345
  const processAgentEndSubagentDepth = new Map<PendingReviewConsentSessionKey, number>();
4140
5346
 
4141
- // Target identities already nudged once per session, so the read-only
4142
- // `agent_end` preflight reminder fires at most once per unreviewed candidate.
4143
- const processAgentEndPreflightNudgedTargets = new Map<PendingReviewConsentSessionKey, Set<string>>();
4144
-
4145
- // gentle-pi#568: the target identity negotiated STATUS reported at
4146
- // `session_start`, before this session touched the worktree. A candidate
4147
- // already present at that point is the user's own pre-session work, not
4148
- // something this session produced, so `agent_end` must not treat it as an
4149
- // unreviewed candidate this session should be reminded about.
4150
- const processAgentEndSessionBaseline = new Map<PendingReviewConsentSessionKey, string>();
4151
-
4152
5347
  // gentle-pi#677: gentle-ai#4309 owns anonymous usage telemetry end to end;
4153
5348
  // Pi only nudges it once per process. This is a plain process-lifetime
4154
5349
  // guard, not a session-keyed map, because the nudge is meant to fire at most
@@ -4341,6 +5536,27 @@ function consentBindingRepositoryMismatchOutcome(operation: ReviewControllerOper
4341
5536
  };
4342
5537
  }
4343
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
+
4344
5560
  function assertNativeStartCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void {
4345
5561
  candidateView.verify();
4346
5562
  if (
@@ -4503,7 +5719,9 @@ async function reconcileNativeMutationFailure(
4503
5719
  try {
4504
5720
  const status = await nativeReviewCli.targetStatus(target);
4505
5721
  syncRetainedNativeStatusSelections(retainedSelections, canonicalRetentionRoot, status, target.baseRef);
4506
- 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;
4507
5725
  void staleStatusDirective;
4508
5726
  // Field defect (fambig, 2026-08-16): an envelope-less mutating failure
4509
5727
  // is stamped mutationOutcome "unknown", but a reconciled authority
@@ -4517,6 +5735,8 @@ async function reconcileNativeMutationFailure(
4517
5735
  void staleReplayability;
4518
5736
  return {
4519
5737
  ...provenBase,
5738
+ ...projectedStatus,
5739
+ status: "blocked",
4520
5740
  outcome: "native-mutation-status-reconciled",
4521
5741
  reconciliation: status.raw,
4522
5742
  authority_applicability: status.applicability,
@@ -4524,17 +5744,19 @@ async function reconcileNativeMutationFailure(
4524
5744
  mutation_performed: false,
4525
5745
  mutation_outcome: "none",
4526
5746
  mutation_outcome_reason: `authority revision unchanged across reconciliation (${preOperationRevision}); the failed operation provably did not mutate`,
4527
- next_action: status.action,
4528
5747
  };
4529
5748
  }
4530
5749
  return {
4531
5750
  ...reconciledBase,
5751
+ ...projectedStatus,
5752
+ status: "blocked",
4532
5753
  outcome: "native-mutation-status-reconciled",
4533
5754
  reconciliation: status.raw,
4534
5755
  authority_applicability: status.applicability,
4535
5756
  provider_action: status.action,
4536
5757
  replayability: status.replayability,
4537
- 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),
4538
5760
  };
4539
5761
  } catch (statusError) {
4540
5762
  return {
@@ -4636,6 +5858,41 @@ function readRetainedNativeUntrackedSelection(selections: Map<string, RetainedNa
4636
5858
  };
4637
5859
  }
4638
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
+
4639
5896
  function isRetainedNativeCaptureRoute(selection: RetainedNativeStatusSelection | undefined): selection is RetainedNativeCaptureRoute {
4640
5897
  return selection !== undefined && "workspaceRoot" in selection;
4641
5898
  }
@@ -4706,6 +5963,16 @@ const REVIEW_HOST_RELAY_REFUSED_ACTION =
4706
5963
  "gentle-ai refused this submission at admission and did not consume the lens slot; the reason is in failure.stderr. "
4707
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.";
4708
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
+
4709
5976
  function reviewHostRelayTimeoutNextAction(error: ReviewHostRelayError): string {
4710
5977
  const measured = error.elapsedMs === null || error.timeoutMs === null
4711
5978
  ? ""
@@ -4891,6 +6158,101 @@ async function executeReviewHostRelayCapture(
4891
6158
  mutation_outcome: "none",
4892
6159
  };
4893
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
+ }
4894
6256
  return {
4895
6257
  tool: "gentle_review_capture",
4896
6258
  status: "blocked",
@@ -4911,6 +6273,21 @@ async function executeReviewHostRelayCapture(
4911
6273
  const REVIEW_PROVIDER_ROLE_RETRY_ACTION =
4912
6274
  "Call fresh STATUS and execute only the exact one-slot role vector it reoffers; never relaunch from transcript inference.";
4913
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
+
4914
6291
  async function executeProviderRoleVectorCapture(
4915
6292
  slot: ReviewProviderRoleVectorSlot,
4916
6293
  nativeReviewCli: NativeReviewCli,
@@ -5080,9 +6457,9 @@ async function negotiatedStatusForHostTransport(
5080
6457
  // under the exact guards `agent_end` uses to decide whether to nudge: a
5081
6458
  // native review CLI with both `reviewMode` and `targetStatus`, a UI-bearing
5082
6459
  // context, and RDD effectively on. Returns `undefined` on any missing guard,
5083
- // an effective-off mode, or any STATUS error or transport refusal, so both
5084
- // `session_start` (recording a baseline) and `agent_end` (deciding whether to
5085
- // 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.
5086
6463
  async function resolveNegotiatedReviewStatusForSession(
5087
6464
  nativeReviewCli: NativeReviewCli | null,
5088
6465
  ctx: ExtensionContext,
@@ -5107,12 +6484,12 @@ async function resolveNegotiatedReviewStatusForSession(
5107
6484
  }
5108
6485
  }
5109
6486
 
5110
- // 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
5111
6488
  // through `agent_end`. It never runs START itself, so it names the one
5112
6489
  // supported continuation (gentle_review inspect) and defers the resulting
5113
6490
  // consent envelope to the human.
5114
6491
  function renderAgentEndReviewPreflightMessage(targetIdentity: string): string {
5115
- 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. 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 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.`;
5116
6493
  }
5117
6494
 
5118
6495
  function canonicalReviewCaptureBinding(value: unknown): string {
@@ -5157,6 +6534,13 @@ function reviewIntendedUntrackedInput(status: ReviewStatusV3): ReviewCollectInpu
5157
6534
  return matches.length === 1 ? matches[0] : undefined;
5158
6535
  }
5159
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
+
5160
6544
  interface PublicReviewCaptureBinding { collectBinding: string; }
5161
6545
  function publicReviewCaptureBindings(status: ReviewStatusV3): readonly PublicReviewCaptureBinding[] {
5162
6546
  if (status.nextTransition?.kind !== "collect") return [];
@@ -5581,7 +6965,7 @@ async function executeReviewControllerOperation(
5581
6965
  const parameters = parseReviewControllerParameters(parametersValue);
5582
6966
  const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
5583
6967
  const pendingReviewConsentSession = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey);
5584
- const useTargetLifecycleRoot = requiresExplicitTargetLifecycleRoot(parameters.workspaceRoot, sessionCwd, defaultCwd);
6968
+ const _useTargetLifecycleRoot = requiresExplicitTargetLifecycleRoot(parameters.workspaceRoot, sessionCwd, defaultCwd);
5585
6969
  const includeWorkspaceRoot = parameters.workspaceRoot !== undefined || defaultCwd !== sessionCwd;
5586
6970
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.EXPORT || parameters.operation === REVIEW_CONTROLLER_OPERATION.IMPORT) {
5587
6971
  // Legacy bundle transport rode on the retired pre-integration graph/compact
@@ -5616,20 +7000,138 @@ async function executeReviewControllerOperation(
5616
7000
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
5617
7001
  return await executeNativeAuthorityMaintenance(parameters.operation, maintenance, input, defaultCwd, nativeReviewCli, signal);
5618
7002
  }
5619
- 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, "");
5620
7011
  try {
5621
7012
  if (nativeReviewCli.targetStatus !== undefined) {
5622
- const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
5623
- cwd: defaultCwd,
5624
- ...(signal === undefined ? {} : { signal }),
5625
- }, retainedUntrackedSelections, defaultCwd);
7013
+ const negotiated = await negotiatedStatusForHostTransport(
7014
+ nativeReviewCli,
7015
+ {
7016
+ cwd: defaultCwd,
7017
+ ...(signal === undefined ? {} : { signal }),
7018
+ },
7019
+ retainedUntrackedSelections,
7020
+ defaultCwd,
7021
+ );
5626
7022
  if (negotiated.transport !== undefined) {
5627
7023
  return {
5628
7024
  ...hostTransportUnavailable(parameters.operation, negotiated.transport),
5629
7025
  ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
5630
7026
  };
5631
7027
  }
5632
- 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
+ };
5633
7135
  }
5634
7136
  return nativeStatusUnsupported(parameters.operation);
5635
7137
  } catch (error) {
@@ -5958,6 +7460,9 @@ async function executeReviewControllerOperation(
5958
7460
  };
5959
7461
  }
5960
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, "");
5961
7466
  completed = completeNativeStart(parameters.operation, answered.start, pending.repositoryCwd, pending.candidateView, pending.candidateViews);
5962
7467
  } catch (error) {
5963
7468
  const value = error as { mutationOutcome?: unknown };
@@ -6009,9 +7514,35 @@ async function executeReviewControllerOperation(
6009
7514
  if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) return nativeStartRejection("base-ref-invalid");
6010
7515
  if (baseRef !== undefined && rawStart.committedOnly !== true) return nativeStartRejection("committed-only-required");
6011
7516
  if (baseRef === undefined && "committedOnly" in rawStart) return nativeStartRejection("committed-only-invalid");
6012
- const untrackedSelection = validateNativeStartUntrackedSelection(rawStart);
6013
- if (untrackedSelection.reason !== undefined) return nativeStartRejection(untrackedSelection.reason);
6014
- 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);
6015
7546
  let canonicalBaseRef: string | undefined;
6016
7547
  if (baseRef !== undefined) {
6017
7548
  try {
@@ -6036,11 +7567,50 @@ async function executeReviewControllerOperation(
6036
7567
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
6037
7568
  ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }),
6038
7569
  ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
6039
- ...(intendedUntrackedSelection === undefined ? {} : { intendedUntrackedSelection }),
7570
+ ...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
6040
7571
  ...(signal === undefined ? {} : { signal }),
6041
7572
  }, retainedUntrackedSelections, defaultCwd);
6042
7573
  if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport);
6043
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
+ }
6044
7614
  if (target.nextTransition?.kind === "collect" || target.applicability !== "unrelated" || target.action !== "start") return mapNativeTargetStatus(parameters.operation, target, parameters.lineageId);
6045
7615
  } catch (error) {
6046
7616
  return nativeOperationFailure(parameters.operation, error);
@@ -6083,7 +7653,7 @@ async function executeReviewControllerOperation(
6083
7653
  targetIdentity: target.targetIdentity,
6084
7654
  projection: target.projection.projection,
6085
7655
  ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
6086
- ...(intendedUntrackedSelection === undefined ? {} : { intendedUntrackedSelection }),
7656
+ ...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
6087
7657
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
6088
7658
  ...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
6089
7659
  ...(focus === undefined ? {} : { focus }),
@@ -6141,22 +7711,30 @@ async function executeReviewControllerOperation(
6141
7711
  };
6142
7712
  }
6143
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, "");
6144
7717
  return completeNativeStart(parameters.operation, result, defaultCwd, candidateView, candidateViews);
6145
7718
  } catch (error) {
6146
- 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 }));
6147
7720
  if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason);
6148
7721
  const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
6149
7722
  const provenNoMutation = value.mutationOutcome === "none";
6150
- const preNativeCandidateFailure = !nativeStartAttempted && error instanceof CandidateViewError;
6151
- 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);
6152
7726
  const failure = provenNoMutation
6153
7727
  ? error
6154
- : preNativeCandidateFailure
6155
- ? Object.assign(error, { candidateViewPreNative: true })
6156
- : Object.assign(error instanceof Error ? error : new Error(String(error)), {
6157
- mutationOutcome: "unknown",
6158
- nextAction: "review.status",
6159
- });
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
+ );
6160
7738
  return reconcileNativeMutationFailure(parameters.operation, failure, nativeReviewCli, {
6161
7739
  cwd: defaultCwd,
6162
7740
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
@@ -6293,7 +7871,7 @@ async function executeReviewControllerOperation(
6293
7871
  ) retainNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId, retainedUntrackedSelection);
6294
7872
  clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state);
6295
7873
  hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, status);
6296
- return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId, defaultCwd), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
7874
+ return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
6297
7875
  } catch (error) {
6298
7876
  return nativeOperationFailure(parameters.operation, error);
6299
7877
  }
@@ -6311,6 +7889,9 @@ export const __testing = {
6311
7889
  listDiscoverableAgents,
6312
7890
  orderDiscoverableAgents,
6313
7891
  classifyGuardedCommand,
7892
+ evaluateGuardedCommand,
7893
+ guardedCommandPreview,
7894
+ guardedCommandTitle,
6314
7895
  loadRuntimeGuardrailsConfig,
6315
7896
  buildGentlePrompt,
6316
7897
  nativeStatusUnsupported,
@@ -6345,6 +7926,9 @@ export const __testing = {
6345
7926
  clearNativeReviewOutcomeMemoForTesting,
6346
7927
  resolveControllerSddStatus,
6347
7928
  resolveStartupControllerSddStatus,
7929
+ resolveSddChangeStartup,
7930
+ resolveSelectedNativeSddChangeStartup,
7931
+ readSddChangeFlag,
6348
7932
  resetTelemetryTriggerGuardForTesting,
6349
7933
  createGentleAiExtension: createGentleAiExtensionForTesting,
6350
7934
  };
@@ -6415,6 +7999,11 @@ function createGentleAiExtensionForTesting(
6415
7999
  const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary;
6416
8000
  const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter();
6417
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
+ });
6418
8007
  declareReviewRelayHandshake(dependencies.processEnv ?? process.env);
6419
8008
  const pendingReviewConsentFallbackKey = Symbol("pending-review-consent-fallback");
6420
8009
  const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
@@ -6449,7 +8038,11 @@ function createGentleAiExtensionForTesting(
6449
8038
  return revoked;
6450
8039
  };
6451
8040
 
8041
+ let reminderSessionActive = true;
8042
+ let reminderEpoch = 0;
6452
8043
  pi.on("session_shutdown", (event, context) => {
8044
+ reminderSessionActive = false;
8045
+ reminderEpoch += 1;
6453
8046
  // Pi tears down this registry on reload as well as session replacement/quit.
6454
8047
  try { candidateViews?.cleanupAll(); } catch { /* Preserve failed owned views for later recovery. */ }
6455
8048
  const reason = (event as { reason?: unknown }).reason;
@@ -6462,12 +8055,11 @@ function createGentleAiExtensionForTesting(
6462
8055
  cleanupAllPendingReviewConsents(pendingReviewConsentRegistry, sessionKey);
6463
8056
  processRetainedNativeStatusSelections.delete(sessionKey);
6464
8057
  processAgentEndSubagentDepth.delete(sessionKey);
6465
- processAgentEndPreflightNudgedTargets.delete(sessionKey);
6466
- processAgentEndSessionBaseline.delete(sessionKey);
6467
8058
  });
6468
8059
 
6469
8060
  pi.registerTool({
6470
8061
  name: "gentle_review_scope",
8062
+ renderShell: "self",
6471
8063
  label: "Gentle Review Scope",
6472
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.",
6473
8065
  parameters: REVIEW_SCOPE_PARAMETERS,
@@ -6510,6 +8102,7 @@ function createGentleAiExtensionForTesting(
6510
8102
 
6511
8103
  pi.registerTool({
6512
8104
  name: "gentle_review_capture_group",
8105
+ renderShell: "self",
6513
8106
  label: "Gentle Review Capture Group",
6514
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.",
6515
8108
  promptSnippet: "Use one complete exact current STATUS materialize reviewer group; acknowledge its forecast before the grouped run.",
@@ -6544,6 +8137,7 @@ function createGentleAiExtensionForTesting(
6544
8137
 
6545
8138
  pi.registerTool({
6546
8139
  name: "gentle_review_capture",
8140
+ renderShell: "self",
6547
8141
  label: "Gentle Review Capture",
6548
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.",
6549
8143
  promptSnippet: "Use one exact current STATUS collectBinding for one ordinary native capture; call fresh STATUS before every additional capture.",
@@ -6584,12 +8178,14 @@ function createGentleAiExtensionForTesting(
6584
8178
 
6585
8179
  pi.registerTool({
6586
8180
  name: "gentle_review",
8181
+ renderShell: "self",
6587
8182
  label: "Gentle Review Controller",
6588
8183
  description:
6589
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.",
6590
8185
  promptSnippet: "Inspect authority, then start native ordinary review; use gentle_review_capture for one current collect slot",
6591
8186
  promptGuidelines: [
6592
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.',
6593
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.",
6594
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.",
6595
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.",
@@ -6616,6 +8212,17 @@ function createGentleAiExtensionForTesting(
6616
8212
  const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
6617
8213
  const retainedSelections = processRetainedNativeStatusSelections.get(sessionKey)
6618
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. */ }
6619
8226
  let details = await executeReviewControllerOperation(
6620
8227
  parameters,
6621
8228
  ctx.cwd,
@@ -6629,6 +8236,16 @@ function createGentleAiExtensionForTesting(
6629
8236
  reviewConsentNow,
6630
8237
  reviewConsentScheduleTimer,
6631
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
+ }
6632
8249
  if (
6633
8250
  isHostReviewConsentEligibleOperation(parameters) &&
6634
8251
  details.outcome === "native-review-consent-required" &&
@@ -6708,10 +8325,12 @@ function createGentleAiExtensionForTesting(
6708
8325
  });
6709
8326
 
6710
8327
  function runSddPreflight(ctx: ExtensionContext, promptFields: readonly SddPreflightField[] = []): Promise<SddPreflightPreferences> {
6711
- 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 });
6712
8329
  }
6713
8330
 
6714
8331
  pi.on("session_start", async (event, ctx) => {
8332
+ reminderSessionActive = true;
8333
+ reminderEpoch += 1;
6715
8334
  try { candidateViews?.sweepOrphans(ctx.cwd); } catch { /* Ownership sweeping must not block startup. */ }
6716
8335
  const reason = (event as { reason?: unknown }).reason;
6717
8336
  if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx);
@@ -6726,7 +8345,7 @@ function createGentleAiExtensionForTesting(
6726
8345
  if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
6727
8346
  }
6728
8347
  try {
6729
- const installResult = installSddAssets(ctx.cwd, true);
8348
+ const installResult = installPackageAssets(ctx.cwd, true, ["delegation", "review"]);
6730
8349
  migrateLegacyProjectModelOverrides(ctx.cwd);
6731
8350
  const modelResult = await applySavedModelConfig(ctx);
6732
8351
  if (ctx.hasUI && modelResult.invalidPath) {
@@ -6738,7 +8357,7 @@ function createGentleAiExtensionForTesting(
6738
8357
  }
6739
8358
  if (ctx.hasUI && modelResult.updated > 0) {
6740
8359
  ctx.ui.notify(
6741
- `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).`,
6742
8361
  "info",
6743
8362
  );
6744
8363
  }
@@ -6752,18 +8371,13 @@ function createGentleAiExtensionForTesting(
6752
8371
  );
6753
8372
  }
6754
8373
  }
6755
- // gentle-pi#568: record the target identity STATUS reports right now,
6756
- // before this session does anything, as the baseline `agent_end` skips
6757
- // later. Best-effort and silent: it never notifies and never lets a
6758
- // 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.
6759
8376
  try {
6760
8377
  const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
6761
- const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
6762
- if (status?.targetIdentity !== undefined) {
6763
- processAgentEndSessionBaseline.set(sessionKey, status.targetIdentity);
6764
- }
8378
+ await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
6765
8379
  } catch {
6766
- // Baseline recording is best-effort only; never surface or throw.
8380
+ // Startup negotiation is best-effort only; never surface or throw.
6767
8381
  }
6768
8382
  });
6769
8383
 
@@ -6771,11 +8385,17 @@ function createGentleAiExtensionForTesting(
6771
8385
  if (typeof event.text !== "string" || !isSddPreflightTrigger(event.text)) {
6772
8386
  return { action: "continue" };
6773
8387
  }
6774
- 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
+ }
6775
8393
  return { action: "continue" };
6776
8394
  });
6777
8395
 
8396
+ let nativeSddStartupBlock: string | undefined;
6778
8397
  pi.on("before_agent_start", async (event, ctx) => {
8398
+ nativeSddStartupBlock = undefined;
6779
8399
  const isSddAgent = isSddAgentStartEvent(event);
6780
8400
  const isNamedAgent = isNamedAgentStartEvent(event);
6781
8401
  const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
@@ -6804,23 +8424,57 @@ function createGentleAiExtensionForTesting(
6804
8424
  // Best-effort only; never surfaced and never affects activation.
6805
8425
  }
6806
8426
  }
6807
- if (isSddAgent && !getSddPreflightPreferences(ctx)) {
6808
- await runSddPreflight(ctx);
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.` };
6809
8435
  }
6810
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.
6811
8440
  const sddPrompt =
6812
8441
  prefs && (!isNamedAgent || isSddAgent)
6813
8442
  ? `\n\n${renderSddPreflightPrompt(prefs)}`
6814
8443
  : "";
6815
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";
6816
8447
  const nativeStatusPrompt = phase
6817
- ? `\n\n${renderNativeSddPhasePrompt(resolveStartupControllerSddStatus(
6818
- ctx.cwd,
6819
- undefined,
6820
- true,
6821
- prefs?.artifactStore,
6822
- ), phase)}`
6823
- : "";
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.";
6824
8478
  // gentle-pi#661: the RDD status line (and the rest of the gentle prompt)
6825
8479
  // is built only for the primary session, mirrored on the
6826
8480
  // reviewContractPrompt condition below -- named/SDD agents never reach
@@ -6847,7 +8501,7 @@ function createGentleAiExtensionForTesting(
6847
8501
  })()
6848
8502
  : "";
6849
8503
  return {
6850
- systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}`,
8504
+ systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}${!isNamedAgent && !isSddAgent ? `\n\n${renderResearchCapabilities(resolveResearchCapabilities(pi))}` : ""}`,
6851
8505
  };
6852
8506
  });
6853
8507
 
@@ -6855,33 +8509,30 @@ function createGentleAiExtensionForTesting(
6855
8509
  // an authorized implementation and report completion without ever running
6856
8510
  // the review STATUS preflight or offering the consent question. This
6857
8511
  // handler is read-only and idempotent: it never runs START, never answers
6858
- // consent, and never writes a file. It only sends one turn-triggering
6859
- // reminder, at most once per unreviewed target identity per session.
6860
- // gentle-pi#568: a candidate matching the baseline `session_start`
6861
- // recorded predates this session's own work and is skipped rather than
6862
- // nudged, so a worktree already dirty from the user's own edits does not
6863
- // 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.
6864
8514
  pi.on("agent_end", async (_event, ctx) => {
6865
8515
  if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return;
6866
- if (ctx.hasUI !== true) return;
8516
+ if (ctx.hasUI !== true || !reminderSessionActive) return;
6867
8517
  const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
6868
8518
  const subagentDepth = processAgentEndSubagentDepth.get(sessionKey) ?? 0;
6869
8519
  if (subagentDepth > 0) {
6870
8520
  processAgentEndSubagentDepth.set(sessionKey, subagentDepth - 1);
6871
8521
  return;
6872
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;
6873
8530
  const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
6874
- 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;
6875
8534
  if (status.nextTransition?.kind !== "execute" || status.nextTransition.execute.operation !== "review.start") return;
6876
8535
  const targetIdentity = status.targetIdentity;
6877
- if (processAgentEndSessionBaseline.get(sessionKey) === targetIdentity) return;
6878
- let nudged = processAgentEndPreflightNudgedTargets.get(sessionKey);
6879
- if (nudged === undefined) {
6880
- nudged = new Set<string>();
6881
- processAgentEndPreflightNudgedTargets.set(sessionKey, nudged);
6882
- }
6883
- if (nudged.has(targetIdentity)) return;
6884
- nudged.add(targetIdentity);
6885
8536
  pi.sendMessage(
6886
8537
  {
6887
8538
  customType: "gentle-pi.review-preflight",
@@ -6890,15 +8541,70 @@ function createGentleAiExtensionForTesting(
6890
8541
  },
6891
8542
  { triggerTurn: true, deliverAs: "followUp" },
6892
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. */ }
6893
8554
  });
6894
8555
 
6895
8556
  pi.on("tool_call", async (event, ctx) => {
8557
+ if (nativeSddStartupBlock && event.toolName !== "subagent_parent_message") return { block: true, reason: `SDD selection blocked: ${nativeSddStartupBlock}` };
6896
8558
  const sensitivePathDenied = evaluateSensitivePathTool(
6897
8559
  event.toolName,
6898
8560
  event.input,
6899
8561
  );
6900
8562
  if (sensitivePathDenied) return sensitivePathDenied;
6901
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;
6902
8608
  const writerScopeDenied = rejectUnscopedBoundedWriterDispatch(event.input);
6903
8609
  if (writerScopeDenied) return writerScopeDenied;
6904
8610
  try {
@@ -6918,39 +8624,50 @@ function createGentleAiExtensionForTesting(
6918
8624
  return await confirmCommand(event.input.command, ctx, pi.events, herdrLifecycle);
6919
8625
  });
6920
8626
 
6921
- pi.registerCommand("gentle:install-sdd", {
6922
- description:
6923
- "Repair or refresh global Gentle AI SDD subagent and chain assets.",
6924
- handler: async (args, ctx) => {
6925
- const force = args.includes("--force");
6926
- const result = installSddAssets(ctx.cwd, force);
6927
- ctx.ui.notify(
6928
- `Global Gentle AI SDD assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`,
6929
- "info",
6930
- );
6931
- },
6932
- });
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
+ }
6933
8641
 
6934
8642
  pi.registerCommand("gentle:sdd-preflight", {
6935
8643
  description:
6936
- "Run or reuse the lazy SDD preflight for this Pi session.",
6937
- handler: async (_args, ctx) => {
6938
- 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
+ }
6939
8655
  },
6940
8656
  });
6941
8657
 
6942
- const handleSddStatusCommand = async (args: string, ctx: ExtensionContext) => {
8658
+ const readCommandSddStatus = async (args: string, ctx: ExtensionContext) => {
6943
8659
  const parsed = parseSddStatusCommandArgs(args);
6944
- const status = resolveControllerSddStatus(
6945
- ctx.cwd,
6946
- parsed.changeName,
6947
- true,
6948
- getSddPreflightPreferences(ctx)?.artifactStore,
6949
- );
6950
- ctx.ui.notify(
6951
- parsed.json ? JSON.stringify(status, null, 2) : renderSddStatusMarkdown(status),
6952
- sddStatusSeverity(status),
6953
- );
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);
6954
8671
  };
6955
8672
 
6956
8673
  pi.registerCommand("gentle-sdd-status", {
@@ -6961,17 +8678,34 @@ function createGentleAiExtensionForTesting(
6961
8678
  });
6962
8679
 
6963
8680
  const handleSddContinueCommand = async (args: string, ctx: ExtensionContext) => {
6964
- const parsed = parseSddStatusCommandArgs(args);
6965
- const status = resolveControllerSddStatus(
6966
- ctx.cwd,
6967
- parsed.changeName,
6968
- true,
6969
- getSddPreflightPreferences(ctx)?.artifactStore,
6970
- );
6971
- ctx.ui.notify(
6972
- parsed.json ? JSON.stringify(status, null, 2) : renderSddDispatcherMarkdown(status),
6973
- sddStatusSeverity(status),
6974
- );
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);
6975
8709
  };
6976
8710
 
6977
8711
  pi.registerCommand("gentle-sdd-continue", {
@@ -6988,6 +8722,13 @@ function createGentleAiExtensionForTesting(
6988
8722
  },
6989
8723
  });
6990
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
+
6991
8732
  pi.registerCommand("gentle:persona", {
6992
8733
  description: "Switch el Gentleman persona between gentleman and neutral.",
6993
8734
  handler: async (_args, ctx) => {
@@ -7058,29 +8799,19 @@ function createGentleAiExtensionForTesting(
7058
8799
  pi.registerCommand("gentle:doctor", {
7059
8800
  description: "Run read-only Gentle AI diagnostics for this Pi workspace.",
7060
8801
  handler: async (_args, ctx) => {
7061
- const agentsInstalled = existsSync(
7062
- join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
7063
- );
7064
- const chainsInstalled = existsSync(
7065
- join(gentlePiAgentHome(), "chains", "sdd-full.chain.md"),
7066
- );
8802
+ const assetLines = packageAssetDiagnosticLines(ctx.cwd);
7067
8803
  const openspecConfigured = existsSync(
7068
8804
  join(ctx.cwd, "openspec", "config.yaml"),
7069
8805
  );
7070
8806
  const skillRegistryPresent = existsSync(
7071
8807
  join(ctx.cwd, ".atl", "skill-registry.md"),
7072
8808
  );
7073
- const staleSddAssets = sddGlobalAssetDriftCount();
7074
- const localSddAgentOverrides = sddLocalAgentOverrideCount(ctx.cwd);
7075
8809
  const modelConfig = await readSavedModelConfigAsync(ctx.cwd);
7076
8810
  const engramActive = hasWritableEngramTool(pi);
7077
8811
  const devBinary = await describeDevBinaryOverride();
7078
8812
  const lines = [
7079
8813
  "el Gentleman doctor",
7080
- `${agentsInstalled ? "pass" : "fail"}: Global SDD agents ${agentsInstalled ? "installed" : "missing"}`,
7081
- `${chainsInstalled ? "pass" : "fail"}: Global SDD chains ${chainsInstalled ? "installed" : "missing"}`,
7082
- `${staleSddAssets === 0 ? "pass" : "warn"}: Global SDD asset drift ${staleSddAssets} file(s)`,
7083
- `${localSddAgentOverrides === 0 ? "pass" : "warn"}: Project-local SDD agent overrides ${localSddAgentOverrides} file(s)`,
8814
+ ...assetLines,
7084
8815
  `${openspecConfigured ? "pass" : "warn"}: OpenSpec config ${openspecConfigured ? "present" : "missing"}`,
7085
8816
  `${skillRegistryPresent ? "pass" : "warn"}: Skill registry ${skillRegistryPresent ? "present" : "missing"}`,
7086
8817
  `${modelConfig.status === "invalid" ? "fail" : "pass"}: Global model config ${modelConfig.status}`,
@@ -7089,18 +8820,12 @@ function createGentleAiExtensionForTesting(
7089
8820
  ...(devBinary.state === "active" ? [`warn: ${devBinary.line}`] : []),
7090
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)"] : []),
7091
8822
  ];
7092
- if (!agentsInstalled || !chainsInstalled) {
7093
- lines.push("remedy: run /gentle:install-sdd --force to refresh global SDD assets intentionally");
7094
- }
7095
8823
  if (modelConfig.status === "invalid") {
7096
8824
  lines.push(`remedy: fix or remove ${modelConfig.path}`);
7097
8825
  }
7098
- if (localSddAgentOverrides > 0) {
7099
- lines.push("remedy: remove project-local SDD agent overrides unless intentionally debugging package assets");
7100
- }
7101
8826
  ctx.ui.notify(
7102
8827
  lines.join("\n"),
7103
- lines.some((line) => line.startsWith("fail:")) ? "warning" : "info",
8828
+ lines.some((line) => line.startsWith("fail:")) || assetLines.some((line) => line.startsWith("warn:")) ? "warning" : "info",
7104
8829
  );
7105
8830
  },
7106
8831
  });
@@ -7130,7 +8855,7 @@ function createGentleAiExtensionForTesting(
7130
8855
  });
7131
8856
 
7132
8857
  pi.registerCommand("gentle:review-mode", {
7133
- 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.",
7134
8859
  handler: async (args, ctx) => {
7135
8860
  const subAction = args.trim().length === 0 ? NATIVE_REVIEW_MODE_OPERATION.STATUS : args.trim();
7136
8861
  if (subAction !== NATIVE_REVIEW_MODE_OPERATION.STATUS && subAction !== NATIVE_REVIEW_MODE_OPERATION.ENABLE && subAction !== NATIVE_REVIEW_MODE_OPERATION.DISABLE) {
@@ -7252,41 +8977,27 @@ function createGentleAiExtensionForTesting(
7252
8977
  pi.registerCommand("gentle:status", {
7253
8978
  description: "Show Gentle AI package status for this project.",
7254
8979
  handler: async (_args, ctx) => {
7255
- const agentsInstalled = existsSync(
7256
- join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
7257
- );
7258
- const chainsInstalled = existsSync(
7259
- join(gentlePiAgentHome(), "chains", "sdd-full.chain.md"),
7260
- );
8980
+ const assetLines = packageAssetDiagnosticLines(ctx.cwd);
7261
8981
  const openspecConfigured = existsSync(
7262
8982
  join(ctx.cwd, "openspec", "config.yaml"),
7263
8983
  );
7264
- const staleSddAssets = sddGlobalAssetDriftCount();
7265
- const localSddAgentOverrides = sddLocalAgentOverrideCount(ctx.cwd);
7266
- const modelConfig = await readModelConfigAsync(ctx.cwd);
8984
+ const savedConfig = await readModelRoutingAuthorityAsync(
8985
+ modelConfigPath(ctx.cwd),
8986
+ legacyProjectModelConfigPath(ctx.cwd),
8987
+ );
7267
8988
  const devBinary = await describeDevBinaryOverride();
7268
8989
  ctx.ui.notify(
7269
8990
  [
7270
8991
  "el Gentleman package is active.",
7271
8992
  ...(devBinary.state === "inactive" ? [] : [devBinary.line]),
7272
8993
  `Persona: ${readPersonaMode(ctx.cwd)}`,
7273
- `Global SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
7274
- `Global SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
7275
- `Global SDD assets stale: ${staleSddAssets} file(s)${
7276
- staleSddAssets > 0
7277
- ? " — run /gentle:install-sdd --force to refresh intentionally"
7278
- : ""
7279
- }`,
7280
- `Project-local SDD agent overrides: ${localSddAgentOverrides} file(s)${
7281
- localSddAgentOverrides > 0
7282
- ? " — local SDD agents shadow package assets; remove them unless intentionally debugging"
7283
- : ""
7284
- }`,
8994
+ ...assetLines,
7285
8995
  `OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
7286
8996
  `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
7287
- ...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 : {})),
7288
8999
  ].join("\n"),
7289
- staleSddAssets > 0 || localSddAgentOverrides > 0 || devBinary.state !== "inactive" ? "warning" : "info",
9000
+ savedConfig.status === "invalid" || assetLines.some((line) => line.startsWith("warn:")) || devBinary.state !== "inactive" ? "warning" : "info",
7290
9001
  );
7291
9002
  },
7292
9003
  });