gentle-pi 2.1.0 → 2.1.2

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; }
@@ -480,6 +480,7 @@ export interface NativeReviewAuthorityEntry {
480
480
  status: NativeReviewAuthorityEntryStatus;
481
481
  state?: string;
482
482
  revision?: string;
483
+ snapshotIdentity?: string;
483
484
  chainIdentity?: string;
484
485
  recovery?: NativeReviewRecovery;
485
486
  problems: readonly string[];
@@ -748,6 +749,7 @@ function exactObject(value: unknown, required: readonly string[], optional: read
748
749
  }
749
750
  function requiredString(value: unknown): string { if (typeof value !== "string" || value.length === 0) throw new Error("expected string"); return value; }
750
751
  function stringValue(value: unknown): string { if (typeof value !== "string") throw new Error("expected string"); return value; }
752
+ function sha256Identity(value: unknown): string { const parsed = requiredString(value); if (!/^sha256:[0-9a-f]{64}$/.test(parsed)) throw new Error("expected canonical SHA-256 identity"); return parsed; }
751
753
  function booleanValue(value: unknown): boolean { if (typeof value !== "boolean") throw new Error("expected boolean"); return value; }
752
754
  function nonNegativeInteger(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error("expected safe non-negative integer"); return value; }
753
755
  function positiveInteger(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new Error("expected safe positive integer"); return value; }
@@ -957,7 +959,7 @@ function decodeNativeReviewRecovery(value: unknown): NativeReviewRecovery {
957
959
  };
958
960
  }
959
961
  function decodeNativeReviewStatusEntry(value: unknown): NativeReviewAuthorityEntry {
960
- const entry = exactObject(value, ["version", "path", "status", "problems"], ["lineage_id", "state", "revision", "chain_identity", "recovery"]);
962
+ const entry = exactObject(value, ["version", "path", "status", "problems"], ["lineage_id", "state", "revision", "snapshot_identity", "chain_identity", "recovery"]);
961
963
  return {
962
964
  version: enumString(entry.version, Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION)) as NativeReviewAuthorityEntryVersion,
963
965
  ...(entry.lineage_id === undefined ? {} : { lineageId: requiredString(entry.lineage_id) }),
@@ -965,6 +967,7 @@ function decodeNativeReviewStatusEntry(value: unknown): NativeReviewAuthorityEnt
965
967
  status: enumString(entry.status, Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS)) as NativeReviewAuthorityEntryStatus,
966
968
  ...(entry.state === undefined ? {} : { state: requiredString(entry.state) }),
967
969
  ...(entry.revision === undefined ? {} : { revision: requiredString(entry.revision) }),
970
+ ...(entry.snapshot_identity === undefined ? {} : { snapshotIdentity: sha256Identity(entry.snapshot_identity) }),
968
971
  ...(entry.chain_identity === undefined ? {} : { chainIdentity: requiredString(entry.chain_identity) }),
969
972
  ...(entry.recovery === undefined ? {} : { recovery: decodeNativeReviewRecovery(entry.recovery) }),
970
973
  problems: stringArray(entry.problems),
@@ -1037,6 +1040,21 @@ function decodeNativeReviewStatus(value: unknown): NativeReviewStatusResult {
1037
1040
  };
1038
1041
  }
1039
1042
  function isWindowsRepositoryPath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value); }
