gentle-pi 2.4.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 (116) hide show
  1. package/README.md +170 -12
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/orchestrator-delegation.md +19 -9
  4. package/assets/orchestrator.md +5 -5
  5. package/docs/delegated-verification.md +25 -0
  6. package/docs/telemetry.md +38 -0
  7. package/extensions/ask-user-choice.ts +26 -20
  8. package/extensions/codegraph-tools.ts +94 -5
  9. package/extensions/gentle-agents.ts +588 -0
  10. package/extensions/gentle-ai.ts +898 -79
  11. package/extensions/gentle-shell.ts +547 -0
  12. package/extensions/gentle-todo.ts +199 -0
  13. package/extensions/quiet-tools.ts +1 -1
  14. package/lib/agents-config.ts +318 -0
  15. package/lib/agents-history.ts +80 -0
  16. package/lib/agents-protocol.ts +429 -0
  17. package/lib/agents-runner.ts +490 -0
  18. package/lib/agents-transcript.ts +87 -0
  19. package/lib/agents-view.ts +557 -0
  20. package/lib/agents-widget.ts +222 -0
  21. package/lib/gentle-ai-renderer.ts +142 -26
  22. package/lib/native-choice-list.ts +194 -0
  23. package/lib/native-fullscreen-interaction.ts +47 -0
  24. package/lib/native-pointer-region.ts +164 -0
  25. package/lib/native-review-cli.ts +88 -12
  26. package/lib/review-candidate-view-owner.ts +177 -0
  27. package/lib/review-candidate-view.ts +127 -35
  28. package/lib/review-consent-ui.ts +65 -0
  29. package/lib/review-integration-v2.ts +58 -8
  30. package/lib/review-last-event-controller.ts +1 -0
  31. package/lib/review-relay-contract.ts +11 -0
  32. package/lib/review-repository.ts +2 -2
  33. package/lib/review-risk-assessment.ts +339 -0
  34. package/lib/review-session-standing-permission-ipc.ts +309 -0
  35. package/lib/review-session-standing-permission.ts +219 -0
  36. package/lib/shell-bar.ts +138 -0
  37. package/lib/shell-card.ts +136 -0
  38. package/lib/shell-changes-view.ts +205 -0
  39. package/lib/shell-changes.ts +210 -0
  40. package/lib/shell-gauge.ts +40 -0
  41. package/lib/shell-prompt.ts +119 -0
  42. package/lib/shell-todo.ts +280 -0
  43. package/lib/shell-usage-view.ts +76 -0
  44. package/lib/shell-usage.ts +246 -0
  45. package/lib/telemetry-trigger.ts +151 -0
  46. package/package.json +4 -4
  47. package/runtime/native-review-cli.mjs +87 -11
  48. package/runtime/review-integration-v2.mjs +58 -8
  49. package/runtime/review-relay-contract.mjs +11 -0
  50. package/runtime/review-risk-assessment.mjs +340 -0
  51. package/runtime/telemetry-trigger.mjs +152 -0
  52. package/scripts/build-runtime-modules.mjs +2 -0
  53. package/scripts/gentle-ai-installer.mjs +10 -10
  54. package/scripts/test-packed-runner.mjs +22 -0
  55. package/scripts/verify-package-files.mjs +6 -2
  56. package/skills/_shared/review-ledger-contract.md +3 -1
  57. package/tests/agents-config.test.ts +143 -0
  58. package/tests/agents-fake-child.ts +52 -0
  59. package/tests/agents-history.test.ts +54 -0
  60. package/tests/agents-protocol.test.ts +153 -0
  61. package/tests/agents-runner-process.test.ts +111 -0
  62. package/tests/agents-runner.test.ts +402 -0
  63. package/tests/agents-transcript.test.ts +30 -0
  64. package/tests/agents-view.test.ts +274 -0
  65. package/tests/agents-widget.test.ts +111 -0
  66. package/tests/ask-user-choice.test.ts +157 -3
  67. package/tests/codegraph-tools.test.ts +110 -1
  68. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  69. package/tests/fixtures/agents-process-child.mjs +23 -0
  70. package/tests/gentle-agents.test.ts +741 -0
  71. package/tests/gentle-ai-binary.test.ts +1 -1
  72. package/tests/gentle-ai-installer.test.ts +47 -47
  73. package/tests/gentle-ai-renderer.test.ts +65 -0
  74. package/tests/gentle-ai.test.ts +28 -12
  75. package/tests/gentle-card-text.ts +35 -0
  76. package/tests/gentle-shell.test.ts +527 -0
  77. package/tests/gentle-todo.test.ts +182 -0
  78. package/tests/native-choice-list.test.ts +202 -0
  79. package/tests/native-fullscreen-interaction.test.ts +125 -0
  80. package/tests/native-pointer-region.test.ts +245 -0
  81. package/tests/native-review-capability-contract.test.ts +16 -1
  82. package/tests/native-review-cli.test.ts +40 -0
  83. package/tests/native-review-consent.test.ts +91 -0
  84. package/tests/native-review-parity-runtime.test.ts +8 -2
  85. package/tests/native-review-parity.test.ts +29 -22
  86. package/tests/orchestrator-budget.test.ts +69 -0
  87. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  88. package/tests/package-manifest.test.ts +17 -6
  89. package/tests/quiet-tool-rendering.test.ts +96 -37
  90. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  91. package/tests/rdd-status-line.test.ts +286 -0
  92. package/tests/review-candidate-view.test.ts +452 -6
  93. package/tests/review-contract-prompt.test.ts +3 -0
  94. package/tests/review-controller-native-recovery.test.ts +29 -4
  95. package/tests/review-controller-native-routing.test.ts +321 -4
  96. package/tests/review-controller-workspace-root.test.ts +45 -2
  97. package/tests/review-controller.test.ts +25 -0
  98. package/tests/review-host-relay-routing.test.ts +20 -4
  99. package/tests/review-integration-v2.test.ts +112 -0
  100. package/tests/review-last-event-closure.test.ts +7 -2
  101. package/tests/review-relay-contract.test.ts +26 -0
  102. package/tests/review-repository.test.ts +28 -1
  103. package/tests/review-risk-assessment.test.ts +626 -0
  104. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  105. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  106. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  107. package/tests/review-session-standing-permission.test.ts +126 -0
  108. package/tests/shell-bar.test.ts +176 -0
  109. package/tests/shell-card.test.ts +118 -0
  110. package/tests/shell-changes-view.test.ts +146 -0
  111. package/tests/shell-changes.test.ts +182 -0
  112. package/tests/shell-prompt.test.ts +118 -0
  113. package/tests/shell-todo.test.ts +170 -0
  114. package/tests/shell-usage-view.test.ts +62 -0
  115. package/tests/shell-usage.test.ts +197 -0
  116. package/tests/telemetry-trigger.test.ts +349 -0
