gentle-pi 2.3.0 → 2.5.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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -1,5 +1,6 @@
1
+ import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
1
2
  import { execFileSync } from "node:child_process";
2
- import { createHash, randomUUID } from "node:crypto";
3
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
3
4
  import {
4
5
  existsSync,
5
6
  lstatSync,
@@ -29,6 +30,7 @@ import type {
29
30
  ToolCallEventResult,
30
31
  } from "@earendil-works/pi-coding-agent";
31
32
  import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
33
+ import { resolveGentlePiAgentHome } from "../lib/agent-home.ts";
32
34
  import {
33
35
  ensureSddPreflight,
34
36
  getSddPreflightPreferences,
@@ -71,7 +73,12 @@ import {
71
73
  ReviewHostRelayError,
72
74
  reviewHostRelaySlots,
73
75
  reviewProviderRoleVectorSlots,
76
+ resolveReviewHostRelaySubmission,
77
+ runReviewHostRelayReviewerGroup,
74
78
  runReviewHostRelaySlot,
79
+ submitReviewHostRelayPreparedResult,
80
+ type ReviewHostRelayPreparedResult,
81
+ type ReviewHostRelayRequest,
75
82
  type ReviewHostRelayRunner,
76
83
  type ReviewHostRelaySlot,
77
84
  type ReviewProviderRoleVectorSlot,
@@ -101,10 +108,15 @@ import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, r
101
108
  import {
102
109
  GentleAiDevBinaryOverrideError,
103
110
  registerGentleAiDevBinary,
111
+ resolveGentleAiBinary,
104
112
  resolveGentleAiDevBinaryOverride,
105
113
  unregisterGentleAiDevBinary,
106
114
  type GentleAiDevBinaryOverride,
107
115
  } from "../lib/gentle-ai-binary.ts";
116
+ import {
117
+ spawnTelemetryTrigger,
118
+ type TelemetryTriggerSpawn,
119
+ } from "../lib/telemetry-trigger.ts";
108
120
  import {
109
121
  createNativeReviewCli,
110
122
  createNodeExecFileAdapter,
@@ -136,7 +148,21 @@ import {
136
148
  type NativeReviewModeSource,
137
149
  type NativeReviewProcessDiagnostics,
138
150
  type NativeStartResult,
151
+ type NativeReviewAssessRequest,
152
+ type ExecFileAdapter,
153
+ type ExecFileResult,
139
154
  } from "../lib/native-review-cli.ts";
155
+ import {
156
+ verificationPlan,
157
+ resolveWriterProfile,
158
+ RDD_LINE,
159
+ VERIFICATION_TIER,
160
+ NATIVE_REVIEW_OUTCOME,
161
+ type RddLine,
162
+ type VerificationTier,
163
+ type ReviewAssessmentV1,
164
+ type NativeReviewOutcome,
165
+ } from "../lib/review-risk-assessment.ts";
140
166
  import {
141
167
  assertReviewApprovedAcknowledgementExecuteV1,
142
168
  decodeReviewLastEventClosureV1,
@@ -147,14 +173,26 @@ import {
147
173
  type ReviewStatusV3,
148
174
  } from "../lib/review-integration-v2.ts";
149
175
  import { reconcileUnknownReviewLastEventCapture } from "../lib/review-last-event-controller.ts";
150
- import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
176
+ import { acquireChildStandingReviewPermissionClient, type ChildStandingReviewPermissionClient } from "../lib/review-session-standing-permission-ipc.ts";
177
+ import { isPiConsentV3, presentReviewConsentUi } from "../lib/review-consent-ui.ts";
178
+ import {
179
+ captureReviewSessionIdentity,
180
+ grantReviewSessionPermission,
181
+ hasReviewSessionPermission,
182
+ resolveCanonicalGitRepositoryIdentity,
183
+ revokeReviewSessionPermission,
184
+ reviewSessionPermissionEpoch,
185
+ revokeReviewSessionPermissionsForSession,
186
+ sameReviewSessionIdentity,
187
+ type ReviewSessionIdentity,
188
+ } from "../lib/review-session-standing-permission.ts";
151
189
 
152
190
  const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations";
153
191
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
154
192
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
155
193
 
156
194
  function gentlePiAgentHome(): string {
157
- return process.env.GENTLE_PI_AGENT_HOME ?? join(homedir(), ".pi", "agent");
195
+ return resolveGentlePiAgentHome();
158
196
  }
159
197
 
160
198
  function sddGlobalAssetDriftCount(): number {
@@ -461,17 +499,19 @@ const SUBAGENTS_PACKAGE_NAMES = ["pi-subagents-j0k3r", "pi-subagents"] as const;
461
499
  const SUBAGENT_RUN_TOOL = "subagent_run";
462
500
  const BOUNDED_WRITER_AGENT_NAMES = ["gentle-ai-worker", "worker"] as const;
463
501
  const ALLOWED_EDIT_SURFACES_HEADING = /^## Allowed edit surfaces[ \t]*$/gim;
464
- const MARKDOWN_HEADING_LINE = /^#{1,6}\s/;
465
- const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)])\s+/;
502
+ const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6} /;
503
+ const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)]) +/;
466
504
  const WRITER_EDIT_SURFACE_REJECTION =
467
- "Writer tasks must include the exact Markdown heading `## Allowed edit surfaces` with narrow repository-relative paths or narrow globs, one per line. The parent must derive or map that canonical block from the delegated task and relaunch the writer; do not accept aliases, and do not ask the human to author paths or globs.";
505
+ "Writer tasks must include the exact Markdown heading `## Allowed edit surfaces` with narrow repository-relative paths or narrow globs, one per line. Every non-empty line belongs to the section until the next canonical Markdown heading and must be a valid surface entry. Paths containing whitespace require whole-entry backticks; begin explanatory prose under the next Markdown heading. The parent must derive or map that canonical block from the delegated task and relaunch the writer; do not accept aliases, and do not ask the human to author paths or globs.";
468
506
 