1043
+ export function normalizeNativeReviewCwd(value: string, platform: NodeJS.Platform = process.platform): string {
1044
+ if (platform !== "win32") return value;
1045
+ const gitBashDrive = /^\/([A-Za-z])(?:\/(.*))?$/.exec(value);
1046
+ const windowsPath = gitBashDrive === null
1047
+ ? value
1048
+ : `${gitBashDrive[1]!.toUpperCase()}:/${gitBashDrive[2] ?? ""}`;
1049
+ if (!isWindowsRepositoryPath(windowsPath)) return windowsPath;
1050
+ const normalized = win32.normalize(windowsPath);
1051
+ return normalized.replace(/^([a-z]):/, (_match, drive: string) => `${drive.toUpperCase()}:`);
1052
+ }
1053
+ async function canonicalNativeReviewCwd(value: string): Promise<string> {
1054
+ const normalized = normalizeNativeReviewCwd(value);
1055
+ try { return await realpath(normalized); }
1056
+ catch { return normalized; }
1057
+ }
1040
1058
  async function repositoryPathIdentity(value: string): Promise<string> {
1041
1059
  const windowsPath = isWindowsRepositoryPath(value);
1042
1060
  try { return `filesystem:${windowsPath ? (await realpath(value)).toLowerCase() : await realpath(value)}`; }
@@ -1360,12 +1378,13 @@ export class NativeReviewCliV214 {
1360
1378
  // and always pass `--scope clone` so Pi's own kill-switch command surface
1361
1379
  // never mutates the operator's global gentle-ai state across other clones.
1362
1380
  async reviewMode(request: NativeReviewModeRequest): Promise<NativeReviewModeResult> {
1363
- await this.verifyVersion(request.cwd, request.signal, ["mode"]);
1381
+ const cwd = await canonicalNativeReviewCwd(request.cwd);
1382
+ await this.verifyVersion(cwd, request.signal, ["mode"]);
1364
1383
  const mutating = request.operation !== NATIVE_REVIEW_MODE_OPERATION.STATUS;
1365
1384
  const { body } = await this.execute(
1366
1385
  NATIVE_REVIEW_OPERATION.MODE,
1367
- request.cwd,
1368
- ["review", "mode", request.operation, "--cwd", request.cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
1386
+ cwd,
1387
+ ["review", "mode", request.operation, "--cwd", cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
1369
1388
  mutating,
1370
1389
  request.signal,
1371
1390
  );
@@ -1654,6 +1673,23 @@ export class NativeReviewConsentRequiredError extends Error {
1654
1673
  }
1655
1674
  }
1656
1675
 
1676
+ // Raised when the provider-issued consent invocation no longer matches the
1677
+ // binding Pi is answering for. Every one of these guards runs before the
1678
+ // provider is launched, so the failure is local and nothing was mutated. It
1679
+ // carries its own identity precisely so callers never report it as a provider
1680
+ // outage: an opaque `native-operation-failed` here sent issue #247 chasing a
1681
+ // missing `--cwd` that Pi does forward.
1682
+ export class NativeReviewConsentBindingError extends Error {
1683
+ readonly reason: string;
1684
+ readonly launchAttempted = false;
1685
+ readonly mutationOutcome = "none";
1686
+ constructor(reason: string, message: string) {
1687
+ super(message);
1688
+ this.name = "NativeReviewConsentBindingError";
1689
+ this.reason = reason;
1690
+ }
1691
+ }
1692
+
1657
1693
  function splitNativeConsentInvocation(invocation: string): readonly string[] {
1658
1694
  const words: string[] = [];
1659
1695
  let current = "";
@@ -1705,26 +1741,26 @@ function exactConsentOption(arguments_: readonly string[], name: string): string
1705
1741
  const token = arguments_[index]!;
1706
1742
  if (token === name) {
1707
1743
  const value = arguments_[index + 1];
1708
- if (value === undefined) throw new TypeError(`Native consent invocation ${name} is missing its value`);
1744
+ if (value === undefined) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation ${name} is missing its value`);
1709
1745
  values.push(value);
1710
1746
  index += 1;
1711
1747
  } else if (token.startsWith(`${name}=`)) values.push(token.slice(name.length + 1));
1712
1748
  }
1713
- if (values.length !== 1) throw new TypeError(`Native consent invocation requires exactly one ${name}`);
1749
+ if (values.length !== 1) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation requires exactly one ${name}`);
1714
1750
  return values[0]!;
1715
1751
  }
1716
1752
 
1717
1753
  function consentInvocationArguments(request: NativeReviewConsentAnswerRequest): readonly string[] {
1718
1754
  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");
1755
+ if (choice === undefined) throw new NativeReviewConsentBindingError("consent-answer-unknown", "Native consent answer must be granted or declined");
1720
1756
  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");
1757
+ 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
1758
  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");
1759
+ if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new NativeReviewConsentBindingError("consent-invocation-contract-changed", "Native consent invocation contract changed");
1760
+ if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
1761
+ if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new NativeReviewConsentBindingError("consent-invocation-target-changed", "Native consent invocation target binding changed");
1762
+ if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new NativeReviewConsentBindingError("consent-invocation-projection-changed", "Native consent invocation projection binding changed");
1763
+ 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
1764
  return arguments_;
1729
1765
  }
1730
1766
 
@@ -1937,24 +1973,21 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1937
1973
  if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native START baseRef must be a non-empty, trimmed, NUL-free string");
1938
1974
  if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement");
1939
1975
  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.
1976
+ if (request.targetIdentity !== undefined && !/^sha256:[0-9a-f]{64}$/.test(request.targetIdentity)) throw new TypeError("Native START targetIdentity must be a canonical sha256 identity");
1977
+ // The controller supplies the target it already projected from the
1978
+ // authority workspace after proving its immutable actor view is identical.
1979
+ // Direct adapter callers may omit it and retain the same-root projection.
1947
1980
  const projection = request.projection ?? "workspace";
1948
- const target = await this.targetStatus({
1981
+ const targetIdentity = request.targetIdentity ?? (await this.targetStatus({
1949
1982
  cwd: request.cwd,
1950
1983
  projection,
1951
1984
  ...(request.baseRef === undefined ? {} : { baseRef: request.baseRef }),
1952
1985
  ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }),
1953
1986
  ...(request.signal === undefined ? {} : { signal: request.signal }),
1954
- });
1987
+ })).targetIdentity;
1955
1988
  const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, [
1956
1989
  "review", "start", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
1957
- "--target", target.targetIdentity, "--projection", projection,
1990
+ "--target", targetIdentity, "--projection", projection,
1958
1991
  ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]),
1959
1992
  ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
1960
1993
  ...(request.policyPath === undefined ? [] : ["--policy", request.policyPath]),
@@ -1966,10 +1999,14 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1966
1999
  // explicit answer it cannot infer. Discriminate before decode and surface
1967
2000
  // the complete envelope; only the caller can map a human answer.
1968
2001
  if (execution.body.action === "consent_required") {
1969
- throw new NativeReviewConsentRequiredError(decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body)));
2002
+ const consent = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body));
2003
+ 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");
2004
+ throw new NativeReviewConsentRequiredError(consent);
1970
2005
  }
