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.
- package/README.md +170 -12
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/orchestrator-delegation.md +19 -9
- package/assets/orchestrator.md +5 -5
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +898 -79
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +88 -12
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-integration-v2.ts +58 -8
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +87 -11
- package/runtime/review-integration-v2.mjs +58 -8
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +6 -2
- package/skills/_shared/review-ledger-contract.md +3 -1
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +28 -12
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +17 -6
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +3 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +25 -0
- package/tests/review-host-relay-routing.test.ts +20 -4
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -0
package/extensions/gentle-ai.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
3710
|
-
return this.staleDispositions.get(
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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,
|
|
5907
|
+
if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession)) {
|
|
5374
5908
|
const stale = staleConsentBindingDiagnostics(
|
|
5375
5909
|
input.consentBinding,
|
|
5376
|
-
pendingReviewConsentRegistry.staleDisposition(
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
...(
|
|
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
|
-
|
|
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(
|
|
5890
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|