469
- function isTaskScopedRepositoryRelativePath(value: string): boolean {
507
+ function isTaskScopedRepositoryRelativePath(value: string, isWholeEntryBackticked: boolean): boolean {
470
508
  const normalized = value.replace(/\\/g, "/");
471
509
  if (
472
510
  normalized.length === 0 ||
473
511
  isAbsolute(value) ||
474
- /^(?:[A-Za-z]:|\/|~)/.test(normalized)
512
+ /^(?:[A-Za-z]:|\/|~)/.test(normalized) ||
513
+ /\p{Cc}|\p{Zl}|\p{Zp}/u.test(normalized) ||
514
+ (/\p{White_Space}/u.test(normalized) && !isWholeEntryBackticked)
475
515
  ) {
476
516
  return false;
477
517
  }
@@ -481,7 +521,6 @@ function isTaskScopedRepositoryRelativePath(value: string): boolean {
481
521
  withoutCurrentDirectory.length === 0 ||
482
522
  withoutCurrentDirectory === "." ||
483
523
  withoutCurrentDirectory.startsWith("/") ||
484
- /\s/.test(withoutCurrentDirectory) ||
485
524
  withoutCurrentDirectory.split("/").some((segment) => segment === "..")
486
525
  ) {
487
526
  return false;
@@ -490,56 +529,37 @@ function isTaskScopedRepositoryRelativePath(value: string): boolean {
490
529
  return !/[?*\[\]{}]/.test(withoutCurrentDirectory.split("/")[0]);
491
530
  }
492
531
 
493
- /** Strips a list marker and surrounding backticks off one surface line. */
494
- function readSurfaceEntry(line: string): string {
495
- const entry = line.replace(MARKDOWN_LIST_MARKER, "");
496
- return entry.match(/^`(.+)`$/)?.[1] ?? entry;
497
- }
532
+ type AllowedEditSurfaceEntry = {
533
+ source: string;
534
+ value: string;
535
+ isWholeEntryBackticked: boolean;
536
+ isValidMarkdownSyntax: boolean;
537
+ };
498
538
 
499
- /** A line still reads as a surface entry when nothing but a bare path is left. */
500
- function looksLikeSurfaceEntry(line: string): boolean {
501
- return line.length > 0 && !MARKDOWN_HEADING_LINE.test(line) && !/\s/.test(readSurfaceEntry(line));
539
+ /** Reads one entry and records whether backticks delimit the whole path. */
540
+ function readSurfaceEntry(line: string): AllowedEditSurfaceEntry {
541
+ const withoutListMarker = line.replace(MARKDOWN_LIST_MARKER, "");
542
+ const backticked = withoutListMarker.match(/^`([^`]+)`$/);
543
+ return {
544
+ source: line,
545
+ value: backticked?.[1] ?? withoutListMarker,
546
+ isWholeEntryBackticked: backticked !== null,
547
+ isValidMarkdownSyntax:
548
+ !/^(?:[-*+]|\d+[.)])$/.test(line) && (!withoutListMarker.includes("`") || backticked !== null),
549
+ };
502
550
  }
503
551
 
504
552
  /**
505
- * Reads the surface list that follows an `## Allowed edit surfaces` heading.
506
- *
507
- * A delegated `task` is a whole prompt: the list is normally followed by deeper
508
- * headings (`### Validation commands`, `#### Return`) and by ordinary prose.
509
- * Ending the section only at `#`/`##` swallowed all of that, turned prose into
510
- * surface entries, and rejected valid authorizations (issue #484). A `context`
511
- * value carrying just the heading and its lines had nothing following it, which
512
- * is why the identical surfaces were accepted there.
513
- *
514
- * So the section ends at the next heading of ANY level, and prose closes the
515
- * list inside it. Blank lines never close it: an entry is only ever dropped
516
- * when nothing below it still reads as a surface entry. A line that a caller
517
- * could pass off as a path stays under validation instead of being discarded,
518
- * because discarding is what would let `/etc/passwd` sit under a blank line or
519
- * a paragraph and reach an accepted dispatch.
553
+ * Reads every non-empty line until the next Markdown heading as an edit surface.
554
+ * A prose line cannot terminate this section: it must fail validation instead.
520
555
  */
521
- function readAllowedEditSurfaceEntries(following: string): string[] {
522
- const lines = following.split(/\r?\n/).map((line) => line.trim());
556
+ function readAllowedEditSurfaceEntries(following: string): AllowedEditSurfaceEntry[] {
557
+ const lines = following.split(/\r?\n/);
523
558
  const headingIndex = lines.findIndex((line) => MARKDOWN_HEADING_LINE.test(line));
524
- const section = headingIndex === -1 ? lines : lines.slice(0, headingIndex);
525
- const entries: string[] = [];
526
-
527
- for (const [index, line] of section.entries()) {
528
- if (line.length === 0) continue;
529
- const entry = readSurfaceEntry(line);
530
- if (/\s/.test(entry)) {
531
- // Prose closes the list only when it is genuinely trailing. Anything
532
- // below it that still reads as a path makes the section ambiguous, so
533
- // every non-empty line is validated and the ambiguity is rejected.
534
- if (section.slice(index + 1).some(looksLikeSurfaceEntry)) {
535
- return section.filter((candidate) => candidate.length > 0).map(readSurfaceEntry);
536
- }
537
- break;
538
- }
539
- entries.push(entry);
540
- }
541
-
542
- return entries;
559
+ return (headingIndex === -1 ? lines : lines.slice(0, headingIndex))
560
+ .map((line) => line.replace(/ +$/g, ""))
561
+ .filter((line) => line.length > 0)
562
+ .map((line) => readSurfaceEntry(line.replace(/^ {0,3}/, "")));
543
563
  }
544
564
 
545
565
  function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean {
@@ -553,9 +573,19 @@ function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean {
553
573
  for (const heading of headings) {
554
574
  const bodyStart = (heading.index ?? 0) + heading[0].length;
555
575
  const entries = readAllowedEditSurfaceEntries(value.slice(bodyStart));
556
- if (entries.length === 0 || !entries.every(isTaskScopedRepositoryRelativePath)) return false;
576
+ if (
577
+ entries.length === 0 ||
578
+ !entries.every(
579
+ (entry) =>
580
+ entry.isValidMarkdownSyntax &&
581
+ !/\p{Cc}|\p{Zl}|\p{Zp}/u.test(entry.source) &&
582
+ isTaskScopedRepositoryRelativePath(entry.value, entry.isWholeEntryBackticked),
583
+ )
584
+ ) {
585
+ return false;
586
+ }
557
587
 
558
- const uniqueEntries = [...new Set(entries)].sort();
588
+ const uniqueEntries = [...new Set(entries.map((entry) => entry.value))].sort();
559
589
  if (
560
590
  expectedEntries &&
561
591
  (expectedEntries.length !== uniqueEntries.length ||
@@ -673,21 +703,288 @@ function renderBackgroundSubagentsStatusLine(
673
703
  return `Background subagent policy: ${background.policy} (capability: ${background.capability})`;
674
704
  }
675
705
 
676
- // Rendered prompts are memoized per background policy/capability key for the
677
- // process lifetime; the assets bytes themselves are read once per key.
706
+ /**
707
+ * A `status` object is only trusted when `effective` is exactly `on`/`off`
708
+ * and `source` is one of the exported `NATIVE_REVIEW_MODE_SOURCE` values.
709
+ * `resolveRddModeStatus` only ever produces a value shaped like this, but
710
+ * `renderRddStatusLine` validates at the render boundary anyway -- a
711
+ * malformed or partial object (a bad decode upstream, a future field
712
+ * change, a hand-built test fixture) must fail closed to the "unknown"
713
+ * line, never render an unrecognized value verbatim.
714
+ */
715
+ function isValidRddModeStatus(
716
+ status: NativeReviewModeStatus | undefined,
717
+ ): status is NativeReviewModeStatus {
718
+ if (status === undefined || status === null || typeof status !== "object") return false;
719
+ if (status.effective !== "on" && status.effective !== "off") return false;
720
+ const validSources: readonly string[] = Object.values(NATIVE_REVIEW_MODE_SOURCE);
721
+ return typeof status.source === "string" && validSources.includes(status.source);
722
+ }
723
+
724
+ /**
725
+ * Renders the receipt-driven-development status line rendered next to
726
+ * `Background subagent policy` (gentle-pi#661). Renders the fail-closed
727
+ * "unknown" line whenever `status` is not a validated on/off status with a
728
+ * recognized source -- `undefined` (the native reader could not answer:
729
+ * binary absent, timed out, aborted, or a native CLI failure) or any
730
+ * malformed/partial object. This is a pure render, never a native call, so
731
+ * it never throws.
732
+ */
733
+ function renderRddStatusLine(
734
+ status: NativeReviewModeStatus | undefined,
735
+ ): string {
736
+ return isValidRddModeStatus(status)
737
+ ? `Receipt-driven development: ${status.effective} (decided by ${status.source})`
738
+ : "Receipt-driven development: unknown (native status unavailable)";
739
+ }
740
+
741
+ // The primary-session prompt awaits this on every non-SDD, non-named agent
742
+ // start, so an unbounded native read would stall session start behind a
743
+ // hung `gentle-ai` child (gentle-pi#661 native-review escalation). The
744
+ // production call site (before_agent_start) passes
745
+ // `AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS)`; resolveRddModeStatus also
746
+ // races the call against that same signal itself (not just the CLI's own
747
+ // signal handling) so an abort is honored even against a stub/mock
748
+ // reviewMode that ignores its `signal` argument, as tests do.
749
+ const RDD_STATUS_TIMEOUT_MS = 3000;
750
+ // Repeated session/agent-start builds within this window reuse the last
751
+ // resolved status instead of respawning the native binary. Deliberately
752
+ // memoizes a failed/undefined resolution too (a sustained outage should not
753
+ // retry every agent start), trading a slower recovery signal for far fewer
754
+ // spawns; the one-shot notify below still surfaces a sustained outage.
755
+ const RDD_STATUS_MEMO_TTL_MS = 30_000;
756
+ const rddStatusMemo = new Map<string, { readonly status: NativeReviewModeStatus | undefined; readonly expiresAt: number }>();
757
+
758
+ /** @internal test seam: clears the per-cwd RDD status memo. */
759
+ function clearRddStatusMemoForTesting(): void {
760
+ rddStatusMemo.clear();
761
+ }
762
+
763
+ // gentle-pi#668 (corrected): last-known outcome for ONE candidate, keyed by
764
+ // repository realpath AND targetIdentity -- never repository alone, or one
765
+ // candidate's outcome would leak into every other candidate's `assess` call.
766
+ // Only declined/unavailable are ever written (from ANSWER_CONSENT); `closed`
767
+ // is never written/derived -- pass it explicitly. A missing entry reads back
768
+ // `undefined`, treated as `unknown` (fail closed, exactly like `off`).
769
+ const nativeReviewOutcomeByCandidate = new Map<string, "declined" | "unavailable">();
770
+
771
+ function nativeReviewOutcomeMemoKey(cwd: string, targetIdentity: string): string {
772
+ try {
773
+ return `${realpathSync(cwd)}\u0000${targetIdentity}`;
774
+ } catch {
775
+ return `${cwd}\u0000${targetIdentity}`;
776
+ }
777
+ }
778
+
779
+ function recordNativeReviewOutcome(cwd: string, targetIdentity: string, outcome: "declined" | "unavailable"): void {
780
+ nativeReviewOutcomeByCandidate.set(nativeReviewOutcomeMemoKey(cwd, targetIdentity), outcome);
781
+ }
782
+
783
+ /** Returns the recorded outcome for exactly this candidate, or `undefined` when none is recorded. */
784
+ function readNativeReviewOutcome(cwd: string, targetIdentity: string): "declined" | "unavailable" | undefined {
785
+ return nativeReviewOutcomeByCandidate.get(nativeReviewOutcomeMemoKey(cwd, targetIdentity));
786
+ }
787
+
788
+ /** @internal test seam: clears the per-candidate native review outcome memo. */
789
+ function clearNativeReviewOutcomeMemoForTesting(): void {
790
+ nativeReviewOutcomeByCandidate.clear();
791
+ }
792
+
793
+ // Best-effort current-candidate target identity for `assess` (gentle-pi#668):
794
+ // reuses `targetStatus`, a native call this tool already makes elsewhere.
795
+ async function readCurrentTargetIdentityBestEffort(
796
+ nativeReviewCli: Pick<NativeReviewCli, "targetStatus"> | null | undefined,
797
+ cwd: string,
798
+ signal?: AbortSignal,
799
+ ): Promise<string | undefined> {
800
+ if (nativeReviewCli?.targetStatus === undefined) return undefined;
801
+ try {
802
+ const status = await nativeReviewCli.targetStatus({ cwd, ...(signal === undefined ? {} : { signal }) });
803
+ return status.applicability === "current_target" ? status.targetIdentity : undefined;
804
+ } catch {
805
+ return undefined;
806
+ }
807
+ }
808
+
809
+ function rddAbortRejection(signal: AbortSignal): Promise<never> {
810
+ return new Promise((_resolve, reject) => {
811
+ if (signal.aborted) {
812
+ reject(signal.reason ?? new Error("aborted"));
813
+ return;
814
+ }
815
+ signal.addEventListener("abort", () => reject(signal.reason ?? new Error("aborted")), { once: true });
816
+ });
817
+ }
818
+
819
+ async function readRddModeStatusOnce(
820
+ nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
821
+ cwd: string,
822
+ signal?: AbortSignal,
823
+ ): Promise<NativeReviewModeStatus | undefined> {
824
+ if (!nativeReviewCli?.reviewMode) return undefined;
825
+ try {
826
+ const call = nativeReviewCli.reviewMode({ cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, signal });
827
+ const result = signal === undefined ? await call : await Promise.race([call, rddAbortRejection(signal)]);
828
+ return result.status;
829
+ } catch {
830
+ return undefined;
831
+ }
832
+ }
833
+
834
+ // gentle-pi#662: read-only combined native risk assessment plus the computed
835
+ // verification plan (`lib/review-risk-assessment.ts`), for the `gentle_review`
836
+ // tool's `assess` operation. Never throws: an unavailable/failed native
837
+ // assess call (older binary without the verb, timeout, malformed response)
838
+ // resolves to the `unassessable` tier, which `verificationPlan` treats the
839
+ // same as `high` -- the fail-closed rule from gentle-pi#662.
840
+ interface ReviewAssessmentPlanDetails {
841
+ schema: "gentle-pi.review-assessment-plan/v1";
842
+ risk: VerificationTier;
843
+ reasons: readonly { code: string; path: string; detail: string }[];
844
+ changedPaths: number;
845
+ changedLines: number;
846
+ candidate: { kind: string; baseRef: string | undefined } | null;
847
+ rddLine: RddLine;
848
+ nativeReviewOutcome: NativeReviewOutcome;
849
+ // gentle-pi#668: where nativeReviewOutcome came from -- explicit (caller
850
+ // passed it), derived (matched this exact candidate), or unknown.
851
+ outcome_source: "explicit" | "derived" | "unknown";
852
+ writerProfile: "small" | "large";
853
+ plan: {
854
+ writerSelfVerification: boolean;
855
+ structuralReadbackOnly: boolean;
856
+ independentVerifier: boolean;
857
+ reason: string;
858
+ };
859
+ }
860
+
861
+ async function resolveReviewAssessmentPlan(
862
+ nativeReviewCli: Pick<NativeReviewCli, "reviewMode" | "assess" | "targetStatus"> | null | undefined,
863
+ cwd: string,
864
+ input: ReviewAssessInput,
865
+ signal?: AbortSignal,
866
+ ): Promise<ReviewAssessmentPlanDetails> {
867
+ if (input.baseRef !== undefined && input.committedOnly !== true) throw new Error("Review assess baseRef requires committedOnly: true");
868
+ if (input.baseRef === undefined && input.committedOnly !== undefined) throw new Error("Review assess committedOnly requires an explicit baseRef");
869
+
870
+ const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
871
+ const rddLine: RddLine = isValidRddModeStatus(status) ? status.effective : RDD_LINE.UNKNOWN;
872
+ const writerProfile = resolveWriterProfile({
873
+ ...(input.writerModelId === undefined ? {} : { model: { id: input.writerModelId } }),
874
+ thinking: input.writerEffort,
875
+ });
876
+
877
+ let assessment: ReviewAssessmentV1 | undefined;
878
+ let unassessableDetail: string | undefined;
879
+ if (nativeReviewCli?.assess === undefined) {
880
+ unassessableDetail = "native review assess is unavailable: the installed gentle-ai binary does not expose the assess command.";
881
+ } else {
882
+ try {
883
+ const request: NativeReviewAssessRequest = {
884
+ cwd,
885
+ ...(input.baseRef === undefined ? {} : { baseRef: input.baseRef, committedOnly: true as const }),
886
+ ...(signal === undefined ? {} : { signal }),
887
+ };
888
+ assessment = await nativeReviewCli.assess(request);
889
+ } catch (error) {
890
+ unassessableDetail = `native review assess failed: ${error instanceof Error ? error.message : String(error)}`;
891
+ }
892
+ }
893
+
894
+ const risk: VerificationTier = assessment?.risk ?? VERIFICATION_TIER.UNASSESSABLE;
895
+ // gentle-pi#668: explicit always wins; otherwise derive only for THIS
896
+ // candidate's own target identity, never repository-only. `closed` is
897
+ // never derived.
898
+ const targetIdentity = input.nativeReviewOutcome === undefined ? await readCurrentTargetIdentityBestEffort(nativeReviewCli, cwd, signal) : undefined;
899
+ const derived = targetIdentity === undefined ? undefined : readNativeReviewOutcome(cwd, targetIdentity);
900
+ const nativeReviewOutcome: NativeReviewOutcome = input.nativeReviewOutcome ?? derived ?? NATIVE_REVIEW_OUTCOME.UNKNOWN;
901
+ const outcomeSource: "explicit" | "derived" | "unknown" = input.nativeReviewOutcome !== undefined ? "explicit" : derived === undefined ? "unknown" : "derived";
902
+ const plan = verificationPlan({ rddLine, risk, writerProfile, nativeReviewOutcome });
903
+ return {
904
+ schema: "gentle-pi.review-assessment-plan/v1",
905
+ risk,
906
+ reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: "native-assess-unavailable", path: "", detail: unassessableDetail }]),
907
+ changedPaths: assessment?.changedPaths ?? 0,
908
+ changedLines: assessment?.changedLines ?? 0,
909
+ candidate: assessment === undefined ? null : { kind: assessment.candidate.kind, baseRef: assessment.candidate.baseRef },
910
+ rddLine,
911
+ nativeReviewOutcome,
912
+ outcome_source: outcomeSource,
913
+ writerProfile,
914
+ plan,
915
+ };
916
+ }
917
+
918
+ let rddStatusUnavailableWarned = false;
919
+
920
+ /**
921
+ * Best-effort native RDD mode status read for prompt rendering, memoized per
922
+ * cwd for `RDD_STATUS_MEMO_TTL_MS`. Reuses the `reviewMode` STATUS reader
923
+ * (`gentle-ai review mode status --json`, decoded to
924
+ * `NativeReviewModeStatus`) that the `/gentle:review-mode` command also
925
+ * calls. Never throws and never hangs past `signal`'s deadline when one is
926
+ * given: an absent binary, a timed-out/aborted process, or a native CLI
927
+ * failure all resolve to `undefined`. `ctx` is optional and used only for a
928
+ * one-shot (per process) UI notice when the read is swallowed, so a
929
+ * sustained native outage is observable beyond the rendered "unknown" line.
930
+ */
931
+ async function resolveRddModeStatus(
932
+ nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
933
+ cwd: string,
934
+ signal?: AbortSignal,
935
+ now: () => number = Date.now,
936
+ ctx?: Pick<ExtensionContext, "hasUI" | "ui">,
937
+ ): Promise<NativeReviewModeStatus | undefined> {
938
+ const nowMs = now();
939
+ const cached = rddStatusMemo.get(cwd);
940
+ if (cached !== undefined && cached.expiresAt > nowMs) return cached.status;
941
+ const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
942
+ rddStatusMemo.set(cwd, { status, expiresAt: nowMs + RDD_STATUS_MEMO_TTL_MS });
943
+ if (status === undefined && !rddStatusUnavailableWarned) {
944
+ rddStatusUnavailableWarned = true;
945
+ if (ctx?.hasUI) {
946
+ ctx.ui.notify(
947
+ "Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt renders \"unknown\" until this recovers; this notice will not repeat this session.",
948
+ "warning",
949
+ );
950
+ }
951
+ }
952
+ return status;
953
+ }
954
+
955
+ /** Resolves and renders the RDD status line for a production call site in one call. */
956
+ async function resolveRddStatusLine(
957
+ nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
958
+ cwd: string,
959
+ signal?: AbortSignal,
960
+ now: () => number = Date.now,
961
+ ctx?: Pick<ExtensionContext, "hasUI" | "ui">,
962
+ ): Promise<string> {
963
+ return renderRddStatusLine(await resolveRddModeStatus(nativeReviewCli, cwd, signal, now, ctx));
964
+ }
965
+
966
+ // Rendered prompts are memoized per background policy/capability/RDD-status
967
+ // key for the process lifetime; the assets bytes themselves are read once
968
+ // per key. `rddStatusLine` defaults to the "unknown" fallback line (the
969
+ // longest of the three renderable forms), not "", so the default no-argument
970
+ // render IS the worst case the canonical 8 KiB budget in
971
+ // tests/orchestrator-budget.test.ts measures; assets/orchestrator.md is
972
+ // sized with that worst case already included. Production still resolves
973
+ // and passes the real line (on/off/unknown) via resolveRddStatusLine.
678
974
  const orchestratorPromptCache = new Map<string, string>();
679
975
  function getOrchestratorPrompt(
680
976
  cwd: string = process.cwd(),
681
977
  activeTools?: readonly string[],
978
+ rddStatusLine: string = renderRddStatusLine(undefined),
682
979
  ): string {
683
980
  const background: BackgroundSubagentsRendering = {
684
981
  policy: loadBackgroundSubagentsPolicy(cwd),
685
982
  capability: resolveBackgroundSubagentsCapability(cwd, activeTools),
686
983
  };
687
- const cacheKey = `${background.policy}:${background.capability}`;
984
+ const cacheKey = `${background.policy}:${background.capability}:${rddStatusLine}`;
688
985
  let prompt = orchestratorPromptCache.get(cacheKey);
689
986
  if (prompt === undefined) {
690
- prompt = renderOrchestratorPrompt(ASSETS_DIR, background);
987
+ prompt = renderOrchestratorPrompt(ASSETS_DIR, background, rddStatusLine);
691
988
  orchestratorPromptCache.set(cacheKey, prompt);
692
989
  }
693
990
  return prompt;
@@ -696,16 +993,76 @@ function getOrchestratorPrompt(
696
993
  function renderOrchestratorPrompt(
697
994
  assetsDir: string,
698
995
  background: BackgroundSubagentsRendering = DEFAULT_BACKGROUND_SUBAGENTS_RENDERING,
996
+ rddStatusLine: string = renderRddStatusLine(undefined),
699
997
  ): string {
998
+ const backgroundPolicyBlock = `${renderBackgroundSubagentsStatusLine(background)}\n${rddStatusLine}`;
700
999
  return readFileSync(join(assetsDir, "orchestrator.md"), "utf8")
701
1000
  .replaceAll("{{GENTLE_PI_ASSETS_ROOT}}", assetsDir)
702
1001
  .replaceAll(
703
1002
  "{{GENTLE_PI_BACKGROUND_POLICY}}",
704
- renderBackgroundSubagentsStatusLine(background),
1003
+ backgroundPolicyBlock,
705
1004
  )
706
1005
  .trim();
707
1006
  }
708
1007
 
1008
+ // gentle-pi#560 / gentle-ai#4056, #4057: Gentle AI stopped writing a
1009
+ // runtime-specific review execution contract into Pi's generated
1010
+ // APPEND_SYSTEM composition on 2026-08-01. This package now injects the
1011
+ // mirrored provider contract bundle's own `orchestration/pi.md` text
1012
+ // instead, read once from the package-local mirror
1013
+ // (contracts/review-provider-contract-mirror/) and cached as the fully
1014
+ // rendered fragment for the process lifetime. It is deliberately NOT folded
1015
+ // into getOrchestratorPrompt/orchestratorPromptCache: that core prompt is
1016
+ // pinned at an 8192-byte budget (tests/orchestrator-budget.test.ts).
1017
+ const PROVIDER_CONTRACT_MIRROR_ROOT = join(PACKAGE_ROOT, "contracts", "review-provider-contract-mirror");
1018
+ const PROVIDER_CONTRACT_LOCK_FILE = "provider-contract.lock.json";
1019
+ const PI_ORCHESTRATION_RUNTIME = "pi";
1020
+
1021
+ let reviewContractPromptFragmentCache: string | null | undefined;
1022
+ let reviewContractPromptMissingWarned = false;
1023
+
1024
+ // Verifies the mirrored orchestration/pi.md bytes against the lock's digest before injection (gentle-ai R1/R3).
1025
+ function readMirroredReviewContractFragment(mirrorRoot: string = PROVIDER_CONTRACT_MIRROR_ROOT): string | null {
1026
+ try {
1027
+ const lockPath = join(mirrorRoot, PROVIDER_CONTRACT_LOCK_FILE);
1028
+ const lock = JSON.parse(readFileSync(lockPath, "utf8")) as {
1029
+ contract_semver?: unknown;
1030
+ entries?: Record<string, unknown>;
1031
+ };
1032
+ if (typeof lock.contract_semver !== "string" || lock.contract_semver === "") return null;
1033
+ const expectedSha256 = lock.entries?.[`orchestration/${PI_ORCHESTRATION_RUNTIME}.md`];
1034
+ if (typeof expectedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(expectedSha256)) return null;
1035
+ const contractPath = join(mirrorRoot, `v${lock.contract_semver}`, "bundle", "orchestration", `${PI_ORCHESTRATION_RUNTIME}.md`);
1036
+ const rawBytes = readFileSync(contractPath);
1037
+ const actualSha256 = createHash("sha256").update(rawBytes).digest("hex");
1038
+ if (!timingSafeEqual(Buffer.from(expectedSha256, "hex"), Buffer.from(actualSha256, "hex"))) return null;
1039
+ const text = rawBytes.toString("utf8").trim();
1040
+ if (text.length === 0) return null;
1041
+ return `## Gentle AI review execution contract (mirrored provider bundle ${lock.contract_semver})\n\n${text}`;
1042
+ } catch {
1043
+ return null;
1044
+ }
1045
+ }
1046
+
1047
+ function loadReviewContractPromptFragment(
1048
+ ctx: Pick<ExtensionContext, "hasUI" | "ui">,
1049
+ mirrorRoot: string = PROVIDER_CONTRACT_MIRROR_ROOT,
1050
+ ): string | null {
1051
+ if (reviewContractPromptFragmentCache === undefined) {
1052
+ reviewContractPromptFragmentCache = readMirroredReviewContractFragment(mirrorRoot);
1053
+ }
1054
+ if (reviewContractPromptFragmentCache === null && !reviewContractPromptMissingWarned) {
1055
+ reviewContractPromptMissingWarned = true;
1056
+ if (ctx.hasUI) {
1057
+ ctx.ui.notify(
1058
+ "Gentle AI review execution contract is unavailable: the mirrored provider bundle is missing, unreadable, or fails digest verification. Review preflight instructions will not be injected this session.",
1059
+ "warning",
1060
+ );
1061
+ }
1062
+ }
1063
+ return reviewContractPromptFragmentCache;
1064
+ }
1065
+
709
1066
  async function pathExists(path: string): Promise<boolean> {
710
1067
  try {
711
1068
  await access(path);
@@ -742,6 +1099,7 @@ function buildGentlePrompt(
742
1099
  persona: PersonaMode,
743
1100
  cwd: string = process.cwd(),
744
1101
  activeTools?: readonly string[],
1102
+ rddStatusLine: string = renderRddStatusLine(undefined),
745
1103
  ): string {
746
1104
  const personaPrompt =
747
1105
  persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT;
@@ -776,7 +1134,7 @@ Harness principles:
776
1134
  - Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
777
1135
  - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
778
1136
 
779
- ${getOrchestratorPrompt(cwd, activeTools)}`;
1137
+ ${getOrchestratorPrompt(cwd, activeTools, rddStatusLine)}`;
780
1138
  }
781
1139
 
782
1140
  // Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
@@ -2589,6 +2947,9 @@ const REVIEW_CONTROLLER_OPERATION = {
2589
2947
  RECONCILE_AUTHORITY: "reconcile-authority",
2590
2948
  REPAIR_LEGACY_ALIAS: "repair-legacy-alias",
2591
2949
  REPAIR: "repair",
2950
+ // gentle-pi#662: read-only native risk assessment (gentle-ai#4295). Never
2951
+ // mutates review authority state and never requires a lineageId.
2952
+ ASSESS: "assess",
2592
2953
  } as const;
2593
2954
 
2594
2955
  type ReviewControllerOperation =
@@ -2635,7 +2996,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
2635
2996
  },
2636
2997
  input: {
2637
2998
  type: "string",
2638
- description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. Legacy controller input remains separate.",
2999
+ description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. ASSESS accepts an optional object with baseRef, committedOnly, writerModelId, writerEffort, and nativeReviewOutcome (gentle-pi#662/#668); omitting writerModelId and writerEffort assesses the ambient working tree and fails closed to a small writer profile (never large) because the writer's actual profile is unknown to this call. nativeReviewOutcome (one of closed, declined, unavailable, unknown) tells ASSESS whether the native review actually closed for this candidate: when Receipt-driven development reads on but the review was declined for this candidate, is unavailable, or its outcome is unknown, ASSESS falls back to the exact risk-gated plan it returns when RDD is off, re-enabling the separate verifier -- a decline is candidate-scoped and never lowers the bar below the RDD-off path. Omitting it lets ASSESS try to derive declined/unavailable from what this process itself recorded for this exact candidate (never a different one, and never from repository state alone), failing closed to unknown when it cannot; `closed` is never derived -- pass it explicitly, and only right after acknowledging the approved review for this same candidate. The returned outcome_source (explicit|derived|unknown) says which of these produced the value. Legacy controller input remains separate.",
2639
3000
  },
2640
3001
  outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
2641
3002
  inputPath: { type: "string", description: "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired." },
@@ -2687,6 +3048,25 @@ interface ReviewCaptureParameters {
2687
3048
  workspaceRoot?: string;
2688
3049
  }
2689
3050
 
3051
+ const REVIEW_CAPTURE_GROUP_PARAMETERS = {
3052
+ type: "object",
3053
+ additionalProperties: false,
3054
+ required: ["lineageId", "collectBindings"],
3055
+ properties: {
3056
+ lineageId: { type: "string", minLength: 1, description: "Exact lineage from the current provider-issued collect transition." },
3057
+ collectBindings: { type: "array", minItems: 1, items: { type: "string", minLength: 1 }, description: "Ordered JSON-serialized exact copies of the complete current materialize reviewer collect set." },
3058
+ reviewerRunAcknowledged: { type: "boolean", description: "Required after the one group forecast; authorizes exactly the forecast reviewer runs." },
3059
+ workspaceRoot: { type: "string", description: "Optional explicit existing Git worktree root, resolved with the controller's worktree confinement semantics." },
3060
+ },
3061
+ } as const;
3062
+
3063
+ interface ReviewCaptureGroupParameters {
3064
+ lineageId: string;
3065
+ collectBindings: readonly string[];
3066
+ reviewerRunAcknowledged?: boolean;
3067
+ workspaceRoot?: string;
3068
+ }
3069
+
2690
3070
  const REVIEW_SCOPE_PARAMETERS = {
2691
3071
  type: "object",
2692
3072
  additionalProperties: false,
@@ -2704,6 +3084,51 @@ interface ReviewScopeParameters {
2704
3084
  cursor?: number;
2705
3085
  }
2706
3086
 
3087
+ // gentle-pi#662: read-only native risk assessment, gating the separate
3088
+ // verifier on native risk instead of a task-description judgment when the
3089
+ // rendered `Receipt-driven development:` line is `off` or `unknown`. Exposed
3090
+ // as `gentle_review` operation `assess` (not a dedicated tool), taking its
3091
+ // optional fields through the controller's existing generic `input` JSON
3092
+ // string, exactly like START's `{"mode":...,"baseRef":...}`.
3093
+ interface ReviewAssessInput {
3094
+ baseRef?: string;
3095
+ committedOnly?: boolean;
3096
+ writerModelId?: string;
3097
+ writerEffort?: string;
3098
+ // gentle-pi#668: the caller's own known outcome for this candidate.
3099
+ // Omitted tries to auto-derive declined/unavailable for this EXACT
3100
+ // candidate's own target identity (never a different one); `closed` is
3101
+ // never auto-derived -- pass it explicitly.
3102
+ nativeReviewOutcome?: NativeReviewOutcome;
3103
+ }
3104
+
3105
+ function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome {
3106
+ return typeof value === "string" && (Object.values(NATIVE_REVIEW_OUTCOME) as readonly string[]).includes(value);
3107
+ }
3108
+
3109
+ function parseReviewAssessInput(operation: ReviewControllerOperation, raw: string | undefined): ReviewAssessInput {
3110
+ if (raw === undefined) return {};
3111
+ const value = parseControllerJson(raw, operation);
3112
+ const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome"]);
3113
+ const unexpected = Object.keys(value).find((key) => !allowed.has(key));
3114
+ if (unexpected !== undefined) throw new Error(`Review controller ${operation} input does not accept ${unexpected}`);
3115
+ const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome } = value;
3116
+ if (baseRef !== undefined && typeof baseRef !== "string") throw new Error(`Review controller ${operation} input baseRef must be a string`);
3117
+ if (committedOnly !== undefined && typeof committedOnly !== "boolean") throw new Error(`Review controller ${operation} input committedOnly must be a boolean`);
3118
+ if (writerModelId !== undefined && typeof writerModelId !== "string") throw new Error(`Review controller ${operation} input writerModelId must be a string`);
3119
+ if (writerEffort !== undefined && typeof writerEffort !== "string") throw new Error(`Review controller ${operation} input writerEffort must be a string`);
3120
+ if (nativeReviewOutcome !== undefined && !isNativeReviewOutcome(nativeReviewOutcome)) {
3121
+ throw new Error(`Review controller ${operation} input nativeReviewOutcome must be one of ${Object.values(NATIVE_REVIEW_OUTCOME).join(", ")}`);
3122
+ }
3123
+ return {
3124
+ ...(baseRef === undefined ? {} : { baseRef: baseRef as string }),
3125
+ ...(committedOnly === undefined ? {} : { committedOnly: committedOnly as boolean }),
3126
+ ...(writerModelId === undefined ? {} : { writerModelId: writerModelId as string }),
3127
+ ...(writerEffort === undefined ? {} : { writerEffort: writerEffort as string }),
3128
+ ...(nativeReviewOutcome === undefined ? {} : { nativeReviewOutcome: nativeReviewOutcome as NativeReviewOutcome }),
3129
+ };
3130
+ }
3131
+
2707
3132
  interface ReviewControllerParameters {
2708
3133
  operation: ReviewControllerOperation;
2709
3134
  lineageId?: string;
@@ -2748,7 +3173,7 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
2748
3173
  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");
2749
3174
  return { operation: value.operation, selectionBinding: value.selectionBinding, intendedUntracked: value.intendedUntracked, ...(typeof value.workspaceRoot === "string" ? { workspaceRoot: value.workspaceRoot } : {}) };
2750
3175
  }
2751
- const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, REVIEW_CONTROLLER_OPERATION.STATUS, REVIEW_CONTROLLER_OPERATION.EXPORT, REVIEW_CONTROLLER_OPERATION.IMPORT, REVIEW_CONTROLLER_OPERATION.INSPECT, REVIEW_CONTROLLER_OPERATION.RESET, REVIEW_CONTROLLER_OPERATION.RECOVER, REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, REVIEW_CONTROLLER_OPERATION.ABANDON, REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY, REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY, REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS, REVIEW_CONTROLLER_OPERATION.REPAIR].includes(value.operation as ReviewControllerOperation);
3176
+ 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);
2752
3177
  if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
2753
3178
  throw new Error("Review controller requires a lineageId");
2754
3179
  }
@@ -2788,6 +3213,23 @@ function parseReviewCaptureParameters(value: unknown): ReviewCaptureParameters {
2788
3213
  };
2789
3214
  }
2790
3215
 
3216
+ function parseReviewCaptureGroupParameters(value: unknown): ReviewCaptureGroupParameters {
3217
+ if (!isRecord(value)) throw new Error("Review capture group parameters must be an object");
3218
+ const allowed = new Set(["lineageId", "collectBindings", "reviewerRunAcknowledged", "workspaceRoot"]);
3219
+ const unexpected = Object.keys(value).find((key) => !allowed.has(key));
3220
+ if (unexpected !== undefined) throw new Error(`Review capture group does not accept ${unexpected}`);
3221
+ if (!isCanonicalProcessString(value.lineageId)) throw new Error("Review capture group requires an exact non-empty lineageId");
3222
+ if (!Array.isArray(value.collectBindings) || value.collectBindings.length === 0 || value.collectBindings.some((binding) => typeof binding !== "string" || binding.length === 0)) throw new Error("Review capture group requires one or more JSON-serialized collectBindings");
3223
+ if (value.reviewerRunAcknowledged !== undefined && typeof value.reviewerRunAcknowledged !== "boolean") throw new Error("Review capture group reviewerRunAcknowledged must be boolean");
3224
+ if (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string") throw new Error("Review capture group workspaceRoot must be a string");
3225
+ return {
3226
+ lineageId: value.lineageId,
3227
+ collectBindings: [...value.collectBindings],
3228
+ ...(value.reviewerRunAcknowledged === undefined ? {} : { reviewerRunAcknowledged: value.reviewerRunAcknowledged }),
3229
+ ...(value.workspaceRoot === undefined ? {} : { workspaceRoot: value.workspaceRoot }),
3230
+ };
3231
+ }
3232
+
2791
3233
  function requiredControllerString(
2792
3234
  parameters: ReviewControllerParameters,
2793
3235
  key: "idempotencyKey" | "transition" | "command" | "input" | "outputPath" | "inputPath" | "operationId",
@@ -3015,6 +3457,15 @@ async function resolveReviewModeGate(
3015
3457
  }
3016
3458
  }
3017
3459
 
3460
+ // gentle-pi#185: a native CLI without negotiated STATUS support (no
3461
+ // `targetStatus`, or a version-incompatible provider) hits this boundary
3462
+ // before any candidate-view restoration is attempted, so it can never
3463
+ // reproduce the #176 empty-registry failure — but the boundary's own
3464
+ // `next_action` was a machine token with nothing a human or an agent could
3465
+ // run. `remediation_command` names the exact upstream command that
3466
+ // re-establishes negotiated STATUS for this session's Pi host identity.
3467
+ const NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND = "gentle-ai review status --cwd <repo> --contract gentle-ai.review-integration/v2 --agent pi --next-transition";
3468
+
3018
3469
  function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<string, unknown> {
3019
3470
  return {
3020
3471
  operation,
@@ -3023,6 +3474,7 @@ function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<s
3023
3474
  ...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { mutation_performed: false }),
3024
3475
  inventory_complete: false,
3025
3476
  next_action: "require-upstream-read-only-native-status-inventory",
3477
+ remediation_command: NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND,
3026
3478
  evidence: {
3027
3479
  native_contract: "gentle-ai/2.1.4",
3028
3480
  general_status: "unsupported",
@@ -3073,6 +3525,23 @@ function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown
3073
3525
  next_action: "require-complete-native-authority-inventory",
3074
3526
  };
3075
3527
  }
3528
+ // gentle-pi#599: a negotiated STATUS/inspect request the native provider
3529
+ // rejects with a decoded failure/v2 envelope (for example a preflight
3530
+ // `invalid_request` refusal for a nested foreign Git repository) used to
3531
+ // fall through to the generic outcome below, discarding the envelope's own
3532
+ // cause, code, retry_safe, and next_action -- the one piece of information
3533
+ // that makes the refusal actionable (pass the intended nested repo as
3534
+ // workspaceRoot). `nativeOperationFailure` already renders this exact
3535
+ // failure-envelope shape faithfully for every mutating operation; reuse it
3536
+ // here instead of masking the refusal as opaque authority-inventory
3537
+ // corruption.
3538
+ if (error instanceof NativeReviewIntegrationError) {
3539
+ return {
3540
+ ...nativeOperationFailure(operation, error),
3541
+ outcome: "native-status-unavailable",
3542
+ inventory_complete: false,
3543
+ };
3544
+ }
3076
3545
  return {
3077
3546
  operation,
3078
3547
  status: "blocked",
@@ -3111,7 +3580,6 @@ function missingNativeMaintenanceInputs(operation: NativeMaintenanceOperation, i
3111
3580
  ...missing,
3112
3581
  ...(Array.isArray(input.capturedLensResults) && input.capturedLensResults.every((entry) => isCanonicalProcessString(entry)) ? [] : ["capturedLensResults"]),
3113
3582
  ...(typeof input.findingsPresent === "boolean" ? [] : ["findingsPresent"]),
3114
- ...(typeof input.evidenceRecordsPresent === "boolean" ? [] : ["evidenceRecordsPresent"]),
3115
3583
  ];
3116
3584
  }
3117
3585
 
@@ -3121,7 +3589,7 @@ function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, in
3121
3589
  }
3122
3590
 
3123
3591
  function nativeMaintenanceAuthorization(operation: NativeMaintenanceOperation, input: Record<string, unknown>): string {
3124
- if (operation === "abandon") return nativeReviewAbandonAuthorization({ lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, evidenceRecordsPresent: input.evidenceRecordsPresent === true, actor: String(input.actor), reason: String(input.reason) });
3592
+ if (operation === "abandon") return nativeReviewAbandonAuthorization({ lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, actor: String(input.actor), reason: String(input.reason) });
3125
3593
  if (operation === "quarantineLegacy") return nativeReviewLegacyQuarantineAuthorization({ repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason) });
3126
3594
  return nativeReviewReconcileAuthorization({ predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }) });
3127
3595
  }
@@ -3148,7 +3616,7 @@ async function executeNativeAuthorityMaintenance(
3148
3616
  }
3149
3617
  try {
3150
3618
  const result = nativeOperation === "abandon"
3151
- ? await nativeReviewCli.abandon!({ cwd, lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, evidenceRecordsPresent: input.evidenceRecordsPresent === true, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
3619
+ ? await nativeReviewCli.abandon!({ cwd, lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
3152
3620
  : nativeOperation === "quarantineLegacy"
3153
3621
  ? await nativeReviewCli.quarantineLegacy!({ cwd, repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
3154
3622
  : await nativeReviewCli.reconcileAuthority!({ cwd, predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) });
@@ -3362,6 +3830,19 @@ function mapNativeTargetStatus(operation: ReviewControllerOperation, status: Rev
3362
3830
  required_status_action: "Use only the provider-selected recovery disposition; do not substitute scope_changed, invalidated, or escalated.",
3363
3831
  };
3364
3832
  }
3833
+ // gentle-pi#627: a stale managed-asset set stops the transition with the
3834
+ // exact `gentle-ai sync` invocation that resolves it. Render that command
3835
+ // as the one actionable next step; every other reason code keeps rendering
3836
+ // as a plain blocked result.
3837
+ if (status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "managed_assets_outdated" && status.nextTransition.continuation !== undefined) {
3838
+ return {
3839
+ operation,
3840
+ status: "blocked",
3841
+ result: status.raw,
3842
+ ...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }),
3843
+ hint: `run ${status.nextTransition.continuation.command}`,
3844
+ };
3845
+ }
3365
3846
  return {
3366
3847
  operation,
3367
3848
  status: status.action === "start" ? "ready" : "blocked",
@@ -3530,6 +4011,8 @@ function nativeStatusInputRejection(reason: string, field?: string): Record<stri
3530
4011
  }
3531
4012
 
3532
4013
  const PENDING_REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
4014
+ const REVIEW_SESSION_PERMISSION_STATUS_KEY = "gentle-review-session-permission";
4015
+ const REVIEW_SESSION_PERMISSION_STATUS_TEXT = "reviews allowed for this session";
3533
4016
 
3534
4017
  type PendingReviewConsentSessionKey = string | symbol;
3535
4018
 
@@ -3564,7 +4047,13 @@ const PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT = 32;
3564
4047
  */
3565
4048
  export class PendingReviewConsentRegistry {
3566
4049
  private readonly sessions = new Map<PendingReviewConsentSessionKey, Map<string, PendingReviewConsent>>();
3567
- private readonly staleDispositions = new Map<PendingReviewConsentSessionKey, Map<string, PendingReviewConsentDisposition>>();
4050
+ // gentle-pi#455: a binding id is globally unique (randomUUID), so its live
4051
+ // owner and its stale disposition are tracked by binding id alone. This
4052
+ // index lets answer-consent resolve and atomically take exactly once a
4053
+ // binding another active Pi session's START created; the per-session map
4054
+ // above stays authoritative for session-scoped listings and shutdown cleanup.
4055
+ private readonly byBinding = new Map<string, PendingReviewConsentSessionKey>();
4056
+ private readonly staleDispositions = new Map<string, PendingReviewConsentDisposition>();
3568
4057
 
3569
4058
  get(sessionKey: PendingReviewConsentSessionKey): Map<string, PendingReviewConsent> | undefined {
3570
4059
  return this.sessions.get(sessionKey);
@@ -3579,34 +4068,46 @@ export class PendingReviewConsentRegistry {
3579
4068
  return pending;
3580
4069
  }
3581
4070
 
4071
+ // Registers a freshly created pending binding under its owning session and
4072
+ // the cross-session binding index in one step so the two never drift.
4073
+ add(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): void {
4074
+ this.ensure(sessionKey).set(pending.id, pending);
4075
+ this.byBinding.set(pending.id, sessionKey);
4076
+ }
4077
+
4078
+ // Resolves a live binding to its owning session regardless of which
4079
+ // session asks, so answer-consent can reach a binding another session's
4080
+ // START created (gentle-pi#455).
4081
+ resolve(bindingId: string): { sessionKey: PendingReviewConsentSessionKey; pending: PendingReviewConsent } | undefined {
4082
+ const sessionKey = this.byBinding.get(bindingId);
4083
+ const pending = sessionKey === undefined ? undefined : this.sessions.get(sessionKey)?.get(bindingId);
4084
+ return pending === undefined ? undefined : { sessionKey, pending };
4085
+ }
4086
+
3582
4087
  private remove(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
3583
4088
  const session = this.sessions.get(sessionKey);
3584
4089
  if (session?.get(pending.id) !== pending) return false;
3585
4090
  session.delete(pending.id);
3586
4091
  if (session.size === 0) this.sessions.delete(sessionKey);
4092
+ if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id);
3587
4093
  return true;
3588
4094
  }
3589
4095
 
3590
- private rememberDisposition(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent, disposition: PendingReviewConsentDisposition): void {
3591
- let stale = this.staleDispositions.get(sessionKey);
3592
- if (stale === undefined) {
3593
- stale = new Map<string, PendingReviewConsentDisposition>();
3594
- this.staleDispositions.set(sessionKey, stale);
3595
- }
3596
- stale.delete(pending.id);
3597
- stale.set(pending.id, disposition);
3598
- while (stale.size > PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT) stale.delete(stale.keys().next().value!);
4096
+ private rememberDisposition(pending: PendingReviewConsent, disposition: PendingReviewConsentDisposition): void {
4097
+ this.staleDispositions.delete(pending.id);
4098
+ this.staleDispositions.set(pending.id, disposition);
4099
+ while (this.staleDispositions.size > PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT) this.staleDispositions.delete(this.staleDispositions.keys().next().value!);
3599
4100
  }
3600
4101
 
3601
4102
  consume(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
3602
4103
  if (!this.remove(sessionKey, pending)) return false;
3603
- this.rememberDisposition(sessionKey, pending, PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED);
4104
+ this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED);
3604
4105
  return true;
3605
4106
  }
3606
4107
 
3607
4108
  expire(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
3608
4109
  if (!this.remove(sessionKey, pending)) return false;
3609
- this.rememberDisposition(sessionKey, pending, PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED);
4110
+ this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED);
3610
4111
  return true;
3611
4112
  }
3612
4113
 
@@ -3614,14 +4115,14 @@ export class PendingReviewConsentRegistry {
3614
4115
  this.remove(sessionKey, pending);
3615
4116
  }
3616
4117
 
3617
- staleDisposition(sessionKey: PendingReviewConsentSessionKey, binding: string): PendingReviewConsentDisposition | undefined {
3618
- return this.staleDispositions.get(sessionKey)?.get(binding);
4118
+ staleDisposition(binding: string): PendingReviewConsentDisposition | undefined {
4119
+ return this.staleDispositions.get(binding);
3619
4120
  }
3620
4121
 
3621
4122
  take(sessionKey: PendingReviewConsentSessionKey): PendingReviewConsent[] {
3622
4123
  const session = this.sessions.get(sessionKey);
3623
4124
  this.sessions.delete(sessionKey);
3624
- this.staleDispositions.delete(sessionKey);
4125
+ if (session !== undefined) for (const pending of session.values()) if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id);
3625
4126
  return session === undefined ? [] : [...session.values()];
3626
4127
  }
3627
4128
  }
@@ -3629,6 +4130,37 @@ export class PendingReviewConsentRegistry {
3629
4130
  const processPendingReviewConsentRegistry = new PendingReviewConsentRegistry();
3630
4131
  const processRetainedNativeStatusSelections = new Map<PendingReviewConsentSessionKey, Map<string, RetainedNativeStatusSelection>>();
3631
4132
 
4133
+ // gentle-pi#556 / gentle-ai#4051: nesting depth of named-agent (SDD phase
4134
+ // executor or other subagent) starts vs. ends for a session. Starts and
4135
+ // ends are paired so a subagent's own loop end never leaves the primary
4136
+ // loop's `agent_end` preflight suppressed for the rest of the session: a
4137
+ // named-agent start increments the depth, a matching end decrements it,
4138
+ // and a fresh primary-loop start resets it to 0.
4139
+ const processAgentEndSubagentDepth = new Map<PendingReviewConsentSessionKey, number>();
4140
+
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
+ // gentle-pi#677: gentle-ai#4309 owns anonymous usage telemetry end to end;
4153
+ // Pi only nudges it once per process. This is a plain process-lifetime
4154
+ // guard, not a session-keyed map, because the nudge is meant to fire at most
4155
+ // once no matter how many primary-session `before_agent_start` events this
4156
+ // process observes.
4157
+ let processTelemetryTriggerAttempted = false;
4158
+
4159
+ /** Testing-only reset for the once-per-process telemetry trigger guard. */
4160
+ function resetTelemetryTriggerGuardForTesting(): void {
4161
+ processTelemetryTriggerAttempted = false;
4162
+ }
4163
+
3632
4164
  function pendingReviewConsentSessionKey(context: ExtensionContext | undefined, fallbackKey: symbol): PendingReviewConsentSessionKey {
3633
4165
  try {
3634
4166
  const sessionManager = (context as unknown as { sessionManager?: { getSessionId?: () => unknown } } | undefined)?.sessionManager;
@@ -3686,6 +4218,61 @@ function reviewConsentDigest(consent: ReviewConsentEnvelope): string {
3686
4218
  return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
3687
4219
  }
3688
4220
 
4221
+ function reviewSessionManagerAndId(context: ExtensionContext): { manager: object; sessionId: string } | undefined {
4222
+ try {
4223
+ const manager = context.sessionManager as unknown as { getSessionId?: () => unknown };
4224
+ const sessionId = manager.getSessionId?.();
4225
+ if (typeof manager !== "object" || manager === null || typeof sessionId !== "string" || sessionId.length === 0) return undefined;
4226
+ return { manager, sessionId };
4227
+ } catch {
4228
+ return undefined;
4229
+ }
4230
+ }
4231
+
4232
+ function isDirectOrdinaryReviewStart(parametersValue: unknown): boolean {
4233
+ try {
4234
+ const parameters = parseReviewControllerParameters(parametersValue);
4235
+ if (parameters.operation !== REVIEW_CONTROLLER_OPERATION.START) return false;
4236
+ const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
4237
+ return input.mode === REVIEW_MODE.ORDINARY;
4238
+ } catch {
4239
+ return false;
4240
+ }
4241
+ }
4242
+
4243
+ function isHostReviewConsentEligibleOperation(parametersValue: unknown): boolean {
4244
+ if (isDirectOrdinaryReviewStart(parametersValue)) return true;
4245
+ try {
4246
+ return parseReviewControllerParameters(parametersValue).operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED;
4247
+ } catch {
4248
+ return false;
4249
+ }
4250
+ }
4251
+
4252
+ function completedGrantedReviewConsent(outcome: Record<string, unknown>): boolean {
4253
+ if (
4254
+ outcome.operation !== REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT ||
4255
+ outcome.status !== undefined ||
4256
+ outcome.outcome !== undefined ||
4257
+ outcome.native_invocation_attempted === false ||
4258
+ outcome.mutation_performed === false ||
4259
+ outcome.lineage_created === false ||
4260
+ !isCanonicalProcessString(outcome.workspace_root)
4261
+ ) return false;
4262
+ const result = outcome.result;
4263
+ if (!isRecord(result)) return false;
4264
+ const nonnegativeInteger = (value: unknown): boolean => Number.isInteger(value) && Number(value) >= 0;
4265
+ return isCanonicalProcessString(result.lineage_id) &&
4266
+ isCanonicalProcessString(result.state) &&
4267
+ isCanonicalProcessString(result.risk_tier) &&
4268
+ Array.isArray(result.selected_lenses) && result.selected_lenses.every(isCanonicalProcessString) &&
4269
+ nonnegativeInteger(result.changed_files) &&
4270
+ nonnegativeInteger(result.original_changed_lines) &&
4271
+ nonnegativeInteger(result.correction_budget) &&
4272
+ (result.action === "created" || result.action === "resumed" || result.action === "replayed" || result.action === "closed") &&
4273
+ typeof result.lenses_required === "boolean";
4274
+ }
4275
+
3689
4276
  // gentle-pi#516: a binding this session does not hold (already answered,
3690
4277
  // expired, or issued by another Pi session or process) used to fall through
3691
4278
  // to the plain negotiated STATUS, which reads exactly like a healthy pre-start
@@ -3731,6 +4318,29 @@ function staleConsentBindingOutcome(operation: ReviewControllerOperation, bindin
3731
4318
  };
3732
4319
  }
3733
4320
 
4321
+ // gentle-pi#455 correction: cross-session resolution looks a binding up by
4322
+ // its opaque id alone, so it must independently confirm the answering
4323
+ // invocation addresses the same repository its owning START minted it for.
4324
+ // This is a typed, non-bearer refusal in the same shape family as the
4325
+ // stale-binding outcome -- it never runs the mode gate or native
4326
+ // answerConsent, and never consumes the binding, so it stays answerable from
4327
+ // the correct repository afterwards.
4328
+ function consentBindingRepositoryMismatchOutcome(operation: ReviewControllerOperation, binding: string, mintingRepositoryCwd: string, answeringRepositoryCwd: string): Record<string, unknown> {
4329
+ return {
4330
+ operation,
4331
+ status: "blocked",
4332
+ outcome: "consent-binding-repository-mismatch",
4333
+ consent_binding: binding,
4334
+ diagnostics: {
4335
+ code: "consent-binding-repository-mismatch",
4336
+ message: `consent binding ${binding} was minted for repository ${mintingRepositoryCwd}, not ${answeringRepositoryCwd}. Answer this binding from a session addressing ${mintingRepositoryCwd}; do not resend this binding from a different repository.`,
4337
+ },
4338
+ native_invocation_attempted: false,
4339
+ ...nativeStartPreAuthorityRejection(),
4340
+ next_action: "answer-from-minting-repository",
4341
+ };
4342
+ }
4343
+
3734
4344
  function assertNativeStartCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void {
3735
4345
  candidateView.verify();
3736
4346
  if (
@@ -3776,7 +4386,7 @@ function completeNativeStart(
3776
4386
  }
3777
4387
 
3778
4388
  function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_review_capture", error: unknown): Record<string, unknown> {
3779
- const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; auditRecord?: unknown; launchAttempted?: unknown; candidateViewPreNative?: unknown; failureEnvelope?: { raw?: unknown; mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown } };
4389
+ const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; auditRecord?: unknown; launchAttempted?: unknown; candidateViewPreNative?: unknown; failureEnvelope?: { raw?: unknown; mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown; code?: unknown; continuation?: { command?: unknown } } };
3780
4390
  if (isRecord(value.failureEnvelope) && isRecord(value.failureEnvelope.raw)) {
3781
4391
  const mutationOutcome = value.failureEnvelope.mutationOutcome;
3782
4392
  return {
@@ -3790,6 +4400,12 @@ function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_r
3790
4400
  : { mutation_performed: false, mutation_outcome: "none" }),
3791
4401
  ...(typeof value.failureEnvelope.replayability === "string" ? { replayability: value.failureEnvelope.replayability } : {}),
3792
4402
  ...(typeof value.failureEnvelope.nextAction === "string" ? { next_action: value.failureEnvelope.nextAction } : {}),
4403
+ // gentle-pi#627: START's preflight failure envelope for a stale
4404
+ // managed-asset set carries a top-level continuation; render its
4405
+ // `gentle-ai sync` command as the one actionable next step.
4406
+ ...(value.failureEnvelope.code === "managed_assets_outdated" && typeof value.failureEnvelope.continuation?.command === "string"
4407
+ ? { hint: `run ${value.failureEnvelope.continuation.command}` }
4408
+ : {}),
3793
4409
  };
3794
4410
  }
3795
4411
  // Every consent binding guard runs before the provider is launched, so this
@@ -4069,9 +4685,15 @@ function requiresExplicitTargetLifecycleRoot(requested: string | undefined, sess
4069
4685
  // The runner is injectable for tests only; production always uses the real
4070
4686
  // relay in lib/review-host-relay.ts.
4071
4687
  let activeReviewHostRelayRunner: ReviewHostRelayRunner = runReviewHostRelaySlot;
4688
+ let activeReviewHostRelayReviewerGroupRunner = runReviewHostRelayReviewerGroup;
4689
+ let activeReviewHostRelaySubmissionRunner = submitReviewHostRelayPreparedResult;
4072
4690
  function setReviewHostRelayRunnerForTesting(runner?: ReviewHostRelayRunner): void {
4073
4691
  activeReviewHostRelayRunner = runner ?? runReviewHostRelaySlot;
4074
4692
  }
4693
+ function setReviewHostRelayGroupRunnersForTesting(reviewerGroup?: typeof runReviewHostRelayReviewerGroup, submission?: typeof submitReviewHostRelayPreparedResult): void {
4694
+ activeReviewHostRelayReviewerGroupRunner = reviewerGroup ?? runReviewHostRelayReviewerGroup;
4695
+ activeReviewHostRelaySubmissionRunner = submission ?? submitReviewHostRelayPreparedResult;
4696
+ }
4075
4697
 
4076
4698
  const REVIEW_HOST_RELAY_RETRY_ACTION =
4077
4699
  "Call fresh STATUS and submit only an exact reoffered one-slot binding; never replay this capture from transcript inference.";
@@ -4166,23 +4788,27 @@ function decodeRelayLastEventClosure(submission: string): ReviewLastEventClosure
4166
4788
  }
4167
4789
 
4168
4790
  async function reconcileUnknownReviewCaptureFailure(
4169
- error: unknown,
4791
+ error: unknown | undefined,
4170
4792
  nativeReviewCli: NativeReviewCli,
4171
4793
  cwd: string,
4172
4794
  binding: ReviewLastEventClosureBinding,
4173
4795
  selections: Map<string, RetainedNativeStatusSelection>,
4174
4796
  route: RetainedNativeCaptureRoute | undefined,
4797
+ expectedReviewCaptureSuffix?: readonly string[],
4798
+ agent?: "pi",
4175
4799
  ): Promise<Record<string, unknown>> {
4176
- const failure = nativeOperationFailure("gentle_review_capture", error);
4177
- if (!nativeMutationRequiresStatus(error)) return failure;
4800
+ const failure = error === undefined ? undefined : nativeOperationFailure("gentle_review_capture", error);
4801
+ if (error !== undefined && !nativeMutationRequiresStatus(error)) return failure;
4178
4802
  try {
4179
- const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, route);
4803
+ const selector = agent === undefined ? route : { ...route, agent };
4804
+ const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, selector);
4180
4805
  syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef);
4806
+ if (expectedReviewCaptureSuffix !== undefined && !hasExactReviewCaptureSuffix(status, expectedReviewCaptureSuffix)) return captureGroupAuthorityDrift(status);
4181
4807
  return {
4182
4808
  tool: "gentle_review_capture",
4183
4809
  status: "reconciled",
4184
4810
  outcome: "native-capture-outcome-unknown",
4185
- native_failure: failure,
4811
+ ...(failure === undefined ? {} : { native_failure: failure }),
4186
4812
  lineage_id: binding.lineageId,
4187
4813
  target_identity: status.targetIdentity,
4188
4814
  provider_action: status.action,
@@ -4190,11 +4816,8 @@ async function reconcileUnknownReviewCaptureFailure(
4190
4816
  result: status.raw,
4191
4817
  };
4192
4818
  } catch (statusError) {
4193
- return {
4194
- ...failure,
4195
- outcome: "native-capture-status-reconciliation-failed",
4196
- reconciliation_failure: nativeOperationFailure("gentle_review_capture", statusError),
4197
- };
4819
+ const reconciliationFailure = nativeOperationFailure("gentle_review_capture", statusError);
4820
+ return { ...(failure ?? reconciliationFailure), outcome: "native-capture-status-reconciliation-failed", reconciliation_failure: reconciliationFailure };
4198
4821
  }
4199
4822
  }
4200
4823
 
@@ -4239,10 +4862,10 @@ async function executeReviewHostRelayCapture(
4239
4862
  },
4240
4863
  };
4241
4864
  } catch (error) {
4242
- if (!(error instanceof ReviewHostRelayError)) return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route);
4865
+ if (!(error instanceof ReviewHostRelayError)) return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT);
4243
4866
  if (error.mutationOutcome === "unknown") {
4244
4867
  return {
4245
- ...(await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route)),
4868
+ ...(await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT)),
4246
4869
  failure: reviewHostRelayFailureReport(error),