1971
2006
  const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
1972
2007
  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");
2008
+ const resultTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
2009
+ if (resultTarget !== undefined && resultTarget !== targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start target mismatch");
1973
2010
  return {
1974
2011
  lineageId: result.lineageId,
1975
2012
  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.2",
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",
@@ -505,6 +505,7 @@ export const NATIVE_REVIEW_RECOVERY_DISPOSITION = {
505
505
 
506
506
 
507
507
 
508
+
508
509
 
509
510
 
510
511
  export const NATIVE_START_ACTION = { CREATED: "created", RESUMED: "resumed", REUSE_RECEIPT: "reuse-receipt", BLOCKED_SCOPE_ACTION: "blocked-scope-action" } ;
@@ -749,6 +750,7 @@ function exactObject(value , required , optional
749
750
  }
750
751
  function requiredString(value ) { if (typeof value !== "string" || value.length === 0) throw new Error("expected string"); return value; }
751
752
  function stringValue(value ) { if (typeof value !== "string") throw new Error("expected string"); return value; }
753
+ function sha256Identity(value ) { const parsed = requiredString(value); if (!/^sha256:[0-9a-f]{64}$/.test(parsed)) throw new Error("expected canonical SHA-256 identity"); return parsed; }
752
754
  function booleanValue(value ) { if (typeof value !== "boolean") throw new Error("expected boolean"); return value; }
753
755
  function nonNegativeInteger(value ) { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error("expected safe non-negative integer"); return value; }
754
756
  function positiveInteger(value ) { if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new Error("expected safe positive integer"); return value; }
@@ -958,7 +960,7 @@ function decodeNativeReviewRecovery(value ) {
958
960
  };
959
961
  }
960
962
  function decodeNativeReviewStatusEntry(value ) {
961
- const entry = exactObject(value, ["version", "path", "status", "problems"], ["lineage_id", "state", "revision", "chain_identity", "recovery"]);
963
+ const entry = exactObject(value, ["version", "path", "status", "problems"], ["lineage_id", "state", "revision", "snapshot_identity", "chain_identity", "recovery"]);
962
964
  return {
963
965
  version: enumString(entry.version, Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION)) ,
964
966
  ...(entry.lineage_id === undefined ? {} : { lineageId: requiredString(entry.lineage_id) }),
@@ -966,6 +968,7 @@ function decodeNativeReviewStatusEntry(value )
966
968
  status: enumString(entry.status, Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS)) ,
967
969
  ...(entry.state === undefined ? {} : { state: requiredString(entry.state) }),
968
970
  ...(entry.revision === undefined ? {} : { revision: requiredString(entry.revision) }),
971
+ ...(entry.snapshot_identity === undefined ? {} : { snapshotIdentity: sha256Identity(entry.snapshot_identity) }),
969
972
  ...(entry.chain_identity === undefined ? {} : { chainIdentity: requiredString(entry.chain_identity) }),
970
973
  ...(entry.recovery === undefined ? {} : { recovery: decodeNativeReviewRecovery(entry.recovery) }),
971
974
  problems: stringArray(entry.problems),
@@ -1038,6 +1041,21 @@ function decodeNativeReviewStatus(value ) {
1038
1041
  };
1039
1042
  }
1040
1043
  function isWindowsRepositoryPath(value ) { return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value); }