@@ -1,3 +1,4 @@
1
+ import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
1
2
  import { execFileSync } from "node:child_process";
2
3
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
3
4
  import {
@@ -107,10 +108,15 @@ import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, r
107
108
  import {
108
109
  GentleAiDevBinaryOverrideError,
109
110
  registerGentleAiDevBinary,
111
+ resolveGentleAiBinary,
110
112
  resolveGentleAiDevBinaryOverride,
111
113
  unregisterGentleAiDevBinary,
112
114
  type GentleAiDevBinaryOverride,
113
115
  } from "../lib/gentle-ai-binary.ts";
116
+ import {
117
+ spawnTelemetryTrigger,
118
+ type TelemetryTriggerSpawn,
119
+ } from "../lib/telemetry-trigger.ts";
114
120
  import {
115
121
  createNativeReviewCli,
116
122
  createNodeExecFileAdapter,
@@ -142,7 +148,21 @@ import {
142
148
  type NativeReviewModeSource,
143
149
  type NativeReviewProcessDiagnostics,
144
150
  type NativeStartResult,
151
+ type NativeReviewAssessRequest,
152
+ type ExecFileAdapter,
153
+ type ExecFileResult,
145
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";
146
166
  import {
147
167
  assertReviewApprovedAcknowledgementExecuteV1,
148
168
  decodeReviewLastEventClosureV1,
@@ -153,7 +173,19 @@ import {
153
173
  type ReviewStatusV3,
154
174
  } from "../lib/review-integration-v2.ts";
155
175
  import { reconcileUnknownReviewLastEventCapture } from "../lib/review-last-event-controller.ts";
156
- 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";
157
189
 
158
190
  const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations";
159
191
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -671,21 +703,288 @@ function renderBackgroundSubagentsStatusLine(
671
703
  return `Background subagent policy: ${background.policy} (capability: ${background.capability})`;
672
704
  }
673
705
 
674
- // Rendered prompts are memoized per background policy/capability key for the
675
- // 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.
676
974
  const orchestratorPromptCache = new Map<string, string>();
677
975
  function getOrchestratorPrompt(
678
976
  cwd: string = process.cwd(),
679
977
  activeTools?: readonly string[],
978
+ rddStatusLine: string = renderRddStatusLine(undefined),
680
979
  ): string {
681
980
  const background: BackgroundSubagentsRendering = {
682
981
  policy: loadBackgroundSubagentsPolicy(cwd),
683
982
  capability: resolveBackgroundSubagentsCapability(cwd, activeTools),
684
983
  };
685
- const cacheKey = `${background.policy}:${background.capability}`;
984
+ const cacheKey = `${background.policy}:${background.capability}:${rddStatusLine}`;
686
985
  let prompt = orchestratorPromptCache.get(cacheKey);
687
986
  if (prompt === undefined) {
688
- prompt = renderOrchestratorPrompt(ASSETS_DIR, background);
987
+ prompt = renderOrchestratorPrompt(ASSETS_DIR, background, rddStatusLine);
689
988
  orchestratorPromptCache.set(cacheKey, prompt);
690
989
  }
691
990
  return prompt;
@@ -694,12 +993,14 @@ function getOrchestratorPrompt(
694
993
  function renderOrchestratorPrompt(
695
994
  assetsDir: string,
696
995
  background: BackgroundSubagentsRendering = DEFAULT_BACKGROUND_SUBAGENTS_RENDERING,
996
+ rddStatusLine: string = renderRddStatusLine(undefined),
697
997
  ): string {
998
+ const backgroundPolicyBlock = `${renderBackgroundSubagentsStatusLine(background)}\n${rddStatusLine}`;
698
999
  return readFileSync(join(assetsDir, "orchestrator.md"), "utf8")
699
1000
  .replaceAll("{{GENTLE_PI_ASSETS_ROOT}}", assetsDir)
700
1001
  .replaceAll(
701
1002
  "{{GENTLE_PI_BACKGROUND_POLICY}}",
702
- renderBackgroundSubagentsStatusLine(background),
1003
+ backgroundPolicyBlock,
703
1004
  )
704
1005
  .trim();
705
1006
  }
@@ -798,6 +1099,7 @@ function buildGentlePrompt(
798
1099
  persona: PersonaMode,
799
1100
  cwd: string = process.cwd(),
800
1101
  activeTools?: readonly string[],
1102
+ rddStatusLine: string = renderRddStatusLine(undefined),
801
1103
  ): string {
802
1104
  const personaPrompt =
803
1105
  persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT;
@@ -832,7 +1134,7 @@ Harness principles:
832
1134
  - Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
833
1135
  - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
834
1136
 
835
- ${getOrchestratorPrompt(cwd, activeTools)}`;
1137
+ ${getOrchestratorPrompt(cwd, activeTools, rddStatusLine)}`;
836
1138
  }
837
1139
 
838
1140
  // Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
@@ -2645,6 +2947,9 @@ const REVIEW_CONTROLLER_OPERATION = {
2645
2947
  RECONCILE_AUTHORITY: "reconcile-authority",
2646
2948
  REPAIR_LEGACY_ALIAS: "repair-legacy-alias",
2647
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",
2648
2953
  } as const;
2649
2954
 
2650
2955
  type ReviewControllerOperation =
@@ -2691,7 +2996,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
2691
2996
  },
2692
2997
  input: {
2693
2998
  type: "string",
2694
- description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. Legacy controller input remains separate.",
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.",
2695
3000
  },
2696
3001
  outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
2697
3002
  inputPath: { type: "string", description: "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired." },
@@ -2779,6 +3084,51 @@ interface ReviewScopeParameters {
2779
3084
  cursor?: number;
2780
3085
  }
2781
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
+
2782
3132
  interface ReviewControllerParameters {
2783
3133
  operation: ReviewControllerOperation;
2784
3134
  lineageId?: string;
@@ -2823,7 +3173,7 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
2823
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");
2824
3174
  return { operation: value.operation, selectionBinding: value.selectionBinding, intendedUntracked: value.intendedUntracked, ...(typeof value.workspaceRoot === "string" ? { workspaceRoot: value.workspaceRoot } : {}) };
2825
3175
  }
2826
- const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, REVIEW_CONTROLLER_OPERATION.STATUS, REVIEW_CONTROLLER_OPERATION.EXPORT, REVIEW_CONTROLLER_OPERATION.IMPORT, REVIEW_CONTROLLER_OPERATION.INSPECT, REVIEW_CONTROLLER_OPERATION.RESET, REVIEW_CONTROLLER_OPERATION.RECOVER, REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, REVIEW_CONTROLLER_OPERATION.ABANDON, REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY, REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY, REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS, REVIEW_CONTROLLER_OPERATION.REPAIR].includes(value.operation as ReviewControllerOperation);
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);
2827
3177
  if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
2828
3178
  throw new Error("Review controller requires a lineageId");
2829
3179
  }
@@ -3107,6 +3457,15 @@ async function resolveReviewModeGate(
3107
3457
  }
3108
3458
  }
3109
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
+
3110
3469
  function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<string, unknown> {
3111
3470
  return {
3112
3471
  operation,
@@ -3115,6 +3474,7 @@ function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<s
3115
3474
  ...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { mutation_performed: false }),
3116
3475
  inventory_complete: false,
3117
3476
  next_action: "require-upstream-read-only-native-status-inventory",
3477
+ remediation_command: NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND,
3118
3478
  evidence: {
3119
3479
  native_contract: "gentle-ai/2.1.4",
3120
3480
  general_status: "unsupported",
@@ -3165,6 +3525,23 @@ function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown
3165
3525
  next_action: "require-complete-native-authority-inventory",
3166
3526
  };
3167
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
+ }
3168
3545
  return {
3169
3546
  operation,
3170
3547
  status: "blocked",
@@ -3203,7 +3580,6 @@ function missingNativeMaintenanceInputs(operation: NativeMaintenanceOperation, i
3203
3580
  ...missing,
3204
3581
  ...(Array.isArray(input.capturedLensResults) && input.capturedLensResults.every((entry) => isCanonicalProcessString(entry)) ? [] : ["capturedLensResults"]),
3205
3582
  ...(typeof input.findingsPresent === "boolean" ? [] : ["findingsPresent"]),
3206
- ...(typeof input.evidenceRecordsPresent === "boolean" ? [] : ["evidenceRecordsPresent"]),
3207
3583
  ];
3208
3584
  }
3209
3585
 
@@ -3213,7 +3589,7 @@ function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, in
3213
3589
  }