4247
4870
  reason: error.message,
4248
4871
  };
@@ -4397,7 +5020,7 @@ function clearReviewTransportProbeForTesting(nativeReviewCli: NativeReviewCli |
4397
5020
  }
4398
5021
 
4399
5022
  function hostTransportUnavailable(
4400
- operation: ReviewControllerOperation | "gentle_review_capture",
5023
+ operation: ReviewControllerOperation | "gentle_review_capture" | "gentle_review_capture_group",
4401
5024
  transport: ReviewTransportRefusal,
4402
5025
  ): Record<string, unknown> {
4403
5026
  // #535: a provider-printed raw `gentle-ai review ...` continuation is a dead
@@ -4406,7 +5029,7 @@ function hostTransportUnavailable(
4406
5029
  // refusal therefore names the continuation that runs in this surface (the
4407
5030
  // gentle_review / gentle_review_capture wrapper tools) while the provider's
4408
5031
  // own diagnostic stays intact in relay_transport as evidence.
4409
- const isCapture = operation === "gentle_review_capture";
5032
+ const isCapture = operation === "gentle_review_capture" || operation === "gentle_review_capture_group";
4410
5033
  return {
4411
5034
  ...(isCapture ? { tool: operation } : { operation }),
4412
5035
  status: "blocked",
@@ -4418,9 +5041,9 @@ function hostTransportUnavailable(
4418
5041
  wrapper_continuation: {
4419
5042
  tool: "gentle_review",
4420
5043
  operation: REVIEW_CONTROLLER_OPERATION.INSPECT,
4421
- ...(isCapture ? { then: "gentle_review_capture" } : {}),
5044
+ ...(isCapture ? { then: operation } : {}),
4422
5045
  },
4423
- next_action: `Install a native gentle-ai provider that supports \`review status --agent pi\`, then re-enter negotiated STATUS with gentle_review {"operation":"inspect"}${isCapture ? " and resubmit gentle_review_capture with the exact one-slot collectBinding that fresh STATUS returns" : " and follow the transition it returns"}. A provider-printed raw CLI continuation does not run in this runtime, and Pi never falls back to an agent-less lifecycle route.`,
5046
+ next_action: `Install a native gentle-ai provider that supports \`review status --agent pi\`, then re-enter negotiated STATUS with gentle_review {"operation":"inspect"}${!isCapture ? " and follow the transition it returns" : operation === "gentle_review_capture_group" ? " and resubmit gentle_review_capture_group with the complete exact ordered collectBindings that fresh STATUS returns" : " and resubmit gentle_review_capture with the exact one-slot collectBinding that fresh STATUS returns"}. A provider-printed raw CLI continuation does not run in this runtime, and Pi never falls back to an agent-less lifecycle route.`,
4424
5047
  };
4425
5048
  }
4426
5049
 
@@ -4453,6 +5076,45 @@ async function negotiatedStatusForHostTransport(
4453
5076
  }
4454
5077
  }
4455
5078
 
5079
+ // gentle-pi#568: resolves the current negotiated review STATUS for a session,
5080
+ // under the exact guards `agent_end` uses to decide whether to nudge: a
5081
+ // native review CLI with both `reviewMode` and `targetStatus`, a UI-bearing
5082
+ // 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.
5086
+ async function resolveNegotiatedReviewStatusForSession(
5087
+ nativeReviewCli: NativeReviewCli | null,
5088
+ ctx: ExtensionContext,
5089
+ sessionKey: PendingReviewConsentSessionKey,
5090
+ ): Promise<ReviewStatusV3 | undefined> {
5091
+ if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return undefined;
5092
+ if (ctx.hasUI !== true) return undefined;
5093
+ let modeEffective: "on" | "off";
5094
+ try {
5095
+ const mode = await nativeReviewCli.reviewMode({ cwd: ctx.cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS });
5096
+ modeEffective = mode.status.effective;
5097
+ } catch {
5098
+ return undefined;
5099
+ }
5100
+ if (modeEffective === "off") return undefined;
5101
+ try {
5102
+ const retainedSelections = ((key: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(key) ?? processRetainedNativeStatusSelections.set(key, new Map()).get(key)!)(sessionKey);
5103
+ const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: ctx.cwd }, retainedSelections, ctx.cwd);
5104
+ return negotiated.status;
5105
+ } catch {
5106
+ return undefined;
5107
+ }
5108
+ }
5109
+
5110
+ // gentle-pi#556 / gentle-ai#4051: the exact once-per-candidate reminder sent
5111
+ // through `agent_end`. It never runs START itself, so it names the one
5112
+ // supported continuation (gentle_review inspect) and defers the resulting
5113
+ // consent envelope to the human.
5114
+ 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.`;
5116
+ }
5117
+
4456
5118
  function canonicalReviewCaptureBinding(value: unknown): string {
4457
5119
  if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") return JSON.stringify(value);
4458
5120
  if (Array.isArray(value)) return `[${value.map((entry) => canonicalReviewCaptureBinding(entry)).join(",")}]`;
@@ -4476,8 +5138,18 @@ function exactCollectArgument(input: ReviewCollectInputV3, name: string): string
4476
5138
  return matches.length === 1 ? matches[0]!.value : undefined;
4477
5139
  }
4478
5140
 
5141
+ // The intended-untracked collect input arrived with status/v6 and every later
5142
+ // status version keeps it; matching one exact version left every workspace
5143
+ // with untracked files unable to start a review once gentle-ai answered v7
5144
+ // (gentle-pi#610, gentle-ai#4187).
5145
+ const INTENDED_UNTRACKED_STATUS_SCHEMA = /^gentle-ai\.review-integration\.status\/v(\d+)$/;
5146
+ function statusCarriesIntendedUntrackedSelection(schema: unknown): boolean {
5147
+ const match = typeof schema === "string" ? INTENDED_UNTRACKED_STATUS_SCHEMA.exec(schema) : null;
5148
+ return match !== null && Number(match[1]) >= 6;
5149
+ }
5150
+
4479
5151
  function reviewIntendedUntrackedInput(status: ReviewStatusV3): ReviewCollectInputV3 | undefined {
4480
- if (status.raw.schema !== "gentle-ai.review-integration.status/v6" || status.nextTransition?.kind !== "collect") return undefined;
5152
+ if (!statusCarriesIntendedUntrackedSelection(status.raw.schema) || status.nextTransition?.kind !== "collect") return undefined;
4481
5153
  const matches = (status.nextTransition.collect?.inputs ?? []).filter((input) => {
4482
5154
  const value = input.submission?.values[0];
4483
5155
  return input.name === "intended_untracked_selection" && input.schema === "gentle-ai.review-intended-untracked-selection/v1" && input.captureOperation === "external.select_intended_untracked" && input.submission?.operationToken === "status" && input.submission.values.length === 1 && value?.slot === "intended_untracked_selection" && value.domain === "schema_bound_json";
@@ -4491,11 +5163,11 @@ function publicReviewCaptureBindings(status: ReviewStatusV3): readonly PublicRev
4491
5163
  return (status.nextTransition.collect?.inputs ?? []).filter((input) => input.captureOperation !== "external.select_intended_untracked").map((input) => ({ collectBinding: canonicalReviewCaptureBinding(input) }));
4492
5164
  }
4493
5165
 
4494
- function captureBindingRejected(reason: string): Record<string, unknown> {
5166
+ function captureBindingRejected(reason: string, group = false): Record<string, unknown> {
4495
5167
  return {
4496
- tool: "gentle_review_capture",
5168
+ tool: group ? "gentle_review_capture_group" : "gentle_review_capture",
4497
5169
  status: "blocked",
4498
- outcome: "capture-binding-rejected",
5170
+ outcome: group ? "capture-group-rejected" : "capture-binding-rejected",
4499
5171
  reason,
4500
5172
  mutation_performed: false,
4501
5173
  mutation_outcome: "none",
@@ -4557,6 +5229,89 @@ function isSelectedReviewCapture(value: SelectedReviewCapture | Record<string, u
4557
5229
  return "input" in value && "binding" in value;
4558
5230
  }
4559
5231
 
5232
+ function captureGroupRejected(reason: string): Record<string, unknown> { return captureBindingRejected(reason, true); }
5233
+
5234
+ function hasExactReviewCaptureSuffix(status: ReviewStatusV3, expected: readonly string[]): boolean {
5235
+ const current = status.nextTransition?.kind === "collect"
5236
+ ? (status.nextTransition.collect?.inputs ?? []).filter((input) => input.captureOperation === "review.capture-result").map(canonicalReviewCaptureBinding)
5237
+ : [];
5238
+ return current.length === expected.length && current.every((binding, index) => binding === expected[index]);
5239
+ }
5240
+
5241
+ function captureGroupAuthorityDrift(status: ReviewStatusV3): Record<string, unknown> {
5242
+ return { ...captureGroupRejected("authoritative STATUS does not offer exactly the unsubmitted reviewer suffix"), outcome: "capture-group-authority-drift", reconciliation: status.raw, authority_applicability: status.applicability, provider_action: status.action, next_transition: status.nextTransition };
5243
+ }
5244
+
5245
+ interface SelectedReviewCaptureGroup {
5246
+ slots: readonly ReviewHostRelaySlot[];
5247
+ binding: ReviewLastEventClosureBinding;
5248
+ }
5249
+
5250
+ function selectExactReviewCaptureGroup(
5251
+ status: ReviewStatusV3,
5252
+ lineageId: string,
5253
+ canonicalBindings: readonly string[],
5254
+ ): SelectedReviewCaptureGroup | Record<string, unknown> {
5255
+ const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : [];
5256
+ const slots = reviewHostRelaySlots(inputs);
5257
+ if (inputs.length === 0 || slots.length !== inputs.length) {
5258
+ return captureGroupRejected("current STATUS does not offer an exclusively materialize reviewer capture group");
5259
+ }
5260
+ const currentBindings = inputs.map((input) => canonicalReviewCaptureBinding(input));
5261
+ if (new Set(currentBindings).size !== currentBindings.length || canonicalBindings.length !== currentBindings.length || canonicalBindings.some((binding, index) => binding !== currentBindings[index])) {
5262
+ return captureGroupRejected("collectBindings must be the complete distinct current reviewer group in exact provider order");
5263
+ }
5264
+ const first = selectExactReviewCapture(status, lineageId, currentBindings[0]!);
5265
+ if (!isSelectedReviewCapture(first)) return captureGroupRejected(String(first.reason ?? "current STATUS rejected a reviewer binding"));
5266
+ const expectedRevision = exactCollectArgument(inputs[0]!, "expected-revision");
5267
+ const repositoryContext = exactCollectArgument(inputs[0]!, "repository-context");
5268
+ const statusTargetIdentity = status.targetIdentity;
5269
+ const currentRepositoryContext = status.repositoryContext;
5270
+ if (!isCanonicalProcessString(expectedRevision) || !isCanonicalProcessString(repositoryContext) || currentRepositoryContext === undefined || expectedRevision !== currentRepositoryContext.revision) {
5271
+ return captureGroupRejected("current STATUS does not bind one matching expected revision and repository context for the reviewer group");
5272
+ }
5273
+ if (currentRepositoryContext.handle !== repositoryContext || currentRepositoryContext.targetIdentity !== statusTargetIdentity) {
5274
+ return captureGroupRejected("current STATUS repository context does not match the reviewer group binding");
5275
+ }
5276
+ const lenses = new Set<string>(), orders = new Set<string>(), subjectHashes = new Set<string>();
5277
+ for (let index = 0; index < inputs.length; index += 1) {
5278
+ const input = inputs[index]!, slot = slots[index]!, subject = input.artifactSubject;
5279
+ const slotLineage = exactCollectArgument(input, "lineage"), target = exactCollectArgument(input, "target");
5280
+ const revision = exactCollectArgument(input, "expected-revision"), context = exactCollectArgument(input, "repository-context");
5281
+ const subjectHash = exactCollectArgument(input, "subject-hash"), order = slot.order, lens = slot.lens;
5282
+ if (
5283
+ subject === undefined || slot.submission === undefined || slotLineage !== lineageId || target !== statusTargetIdentity
5284
+ || revision !== expectedRevision || context !== repositoryContext || subjectHash !== subject.subjectHash || order === undefined || lens === undefined
5285
+ || subject.lineageId !== lineageId || subject.authorityRevision !== expectedRevision || subject.targetIdentity !== statusTargetIdentity
5286
+ || lens !== subject.lens || String(subject.selectedOrder) !== order
5287
+ ) return captureGroupRejected("current STATUS carries an incomplete or mismatched materialize reviewer binding");
5288
+ try { resolveReviewHostRelaySubmission(slot.submission); } catch { return captureGroupRejected("current STATUS carries an invalid provider reviewer submission descriptor"); }
5289
+ const value = slot.submission.values[0];
5290
+ if (slot.submission.operationToken !== "capture-result" || value?.slot !== "reviewer_result" || value.domain !== "artifact_path_or_stdin" || lenses.has(lens) || orders.has(order) || subjectHashes.has(subject.subjectHash)) {
5291
+ return captureGroupRejected("current STATUS carries duplicate or invalid reviewer slot identities");
5292
+ }
5293
+ lenses.add(lens); orders.add(order); subjectHashes.add(subject.subjectHash);
5294
+ }
5295
+ return { slots, binding: first.binding };
5296
+ }
5297
+
5298
+ function reviewHostRelayGroupFailure(
5299
+ error: ReviewHostRelayError,
5300
+ slots: readonly ReviewHostRelaySlot[],
5301
+ prepared: readonly ReviewHostRelayPreparedResult[],
5302
+ submitted: number,
5303
+ ): Record<string, unknown> {
5304
+ return {
5305
+ tool: "gentle_review_capture_group",
5306
+ status: "blocked",
5307
+ outcome: error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE ? "pi-host-relay-unavailable" : error.kind === REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED ? "pi-host-relay-handshake-refused" : error.kind === REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT ? "pi-host-relay-timeout" : "pi-host-relay-transport-failure",
5308
+ reason: error.message,
5309
+ failure: reviewHostRelayFailureReport(error),
5310
+ ...reviewHostRelayGroupProgress(slots, prepared, submitted),
5311
+ next_action: error.kind === REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED ? REVIEW_HOST_RELAY_REFUSED_ACTION : REVIEW_HOST_RELAY_RETRY_ACTION,
5312
+ };
5313
+ }
5314
+
4560
5315
  async function executeReviewCaptureOperation(
4561
5316
  parametersValue: unknown,
4562
5317
  sessionCwd: string,
@@ -4670,6 +5425,118 @@ async function executeReviewCaptureOperation(
4670
5425
  return captureBindingRejected(`unsupported provider capture operation: ${selected.input.captureOperation}`);
4671
5426
  }
4672
5427
 
5428
+ function reviewHostRelayGroupDiagnostics(slots: readonly ReviewHostRelaySlot[], prepared: readonly ReviewHostRelayPreparedResult[], count: number): readonly Record<string, unknown>[] {
5429
+ return slots.slice(0, count).map((slot, index) => ({
5430
+ ...(slot.lens === undefined ? {} : { lens: slot.lens }),
5431
+ ...(slot.order === undefined ? {} : { order: slot.order }),
5432
+ ...(slot.subjectHash === undefined ? {} : { subject_hash: slot.subjectHash }),
5433
+ prompt_bytes: prepared[index]?.promptByteLength,
5434
+ result_bytes: prepared[index]?.resultByteLength,
5435
+ }));
5436
+ }
5437
+
5438
+ function reviewHostRelayGroupProgress(
5439
+ slots: readonly ReviewHostRelaySlot[],
5440
+ prepared: readonly ReviewHostRelayPreparedResult[],
5441
+ submitted: number,
5442
+ uncertain = false,
5443
+ ): Record<string, unknown> {
5444
+ const outcome = submitted === 0 ? uncertain ? "unknown" : "none" : uncertain ? "partial_unknown" : submitted === slots.length ? "completed" : "partial";
5445
+ return {
5446
+ prepared_reviewers: prepared.length,
5447
+ submitted_reviewers: submitted,
5448
+ host_relay: { transport: "pi_host_relay", reviewers: reviewHostRelayGroupDiagnostics(slots, prepared, submitted) },
5449
+ ...(submitted === 0 && uncertain ? { mutation_outcome: outcome } : { mutation_performed: submitted > 0, mutation_outcome: outcome }),
5450
+ };
5451
+ }
5452
+
5453
+ async function executeReviewCaptureGroupOperation(
5454
+ parametersValue: unknown,
5455
+ sessionCwd: string,
5456
+ nativeReviewCli: NativeReviewCli | null,
5457
+ signal?: AbortSignal,
5458
+ candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
5459
+ retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
5460
+ requireRegisteredRoute = false,
5461
+ ): Promise<Record<string, unknown>> {
5462
+ const parameters = parseReviewCaptureGroupParameters(parametersValue);
5463
+ if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) return { ...captureGroupRejected("native target STATUS is unavailable"), outcome: "native-status-unsupported" };
5464
+ const canonicalBindings = parameters.collectBindings.map((binding) => parseCanonicalReviewCaptureBinding(binding));
5465
+ const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
5466
+ const routes = canonicalBindings.map((binding) => readRetainedNativeCaptureRoute(retainedUntrackedSelections, binding));
5467
+ const route = routes[0];
5468
+ if (requireRegisteredRoute && (route === undefined || routes.some((candidate) => candidate === undefined || candidate.workspaceRoot !== cwd || candidate.lineageId !== parameters.lineageId || candidate.baseRef !== route.baseRef))) {
5469
+ return captureGroupRejected("collectBindings are unknown, expired, or belong to different session routes");
5470
+ }
5471
+ const freshStatus = () => negotiatedStatusForHostTransport(nativeReviewCli, {
5472
+ cwd, lineageId: parameters.lineageId,
5473
+ ...(route?.baseRef === undefined ? {} : { baseRef: route.baseRef, committedOnly: true }),
5474
+ ...readRetainedNativeUntrackedSelection(retainedUntrackedSelections, cwd, parameters.lineageId),
5475
+ ...(signal === undefined ? {} : { signal }),
5476
+ }, retainedUntrackedSelections, cwd);
5477
+ let status: ReviewStatusV3;
5478
+ try {
5479
+ const negotiated = await freshStatus();
5480
+ if (negotiated.transport !== undefined) return hostTransportUnavailable("gentle_review_capture_group", negotiated.transport);
5481
+ status = negotiated.status!;
5482
+ } catch (error) {
5483
+ return { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "native-status-failed" };
5484
+ }
5485
+ const group = selectExactReviewCaptureGroup(status, parameters.lineageId, canonicalBindings);
5486
+ if (!("slots" in group && "binding" in group)) return group;
5487
+ if (parameters.reviewerRunAcknowledged !== true) {
5488
+ return {
5489
+ tool: "gentle_review_capture_group",
5490
+ status: "blocked",
5491
+ outcome: "reviewer-model-run-forecast",
5492
+ cost_forecast: { transport: "pi_host_relay", model_runs: group.slots.length, lenses: group.slots.map((slot) => slot.lens).filter((lens): lens is string => lens !== undefined) },
5493
+ mutation_performed: false,
5494
+ mutation_outcome: "none",
5495
+ };
5496
+ }
5497
+ const requests: readonly ReviewHostRelayRequest[] = group.slots.map((slot) => ({
5498
+ captureArgumentTokens: slot.captureArgumentTokens,
5499
+ targetCwd: cwd,
5500
+ submission: slot.submission!,
5501
+ ...(signal === undefined ? {} : { signal }),
5502
+ }));
5503
+ let prepared: readonly ReviewHostRelayPreparedResult[];
5504
+ try {
5505
+ prepared = await activeReviewHostRelayReviewerGroupRunner(requests);
5506
+ if (prepared.length !== requests.length) throw new Error("Pi host relay reviewer group returned a different number of prepared results");
5507
+ } catch (error) {
5508
+ return error instanceof ReviewHostRelayError
5509
+ ? reviewHostRelayGroupFailure(error, group.slots, [], 0)
5510
+ : { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "pi-host-relay-reviewer-group-failed" };
5511
+ }
5512
+ for (let index = 0; index < prepared.length; index += 1) {
5513
+ let current: SelectedReviewCapture | Record<string, unknown>;
5514
+ try {
5515
+ const negotiated = await freshStatus();
5516
+ if (negotiated.transport !== undefined) return { ...hostTransportUnavailable("gentle_review_capture_group", negotiated.transport), ...reviewHostRelayGroupProgress(group.slots, prepared, index) };
5517
+ if (!hasExactReviewCaptureSuffix(negotiated.status!, canonicalBindings.slice(index))) return { ...captureGroupAuthorityDrift(negotiated.status!), ...reviewHostRelayGroupProgress(group.slots, prepared, index) };
5518
+ current = selectExactReviewCapture(negotiated.status!, parameters.lineageId, canonicalBindings[index]!);
5519
+ } catch (error) {
5520
+ return { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "native-status-failed", ...reviewHostRelayGroupProgress(group.slots, prepared, index) };
5521
+ }
5522
+ if (!isSelectedReviewCapture(current)) return { ...captureGroupRejected(String(current.reason ?? "current STATUS rejected a reviewer binding")), ...reviewHostRelayGroupProgress(group.slots, prepared, index) };
5523
+ try {
5524
+ const result = await activeReviewHostRelaySubmissionRunner(prepared[index]!);
5525
+ const closure = decodeRelayLastEventClosure(result.submission);
5526
+ if (closure !== undefined) {
5527
+ const closed = mapAndClearLastEventClosure(closure, current.binding, retainedUntrackedSelections, cwd);
5528
+ return { ...closed, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index + 1) };
5529
+ }
5530
+ } catch (error) {
5531
+ if (error instanceof ReviewHostRelayError && error.mutationOutcome !== "unknown") return reviewHostRelayGroupFailure(error, group.slots, prepared, index);
5532
+ const reconciled = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, current.binding, retainedUntrackedSelections, route, undefined, REVIEW_HOST_AGENT);
5533
+ return { ...reconciled, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index, true), ...(error instanceof ReviewHostRelayError ? { failure: reviewHostRelayFailureReport(error), reason: error.message } : {}) };
5534
+ }
5535
+ }
5536
+ const reconciled = await reconcileUnknownReviewCaptureFailure(undefined, nativeReviewCli, cwd, group.binding, retainedUntrackedSelections, route, canonicalBindings.slice(prepared.length), REVIEW_HOST_AGENT);
5537
+ return { ...reconciled, tool: "gentle_review_capture_group", outcome: reconciled.outcome === "capture-group-authority-drift" ? reconciled.outcome : reconciled.status === "reconciled" ? "native-reviewer-group-status-reconciled" : "native-reviewer-group-status-reconciliation-failed", ...reviewHostRelayGroupProgress(group.slots, prepared, prepared.length) };
5538
+ }
5539
+
4673
5540
  type DispatchHydrationOutcome =
4674
5541
  | { hydrated: true; lineage_id: string; lenses: readonly string[] }
4675
5542
  | { hydrated: false; lineage_id: string; reason: string; message: string }
@@ -4707,7 +5574,6 @@ async function executeReviewControllerOperation(
4707
5574
  retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
4708
5575
  pendingReviewConsentRegistry: PendingReviewConsentRegistry = processPendingReviewConsentRegistry,
4709
5576
  pendingReviewConsentFallbackKey: symbol = Symbol("pending-review-consent-fallback"),
4710
- writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
4711
5577
  reviewConsentNow: () => number = Date.now,
4712
5578
  reviewConsentScheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void } = setTimeout,
4713
5579
  intendedUntrackedSelection?: NativeIntendedUntrackedSelectionSubmission,
@@ -4732,6 +5598,15 @@ async function executeReviewControllerOperation(
4732
5598
  next_action: "Use the native `gentle-ai review` CLI (start/finalize/validate/status/recover) against the repository review authority; receipts and canonical artifacts live in the Git common-directory store at .git/gentle-ai/reviews and travel with the repository through normal Git replication.",
4733
5599
  };
4734
5600
  }
5601
+ if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ASSESS) {
5602
+ // Read-only native risk assessment (gentle-ai#4295, gentle-pi#662). Never
5603
+ // mutates, never requires a lineageId, and never routes through
5604
+ // authorizeDestructiveReviewOperation (it returns early for any
5605
+ // operation that is neither RESET nor a maintenance operation).
5606
+ const input = parseReviewAssessInput(parameters.operation, parameters.input);
5607
+ const details = await resolveReviewAssessmentPlan(nativeReviewCli, defaultCwd, input, signal);
5608
+ return { operation: parameters.operation, ...details, ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
5609
+ }
4735
5610
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS) {
4736
5611
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
4737
5612
  return await executeNativeLegacyAliasRepair(input, defaultCwd, nativeReviewCli, signal, context);
@@ -4974,6 +5849,9 @@ async function executeReviewControllerOperation(
4974
5849
  // The registry owns restoring writability of its 0555 views before
4975
5850
  // removal; a terminal approved cleanup keeps the lineage projection.
4976
5851
  candidateViews?.cleanupTerminal(parameters.lineageId, "approved", defaultCwd);
5852
+ // gentle-pi#668: `closed` is never auto-derived or recorded here --
5853
+ // a parent that wants the on-path passes nativeReviewOutcome:
5854
+ // "closed" explicitly on its next assess call for this candidate.
4977
5855
  return {
4978
5856
  operation: parameters.operation,
4979
5857
  status: "closed",
@@ -4997,13 +5875,18 @@ async function executeReviewControllerOperation(
4997
5875
  if (Object.keys(input).some((key) => key !== "consentBinding" && key !== "answer") || Object.keys(input).length !== 2) throw new Error("Review controller answer-consent input must contain exactly consentBinding and answer");
4998
5876
  if (typeof input.consentBinding !== "string" || input.consentBinding.length === 0) throw new Error("Review controller answer-consent requires an opaque consentBinding");
4999
5877
  if (input.answer !== "granted" && input.answer !== "declined") throw new Error("Review controller answer-consent answer must be granted or declined");
5000
- const pending = pendingReviewConsentRegistry.get(pendingReviewConsentSession)?.get(input.consentBinding);
5878
+ // gentle-pi#455: resolve the binding by its opaque id alone, so a
5879
+ // binding one active Pi session's START created is answerable from any
5880
+ // active session presenting it -- not only the session that created it.
5881
+ const resolved = pendingReviewConsentRegistry.resolve(input.consentBinding);
5882
+ const pending = resolved?.pending;
5883
+ const owningSession = resolved?.sessionKey ?? pendingReviewConsentSession;
5001
5884
  if (pending === undefined || pending.expiresAt <= reviewConsentNow()) {
5002
5885
  const disposition = pending === undefined
5003
- ? pendingReviewConsentRegistry.staleDisposition(pendingReviewConsentSession, input.consentBinding)
5886
+ ? pendingReviewConsentRegistry.staleDisposition(input.consentBinding)
5004
5887
  : PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED;
5005
5888
  const stale = staleConsentBindingDiagnostics(input.consentBinding, disposition);
5006
- if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession);
5889
+ if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
5007
5890
  if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
5008
5891
  try {
5009
5892
  const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
@@ -5016,14 +5899,15 @@ async function executeReviewControllerOperation(
5016
5899
  return nativeStatusFailed(parameters.operation, error);
5017
5900
  }
5018
5901
  }
5019
- if (realpathSync(defaultCwd) !== pending.repositoryCwd) throw new Error("Review controller consent repository binding changed");
5902
+ const answeringRepositoryCwd = realpathSync(defaultCwd);
5903
+ if (answeringRepositoryCwd !== pending.repositoryCwd) return consentBindingRepositoryMismatchOutcome(parameters.operation, input.consentBinding, pending.repositoryCwd, answeringRepositoryCwd);
5020
5904
  if (reviewConsentDigest(pending.consent) !== pending.consentDigest) throw new Error("Review controller consent envelope binding changed");
5021
5905
  pending.verifyCandidate();
5022
5906
  if (nativeReviewCli?.answerConsent === undefined) throw new Error("Native review consent follow-up is unavailable");
5023
- if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession)) {
5907
+ if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession)) {
5024
5908
  const stale = staleConsentBindingDiagnostics(
5025
5909
  input.consentBinding,
5026
- pendingReviewConsentRegistry.staleDisposition(pendingReviewConsentSession, input.consentBinding),
5910
+ pendingReviewConsentRegistry.staleDisposition(input.consentBinding),
5027
5911
  );
5028
5912
  if (nativeReviewCli.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
5029
5913
  try {
@@ -5040,11 +5924,14 @@ async function executeReviewControllerOperation(
5040
5924
  try {
5041
5925
  const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
5042
5926
  if (gated !== undefined) {
5043
- cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession);
5927
+ // gentle-pi#668: mode disabled for this exact candidate -- keyed by
5928
+ // its targetIdentity, never by repository alone.
5929
+ recordNativeReviewOutcome(pending.authorityCwd, pending.consent.targetIdentity, NATIVE_REVIEW_OUTCOME.UNAVAILABLE);
5930
+ cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
5044
5931
  return gated;
5045
5932
  }
5046
5933
  } catch (error) {
5047
- cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession);
5934
+ cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
5048
5935
  return nativeOperationFailure(parameters.operation, error);
5049
5936
  }
5050
5937
  // The one-shot binding is consumed before the first answer-path await. Any
@@ -5058,6 +5945,9 @@ async function executeReviewControllerOperation(
5058
5945
  ...(signal === undefined ? {} : { signal }),
5059
5946
  });
5060
5947
  if (answered.kind === "declined") {
5948
+ // gentle-pi#668: candidate-scoped decline, keyed by this exact
5949
+ // candidate's targetIdentity, never by repository alone.
5950
+ recordNativeReviewOutcome(pending.authorityCwd, pending.consent.targetIdentity, NATIVE_REVIEW_OUTCOME.DECLINED);
5061
5951
  pending.cleanupCandidate();
5062
5952
  return {
5063
5953
  operation: parameters.operation,
@@ -5078,15 +5968,6 @@ async function executeReviewControllerOperation(
5078
5968
  projection: "workspace",
5079
5969
  }, retainedUntrackedSelections);
5080
5970
  }
5081
- if (input.answer === "granted") {
5082
- try {
5083
- writeReviewConsentLatch(pending.repositoryCwd);
5084
- } catch (error) {
5085
- try {
5086
- context?.ui.notify(`Native review start completed, but Pi could not record the local consent latch: ${error instanceof Error ? error.message : String(error)}`, "warning");
5087
- } catch { /* Reporting is best effort; native completion remains authoritative. */ }
5088
- }
5089
- }
5090
5971
  return completed;
5091
5972
  }
5092
5973
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED) {
@@ -5106,7 +5987,7 @@ async function executeReviewControllerOperation(
5106
5987
  const rejected = input === undefined || canonicalReviewCaptureBinding(input) !== canonicalBinding || exactCollectArgument(input, "target_identity") !== status.targetIdentity || exactCollectArgument(input, "projection") !== status.projection.projection || exactCollectArgument(input, "base_tree") !== status.projection.baseTree || exactCollectArgument(input, "candidate_tree") !== status.projection.currentCandidateTree || !Array.isArray(eligible) || selected.reason !== undefined || selected.intendedUntracked!.some((path) => !eligible.includes(path));
5107
5988
  if (rejected) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" };
5108
5989
  const submission = { argumentTokens: input.submission!.argumentTokens, value: JSON.stringify({ schema: "gentle-ai.review-intended-untracked-selection/v1", untracked_scope: scope, expected_untracked_inventory: inventory, intended_untracked: selected.intendedUntracked }) };
5109
- const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, writeReviewConsentLatch, reviewConsentNow, reviewConsentScheduleTimer, submission);
5990
+ const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, reviewConsentNow, reviewConsentScheduleTimer, submission);
5110
5991
  return { ...result, operation: parameters.operation };
5111
5992
  }
5112
5993
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
@@ -5164,7 +6045,16 @@ async function executeReviewControllerOperation(
5164
6045
  } catch (error) {
5165
6046
  return nativeOperationFailure(parameters.operation, error);
5166
6047
  }
5167
- const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null });
6048
+ // gentle-pi#323: the replay key must fold in the current candidate
6049
+ // content identity. Without it, a second START with identical
6050
+ // {cwd, lineageId, input, inputPath} reuses a still-live (never
6051
+ // lineage-bound) frozen candidate view from within the consent TTL
6052
+ // window even after the live candidate content changed underneath
6053
+ // it, and dead-ends at candidate-target-projection-drift with no
6054
+ // recovery. Folding in currentCandidateTree makes a content change
6055
+ // mint a fresh replay key -- and therefore a fresh candidate view --
6056
+ // instead of reusing the stale one.
6057
+ const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null, candidateTree: target.projection.currentCandidateTree });
5168
6058
  // Synchronously drop any binding whose TTL has already elapsed
5169
6059
  // before reusing its retained candidate view, so a fresh-candidate
5170
6060
  // retry cannot reuse a view tied to an expired binding and trip
@@ -5227,14 +6117,14 @@ async function executeReviewControllerOperation(
5227
6117
  cleanupCandidate: () => {
5228
6118
  if (candidateCleaned) return;
5229
6119
  candidateCleaned = true;
5230
- consentCandidateView.cleanup();
6120
+ try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent expiry/teardown still completes. */ }
5231
6121
  },
5232
6122
  ...(retainedUntrackedSelection === undefined ? {} : { untrackedSelection: retainedUntrackedSelection }),
5233
6123
  consent: error.consent,
5234
6124
  consentDigest,
5235
6125
  expiresAt: reviewConsentNow() + PENDING_REVIEW_CONSENT_TTL_MS,
5236
6126
  };
5237
- pendingReviewConsentRegistry.ensure(pendingReviewConsentSession).set(id, pending);
6127
+ pendingReviewConsentRegistry.add(pendingReviewConsentSession, pending);
5238
6128
  pending.expiry = reviewConsentScheduleTimer(
5239
6129
  () => expirePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession),
5240
6130
  PENDING_REVIEW_CONSENT_TTL_MS,
@@ -5375,12 +6265,16 @@ async function executeReviewControllerOperation(
5375
6265
  const effectiveUntrackedSelection = rawStatus === undefined && parameters.lineageId !== undefined
5376
6266
  ? readRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId)
5377
6267
  : untrackedSelection;
6268
+ const retainedCommittedTarget = rawStatus === undefined && parameters.lineageId !== undefined && candidateViews?.hasProjection(parameters.lineageId, defaultCwd)
6269
+ ? candidateViews.resolveProjection(parameters.lineageId, defaultCwd)
6270
+ : undefined;
6271
+ const effectiveBaseRef = baseRef ?? (retainedCommittedTarget?.committedOnly === true ? retainedCommittedTarget.baseCommit : undefined);
5378
6272
  if (nativeReviewCli?.targetStatus !== undefined) {
5379
6273
  try {
5380
6274
  const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
5381
6275
  cwd: defaultCwd,
5382
6276
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5383
- ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }),
6277
+ ...(effectiveBaseRef === undefined ? {} : { baseRef: effectiveBaseRef, committedOnly: true }),
5384
6278
  ...(effectiveUntrackedSelection.untrackedScope === undefined ? {} : effectiveUntrackedSelection),
5385
6279
  ...(signal === undefined ? {} : { signal }),
5386
6280
  }, retainedUntrackedSelections, defaultCwd);
@@ -5422,11 +6316,15 @@ export const __testing = {
5422
6316
  nativeStatusUnsupported,
5423
6317
  executeReviewControllerOperation,
5424
6318
  executeReviewCaptureOperation,
6319
+ executeReviewCaptureGroupOperation,
5425
6320
  setReviewHostRelayRunnerForTesting,
6321
+ setReviewHostRelayGroupRunnersForTesting,
5426
6322
  clearReviewTransportProbeForTesting,
5427
6323
  renderSddModelPanel: renderSddModelPanelForTesting,
5428
6324
  getOrchestratorPrompt,
5429
6325
  renderOrchestratorPrompt,
6326
+ loadReviewContractPromptFragment,
6327
+ readMirroredReviewContractFragment,
5430
6328
  loadBackgroundSubagentsPolicy,
5431
6329
  resolveBackgroundSubagentsPolicy,
5432
6330
  renderBackgroundSubagentsReport,
@@ -5435,8 +6333,19 @@ export const __testing = {
5435
6333
  resolveBackgroundSubagentsCapability,
5436
6334
  readActiveToolNames,
5437
6335
  renderBackgroundSubagentsStatusLine,
6336
+ renderRddStatusLine,
6337
+ isValidRddModeStatus,
6338
+ resolveRddModeStatus,
6339
+ resolveRddStatusLine,
6340
+ RDD_STATUS_TIMEOUT_MS,
6341
+ RDD_STATUS_MEMO_TTL_MS,
6342
+ clearRddStatusMemoForTesting,
6343
+ readNativeReviewOutcome,
6344
+ recordNativeReviewOutcome,
6345
+ clearNativeReviewOutcomeMemoForTesting,
5438
6346
  resolveControllerSddStatus,
5439
6347
  resolveStartupControllerSddStatus,
6348
+ resetTelemetryTriggerGuardForTesting,
5440
6349
  createGentleAiExtension: createGentleAiExtensionForTesting,
5441
6350
  };
5442
6351
 
@@ -5470,6 +6379,22 @@ export interface GentleAiRuntimeDependencies {
5470
6379
  // sleep and without relying on the queued cleanup macrotask firing.
5471
6380
  now?: () => number;
5472
6381
  scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
6382
+ // The environment the session's child processes inherit; tests inject a
6383
+ // plain object so the handshake declaration is observable without
6384
+ // touching the test runner's own process.env.
6385
+ processEnv?: NodeJS.ProcessEnv;
6386
+ // Package-owned children use this parent-bound channel only to ask whether
6387
+ // their own pending ordinary START may replay a grant locally.
6388
+ childStandingReviewPermissionClient?: Pick<ChildStandingReviewPermissionClient, "requestAuthorization" | "close">;
6389
+ // gentle-pi#677: test-only seams for the telemetry trigger. Production
6390
+ // leaves both undefined: the real package-local resolveGentleAiBinary()
6391
+ // and the real detached child_process spawn run.
6392
+ resolveTelemetryTriggerBinary?: () => string;
6393
+ telemetryTriggerSpawn?: TelemetryTriggerSpawn;
6394
+ // Test-only seam for the foreground `/gentle:telemetry` slash command's
6395
+ // bounded exec; production leaves this undefined and uses the real node
6396
+ // exec-file adapter shared with the rest of the extension.
6397
+ telemetryExecFileAdapter?: ExecFileAdapter;
5473
6398
  }
5474
6399
 
5475
6400
  export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void {
@@ -5478,21 +6403,67 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
5478
6403
 
5479
6404
  function createGentleAiExtensionForTesting(
5480
6405
  dependencies: GentleAiRuntimeDependencies = {},
5481
- writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
5482
6406
  ): (pi: ExtensionAPI) => void {
5483
6407
  const nativeReviewCli = dependencies.nativeReviewCli === undefined ? createNativeReviewCli() : dependencies.nativeReviewCli;
6408
+ const childStandingReviewPermissionLease = dependencies.childStandingReviewPermissionClient === undefined
6409
+ ? acquireChildStandingReviewPermissionClient(dependencies.processEnv ?? process.env)
6410
+ : undefined;
6411
+ const childStandingReviewPermission = dependencies.childStandingReviewPermissionClient ?? childStandingReviewPermissionLease?.client;
5484
6412
  const reviewConsentNow = dependencies.now ?? (() => Date.now());
5485
6413
  const reviewConsentScheduleTimer = dependencies.scheduleTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
5486
6414
  const pendingReviewConsentRegistry = dependencies.pendingReviewConsentRegistry ?? processPendingReviewConsentRegistry;
6415
+ const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary;
6416
+ const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter();
5487
6417
  return function gentleAi(pi: ExtensionAPI): void {
6418
+ declareReviewRelayHandshake(dependencies.processEnv ?? process.env);
5488
6419
  const pendingReviewConsentFallbackKey = Symbol("pending-review-consent-fallback");
5489
6420
  const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
5490
6421
  const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events);
6422
+ const permissionEnvironment = dependencies.processEnv ?? process.env;
5491
6423
 
5492
- pi.on("session_shutdown", (_event, context) => {
6424
+ const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => {
6425
+ try {
6426
+ (context.ui as unknown as { setStatus?: (key: string, text?: string) => void }).setStatus?.(
6427
+ REVIEW_SESSION_PERMISSION_STATUS_KEY,
6428
+ active ? REVIEW_SESSION_PERMISSION_STATUS_TEXT : undefined,
6429
+ );
6430
+ } catch { /* Status is nonblocking and never permission authority. */ }
6431
+ };
6432
+ const capturePermissionIdentity = (context: ExtensionContext, cwd: string = context.cwd): Promise<ReviewSessionIdentity | undefined> =>
6433
+ captureReviewSessionIdentity({ ...context, cwd }, permissionEnvironment);
6434
+ const refreshReviewSessionPermissionStatus = async (context: ExtensionContext): Promise<ReviewSessionIdentity | undefined> => {
6435
+ const identity = await capturePermissionIdentity(context);
6436
+ setReviewSessionPermissionStatus(context, identity !== undefined && hasReviewSessionPermission(identity));
6437
+ return identity;
6438
+ };
6439
+ const revokeCurrentReviewSessionPermission = (context: ExtensionContext): boolean => {
6440
+ const coordinates = reviewSessionManagerAndId(context);
6441
+ const revoked = coordinates === undefined ? false : revokeReviewSessionPermissionsForSession(coordinates.manager, coordinates.sessionId);
6442
+ setReviewSessionPermissionStatus(context, false);
6443
+ return revoked;
6444
+ };
6445
+ const revokeCurrentRepositoryReviewSessionPermission = async (context: ExtensionContext): Promise<boolean> => {
6446
+ const identity = await capturePermissionIdentity(context);
6447
+ const revoked = identity === undefined ? false : revokeReviewSessionPermission(identity);
6448
+ setReviewSessionPermissionStatus(context, false);
6449
+ return revoked;
6450
+ };
6451
+
6452
+ pi.on("session_shutdown", (event, context) => {
6453
+ // Pi tears down this registry on reload as well as session replacement/quit.
6454
+ try { candidateViews?.cleanupAll(); } catch { /* Preserve failed owned views for later recovery. */ }
6455
+ const reason = (event as { reason?: unknown }).reason;
6456
+ if (reason !== "reload") {
6457
+ if (childStandingReviewPermissionLease !== undefined) childStandingReviewPermissionLease.closeIfCurrent();
6458
+ else childStandingReviewPermission?.close();
6459
+ revokeCurrentReviewSessionPermission(context);
6460
+ }
5493
6461
  const sessionKey = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey);
5494
6462
  cleanupAllPendingReviewConsents(pendingReviewConsentRegistry, sessionKey);
5495
6463
  processRetainedNativeStatusSelections.delete(sessionKey);
6464
+ processAgentEndSubagentDepth.delete(sessionKey);
6465
+ processAgentEndPreflightNudgedTargets.delete(sessionKey);
6466
+ processAgentEndSessionBaseline.delete(sessionKey);
5496
6467
  });
5497
6468
 
5498
6469
  pi.registerTool({
@@ -5508,8 +6479,8 @@ function createGentleAiExtensionForTesting(
5508
6479
  context as GentleAiRenderContext | undefined,
5509
6480
  );
5510
6481
  },
5511
- renderResult(result, options) {
5512
- return renderGentleAiResult(result, options);
6482
+ renderResult(result, options, theme, context) {
6483
+ return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
5513
6484
  },
5514
6485
  async execute(_toolCallId, parameters) {
5515
6486
  const input = parameters as ReviewScopeParameters;
@@ -5518,6 +6489,59 @@ function createGentleAiExtensionForTesting(
5518
6489
  },
5519
6490
  });
5520
6491
 
6492
+ // The lens a reviewer capture runs is inside its collect binding, so the
6493
+ // card can say "review capture · risk" instead of a bare operation name.
6494
+ const lensLabel = (lens: unknown): string | undefined =>
6495
+ typeof lens === "string" && lens.length > 0 ? lens.replace(/^review-/, "") : undefined;
6496
+ const collectBindingLens = (binding: unknown): string | undefined => {
6497
+ if (typeof binding !== "string") return undefined;
6498
+ try {
6499
+ const parsed = JSON.parse(binding) as Record<string, unknown>;
6500
+ const subject = (parsed.artifactSubject ?? parsed.artifact_subject) as Record<string, unknown> | undefined;
6501
+ return lensLabel(subject?.lens);
6502
+ } catch {
6503
+ return undefined;
6504
+ }
6505
+ };
6506
+ const withLenses = (operation: string, lenses: readonly (string | undefined)[]): string => {
6507
+ const named = lenses.filter((lens): lens is string => lens !== undefined);
6508
+ return named.length === 0 ? operation : `${operation} · ${named.join(" · ")}`;
6509
+ };
6510
+
6511
+ pi.registerTool({
6512
+ name: "gentle_review_capture_group",
6513
+ label: "Gentle Review Capture Group",
6514
+ 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
+ promptSnippet: "Use one complete exact current STATUS materialize reviewer group; acknowledge its forecast before the grouped run.",
6516
+ promptGuidelines: [
6517
+ "Pass only lineageId, the complete ordered collectBindings array from one current STATUS result, and reviewerRunAcknowledged after its forecast. Never mix, reorder, duplicate, or partially select bindings.",
6518
+ "The group materializes and runs independent reviewers concurrently, but rechecks STATUS before every provider-ordered submission. It stops on a closure, correction, drift, or uncertain capture outcome; it never follows another transition or replays a prepared output.",
6519
+ ],
6520
+ parameters: REVIEW_CAPTURE_GROUP_PARAMETERS,
6521
+ executionMode: "sequential",
6522
+ renderCall(args, theme, context) {
6523
+ const bindings = (args as { collectBindings?: unknown }).collectBindings;
6524
+ const lenses = Array.isArray(bindings) ? bindings.map(collectBindingLens) : [];
6525
+ return renderGentleAiLifecycleCall(withLenses("review capture group", lenses), theme, context as GentleAiRenderContext | undefined);
6526
+ },
6527
+ renderResult(result, options, theme, context) {
6528
+ return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
6529
+ },
6530
+ async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
6531
+ if (signal?.aborted) throw new Error("Review capture group was cancelled");
6532
+ const details = await executeReviewCaptureGroupOperation(
6533
+ parameters,
6534
+ ctx.cwd,
6535
+ nativeReviewCli,
6536
+ signal,
6537
+ candidateViews,
6538
+ ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
6539
+ true,
6540
+ );
6541
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
6542
+ },
6543
+ });
6544
+
5521
6545
  pi.registerTool({
5522
6546
  name: "gentle_review_capture",
5523
6547
  label: "Gentle Review Capture",
@@ -5530,15 +6554,15 @@ function createGentleAiExtensionForTesting(
5530
6554
  ],
5531
6555
  parameters: REVIEW_CAPTURE_PARAMETERS,
5532
6556
  executionMode: "sequential",
5533
- renderCall(_args, theme, context) {
6557
+ renderCall(args, theme, context) {
5534
6558
  return renderGentleAiLifecycleCall(
5535
- "review capture",
6559
+ withLenses("review capture", [collectBindingLens((args as { collectBinding?: unknown }).collectBinding)]),
5536
6560
  theme,
5537
6561
  context as GentleAiRenderContext | undefined,
5538
6562
  );
5539
6563
  },
5540
- renderResult(result, options) {
5541
- return renderGentleAiResult(result, options);
6564
+ renderResult(result, options, theme, context) {
6565
+ return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
5542
6566
  },
5543
6567
  async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
5544
6568
  if (signal?.aborted) throw new Error("Review capture was cancelled");
@@ -5567,11 +6591,12 @@ function createGentleAiExtensionForTesting(
5567
6591
  promptGuidelines: [
5568
6592
  '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).',
5569
6593
  "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.",
5570
- "Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, capturedLensResults, findingsPresent, evidenceRecordsPresent, actor, and reason; QUARANTINE_LEGACY accepts only the published malformed freeze-findings diagnostic/disposition. A dual reconciliation may supply only anomalies `unchanged_target,malformed_recovery_authorization` in that exact order. Use REPAIR_LEGACY_ALIAS only with lineage, actor, and reason: Pi freshly reads native inventory and derives repository, revision, diagnostic, disposition, and the exact eight-line binding before interactive approval. `review dispose-result` is unsupported pending design.",
6594
+ "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.",
5571
6595
  "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.",
5572
6596
  "For blocked-legacy or blocked-mixed, do not call START repeatedly. Explain invalidation, request explicit user authorization, then call RESET or RECOVER only after authorization. RESET and RECOVER_LOCK route to audited native `gentle-ai review reclaim`; only RESET carries the legacy repositoryId, commonDirHash, inventoryHash, and confirmation challenge. RECOVER routes to native `gentle-ai review recover` with exactly six inputs: predecessorLineage, expectedPredecessorRevision, successorLineage, disposition, actor, and reason. Never send RECOVER the reset challenge and never send it a maintainerAuthorization: Pi reads fresh native target status, pins the predecessor lineage, revision, provider-selected disposition, and target identity, derives the exact six-line native authorization binding, displays it for fresh UI approval, and re-reads status before mutating. Negotiated target status supplies the sole accepted recovery disposition, and a caller-supplied substitute is rejected. Treat a native-input-required envelope as a request for exact values, never as permission to invent them. After a committed native recovery record, INSPECT before any fresh ordinary START.",
5573
- "A consent-required START returns the complete provider envelope and an opaque consent_binding, then stops. The parent presents and localizes that envelope without changing machine tokens, commands, target IDs, or invocations. After one explicit human answer, call answer-consent exactly once with a JSON string containing only consentBinding and answer (`granted` or `declined`). A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START output, the controller calls target-scoped native status once and returns only its declared action. An ambiguous gentle_review_capture outcome independently reconciles once and never replays the capture.",
6597
+ "A consent-required START may be resolved inside the eligible interactive Pi host. Its third UI action is host-owned: it runs this envelope's exact provider grant once and allows later fresh validated envelopes only for the same live SessionManager, nonempty session ID, and canonical Git common-directory identity, including sibling worktrees; an unrelated repository requires a new explicit human grant. Revoke removes the current repository grant, while nonreload replacement, quit, and process exit remove all session grants; reload preserves them. It grants no provider mode, verdict, acknowledgement, maintenance, delivery, or cross-repository authority. A package-owned child may ask its parent only with the canonical digest of its exact pending target; the parent binds that digest to the task repository and fails closed otherwise. If the tool returns an unresolved envelope, present the original two provider choices without changing machine tokens, commands, target IDs, or invocations; never add the host action to the decoded provider envelope. After one explicit relayed human answer, call answer-consent exactly once with only consentBinding and answer (`granted` or `declined`). Never create host permission from tool arguments, model prose, child/headless responses, or an uncertain native result. A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START output, the controller calls target-scoped native status once and returns only its declared action. An ambiguous gentle_review_capture outcome independently reconciles once and never replays the capture.",
5574
6598
  "Use gentle_review only for native review authority operations; delivery commands follow ordinary repository policy.",
6599
+ 'ASSESS (gentle-pi#662/#668) is read-only and needs no lineageId: after a delegated writer returns, call {"operation":"assess"} over its diff and follow the returned plan (writerSelfVerification, structuralReadbackOnly, independentVerifier, reason) instead of judging non-triviality from the task description. Pass input as JSON only to assess a committed range ({"baseRef":"<ref>","committedOnly":true}), to record the writer profile ({"writerModelId":"...", "writerEffort":"..."}), or to state the native review\'s outcome for this candidate ({"nativeReviewOutcome":"closed|declined|unavailable|unknown"}). Omitting writerModelId and writerEffort is treated as a small writer profile (fail closed), never large, because the writer\'s actual profile is then unknown to this call; pass the writer\'s real model id/effort to get credit for a known large profile. The on-path (writer self-verification is the record, no separate verifier) holds only when nativeReviewOutcome is "closed" for this candidate; a decline, an unavailable review, or an omitted/unknown outcome falls back to the exact risk-gated plan RDD off would return, re-enabling the separate verifier -- a decline is candidate-scoped and never lowers the bar below RDD off. "closed" is never inferred: pass it only right after this same caller acknowledged the approved review for this same candidate; omitting nativeReviewOutcome only ever auto-derives declined/unavailable, bound to that exact candidate\'s own target identity, never to a different candidate or to bare repository state. The result\'s outcome_source (explicit|derived|unknown) states which. A failed or unavailable native assessment reports risk "unassessable", verified exactly like "high". This never mutates review authority state.',
5575
6600
  ],
5576
6601
  parameters: REVIEW_CONTROLLER_PARAMETERS,
5577
6602
  executionMode: "sequential",
@@ -5582,26 +6607,99 @@ function createGentleAiExtensionForTesting(
5582
6607
  context as GentleAiRenderContext | undefined,
5583
6608
  );
5584
6609
  },
5585
- renderResult(result, options) {
5586
- return renderGentleAiResult(result, options);
6610
+ renderResult(result, options, theme, context) {
6611
+ return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
5587
6612
  },
5588
6613
  async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
5589
6614
  if (signal?.aborted) throw new Error("Review controller operation was cancelled");
5590
6615
  await authorizeDestructiveReviewOperation(parameters, ctx);
5591
- const details = await executeReviewControllerOperation(
6616
+ const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
6617
+ const retainedSelections = processRetainedNativeStatusSelections.get(sessionKey)
6618
+ ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!;
6619
+ let details = await executeReviewControllerOperation(
5592
6620
  parameters,
5593
6621
  ctx.cwd,
5594
6622
  nativeReviewCli,
5595
6623
  signal,
5596
6624
  candidateViews,
5597
6625
  ctx,
5598
- ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
6626
+ retainedSelections,
5599
6627
  pendingReviewConsentRegistry,
5600
6628
  pendingReviewConsentFallbackKey,
5601
- writeReviewConsentLatch,
5602
6629
  reviewConsentNow,
5603
6630
  reviewConsentScheduleTimer,
5604
6631
  );
6632
+ if (
6633
+ isHostReviewConsentEligibleOperation(parameters) &&
6634
+ details.outcome === "native-review-consent-required" &&
6635
+ typeof details.consent_binding === "string"
6636
+ ) {
6637
+ const resolved = pendingReviewConsentRegistry.resolve(details.consent_binding);
6638
+ const pending = resolved?.pending;
6639
+ const eligiblePending = pending !== undefined && isPiConsentV3(pending.consent)
6640
+ ? pending
6641
+ : undefined;
6642
+ const answerPendingConsent = async (answer: "granted" | "declined") => executeReviewControllerOperation(
6643
+ {
6644
+ operation: REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT,
6645
+ input: JSON.stringify({ consentBinding: eligiblePending!.id, answer }),
6646
+ workspaceRoot: eligiblePending!.authorityCwd,
6647
+ },
6648
+ ctx.cwd,
6649
+ nativeReviewCli,
6650
+ signal,
6651
+ candidateViews,
6652
+ ctx,
6653
+ retainedSelections,
6654
+ pendingReviewConsentRegistry,
6655
+ pendingReviewConsentFallbackKey,
6656
+ reviewConsentNow,
6657
+ reviewConsentScheduleTimer,
6658
+ );
6659
+ let permissionWorkspaceRoot: string | undefined;
6660
+ try {
6661
+ const parsed = parseReviewControllerParameters(parameters);
6662
+ permissionWorkspaceRoot = resolveReviewControllerWorkspaceRoot(parsed.workspaceRoot, ctx.cwd, candidateViews, parsed.lineageId);
6663
+ } catch {
6664
+ // The successful native operation above remains authoritative; an
6665
+ // unresolvable local binding simply cannot consume host permission.
6666
+ }
6667
+ const initialIdentity = permissionWorkspaceRoot === undefined
6668
+ ? undefined
6669
+ : await capturePermissionIdentity(ctx, permissionWorkspaceRoot);
6670
+ if (eligiblePending !== undefined && initialIdentity === undefined) {
6671
+ // A package child has no local standing grant. It may ask its
6672
+ // inherited parent only for the canonical repository identity of
6673
+ // this exact pending target, then replay this local binding once.
6674
+ const repositoryIdentity = permissionWorkspaceRoot === undefined
6675
+ ? undefined
6676
+ : await resolveCanonicalGitRepositoryIdentity(permissionWorkspaceRoot);
6677
+ if (repositoryIdentity !== undefined && await childStandingReviewPermission?.requestAuthorization(repositoryIdentity) === true) details = await answerPendingConsent("granted");
6678
+ } else if (eligiblePending !== undefined && initialIdentity !== undefined) {
6679
+ const initialEpoch = reviewSessionPermissionEpoch(initialIdentity);
6680
+ const permissionAlreadyActive = hasReviewSessionPermission(initialIdentity);
6681
+ const selection = initialEpoch === undefined
6682
+ ? undefined
6683
+ : permissionAlreadyActive
6684
+ ? { kind: "host-session" as const }
6685
+ : await presentReviewConsentUi(ctx, eligiblePending.consent);
6686
+ if (selection !== undefined) {
6687
+ const confirmedIdentity = await capturePermissionIdentity(ctx, permissionWorkspaceRoot);
6688
+ if (initialEpoch !== undefined && confirmedIdentity !== undefined && sameReviewSessionIdentity(initialIdentity, confirmedIdentity) && reviewSessionPermissionEpoch(confirmedIdentity) === initialEpoch) {
6689
+ const answer = selection.kind === "provider" ? selection.answer : "granted";
6690
+ details = await answerPendingConsent(answer);
6691
+ if (!permissionAlreadyActive && selection.kind === "host-session" && completedGrantedReviewConsent(details)) {
6692
+ if (grantReviewSessionPermission(confirmedIdentity, initialEpoch)) {
6693
+ setReviewSessionPermissionStatus(ctx, true);
6694
+ try { ctx.ui.notify("Reviews are allowed for this Pi session and this Git repository.", "info"); } catch { /* Nonblocking indication only. */ }
6695
+ } else {
6696
+ try { ctx.ui.notify("This review started, but the in-memory session permission registry was incompatible, so later candidates will ask again.", "warning"); } catch { /* Best effort. */ }
6697
+ }
6698
+ }
6699
+ }
6700
+ }
6701
+ }
6702
+ }
5605
6703
  return {
5606
6704
  content: [{ type: "text", text: JSON.stringify(details) }],
5607
6705
  details,
@@ -5613,7 +6711,11 @@ function createGentleAiExtensionForTesting(
5613
6711
  return ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installSddAssets(cwd, false), applyModelConfig: async () => applySavedModelConfig(ctx) }, { promptFields });
5614
6712
  }
5615
6713
 
5616
- pi.on("session_start", async (_event, ctx) => {
6714
+ pi.on("session_start", async (event, ctx) => {
6715
+ try { candidateViews?.sweepOrphans(ctx.cwd); } catch { /* Ownership sweeping must not block startup. */ }
6716
+ const reason = (event as { reason?: unknown }).reason;
6717
+ if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx);
6718
+ await refreshReviewSessionPermissionStatus(ctx);
5617
6719
  // Loud, every session: an active dev-binary override means this session
5618
6720
  // runs an unpinned gentle-ai. Announce which one before anything else.
5619
6721
  try {
@@ -5650,6 +6752,19 @@ function createGentleAiExtensionForTesting(
5650
6752
  );
5651
6753
  }
5652
6754
  }
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.
6759
+ try {
6760
+ 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
+ }
6765
+ } catch {
6766
+ // Baseline recording is best-effort only; never surface or throw.
6767
+ }
5653
6768
  });
5654
6769
 
5655
6770
  pi.on("input", async (event, ctx) => {
@@ -5663,6 +6778,32 @@ function createGentleAiExtensionForTesting(
5663
6778
  pi.on("before_agent_start", async (event, ctx) => {
5664
6779
  const isSddAgent = isSddAgentStartEvent(event);
5665
6780
  const isNamedAgent = isNamedAgentStartEvent(event);
6781
+ const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
6782
+ if (isSddAgent || isNamedAgent) {
6783
+ processAgentEndSubagentDepth.set(subagentDepthKey, (processAgentEndSubagentDepth.get(subagentDepthKey) ?? 0) + 1);
6784
+ } else {
6785
+ processAgentEndSubagentDepth.set(subagentDepthKey, 0);
6786
+ }
6787
+ // gentle-pi#677: nudge gentle-ai's own telemetry trigger for a primary
6788
+ // session only, reusing the exact isNamedAgent/isSddAgent predicate that
6789
+ // decides the orchestrator prompt below. At most one attempt per
6790
+ // process regardless of how many primary-session before_agent_start
6791
+ // events this process observes; a missing/old binary or a spawn error
6792
+ // must never affect activation, so every failure is swallowed silently.
6793
+ if (!isNamedAgent && !isSddAgent && !processTelemetryTriggerAttempted) {
6794
+ processTelemetryTriggerAttempted = true;
6795
+ try {
6796
+ const executable = resolveTelemetryTriggerBinary();
6797
+ spawnTelemetryTrigger({
6798
+ executable,
6799
+ cwd: ctx.cwd,
6800
+ env: dependencies.processEnv ?? process.env,
6801
+ spawn: dependencies.telemetryTriggerSpawn,
6802
+ });
6803
+ } catch {
6804
+ // Best-effort only; never surfaced and never affects activation.
6805
+ }
6806
+ }
5666
6807
  if (isSddAgent && !getSddPreflightPreferences(ctx)) {
5667
6808
  await runSddPreflight(ctx);
5668
6809
  }
@@ -5680,14 +6821,77 @@ function createGentleAiExtensionForTesting(
5680
6821
  prefs?.artifactStore,
5681
6822
  ), phase)}`