1044
+ export function normalizeNativeReviewCwd(value , platform = process.platform) {
1045
+ if (platform !== "win32") return value;
1046
+ const gitBashDrive = /^\/([A-Za-z])(?:\/(.*))?$/.exec(value);
1047
+ const windowsPath = gitBashDrive === null
1048
+ ? value
1049
+ : `${gitBashDrive[1] .toUpperCase()}:/${gitBashDrive[2] ?? ""}`;
1050
+ if (!isWindowsRepositoryPath(windowsPath)) return windowsPath;
1051
+ const normalized = win32.normalize(windowsPath);
1052
+ return normalized.replace(/^([a-z]):/, (_match, drive ) => `${drive.toUpperCase()}:`);
1053
+ }
1054
+ async function canonicalNativeReviewCwd(value ) {
1055
+ const normalized = normalizeNativeReviewCwd(value);
1056
+ try { return await realpath(normalized); }
1057
+ catch { return normalized; }
1058
+ }
1041
1059
  async function repositoryPathIdentity(value ) {
1042
1060
  const windowsPath = isWindowsRepositoryPath(value);
1043
1061
  try { return `filesystem:${windowsPath ? (await realpath(value)).toLowerCase() : await realpath(value)}`; }
@@ -1361,12 +1379,13 @@ export class NativeReviewCliV214 {
1361
1379
  // and always pass `--scope clone` so Pi's own kill-switch command surface
1362
1380
  // never mutates the operator's global gentle-ai state across other clones.
1363
1381
  async reviewMode(request ) {
1364
- await this.verifyVersion(request.cwd, request.signal, ["mode"]);
1382
+ const cwd = await canonicalNativeReviewCwd(request.cwd);
1383
+ await this.verifyVersion(cwd, request.signal, ["mode"]);
1365
1384
  const mutating = request.operation !== NATIVE_REVIEW_MODE_OPERATION.STATUS;
1366
1385
  const { body } = await this.execute(
1367
1386
  NATIVE_REVIEW_OPERATION.MODE,
1368
- request.cwd,
1369
- ["review", "mode", request.operation, "--cwd", request.cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
1387
+ cwd,
1388
+ ["review", "mode", request.operation, "--cwd", cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
1370
1389
  mutating,
1371
1390
  request.signal,
1372
1391
  );
@@ -1655,6 +1674,23 @@ export class NativeReviewConsentRequiredError extends Error {
1655
1674
  }
1656
1675
  }
1657
1676
 
1677
+ // Raised when the provider-issued consent invocation no longer matches the
1678
+ // binding Pi is answering for. Every one of these guards runs before the
1679
+ // provider is launched, so the failure is local and nothing was mutated. It
1680
+ // carries its own identity precisely so callers never report it as a provider
1681
+ // outage: an opaque `native-operation-failed` here sent issue #247 chasing a
1682
+ // missing `--cwd` that Pi does forward.
1683
+ export class NativeReviewConsentBindingError extends Error {
1684
+ reason ;
1685
+ launchAttempted = false;
1686
+ mutationOutcome = "none";
1687
+ constructor(reason , message ) {
1688
+ super(message);
1689
+ this.name = "NativeReviewConsentBindingError";
1690
+ this.reason = reason;
1691
+ }
1692
+ }
1693
+
1658
1694
  function splitNativeConsentInvocation(invocation ) {
1659
1695
  const words = [];
1660
1696
  let current = "";
@@ -1706,26 +1742,26 @@ function exactConsentOption(arguments_ , name )
1706
1742
  const token = arguments_[index] ;
1707
1743
  if (token === name) {
1708
1744
  const value = arguments_[index + 1];
1709
- if (value === undefined) throw new TypeError(`Native consent invocation ${name} is missing its value`);
1745
+ if (value === undefined) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation ${name} is missing its value`);
1710
1746
  values.push(value);
1711
1747
  index += 1;
1712
1748
  } else if (token.startsWith(`${name}=`)) values.push(token.slice(name.length + 1));
1713
1749
  }
1714
- if (values.length !== 1) throw new TypeError(`Native consent invocation requires exactly one ${name}`);
1750
+ if (values.length !== 1) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation requires exactly one ${name}`);
1715
1751
  return values[0] ;
1716
1752
  }
1717
1753
 
1718
1754
  function consentInvocationArguments(request ) {
1719
1755
  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");
1756
+ if (choice === undefined) throw new NativeReviewConsentBindingError("consent-answer-unknown", "Native consent answer must be granted or declined");
1721
1757
  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");
1758
+ 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
1759
  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");
1760
+ if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new NativeReviewConsentBindingError("consent-invocation-contract-changed", "Native consent invocation contract changed");
1761
+ if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
1762
+ if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new NativeReviewConsentBindingError("consent-invocation-target-changed", "Native consent invocation target binding changed");
1763
+ if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new NativeReviewConsentBindingError("consent-invocation-projection-changed", "Native consent invocation projection binding changed");
1764
+ 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
1765
  return arguments_;
1730
1766
  }
1731
1767
 
@@ -1938,24 +1974,21 @@ export class NativeReviewCliV216 {
1938
1974
  if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native START baseRef must be a non-empty, trimmed, NUL-free string");
1939
1975
  if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement");
1940
1976
  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.
1977
+ if (request.targetIdentity !== undefined && !/^sha256:[0-9a-f]{64}$/.test(request.targetIdentity)) throw new TypeError("Native START targetIdentity must be a canonical sha256 identity");
1978
+ // The controller supplies the target it already projected from the
1979
+ // authority workspace after proving its immutable actor view is identical.
1980
+ // Direct adapter callers may omit it and retain the same-root projection.
1948
1981
  const projection = request.projection ?? "workspace";
1949
- const target = await this.targetStatus({
1982
+ const targetIdentity = request.targetIdentity ?? (await this.targetStatus({
1950
1983
  cwd: request.cwd,
1951
1984
  projection,
1952
1985
  ...(request.baseRef === undefined ? {} : { baseRef: request.baseRef }),
1953
1986
  ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }),
1954
1987
  ...(request.signal === undefined ? {} : { signal: request.signal }),
1955
- });
1988
+ })).targetIdentity;
1956
1989
  const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, [
1957
1990
  "review", "start", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
1958
- "--target", target.targetIdentity, "--projection", projection,
1991
+ "--target", targetIdentity, "--projection", projection,
1959
1992
  ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]),
1960
1993
  ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
1961
1994
  ...(request.policyPath === undefined ? [] : ["--policy", request.policyPath]),
@@ -1967,10 +2000,14 @@ export class NativeReviewCliV216 {
1967
2000
  // explicit answer it cannot infer. Discriminate before decode and surface
1968
2001
  // the complete envelope; only the caller can map a human answer.
1969
2002
  if (execution.body.action === "consent_required") {
1970
- throw new NativeReviewConsentRequiredError(decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body)));
2003
+ const consent = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body));
2004
+ 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");
2005
+ throw new NativeReviewConsentRequiredError(consent);
1971
2006
  }