3214
3590
 
3215
3591
  function nativeMaintenanceAuthorization(operation: NativeMaintenanceOperation, input: Record<string, unknown>): string {
3216
- if (operation === "abandon") return nativeReviewAbandonAuthorization({ lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, evidenceRecordsPresent: input.evidenceRecordsPresent === true, actor: String(input.actor), reason: String(input.reason) });
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) });
3217
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) });
3218
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 }) });
3219
3595
  }
@@ -3240,7 +3616,7 @@ async function executeNativeAuthorityMaintenance(
3240
3616
  }
3241
3617
  try {
3242
3618
  const result = nativeOperation === "abandon"
3243
- ? await nativeReviewCli.abandon!({ cwd, lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, evidenceRecordsPresent: input.evidenceRecordsPresent === true, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
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 }) })
3244
3620
  : nativeOperation === "quarantineLegacy"
3245
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 }) })
3246
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 }) });
@@ -3454,6 +3830,19 @@ function mapNativeTargetStatus(operation: ReviewControllerOperation, status: Rev
3454
3830
  required_status_action: "Use only the provider-selected recovery disposition; do not substitute scope_changed, invalidated, or escalated.",
3455
3831
  };
3456
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
+ }
3457
3846
  return {
3458
3847
  operation,
3459
3848
  status: status.action === "start" ? "ready" : "blocked",
@@ -3622,6 +4011,8 @@ function nativeStatusInputRejection(reason: string, field?: string): Record<stri
3622
4011
  }