5682
6823
  : "";
6824
+ // gentle-pi#661: the RDD status line (and the rest of the gentle prompt)
6825
+ // is built only for the primary session, mirrored on the
6826
+ // reviewContractPrompt condition below -- named/SDD agents never reach
6827
+ // this branch, so no line is resolved or computed for them.
6828
+ // resolveRddStatusLine never throws and never hangs past
6829
+ // RDD_STATUS_TIMEOUT_MS: an absent/timed-out/aborted/failing native
6830
+ // binary renders the fail-closed "unknown" line instead.
5683
6831
  const gentlePrompt = isNamedAgent || isSddAgent
5684
6832
  ? ""
5685
- : `\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd), ctx.cwd, readActiveToolNames(pi))}`;
6833
+ : `\n\n${buildGentlePrompt(
6834
+ readPersonaMode(ctx.cwd),
6835
+ ctx.cwd,
6836
+ readActiveToolNames(pi),
6837
+ await resolveRddStatusLine(nativeReviewCli, ctx.cwd, AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS), undefined, ctx),
6838
+ )}`;
6839
+ // gentle-pi#560 / gentle-ai#4056, #4057: inject the mirrored provider
6840
+ // contract bundle's review execution contract for the primary session
6841
+ // only, and only when a native review CLI is actually present.
6842
+ const reviewContractPrompt =
6843
+ !isNamedAgent && !isSddAgent && nativeReviewCli !== null
6844
+ ? (() => {
6845
+ const fragment = loadReviewContractPromptFragment(ctx);
6846
+ return fragment === null ? "" : `\n\n${fragment}`;
6847
+ })()
6848
+ : "";
5686
6849
  return {
5687
- systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}`,
6850
+ systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}`,
5688
6851
  };