1972
2007
  const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
1973
2008
  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");
2009
+ const resultTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
2010
+ if (resultTarget !== undefined && resultTarget !== targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start target mismatch");
1974
2011
  return {
1975
2012
  lineageId: result.lineageId,
1976
2013
  state: result.state ,
@@ -668,6 +668,33 @@ test("native review status uses the anticipated v2.1.5 contract, preserves Windo
668
668
  }
669
669
  });
670
670
 
671
+ test("native review status decodes and retains the v2.2.2 compact snapshot identity", async () => {
672
+ const snapshotIdentity = "sha256:0586c3d40c3acf3db92214ddaf07afe44ad862518b48d608ae4157377ab2f3cc";
673
+ const entry = {
674
+ version: "compact-v2",
675
+ lineage_id: "issue-136-v2-1-2-final",
676
+ path: "C:\\repo with spaces\\.git\\gentle-ai\\review-transactions\\v2\\issue-136-v2-1-2-final",
677
+ status: "active",
678
+ state: "reviewing",
679
+ revision: "sha256:8103efb360d10d1717a4d22e38c8b75074f99680ed6a5eb3ab40e0ed9fb69931",
680
+ snapshot_identity: snapshotIdentity,
681
+ problems: [],
682
+ };
683
+ const status = { ...JSON.parse(REVIEW_STATUS.stdout), status: "active", entries: [entry] };
684
+ const queue = queuedAdapter([{ stdout: "gentle-ai 2.2.2\n" }, { stdout: JSON.stringify(status) }]);
685
+ const decoded = await new NativeReviewCliV213(queue.adapter).reviewStatus({ cwd: "C:\\repo with spaces" });
686
+ assert.equal(decoded.entries[0]?.snapshotIdentity, snapshotIdentity);
687
+
688
+ const malformed = queuedAdapter([{ stdout: "gentle-ai 2.2.2\n" }, { stdout: JSON.stringify({ ...status, entries: [{ ...entry, snapshot_identity: snapshotIdentity.slice("sha256:".length) }] }) }]);
689
+ await assert.rejects(
690
+ () => new NativeReviewCliV213(malformed.adapter).reviewStatus({ cwd: "C:\\repo with spaces" }),
691
+ (error: unknown) => error instanceof NativeReviewCliError
692
+ && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE
693
+ && error.operation === "review/status"
694
+ && error.mutationOutcome === "none",
695
+ );
696
+ });
697
+
671
698
  test("native review status decodes 2.1.8 released lock residue and keeps unknown lock statuses fail-closed", async () => {
672
699
  // gentle-ai 2.1.8 leaves review-transactions/v2/LOCK behind after ORDINARY
673
700
  // successful operations and reports it as {"status":"released"} without