3623
4012
 
3624
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";
3625
4016
 
3626
4017
  type PendingReviewConsentSessionKey = string | symbol;
3627
4018
 
@@ -3656,7 +4047,13 @@ const PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT = 32;
3656
4047
  */
3657
4048
  export class PendingReviewConsentRegistry {
3658
4049
  private readonly sessions = new Map<PendingReviewConsentSessionKey, Map<string, PendingReviewConsent>>();
3659
- 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>();
3660
4057
 
3661
4058
  get(sessionKey: PendingReviewConsentSessionKey): Map<string, PendingReviewConsent> | undefined {
3662
4059
  return this.sessions.get(sessionKey);
@@ -3671,34 +4068,46 @@ export class PendingReviewConsentRegistry {
3671
4068
  return pending;
3672
4069
  }
3673
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
+
3674
4087
  private remove(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
3675
4088
  const session = this.sessions.get(sessionKey);
3676
4089
  if (session?.get(pending.id) !== pending) return false;
3677
4090
  session.delete(pending.id);
3678
4091
  if (session.size === 0) this.sessions.delete(sessionKey);
4092
+ if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id);
3679
4093
  return true;
3680
4094
  }
3681
4095
 
3682
- private rememberDisposition(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent, disposition: PendingReviewConsentDisposition): void {
3683
- let stale = this.staleDispositions.get(sessionKey);
3684
- if (stale === undefined) {
3685
- stale = new Map<string, PendingReviewConsentDisposition>();
3686
- this.staleDispositions.set(sessionKey, stale);
3687
- }
3688
- stale.delete(pending.id);
3689
- stale.set(pending.id, disposition);
3690
- while (stale.size > PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT) stale.delete(stale.keys().next().value!);
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!);
3691
4100
  }
3692
4101
 
3693
4102
  consume(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
3694
4103
  if (!this.remove(sessionKey, pending)) return false;
3695
- this.rememberDisposition(sessionKey, pending, PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED);
4104
+ this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED);
3696
4105
  return true;
3697
4106
  }
3698
4107
 
3699
4108
  expire(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
3700
4109
  if (!this.remove(sessionKey, pending)) return false;
3701
- this.rememberDisposition(sessionKey, pending, PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED);
4110
+ this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED);
3702
4111
  return true;
3703
4112
  }
3704
4113
 
@@ -3706,14 +4115,14 @@ export class PendingReviewConsentRegistry {
3706
4115
  this.remove(sessionKey, pending);
3707
4116
  }
3708
4117
 
3709
- staleDisposition(sessionKey: PendingReviewConsentSessionKey, binding: string): PendingReviewConsentDisposition | undefined {
3710
- return this.staleDispositions.get(sessionKey)?.get(binding);
4118
+ staleDisposition(binding: string): PendingReviewConsentDisposition | undefined {
4119
+ return this.staleDispositions.get(binding);
3711
4120
  }
3712
4121
 
3713
4122
  take(sessionKey: PendingReviewConsentSessionKey): PendingReviewConsent[] {
3714
4123
  const session = this.sessions.get(sessionKey);
3715
4124
  this.sessions.delete(sessionKey);
3716
- 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);
3717
4126
  return session === undefined ? [] : [...session.values()];
3718
4127
  }
3719
4128
  }
@@ -3740,6 +4149,18 @@ const processAgentEndPreflightNudgedTargets = new Map<PendingReviewConsentSessio
3740
4149
  // unreviewed candidate this session should be reminded about.
3741
4150
  const processAgentEndSessionBaseline = new Map<PendingReviewConsentSessionKey, string>();
3742
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
+
3743
4164
  function pendingReviewConsentSessionKey(context: ExtensionContext | undefined, fallbackKey: symbol): PendingReviewConsentSessionKey {
3744
4165
  try {
3745
4166
  const sessionManager = (context as unknown as { sessionManager?: { getSessionId?: () => unknown } } | undefined)?.sessionManager;
@@ -3797,6 +4218,61 @@ function reviewConsentDigest(consent: ReviewConsentEnvelope): string {
3797
4218
  return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
3798
4219
  }
3799
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
+
3800
4276
  // gentle-pi#516: a binding this session does not hold (already answered,
3801
4277
  // expired, or issued by another Pi session or process) used to fall through
3802
4278
  // to the plain negotiated STATUS, which reads exactly like a healthy pre-start
@@ -3842,6 +4318,29 @@ function staleConsentBindingOutcome(operation: ReviewControllerOperation, bindin
3842
4318
  };
3843
4319
  }
3844
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
+
3845
4344
  function assertNativeStartCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void {
3846
4345
  candidateView.verify();
3847
4346
  if (
@@ -3887,7 +4386,7 @@ function completeNativeStart(
3887
4386
  }
3888
4387
 
3889
4388
  function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_review_capture", error: unknown): Record<string, unknown> {
3890
- const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; auditRecord?: unknown; launchAttempted?: unknown; candidateViewPreNative?: unknown; failureEnvelope?: { raw?: unknown; mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown } };
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 } } };
3891
4390
  if (isRecord(value.failureEnvelope) && isRecord(value.failureEnvelope.raw)) {
3892
4391
  const mutationOutcome = value.failureEnvelope.mutationOutcome;
3893
4392
  return {
@@ -3901,6 +4400,12 @@ function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_r
3901
4400
  : { mutation_performed: false, mutation_outcome: "none" }),
3902
4401
  ...(typeof value.failureEnvelope.replayability === "string" ? { replayability: value.failureEnvelope.replayability } : {}),
3903
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
+ : {}),
3904
4409
  };
