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.
@@ -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.2 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.2", "the release manifest must remain explicitly pinned to v2.1.2");
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";