gentle-pi 2.0.0 → 2.1.1
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 +3 -12
- package/extensions/gentle-ai.ts +362 -123
- package/lib/gentle-ai-binary.ts +64 -17
- package/lib/native-review-cli.ts +207 -27
- package/lib/review-candidate-view.ts +9 -5
- package/lib/review-integration-v2.ts +5 -1
- package/package.json +1 -1
- package/runtime/gentle-ai-binary.mjs +64 -17
- package/runtime/native-review-cli.mjs +206 -26
- package/runtime/review-integration-v2.mjs +5 -1
- package/scripts/gentle-ai-installer.mjs +454 -71
- package/scripts/test-packed-runner.mjs +1 -1
- package/skills/_shared/review-ledger-contract.md +3 -1
- package/tests/gentle-ai-binary.test.ts +70 -3
- package/tests/gentle-ai-installer.test.ts +543 -7
- package/tests/gentle-ai.test.ts +2 -2
- package/tests/native-review-consent.test.ts +205 -0
- package/tests/native-review-parity-runtime.test.ts +15 -6
- package/tests/native-review-parity.test.ts +228 -53
- package/tests/package-manifest.test.ts +10 -3
- package/tests/review-candidate-view.test.ts +43 -0
- package/tests/review-controller-lock-status.test.ts +25 -7
- package/tests/review-controller-native-routing.test.ts +419 -20
- package/tests/review-controller-workspace-root.test.ts +40 -10
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-integration-v2.test.ts +10 -1
- package/tests/runtime-harness.mjs +24 -12
package/extensions/gentle-ai.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import {
|
|
4
4
|
existsSync,
|
|
5
5
|
lstatSync,
|
|
@@ -96,7 +96,7 @@ import {
|
|
|
96
96
|
type ReviewProjectionV1,
|
|
97
97
|
} from "../lib/review-snapshot.ts";
|
|
98
98
|
import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
|
|
99
|
-
import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, resolveCanonicalCandidateBase, type NativeCandidateProjectionDescriptor } from "../lib/review-candidate-view.ts";
|
|
99
|
+
import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, resolveCanonicalCandidateBase, type CandidateView, type NativeCandidateProjectionDescriptor } from "../lib/review-candidate-view.ts";
|
|
100
100
|
import {
|
|
101
101
|
createNativeReviewCli,
|
|
102
102
|
isCanonicalProcessString,
|
|
@@ -104,7 +104,10 @@ import {
|
|
|
104
104
|
nativeReviewLegacyAliasRepairAuthorization,
|
|
105
105
|
nativeReviewLegacyQuarantineAuthorization,
|
|
106
106
|
nativeReviewReconcileAuthorization,
|
|
107
|
+
normalizeNativeReviewCwd,
|
|
107
108
|
NativeReviewCliError,
|
|
109
|
+
NativeReviewConsentBindingError,
|
|
110
|
+
NativeReviewConsentRequiredError,
|
|
108
111
|
NATIVE_REVIEW_ERROR_CODE,
|
|
109
112
|
NATIVE_REVIEW_LEGACY_QUARANTINE,
|
|
110
113
|
NATIVE_REVIEW_LEGACY_ALIAS_REPAIR,
|
|
@@ -120,8 +123,7 @@ import {
|
|
|
120
123
|
type NativeStartResult,
|
|
121
124
|
type NativeValidateResult,
|
|
122
125
|
} from "../lib/native-review-cli.ts";
|
|
123
|
-
import {
|
|
124
|
-
import type { ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
126
|
+
import type { ReviewConsentV2, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
125
127
|
import { assertDistinctCorrectionEvidence, resolveCorrectionStep, type CorrectionEvidence, type CorrectionOutcome, type CorrectionStep } from "../lib/review-correction-lifecycle.ts";
|
|
126
128
|
|
|
127
129
|
const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations";
|
|
@@ -2106,6 +2108,7 @@ async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
2106
2108
|
|
|
2107
2109
|
const REVIEW_CONTROLLER_OPERATION = {
|
|
2108
2110
|
START: "start",
|
|
2111
|
+
ANSWER_CONSENT: "answer-consent",
|
|
2109
2112
|
FINALIZE: "finalize",
|
|
2110
2113
|
ADVANCE: "advance",
|
|
2111
2114
|
STATUS: "status",
|
|
@@ -2161,7 +2164,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
|
|
|
2161
2164
|
},
|
|
2162
2165
|
input: {
|
|
2163
2166
|
type: "string",
|
|
2164
|
-
description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"};
|
|
2167
|
+
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\"}. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. Legacy compact START retains policyHash. FINALIZE supplies reviewer results, correction forecast, targeted validation, final evidence, and an explicit final_verification_passed boolean. Judgment Day retains graph-v1 input.",
|
|
2165
2168
|
},
|
|
2166
2169
|
outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
|
|
2167
2170
|
inputPath: { type: "string", description: "Repository-local JSON input file for finalize/advance (alternative to input). Legacy bundle import is retired." },
|
|
@@ -2461,7 +2464,7 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
|
|
|
2461
2464
|
// VALIDATE defers its lineage requirement to execution time: the proven
|
|
2462
2465
|
// release-from-protected-main fast path needs no receipt lineage, while
|
|
2463
2466
|
// every other validation still requires one before receipt validation.
|
|
2464
|
-
const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.FINALIZE, 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.VALIDATE, REVIEW_CONTROLLER_OPERATION.BIND_SDD].includes(value.operation as ReviewControllerOperation);
|
|
2467
|
+
const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, REVIEW_CONTROLLER_OPERATION.FINALIZE, 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.VALIDATE, REVIEW_CONTROLLER_OPERATION.BIND_SDD].includes(value.operation as ReviewControllerOperation);
|
|
2465
2468
|
if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
|
|
2466
2469
|
throw new Error("Review controller requires a lineageId");
|
|
2467
2470
|
}
|
|
@@ -2871,7 +2874,7 @@ export function resolveReviewLifecycleCommand(
|
|
|
2871
2874
|
}
|
|
2872
2875
|
if (words[0] !== "git") return null;
|
|
2873
2876
|
const gitGlobalArgs: string[] = [];
|
|
2874
|
-
let resolvedCwd = resolve(defaultCwd);
|
|
2877
|
+
let resolvedCwd = resolve(normalizeNativeReviewCwd(defaultCwd));
|
|
2875
2878
|
let index = 1;
|
|
2876
2879
|
while (index < words.length) {
|
|
2877
2880
|
const option = words[index];
|
|
@@ -2879,7 +2882,7 @@ export function resolveReviewLifecycleCommand(
|
|
|
2879
2882
|
const value = words[index + 1];
|
|
2880
2883
|
if (value === undefined) return null;
|
|
2881
2884
|
gitGlobalArgs.push(option, value);
|
|
2882
|
-
if (option === "-C") resolvedCwd = resolve(resolvedCwd, value);
|
|
2885
|
+
if (option === "-C") resolvedCwd = resolve(resolvedCwd, normalizeNativeReviewCwd(value));
|
|
2883
2886
|
else return null;
|
|
2884
2887
|
index += 2;
|
|
2885
2888
|
continue;
|
|
@@ -3866,6 +3869,7 @@ function nativeReviewModeSkipped(operation: ReviewControllerOperation, source: N
|
|
|
3866
3869
|
operation,
|
|
3867
3870
|
status: "skipped",
|
|
3868
3871
|
outcome: REVIEW_MODE_DISABLED_OUTCOME,
|
|
3872
|
+
delivery: "disabled/unmanaged",
|
|
3869
3873
|
mode_source: source,
|
|
3870
3874
|
reason: `receipt-driven development is disabled: ${operation} is skipped because the ${source} mode source keeps it off`,
|
|
3871
3875
|
...(continuation === undefined ? {} : { next_action: continuation }),
|
|
@@ -3915,6 +3919,14 @@ function asNativeReviewCliError(error: unknown): { code: string; diagnostics: Na
|
|
|
3915
3919
|
return diagnostics === undefined || value.code !== diagnostics.error_code ? undefined : { code: value.code, diagnostics };
|
|
3916
3920
|
}
|
|
3917
3921
|
|
|
3922
|
+
// Same coexisting-module-instance caveat as asNativeReviewCliError above.
|
|
3923
|
+
function asNativeReviewConsentBindingError(error: unknown): { reason: string; message: string } | undefined {
|
|
3924
|
+
if (error instanceof NativeReviewConsentBindingError) return { reason: error.reason, message: error.message };
|
|
3925
|
+
if (!(error instanceof Error) || error.name !== "NativeReviewConsentBindingError") return undefined;
|
|
3926
|
+
const reason = (error as unknown as { reason?: unknown }).reason;
|
|
3927
|
+
return typeof reason !== "string" || reason.length === 0 ? undefined : { reason, message: error.message };
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3918
3930
|
function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown): Record<string, unknown> {
|
|
3919
3931
|
const cliError = asNativeReviewCliError(error);
|
|
3920
3932
|
if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) return nativeStatusUnsupported(operation);
|
|
@@ -4326,12 +4338,33 @@ function assertFrozenPreCommitProjection(
|
|
|
4326
4338
|
return projection.candidateTree;
|
|
4327
4339
|
}
|
|
4328
4340
|
|
|
4341
|
+
function reproveNativePreCommitTree(
|
|
4342
|
+
derived: DerivedReviewGateTarget,
|
|
4343
|
+
lineageId: string,
|
|
4344
|
+
candidateViews: CandidateViewRegistry | null,
|
|
4345
|
+
): string | undefined {
|
|
4346
|
+
return assertFrozenPreCommitProjection(derived, lineageId, candidateViews) ??
|
|
4347
|
+
(derived.command.event === "pre-commit" ? derived.actualIntendedCommitTree : undefined);
|
|
4348
|
+
}
|
|
4349
|
+
|
|
4329
4350
|
function authorizationTargetHash(derived: DerivedReviewGateTarget): string {
|
|
4330
4351
|
return derived.nativePublication === undefined
|
|
4331
4352
|
? canonicalHash(derived.target)
|
|
4332
4353
|
: canonicalHash({ target: derived.target, native_publication: derived.nativePublication });
|
|
4333
4354
|
}
|
|
4334
4355
|
|
|
4356
|
+
function nativeAuthorizationConsumptionIdentity(authorization: PendingReviewAuthorization | undefined): string | undefined {
|
|
4357
|
+
if (authorization?.native_gate === undefined) return undefined;
|
|
4358
|
+
return canonicalHash({
|
|
4359
|
+
command_hash: authorization.command_hash,
|
|
4360
|
+
target_hash: authorization.target_hash,
|
|
4361
|
+
lineage_id: authorization.native_gate.lineage_id,
|
|
4362
|
+
store_revision: authorization.native_gate.store_revision,
|
|
4363
|
+
fingerprint: authorization.native_gate.fingerprint,
|
|
4364
|
+
intended_tree: authorization.native_gate.intended_tree ?? null,
|
|
4365
|
+
});
|
|
4366
|
+
}
|
|
4367
|
+
|
|
4335
4368
|
function assertNativePublicationBinding(result: NativeValidateResult, derived: DerivedReviewGateTarget): void {
|
|
4336
4369
|
const returnedGate = result.gateContext.raw.gate;
|
|
4337
4370
|
if (returnedGate !== requestedNativeGate(derived) && (result.allowed || returnedGate !== "")) {
|
|
@@ -4383,6 +4416,64 @@ async function rederiveNativePublicationTarget(
|
|
|
4383
4416
|
return fresh;
|
|
4384
4417
|
}
|
|
4385
4418
|
|
|
4419
|
+
interface NativePreCommitReceiptConsumption {
|
|
4420
|
+
authorization?: PendingReviewAuthorization;
|
|
4421
|
+
delivery?: "disabled/unmanaged";
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
async function consumeNativePreCommitReceipt(
|
|
4425
|
+
command: string,
|
|
4426
|
+
defaultCwd: string,
|
|
4427
|
+
derivedTarget: DerivedReviewGateTarget,
|
|
4428
|
+
nativeReviewCli: NativeReviewCli,
|
|
4429
|
+
publicationProbe: PublicationProbe,
|
|
4430
|
+
publicationProbeTimeoutMs: number,
|
|
4431
|
+
signal?: AbortSignal,
|
|
4432
|
+
): Promise<NativePreCommitReceiptConsumption> {
|
|
4433
|
+
const derived = await deriveNativePublicationTarget(
|
|
4434
|
+
derivedTarget,
|
|
4435
|
+
publicationProbe,
|
|
4436
|
+
publicationProbeTimeoutMs,
|
|
4437
|
+
signal,
|
|
4438
|
+
);
|
|
4439
|
+
if (derived.command.event !== "pre-commit" || derived.actualIntendedCommitTree === undefined) return {};
|
|
4440
|
+
const result = await nativeReviewCli.validate({
|
|
4441
|
+
cwd: derived.command.cwd,
|
|
4442
|
+
gate: "pre-commit",
|
|
4443
|
+
...(signal === undefined ? {} : { signal }),
|
|
4444
|
+
});
|
|
4445
|
+
assertNativePublicationBinding(result, derived);
|
|
4446
|
+
const fresh = await rederiveNativePublicationTarget(
|
|
4447
|
+
derived,
|
|
4448
|
+
command,
|
|
4449
|
+
defaultCwd,
|
|
4450
|
+
publicationProbe,
|
|
4451
|
+
publicationProbeTimeoutMs,
|
|
4452
|
+
signal,
|
|
4453
|
+
);
|
|
4454
|
+
if (result.delivery === "disabled/unmanaged") return { delivery: result.delivery };
|
|
4455
|
+
if (!result.allowed || result.result !== "allow") return {};
|
|
4456
|
+
if (
|
|
4457
|
+
result.gateContext.lineageId.length === 0 ||
|
|
4458
|
+
result.gateContext.raw.candidate_tree !== derived.actualIntendedCommitTree ||
|
|
4459
|
+
fresh.actualIntendedCommitTree !== derived.actualIntendedCommitTree
|
|
4460
|
+
) throw new Error("Native approved receipt does not bind the exact current pre-commit tree");
|
|
4461
|
+
const commandHash = reviewAuthorizationKey(command, fresh.command.cwd);
|
|
4462
|
+
return {
|
|
4463
|
+
authorization: {
|
|
4464
|
+
command_hash: commandHash,
|
|
4465
|
+
target_hash: authorizationTargetHash(fresh),
|
|
4466
|
+
receipt_hash: null,
|
|
4467
|
+
native_gate: {
|
|
4468
|
+
lineage_id: result.gateContext.lineageId,
|
|
4469
|
+
store_revision: result.gateContext.storeRevision,
|
|
4470
|
+
fingerprint: nativeGateFingerprint(result, fresh),
|
|
4471
|
+
intended_tree: derived.actualIntendedCommitTree,
|
|
4472
|
+
},
|
|
4473
|
+
},
|
|
4474
|
+
};
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4386
4477
|
interface NativeStartPolicyValidation {
|
|
4387
4478
|
policyPath?: string;
|
|
4388
4479
|
reason?: string;
|
|
@@ -4464,71 +4555,79 @@ function nativeStartRejection(reason: string, field?: string): Record<string, un
|
|
|
4464
4555
|
};
|
|
4465
4556
|
}
|
|
4466
4557
|
|
|
4467
|
-
|
|
4468
|
-
// consent question, distinct from and in addition to gentle-ai's own
|
|
4469
|
-
// internal headless notice tolerated in lib/native-review-cli.ts. Pi always
|
|
4470
|
-
// spawns gentle-ai without a TTY, so gentle-ai's own interactive prompt never
|
|
4471
|
-
// fires from Pi — this is Pi's own UI-layer question, asked once per clone.
|
|
4472
|
-
const REVIEW_CONSENT_TITLE = "Run the review now?";
|
|
4473
|
-
const REVIEW_CONSENT_VALUE_LINE = "Reviewing takes a bit longer, and it makes the result substantially safer.";
|
|
4474
|
-
const REVIEW_CONSENT_ANSWERS_LINE = "Yes = run the review now / No = not now, just this once";
|
|
4475
|
-
const REVIEW_CONSENT_OFF_PATH_LINE = "To turn reviews off for good, run /gentle:review-mode disable.";
|
|
4476
|
-
const REVIEW_CONSENT_HEADLESS_NOTICE = "Gentle AI reviewed this change without asking, because this session has no interface to answer on. Run /gentle:review-mode disable to turn reviews off, or /gentle:review-mode status to see the current setting.";
|
|
4477
|
-
const REVIEW_CONSENT_UNREADABLE_NOTICE = "Gentle AI could not read an answer, so it reviewed this change and will ask again next time.";
|
|
4558
|
+
const PENDING_REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
|
|
4478
4559
|
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
):
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
let accepted: boolean;
|
|
4520
|
-
try {
|
|
4521
|
-
accepted = await context.ui.confirm(REVIEW_CONSENT_TITLE, reviewConsentBody(riskEvidence));
|
|
4522
|
-
} catch {
|
|
4523
|
-
return { proceed: true, consentNotice: REVIEW_CONSENT_UNREADABLE_NOTICE };
|
|
4524
|
-
}
|
|
4525
|
-
if (!accepted) return { proceed: false };
|
|
4526
|
-
try {
|
|
4527
|
-
recordReviewConsentLatch(cwd);
|
|
4528
|
-
} catch {
|
|
4529
|
-
// Unresolvable repository: proceed without persisting (Threat Matrix).
|
|
4560
|
+
interface PendingReviewConsent {
|
|
4561
|
+
id: string;
|
|
4562
|
+
repositoryCwd: string;
|
|
4563
|
+
authorityCwd: string;
|
|
4564
|
+
candidateView: CandidateView;
|
|
4565
|
+
consent: ReviewConsentV2;
|
|
4566
|
+
consentDigest: string;
|
|
4567
|
+
expiresAt: number;
|
|
4568
|
+
expiry?: ReturnType<typeof setTimeout>;
|
|
4569
|
+
}
|
|
4570
|
+
|
|
4571
|
+
function consumePendingReviewConsent(pending: PendingReviewConsent, pendingReviewConsents: Map<string, PendingReviewConsent>): void {
|
|
4572
|
+
if (pending.expiry !== undefined) clearTimeout(pending.expiry);
|
|
4573
|
+
pending.expiry = undefined;
|
|
4574
|
+
if (pendingReviewConsents.get(pending.id) === pending) pendingReviewConsents.delete(pending.id);
|
|
4575
|
+
}
|
|
4576
|
+
|
|
4577
|
+
function cleanupPendingReviewConsent(pending: PendingReviewConsent, pendingReviewConsents: Map<string, PendingReviewConsent>, candidateViews: CandidateViewRegistry | null): void {
|
|
4578
|
+
consumePendingReviewConsent(pending, pendingReviewConsents);
|
|
4579
|
+
if (![...pendingReviewConsents.values()].some((current) => current.candidateView.token === pending.candidateView.token)) candidateViews?.cleanup(pending.candidateView.token);
|
|
4580
|
+
}
|
|
4581
|
+
|
|
4582
|
+
function cleanupAllPendingReviewConsents(pendingReviewConsents: Map<string, PendingReviewConsent>, candidateViews: CandidateViewRegistry | null): void {
|
|
4583
|
+
for (const pending of [...pendingReviewConsents.values()]) cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
|
|
4584
|
+
}
|
|
4585
|
+
|
|
4586
|
+
function reviewConsentDigest(consent: ReviewConsentV2): string {
|
|
4587
|
+
return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
|
|
4588
|
+
}
|
|
4589
|
+
|
|
4590
|
+
function assertNativeStartCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void {
|
|
4591
|
+
candidateView.verify();
|
|
4592
|
+
if (
|
|
4593
|
+
target.projection.projection !== "workspace" ||
|
|
4594
|
+
target.projection.baseTree !== candidateView.baseTree ||
|
|
4595
|
+
target.projection.initialReviewTree !== candidateView.candidateTree ||
|
|
4596
|
+
target.projection.currentCandidateTree !== candidateView.candidateTree ||
|
|
4597
|
+
JSON.stringify([...target.projection.paths].sort()) !== JSON.stringify([...candidateView.paths].sort())
|
|
4598
|
+
) {
|
|
4599
|
+
throw new CandidateViewError("native START workspace target does not match the immutable reviewer candidate view", "candidate-target-projection-drift");
|
|
4530
4600
|
}
|
|
4531
|
-
|
|
4601
|
+
}
|
|
4602
|
+
|
|
4603
|
+
function completeNativeStart(
|
|
4604
|
+
operation: ReviewControllerOperation,
|
|
4605
|
+
result: NativeStartResult,
|
|
4606
|
+
workspaceRoot: string,
|
|
4607
|
+
candidateView: CandidateView | undefined,
|
|
4608
|
+
candidateViews: CandidateViewRegistry | null,
|
|
4609
|
+
): Record<string, unknown> {
|
|
4610
|
+
if (candidateView === undefined) return { operation, result: mapNativeStartResult(result), workspace_root: workspaceRoot };
|
|
4611
|
+
if (candidateViews && result.lensesRequired) {
|
|
4612
|
+
const binding = { token: candidateView.token, lineageId: result.lineageId, selectedLenses: result.selectedLenses };
|
|
4613
|
+
if (result.action === "resumed" && !candidateViews.hasCurrentBinding()) candidateViews.restoreCurrentFromNativeStart(binding);
|
|
4614
|
+
else candidateViews.bindCurrent(binding);
|
|
4615
|
+
} else if (candidateViews && ((result.action === "created" && result.state === "reviewing") || result.action === "resumed" || result.action === "reuse-receipt")) candidateViews.retain(candidateView.token, result.lineageId);
|
|
4616
|
+
else candidateViews?.cleanup(candidateView.token);
|
|
4617
|
+
const actorBinding = result.lensesRequired
|
|
4618
|
+
? {
|
|
4619
|
+
workspace_root: workspaceRoot,
|
|
4620
|
+
candidate_root: candidateView.root,
|
|
4621
|
+
candidate_tree: candidateView.candidateTree,
|
|
4622
|
+
candidate_paths: candidateView.paths,
|
|
4623
|
+
}
|
|
4624
|
+
: undefined;
|
|
4625
|
+
return {
|
|
4626
|
+
operation,
|
|
4627
|
+
result: mapNativeStartResult(result),
|
|
4628
|
+
workspace_root: workspaceRoot,
|
|
4629
|
+
...(actorBinding === undefined ? {} : { actor_binding: actorBinding }),
|
|
4630
|
+
};
|
|
4532
4631
|
}
|
|
4533
4632
|
|
|
4534
4633
|
function nativeOperationFailure(operation: ReviewControllerOperation, error: unknown): Record<string, unknown> {
|
|
@@ -4548,6 +4647,23 @@ function nativeOperationFailure(operation: ReviewControllerOperation, error: unk
|
|
|
4548
4647
|
...(typeof value.failureEnvelope.nextAction === "string" ? { next_action: value.failureEnvelope.nextAction } : {}),
|
|
4549
4648
|
};
|
|
4550
4649
|
}
|
|
4650
|
+
// Every consent binding guard runs before the provider is launched, so this
|
|
4651
|
+
// is a local mismatch with nothing to reconcile. Reporting it as a native
|
|
4652
|
+
// operation failure hides the one fact that makes it fixable.
|
|
4653
|
+
const consentBinding = asNativeReviewConsentBindingError(error);
|
|
4654
|
+
if (consentBinding !== undefined) {
|
|
4655
|
+
return {
|
|
4656
|
+
operation,
|
|
4657
|
+
status: "blocked",
|
|
4658
|
+
outcome: "consent-binding-invalid",
|
|
4659
|
+
native_invocation_attempted: false,
|
|
4660
|
+
lineage_created: false,
|
|
4661
|
+
mutation_performed: false,
|
|
4662
|
+
mutation_outcome: "none" as const,
|
|
4663
|
+
diagnostics: { code: consentBinding.reason, message: consentBinding.message },
|
|
4664
|
+
next_action: "resolve-consent-binding",
|
|
4665
|
+
};
|
|
4666
|
+
}
|
|
4551
4667
|
const mutationOutcome = value.mutationOutcome === "unknown" ? "unknown" : "none";
|
|
4552
4668
|
const nativeDiagnostics = asNativeReviewCliError(error)?.diagnostics;
|
|
4553
4669
|
const diagnostics = operation === REVIEW_CONTROLLER_OPERATION.START && error instanceof CandidateViewError && value.candidateViewPreNative === true
|
|
@@ -4826,6 +4942,7 @@ async function executeReviewControllerOperation(
|
|
|
4826
4942
|
candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
|
|
4827
4943
|
context?: ExtensionContext,
|
|
4828
4944
|
correctionEvidenceByLineage: Map<string, CorrectionEvidence> = new Map(),
|
|
4945
|
+
pendingReviewConsents: Map<string, PendingReviewConsent> = new Map(),
|
|
4829
4946
|
): Promise<Record<string, unknown>> {
|
|
4830
4947
|
const parameters = parseReviewControllerParameters(parametersValue);
|
|
4831
4948
|
const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd);
|
|
@@ -4974,6 +5091,60 @@ async function executeReviewControllerOperation(
|
|
|
4974
5091
|
});
|
|
4975
5092
|
}
|
|
4976
5093
|
}
|
|
5094
|
+
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT) {
|
|
5095
|
+
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
5096
|
+
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");
|
|
5097
|
+
if (typeof input.consentBinding !== "string" || input.consentBinding.length === 0) throw new Error("Review controller answer-consent requires an opaque consentBinding");
|
|
5098
|
+
if (input.answer !== "granted" && input.answer !== "declined") throw new Error("Review controller answer-consent answer must be granted or declined");
|
|
5099
|
+
const pending = pendingReviewConsents.get(input.consentBinding);
|
|
5100
|
+
if (pending === undefined || pending.expiresAt <= Date.now()) {
|
|
5101
|
+
if (pending !== undefined) cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
|
|
5102
|
+
throw new Error("Review controller consent binding is unknown, expired, or already consumed");
|
|
5103
|
+
}
|
|
5104
|
+
if (realpathSync(defaultCwd) !== pending.repositoryCwd) throw new Error("Review controller consent repository binding changed");
|
|
5105
|
+
if (reviewConsentDigest(pending.consent) !== pending.consentDigest) throw new Error("Review controller consent envelope binding changed");
|
|
5106
|
+
pending.candidateView.verify();
|
|
5107
|
+
if (nativeReviewCli?.answerConsent === undefined) throw new Error("Native review consent follow-up is unavailable");
|
|
5108
|
+
try {
|
|
5109
|
+
const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
|
|
5110
|
+
if (gated !== undefined) {
|
|
5111
|
+
cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
|
|
5112
|
+
return gated;
|
|
5113
|
+
}
|
|
5114
|
+
} catch (error) {
|
|
5115
|
+
return nativeOperationFailure(parameters.operation, error);
|
|
5116
|
+
}
|
|
5117
|
+
// The one-shot binding is consumed before the provider mutation. Any
|
|
5118
|
+
// ambiguous result reconciles through STATUS and can never be replayed.
|
|
5119
|
+
consumePendingReviewConsent(pending, pendingReviewConsents);
|
|
5120
|
+
try {
|
|
5121
|
+
const answered = await nativeReviewCli.answerConsent({
|
|
5122
|
+
cwd: pending.authorityCwd,
|
|
5123
|
+
consent: pending.consent,
|
|
5124
|
+
answer: input.answer,
|
|
5125
|
+
...(signal === undefined ? {} : { signal }),
|
|
5126
|
+
});
|
|
5127
|
+
if (answered.kind === "declined") {
|
|
5128
|
+
candidateViews?.cleanup(pending.candidateView.token);
|
|
5129
|
+
return {
|
|
5130
|
+
operation: parameters.operation,
|
|
5131
|
+
status: "skipped",
|
|
5132
|
+
outcome: "consent-declined-this-candidate",
|
|
5133
|
+
consent: answered.raw,
|
|
5134
|
+
...nativeStartPreAuthorityRejection(),
|
|
5135
|
+
};
|
|
5136
|
+
}
|
|
5137
|
+
return completeNativeStart(parameters.operation, answered.start, pending.repositoryCwd, pending.candidateView, candidateViews);
|
|
5138
|
+
} catch (error) {
|
|
5139
|
+
const value = error as { mutationOutcome?: unknown };
|
|
5140
|
+
if (value.mutationOutcome === "none") candidateViews?.cleanup(pending.candidateView.token);
|
|
5141
|
+
return await reconcileNativeMutationFailure(parameters.operation, error, nativeReviewCli, {
|
|
5142
|
+
cwd: pending.authorityCwd,
|
|
5143
|
+
...(pending.candidateView.committedOnly ? { baseRef: pending.candidateView.baseCommit } : {}),
|
|
5144
|
+
projection: "workspace",
|
|
5145
|
+
});
|
|
5146
|
+
}
|
|
5147
|
+
}
|
|
4977
5148
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
|
|
4978
5149
|
const rawStart = parseControllerJson(
|
|
4979
5150
|
requiredControllerString(parameters, "input"),
|
|
@@ -5007,8 +5178,9 @@ async function executeReviewControllerOperation(
|
|
|
5007
5178
|
return nativeStartRejection("base-ref-unresolvable");
|
|
5008
5179
|
}
|
|
5009
5180
|
}
|
|
5181
|
+
let target: ReviewStatusV3;
|
|
5010
5182
|
try {
|
|
5011
|
-
|
|
5183
|
+
target = await nativeReviewCli.targetStatus({
|
|
5012
5184
|
cwd: defaultCwd,
|
|
5013
5185
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5014
5186
|
...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef }),
|
|
@@ -5023,51 +5195,46 @@ async function executeReviewControllerOperation(
|
|
|
5023
5195
|
let nativeStartAttempted = false;
|
|
5024
5196
|
try {
|
|
5025
5197
|
candidateView = candidateViews?.createOrReuse({ contributorRoot: defaultCwd, replayKey, ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }) });
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
:
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
const
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
// START already ran and its result is still reported.
|
|
5056
|
-
const actorBinding = candidateView !== undefined && result.lensesRequired && !consentDeclined
|
|
5057
|
-
? {
|
|
5058
|
-
workspace_root: defaultCwd,
|
|
5059
|
-
candidate_root: candidateView.root,
|
|
5060
|
-
candidate_tree: candidateView.candidateTree,
|
|
5061
|
-
candidate_paths: candidateView.paths,
|
|
5198
|
+
if (candidateView !== undefined) assertNativeStartCandidateBinding(candidateView, target);
|
|
5199
|
+
let result: NativeStartResult;
|
|
5200
|
+
try {
|
|
5201
|
+
nativeStartAttempted = true;
|
|
5202
|
+
result = await nativeReviewCli.start({
|
|
5203
|
+
cwd: defaultCwd,
|
|
5204
|
+
...(canonicalBaseRef === undefined
|
|
5205
|
+
? {}
|
|
5206
|
+
: { baseRef: candidateView?.baseCommit ?? canonicalBaseRef, committedOnly: true }),
|
|
5207
|
+
targetIdentity: target.targetIdentity,
|
|
5208
|
+
projection: target.projection.projection,
|
|
5209
|
+
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5210
|
+
...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
|
|
5211
|
+
...(signal === undefined ? {} : { signal }),
|
|
5212
|
+
});
|
|
5213
|
+
} catch (error) {
|
|
5214
|
+
if (!(error instanceof NativeReviewConsentRequiredError)) throw error;
|
|
5215
|
+
if (candidateView === undefined) throw new CandidateViewError("native consent requires a frozen candidate view");
|
|
5216
|
+
const consentCandidateView = candidateView;
|
|
5217
|
+
const repositoryCwd = realpathSync(defaultCwd);
|
|
5218
|
+
const consentDigest = reviewConsentDigest(error.consent);
|
|
5219
|
+
const existing = [...pendingReviewConsents.values()].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest && pending.expiresAt > Date.now());
|
|
5220
|
+
if (existing === undefined) for (const pending of [...pendingReviewConsents.values()]) if (pending.candidateView.token === consentCandidateView.token) consumePendingReviewConsent(pending, pendingReviewConsents);
|
|
5221
|
+
const id = existing?.id ?? randomUUID();
|
|
5222
|
+
if (existing === undefined) {
|
|
5223
|
+
const pending: PendingReviewConsent = { id, repositoryCwd, authorityCwd: defaultCwd, candidateView: consentCandidateView, consent: error.consent, consentDigest, expiresAt: Date.now() + PENDING_REVIEW_CONSENT_TTL_MS };
|
|
5224
|
+
pendingReviewConsents.set(id, pending);
|
|
5225
|
+
pending.expiry = setTimeout(() => cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews), PENDING_REVIEW_CONSENT_TTL_MS);
|
|
5226
|
+
pending.expiry.unref();
|
|
5062
5227
|
}
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5228
|
+
return {
|
|
5229
|
+
operation: parameters.operation,
|
|
5230
|
+
status: "blocked",
|
|
5231
|
+
outcome: "native-review-consent-required",
|
|
5232
|
+
consent: error.consent.raw,
|
|
5233
|
+
consent_binding: id,
|
|
5234
|
+
...nativeStartPreAuthorityRejection(),
|
|
5235
|
+
};
|
|
5236
|
+
}
|
|
5237
|
+
return completeNativeStart(parameters.operation, result, defaultCwd, candidateView, candidateViews);
|
|
5071
5238
|
} catch (error) {
|
|
5072
5239
|
if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason);
|
|
5073
5240
|
const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
|
|
@@ -5083,7 +5250,7 @@ async function executeReviewControllerOperation(
|
|
|
5083
5250
|
nextAction: "review.status",
|
|
5084
5251
|
});
|
|
5085
5252
|
return reconcileNativeMutationFailure(parameters.operation, failure, nativeReviewCli, {
|
|
5086
|
-
cwd:
|
|
5253
|
+
cwd: defaultCwd,
|
|
5087
5254
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5088
5255
|
...(canonicalBaseRef === undefined ? {} : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef }),
|
|
5089
5256
|
projection: "workspace",
|
|
@@ -5577,6 +5744,19 @@ async function gateLifecycleCommand(
|
|
|
5577
5744
|
if (!inspection.command) {
|
|
5578
5745
|
return { block: true, reason: inspection.failClosedReason ?? "Lifecycle command failed closed." };
|
|
5579
5746
|
}
|
|
5747
|
+
if (nativeReviewCli?.reviewMode !== undefined) {
|
|
5748
|
+
try {
|
|
5749
|
+
const mode = await nativeReviewCli.reviewMode({ cwd: inspection.command.cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, ...(signal === undefined ? {} : { signal }) });
|
|
5750
|
+
if (mode.status.effective === "off") {
|
|
5751
|
+
pendingAuthorizations.clear();
|
|
5752
|
+
return undefined;
|
|
5753
|
+
}
|
|
5754
|
+
} catch (error) {
|
|
5755
|
+
if (asNativeReviewCliError(error)?.code !== NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) {
|
|
5756
|
+
return { block: true, reason: `Gentle AI ${inspection.event} gate could not reconsult review mode and failed closed.` };
|
|
5757
|
+
}
|
|
5758
|
+
}
|
|
5759
|
+
}
|
|
5580
5760
|
let derived: DerivedReviewGateTarget;
|
|
5581
5761
|
try {
|
|
5582
5762
|
derived = deriveReviewGateTarget(command, defaultCwd);
|
|
@@ -5616,7 +5796,7 @@ async function gateLifecycleCommand(
|
|
|
5616
5796
|
try {
|
|
5617
5797
|
const intendedTree = authorization.native_gate === undefined
|
|
5618
5798
|
? undefined
|
|
5619
|
-
:
|
|
5799
|
+
: reproveNativePreCommitTree(derived, authorization.native_gate.lineage_id, candidateViews);
|
|
5620
5800
|
if (authorization.native_gate?.intended_tree !== undefined && intendedTree !== authorization.native_gate.intended_tree) {
|
|
5621
5801
|
return {
|
|
5622
5802
|
block: true,
|
|
@@ -5672,7 +5852,7 @@ async function gateLifecycleCommand(
|
|
|
5672
5852
|
publicationProbeTimeoutMs,
|
|
5673
5853
|
signal,
|
|
5674
5854
|
);
|
|
5675
|
-
const postNativeIntendedTree =
|
|
5855
|
+
const postNativeIntendedTree = reproveNativePreCommitTree(postNativeDerived, authorization.native_gate.lineage_id, candidateViews);
|
|
5676
5856
|
if (authorization.native_gate.intended_tree !== undefined && postNativeIntendedTree !== authorization.native_gate.intended_tree) throw new CandidateViewError("staged projection changed during native validation");
|
|
5677
5857
|
assertNativePublicationBinding(fresh, postNativeDerived);
|
|
5678
5858
|
if (nativeGateFingerprint(fresh, postNativeDerived) !== authorization.native_gate.fingerprint) {
|
|
@@ -5746,7 +5926,6 @@ export async function enforceReviewGateAndCommandSafety(
|
|
|
5746
5926
|
/** @internal */
|
|
5747
5927
|
export const __testing = {
|
|
5748
5928
|
resolveReviewModeGate,
|
|
5749
|
-
requestReviewConsent,
|
|
5750
5929
|
listAgentsFromDir,
|
|
5751
5930
|
listAgentsFromDirAsync,
|
|
5752
5931
|
listDiscoverableAgents,
|
|
@@ -5833,10 +6012,16 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
5833
6012
|
if (!Number.isSafeInteger(bashTimeRevalidationTimeoutMs) || bashTimeRevalidationTimeoutMs <= 0) throw new TypeError("Bash-time revalidation timeout must be a positive safe integer");
|
|
5834
6013
|
return function gentleAi(pi: ExtensionAPI): void {
|
|
5835
6014
|
const pendingReviewAuthorizations = new Map<string, PendingReviewAuthorization>();
|
|
6015
|
+
const pendingReviewConsents = new Map<string, PendingReviewConsent>();
|
|
6016
|
+
const consumedNativeAuthorizations = new Set<string>();
|
|
5836
6017
|
const pendingCommitTransactions = new Map<string, { cwd: string; transactionId: string }>();
|
|
5837
6018
|
const correctionEvidenceByLineage = new Map<string, CorrectionEvidence>();
|
|
5838
6019
|
const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
|
|
5839
6020
|
|
|
6021
|
+
pi.on("session_shutdown", () => {
|
|
6022
|
+
cleanupAllPendingReviewConsents(pendingReviewConsents, candidateViews);
|
|
6023
|
+
});
|
|
6024
|
+
|
|
5840
6025
|
pi.registerTool({
|
|
5841
6026
|
name: "gentle_review",
|
|
5842
6027
|
label: "Gentle Review Controller",
|
|
@@ -5849,7 +6034,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
5849
6034
|
"Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, 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.",
|
|
5850
6035
|
"Run selected lenses once, then call FINALIZE with a JSON string containing review_result.lens_results entries for every START-selected lens. Each entry has lens, findings, and non-empty evidence; clean lenses use findings: []. Pair final_evidence with exactly one of final_verification_passed or final_verification_outcome (passed, verification_failed, procedural_tooling_failed). Correction evidence is captured natively before STATUS can expose targeted validation. This Pi wrapper shape differs from native CLI --result, --refuter, --validation, and --evidence files. Use ADVANCE only for explicit graph-v1 Judgment Day.",
|
|
5851
6036
|
"For blocked-legacy or blocked-mixed, do not call START repeatedly. Explain invalidation, request explicit user authorization for the exact reset_request challenge, then call RESET or RECOVER only after authorization. RESET and RECOVER_LOCK route to audited native `gentle-ai review reclaim` and RECOVER routes to native `gentle-ai review recover`; 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.",
|
|
5852
|
-
"A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START or FINALIZE output, the controller calls target-scoped native status first and returns only its declared action. Never infer or prescribe replay unless native explicitly reports exact_replay_safe for the same canonical request and required lineage.",
|
|
6037
|
+
"A consent-required START returns the complete provider envelope and an opaque consent_binding, then stops. The parent presents and localizes that envelope without changing machine tokens, commands, target IDs, or invocations. After one explicit human answer, call answer-consent exactly once with a JSON string containing only consentBinding and answer (`granted` or `declined`). A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START, answer-consent, or FINALIZE output, the controller calls target-scoped native status first and returns only its declared action. Never infer or prescribe replay unless native explicitly reports exact_replay_safe for the same canonical request and required lineage.",
|
|
5853
6038
|
"Use gentle_review for bounded review transaction operations and exact lifecycle validation; never fabricate bash tool metadata or a separate gate target.",
|
|
5854
6039
|
],
|
|
5855
6040
|
parameters: REVIEW_CONTROLLER_PARAMETERS,
|
|
@@ -5868,6 +6053,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
5868
6053
|
candidateViews,
|
|
5869
6054
|
ctx,
|
|
5870
6055
|
correctionEvidenceByLineage,
|
|
6056
|
+
pendingReviewConsents,
|
|
5871
6057
|
);
|
|
5872
6058
|
return {
|
|
5873
6059
|
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
@@ -5991,12 +6177,60 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
5991
6177
|
return undefined;
|
|
5992
6178
|
const originalCommand = event.input.command;
|
|
5993
6179
|
const inspection = inspectReviewLifecycleCommand(originalCommand, ctx.cwd);
|
|
5994
|
-
|
|
5995
|
-
|
|
6180
|
+
let reviewModeDisabled = false;
|
|
6181
|
+
if (inspection.command?.event === "pre-commit" && nativeReviewCli?.reviewMode !== undefined) {
|
|
6182
|
+
try {
|
|
6183
|
+
const mode = await nativeReviewCli.reviewMode({ cwd: inspection.command.cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, ...(ctx.signal === undefined ? {} : { signal: ctx.signal }) });
|
|
6184
|
+
reviewModeDisabled = mode.status.effective === "off";
|
|
6185
|
+
if (reviewModeDisabled) pendingReviewAuthorizations.clear();
|
|
6186
|
+
} catch (error) {
|
|
6187
|
+
if (asNativeReviewCliError(error)?.code !== NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) return { block: true, reason: "Gentle AI lifecycle gate could not reconsult review mode and failed closed." };
|
|
6188
|
+
}
|
|
6189
|
+
}
|
|
6190
|
+
const commandAuthorizationKey = inspection.command?.event === "pre-commit"
|
|
6191
|
+
? reviewAuthorizationKey(originalCommand, inspection.command.cwd)
|
|
5996
6192
|
: undefined;
|
|
6193
|
+
let nativeCommitAuthorization = commandAuthorizationKey === undefined
|
|
6194
|
+
? undefined
|
|
6195
|
+
: pendingReviewAuthorizations.get(commandAuthorizationKey);
|
|
6196
|
+
let authorizationConsumptionIdentity = nativeAuthorizationConsumptionIdentity(nativeCommitAuthorization);
|
|
6197
|
+
if (authorizationConsumptionIdentity !== undefined && consumedNativeAuthorizations.has(authorizationConsumptionIdentity)) {
|
|
6198
|
+
if (commandAuthorizationKey !== undefined) pendingReviewAuthorizations.delete(commandAuthorizationKey);
|
|
6199
|
+
nativeCommitAuthorization = undefined;
|
|
6200
|
+
authorizationConsumptionIdentity = undefined;
|
|
6201
|
+
}
|
|
6202
|
+
let unmanagedPreCommit = reviewModeDisabled && inspection.command?.event === "pre-commit";
|
|
6203
|
+
if (!unmanagedPreCommit && inspection.command?.event === "pre-commit" && commandAuthorizationKey !== undefined && nativeCommitAuthorization === undefined && nativeReviewCli !== null) {
|
|
6204
|
+
let derived: DerivedReviewGateTarget;
|
|
6205
|
+
try {
|
|
6206
|
+
derived = deriveReviewGateTarget(originalCommand, ctx.cwd);
|
|
6207
|
+
} catch (error) {
|
|
6208
|
+
return {
|
|
6209
|
+
block: true,
|
|
6210
|
+
reason: `Gentle AI pre-commit gate could not exactly derive the command target and failed closed: ${error instanceof Error ? error.message : String(error)}`,
|
|
6211
|
+
};
|
|
6212
|
+
}
|
|
6213
|
+
const deadline = AbortSignal.timeout(bashTimeRevalidationTimeoutMs);
|
|
6214
|
+
const signal = ctx.signal === undefined ? deadline : AbortSignal.any([ctx.signal, deadline]);
|
|
6215
|
+
try {
|
|
6216
|
+
const consumed = await consumeNativePreCommitReceipt(originalCommand, ctx.cwd, derived, nativeReviewCli, publicationProbe, publicationProbeTimeoutMs, signal);
|
|
6217
|
+
nativeCommitAuthorization = consumed.authorization;
|
|
6218
|
+
unmanagedPreCommit = consumed.delivery === "disabled/unmanaged";
|
|
6219
|
+
authorizationConsumptionIdentity = nativeAuthorizationConsumptionIdentity(nativeCommitAuthorization);
|
|
6220
|
+
if (authorizationConsumptionIdentity !== undefined && consumedNativeAuthorizations.has(authorizationConsumptionIdentity)) {
|
|
6221
|
+
nativeCommitAuthorization = undefined;
|
|
6222
|
+
authorizationConsumptionIdentity = undefined;
|
|
6223
|
+
} else if (nativeCommitAuthorization !== undefined) {
|
|
6224
|
+
pendingReviewAuthorizations.set(nativeCommitAuthorization.command_hash, nativeCommitAuthorization);
|
|
6225
|
+
}
|
|
6226
|
+
} catch (error) {
|
|
6227
|
+
return { block: true, reason: `Gentle AI pre-commit receipt consumption failed closed: ${error instanceof Error ? error.message : String(error)}` };
|
|
6228
|
+
}
|
|
6229
|
+
}
|
|
5997
6230
|
const gateResult = await enforceReviewGateAndCommandSafety(
|
|
5998
6231
|
originalCommand,
|
|
5999
6232
|
(command) => {
|
|
6233
|
+
if (unmanagedPreCommit) return Promise.resolve(undefined);
|
|
6000
6234
|
const deadline = AbortSignal.timeout(bashTimeRevalidationTimeoutMs);
|
|
6001
6235
|
const signal = ctx.signal === undefined ? deadline : AbortSignal.any([ctx.signal, deadline]);
|
|
6002
6236
|
return gateLifecycleCommand(command, ctx.cwd, pendingReviewAuthorizations, nativeReviewCli, publicationProbe, publicationProbeTimeoutMs, signal, candidateViews);
|
|
@@ -6004,6 +6238,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
6004
6238
|
(command) => confirmCommand(command, ctx),
|
|
6005
6239
|
);
|
|
6006
6240
|
if (gateResult) return gateResult;
|
|
6241
|
+
if (authorizationConsumptionIdentity !== undefined) consumedNativeAuthorizations.add(authorizationConsumptionIdentity);
|
|
6007
6242
|
if (inspection.command?.event !== "pre-commit" || nativeCommitAuthorization?.native_gate === undefined) return undefined;
|
|
6008
6243
|
if (nativeReviewCli?.targetStatus === undefined) return undefined;
|
|
6009
6244
|
if (nativeCommitAuthorization.native_gate.intended_tree === undefined) {
|
|
@@ -6209,6 +6444,10 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
6209
6444
|
}
|
|
6210
6445
|
try {
|
|
6211
6446
|
const result = await nativeReviewCli.reviewMode({ cwd: ctx.cwd, operation: subAction as NativeReviewModeOperation });
|
|
6447
|
+
if (subAction === NATIVE_REVIEW_MODE_OPERATION.DISABLE && result.status.effective === "off") {
|
|
6448
|
+
pendingReviewAuthorizations.clear();
|
|
6449
|
+
cleanupAllPendingReviewConsents(pendingReviewConsents, candidateViews);
|
|
6450
|
+
}
|
|
6212
6451
|
const report = `receipt-driven development: ${result.status.effective} (decided by ${result.status.source})`;
|
|
6213
6452
|
// A mutating sub-action that left the effective mode unchanged did
|
|
6214
6453
|
// not do what the user asked, and reporting only the resulting
|