3905
4410
  }
3906
4411
  // Every consent binding guard runs before the provider is launched, so this
@@ -4290,11 +4795,13 @@ async function reconcileUnknownReviewCaptureFailure(
4290
4795
  selections: Map<string, RetainedNativeStatusSelection>,
4291
4796
  route: RetainedNativeCaptureRoute | undefined,
4292
4797
  expectedReviewCaptureSuffix?: readonly string[],
4798
+ agent?: "pi",
4293
4799
  ): Promise<Record<string, unknown>> {
4294
4800
  const failure = error === undefined ? undefined : nativeOperationFailure("gentle_review_capture", error);
4295
4801
  if (error !== undefined && !nativeMutationRequiresStatus(error)) return failure;
4296
4802
  try {
4297
- 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);
4298
4805
  syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef);
4299
4806
  if (expectedReviewCaptureSuffix !== undefined && !hasExactReviewCaptureSuffix(status, expectedReviewCaptureSuffix)) return captureGroupAuthorityDrift(status);
4300
4807
  return {
@@ -4355,10 +4862,10 @@ async function executeReviewHostRelayCapture(
4355
4862
  },
4356
4863
  };
4357
4864
  } catch (error) {
4358
- 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);
4359
4866
  if (error.mutationOutcome === "unknown") {
4360
4867
  return {
4361
- ...(await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route)),
4868
+ ...(await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT)),
4362
4869
  failure: reviewHostRelayFailureReport(error),
4363
4870
  reason: error.message,
4364
4871
  };
