gentle-pi 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ import { GENTLE_AI_VERSION } from "../lib/gentle-ai-binary.ts";
6
+ import {
7
+ NativeReviewCliV216,
8
+ NativeReviewConsentBindingError,
9
+ NativeReviewConsentRequiredError,
10
+ clearNativeReviewCapabilitiesCacheForTesting,
11
+ type ExecFileAdapter,
12
+ } from "../lib/native-review-cli.ts";
13
+ import type { ReviewConsentV2 } from "../lib/review-integration-v2.ts";
14
+
15
+ const fixtureRoot = join(process.cwd(), "contracts", "review-integration", "v2", "fixtures");
16
+ const fixture = <T = Record<string, unknown>>(name: string): T => JSON.parse(readFileSync(join(fixtureRoot, name), "utf8")) as T;
17
+ const executableDigest = "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705";
18
+
19
+ function capabilities(): Record<string, unknown> {
20
+ const value = fixture<Record<string, unknown>>("capabilities.fixture.json");
21
+ (value.package as Record<string, unknown>).version = GENTLE_AI_VERSION;
22
+ return value;
23
+ }
24
+
25
+ function unrelatedStatus(targetIdentity: string): Record<string, unknown> {
26
+ const status = fixture<Record<string, unknown>>("status.fixture.json");
27
+ status.applicability = "unrelated";
28
+ status.receipt = { status: "not_applicable" };
29
+ status.action = "start";
30
+ status.replayability = "not_replayable";
31
+ status.target_identity = targetIdentity;
32
+ status.candidates = [];
33
+ delete status.authority;
34
+ delete status.frozen;
35
+ delete status.next_transition;
36
+ const projection = status.projection as Record<string, unknown>;
37
+ projection.initial_snapshot_identity = targetIdentity;
38
+ projection.current_snapshot_identity = targetIdentity;
39
+ return status;
40
+ }
41
+
42
+ function queuedAdapter(outputs: readonly Record<string, unknown>[]): { adapter: ExecFileAdapter; calls: Array<readonly string[]> } {
43
+ const queue = [...outputs];
44
+ const calls: Array<readonly string[]> = [];
45
+ return {
46
+ calls,
47
+ adapter: async (request) => {
48
+ calls.push(request.arguments);
49
+ const body = queue.shift();
50
+ if (body === undefined) throw new Error("unexpected native invocation");
51
+ return { stdout: JSON.stringify(body), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
52
+ },
53
+ };
54
+ }
55
+
56
+ function client(adapter: ExecFileAdapter): NativeReviewCliV216 {
57
+ clearNativeReviewCapabilitiesCacheForTesting();
58
+ return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => executableDigest);
59
+ }
60
+
61
+ test("negotiated ordinary START declares relay and preserves the complete target-bound consent envelope", async () => {
62
+ const consent = fixture<Record<string, unknown>>("consent.fixture.json");
63
+ const target = String(consent.target_identity);
64
+ const queue = queuedAdapter([
65
+ capabilities(),
66
+ unrelatedStatus(target),
67
+ consent,
68
+ ]);
69
+ await assert.rejects(
70
+ () => client(queue.adapter).start({ cwd: "/repo" }),
71
+ (error: unknown) => {
72
+ assert.ok(error instanceof NativeReviewConsentRequiredError);
73
+ assert.deepEqual(error.consent.raw, consent);
74
+ assert.equal(error.consent.targetIdentity, target);
75
+ return true;
76
+ },
77
+ );
78
+ assert.deepEqual(queue.calls.at(-1), [
79
+ "review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
80
+ "--target", target, "--projection", "workspace", "--consent", "relay",
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");
98
+ });
99
+
100
+ test("consent follow-up executes the provider-named invocation exactly once and refuses a changed target binding", async () => {
101
+ const consent = fixture<ReviewConsentV2 extends never ? never : Record<string, unknown>>("consent.fixture.json");
102
+ const decodedConsent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(consent);
103
+ const started = JSON.parse(JSON.stringify(fixture<Record<string, unknown>>("start.fixture.json"))
104
+ .replaceAll("review-start-fixture", "review-consent-fixture")) as Record<string, unknown>;
105
+ const queue = queuedAdapter([capabilities(), started]);
106
+ const native = client(queue.adapter);
107
+ const result = await native.answerConsent!({ cwd: "/repo", consent: decodedConsent, answer: "granted" });
108
+ assert.equal(result.kind, "started");
109
+ assert.deepEqual(queue.calls.at(-1), decodedConsent.choices[0].invocation.split(" ").slice(1));
110
+ assert.equal(queue.calls.filter((arguments_) => arguments_.includes("granted")).length, 1);
111
+
112
+ const changed = structuredClone(decodedConsent);
113
+ changed.targetIdentity = `sha256:${"b".repeat(64)}`;
114
+ await assert.rejects(
115
+ () => native.answerConsent!({ cwd: "/repo", consent: changed, answer: "declined" }),
116
+ /target|binding/,
117
+ );
118
+ });
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
+
180
+ test("declined consent decodes the provider's explicit empty authority fields without creating a lineage", async () => {
181
+ const rawConsent = fixture<Record<string, unknown>>("consent.fixture.json");
182
+ const consent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(rawConsent);
183
+ const declined = {
184
+ operation: "review/start",
185
+ action: "declined",
186
+ lenses_required: false,
187
+ lineage_id: "",
188
+ state: "",
189
+ risk_level: "high",
190
+ selected_lenses: [],
191
+ lens_bindings: [],
192
+ projection: "workspace",
193
+ target_identity: consent.targetIdentity,
194
+ changed_files: 1,
195
+ changed_lines: 1,
196
+ correction_budget: 0,
197
+ risk_evidence: ["shell scripting in scripts/deploy.sh"],
198
+ consent: "declined_this_candidate",
199
+ };
200
+ const queue = queuedAdapter([capabilities(), declined]);
201
+ const result = await client(queue.adapter).answerConsent!({ cwd: "/repo", consent, answer: "declined" });
202
+ assert.equal(result.kind, "declined");
203
+ assert.equal("lineageId" in result, false);
204
+ assert.deepEqual(queue.calls.at(-1), consent.choices[1].invocation.split(" ").slice(1));
205
+ });
@@ -378,7 +378,7 @@ test("official pinned package runtime keeps frozen candidate lineages and receip
378
378
  t.diagnostic("pre-push, pre-pr, and release require remote/publication evidence; their network-aware gate contracts remain covered by dedicated gate integration tests rather than this hermetic binary E2E.");
379
379
  });
380
380
 
381
- test("registered gentle_review START materializes a safe internal skill symlink before invoking native authority", async (t) => {
381
+ test("registered gentle_review START materializes a safe internal skill symlink and cleans pending consent on session shutdown", async (t) => {
382
382
  const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-symlink-candidate-"));
383
383
  const repository = join(workspace, "repository");
384
384
  t.after(async () => rm(workspace, { recursive: true, force: true }));
@@ -412,8 +412,9 @@ test("registered gentle_review START materializes a safe internal skill symlink
412
412
  return { ...command, signal: null, timedOut: false, outputLimitExceeded: false };
413
413
  });
414
414
  const tools = new Map<string, RegisteredController>();
415
+ let sessionShutdown: ((event: unknown, context: ExtensionContext) => Promise<void> | void) | undefined;
415
416
  createGentleAiExtension({ nativeReviewCli: native, candidateViews } as Parameters<typeof createGentleAiExtension>[0])({
416
- on() {},
417
+ on(name: string, handler: (event: unknown, context: ExtensionContext) => Promise<void> | void) { if (name === "session_shutdown") sessionShutdown = handler; },
417
418
  registerTool(definition: RegisteredController & { name: string }) { tools.set(definition.name, definition); },
418
419
  registerCommand() {},
419
420
  } as unknown as ExtensionAPI);
@@ -434,8 +435,16 @@ test("registered gentle_review START materializes a safe internal skill symlink
434
435
  t.diagnostic(JSON.stringify({ returned: returned?.details, error, nativeStartReached }));
435
436
  assert.equal(thrown, undefined, "safe internal symlink materialization must not throw before START");
436
437
  assert.equal(nativeStartReached, true, "safe internal symlink materialization must reach native START");
437
- const result = (returned?.details as { result?: Record<string, unknown> } | undefined)?.result;
438
- assert.equal(typeof result?.lineage_id, "string", "safe internal symlink materialization must return native review authority");
439
- assert.equal(result?.state, "reviewing");
440
- candidateViews.cleanup(candidateViews.resolveForLens(result!.lineage_id as string, "review-reliability").token);
438
+ const result = returned?.details as Record<string, unknown> | undefined;
439
+ assert.equal(result?.status, "blocked");
440
+ assert.equal(result?.outcome, "native-review-consent-required");
441
+ assert.equal(typeof result?.consent_binding, "string");
442
+ assert.equal(result?.lineage_created, false);
443
+ assert.ok(sessionShutdown, "extension must register session_shutdown cleanup");
444
+ const context = { cwd: repository, hasUI: false, ui: { notify: () => {} } } as unknown as ExtensionContext;
445
+ await sessionShutdown({}, context);
446
+ await assert.rejects(
447
+ controller.execute("answer-after-shutdown", { operation: "answer-consent", input: JSON.stringify({ consentBinding: result!.consent_binding, answer: "granted" }) }, undefined, undefined, context),
448
+ /unknown, expired, or already consumed/,
449
+ );
441
450
  });
@@ -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,14 +9,20 @@ import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
9
9
  import {
10
10
  NATIVE_REVIEW_ERROR_CODE,
11
11
  NativeReviewCliError,
12
+ NativeReviewConsentBindingError,
13
+ NativeReviewConsentRequiredError,
12
14
  NativeReviewCliV213 as NativeReviewCliV213Production,
15
+ normalizeNativeReviewCwd,
13
16
  setNativeCliContractForTesting,
14
17
  type ExecFileAdapter,
15
18
  type NativeReviewCli,
19
+ type NativeReviewConsentAnswer,
20
+ type NativeReviewConsentAnswerRequest,
21
+ type NativeStartRequest,
16
22
  } from "../lib/native-review-cli.ts";
17
23
  import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
18
- import { readReviewConsentLatch, recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
19
- import type { AuthorityRepairAssessmentV1, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
24
+ import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
25
+ import type { AuthorityRepairAssessmentV1, ReviewConsentV2, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
20
26
 
21
27
  // Queued-adapter clients never execute a real process; default to a fixed
22
28
  // absolute package-local path so these tests do not depend on an installed
@@ -110,6 +116,26 @@ test("reviewMode status uses the exact fixed argv and decodes the effective mode
110
116
  assert.equal(result.scope, "both");
111
117
  });
112
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
+
113
139
  test("reviewMode status decodes an off effective mode with its deciding source", async () => {
114
140
  const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("off", { clone_local: "off", source: "clone_local", revision: "sha256:deadbeef" })) }]);
115
141
  const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" });
@@ -290,18 +316,22 @@ const UNSUPPORTED_REPAIR_ASSESSMENT: AuthorityRepairAssessmentV1 = {
290
316
  authorizationSchema: "gentle-ai.review-repair-authorization/v1",
291
317
  };
292
318
 
293
- function unrelatedStartTargetStatus(): ReviewStatusV3 {
319
+ function unrelatedStartTargetStatus(cwd: string): ReviewStatusV3 {
294
320
  const sha = `sha256:${"a".repeat(64)}`;
295
- 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();
296
326
  const projection = {
297
327
  schema: "gentle-ai.review-integration.projection/v1" as const,
298
328
  kind: "current-changes" as const,
299
329
  projection: "workspace" as const,
300
- baseTree: tree,
330
+ baseTree,
301
331
  initialReviewTree: tree,
302
332
  currentCandidateTree: tree,
303
333
  pathsDigest: sha,
304
- paths: ["app.ts"],
334
+ paths,
305
335
  intendedUntracked: [],
306
336
  intendedUntrackedProof: sha,
307
337
  initialSnapshotIdentity: sha,
@@ -331,7 +361,7 @@ function unrelatedStartTargetStatus(): ReviewStatusV3 {
331
361
  schema: "gentle-ai.review-integration.status/v3", contract: "gentle-ai.review-integration/v2", operation: "review.status",
332
362
  applicability: "unrelated", receipt: { status: "not_applicable" }, action: "start", replayability: "not_replayable", target_identity: sha,
333
363
  repair: rawRepair,
334
- 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 },
335
365
  candidates: [],
336
366
  },
337
367
  };
@@ -367,8 +397,8 @@ function fakeOrganicNative(options: FakeOrganicNativeOptions = {}): { native: Na
367
397
  async bindSdd(): Promise<never> { throw new Error("bindSdd not used in this test"); },
368
398
  async sddStatus(): Promise<never> { throw new Error("sddStatus not used in this test"); },
369
399
  async reviewStatus(): Promise<never> { throw new Error("reviewStatus not used in this test"); },
370
- async targetStatus() {
371
- return unrelatedStartTargetStatus();
400
+ async targetStatus(request: { cwd: string }) {
401
+ return unrelatedStartTargetStatus(request.cwd);
372
402
  },
373
403
  ...(reviewModeCapable
374
404
  ? {
@@ -407,18 +437,23 @@ interface RegisteredCommandFixture {
407
437
  handler: (args: string, ctx: ExtensionContext) => Promise<void>;
408
438
  }
409
439
 
410
- function runtime(nativeReviewCli: NativeReviewCli | null): { controller: RegisteredTool; commands: Map<string, RegisteredCommandFixture> } {
440
+ interface RegisteredEventFixture {
441
+ (event: unknown, ctx: ExtensionContext): Promise<unknown> | unknown;
442
+ }
443
+
444
+ function runtime(nativeReviewCli: NativeReviewCli | null): { controller: RegisteredTool; commands: Map<string, RegisteredCommandFixture>; events: Map<string, RegisteredEventFixture> } {
411
445
  const tools = new Map<string, RegisteredTool>();
412
446
  const commands = new Map<string, RegisteredCommandFixture>();
447
+ const events = new Map<string, RegisteredEventFixture>();
413
448
  const dependencies = { nativeReviewCli, candidateViews: new CandidateViewRegistry() } as unknown as Parameters<typeof createGentleAiExtension>[0];
414
449
  createGentleAiExtension(dependencies)({
415
- on() {},
450
+ on(name: string, handler: RegisteredEventFixture) { events.set(name, handler); },
416
451
  registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
417
452
  registerCommand(name: string, definition: RegisteredCommandFixture) { commands.set(name, definition); },
418
453
  } as unknown as ExtensionAPI);
419
454
  const controller = tools.get("gentle_review");
420
455
  assert.ok(controller);
421
- return { controller: controller!, commands };
456
+ return { controller: controller!, commands, events };
422
457
  }
423
458
 
424
459
  function headlessContext(cwd: string, notices: Array<{ message: string; type?: string }> = []): ExtensionContext {
@@ -447,6 +482,7 @@ test("kill-switch: effective off returns a non-failure skipped envelope and neve
447
482
  const result = await execStart(controller, "kill-switch-off", headlessContext(cwd));
448
483
  assert.equal(result.status, "skipped");
449
484
  assert.equal(result.outcome, "review-mode-disabled");
485
+ assert.equal(result.delivery, "disabled/unmanaged");
450
486
  assert.equal(result.mutation_performed, false);
451
487
  assert.equal(state.startCalls, 0, "native start must not be called once the kill switch reports off");
452
488
  });
@@ -516,67 +552,206 @@ test("kill-switch: an unexpected reviewMode failure maps to the existing native-
516
552
  assert.equal(result.outcome, "native-operation-failed");
517
553
  });
518
554
 
519
- test("consent: an existing latch skips the prompt and proceeds with actor_binding", async (t) => {
555
+ function candidateConsent(cwd: string): ReviewConsentV2 {
556
+ const targetIdentity = `sha256:${"a".repeat(64)}`;
557
+ const choices = [
558
+ { answer: "granted" as const, label: "Run the review now", effect: "Review this exact candidate only.", invocation: `gentle-ai review start --contract gentle-ai.review-integration/v2 --cwd ${cwd} --target ${targetIdentity} --projection workspace --lineage native-lineage --consent granted` },
559
+ { answer: "declined" as const, label: "Not now, just this once", effect: "Create no authority and ask again next candidate.", invocation: `gentle-ai review start --contract gentle-ai.review-integration/v2 --cwd ${cwd} --target ${targetIdentity} --projection workspace --lineage native-lineage --consent declined` },
560
+ ] as const;
561
+ const raw = { schema: "gentle-ai.review-integration.consent/v2", contract: "gentle-ai.review-integration/v2", operation: "review.start", action: "consent_required", blocking: true, target_identity: targetIdentity, projection: "workspace", risk_level: "high", changed_files: 1, changed_lines: 1, headline: "Review this candidate", reason: "It changes a process boundary.", value: "Review catches regressions.", risk_evidence: ["shell process"], choices: choices.map((choice) => ({ ...choice })), off_path: { note: "Disable reviews separately.", command: "gentle-ai review mode disable" } };
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 };
563
+ }
564
+
565
+ function relayedConsentNative(cwd: string): { native: NativeReviewCli; answers: NativeReviewConsentAnswer[]; startRequests: NativeStartRequest[]; answerRequests: NativeReviewConsentAnswerRequest[] } {
566
+ const { native } = fakeOrganicNative();
567
+ const consent = candidateConsent(cwd);
568
+ const answers: NativeReviewConsentAnswer[] = [];
569
+ const startRequests: NativeStartRequest[] = [];
570
+ const answerRequests: NativeReviewConsentAnswerRequest[] = [];
571
+ native.start = async (request) => {
572
+ startRequests.push(request);
573
+ throw new NativeReviewConsentRequiredError(consent);
574
+ };
575
+ native.answerConsent = async (request) => {
576
+ answers.push(request.answer);
577
+ answerRequests.push(request);
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" } };
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 } };
580
+ };
581
+ return { native, answers, startRequests, answerRequests };
582
+ }
583
+
584
+ async function answerConsent(controller: RegisteredTool, binding: unknown, answer: unknown, ctx: ExtensionContext): Promise<Record<string, unknown>> {
585
+ const { details } = await controller.execute("consent-answer", { operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer }) }, undefined, undefined, ctx);
586
+ return details as Record<string, unknown>;
587
+ }
588
+
589
+ async function blockedConsent(controller: RegisteredTool, id: string, ctx: ExtensionContext): Promise<Record<string, unknown>> {
590
+ const result = await execStart(controller, id, ctx);
591
+ assert.equal(result.status, "blocked");
592
+ assert.equal(result.outcome, "native-review-consent-required");
593
+ assert.equal(typeof result.consent_binding, "string");
594
+ return result;
595
+ }
596
+
597
+ test("consent relay returns the identical complete parent-visible envelope with or without UI and never answers internally", async (t) => {
520
598
  const cwd = repository(t);
521
599
  recordReviewConsentLatch(cwd);
522
- const { native } = fakeOrganicNative({ riskEvidence: ["shell scripting in deploy.sh"] });
523
- const { controller } = runtime(native);
524
- const result = await execStart(controller, "consent-latched", confirmContext(cwd, false));
525
- assert.ok(result.actor_binding, "an existing latch must not block actor_binding even though confirm() would decline");
526
- assert.equal(result.consent_notice, undefined);
600
+ for (const ctx of [
601
+ { cwd, hasUI: true, ui: { select: async () => { throw new Error("internal consent UI must not run"); }, notify: () => {} } } as unknown as ExtensionContext,
602
+ headlessContext(cwd),
603
+ ]) {
604
+ const { native, answers } = relayedConsentNative(cwd);
605
+ const result = await blockedConsent(runtime(native).controller, "consent-blocked", ctx);
606
+ assert.deepEqual(result.consent, candidateConsent(cwd).raw);
607
+ assert.deepEqual(answers, []);
608
+ assert.equal(result.lineage_created, false);
609
+ }
527
610
  });
528
611
 
529
- test("consent: headless never blocks, always surfaces a notice, and leaves the latch untouched", async (t) => {
612
+ test("explicit consent follow-up grants or declines exactly once", async (t) => {
530
613
  const cwd = repository(t);
531
- const { native } = fakeOrganicNative({ riskEvidence: ["service credentials in .env.example"] });
532
- const { controller } = runtime(native);
533
- const notices: Array<{ message: string; type?: string }> = [];
534
- const result = await execStart(controller, "consent-headless", headlessContext(cwd, notices));
535
- assert.ok(result.actor_binding, "headless must still run the review");
536
- assert.equal(typeof result.consent_notice, "string");
537
- assert.ok(notices.some((notice) => notice.type === "info" && notice.message === result.consent_notice));
538
- assert.equal(readReviewConsentLatch(cwd), false, "headless must never consume/persist the one-time question");
614
+ for (const answer of ["granted", "declined"] as const) {
615
+ const { native, answers, startRequests, answerRequests } = relayedConsentNative(cwd);
616
+ const { controller } = runtime(native);
617
+ const blocked = await blockedConsent(controller, `consent-${answer}`, headlessContext(cwd));
618
+ const result = await answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd));
619
+ assert.deepEqual(answers, [answer]);
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 {
632
+ assert.equal(result.outcome, "consent-declined-this-candidate");
633
+ assert.equal(result.lineage_created, false);
634
+ assert.equal(result.actor_binding, undefined);
635
+ }
636
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd)), /unknown, expired, or already consumed/);
637
+ }
539
638
  });
540
639
 
541
- test("consent: accepting the prompt records the latch and proceeds with actor_binding", async (t) => {
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) => {
542
643
  const cwd = repository(t);
543
- const { native } = fakeOrganicNative({ riskEvidence: ["payments in billing.ts"] });
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
+ };
544
648
  const { controller } = runtime(native);
545
- assert.equal(readReviewConsentLatch(cwd), false);
546
- const result = await execStart(controller, "consent-accept", confirmContext(cwd, true));
547
- assert.ok(result.actor_binding);
548
- assert.equal(readReviewConsentLatch(cwd), true);
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, []);
549
660
  });
