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.
@@ -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.candidateView.root,
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.candidateView.root,
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
- const target = await nativeReviewCli.targetStatus({
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
- nativeStartAttempted = true;
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: candidateView?.root ?? defaultCwd,
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: candidateView?.root ?? defaultCwd,
5253
+ cwd: defaultCwd,
5209
5254
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5210
5255
  ...(canonicalBaseRef === undefined ? {} : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef }),
5211
5256
  projection: "workspace",
@@ -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 this.verifyVersion(request.cwd, request.signal, ["mode"]);
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
- request.cwd,
1368
- ["review", "mode", request.operation, "--cwd", request.cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
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 TypeError(`Native consent invocation ${name} is missing its value`);
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 TypeError(`Native consent invocation requires exactly one ${name}`);
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 TypeError("Native consent answer must be granted or declined");
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 TypeError("Native consent invocation is not a provider review START");
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 TypeError("Native consent invocation contract changed");
1724
- if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new TypeError("Native consent invocation repository binding changed");
1725
- if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new TypeError("Native consent invocation target binding changed");
1726
- if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new TypeError("Native consent invocation projection binding changed");
1727
- if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new TypeError("Native consent invocation answer binding changed");
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
- // A negotiated START requires exactly one --contract, --target, and
1941
- // --projection. The target is resolved here, from the same root START is
1942
- // about to run in, rather than threaded in by the caller: the caller's
1943
- // root and START's root differ whenever a candidate view is in play, and
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 target = await this.targetStatus({
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", target.targetIdentity, "--projection", projection,
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
- throw new NativeReviewConsentRequiredError(decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body)));
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.0",
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 this.verifyVersion(request.cwd, request.signal, ["mode"]);
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
- request.cwd,
1369
- ["review", "mode", request.operation, "--cwd", request.cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
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 TypeError(`Native consent invocation ${name} is missing its value`);
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 TypeError(`Native consent invocation requires exactly one ${name}`);
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 TypeError("Native consent answer must be granted or declined");
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 TypeError("Native consent invocation is not a provider review START");
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 TypeError("Native consent invocation contract changed");
1725
- if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new TypeError("Native consent invocation repository binding changed");
1726
- if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new TypeError("Native consent invocation target binding changed");
1727
- if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new TypeError("Native consent invocation projection binding changed");
1728
- if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new TypeError("Native consent invocation answer binding changed");
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
- // A negotiated START requires exactly one --contract, --target, and
1942
- // --projection. The target is resolved here, from the same root START is
1943
- // about to run in, rather than threaded in by the caller: the caller's
1944
- // root and START's root differ whenever a candidate view is in play, and
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 target = await this.targetStatus({
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", target.targetIdentity, "--projection", projection,
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
- throw new NativeReviewConsentRequiredError(decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body)));
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 tree = "b".repeat(40);
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: tree,
330
+ baseTree,
303
331
  initialReviewTree: tree,
304
332
  currentCandidateTree: tree,
305
333
  pathsDigest: sha,
306
- paths: ["app.ts"],
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: tree, initial_review_tree: tree, current_candidate_tree: tree, paths_digest: sha, paths: ["app.ts"], intended_untracked: [], intended_untracked_proof: sha, initial_snapshot_identity: sha, current_snapshot_identity: sha },
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
- native.start = async () => { throw new NativeReviewConsentRequiredError(consent); };
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
- if (answer === "granted") assert.ok(result.actor_binding);
587
- else {
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.0 release package and runtime stop before publication", () => {
1140
+ test("v2.1.1 release package and runtime stop before publication", () => {
1141
1141
  const packageJson = readPackageJson();
1142
- assert.equal(packageJson.version, "2.1.0", "the release manifest must remain explicitly pinned to v2.1.0");
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
- raw: { action: blocking ? "repair_authority" : "start", locks: status.locks },
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
- let started = 0;
125
- const proceeded = await runtime(fakeNative(nativeStatus(cwd, "clean", [RELEASED_LOCK]), () => { started += 1; }))
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.equal(started, 1);
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 { basename, dirname, join } from "node:path";
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
- ? targetStatusFixture({ applicability: "unrelated", action: "start" })
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
- ? targetStatusFixture({ applicability: "unrelated", action: "start" })
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 ? targetStatusFixture({ applicability: "unrelated", action: "start" }) : reconciled;
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
- candidateViews.cleanup(basename(requests[0]!.cwd));
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: view.root }]);
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: view.root, baseRef: view.baseCommit, committedOnly: true }]);
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 targetStatusFixture({ applicability: "unrelated", action: "start" });
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
- ? targetStatusFixture({ applicability: "unrelated", action: "start" })
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: tree,
120
+ baseTree,
116
121
  initialReviewTree: tree,
117
122
  currentCandidateTree: tree,
118
123
  pathsDigest: sha,
119
- paths: ["app.ts"],
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: tree,
154
+ base_tree: baseTree,
150
155
  initial_review_tree: tree,
151
156
  current_candidate_tree: tree,
152
157
  paths_digest: sha,
153
- paths: projection.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 startCwds: string[] = [];
227
+ const startRequests: Parameters<NativeReviewCli["start"]>[0][] = [];
204
228
  const { controller, toolCall } = runtime(fakeNative({
205
229
  start: async (request) => {
206
- startCwds.push(request.cwd);
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(startCwds, [view.root]);
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 () => ({ lineageId: "session-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true }),
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);