@@ -4605,7 +5112,7 @@ async function resolveNegotiatedReviewStatusForSession(
4605
5112
  // supported continuation (gentle_review inspect) and defers the resulting
4606
5113
  // consent envelope to the human.
4607
5114
  function renderAgentEndReviewPreflightMessage(targetIdentity: string): string {
4608
- return `Receipt-driven development is enabled, and this worktree holds an unreviewed candidate (target ${targetIdentity}). By the review contract entry rule, run the review preflight before reporting completion.\n\nCall the gentle_review tool with {"operation":"inspect"} and follow the transition it returns; it currently offers review.start for this target. Relay the resulting gentle-ai.review-integration.consent/v3 envelope to the human losslessly, and never answer it on the human's behalf.\n\nThis extension never runs START itself. This reminder is sent once per candidate.`;
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.`;
4609
5116
  }
4610
5117
 
4611
5118
  function canonicalReviewCaptureBinding(value: unknown): string {
@@ -4631,8 +5138,18 @@ function exactCollectArgument(input: ReviewCollectInputV3, name: string): string
4631
5138
  return matches.length === 1 ? matches[0]!.value : undefined;
4632
5139
  }
4633
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
+
4634
5151
  function reviewIntendedUntrackedInput(status: ReviewStatusV3): ReviewCollectInputV3 | undefined {
4635
- 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;
4636
5153
  const matches = (status.nextTransition.collect?.inputs ?? []).filter((input) => {
4637
5154
  const value = input.submission?.values[0];
4638
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";
@@ -5012,11 +5529,11 @@ async function executeReviewCaptureGroupOperation(
5012
5529
  }
5013
5530
  } catch (error) {
5014
5531
  if (error instanceof ReviewHostRelayError && error.mutationOutcome !== "unknown") return reviewHostRelayGroupFailure(error, group.slots, prepared, index);
5015
- const reconciled = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, current.binding, retainedUntrackedSelections, route);
5532
+ const reconciled = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, current.binding, retainedUntrackedSelections, route, undefined, REVIEW_HOST_AGENT);
5016
5533
  return { ...reconciled, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index, true), ...(error instanceof ReviewHostRelayError ? { failure: reviewHostRelayFailureReport(error), reason: error.message } : {}) };
5017
5534
  }
5018
5535
  }
5019
- const reconciled = await reconcileUnknownReviewCaptureFailure(undefined, nativeReviewCli, cwd, group.binding, retainedUntrackedSelections, route, canonicalBindings.slice(prepared.length));
5536
+ const reconciled = await reconcileUnknownReviewCaptureFailure(undefined, nativeReviewCli, cwd, group.binding, retainedUntrackedSelections, route, canonicalBindings.slice(prepared.length), REVIEW_HOST_AGENT);
5020
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) };
5021
5538
  }
5022
5539
 
@@ -5057,7 +5574,6 @@ async function executeReviewControllerOperation(
5057
5574
  retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
5058
5575
  pendingReviewConsentRegistry: PendingReviewConsentRegistry = processPendingReviewConsentRegistry,
5059
5576
  pendingReviewConsentFallbackKey: symbol = Symbol("pending-review-consent-fallback"),
5060
- writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
5061
5577
  reviewConsentNow: () => number = Date.now,
5062
5578
  reviewConsentScheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void } = setTimeout,
5063
5579
  intendedUntrackedSelection?: NativeIntendedUntrackedSelectionSubmission,
@@ -5082,6 +5598,15 @@ async function executeReviewControllerOperation(
5082
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.",
5083
5599
  };
5084
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
+ }
5085
5610
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS) {
5086
5611
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
5087
5612
  return await executeNativeLegacyAliasRepair(input, defaultCwd, nativeReviewCli, signal, context);
@@ -5324,6 +5849,9 @@ async function executeReviewControllerOperation(
5324
5849
  // The registry owns restoring writability of its 0555 views before
5325
5850
  // removal; a terminal approved cleanup keeps the lineage projection.
5326
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.
5327
5855
  return {
5328
5856
  operation: parameters.operation,
5329
5857
  status: "closed",
@@ -5347,13 +5875,18 @@ async function executeReviewControllerOperation(
5347
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");
5348
5876
  if (typeof input.consentBinding !== "string" || input.consentBinding.length === 0) throw new Error("Review controller answer-consent requires an opaque consentBinding");
5349
5877
  if (input.answer !== "granted" && input.answer !== "declined") throw new Error("Review controller answer-consent answer must be granted or declined");
5350
- const pending = pendingReviewConsentRegistry.get(pendingReviewConsentSession)?.get(input.consentBinding);
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;
5351
5884
  if (pending === undefined || pending.expiresAt <= reviewConsentNow()) {
5352
5885
  const disposition = pending === undefined
5353
- ? pendingReviewConsentRegistry.staleDisposition(pendingReviewConsentSession, input.consentBinding)
5886
+ ? pendingReviewConsentRegistry.staleDisposition(input.consentBinding)
5354
5887
  : PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED;
5355
5888
  const stale = staleConsentBindingDiagnostics(input.consentBinding, disposition);
5356
- if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession);
5889
+ if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
5357
5890
  if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
5358
5891
  try {
5359
5892
  const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
@@ -5366,14 +5899,15 @@ async function executeReviewControllerOperation(
5366
5899
  return nativeStatusFailed(parameters.operation, error);
5367
5900
  }
5368
5901
  }
5369
- 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);
5370
5904
  if (reviewConsentDigest(pending.consent) !== pending.consentDigest) throw new Error("Review controller consent envelope binding changed");
5371
5905
  pending.verifyCandidate();
5372
5906
  if (nativeReviewCli?.answerConsent === undefined) throw new Error("Native review consent follow-up is unavailable");
5373
- if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession)) {
5907
+ if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession)) {
5374
5908
  const stale = staleConsentBindingDiagnostics(
5375
5909
  input.consentBinding,
5376
- pendingReviewConsentRegistry.staleDisposition(pendingReviewConsentSession, input.consentBinding),
5910
+ pendingReviewConsentRegistry.staleDisposition(input.consentBinding),
5377
5911
  );
5378
5912
  if (nativeReviewCli.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
5379
5913
  try {
@@ -5390,11 +5924,14 @@ async function executeReviewControllerOperation(
5390
5924
  try {
5391
5925
  const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
5392
5926
  if (gated !== undefined) {
5393
- 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);
5394
5931
  return gated;
5395
5932
  }
5396
5933
  } catch (error) {
5397
- cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession);
5934
+ cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
5398
5935
  return nativeOperationFailure(parameters.operation, error);
5399
5936
  }
5400
5937
  // The one-shot binding is consumed before the first answer-path await. Any
@@ -5408,6 +5945,9 @@ async function executeReviewControllerOperation(
5408
5945
  ...(signal === undefined ? {} : { signal }),
5409
5946
  });
5410
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);
5411
5951
  pending.cleanupCandidate();
5412
5952
  return {
5413
5953
  operation: parameters.operation,
@@ -5428,15 +5968,6 @@ async function executeReviewControllerOperation(
5428
5968
  projection: "workspace",
5429
5969
  }, retainedUntrackedSelections);
5430
5970
  }
5431
- if (input.answer === "granted") {
5432
- try {
5433
- writeReviewConsentLatch(pending.repositoryCwd);
5434
- } catch (error) {
5435
- try {
5436
- context?.ui.notify(`Native review start completed, but Pi could not record the local consent latch: ${error instanceof Error ? error.message : String(error)}`, "warning");
5437
- } catch { /* Reporting is best effort; native completion remains authoritative. */ }
5438
- }
5439
- }
5440
5971
  return completed;
5441
5972
  }
5442
5973
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED) {
@@ -5456,7 +5987,7 @@ async function executeReviewControllerOperation(
5456
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));
5457
5988
  if (rejected) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" };
5458
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 }) };
5459
- const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, writeReviewConsentLatch, reviewConsentNow, reviewConsentScheduleTimer, submission);
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);
5460
5991
  return { ...result, operation: parameters.operation };
5461
5992
  }
5462
5993
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
@@ -5514,7 +6045,16 @@ async function executeReviewControllerOperation(
5514
6045
  } catch (error) {
5515
6046
  return nativeOperationFailure(parameters.operation, error);
5516
6047
  }
5517
- 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 });
5518
6058
  // Synchronously drop any binding whose TTL has already elapsed
5519
6059
  // before reusing its retained candidate view, so a fresh-candidate
5520
6060
  // retry cannot reuse a view tied to an expired binding and trip
@@ -5577,14 +6117,14 @@ async function executeReviewControllerOperation(
5577
6117
  cleanupCandidate: () => {
5578
6118
  if (candidateCleaned) return;
5579
6119
  candidateCleaned = true;
5580
- consentCandidateView.cleanup();
6120
+ try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent expiry/teardown still completes. */ }
5581
6121
  },
5582
6122
  ...(retainedUntrackedSelection === undefined ? {} : { untrackedSelection: retainedUntrackedSelection }),
5583
6123
  consent: error.consent,
5584
6124
  consentDigest,
5585
6125
  expiresAt: reviewConsentNow() + PENDING_REVIEW_CONSENT_TTL_MS,
5586
6126
  };
5587
- pendingReviewConsentRegistry.ensure(pendingReviewConsentSession).set(id, pending);
6127
+ pendingReviewConsentRegistry.add(pendingReviewConsentSession, pending);
5588
6128
  pending.expiry = reviewConsentScheduleTimer(
5589
6129
  () => expirePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession),
5590
6130
  PENDING_REVIEW_CONSENT_TTL_MS,
@@ -5725,12 +6265,16 @@ async function executeReviewControllerOperation(
5725
6265
  const effectiveUntrackedSelection = rawStatus === undefined && parameters.lineageId !== undefined
5726
6266
  ? readRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId)
5727
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);
5728
6272
  if (nativeReviewCli?.targetStatus !== undefined) {
5729
6273
  try {
5730
6274
  const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
5731
6275
  cwd: defaultCwd,
5732
6276
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5733
- ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }),
6277
+ ...(effectiveBaseRef === undefined ? {} : { baseRef: effectiveBaseRef, committedOnly: true }),
5734
6278
  ...(effectiveUntrackedSelection.untrackedScope === undefined ? {} : effectiveUntrackedSelection),
5735
6279
  ...(signal === undefined ? {} : { signal }),
5736
6280
  }, retainedUntrackedSelections, defaultCwd);
@@ -5789,8 +6333,19 @@ export const __testing = {
5789
6333
  resolveBackgroundSubagentsCapability,
5790
6334
  readActiveToolNames,
5791
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,
5792
6346
  resolveControllerSddStatus,
5793
6347
  resolveStartupControllerSddStatus,
6348
+ resetTelemetryTriggerGuardForTesting,
5794
6349
  createGentleAiExtension: createGentleAiExtensionForTesting,
5795
6350
  };
5796
6351
 
@@ -5824,6 +6379,22 @@ export interface GentleAiRuntimeDependencies {
5824
6379
  // sleep and without relying on the queued cleanup macrotask firing.
5825
6380
  now?: () => number;
5826
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;
5827
6398
  }
5828
6399
 
5829
6400
  export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void {
@@ -5832,18 +6403,61 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
5832
6403
 
5833
6404
  function createGentleAiExtensionForTesting(
5834
6405
  dependencies: GentleAiRuntimeDependencies = {},
5835
- writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
5836
6406
  ): (pi: ExtensionAPI) => void {
5837
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;
5838
6412
  const reviewConsentNow = dependencies.now ?? (() => Date.now());
5839
6413
  const reviewConsentScheduleTimer = dependencies.scheduleTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
5840
6414
  const pendingReviewConsentRegistry = dependencies.pendingReviewConsentRegistry ?? processPendingReviewConsentRegistry;
6415
+ const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary;
6416
+ const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter();
5841
6417
  return function gentleAi(pi: ExtensionAPI): void {
6418
+ declareReviewRelayHandshake(dependencies.processEnv ?? process.env);
5842
6419
  const pendingReviewConsentFallbackKey = Symbol("pending-review-consent-fallback");
5843
6420
  const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
5844
6421
  const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events);
6422
+ const permissionEnvironment = dependencies.processEnv ?? process.env;
5845
6423
 
5846
- 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
+ }
5847
6461
  const sessionKey = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey);
5848
6462
  cleanupAllPendingReviewConsents(pendingReviewConsentRegistry, sessionKey);
5849
6463
  processRetainedNativeStatusSelections.delete(sessionKey);
@@ -5865,8 +6479,8 @@ function createGentleAiExtensionForTesting(
5865
6479
  context as GentleAiRenderContext | undefined,
5866
6480
  );
5867
6481
  },
5868
- renderResult(result, options) {
5869
- return renderGentleAiResult(result, options);
6482
+ renderResult(result, options, theme, context) {
6483
+ return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
5870
6484
  },
5871
6485
  async execute(_toolCallId, parameters) {
5872
6486
  const input = parameters as ReviewScopeParameters;
@@ -5875,6 +6489,25 @@ function createGentleAiExtensionForTesting(
5875
6489
  },
5876
6490
  });
5877
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
+
5878
6511
  pi.registerTool({
5879
6512
  name: "gentle_review_capture_group",
5880
6513
  label: "Gentle Review Capture Group",
@@ -5886,11 +6519,13 @@ function createGentleAiExtensionForTesting(
5886
6519
  ],
5887
6520
  parameters: REVIEW_CAPTURE_GROUP_PARAMETERS,
5888
6521
  executionMode: "sequential",
5889
- renderCall(_args, theme, context) {
5890
- return renderGentleAiLifecycleCall("review capture group", theme, context as GentleAiRenderContext | undefined);
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);
5891
6526
  },
5892
- renderResult(result, options) {
5893
- return renderGentleAiResult(result, options);
6527
+ renderResult(result, options, theme, context) {
6528
+ return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
5894
6529
  },
5895
6530
  async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
5896
6531
  if (signal?.aborted) throw new Error("Review capture group was cancelled");
@@ -5919,15 +6554,15 @@ function createGentleAiExtensionForTesting(
5919
6554
  ],
5920
6555
  parameters: REVIEW_CAPTURE_PARAMETERS,
5921
6556
  executionMode: "sequential",
5922
- renderCall(_args, theme, context) {
6557
+ renderCall(args, theme, context) {
5923
6558
  return renderGentleAiLifecycleCall(
5924
- "review capture",
6559
+ withLenses("review capture", [collectBindingLens((args as { collectBinding?: unknown }).collectBinding)]),
5925
6560
  theme,
5926
6561
  context as GentleAiRenderContext | undefined,
5927
6562
  );
5928
6563
  },
5929
- renderResult(result, options) {
5930
- return renderGentleAiResult(result, options);
6564
+ renderResult(result, options, theme, context) {
6565
+ return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
5931
6566
  },
5932
6567
  async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
5933
6568
  if (signal?.aborted) throw new Error("Review capture was cancelled");
@@ -5956,11 +6591,12 @@ function createGentleAiExtensionForTesting(
5956
6591
  promptGuidelines: [
5957
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).',
5958
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.",
5959
- "Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, capturedLensResults, findingsPresent, evidenceRecordsPresent, actor, and reason; QUARANTINE_LEGACY accepts only the published malformed freeze-findings diagnostic/disposition. A dual reconciliation may supply only anomalies `unchanged_target,malformed_recovery_authorization` in that exact order. Use REPAIR_LEGACY_ALIAS only with lineage, actor, and reason: Pi freshly reads native inventory and derives repository, revision, diagnostic, disposition, and the exact eight-line binding before interactive approval. `review dispose-result` is unsupported pending design.",
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.",
5960
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.",
5961
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.",
5962
- "A consent-required START returns the complete provider envelope and an opaque consent_binding, then stops. The parent presents and localizes that envelope without changing machine tokens, commands, target IDs, or invocations. After one explicit human answer, call answer-consent exactly once with a JSON string containing only consentBinding and answer (`granted` or `declined`). A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START output, the controller calls target-scoped native status once and returns only its declared action. An ambiguous gentle_review_capture outcome independently reconciles once and never replays the capture.",
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.",
5963
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.',
5964
6600
  ],
5965
6601
  parameters: REVIEW_CONTROLLER_PARAMETERS,
5966
6602
  executionMode: "sequential",
@@ -5971,26 +6607,99 @@ function createGentleAiExtensionForTesting(
5971
6607
  context as GentleAiRenderContext | undefined,
5972
6608
  );
5973
6609
  },
5974
- renderResult(result, options) {
5975
- return renderGentleAiResult(result, options);
6610
+ renderResult(result, options, theme, context) {
6611
+ return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
5976
6612
  },
5977
6613
  async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
5978
6614
  if (signal?.aborted) throw new Error("Review controller operation was cancelled");
5979
6615
  await authorizeDestructiveReviewOperation(parameters, ctx);
5980
- 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(
5981
6620
  parameters,
5982
6621
  ctx.cwd,
5983
6622
  nativeReviewCli,
5984
6623
  signal,
5985
6624
  candidateViews,
5986
6625
  ctx,
5987
- ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
6626
+ retainedSelections,
5988
6627
  pendingReviewConsentRegistry,
5989
6628
  pendingReviewConsentFallbackKey,
5990
- writeReviewConsentLatch,
5991
6629
  reviewConsentNow,
5992
6630
  reviewConsentScheduleTimer,
5993
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
+ }
5994
6703
  return {
5995
6704
  content: [{ type: "text", text: JSON.stringify(details) }],
5996
6705
  details,
@@ -6002,7 +6711,11 @@ function createGentleAiExtensionForTesting(
6002
6711
  return ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installSddAssets(cwd, false), applyModelConfig: async () => applySavedModelConfig(ctx) }, { promptFields });
6003
6712
  }
6004
6713
 
6005
- 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);
6006
6719
  // Loud, every session: an active dev-binary override means this session
6007
6720
  // runs an unpinned gentle-ai. Announce which one before anything else.
6008
6721
  try {
@@ -6071,6 +6784,26 @@ function createGentleAiExtensionForTesting(
6071
6784
  } else {
6072
6785
  processAgentEndSubagentDepth.set(subagentDepthKey, 0);
6073
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
+ }
6074
6807
  if (isSddAgent && !getSddPreflightPreferences(ctx)) {
6075
6808
  await runSddPreflight(ctx);
6076
6809
  }
@@ -6088,9 +6821,21 @@ function createGentleAiExtensionForTesting(
6088
6821
  prefs?.artifactStore,
6089
6822
  ), phase)}`
6090
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.
6091
6831
  const gentlePrompt = isNamedAgent || isSddAgent
6092
6832
  ? ""
6093
- : `\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
+ )}`;
6094
6839
  // gentle-pi#560 / gentle-ai#4056, #4057: inject the mirrored provider
6095
6840
  // contract bundle's review execution contract for the primary session
6096
6841
  // only, and only when a native review CLI is actually present.
@@ -6360,6 +7105,30 @@ function createGentleAiExtensionForTesting(
6360
7105
  },
6361
7106
  });
6362
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
+
6363
7132
  pi.registerCommand("gentle:review-mode", {
6364
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.",
6365
7134
  handler: async (args, ctx) => {
@@ -6405,6 +7174,56 @@ function createGentleAiExtensionForTesting(
6405
7174
  },
6406
7175
  });
6407
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
+
6408
7227
  // Mirrors gentle:review-mode: a user-owned switch, never an automated one.
6409
7228
  // It matters more here than there, because this policy governs whether
6410
7229
  // background subagents may be launched at all, so nothing in Pi may write