550
661
 
551
- test("consent: declining persists nothing, applies only to this work unit, and withholds actor_binding", async (t) => {
662
+ test("consent follow-up rejects invalid token, unknown id, changed cwd, and changed target binding", async (t) => {
552
663
  const cwd = repository(t);
553
- const { native } = fakeOrganicNative({ riskEvidence: ["authentication in auth.ts"] });
664
+ const consent = candidateConsent(cwd);
665
+ const { native, answers } = relayedConsentNative(cwd);
666
+ native.start = async () => { throw new NativeReviewConsentRequiredError(consent); };
554
667
  const { controller } = runtime(native);
555
- const result = await execStart(controller, "consent-decline", confirmContext(cwd, false));
556
- assert.equal(result.actor_binding, undefined, "declining must withhold actor dispatch for this work unit");
557
- assert.equal(readReviewConsentLatch(cwd), false, "declining must never persist anything");
558
- assert.ok(result.result, "the native start result itself is still reported");
668
+ const blocked = await blockedConsent(controller, "consent-invalid", headlessContext(cwd));
669
+ await assert.rejects(() => controller.execute("malformed", { operation: "answer-consent", input: "not-json" }, undefined, undefined, headlessContext(cwd)), /input is not valid JSON/);
670
+ await assert.rejects(() => controller.execute("extra", { operation: "answer-consent", input: JSON.stringify({ consentBinding: blocked.consent_binding, answer: "granted", target: "substitute" }) }, undefined, undefined, headlessContext(cwd)), /exactly consentBinding and answer/);
671
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "yes", headlessContext(cwd)), /answer must be granted or declined/);
672
+ await assert.rejects(() => answerConsent(controller, "missing", "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
673
+ const other = repository(t);
674
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(other)), /repository binding changed/);
675
+ const originalTarget = consent.targetIdentity;
676
+ (consent as { targetIdentity: string }).targetIdentity = `sha256:${"b".repeat(64)}`;
677
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
678
+ (consent as { targetIdentity: string }).targetIdentity = originalTarget;
679
+ (consent as { projection: string }).projection = "staged";
680
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
681
+ (consent as { projection: string }).projection = "workspace";
682
+ (consent.choices[0] as { invocation: string }).invocation = consent.choices[0].invocation.replace("native-lineage", "changed-lineage");
683
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
684
+ assert.deepEqual(answers, []);
685
+ });
686
+
687
+ test("ambiguous consent mutation consumes the one-shot binding and requires status instead of blind replay", async (t) => {
688
+ const cwd = repository(t);
689
+ const { native } = relayedConsentNative(cwd);
690
+ let statusCalls = 0;
691
+ const targetStatus = native.targetStatus!;
692
+ native.targetStatus = async (request) => { statusCalls += 1; return await targetStatus(request); };
693
+ native.answerConsent = async () => { throw Object.assign(new Error("ambiguous"), { mutationOutcome: "unknown", nextAction: "review.status" }); };
694
+ const { controller } = runtime(native);
695
+ const blocked = await blockedConsent(controller, "consent-ambiguous", headlessContext(cwd));
696
+ await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
697
+ assert.equal(statusCalls, 2, "ambiguous consent must reconcile through target status");
698
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
559
699
  });