5689
6852
  });
5690
6853
 
6854
+ // gentle-pi#556 / gentle-ai#4051: with RDD enabled, the agent could finish
6855
+ // an authorized implementation and report completion without ever running
6856
+ // the review STATUS preflight or offering the consent question. This
6857
+ // 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.
6864
+ pi.on("agent_end", async (_event, ctx) => {
6865
+ if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return;
6866
+ if (ctx.hasUI !== true) return;
6867
+ const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
6868
+ const subagentDepth = processAgentEndSubagentDepth.get(sessionKey) ?? 0;
6869
+ if (subagentDepth > 0) {
6870
+ processAgentEndSubagentDepth.set(sessionKey, subagentDepth - 1);
6871
+ return;
6872
+ }
6873
+ const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
6874
+ if (status === undefined) return;
6875
+ if (status.nextTransition?.kind !== "execute" || status.nextTransition.execute.operation !== "review.start") return;
6876
+ 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
+ pi.sendMessage(
6886
+ {
6887
+ customType: "gentle-pi.review-preflight",
6888
+ content: renderAgentEndReviewPreflightMessage(targetIdentity),
6889
+ display: true,
6890
+ },
6891
+ { triggerTurn: true, deliverAs: "followUp" },
6892
+ );
6893
+ });
6894
+
5691
6895
  pi.on("tool_call", async (event, ctx) => {
5692
6896
  const sensitivePathDenied = evaluateSensitivePathTool(
5693
6897
  event.toolName,
@@ -5901,6 +7105,30 @@ function createGentleAiExtensionForTesting(
5901
7105
  },
5902
7106
  });
5903
7107
 
7108
+ pi.registerCommand("gentle:review-session-permission", {
7109
+ description: "Show or revoke the process-memory review permission for this exact Pi session and Git repository (status|revoke).",
7110
+ handler: async (args, ctx) => {
7111
+ const subAction = args.trim().length === 0 ? "status" : args.trim();
7112
+ if (subAction !== "status" && subAction !== "revoke") {
7113
+ ctx.ui.notify(`Unknown /gentle:review-session-permission sub-action "${subAction}". Use status or revoke.`, "warning");
7114
+ return;
7115
+ }
7116
+ if (subAction === "revoke") {
7117
+ const revoked = await revokeCurrentRepositoryReviewSessionPermission(ctx);
7118
+ ctx.ui.notify(revoked ? "Review permission revoked for this Git repository in this Pi session. Provider review mode and authority were not changed." : "No review permission is active for this Git repository in this Pi session. Provider review mode and authority were not changed.", "info");
7119
+ return;
7120
+ }
7121
+ const identity = await refreshReviewSessionPermissionStatus(ctx);
7122
+ if (identity === undefined) {
7123
+ ctx.ui.notify("Review session permission is unavailable: it requires the interactive Pi TUI, a non-child session, a nonempty session ID, and a canonical Git worktree.", "info");
7124
+ return;
7125
+ }
7126
+ ctx.ui.notify(hasReviewSessionPermission(identity)
7127
+ ? "Reviews are allowed for this Pi session and Git repository. Use /gentle:review-session-permission revoke to ask again."
7128
+ : "Reviews are not pre-authorized for this Pi session; each medium- or high-risk candidate asks normally.", "info");
7129
+ },
7130
+ });
7131
+
5904
7132
  pi.registerCommand("gentle:review-mode", {
5905
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.",
5906
7134
  handler: async (args, ctx) => {
@@ -5946,6 +7174,56 @@ function createGentleAiExtensionForTesting(
5946
7174
  },
5947
7175
  });
5948
7176
 
7177
+ // gentle-pi#677: gentle-ai owns telemetry end to end (status, the opt-out
7178
+ // switches, and rate limiting); this command only runs the corresponding
7179
+ // `gentle-ai telemetry <op> --json` in the foreground and relays its
7180
+ // output, so a Pi user never has to leave Pi to check or change it.
7181
+ pi.registerCommand("gentle:telemetry", {
7182
+ description: "Show or change the local Gentle AI telemetry trigger (status|enable|disable|preview); gentle-ai owns the data and the opt-out.",
7183
+ handler: async (args, ctx) => {
7184
+ const subAction = args.trim().length === 0 ? "status" : args.trim();
7185
+ if (subAction !== "status" && subAction !== "enable" && subAction !== "disable" && subAction !== "preview") {
7186
+ ctx.ui.notify(`Unknown /gentle:telemetry sub-action "${subAction}". Use status, enable, disable, or preview.`, "warning");
7187
+ return;
7188
+ }
7189
+ let executable: string;
7190
+ try {
7191
+ executable = resolveTelemetryTriggerBinary();
7192
+ } catch (error) {
7193
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
7194
+ return;
7195
+ }
7196
+ let result: ExecFileResult;
7197
+ try {
7198
+ result = await telemetryExecFileAdapter({
7199
+ file: executable,
7200
+ arguments: ["telemetry", subAction, "--json"],
7201
+ cwd: ctx.cwd,
7202
+ timeoutMs: 5_000,
7203
+ maxBufferBytes: 1024 * 1024,
7204
+ });
7205
+ } catch (error) {
7206
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
7207
+ return;
7208
+ }
7209
+ if (result.exitCode !== 0) {
7210
+ ctx.ui.notify(`gentle-ai telemetry ${subAction} failed (exit ${result.exitCode}): ${(result.stderr || result.stdout || "no output").trim()}`, "error");
7211
+ return;
7212
+ }
7213
+ let relayed: string;
7214
+ try {
7215
+ relayed = JSON.stringify(JSON.parse(result.stdout), null, 2);
7216
+ } catch {
7217
+ relayed = result.stdout.trim();
7218
+ }
7219
+ if (subAction === "disable") {
7220
+ ctx.ui.notify("Gentle AI telemetry disabled.", "info");
7221
+ return;
7222
+ }
7223
+ ctx.ui.notify(relayed, "info");
7224
+ },
7225
+ });
7226
+
5949
7227
  // Mirrors gentle:review-mode: a user-owned switch, never an automated one.
5950
7228
  // It matters more here than there, because this policy governs whether
5951
7229
  // background subagents may be launched at all, so nothing in Pi may write