gentle-pi 2.1.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/extensions/gentle-ai.ts +54 -9
- package/lib/native-review-cli.ts +58 -24
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +57 -23
- package/tests/native-review-consent.test.ts +77 -0
- package/tests/native-review-parity.test.ts +80 -14
- package/tests/package-manifest.test.ts +2 -2
- package/tests/review-controller-lock-status.test.ts +25 -7
- package/tests/review-controller-native-routing.test.ts +172 -14
- package/tests/review-controller-workspace-root.test.ts +40 -10
package/extensions/gentle-ai.ts
CHANGED
|
@@ -104,7 +104,9 @@ import {
|
|
|
104
104
|
nativeReviewLegacyAliasRepairAuthorization,
|
|
105
105
|
nativeReviewLegacyQuarantineAuthorization,
|
|
106
106
|
nativeReviewReconcileAuthorization,
|
|
107
|
+
normalizeNativeReviewCwd,
|
|
107
108
|
NativeReviewCliError,
|
|
109
|
+
NativeReviewConsentBindingError,
|
|
108
110
|
NativeReviewConsentRequiredError,
|
|
109
111
|
NATIVE_REVIEW_ERROR_CODE,
|
|
110
112
|
NATIVE_REVIEW_LEGACY_QUARANTINE,
|
|
@@ -2872,7 +2874,7 @@ export function resolveReviewLifecycleCommand(
|
|
|
2872
2874
|
}
|
|
2873
2875
|
if (words[0] !== "git") return null;
|
|
2874
2876
|
const gitGlobalArgs: string[] = [];
|
|
2875
|
-
let resolvedCwd = resolve(defaultCwd);
|
|
2877
|
+
let resolvedCwd = resolve(normalizeNativeReviewCwd(defaultCwd));
|
|
2876
2878
|
let index = 1;
|
|
2877
2879
|
while (index < words.length) {
|
|
2878
2880
|
const option = words[index];
|
|
@@ -2880,7 +2882,7 @@ export function resolveReviewLifecycleCommand(
|
|
|
2880
2882
|
const value = words[index + 1];
|
|
2881
2883
|
if (value === undefined) return null;
|
|
2882
2884
|
gitGlobalArgs.push(option, value);
|
|
2883
|
-
if (option === "-C") resolvedCwd = resolve(resolvedCwd, value);
|
|
2885
|
+
if (option === "-C") resolvedCwd = resolve(resolvedCwd, normalizeNativeReviewCwd(value));
|
|
2884
2886
|
else return null;
|
|
2885
2887
|
index += 2;
|
|
2886
2888
|
continue;
|
|
@@ -3917,6 +3919,14 @@ function asNativeReviewCliError(error: unknown): { code: string; diagnostics: Na
|
|
|
3917
3919
|
return diagnostics === undefined || value.code !== diagnostics.error_code ? undefined : { code: value.code, diagnostics };
|
|
3918
3920
|
}
|
|
3919
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
|
+
|
|
3920
3930
|
function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown): Record<string, unknown> {
|
|
3921
3931
|
const cliError = asNativeReviewCliError(error);
|
|
3922
3932
|
if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) return nativeStatusUnsupported(operation);
|
|
@@ -4550,6 +4560,7 @@ const PENDING_REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
|
|
|
4550
4560
|
interface PendingReviewConsent {
|
|
4551
4561
|
id: string;
|
|
4552
4562
|
repositoryCwd: string;
|
|
4563
|
+
authorityCwd: string;
|
|
4553
4564
|
candidateView: CandidateView;
|
|
4554
4565
|
consent: ReviewConsentV2;
|
|
4555
4566
|
consentDigest: string;
|
|
@@ -4576,6 +4587,19 @@ function reviewConsentDigest(consent: ReviewConsentV2): string {
|
|
|
4576
4587
|
return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
|
|
4577
4588
|
}
|
|
4578
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");
|
|
4600
|
+
}
|
|
4601
|
+
}
|
|
4602
|
+
|
|
4579
4603
|
function completeNativeStart(
|
|
4580
4604
|
operation: ReviewControllerOperation,
|
|
4581
4605
|
result: NativeStartResult,
|
|
@@ -4623,6 +4647,23 @@ function nativeOperationFailure(operation: ReviewControllerOperation, error: unk
|
|
|
4623
4647
|
...(typeof value.failureEnvelope.nextAction === "string" ? { next_action: value.failureEnvelope.nextAction } : {}),
|
|
4624
4648
|
};
|
|
4625
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
|
+
}
|
|
4626
4667
|
const mutationOutcome = value.mutationOutcome === "unknown" ? "unknown" : "none";
|
|
4627
4668
|
const nativeDiagnostics = asNativeReviewCliError(error)?.diagnostics;
|
|
4628
4669
|
const diagnostics = operation === REVIEW_CONTROLLER_OPERATION.START && error instanceof CandidateViewError && value.candidateViewPreNative === true
|
|
@@ -5078,7 +5119,7 @@ async function executeReviewControllerOperation(
|
|
|
5078
5119
|
consumePendingReviewConsent(pending, pendingReviewConsents);
|
|
5079
5120
|
try {
|
|
5080
5121
|
const answered = await nativeReviewCli.answerConsent({
|
|
5081
|
-
cwd: pending.
|
|
5122
|
+
cwd: pending.authorityCwd,
|
|
5082
5123
|
consent: pending.consent,
|
|
5083
5124
|
answer: input.answer,
|
|
5084
5125
|
...(signal === undefined ? {} : { signal }),
|
|
@@ -5098,7 +5139,7 @@ async function executeReviewControllerOperation(
|
|
|
5098
5139
|
const value = error as { mutationOutcome?: unknown };
|
|
5099
5140
|
if (value.mutationOutcome === "none") candidateViews?.cleanup(pending.candidateView.token);
|
|
5100
5141
|
return await reconcileNativeMutationFailure(parameters.operation, error, nativeReviewCli, {
|
|
5101
|
-
cwd: pending.
|
|
5142
|
+
cwd: pending.authorityCwd,
|
|
5102
5143
|
...(pending.candidateView.committedOnly ? { baseRef: pending.candidateView.baseCommit } : {}),
|
|
5103
5144
|
projection: "workspace",
|
|
5104
5145
|
});
|
|
@@ -5137,8 +5178,9 @@ async function executeReviewControllerOperation(
|
|
|
5137
5178
|
return nativeStartRejection("base-ref-unresolvable");
|
|
5138
5179
|
}
|
|
5139
5180
|
}
|
|
5181
|
+
let target: ReviewStatusV3;
|
|
5140
5182
|
try {
|
|
5141
|
-
|
|
5183
|
+
target = await nativeReviewCli.targetStatus({
|
|
5142
5184
|
cwd: defaultCwd,
|
|
5143
5185
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5144
5186
|
...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef }),
|
|
@@ -5153,14 +5195,17 @@ async function executeReviewControllerOperation(
|
|
|
5153
5195
|
let nativeStartAttempted = false;
|
|
5154
5196
|
try {
|
|
5155
5197
|
candidateView = candidateViews?.createOrReuse({ contributorRoot: defaultCwd, replayKey, ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }) });
|
|
5156
|
-
|
|
5198
|
+
if (candidateView !== undefined) assertNativeStartCandidateBinding(candidateView, target);
|
|
5157
5199
|
let result: NativeStartResult;
|
|
5158
5200
|
try {
|
|
5201
|
+
nativeStartAttempted = true;
|
|
5159
5202
|
result = await nativeReviewCli.start({
|
|
5160
|
-
cwd:
|
|
5203
|
+
cwd: defaultCwd,
|
|
5161
5204
|
...(canonicalBaseRef === undefined
|
|
5162
5205
|
? {}
|
|
5163
5206
|
: { baseRef: candidateView?.baseCommit ?? canonicalBaseRef, committedOnly: true }),
|
|
5207
|
+
targetIdentity: target.targetIdentity,
|
|
5208
|
+
projection: target.projection.projection,
|
|
5164
5209
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5165
5210
|
...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
|
|
5166
5211
|
...(signal === undefined ? {} : { signal }),
|
|
@@ -5175,7 +5220,7 @@ async function executeReviewControllerOperation(
|
|
|
5175
5220
|
if (existing === undefined) for (const pending of [...pendingReviewConsents.values()]) if (pending.candidateView.token === consentCandidateView.token) consumePendingReviewConsent(pending, pendingReviewConsents);
|
|
5176
5221
|
const id = existing?.id ?? randomUUID();
|
|
5177
5222
|
if (existing === undefined) {
|
|
5178
|
-
const pending: PendingReviewConsent = { id, repositoryCwd, candidateView: consentCandidateView, consent: error.consent, consentDigest, expiresAt: Date.now() + PENDING_REVIEW_CONSENT_TTL_MS };
|
|
5223
|
+
const pending: PendingReviewConsent = { id, repositoryCwd, authorityCwd: defaultCwd, candidateView: consentCandidateView, consent: error.consent, consentDigest, expiresAt: Date.now() + PENDING_REVIEW_CONSENT_TTL_MS };
|
|
5179
5224
|
pendingReviewConsents.set(id, pending);
|
|
5180
5225
|
pending.expiry = setTimeout(() => cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews), PENDING_REVIEW_CONSENT_TTL_MS);
|
|
5181
5226
|
pending.expiry.unref();
|
|
@@ -5205,7 +5250,7 @@ async function executeReviewControllerOperation(
|
|
|
5205
5250
|
nextAction: "review.status",
|
|
5206
5251
|
});
|
|
5207
5252
|
return reconcileNativeMutationFailure(parameters.operation, failure, nativeReviewCli, {
|
|
5208
|
-
cwd:
|
|
5253
|
+
cwd: defaultCwd,
|
|
5209
5254
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5210
5255
|
...(canonicalBaseRef === undefined ? {} : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef }),
|
|
5211
5256
|
projection: "workspace",
|
package/lib/native-review-cli.ts
CHANGED
|
@@ -371,7 +371,7 @@ export interface NativeReviewVerificationEvidenceV2 {
|
|
|
371
371
|
recordDigest: string;
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
-
export interface NativeStartRequest { cwd: string; baseRef?: string; committedOnly?: boolean; lineageId?: string; policyPath?: string; focus?: string; projection?: "workspace" | "staged"; signal?: AbortSignal; }
|
|
374
|
+
export interface NativeStartRequest { cwd: string; baseRef?: string; committedOnly?: boolean; lineageId?: string; policyPath?: string; focus?: string; targetIdentity?: string; projection?: "workspace" | "staged"; signal?: AbortSignal; }
|
|
375
375
|
export const NATIVE_REVIEW_CONSENT_ANSWER = { GRANTED: "granted", DECLINED: "declined" } as const;
|
|
376
376
|
export type NativeReviewConsentAnswer = (typeof NATIVE_REVIEW_CONSENT_ANSWER)[keyof typeof NATIVE_REVIEW_CONSENT_ANSWER];
|
|
377
377
|
export interface NativeReviewConsentAnswerRequest { cwd: string; consent: ReviewConsentV2; answer: NativeReviewConsentAnswer; signal?: AbortSignal; }
|
|
@@ -1037,6 +1037,21 @@ function decodeNativeReviewStatus(value: unknown): NativeReviewStatusResult {
|
|
|
1037
1037
|
};
|
|
1038
1038
|
}
|
|
1039
1039
|
function isWindowsRepositoryPath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value); }
|
|
1040
|
+
export function normalizeNativeReviewCwd(value: string, platform: NodeJS.Platform = process.platform): string {
|
|
1041
|
+
if (platform !== "win32") return value;
|
|
1042
|
+
const gitBashDrive = /^\/([A-Za-z])(?:\/(.*))?$/.exec(value);
|
|
1043
|
+
const windowsPath = gitBashDrive === null
|
|
1044
|
+
? value
|
|
1045
|
+
: `${gitBashDrive[1]!.toUpperCase()}:/${gitBashDrive[2] ?? ""}`;
|
|
1046
|
+
if (!isWindowsRepositoryPath(windowsPath)) return windowsPath;
|
|
1047
|
+
const normalized = win32.normalize(windowsPath);
|
|
1048
|
+
return normalized.replace(/^([a-z]):/, (_match, drive: string) => `${drive.toUpperCase()}:`);
|
|
1049
|
+
}
|
|
1050
|
+
async function canonicalNativeReviewCwd(value: string): Promise<string> {
|
|
1051
|
+
const normalized = normalizeNativeReviewCwd(value);
|
|
1052
|
+
try { return await realpath(normalized); }
|
|
1053
|
+
catch { return normalized; }
|
|
1054
|
+
}
|
|
1040
1055
|
async function repositoryPathIdentity(value: string): Promise<string> {
|
|
1041
1056
|
const windowsPath = isWindowsRepositoryPath(value);
|
|
1042
1057
|
try { return `filesystem:${windowsPath ? (await realpath(value)).toLowerCase() : await realpath(value)}`; }
|
|
@@ -1360,12 +1375,13 @@ export class NativeReviewCliV214 {
|
|
|
1360
1375
|
// and always pass `--scope clone` so Pi's own kill-switch command surface
|
|
1361
1376
|
// never mutates the operator's global gentle-ai state across other clones.
|
|
1362
1377
|
async reviewMode(request: NativeReviewModeRequest): Promise<NativeReviewModeResult> {
|
|
1363
|
-
await
|
|
1378
|
+
const cwd = await canonicalNativeReviewCwd(request.cwd);
|
|
1379
|
+
await this.verifyVersion(cwd, request.signal, ["mode"]);
|
|
1364
1380
|
const mutating = request.operation !== NATIVE_REVIEW_MODE_OPERATION.STATUS;
|
|
1365
1381
|
const { body } = await this.execute(
|
|
1366
1382
|
NATIVE_REVIEW_OPERATION.MODE,
|
|
1367
|
-
|
|
1368
|
-
["review", "mode", request.operation, "--cwd",
|
|
1383
|
+
cwd,
|
|
1384
|
+
["review", "mode", request.operation, "--cwd", cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
|
|
1369
1385
|
mutating,
|
|
1370
1386
|
request.signal,
|
|
1371
1387
|
);
|
|
@@ -1654,6 +1670,23 @@ export class NativeReviewConsentRequiredError extends Error {
|
|
|
1654
1670
|
}
|
|
1655
1671
|
}
|
|
1656
1672
|
|
|
1673
|
+
// Raised when the provider-issued consent invocation no longer matches the
|
|
1674
|
+
// binding Pi is answering for. Every one of these guards runs before the
|
|
1675
|
+
// provider is launched, so the failure is local and nothing was mutated. It
|
|
1676
|
+
// carries its own identity precisely so callers never report it as a provider
|
|
1677
|
+
// outage: an opaque `native-operation-failed` here sent issue #247 chasing a
|
|
1678
|
+
// missing `--cwd` that Pi does forward.
|
|
1679
|
+
export class NativeReviewConsentBindingError extends Error {
|
|
1680
|
+
readonly reason: string;
|
|
1681
|
+
readonly launchAttempted = false;
|
|
1682
|
+
readonly mutationOutcome = "none";
|
|
1683
|
+
constructor(reason: string, message: string) {
|
|
1684
|
+
super(message);
|
|
1685
|
+
this.name = "NativeReviewConsentBindingError";
|
|
1686
|
+
this.reason = reason;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1657
1690
|
function splitNativeConsentInvocation(invocation: string): readonly string[] {
|
|
1658
1691
|
const words: string[] = [];
|
|
1659
1692
|
let current = "";
|
|
@@ -1705,26 +1738,26 @@ function exactConsentOption(arguments_: readonly string[], name: string): string
|
|
|
1705
1738
|
const token = arguments_[index]!;
|
|
1706
1739
|
if (token === name) {
|
|
1707
1740
|
const value = arguments_[index + 1];
|
|
1708
|
-
if (value === undefined) throw new
|
|
1741
|
+
if (value === undefined) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation ${name} is missing its value`);
|
|
1709
1742
|
values.push(value);
|
|
1710
1743
|
index += 1;
|
|
1711
1744
|
} else if (token.startsWith(`${name}=`)) values.push(token.slice(name.length + 1));
|
|
1712
1745
|
}
|
|
1713
|
-
if (values.length !== 1) throw new
|
|
1746
|
+
if (values.length !== 1) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation requires exactly one ${name}`);
|
|
1714
1747
|
return values[0]!;
|
|
1715
1748
|
}
|
|
1716
1749
|
|
|
1717
1750
|
function consentInvocationArguments(request: NativeReviewConsentAnswerRequest): readonly string[] {
|
|
1718
1751
|
const choice = request.consent.choices.find((candidate) => candidate.answer === request.answer);
|
|
1719
|
-
if (choice === undefined) throw new
|
|
1752
|
+
if (choice === undefined) throw new NativeReviewConsentBindingError("consent-answer-unknown", "Native consent answer must be granted or declined");
|
|
1720
1753
|
const words = splitNativeConsentInvocation(choice.invocation);
|
|
1721
|
-
if (words[0] !== "gentle-ai" || words[1] !== "review" || words[2] !== "start") throw new
|
|
1754
|
+
if (words[0] !== "gentle-ai" || words[1] !== "review" || words[2] !== "start") throw new NativeReviewConsentBindingError("consent-invocation-not-start", "Native consent invocation is not a provider review START");
|
|
1722
1755
|
const arguments_ = words.slice(1);
|
|
1723
|
-
if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new
|
|
1724
|
-
if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new
|
|
1725
|
-
if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new
|
|
1726
|
-
if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new
|
|
1727
|
-
if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new
|
|
1756
|
+
if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new NativeReviewConsentBindingError("consent-invocation-contract-changed", "Native consent invocation contract changed");
|
|
1757
|
+
if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
|
|
1758
|
+
if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new NativeReviewConsentBindingError("consent-invocation-target-changed", "Native consent invocation target binding changed");
|
|
1759
|
+
if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new NativeReviewConsentBindingError("consent-invocation-projection-changed", "Native consent invocation projection binding changed");
|
|
1760
|
+
if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new NativeReviewConsentBindingError("consent-invocation-answer-changed", "Native consent invocation answer binding changed");
|
|
1728
1761
|
return arguments_;
|
|
1729
1762
|
}
|
|
1730
1763
|
|
|
@@ -1937,24 +1970,21 @@ export class NativeReviewCliV216 implements NativeReviewCli {
|
|
|
1937
1970
|
if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native START baseRef must be a non-empty, trimmed, NUL-free string");
|
|
1938
1971
|
if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement");
|
|
1939
1972
|
if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native START committedOnly requires an explicit baseRef");
|
|
1940
|
-
|
|
1941
|
-
//
|
|
1942
|
-
//
|
|
1943
|
-
//
|
|
1944
|
-
// a target frozen from the wrong root would name a snapshot this START
|
|
1945
|
-
// never inspects. targetStatus is read-only, so this costs one extra
|
|
1946
|
-
// read and cannot create authority.
|
|
1973
|
+
if (request.targetIdentity !== undefined && !/^sha256:[0-9a-f]{64}$/.test(request.targetIdentity)) throw new TypeError("Native START targetIdentity must be a canonical sha256 identity");
|
|
1974
|
+
// The controller supplies the target it already projected from the
|
|
1975
|
+
// authority workspace after proving its immutable actor view is identical.
|
|
1976
|
+
// Direct adapter callers may omit it and retain the same-root projection.
|
|
1947
1977
|
const projection = request.projection ?? "workspace";
|
|
1948
|
-
const
|
|
1978
|
+
const targetIdentity = request.targetIdentity ?? (await this.targetStatus({
|
|
1949
1979
|
cwd: request.cwd,
|
|
1950
1980
|
projection,
|
|
1951
1981
|
...(request.baseRef === undefined ? {} : { baseRef: request.baseRef }),
|
|
1952
1982
|
...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }),
|
|
1953
1983
|
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
1954
|
-
});
|
|
1984
|
+
})).targetIdentity;
|
|
1955
1985
|
const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, [
|
|
1956
1986
|
"review", "start", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
|
|
1957
|
-
"--target",
|
|
1987
|
+
"--target", targetIdentity, "--projection", projection,
|
|
1958
1988
|
...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]),
|
|
1959
1989
|
...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
|
|
1960
1990
|
...(request.policyPath === undefined ? [] : ["--policy", request.policyPath]),
|
|
@@ -1966,10 +1996,14 @@ export class NativeReviewCliV216 implements NativeReviewCli {
|
|
|
1966
1996
|
// explicit answer it cannot infer. Discriminate before decode and surface
|
|
1967
1997
|
// the complete envelope; only the caller can map a human answer.
|
|
1968
1998
|
if (execution.body.action === "consent_required") {
|
|
1969
|
-
|
|
1999
|
+
const consent = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body));
|
|
2000
|
+
if (consent.targetIdentity !== targetIdentity || consent.projection !== projection) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent target binding mismatch");
|
|
2001
|
+
throw new NativeReviewConsentRequiredError(consent);
|
|
1970
2002
|
}
|
|
1971
2003
|
const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
|
|
1972
2004
|
if (request.lineageId !== undefined && result.lineageId !== request.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start lineage mismatch");
|
|
2005
|
+
const resultTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
|
|
2006
|
+
if (resultTarget !== undefined && resultTarget !== targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start target mismatch");
|
|
1973
2007
|
return {
|
|
1974
2008
|
lineageId: result.lineageId,
|
|
1975
2009
|
state: result.state as NativeStartResult["state"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -1038,6 +1038,21 @@ function decodeNativeReviewStatus(value ) {
|
|
|
1038
1038
|
};
|
|
1039
1039
|
}
|
|
1040
1040
|
function isWindowsRepositoryPath(value ) { return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value); }
|
|
1041
|
+
export function normalizeNativeReviewCwd(value , platform = process.platform) {
|
|
1042
|
+
if (platform !== "win32") return value;
|
|
1043
|
+
const gitBashDrive = /^\/([A-Za-z])(?:\/(.*))?$/.exec(value);
|
|
1044
|
+
const windowsPath = gitBashDrive === null
|
|
1045
|
+
? value
|
|
1046
|
+
: `${gitBashDrive[1] .toUpperCase()}:/${gitBashDrive[2] ?? ""}`;
|
|
1047
|
+
if (!isWindowsRepositoryPath(windowsPath)) return windowsPath;
|
|
1048
|
+
const normalized = win32.normalize(windowsPath);
|
|
1049
|
+
return normalized.replace(/^([a-z]):/, (_match, drive ) => `${drive.toUpperCase()}:`);
|
|
1050
|
+
}
|
|
1051
|
+
async function canonicalNativeReviewCwd(value ) {
|
|
1052
|
+
const normalized = normalizeNativeReviewCwd(value);
|
|
1053
|
+
try { return await realpath(normalized); }
|
|
1054
|
+
catch { return normalized; }
|
|
1055
|
+
}
|
|
1041
1056
|
async function repositoryPathIdentity(value ) {
|
|
1042
1057
|
const windowsPath = isWindowsRepositoryPath(value);
|
|
1043
1058
|
try { return `filesystem:${windowsPath ? (await realpath(value)).toLowerCase() : await realpath(value)}`; }
|
|
@@ -1361,12 +1376,13 @@ export class NativeReviewCliV214 {
|
|
|
1361
1376
|
// and always pass `--scope clone` so Pi's own kill-switch command surface
|
|
1362
1377
|
// never mutates the operator's global gentle-ai state across other clones.
|
|
1363
1378
|
async reviewMode(request ) {
|
|
1364
|
-
await
|
|
1379
|
+
const cwd = await canonicalNativeReviewCwd(request.cwd);
|
|
1380
|
+
await this.verifyVersion(cwd, request.signal, ["mode"]);
|
|
1365
1381
|
const mutating = request.operation !== NATIVE_REVIEW_MODE_OPERATION.STATUS;
|
|
1366
1382
|
const { body } = await this.execute(
|
|
1367
1383
|
NATIVE_REVIEW_OPERATION.MODE,
|
|
1368
|
-
|
|
1369
|
-
["review", "mode", request.operation, "--cwd",
|
|
1384
|
+
cwd,
|
|
1385
|
+
["review", "mode", request.operation, "--cwd", cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
|
|
1370
1386
|
mutating,
|
|
1371
1387
|
request.signal,
|
|
1372
1388
|
);
|
|
@@ -1655,6 +1671,23 @@ export class NativeReviewConsentRequiredError extends Error {
|
|
|
1655
1671
|
}
|
|
1656
1672
|
}
|
|
1657
1673
|
|
|
1674
|
+
// Raised when the provider-issued consent invocation no longer matches the
|
|
1675
|
+
// binding Pi is answering for. Every one of these guards runs before the
|
|
1676
|
+
// provider is launched, so the failure is local and nothing was mutated. It
|
|
1677
|
+
// carries its own identity precisely so callers never report it as a provider
|
|
1678
|
+
// outage: an opaque `native-operation-failed` here sent issue #247 chasing a
|
|
1679
|
+
// missing `--cwd` that Pi does forward.
|
|
1680
|
+
export class NativeReviewConsentBindingError extends Error {
|
|
1681
|
+
reason ;
|
|
1682
|
+
launchAttempted = false;
|
|
1683
|
+
mutationOutcome = "none";
|
|
1684
|
+
constructor(reason , message ) {
|
|
1685
|
+
super(message);
|
|
1686
|
+
this.name = "NativeReviewConsentBindingError";
|
|
1687
|
+
this.reason = reason;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1658
1691
|
function splitNativeConsentInvocation(invocation ) {
|
|
1659
1692
|
const words = [];
|
|
1660
1693
|
let current = "";
|
|
@@ -1706,26 +1739,26 @@ function exactConsentOption(arguments_ , name )
|
|
|
1706
1739
|
const token = arguments_[index] ;
|
|
1707
1740
|
if (token === name) {
|
|
1708
1741
|
const value = arguments_[index + 1];
|
|
1709
|
-
if (value === undefined) throw new
|
|
1742
|
+
if (value === undefined) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation ${name} is missing its value`);
|
|
1710
1743
|
values.push(value);
|
|
1711
1744
|
index += 1;
|
|
1712
1745
|
} else if (token.startsWith(`${name}=`)) values.push(token.slice(name.length + 1));
|
|
1713
1746
|
}
|
|
1714
|
-
if (values.length !== 1) throw new
|
|
1747
|
+
if (values.length !== 1) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation requires exactly one ${name}`);
|
|
1715
1748
|
return values[0] ;
|
|
1716
1749
|
}
|
|
1717
1750
|
|
|
1718
1751
|
function consentInvocationArguments(request ) {
|
|
1719
1752
|
const choice = request.consent.choices.find((candidate) => candidate.answer === request.answer);
|
|
1720
|
-
if (choice === undefined) throw new
|
|
1753
|
+
if (choice === undefined) throw new NativeReviewConsentBindingError("consent-answer-unknown", "Native consent answer must be granted or declined");
|
|
1721
1754
|
const words = splitNativeConsentInvocation(choice.invocation);
|
|
1722
|
-
if (words[0] !== "gentle-ai" || words[1] !== "review" || words[2] !== "start") throw new
|
|
1755
|
+
if (words[0] !== "gentle-ai" || words[1] !== "review" || words[2] !== "start") throw new NativeReviewConsentBindingError("consent-invocation-not-start", "Native consent invocation is not a provider review START");
|
|
1723
1756
|
const arguments_ = words.slice(1);
|
|
1724
|
-
if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new
|
|
1725
|
-
if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new
|
|
1726
|
-
if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new
|
|
1727
|
-
if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new
|
|
1728
|
-
if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new
|
|
1757
|
+
if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new NativeReviewConsentBindingError("consent-invocation-contract-changed", "Native consent invocation contract changed");
|
|
1758
|
+
if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
|
|
1759
|
+
if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new NativeReviewConsentBindingError("consent-invocation-target-changed", "Native consent invocation target binding changed");
|
|
1760
|
+
if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new NativeReviewConsentBindingError("consent-invocation-projection-changed", "Native consent invocation projection binding changed");
|
|
1761
|
+
if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new NativeReviewConsentBindingError("consent-invocation-answer-changed", "Native consent invocation answer binding changed");
|
|
1729
1762
|
return arguments_;
|
|
1730
1763
|
}
|
|
1731
1764
|
|
|
@@ -1938,24 +1971,21 @@ export class NativeReviewCliV216 {
|
|
|
1938
1971
|
if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native START baseRef must be a non-empty, trimmed, NUL-free string");
|
|
1939
1972
|
if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement");
|
|
1940
1973
|
if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native START committedOnly requires an explicit baseRef");
|
|
1941
|
-
|
|
1942
|
-
//
|
|
1943
|
-
//
|
|
1944
|
-
//
|
|
1945
|
-
// a target frozen from the wrong root would name a snapshot this START
|
|
1946
|
-
// never inspects. targetStatus is read-only, so this costs one extra
|
|
1947
|
-
// read and cannot create authority.
|
|
1974
|
+
if (request.targetIdentity !== undefined && !/^sha256:[0-9a-f]{64}$/.test(request.targetIdentity)) throw new TypeError("Native START targetIdentity must be a canonical sha256 identity");
|
|
1975
|
+
// The controller supplies the target it already projected from the
|
|
1976
|
+
// authority workspace after proving its immutable actor view is identical.
|
|
1977
|
+
// Direct adapter callers may omit it and retain the same-root projection.
|
|
1948
1978
|
const projection = request.projection ?? "workspace";
|
|
1949
|
-
const
|
|
1979
|
+
const targetIdentity = request.targetIdentity ?? (await this.targetStatus({
|
|
1950
1980
|
cwd: request.cwd,
|
|
1951
1981
|
projection,
|
|
1952
1982
|
...(request.baseRef === undefined ? {} : { baseRef: request.baseRef }),
|
|
1953
1983
|
...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }),
|
|
1954
1984
|
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
1955
|
-
});
|
|
1985
|
+
})).targetIdentity;
|
|
1956
1986
|
const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, [
|
|
1957
1987
|
"review", "start", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
|
|
1958
|
-
"--target",
|
|
1988
|
+
"--target", targetIdentity, "--projection", projection,
|
|
1959
1989
|
...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]),
|
|
1960
1990
|
...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
|
|
1961
1991
|
...(request.policyPath === undefined ? [] : ["--policy", request.policyPath]),
|
|
@@ -1967,10 +1997,14 @@ export class NativeReviewCliV216 {
|
|
|
1967
1997
|
// explicit answer it cannot infer. Discriminate before decode and surface
|
|
1968
1998
|
// the complete envelope; only the caller can map a human answer.
|
|
1969
1999
|
if (execution.body.action === "consent_required") {
|
|
1970
|
-
|
|
2000
|
+
const consent = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body));
|
|
2001
|
+
if (consent.targetIdentity !== targetIdentity || consent.projection !== projection) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent target binding mismatch");
|
|
2002
|
+
throw new NativeReviewConsentRequiredError(consent);
|
|
1971
2003
|
}
|
|
1972
2004
|
const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
|
|
1973
2005
|
if (request.lineageId !== undefined && result.lineageId !== request.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start lineage mismatch");
|
|
2006
|
+
const resultTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
|
|
2007
|
+
if (resultTarget !== undefined && resultTarget !== targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start target mismatch");
|
|
1974
2008
|
return {
|
|
1975
2009
|
lineageId: result.lineageId,
|
|
1976
2010
|
state: result.state ,
|
|
@@ -5,6 +5,7 @@ import test from "node:test";
|
|
|
5
5
|
import { GENTLE_AI_VERSION } from "../lib/gentle-ai-binary.ts";
|
|
6
6
|
import {
|
|
7
7
|
NativeReviewCliV216,
|
|
8
|
+
NativeReviewConsentBindingError,
|
|
8
9
|
NativeReviewConsentRequiredError,
|
|
9
10
|
clearNativeReviewCapabilitiesCacheForTesting,
|
|
10
11
|
type ExecFileAdapter,
|
|
@@ -78,6 +79,22 @@ test("negotiated ordinary START declares relay and preserves the complete target
|
|
|
78
79
|
"review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
|
|
79
80
|
"--target", target, "--projection", "workspace", "--consent", "relay",
|
|
80
81
|
]);
|
|
82
|
+
assert.equal(queue.calls.some((arguments_) => arguments_[1] === "status"), true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("controller-prebound START target is used without projecting a second workspace candidate", async () => {
|
|
86
|
+
const consent = fixture<Record<string, unknown>>("consent.fixture.json");
|
|
87
|
+
const target = String(consent.target_identity);
|
|
88
|
+
const queue = queuedAdapter([capabilities(), consent]);
|
|
89
|
+
await assert.rejects(
|
|
90
|
+
() => client(queue.adapter).start({ cwd: "/repo", targetIdentity: target, projection: "workspace" }),
|
|
91
|
+
(error: unknown) => error instanceof NativeReviewConsentRequiredError,
|
|
92
|
+
);
|
|
93
|
+
assert.deepEqual(queue.calls.at(-1), [
|
|
94
|
+
"review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
|
|
95
|
+
"--target", target, "--projection", "workspace", "--consent", "relay",
|
|
96
|
+
]);
|
|
97
|
+
assert.equal(queue.calls.some((arguments_) => arguments_[1] === "status"), false, "a prebound START target must not be projected again");
|
|
81
98
|
});
|
|
82
99
|
|
|
83
100
|
test("consent follow-up executes the provider-named invocation exactly once and refuses a changed target binding", async () => {
|
|
@@ -100,6 +117,66 @@ test("consent follow-up executes the provider-named invocation exactly once and
|
|
|
100
117
|
);
|
|
101
118
|
});
|
|
102
119
|
|
|
120
|
+
// A binding mismatch is decided entirely inside Pi, before the provider is
|
|
121
|
+
// launched, so it must not be reported as a provider failure (issue #247).
|
|
122
|
+
test("a consent invocation binding mismatch is a typed pre-native error that never launches the provider", async () => {
|
|
123
|
+
const consent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
|
|
124
|
+
const queue = queuedAdapter([]);
|
|
125
|
+
await assert.rejects(
|
|
126
|
+
() => client(queue.adapter).answerConsent!({ cwd: "/repo/.git/gentle-ai/candidate-views/a1c7fdae", consent, answer: "granted" }),
|
|
127
|
+
(error: unknown) => {
|
|
128
|
+
assert.ok(error instanceof NativeReviewConsentBindingError);
|
|
129
|
+
assert.equal(error.name, "NativeReviewConsentBindingError");
|
|
130
|
+
assert.equal(error.reason, "consent-invocation-cwd-changed");
|
|
131
|
+
assert.equal(error.launchAttempted, false);
|
|
132
|
+
assert.equal(error.mutationOutcome, "none");
|
|
133
|
+
assert.match(error.message, /repository binding changed/);
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
assert.deepEqual(queue.calls, []);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// `decodeReviewConsentV2` already rejects a malformed invocation, so these
|
|
141
|
+
// guards defend against a consent object that drifted after decoding. Each one
|
|
142
|
+
// must still name itself rather than collapse into a generic failure.
|
|
143
|
+
test("every consent invocation binding guard reports its own reason without launching the provider", async () => {
|
|
144
|
+
const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
|
|
145
|
+
const drifted = (mutate: (consent: ReviewConsentV2) => void): ReviewConsentV2 => {
|
|
146
|
+
const value = structuredClone(decoded);
|
|
147
|
+
mutate(value);
|
|
148
|
+
return value;
|
|
149
|
+
};
|
|
150
|
+
const rewriteGranted = (consent: ReviewConsentV2, replace: (invocation: string) => string): void => {
|
|
151
|
+
const choice = consent.choices.find((candidate) => candidate.answer === "granted") as { invocation: string };
|
|
152
|
+
choice.invocation = replace(choice.invocation);
|
|
153
|
+
};
|
|
154
|
+
const cases = [
|
|
155
|
+
{
|
|
156
|
+
reason: "consent-answer-unknown",
|
|
157
|
+
consent: drifted((consent) => { (consent as { choices: unknown }).choices = consent.choices.filter((choice) => choice.answer !== "granted"); }),
|
|
158
|
+
},
|
|
159
|
+
{ reason: "consent-invocation-not-start", consent: drifted((consent) => rewriteGranted(consent, (value) => value.replace("review start", "review finalize"))) },
|
|
160
|
+
{ reason: "consent-invocation-contract-changed", consent: drifted((consent) => rewriteGranted(consent, (value) => value.replace("gentle-ai.review-integration/v2", "gentle-ai.review-integration/v1"))) },
|
|
161
|
+
{ reason: "consent-invocation-target-changed", consent: drifted((consent) => { (consent as { targetIdentity: string }).targetIdentity = `sha256:${"c".repeat(64)}`; }) },
|
|
162
|
+
{ reason: "consent-invocation-projection-changed", consent: drifted((consent) => { (consent as { projection: string }).projection = "staged"; }) },
|
|
163
|
+
{ reason: "consent-invocation-answer-changed", consent: drifted((consent) => rewriteGranted(consent, (value) => value.replace("--consent granted", "--consent declined"))) },
|
|
164
|
+
{ reason: "consent-invocation-option-invalid", consent: drifted((consent) => rewriteGranted(consent, (value) => `${value} --consent granted`)) },
|
|
165
|
+
] as const;
|
|
166
|
+
for (const scenario of cases) {
|
|
167
|
+
const queue = queuedAdapter([]);
|
|
168
|
+
await assert.rejects(
|
|
169
|
+
() => client(queue.adapter).answerConsent!({ cwd: "/repo", consent: scenario.consent, answer: "granted" }),
|
|
170
|
+
(error: unknown) => {
|
|
171
|
+
assert.ok(error instanceof NativeReviewConsentBindingError, `${scenario.reason} must be a typed binding error`);
|
|
172
|
+
assert.equal(error.reason, scenario.reason);
|
|
173
|
+
return true;
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
assert.deepEqual(queue.calls, [], `${scenario.reason} must not launch the provider`);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
103
180
|
test("declined consent decodes the provider's explicit empty authority fields without creating a lineage", async () => {
|
|
104
181
|
const rawConsent = fixture<Record<string, unknown>>("consent.fixture.json");
|
|
105
182
|
const consent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(rawConsent);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
@@ -9,12 +9,16 @@ import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
|
9
9
|
import {
|
|
10
10
|
NATIVE_REVIEW_ERROR_CODE,
|
|
11
11
|
NativeReviewCliError,
|
|
12
|
+
NativeReviewConsentBindingError,
|
|
12
13
|
NativeReviewConsentRequiredError,
|
|
13
14
|
NativeReviewCliV213 as NativeReviewCliV213Production,
|
|
15
|
+
normalizeNativeReviewCwd,
|
|
14
16
|
setNativeCliContractForTesting,
|
|
15
17
|
type ExecFileAdapter,
|
|
16
18
|
type NativeReviewCli,
|
|
17
19
|
type NativeReviewConsentAnswer,
|
|
20
|
+
type NativeReviewConsentAnswerRequest,
|
|
21
|
+
type NativeStartRequest,
|
|
18
22
|
} from "../lib/native-review-cli.ts";
|
|
19
23
|
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
20
24
|
import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
|
|
@@ -112,6 +116,26 @@ test("reviewMode status uses the exact fixed argv and decodes the effective mode
|
|
|
112
116
|
assert.equal(result.scope, "both");
|
|
113
117
|
});
|
|
114
118
|
|
|
119
|
+
test("reviewMode canonicalizes an existing repository cwd before the version probe and status argv", async (t) => {
|
|
120
|
+
if (process.platform === "win32") return t.skip("directory symlink creation requires elevated Windows privileges");
|
|
121
|
+
const repository = mkdtempSync(join(tmpdir(), "gentle-pi-review-mode-cwd-"));
|
|
122
|
+
const alias = `${repository}-alias`;
|
|
123
|
+
symlinkSync(repository, alias, "dir");
|
|
124
|
+
t.after(() => { rmSync(alias, { force: true }); rmSync(repository, { recursive: true, force: true }); });
|
|
125
|
+
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("off", { global: "off", source: "global" })) }]);
|
|
126
|
+
const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: alias, operation: "status" });
|
|
127
|
+
assert.equal(result.status.effective, "off");
|
|
128
|
+
assert.equal(queue.calls.every((call) => call.cwd === repository), true);
|
|
129
|
+
assert.deepEqual(queue.calls[1]?.arguments, ["review", "mode", "status", "--cwd", repository, "--json"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("native review cwd normalization unifies Git Bash and drive-form Windows paths", () => {
|
|
133
|
+
const expected = "C:\\Users\\Alan\\worktree B";
|
|
134
|
+
assert.equal(normalizeNativeReviewCwd("/c/Users/Alan/worktree B", "win32"), expected);
|
|
135
|
+
assert.equal(normalizeNativeReviewCwd("c:/Users/Alan/worktree B", "win32"), expected);
|
|
136
|
+
assert.equal(normalizeNativeReviewCwd("/c/Users/Alan/worktree B", "linux"), "/c/Users/Alan/worktree B");
|
|
137
|
+
});
|
|
138
|
+
|
|
115
139
|
test("reviewMode status decodes an off effective mode with its deciding source", async () => {
|
|
116
140
|
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("off", { clone_local: "off", source: "clone_local", revision: "sha256:deadbeef" })) }]);
|
|
117
141
|
const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" });
|
|
@@ -292,18 +316,22 @@ const UNSUPPORTED_REPAIR_ASSESSMENT: AuthorityRepairAssessmentV1 = {
|
|
|
292
316
|
authorizationSchema: "gentle-ai.review-repair-authorization/v1",
|
|
293
317
|
};
|
|
294
318
|
|
|
295
|
-
function unrelatedStartTargetStatus(): ReviewStatusV3 {
|
|
319
|
+
function unrelatedStartTargetStatus(cwd: string): ReviewStatusV3 {
|
|
296
320
|
const sha = `sha256:${"a".repeat(64)}`;
|
|
297
|
-
const
|
|
321
|
+
const candidate = new CandidateViewRegistry().create({ contributorRoot: cwd });
|
|
322
|
+
const tree = candidate.candidateTree;
|
|
323
|
+
const baseTree = candidate.baseTree;
|
|
324
|
+
const paths = candidate.paths;
|
|
325
|
+
candidate.cleanup();
|
|
298
326
|
const projection = {
|
|
299
327
|
schema: "gentle-ai.review-integration.projection/v1" as const,
|
|
300
328
|
kind: "current-changes" as const,
|
|
301
329
|
projection: "workspace" as const,
|
|
302
|
-
baseTree
|
|
330
|
+
baseTree,
|
|
303
331
|
initialReviewTree: tree,
|
|
304
332
|
currentCandidateTree: tree,
|
|
305
333
|
pathsDigest: sha,
|
|
306
|
-
paths
|
|
334
|
+
paths,
|
|
307
335
|
intendedUntracked: [],
|
|
308
336
|
intendedUntrackedProof: sha,
|
|
309
337
|
initialSnapshotIdentity: sha,
|
|
@@ -333,7 +361,7 @@ function unrelatedStartTargetStatus(): ReviewStatusV3 {
|
|
|
333
361
|
schema: "gentle-ai.review-integration.status/v3", contract: "gentle-ai.review-integration/v2", operation: "review.status",
|
|
334
362
|
applicability: "unrelated", receipt: { status: "not_applicable" }, action: "start", replayability: "not_replayable", target_identity: sha,
|
|
335
363
|
repair: rawRepair,
|
|
336
|
-
projection: { schema: projection.schema, kind: projection.kind, projection: projection.projection, base_tree:
|
|
364
|
+
projection: { schema: projection.schema, kind: projection.kind, projection: projection.projection, base_tree: baseTree, initial_review_tree: tree, current_candidate_tree: tree, paths_digest: sha, paths, intended_untracked: [], intended_untracked_proof: sha, initial_snapshot_identity: sha, current_snapshot_identity: sha },
|
|
337
365
|
candidates: [],
|
|
338
366
|
},
|
|
339
367
|
};
|
|
@@ -369,8 +397,8 @@ function fakeOrganicNative(options: FakeOrganicNativeOptions = {}): { native: Na
|
|
|
369
397
|
async bindSdd(): Promise<never> { throw new Error("bindSdd not used in this test"); },
|
|
370
398
|
async sddStatus(): Promise<never> { throw new Error("sddStatus not used in this test"); },
|
|
371
399
|
async reviewStatus(): Promise<never> { throw new Error("reviewStatus not used in this test"); },
|
|
372
|
-
async targetStatus() {
|
|
373
|
-
return unrelatedStartTargetStatus();
|
|
400
|
+
async targetStatus(request: { cwd: string }) {
|
|
401
|
+
return unrelatedStartTargetStatus(request.cwd);
|
|
374
402
|
},
|
|
375
403
|
...(reviewModeCapable
|
|
376
404
|
? {
|
|
@@ -534,17 +562,23 @@ function candidateConsent(cwd: string): ReviewConsentV2 {
|
|
|
534
562
|
return { schema: "gentle-ai.review-integration.consent/v2", contract: "gentle-ai.review-integration/v2", operation: "review.start", action: "consent_required", blocking: true, targetIdentity, projection: "workspace", riskLevel: "high", changedFiles: 1, changedLines: 1, headline: "Review this candidate", reason: "It changes a process boundary.", value: "Review catches regressions.", riskEvidence: ["shell process"], choices, offPath: { note: "Disable reviews separately.", command: "gentle-ai review mode disable" }, raw };
|
|
535
563
|
}
|
|
536
564
|
|
|
537
|
-
function relayedConsentNative(cwd: string): { native: NativeReviewCli; answers: NativeReviewConsentAnswer[] } {
|
|
565
|
+
function relayedConsentNative(cwd: string): { native: NativeReviewCli; answers: NativeReviewConsentAnswer[]; startRequests: NativeStartRequest[]; answerRequests: NativeReviewConsentAnswerRequest[] } {
|
|
538
566
|
const { native } = fakeOrganicNative();
|
|
539
567
|
const consent = candidateConsent(cwd);
|
|
540
568
|
const answers: NativeReviewConsentAnswer[] = [];
|
|
541
|
-
|
|
569
|
+
const startRequests: NativeStartRequest[] = [];
|
|
570
|
+
const answerRequests: NativeReviewConsentAnswerRequest[] = [];
|
|
571
|
+
native.start = async (request) => {
|
|
572
|
+
startRequests.push(request);
|
|
573
|
+
throw new NativeReviewConsentRequiredError(consent);
|
|
574
|
+
};
|
|
542
575
|
native.answerConsent = async (request) => {
|
|
543
576
|
answers.push(request.answer);
|
|
577
|
+
answerRequests.push(request);
|
|
544
578
|
if (request.answer === "declined") return { kind: "declined", targetIdentity: consent.targetIdentity, projection: "workspace", riskLevel: "high", changedFiles: 1, changedLines: 1, consent: "declined_this_candidate", raw: { operation: "review/start", action: "declined", consent: "declined_this_candidate" } };
|
|
545
579
|
return { kind: "started", start: { lineageId: "native-lineage", state: "reviewing", riskLevel: "high", selectedLenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true } };
|
|
546
580
|
};
|
|
547
|
-
return { native, answers };
|
|
581
|
+
return { native, answers, startRequests, answerRequests };
|
|
548
582
|
}
|
|
549
583
|
|
|
550
584
|
async function answerConsent(controller: RegisteredTool, binding: unknown, answer: unknown, ctx: ExtensionContext): Promise<Record<string, unknown>> {
|
|
@@ -578,13 +612,23 @@ test("consent relay returns the identical complete parent-visible envelope with
|
|
|
578
612
|
test("explicit consent follow-up grants or declines exactly once", async (t) => {
|
|
579
613
|
const cwd = repository(t);
|
|
580
614
|
for (const answer of ["granted", "declined"] as const) {
|
|
581
|
-
const { native, answers } = relayedConsentNative(cwd);
|
|
615
|
+
const { native, answers, startRequests, answerRequests } = relayedConsentNative(cwd);
|
|
582
616
|
const { controller } = runtime(native);
|
|
583
617
|
const blocked = await blockedConsent(controller, `consent-${answer}`, headlessContext(cwd));
|
|
584
618
|
const result = await answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd));
|
|
585
619
|
assert.deepEqual(answers, [answer]);
|
|
586
|
-
|
|
587
|
-
|
|
620
|
+
assert.equal(startRequests.length, 1);
|
|
621
|
+
assert.equal(startRequests[0]?.cwd, cwd);
|
|
622
|
+
assert.equal(startRequests[0]?.targetIdentity, candidateConsent(cwd).targetIdentity);
|
|
623
|
+
assert.equal(startRequests[0]?.projection, "workspace");
|
|
624
|
+
assert.equal(answerRequests.length, 1);
|
|
625
|
+
assert.equal(answerRequests[0]?.cwd, cwd);
|
|
626
|
+
assert.equal(answerRequests[0]?.consent.targetIdentity, startRequests[0]?.targetIdentity);
|
|
627
|
+
if (answer === "granted") {
|
|
628
|
+
const actorBinding = result.actor_binding as { workspace_root: string; candidate_root: string };
|
|
629
|
+
assert.equal(actorBinding.workspace_root, cwd);
|
|
630
|
+
assert.notEqual(actorBinding.candidate_root, cwd);
|
|
631
|
+
} else {
|
|
588
632
|
assert.equal(result.outcome, "consent-declined-this-candidate");
|
|
589
633
|
assert.equal(result.lineage_created, false);
|
|
590
634
|
assert.equal(result.actor_binding, undefined);
|
|
@@ -593,6 +637,28 @@ test("explicit consent follow-up grants or declines exactly once", async (t) =>
|
|
|
593
637
|
}
|
|
594
638
|
});
|
|
595
639
|
|
|
640
|
+
// Issue #247: a local binding mismatch was indistinguishable from a provider
|
|
641
|
+
// outage, so the reporter diagnosed a missing --cwd that Pi does forward.
|
|
642
|
+
test("a consent binding mismatch surfaces as an actionable local failure, not an opaque native operation failure", async (t) => {
|
|
643
|
+
const cwd = repository(t);
|
|
644
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
645
|
+
native.answerConsent = async () => {
|
|
646
|
+
throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
|
|
647
|
+
};
|
|
648
|
+
const { controller } = runtime(native);
|
|
649
|
+
const blocked = await blockedConsent(controller, "consent-binding", headlessContext(cwd));
|
|
650
|
+
const result = await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
|
|
651
|
+
assert.equal(result.status, "blocked");
|
|
652
|
+
assert.equal(result.outcome, "consent-binding-invalid");
|
|
653
|
+
assert.deepEqual(result.diagnostics, { code: "consent-invocation-cwd-changed", message: "Native consent invocation repository binding changed" });
|
|
654
|
+
assert.equal(result.native_invocation_attempted, false);
|
|
655
|
+
assert.equal(result.lineage_created, false);
|
|
656
|
+
assert.equal(result.mutation_performed, false);
|
|
657
|
+
assert.equal(result.mutation_outcome, "none");
|
|
658
|
+
assert.equal(result.next_action, "resolve-consent-binding");
|
|
659
|
+
assert.deepEqual(answers, []);
|
|
660
|
+
});
|
|
661
|
+
|
|
596
662
|
test("consent follow-up rejects invalid token, unknown id, changed cwd, and changed target binding", async (t) => {
|
|
597
663
|
const cwd = repository(t);
|
|
598
664
|
const consent = candidateConsent(cwd);
|
|
@@ -1137,9 +1137,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
|
|
|
1137
1137
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1138
1138
|
});
|
|
1139
1139
|
|
|
1140
|
-
test("v2.1.
|
|
1140
|
+
test("v2.1.1 release package and runtime stop before publication", () => {
|
|
1141
1141
|
const packageJson = readPackageJson();
|
|
1142
|
-
assert.equal(packageJson.version, "2.1.
|
|
1142
|
+
assert.equal(packageJson.version, "2.1.1", "the release manifest must remain explicitly pinned to v2.1.1");
|
|
1143
1143
|
assert.equal(
|
|
1144
1144
|
packageJson.scripts?.test,
|
|
1145
1145
|
"node --experimental-strip-types --test tests/*.test.ts && pnpm run test:harness",
|
|
@@ -75,11 +75,27 @@ function nativeStatus(cwd: string, status: string, locks: readonly unknown[]): N
|
|
|
75
75
|
} as NativeReviewStatusResult;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
function fakeNative(status: NativeReviewStatusResult, onStart?: () => void): NativeReviewCli {
|
|
78
|
+
function fakeNative(status: NativeReviewStatusResult, onStart?: (request: Parameters<NativeReviewCli["start"]>[0]) => void): NativeReviewCli {
|
|
79
79
|
const blocking = status.locks.some((lock) => (lock as { status?: string }).status !== "released");
|
|
80
|
+
const tree = execFileSync("git", ["rev-parse", "HEAD^{tree}"], { cwd: status.repository, encoding: "utf8" }).trim();
|
|
81
|
+
const targetIdentity = `sha256:${"a".repeat(64)}`;
|
|
82
|
+
const projection = {
|
|
83
|
+
schema: "gentle-ai.review-integration.projection/v1",
|
|
84
|
+
kind: "current-changes",
|
|
85
|
+
projection: "workspace",
|
|
86
|
+
baseTree: tree,
|
|
87
|
+
initialReviewTree: tree,
|
|
88
|
+
currentCandidateTree: tree,
|
|
89
|
+
pathsDigest: targetIdentity,
|
|
90
|
+
paths: [],
|
|
91
|
+
intendedUntracked: [],
|
|
92
|
+
intendedUntrackedProof: targetIdentity,
|
|
93
|
+
initialSnapshotIdentity: targetIdentity,
|
|
94
|
+
currentSnapshotIdentity: targetIdentity,
|
|
95
|
+
};
|
|
80
96
|
return {
|
|
81
|
-
start: async () => {
|
|
82
|
-
onStart?.();
|
|
97
|
+
start: async (request) => {
|
|
98
|
+
onStart?.(request);
|
|
83
99
|
return { lineageId: "native-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 2, correctionBudget: 1, action: "created", lensesRequired: true };
|
|
84
100
|
},
|
|
85
101
|
finalize: async () => { throw new Error("finalize must not run"); },
|
|
@@ -90,7 +106,9 @@ function fakeNative(status: NativeReviewStatusResult, onStart?: () => void): Nat
|
|
|
90
106
|
targetStatus: async () => ({
|
|
91
107
|
applicability: blocking ? "corrupted" : "unrelated",
|
|
92
108
|
action: blocking ? "repair_authority" : "start",
|
|
93
|
-
|
|
109
|
+
targetIdentity,
|
|
110
|
+
projection,
|
|
111
|
+
raw: { action: blocking ? "repair_authority" : "start", locks: status.locks, target_identity: targetIdentity, projection: { projection: "workspace" } },
|
|
94
112
|
}),
|
|
95
113
|
} as unknown as NativeReviewCli;
|
|
96
114
|
}
|
|
@@ -121,11 +139,11 @@ test("INSPECT treats released lock residue as non-blocking and still blocks on l
|
|
|
121
139
|
test("START precondition ignores released lock residue and still blocks on live lock claims", async (t) => {
|
|
122
140
|
const cwd = repository(t);
|
|
123
141
|
|
|
124
|
-
|
|
125
|
-
const proceeded = await runtime(fakeNative(nativeStatus(cwd, "clean", [RELEASED_LOCK]), () => {
|
|
142
|
+
const startRequests: Parameters<NativeReviewCli["start"]>[0][] = [];
|
|
143
|
+
const proceeded = await runtime(fakeNative(nativeStatus(cwd, "clean", [RELEASED_LOCK]), (request) => { startRequests.push(request); }))
|
|
126
144
|
.execute("start-released", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
127
145
|
const proceededDetails = proceeded.details as Record<string, unknown>;
|
|
128
|
-
assert.
|
|
146
|
+
assert.deepEqual(startRequests, [{ cwd, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" }]);
|
|
129
147
|
assert.equal((proceededDetails.result as Record<string, unknown>).lineage_id, "native-lineage");
|
|
130
148
|
assert.notEqual(proceededDetails.outcome, "native-authority-lock-present");
|
|
131
149
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import {
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
@@ -267,7 +267,7 @@ function fakeNative(overrides: Partial<NativeReviewCli> = {}): NativeReviewCli {
|
|
|
267
267
|
targetStatus: async (request) => {
|
|
268
268
|
const lineageId = request.lineageId ?? "";
|
|
269
269
|
return lineageId === ""
|
|
270
|
-
?
|
|
270
|
+
? candidateStartTargetStatus(request)
|
|
271
271
|
: targetStatusFixture({ lineageId });
|
|
272
272
|
},
|
|
273
273
|
...overrides,
|
|
@@ -378,6 +378,28 @@ function targetStatusFixture(options: {
|
|
|
378
378
|
};
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
function candidateStartTargetStatus(request: Parameters<NonNullable<NativeReviewCli["targetStatus"]>>[0]): ReviewStatusV3 {
|
|
382
|
+
let candidate: ReturnType<CandidateViewRegistry["create"]> | undefined;
|
|
383
|
+
try {
|
|
384
|
+
candidate = new CandidateViewRegistry().create({
|
|
385
|
+
contributorRoot: request.cwd,
|
|
386
|
+
...(request.baseRef === undefined ? {} : { baseRef: request.baseRef, committedOnly: true }),
|
|
387
|
+
});
|
|
388
|
+
return targetStatusFixture({
|
|
389
|
+
applicability: "unrelated",
|
|
390
|
+
action: "start",
|
|
391
|
+
baseTree: candidate.baseTree,
|
|
392
|
+
currentCandidateTree: candidate.candidateTree,
|
|
393
|
+
paths: candidate.paths,
|
|
394
|
+
projection: request.projection ?? "workspace",
|
|
395
|
+
});
|
|
396
|
+
} catch {
|
|
397
|
+
return targetStatusFixture({ applicability: "unrelated", action: "start" });
|
|
398
|
+
} finally {
|
|
399
|
+
candidate?.cleanup();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
381
403
|
function bindReviewerManifest(status: ReviewStatusV3, cwd: string, manifestHash = `sha256:${"7".repeat(64)}`): ReviewStatusV3 {
|
|
382
404
|
const manifest = deriveChangedPathManifest(cwd, status.projection.baseTree, status.projection.currentCandidateTree).map((entry) => ({
|
|
383
405
|
...entry,
|
|
@@ -613,7 +635,7 @@ test("fresh registry reload restores the native resumed lineage only while the l
|
|
|
613
635
|
stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false,
|
|
614
636
|
}));
|
|
615
637
|
native.targetStatus = async (request) => request.lineageId === undefined
|
|
616
|
-
?
|
|
638
|
+
? candidateStartTargetStatus(request)
|
|
617
639
|
: targetStatusFixture({ lineageId: request.lineageId });
|
|
618
640
|
const { controller, toolCall } = runtime(native, undefined, undefined, undefined, candidateViews);
|
|
619
641
|
await controller.execute("reload-start", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
@@ -860,10 +882,10 @@ test("ambiguous native START runs target status first and follows only its decla
|
|
|
860
882
|
let statuses = 0;
|
|
861
883
|
const reconciled = targetStatusFixture({ action: "finalize", lineageId: "resumed-lineage" });
|
|
862
884
|
const { controller } = runtime(fakeNative({
|
|
863
|
-
targetStatus: async () => {
|
|
885
|
+
targetStatus: async (request) => {
|
|
864
886
|
calls.push("status");
|
|
865
887
|
statuses += 1;
|
|
866
|
-
return statuses === 1 ?
|
|
888
|
+
return statuses === 1 ? candidateStartTargetStatus(request) : reconciled;
|
|
867
889
|
},
|
|
868
890
|
start: async (request) => {
|
|
869
891
|
calls.push("start");
|
|
@@ -886,7 +908,9 @@ test("ambiguous native START runs target status first and follows only its decla
|
|
|
886
908
|
authority_applicability: "current_target",
|
|
887
909
|
provider_action: "finalize",
|
|
888
910
|
});
|
|
889
|
-
|
|
911
|
+
assert.equal(requests[0]?.cwd, cwd);
|
|
912
|
+
const replayKey = JSON.stringify({ cwd, lineageId: null, input: request.input, inputPath: null });
|
|
913
|
+
candidateViews.createOrReuse({ contributorRoot: cwd, replayKey }).cleanup();
|
|
890
914
|
});
|
|
891
915
|
|
|
892
916
|
test("ambiguous native FINALIZE returns the target-status action without a second mutation", async (t) => {
|
|
@@ -1558,8 +1582,8 @@ test("native START uses the default policy or a canonical safe policy path, and
|
|
|
1558
1582
|
await controller.execute("default-policy", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
1559
1583
|
await controller.execute("custom-policy", { operation: "start", input: JSON.stringify({ mode: "ordinary", policyPath: ".gentle-ai/policies/team policy.json" }) }, undefined, undefined, context(cwd));
|
|
1560
1584
|
assert.deepEqual(requests, [
|
|
1561
|
-
{ cwd },
|
|
1562
|
-
{ cwd, policyPath },
|
|
1585
|
+
{ cwd, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" },
|
|
1586
|
+
{ cwd, policyPath, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" },
|
|
1563
1587
|
]);
|
|
1564
1588
|
for (const [input, outcome, reason] of [
|
|
1565
1589
|
[{ mode: "ordinary", policyHash: "legacy" }, "native-start-legacy-policy-hash-unsupported", "legacy-policy-hash-unsupported"],
|
|
@@ -1595,12 +1619,18 @@ test("native START preserves the default dirty-inclusive candidate without base
|
|
|
1595
1619
|
return { lineageId: "default-dirty-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 2, changedLines: 2, correctionBudget: 1, action: "created", lensesRequired: true };
|
|
1596
1620
|
},
|
|
1597
1621
|
}), undefined, undefined, undefined, candidateViews);
|
|
1598
|
-
await controller.execute("default-dirty", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
1622
|
+
const started = await controller.execute("default-dirty", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
1599
1623
|
const view = candidateViews.resolveForLens("default-dirty-lineage", "review-reliability");
|
|
1600
1624
|
try {
|
|
1601
1625
|
assert.deepEqual(view.paths, ["app.ts", "untracked.ts"]);
|
|
1602
1626
|
assert.equal(view.committedOnly, false);
|
|
1603
|
-
assert.deepEqual(requests, [{ cwd:
|
|
1627
|
+
assert.deepEqual(requests, [{ cwd, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" }]);
|
|
1628
|
+
const actorBinding = (started.details as { actor_binding: { workspace_root: string; candidate_root: string; candidate_tree: string; candidate_paths: readonly string[] } }).actor_binding;
|
|
1629
|
+
assert.equal(actorBinding.workspace_root, cwd);
|
|
1630
|
+
assert.equal(actorBinding.candidate_root, view.root);
|
|
1631
|
+
assert.notEqual(actorBinding.candidate_root, requests[0]?.cwd);
|
|
1632
|
+
assert.equal(actorBinding.candidate_tree, view.candidateTree);
|
|
1633
|
+
assert.deepEqual(actorBinding.candidate_paths, view.paths);
|
|
1604
1634
|
} finally {
|
|
1605
1635
|
view.cleanup();
|
|
1606
1636
|
}
|
|
@@ -1626,12 +1656,68 @@ test("native START binds an acknowledged committed range and native identity to
|
|
|
1626
1656
|
assert.deepEqual(view.paths, ["committed-after-base.ts"]);
|
|
1627
1657
|
assert.equal(view.committedOnly, true);
|
|
1628
1658
|
assert.equal(view.baseCommit, baseCommit);
|
|
1629
|
-
assert.deepEqual(requests, [{ cwd
|
|
1659
|
+
assert.deepEqual(requests, [{ cwd, baseRef: view.baseCommit, committedOnly: true, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" }]);
|
|
1630
1660
|
} finally {
|
|
1631
1661
|
view.cleanup();
|
|
1632
1662
|
}
|
|
1633
1663
|
});
|
|
1634
1664
|
|
|
1665
|
+
test("native START fails closed before mutation when the workspace target and immutable candidate view differ", async (t) => {
|
|
1666
|
+
const cwd = repository(t);
|
|
1667
|
+
writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
|
|
1668
|
+
let starts = 0;
|
|
1669
|
+
const { controller } = runtime(fakeNative({
|
|
1670
|
+
targetStatus: async () => targetStatusFixture({
|
|
1671
|
+
applicability: "unrelated",
|
|
1672
|
+
action: "start",
|
|
1673
|
+
baseTree: git(cwd, "rev-parse", "HEAD^{tree}"),
|
|
1674
|
+
currentCandidateTree: "b".repeat(40),
|
|
1675
|
+
paths: ["app.ts"],
|
|
1676
|
+
}),
|
|
1677
|
+
start: async () => {
|
|
1678
|
+
starts += 1;
|
|
1679
|
+
return { lineageId: "must-not-start", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true };
|
|
1680
|
+
},
|
|
1681
|
+
}), undefined, undefined, undefined, new CandidateViewRegistry());
|
|
1682
|
+
const result = await controller.execute("target-view-drift", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
1683
|
+
assert.equal((result.details as { outcome: string }).outcome, "native-operation-failed");
|
|
1684
|
+
assert.deepEqual((result.details as { diagnostics: unknown }).diagnostics, {
|
|
1685
|
+
code: "candidate-target-projection-drift",
|
|
1686
|
+
message: "candidate view rejected before native START",
|
|
1687
|
+
});
|
|
1688
|
+
assert.equal(starts, 0);
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
test("native START re-verifies candidate-view integrity before granting workspace authority", async (t) => {
|
|
1692
|
+
const cwd = repository(t);
|
|
1693
|
+
writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
|
|
1694
|
+
class DriftingCandidateViewRegistry extends CandidateViewRegistry {
|
|
1695
|
+
override createOrReuse(request: Parameters<CandidateViewRegistry["createOrReuse"]>[0]): ReturnType<CandidateViewRegistry["createOrReuse"]> {
|
|
1696
|
+
const candidate = super.createOrReuse(request);
|
|
1697
|
+
chmodSync(candidate.root, 0o755);
|
|
1698
|
+
chmodSync(join(candidate.root, "app.ts"), 0o644);
|
|
1699
|
+
writeFileSync(join(candidate.root, "app.ts"), "corrupted frozen content\n");
|
|
1700
|
+
chmodSync(join(candidate.root, "app.ts"), 0o444);
|
|
1701
|
+
chmodSync(candidate.root, 0o555);
|
|
1702
|
+
return candidate;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
let starts = 0;
|
|
1706
|
+
const { controller } = runtime(fakeNative({
|
|
1707
|
+
start: async () => {
|
|
1708
|
+
starts += 1;
|
|
1709
|
+
return { lineageId: "must-not-start", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true };
|
|
1710
|
+
},
|
|
1711
|
+
}), undefined, undefined, undefined, new DriftingCandidateViewRegistry());
|
|
1712
|
+
const result = await controller.execute("candidate-view-drift", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
1713
|
+
assert.equal((result.details as { outcome: string }).outcome, "native-operation-failed");
|
|
1714
|
+
assert.deepEqual((result.details as { diagnostics: unknown }).diagnostics, {
|
|
1715
|
+
code: "candidate-view-invalid",
|
|
1716
|
+
message: "candidate view rejected before native START",
|
|
1717
|
+
});
|
|
1718
|
+
assert.equal(starts, 0);
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1635
1721
|
test("native START rejects an unresolvable explicit base before native mutation", async (t) => {
|
|
1636
1722
|
const cwd = repository(t);
|
|
1637
1723
|
let starts = 0;
|
|
@@ -1698,7 +1784,7 @@ test("native START forwards an acknowledged base ref and rejects invalid values
|
|
|
1698
1784
|
},
|
|
1699
1785
|
}));
|
|
1700
1786
|
await controller.execute("committed-base", { operation: "start", input: JSON.stringify({ mode: "ordinary", baseRef: "refs/heads/main", committedOnly: true }) }, undefined, undefined, context(cwd));
|
|
1701
|
-
assert.deepEqual(requests, [{ cwd, baseRef: git(cwd, "rev-parse", "refs/heads/main"), committedOnly: true }]);
|
|
1787
|
+
assert.deepEqual(requests, [{ cwd, baseRef: git(cwd, "rev-parse", "refs/heads/main"), committedOnly: true, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" }]);
|
|
1702
1788
|
for (const baseRef of ["", " ", " origin/main", "origin/main ", "origin\0main", "origin\nmain", "origin\rmain", "origin\tmain", "origin\u007fmain", 42, [], {}]) {
|
|
1703
1789
|
const rejected = await controller.execute("invalid-base", { operation: "start", input: JSON.stringify({ mode: "ordinary", baseRef }) }, undefined, undefined, context(cwd));
|
|
1704
1790
|
assert.deepEqual(rejected.details, {
|
|
@@ -4032,7 +4118,7 @@ test("RECOVER rechecks a committed range against its frozen base instead of the
|
|
|
4032
4118
|
const { controller } = runtime(fakeNative({
|
|
4033
4119
|
targetStatus: async (request) => {
|
|
4034
4120
|
statusRequests.push(request as Record<string, unknown>);
|
|
4035
|
-
if (request.lineageId === undefined) return
|
|
4121
|
+
if (request.lineageId === undefined) return candidateStartTargetStatus(request);
|
|
4036
4122
|
assert.equal(request.baseRef, baseRef);
|
|
4037
4123
|
const status = targetStatusFixture({ lineageId: "native-lineage", action: "recover" });
|
|
4038
4124
|
return { ...status, actionDisposition: "invalidated", authority: { ...status.authority!, revision: "rev-1" } };
|
|
@@ -4097,6 +4183,78 @@ test("out-of-band review-mode disable discards stale lifecycle authorization and
|
|
|
4097
4183
|
assert.equal(validations, 1, "disabled organic delivery must not reuse or revalidate stale review authority");
|
|
4098
4184
|
});
|
|
4099
4185
|
|
|
4186
|
+
test("RDD-off commit and push canonicalize a git -C linked-worktree target before native mode reconsult", async (t) => {
|
|
4187
|
+
const sessionCwd = repository(t);
|
|
4188
|
+
const worktreeParent = mkdtempSync(join(tmpdir(), "gentle-pi-lifecycle-worktrees-"));
|
|
4189
|
+
const worktree = join(worktreeParent, "worktree B");
|
|
4190
|
+
const worktreeAlias = join(worktreeParent, "worktree B alias");
|
|
4191
|
+
git(sessionCwd, "worktree", "add", "-b", "issue-246-worktree", worktree);
|
|
4192
|
+
const windowsDrive = /^([A-Za-z]):[\\/](.*)$/.exec(worktree);
|
|
4193
|
+
const worktreeSpelling = process.platform === "win32"
|
|
4194
|
+
? `/${windowsDrive?.[1]?.toLowerCase()}/${windowsDrive?.[2]?.replaceAll("\\", "/")}`
|
|
4195
|
+
: worktreeAlias;
|
|
4196
|
+
if (process.platform === "win32") assert.ok(windowsDrive, "Windows worktree must have a drive-qualified path");
|
|
4197
|
+
else symlinkSync(worktree, worktreeAlias, "dir");
|
|
4198
|
+
t.after(() => {
|
|
4199
|
+
try { git(sessionCwd, "worktree", "remove", "--force", worktree); } catch {}
|
|
4200
|
+
rmSync(worktreeParent, { recursive: true, force: true });
|
|
4201
|
+
});
|
|
4202
|
+
const canonicalWorktree = realpathSync(worktree);
|
|
4203
|
+
const commonDirectory = (cwd: string): string => realpathSync(resolve(cwd, git(cwd, "rev-parse", "--git-common-dir")));
|
|
4204
|
+
assert.equal(commonDirectory(sessionCwd), commonDirectory(canonicalWorktree));
|
|
4205
|
+
const parsed = __testing.resolveReviewLifecycleCommand(`git -C "${worktreeSpelling}" commit -m "issue 246"`, sessionCwd);
|
|
4206
|
+
assert.deepEqual(parsed?.gitGlobalArgs, ["-C", worktreeSpelling], "cwd canonicalization must preserve the exact typed Git selector");
|
|
4207
|
+
|
|
4208
|
+
const nativeCalls: Array<{ arguments: readonly string[]; cwd: string }> = [];
|
|
4209
|
+
const native = new NativeReviewCliV214(async (request) => {
|
|
4210
|
+
nativeCalls.push({ arguments: request.arguments, cwd: request.cwd });
|
|
4211
|
+
if (request.cwd !== canonicalWorktree) throw new Error(`native process cwd was not canonical: ${request.cwd}`);
|
|
4212
|
+
if (request.arguments[0] === "version") {
|
|
4213
|
+
return { stdout: "gentle-ai 2.2.2\n", stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
4214
|
+
}
|
|
4215
|
+
if (request.arguments[0] === "review" && request.arguments[1] === "mode") {
|
|
4216
|
+
return {
|
|
4217
|
+
stdout: JSON.stringify({
|
|
4218
|
+
schema: "gentle-ai.review-mode/v1",
|
|
4219
|
+
operation: "status",
|
|
4220
|
+
scope: "both",
|
|
4221
|
+
status: { schema: "gentle-ai.rdd-mode-status/v1", global: "off", clone_local: "off", effective: "off", source: "clone_local" },
|
|
4222
|
+
}),
|
|
4223
|
+
stderr: "",
|
|
4224
|
+
exitCode: 0,
|
|
4225
|
+
signal: null,
|
|
4226
|
+
timedOut: false,
|
|
4227
|
+
outputLimitExceeded: false,
|
|
4228
|
+
};
|
|
4229
|
+
}
|
|
4230
|
+
throw new Error(`unexpected native review operation: ${request.arguments.join(" ")}`);
|
|
4231
|
+
});
|
|
4232
|
+
const { toolCall } = runtime(native);
|
|
4233
|
+
for (const command of [
|
|
4234
|
+
`git -C "${worktreeSpelling}" commit -m "issue 246"`,
|
|
4235
|
+
`git -C "${worktreeSpelling}" push origin issue-246-worktree`,
|
|
4236
|
+
]) {
|
|
4237
|
+
assert.equal(await toolCall({ toolName: "bash", input: { command } }, interactiveContext(sessionCwd)), undefined);
|
|
4238
|
+
}
|
|
4239
|
+
assert.equal(nativeCalls.length, 4);
|
|
4240
|
+
assert.equal(nativeCalls.every((call) => call.cwd === canonicalWorktree), true);
|
|
4241
|
+
assert.deepEqual(nativeCalls.filter((call) => call.arguments[0] === "review").map((call) => call.arguments), [
|
|
4242
|
+
["review", "mode", "status", "--cwd", canonicalWorktree, "--json"],
|
|
4243
|
+
["review", "mode", "status", "--cwd", canonicalWorktree, "--json"],
|
|
4244
|
+
]);
|
|
4245
|
+
assert.equal(nativeCalls.some((call) => call.arguments.includes("validate")), false);
|
|
4246
|
+
});
|
|
4247
|
+
|
|
4248
|
+
test("git -C linked-worktree lifecycle commands remain fail-closed when mode reconsult genuinely fails", async (t) => {
|
|
4249
|
+
const sessionCwd = repository(t);
|
|
4250
|
+
const { toolCall } = runtime(fakeNative({ reviewMode: async () => { throw new Error("mode unavailable"); } }));
|
|
4251
|
+
for (const command of ["git -C . commit -m failure", "git -C . push origin main"]) {
|
|
4252
|
+
const result = await toolCall({ toolName: "bash", input: { command } }, interactiveContext(sessionCwd)) as { block: boolean; reason: string };
|
|
4253
|
+
assert.equal(result.block, true);
|
|
4254
|
+
assert.match(result.reason, /could not reconsult review mode and failed closed/);
|
|
4255
|
+
}
|
|
4256
|
+
});
|
|
4257
|
+
|
|
4100
4258
|
test("successful /gentle:review-mode disable clears pending authorizations even after mode is re-enabled", async (t) => {
|
|
4101
4259
|
const cwd = repository(t);
|
|
4102
4260
|
let effective: "on" | "off" = "on";
|
|
@@ -84,7 +84,7 @@ function fakeNative(overrides: Partial<NativeReviewCli> = {}): NativeReviewCli {
|
|
|
84
84
|
sddStatus: async () => ({ ready: false }),
|
|
85
85
|
reviewStatus: async () => ({ schema: "gentle-ai.review-authority-status/v1", repository: "/repo", complete: true, authoritative: true, status: "clean", entries: [], locks: [], diagnostics: [], raw: { schema: "gentle-ai.review-authority-status/v1", operation: "review/status", repository: "/repo", complete: true, authoritative: true, status: "clean", entries: [], locks: [], diagnostics: [] } }),
|
|
86
86
|
targetStatus: async (request) => request.lineageId === undefined
|
|
87
|
-
?
|
|
87
|
+
? candidateStartTargetStatus(request)
|
|
88
88
|
: targetStatusFixture({ lineageId: request.lineageId }),
|
|
89
89
|
...overrides,
|
|
90
90
|
};
|
|
@@ -102,21 +102,26 @@ function targetStatusFixture(options: {
|
|
|
102
102
|
applicability?: "current_target" | "unrelated";
|
|
103
103
|
action?: ReviewStatusV3["action"];
|
|
104
104
|
lineageId?: string;
|
|
105
|
+
baseTree?: string;
|
|
106
|
+
currentCandidateTree?: string;
|
|
107
|
+
paths?: readonly string[];
|
|
105
108
|
} = {}): ReviewStatusV3 {
|
|
106
109
|
const applicability = options.applicability ?? "current_target";
|
|
107
110
|
const action = options.action ?? (applicability === "current_target" ? "finalize" : "start");
|
|
108
111
|
const lineageId = options.lineageId ?? "native-lineage";
|
|
109
112
|
const sha = `sha256:${"a".repeat(64)}`;
|
|
110
|
-
const tree = "b".repeat(40);
|
|
113
|
+
const tree = options.currentCandidateTree ?? "b".repeat(40);
|
|
114
|
+
const baseTree = options.baseTree ?? tree;
|
|
115
|
+
const paths = options.paths ?? ["app.ts"];
|
|
111
116
|
const projection = {
|
|
112
117
|
schema: "gentle-ai.review-integration.projection/v1" as const,
|
|
113
118
|
kind: "current-changes" as const,
|
|
114
119
|
projection: "workspace" as const,
|
|
115
|
-
baseTree
|
|
120
|
+
baseTree,
|
|
116
121
|
initialReviewTree: tree,
|
|
117
122
|
currentCandidateTree: tree,
|
|
118
123
|
pathsDigest: sha,
|
|
119
|
-
paths
|
|
124
|
+
paths,
|
|
120
125
|
intendedUntracked: [],
|
|
121
126
|
intendedUntrackedProof: sha,
|
|
122
127
|
initialSnapshotIdentity: sha,
|
|
@@ -146,11 +151,11 @@ function targetStatusFixture(options: {
|
|
|
146
151
|
schema: projection.schema,
|
|
147
152
|
kind: projection.kind,
|
|
148
153
|
projection: projection.projection,
|
|
149
|
-
base_tree:
|
|
154
|
+
base_tree: baseTree,
|
|
150
155
|
initial_review_tree: tree,
|
|
151
156
|
current_candidate_tree: tree,
|
|
152
157
|
paths_digest: sha,
|
|
153
|
-
paths
|
|
158
|
+
paths,
|
|
154
159
|
intended_untracked: [],
|
|
155
160
|
intended_untracked_proof: sha,
|
|
156
161
|
initial_snapshot_identity: sha,
|
|
@@ -178,6 +183,25 @@ function targetStatusFixture(options: {
|
|
|
178
183
|
};
|
|
179
184
|
}
|
|
180
185
|
|
|
186
|
+
function candidateStartTargetStatus(request: Parameters<NonNullable<NativeReviewCli["targetStatus"]>>[0]): ReviewStatusV3 {
|
|
187
|
+
let candidate: ReturnType<CandidateViewRegistry["create"]> | undefined;
|
|
188
|
+
try {
|
|
189
|
+
candidate = new CandidateViewRegistry().create({
|
|
190
|
+
contributorRoot: request.cwd,
|
|
191
|
+
...(request.baseRef === undefined ? {} : { baseRef: request.baseRef, committedOnly: true }),
|
|
192
|
+
});
|
|
193
|
+
return targetStatusFixture({
|
|
194
|
+
applicability: "unrelated",
|
|
195
|
+
action: "start",
|
|
196
|
+
baseTree: candidate.baseTree,
|
|
197
|
+
currentCandidateTree: candidate.candidateTree,
|
|
198
|
+
paths: candidate.paths,
|
|
199
|
+
});
|
|
200
|
+
} finally {
|
|
201
|
+
candidate?.cleanup();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
181
205
|
test("INSPECT and STATUS operate on the explicit workspace root while the session cwd stays elsewhere", async (t) => {
|
|
182
206
|
const sessionCwd = repository(t);
|
|
183
207
|
const worktree = addWorktree(t, sessionCwd, "feat-binding");
|
|
@@ -200,10 +224,10 @@ test("START freezes the candidate from the explicit workspace root and returns t
|
|
|
200
224
|
const worktree = addWorktree(t, sessionCwd, "feat-candidate");
|
|
201
225
|
writeFileSync(join(worktree, "app.ts"), "export const value = 2; // worktree candidate\n");
|
|
202
226
|
const candidateViews = new CandidateViewRegistry();
|
|
203
|
-
const
|
|
227
|
+
const startRequests: Parameters<NativeReviewCli["start"]>[0][] = [];
|
|
204
228
|
const { controller, toolCall } = runtime(fakeNative({
|
|
205
229
|
start: async (request) => {
|
|
206
|
-
|
|
230
|
+
startRequests.push(request);
|
|
207
231
|
return { lineageId: "worktree-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true };
|
|
208
232
|
},
|
|
209
233
|
}), candidateViews);
|
|
@@ -219,7 +243,8 @@ test("START freezes the candidate from the explicit workspace root and returns t
|
|
|
219
243
|
const view = candidateViews.resolveForLens("worktree-lineage", "review-reliability");
|
|
220
244
|
assert.equal(details.actor_binding.candidate_root, view.root);
|
|
221
245
|
assert.equal(details.actor_binding.candidate_tree, view.candidateTree);
|
|
222
|
-
assert.deepEqual(
|
|
246
|
+
assert.deepEqual(startRequests, [{ cwd: root, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" }]);
|
|
247
|
+
assert.notEqual(startRequests[0]?.cwd, view.root);
|
|
223
248
|
assert.equal(readFileSync(join(view.root, "app.ts"), "utf8"), "export const value = 2; // worktree candidate\n");
|
|
224
249
|
assert.equal(view.paths.includes("unrelated.ts"), false);
|
|
225
250
|
const dispatch = { agent: "review-reliability", task: "review the change", mode: "task" };
|
|
@@ -234,8 +259,12 @@ test("absent workspaceRoot keeps the session-cwd flow and still reports the acto
|
|
|
234
259
|
const sessionCwd = repository(t);
|
|
235
260
|
writeFileSync(join(sessionCwd, "app.ts"), "export const value = 3;\n");
|
|
236
261
|
const candidateViews = new CandidateViewRegistry();
|
|
262
|
+
const startRequests: Parameters<NativeReviewCli["start"]>[0][] = [];
|
|
237
263
|
const { controller } = runtime(fakeNative({
|
|
238
|
-
start: async () =>
|
|
264
|
+
start: async (request) => {
|
|
265
|
+
startRequests.push(request);
|
|
266
|
+
return { lineageId: "session-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true };
|
|
267
|
+
},
|
|
239
268
|
}), candidateViews);
|
|
240
269
|
const started = await controller.execute("start-session", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(sessionCwd));
|
|
241
270
|
const details = started.details as {
|
|
@@ -245,6 +274,7 @@ test("absent workspaceRoot keeps the session-cwd flow and still reports the acto
|
|
|
245
274
|
assert.equal(details.workspace_root, sessionCwd);
|
|
246
275
|
assert.equal(details.actor_binding.workspace_root, sessionCwd);
|
|
247
276
|
assert.deepEqual(details.actor_binding.candidate_paths, ["app.ts"]);
|
|
277
|
+
assert.deepEqual(startRequests, [{ cwd: sessionCwd, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" }]);
|
|
248
278
|
const view = candidateViews.resolveForLens("session-lineage", "review-reliability");
|
|
249
279
|
assert.equal(details.actor_binding.candidate_root, view.root);
|
|
250
280
|
candidateViews.cleanup(view.token);
|