560
700
 
561
- test("consent: an unreadable answer (confirm throws) still runs the review, leaves the latch untouched, and surfaces a notice", async (t) => {
701
+ test("session shutdown clears pending candidate consent bindings and is idempotent", async (t) => {
702
+ const cwd = repository(t);
703
+ const { native, answers } = relayedConsentNative(cwd);
704
+ const { controller, events } = runtime(native);
705
+ const blocked = await blockedConsent(controller, "consent-before-shutdown", headlessContext(cwd));
706
+ const shutdown = events.get("session_shutdown");
707
+ assert.ok(shutdown);
708
+ await shutdown({}, headlessContext(cwd));
709
+ await shutdown({}, headlessContext(cwd));
710
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
711
+ assert.deepEqual(answers, []);
712
+ });
713
+
714
+ test("successful explicit disable clears pending candidate consent binding even after re-enable", async (t) => {
562
715
  const cwd = repository(t);
563
- const { native } = fakeOrganicNative({ riskEvidence: ["an executable permission change in run.sh"] });
716
+ const { native, answers } = relayedConsentNative(cwd);
717
+ let effective: "on" | "off" = "on";
718
+ native.reviewMode = async (request) => {
719
+ if (request.operation === "disable") effective = "off";
720
+ if (request.operation === "enable") effective = "on";
721
+ return { operation: request.operation, scope: "clone", status: { global: "", cloneLocal: effective === "off" ? "off" : "", effective, source: effective === "off" ? "clone_local" : "default" } };
722
+ };
723
+ const { controller, commands } = runtime(native);
724
+ const blocked = await blockedConsent(controller, "consent-before-disable", headlessContext(cwd));
725
+ const command = commands.get("gentle:review-mode")!;
726
+ await command.handler("disable", headlessContext(cwd));
727
+ await command.handler("enable", headlessContext(cwd));
728
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
729
+ assert.deepEqual(answers, []);
730
+ });
731
+
732
+ test("candidate-scoped decline creates no authority and the next candidate asks again", async (t) => {
733
+ const cwd = repository(t);
734
+ const { native, answers } = relayedConsentNative(cwd);
564
735
  const { controller } = runtime(native);
565
- const result = await execStart(controller, "consent-throw", throwingConfirmContext(cwd));
566
- assert.ok(result.actor_binding, "an unreadable answer must still run the review");
567
- assert.equal(typeof result.consent_notice, "string");
568
- assert.equal(readReviewConsentLatch(cwd), false);
736
+ for (const id of ["decline-one", "decline-two"]) {
737
+ const blocked = await blockedConsent(controller, id, headlessContext(cwd));
738
+ const result = await answerConsent(controller, blocked.consent_binding, "declined", headlessContext(cwd));
739
+ assert.equal(result.outcome, "consent-declined-this-candidate");
740
+ assert.equal(result.lineage_created, false);
741
+ assert.equal(result.actor_binding, undefined);
742
+ }
743
+ assert.deepEqual(answers, ["declined", "declined"]);
569
744
  });
570
745
 
571
- test("consent stays dark (capability-gated) when the native start result carries no riskEvidence", async (t) => {
746
+ test("low-risk zero-lens START remains silent", async (t) => {
572
747
  const cwd = repository(t);
573
- const { native } = fakeOrganicNative({ riskEvidence: undefined, lensesRequired: true });
748
+ const { native } = fakeOrganicNative({ riskEvidence: undefined, lensesRequired: false });
574
749
  const { controller } = runtime(native);
575
- let confirmed = false;
576
- const ctx = { cwd, hasUI: true, ui: { confirm: async () => { confirmed = true; return true; }, notify: () => {} } } as unknown as ExtensionContext;
577
- const result = await execStart(controller, "consent-dark", ctx);
578
- assert.equal(confirmed, false, "no riskEvidence (dark riskEvidence capability) must never trigger the consent prompt");
579
- assert.ok(result.actor_binding);
750
+ let selected = false;
751
+ const ctx = { cwd, hasUI: true, ui: { select: async () => { selected = true; return undefined; }, notify: () => {} } } as unknown as ExtensionContext;
752
+ const result = await execStart(controller, "consent-low-risk", ctx);
753
+ assert.equal(selected, false);
754
+ assert.ok(result.result);
580
755
  });
581
756
 
582
757
  // ---------------------------------------------------------------------------