gentle-pi 3.2.0 → 3.3.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 (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
@@ -1,5 +1,6 @@
1
1
  import { consumeReviewMutation, pendingReviewMutation, recordReviewMutation } from "../lib/review-reminder-receipt.ts";
2
2
  import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
3
+ import { OddRuntimeDelegationGate } from "../lib/odd-runtime-delegation-gate.ts";
3
4
  import { resolveResearchCapabilities, renderResearchCapabilities } from "../lib/sdd-research-capabilities.ts";
4
5
  import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
5
6
  import { execFileSync } from "node:child_process";
@@ -116,6 +117,7 @@ import {
116
117
  readOrchestratorSettings,
117
118
  restoreOrchestratorSettings,
118
119
  type OrchestratorSettingsReadResult,
120
+ parseOrchestratorModelRef,
119
121
  } from "../lib/profiles-orchestrator.ts";
120
122
  import { measureAgentsViewLayout, type AgentsViewLayout } from "../lib/agents-view-layout.ts";
121
123
  import { NativeChoiceList } from "../lib/native-choice-list.ts";
@@ -132,6 +134,7 @@ import {
132
134
  REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
133
135
  REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
134
136
  ReviewHostRelayError,
137
+ reviewHostMediatedRoleSlots,
135
138
  reviewHostRelaySlots,
136
139
  reviewHostRelayUnachievableDetail,
137
140
  reviewHostRelayUnachievableReason,
@@ -146,6 +149,7 @@ import {
146
149
  type ReviewHostRelaySlot,
147
150
  type ReviewProviderRoleVectorSlot,
148
151
  } from "../lib/review-host-relay.ts";
152
+ import type { InProcessReviewerRegistry } from "../lib/inprocess-reviewer.ts";
149
153
  import {
150
154
  JOURNAL_STATUS,
151
155
  REVIEW_OPERATION,
@@ -1274,7 +1278,7 @@ Organic Driven Development (ODD) is the predefined workflow of this orchestrator
1274
1278
  3. **Resolve uncertainty.** Recommend optional research only for a named uncertainty; ask one focused user question only for a real unresolved product decision, then stop and wait; use at most one scoped read-only assumption challenge for a high-consequence unproven premise.
1275
1279
  4. **Classify.** The work is substantial when exploration yields two or more meaningful implementation steps, or progress worth recovering after an interruption. Small, understood work stays small and creates no durable task artifacts.
1276
1280
  5. **Track before the first write.** For substantial authorized implementation, create \`odd/tasks/<feature-name>.md\` and its Engram mirror \`odd/<feature-name>/tasks\` automatically, then create or rebuild the visible \`todo\` list from the reconciled feature tasks, all before the first source write and without asking permission for tasks or storage. Tell the user in one line which feature document was created and how many tasks it holds.
1277
- 6. **Implement task by task.** Route each task through the orchestrator's Work Routing Ladder, with the configured TDD mode and applicable checks. Check an item off only after its outcome and checks were observed; update the file, mirror, and visible \`todo\` projection after every task transition and material plan change. Every task closes with at least one work-unit commit on the feature branch, branch first when on the default branch, with tests and docs alongside the behavior, using a Conventional Commit message; record the commit identity in the feature document as evidence. Work-unit commits on the feature branch are part of authorized substantial ODD implementation; push, pull request creation, and merge remain the user's decisions.
1281
+ 6. **Implement task by task.** Route each task through the orchestrator's Work Routing Ladder, honoring its mandatory delegation triggers, with the configured TDD mode and applicable checks. These triggers are mandatory, not advisory: executing past a fired trigger inline is a routing defect even if the work succeeds. Check an item off only after its outcome and checks were observed; update the file, mirror, and visible \`todo\` projection after every task transition and material plan change. Every task closes with at least one work-unit commit on the feature branch, branch first when on the default branch, with tests and docs alongside the behavior, using a Conventional Commit message; record the commit identity in the feature document as evidence. Work-unit commits on the feature branch are part of authorized substantial ODD implementation; push, pull request creation, and merge remain the user's decisions.
1278
1282
  7. **Close.** Report the verified outcome, every failed, skipped, or pending check, and the next step. The native review candidate is a work-unit commit or a PR slice, never a TODO checkbox and never the accumulated feature branch; native review runs only under the user-owned RDD switch.
1279
1283
  Resume an interrupted feature with \`mem_context\`, then project- and feature-scoped \`mem_search\`, then \`mem_get_observation\` for the full document, then the task file itself; reconcile before continuing the next unfinished task. Detail for steps 3–7: \`orchestrator-delegation.md\` and \`orchestrator-memory.md\`.
1280
1284
 
@@ -4136,6 +4140,39 @@ function reportProfilesDrops(ctx: ExtensionContext, path: string, drops: Profile
4136
4140
  }
4137
4141
  }
4138
4142
 
4143
+ /** Pi's own live-session controls: the ExtensionAPI's setModel/setThinkingLevel. */
4144
+ type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel">;
4145
+
4146
+ /**
4147
+ * Switch the running session to the profile's orchestrator. `settings.json`
4148
+ * is the default for new sessions only; Pi's `setModel`/`setThinkingLevel`
4149
+ * are what move the live one. Failures never undo the persisted default: the
4150
+ * profile is applied for the next session either way, and the note says what
4151
+ * this session did. Nothing here may throw — settings.json is already written
4152
+ * and the apply must finish reporting.
4153
+ */
4154
+ async function switchLiveOrchestrator(ctx: ExtensionContext, live: LiveSession, entry: AgentRoutingEntry): Promise<string> {
4155
+ const reference = parseOrchestratorModelRef(entry.model);
4156
+ if (reference === undefined) return "";
4157
+ const label = `${reference.provider}/${reference.model}`;
4158
+ const model = ctx.modelRegistry.find(reference.provider, reference.model);
4159
+ if (model === undefined) return `\n${label} is not in the model catalog; this session keeps its current model.`;
4160
+ let switched = false;
4161
+ try {
4162
+ switched = await live.setModel(model);
4163
+ } catch (error) {
4164
+ return `\nThis session could not switch to ${label}: ${sanitizeTerminalText(error instanceof Error ? error.message : String(error))}.`;
4165
+ }
4166
+ if (!switched) return `\nno authentication is configured for ${reference.provider}; this session keeps its current model.`;
4167
+ if (entry.thinking === undefined) return `\nThis session now runs on ${label}.`;
4168
+ try {
4169
+ live.setThinkingLevel(entry.thinking);
4170
+ } catch (error) {
4171
+ return `\nThis session now runs on ${label}, but its thinking level could not be set to ${entry.thinking}: ${sanitizeTerminalText(error instanceof Error ? error.message : String(error))}.`;
4172
+ }
4173
+ return `\nThis session now runs on ${label} · ${entry.thinking}.`;
4174
+ }
4175
+
4139
4176
  function profileSnapshotFrom(
4140
4177
  current: AgentModelConfig,
4141
4178
  settings: OrchestratorSettingsReadResult,
@@ -4149,6 +4186,7 @@ function profileSnapshotFrom(
4149
4186
 
4150
4187
  async function runProfilesPanelAction(
4151
4188
  ctx: ExtensionContext,
4189
+ live: LiveSession,
4152
4190
  path: string,
4153
4191
  file: AgentProfilesFile,
4154
4192
  result: Exclude<ProfilesPanelResult, { type: "close" }>,
@@ -4290,6 +4328,10 @@ async function runProfilesPanelAction(
4290
4328
  orchestratorRollback = () => restoreOrchestratorSettings(settingsPath, previous);
4291
4329
  orchestratorNote = `\nOrchestrator set to ${formatOrchestratorSelection(orchestratorEntry)} in ${sanitizeTerminalText(settingsPath)}.`;
4292
4330
  }
4331
+ // settings.json only governs future sessions. The session the user is
4332
+ // sitting in keeps its model until told otherwise, which made a profile
4333
+ // look applied while the orchestrator kept answering with the old one.
4334
+ orchestratorNote += await switchLiveOrchestrator(ctx, live, orchestratorEntry);
4293
4335
  }
4294
4336
  // A pin that does not resolve changes nothing at launch, so the global apply
4295
4337
  // above is what governs this repository. Saying so keeps a broken pin from
@@ -4536,7 +4578,7 @@ async function runProfilesPanelAction(
4536
4578
  }
4537
4579
  }
4538
4580
 
4539
- async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
4581
+ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): Promise<void> {
4540
4582
  const path = profilesFilePath(gentleAiConfigHome());
4541
4583
  const read = readProfilesFileResult(path);
4542
4584
  if (read.status === "invalid") {
@@ -4586,7 +4628,7 @@ async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
4586
4628
  );
4587
4629
  while (result.type !== "close") {
4588
4630
  selectedName = "name" in result ? result.name : undefined;
4589
- file = await runProfilesPanelAction(ctx, path, file, result);
4631
+ file = await runProfilesPanelAction(ctx, live, path, file, result);
4590
4632
  result = await showProfilesPanel(
4591
4633
  ctx,
4592
4634
  file,
@@ -5770,6 +5812,19 @@ function nativeStatusInputRejection(reason: string, field?: string): Record<stri
5770
5812
  };
5771
5813
  }
5772
5814
 
5815
+ function nativeInspectInputRejection(reason: string, field?: string): Record<string, unknown> {
5816
+ return {
5817
+ operation: REVIEW_CONTROLLER_OPERATION.INSPECT,
5818
+ status: "blocked",
5819
+ outcome: "native-inspect-input-invalid",
5820
+ reason,
5821
+ ...(field === undefined ? {} : { field }),
5822
+ ...baseRefRejectionHint(reason),
5823
+ mutation_performed: false,
5824
+ mutation_outcome: "none",
5825
+ };
5826
+ }
5827
+
5773
5828
  const PENDING_REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
5774
5829
  const REVIEW_SESSION_PERMISSION_STATUS_KEY = "gentle-review-session-permission";
5775
5830
  const REVIEW_SESSION_PERMISSION_STATUS_TEXT = "reviews allowed for this session";
@@ -6575,6 +6630,27 @@ function reviewHostRelayFailureReport(error: ReviewHostRelayError): Record<strin
6575
6630
  // refusal reason lives (gentle-pi#524); dropping it hid every admission
6576
6631
  // refusal behind "submission-refused".
6577
6632
  ...(error.stderr.length === 0 ? {} : { stderr: error.stderr }),
6633
+ // gentle-shell#1156: what the reviewer child's own event stream revealed.
6634
+ ...(error.reviewerEvidence === undefined ? {} : { reviewer: error.reviewerEvidence }),
6635
+ };
6636
+ }
6637
+
6638
+ // gentle-pi#311 P2 (superseding gentle-shell#1136 / #1158): the lens's
6639
+ // completion selection comes from its entry in the agent model routing
6640
+ // config, keyed by its routing key (`review-<lens>`). There is no extension
6641
+ // allowlist and no ambient default model: the in-process completion resolves
6642
+ // its provider through the live model registry the caller supplies, or it is
6643
+ // refused before materialize ever runs (validateReviewerSelectionConfiguration
6644
+ // in lib/review-host-relay.ts).
6645
+ function reviewHostRelaySelection(lens: string | undefined, config: AgentModelConfig): { selection?: string; thinking?: string; routingKey: string } {
6646
+ const routingKey = lens === undefined || lens.length === 0 ? "review capture" : lens.startsWith("review-") ? lens : `review-${lens}`;
6647
+ const entry = config[routingKey];
6648
+ const model = typeof entry === "object" && entry !== null && typeof (entry as AgentRoutingEntry).model === "string" && (entry as AgentRoutingEntry).model!.length > 0 ? (entry as AgentRoutingEntry).model : undefined;
6649
+ const thinking = typeof entry === "object" && entry !== null && typeof (entry as AgentRoutingEntry).thinking === "string" ? (entry as AgentRoutingEntry).thinking : undefined;
6650
+ return {
6651
+ ...(model === undefined ? {} : { selection: model }),
6652
+ ...(thinking === undefined ? {} : { thinking }),
6653
+ routingKey,
6578
6654
  };
6579
6655
  }
6580
6656
 
@@ -6677,6 +6753,7 @@ async function executeReviewHostRelayCapture(
6677
6753
  selections: Map<string, RetainedNativeStatusSelection>,
6678
6754
  route: RetainedNativeCaptureRoute | undefined,
6679
6755
  signal?: AbortSignal,
6756
+ modelRegistry?: InProcessReviewerRegistry,
6680
6757
  ): Promise<Record<string, unknown>> {
6681
6758
  try {
6682
6759
  if (slot.submission === undefined) {
@@ -6686,12 +6763,23 @@ async function executeReviewHostRelayCapture(
6686
6763
  REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
6687
6764
  );
6688
6765
  }
6689
- const result = await activeReviewHostRelayRunner({
6690
- captureArgumentTokens: slot.captureArgumentTokens,
6691
- targetCwd: cwd,
6692
- submission: slot.submission,
6693
- ...(signal === undefined ? {} : { signal }),
6694
- });
6766
+ const result = await activeReviewHostRelayRunner((() => {
6767
+ // gentle-pi#311 P2 / P3: the lens's (or, for a v9 host-mediated role
6768
+ // slot, the fixed review-refuter/review-validator routing key's)
6769
+ // user-owned completion selection rides the request alongside the
6770
+ // live model registry; the relay validates and refuses a missing
6771
+ // registry or a routing entry with no configured model typed before
6772
+ // anything launches, never a fallback to a child.
6773
+ const launch = reviewHostRelaySelection(slot.routingKey ?? slot.lens, readModelConfig(cwd));
6774
+ return {
6775
+ captureArgumentTokens: slot.captureArgumentTokens,
6776
+ targetCwd: cwd,
6777
+ submission: slot.submission,
6778
+ ...launch,
6779
+ ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
6780
+ ...(signal === undefined ? {} : { signal }),
6781
+ };
6782
+ })());
6695
6783
  const closure = decodeRelayLastEventClosure(result.submission);
6696
6784
  if (closure !== undefined) return mapAndClearLastEventClosure(closure, binding, selections, cwd);
6697
6785
  return {
@@ -6704,6 +6792,7 @@ async function executeReviewHostRelayCapture(
6704
6792
  ...(slot.lens === undefined ? {} : { lens: slot.lens }),
6705
6793
  ...(slot.order === undefined ? {} : { order: slot.order }),
6706
6794
  ...(slot.subjectHash === undefined ? {} : { subject_hash: slot.subjectHash }),
6795
+ ...(slot.routingKey === undefined ? {} : { role: slot.name }),
6707
6796
  prompt_bytes: result.promptByteLength,
6708
6797
  result_bytes: result.resultByteLength,
6709
6798
  submission: result.submission,
@@ -7285,6 +7374,11 @@ async function executeReviewCaptureOperation(
7285
7374
  candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
7286
7375
  retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
7287
7376
  requireRegisteredRoute = false,
7377
+ // gentle-pi#311 P2: the live model registry a lens materialize capture
7378
+ // resolves its in-process completion through. Appended last (rather than
7379
+ // inserted) so every existing positional call site — none of which pass an
7380
+ // eighth argument — keeps compiling unchanged.
7381
+ modelRegistry?: InProcessReviewerRegistry,
7288
7382
  ): Promise<Record<string, unknown>> {
7289
7383
  const parameters = parseReviewCaptureParameters(parametersValue);
7290
7384
  if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) {
@@ -7344,7 +7438,33 @@ async function executeReviewCaptureOperation(
7344
7438
  mutation_outcome: "none",
7345
7439
  };
7346
7440
  }
7347
- return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal));
7441
+ return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry));
7442
+ }
7443
+
7444
+ // gentle-pi#311 P3: gentle-ai's v9 contract renders the refuter and
7445
+ // targeted-validator role captures host-mediated, exactly like the lens
7446
+ // slot above — same relay machinery, only its fixed review-refuter /
7447
+ // review-validator routing key (carried on the slot) and its own schema
7448
+ // differ. An older gentle-ai's self-contained --execute=true vector still
7449
+ // falls through to reviewProviderRoleVectorSlots below unchanged.
7450
+ const hostMediatedRoleSlots = reviewHostMediatedRoleSlots([selected.input]);
7451
+ if (hostMediatedRoleSlots.length === 1) {
7452
+ if (parameters.correctionLines !== undefined) return captureBindingRejected("correctionLines is valid only for a correction-plan capture");
7453
+ if (parameters.reviewerRunAcknowledged !== true) {
7454
+ return {
7455
+ tool: "gentle_review_capture",
7456
+ status: "blocked",
7457
+ outcome: "reviewer-model-run-forecast",
7458
+ cost_forecast: {
7459
+ transport: "pi_host_relay",
7460
+ model_runs: 1,
7461
+ roles: [hostMediatedRoleSlots[0]!.routingKey],
7462
+ },
7463
+ mutation_performed: false,
7464
+ mutation_outcome: "none",
7465
+ };
7466
+ }
7467
+ return withCorrectionTarget(await executeReviewHostRelayCapture(hostMediatedRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry));
7348
7468
  }
7349
7469
 
7350
7470
  if (selected.input.captureOperation === "review.capture-correction-plan") {
@@ -7423,6 +7543,8 @@ async function executeReviewCaptureGroupOperation(
7423
7543
  candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
7424
7544
  retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
7425
7545
  requireRegisteredRoute = false,
7546
+ // gentle-pi#311 P2: see executeReviewCaptureOperation's matching parameter.
7547
+ modelRegistry?: InProcessReviewerRegistry,
7426
7548
  ): Promise<Record<string, unknown>> {
7427
7549
  const parameters = parseReviewCaptureGroupParameters(parametersValue);
7428
7550
  if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) return { ...captureGroupRejected("native target STATUS is unavailable"), outcome: "native-status-unsupported" };
@@ -7463,6 +7585,8 @@ async function executeReviewCaptureGroupOperation(
7463
7585
  captureArgumentTokens: slot.captureArgumentTokens,
7464
7586
  targetCwd: cwd,
7465
7587
  submission: slot.submission!,
7588
+ ...reviewHostRelaySelection(slot.lens, readModelConfig(cwd)),
7589
+ ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
7466
7590
  ...(signal === undefined ? {} : { signal }),
7467
7591
  }));
7468
7592
  let prepared: readonly ReviewHostRelayPreparedResult[];
@@ -7585,6 +7709,30 @@ async function executeReviewControllerOperation(
7585
7709
  parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT &&
7586
7710
  nativeReviewCli !== null
7587
7711
  ) {
7712
+ const rawInspect = parameters.input === undefined
7713
+ ? undefined
7714
+ : parseControllerJson(parameters.input, REVIEW_CONTROLLER_OPERATION.INSPECT);
7715
+ const unknownField = rawInspect === undefined
7716
+ ? undefined
7717
+ : Object.keys(rawInspect).find((field) => !["baseRef", "committedOnly"].includes(field));
7718
+ if (unknownField !== undefined) return nativeInspectInputRejection("unknown-field", unknownField);
7719
+ const baseRef = rawInspect?.baseRef;
7720
+ if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) return nativeInspectInputRejection("base-ref-invalid");
7721
+ if (baseRef !== undefined && rawInspect?.committedOnly !== true) return nativeInspectInputRejection("committed-only-required");
7722
+ if (rawInspect !== undefined && baseRef === undefined) return nativeInspectInputRejection("committed-only-invalid");
7723
+ let canonicalBaseRef: string | undefined;
7724
+ if (typeof baseRef === "string") {
7725
+ try {
7726
+ canonicalBaseRef = resolveCanonicalCandidateBase(defaultCwd, baseRef).commit;
7727
+ } catch (error) {
7728
+ if (error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true }));
7729
+ if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeInspectInputRejection(error.reason);
7730
+ return nativeInspectInputRejection("base-ref-unresolvable");
7731
+ }
7732
+ }
7733
+ const inspectSelector = canonicalBaseRef === undefined
7734
+ ? {}
7735
+ : { baseRef: canonicalBaseRef, committedOnly: true as const };
7588
7736
  // A new inspect supersedes every pre-lineage selection before its first
7589
7737
  // STATUS attempt. A failed or changed-candidate inspect cannot leave an
7590
7738
  // older selection available for a later START.
@@ -7595,6 +7743,7 @@ async function executeReviewControllerOperation(
7595
7743
  nativeReviewCli,
7596
7744
  {
7597
7745
  cwd: defaultCwd,
7746
+ ...inspectSelector,
7598
7747
  ...(signal === undefined ? {} : { signal }),
7599
7748
  },
7600
7749
  retainedUntrackedSelections,
@@ -7670,6 +7819,7 @@ async function executeReviewControllerOperation(
7670
7819
  nativeReviewCli,
7671
7820
  {
7672
7821
  cwd: defaultCwd,
7822
+ ...inspectSelector,
7673
7823
  untrackedScope: parameters.untrackedScope,
7674
7824
  expectedUntrackedInventory: inventory,
7675
7825
  intendedUntracked: selected.intendedUntracked,
@@ -8583,6 +8733,11 @@ function createGentleAiExtensionForTesting(
8583
8733
  const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
8584
8734
  const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events);
8585
8735
  const permissionEnvironment = dependencies.processEnv ?? process.env;
8736
+ const oddDelegationGate = new OddRuntimeDelegationGate();
8737
+ const oddSessionId = (ctx: ExtensionContext): string => {
8738
+ try { return ctx.sessionManager.getSessionId(); }
8739
+ catch { return ""; }
8740
+ };
8586
8741
 
8587
8742
  const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => {
8588
8743
  try {
@@ -8704,6 +8859,7 @@ function createGentleAiExtensionForTesting(
8704
8859
  candidateViews,
8705
8860
  ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
8706
8861
  true,
8862
+ ctx.modelRegistry,
8707
8863
  );
8708
8864
  return { content: [{ type: "text", text: JSON.stringify(details) }], details };
8709
8865
  },
@@ -8717,7 +8873,7 @@ function createGentleAiExtensionForTesting(
8717
8873
  promptSnippet: "Use one exact current STATUS collectBinding for one ordinary native capture; call fresh STATUS before every additional capture.",
8718
8874
  promptGuidelines: [
8719
8875
  "Pass only lineageId, the JSON-serialized exact collectBinding from current STATUS, and the route-specific optional acknowledgement or correctionLines value. Never compose provider argument tokens, prompts, results, verdicts, or lens arrays.",
8720
- "A materialize reviewer slot first forecasts one model run; re-submit that same exact binding with reviewerRunAcknowledged: true to authorize one host relay. Correction-plan slots require correctionLines inside the provider-issued bounds, counted in diff lines (one replaced source line is one deletion plus one addition) — a different unit from the frozen logical correction budget. Refuter and validation vectors execute exactly once as provider-rendered.",
8876
+ "A materialize reviewer slot first forecasts one model run; re-submit that same exact binding with reviewerRunAcknowledged: true to authorize one host relay. Correction-plan slots require correctionLines inside the provider-issued bounds, counted in diff lines (one replaced source line is one deletion plus one addition) — a different unit from the frozen logical correction budget. A refuter or targeted-validator slot forecasts and runs the same way when the provider renders it host-mediated; an older provider's self-contained refuter/validation vector still executes exactly once as provider-rendered.",
8721
8877
  "A native terminal closure or nonterminal capture returns directly. Do not expect automatic STATUS, FINALIZE, receipt, delivery, or another capture; call fresh STATUS before any next capture.",
8722
8878
  ],
8723
8879
  parameters: REVIEW_CAPTURE_PARAMETERS,
@@ -8742,6 +8898,7 @@ function createGentleAiExtensionForTesting(
8742
8898
  candidateViews,
8743
8899
  ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
8744
8900
  true,
8901
+ ctx.modelRegistry,
8745
8902
  );
8746
8903
  return {
8747
8904
  content: [{ type: "text", text: JSON.stringify(details) }],
@@ -8978,6 +9135,10 @@ function createGentleAiExtensionForTesting(
8978
9135
  const retiredSync = readAgentStartNames(event).includes("sdd-sync") || /\bSDD sync executor\b/i.test(event.systemPrompt ?? "");
8979
9136
  const isSddAgent = retiredSync || isSddAgentStartEvent(event);
8980
9137
  const isNamedAgent = isNamedAgentStartEvent(event);
9138
+ oddDelegationGate.start(
9139
+ oddSessionId(ctx),
9140
+ !isNamedAgent && !isSddAgent && permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1",
9141
+ );
8981
9142
  const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
8982
9143
  if (isSddAgent || isNamedAgent) {
8983
9144
  processAgentEndSubagentDepth.set(subagentDepthKey, (processAgentEndSubagentDepth.get(subagentDepthKey) ?? 0) + 1);
@@ -9087,6 +9248,7 @@ function createGentleAiExtensionForTesting(
9087
9248
  // consent, or chooses a partial candidate. Durable own-mutation receipts
9088
9249
  // gate STATUS and consume only the generation captured before that await.
9089
9250
  pi.on("agent_end", async (_event, ctx) => {
9251
+ oddDelegationGate.endChild(oddSessionId(ctx));
9090
9252
  if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return;
9091
9253
  if (ctx.hasUI !== true || !reminderSessionActive) return;
9092
9254
  const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
@@ -9122,6 +9284,7 @@ function createGentleAiExtensionForTesting(
9122
9284
  pi.on("tool_result", (event, ctx) => {
9123
9285
  if (!reminderSessionActive || event.isError !== false || (event.toolName !== "write" && event.toolName !== "edit")) return;
9124
9286
  if (!isRecord(event.input) || typeof event.input.path !== "string" || !event.input.path.trim()) return;
9287
+ oddDelegationGate.recordSuccess(oddSessionId(ctx), event.toolName, event.input, ctx.cwd);
9125
9288
  try {
9126
9289
  const root = resolveSessionWorktree(event.input.path, ctx.cwd)?.root;
9127
9290
  if (root) recordReviewMutation(pi, ctx.sessionManager, root, { source: "direct", toolName: event.toolName, toolCallId: event.toolCallId });
@@ -9135,6 +9298,10 @@ function createGentleAiExtensionForTesting(
9135
9298
  event.input,
9136
9299
  );
9137
9300
  if (sensitivePathDenied) return sensitivePathDenied;
9301
+ const oddDelegationDenied = oddDelegationGate.beforeTool(
9302
+ oddSessionId(ctx), event.toolName, event.input, ctx.cwd, readActiveToolNames(pi),
9303
+ );
9304
+ if (oddDelegationDenied) return oddDelegationDenied;
9138
9305
  if (event.toolName === "subagent_run") {
9139
9306
  const sddAgent = sddDispatchAgentName(event.input);
9140
9307
  if (sddAgent === "invalid") {
@@ -9300,7 +9467,7 @@ function createGentleAiExtensionForTesting(
9300
9467
  pi.registerCommand("gentle:profiles", {
9301
9468
  description: "Create, switch, and manage global agent-model profiles for el Gentleman.",
9302
9469
  handler: async (_args, ctx) => {
9303
- await handleProfilesCommand(ctx);
9470
+ await handleProfilesCommand(ctx, pi);
9304
9471
  },
9305
9472
  });
9306
9473