gentle-pi 2.0.0 → 2.1.0

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,128 @@
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
+ NativeReviewConsentRequiredError,
9
+ clearNativeReviewCapabilitiesCacheForTesting,
10
+ type ExecFileAdapter,
11
+ } from "../lib/native-review-cli.ts";
12
+ import type { ReviewConsentV2 } from "../lib/review-integration-v2.ts";
13
+
14
+ const fixtureRoot = join(process.cwd(), "contracts", "review-integration", "v2", "fixtures");
15
+ const fixture = <T = Record<string, unknown>>(name: string): T => JSON.parse(readFileSync(join(fixtureRoot, name), "utf8")) as T;
16
+ const executableDigest = "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705";
17
+
18
+ function capabilities(): Record<string, unknown> {
19
+ const value = fixture<Record<string, unknown>>("capabilities.fixture.json");
20
+ (value.package as Record<string, unknown>).version = GENTLE_AI_VERSION;
21
+ return value;
22
+ }
23
+
24
+ function unrelatedStatus(targetIdentity: string): Record<string, unknown> {
25
+ const status = fixture<Record<string, unknown>>("status.fixture.json");
26
+ status.applicability = "unrelated";
27
+ status.receipt = { status: "not_applicable" };
28
+ status.action = "start";
29
+ status.replayability = "not_replayable";
30
+ status.target_identity = targetIdentity;
31
+ status.candidates = [];
32
+ delete status.authority;
33
+ delete status.frozen;
34
+ delete status.next_transition;
35
+ const projection = status.projection as Record<string, unknown>;
36
+ projection.initial_snapshot_identity = targetIdentity;
37
+ projection.current_snapshot_identity = targetIdentity;
38
+ return status;
39
+ }
40
+
41
+ function queuedAdapter(outputs: readonly Record<string, unknown>[]): { adapter: ExecFileAdapter; calls: Array<readonly string[]> } {
42
+ const queue = [...outputs];
43
+ const calls: Array<readonly string[]> = [];
44
+ return {
45
+ calls,
46
+ adapter: async (request) => {
47
+ calls.push(request.arguments);
48
+ const body = queue.shift();
49
+ if (body === undefined) throw new Error("unexpected native invocation");
50
+ return { stdout: JSON.stringify(body), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
51
+ },
52
+ };
53
+ }
54
+
55
+ function client(adapter: ExecFileAdapter): NativeReviewCliV216 {
56
+ clearNativeReviewCapabilitiesCacheForTesting();
57
+ return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => executableDigest);
58
+ }
59
+
60
+ test("negotiated ordinary START declares relay and preserves the complete target-bound consent envelope", async () => {
61
+ const consent = fixture<Record<string, unknown>>("consent.fixture.json");
62
+ const target = String(consent.target_identity);
63
+ const queue = queuedAdapter([
64
+ capabilities(),
65
+ unrelatedStatus(target),
66
+ consent,
67
+ ]);
68
+ await assert.rejects(
69
+ () => client(queue.adapter).start({ cwd: "/repo" }),
70
+ (error: unknown) => {
71
+ assert.ok(error instanceof NativeReviewConsentRequiredError);
72
+ assert.deepEqual(error.consent.raw, consent);
73
+ assert.equal(error.consent.targetIdentity, target);
74
+ return true;
75
+ },
76
+ );
77
+ assert.deepEqual(queue.calls.at(-1), [
78
+ "review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
79
+ "--target", target, "--projection", "workspace", "--consent", "relay",
80
+ ]);
81
+ });
82
+
83
+ test("consent follow-up executes the provider-named invocation exactly once and refuses a changed target binding", async () => {
84
+ const consent = fixture<ReviewConsentV2 extends never ? never : Record<string, unknown>>("consent.fixture.json");
85
+ const decodedConsent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(consent);
86
+ const started = JSON.parse(JSON.stringify(fixture<Record<string, unknown>>("start.fixture.json"))
87
+ .replaceAll("review-start-fixture", "review-consent-fixture")) as Record<string, unknown>;
88
+ const queue = queuedAdapter([capabilities(), started]);
89
+ const native = client(queue.adapter);
90
+ const result = await native.answerConsent!({ cwd: "/repo", consent: decodedConsent, answer: "granted" });
91
+ assert.equal(result.kind, "started");
92
+ assert.deepEqual(queue.calls.at(-1), decodedConsent.choices[0].invocation.split(" ").slice(1));
93
+ assert.equal(queue.calls.filter((arguments_) => arguments_.includes("granted")).length, 1);
94
+
95
+ const changed = structuredClone(decodedConsent);
96
+ changed.targetIdentity = `sha256:${"b".repeat(64)}`;
97
+ await assert.rejects(
98
+ () => native.answerConsent!({ cwd: "/repo", consent: changed, answer: "declined" }),
99
+ /target|binding/,
100
+ );
101
+ });
102
+
103
+ test("declined consent decodes the provider's explicit empty authority fields without creating a lineage", async () => {
104
+ const rawConsent = fixture<Record<string, unknown>>("consent.fixture.json");
105
+ const consent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(rawConsent);
106
+ const declined = {
107
+ operation: "review/start",
108
+ action: "declined",
109
+ lenses_required: false,
110
+ lineage_id: "",
111
+ state: "",
112
+ risk_level: "high",
113
+ selected_lenses: [],
114
+ lens_bindings: [],
115
+ projection: "workspace",
116
+ target_identity: consent.targetIdentity,
117
+ changed_files: 1,
118
+ changed_lines: 1,
119
+ correction_budget: 0,
120
+ risk_evidence: ["shell scripting in scripts/deploy.sh"],
121
+ consent: "declined_this_candidate",
122
+ };
123
+ const queue = queuedAdapter([capabilities(), declined]);
124
+ const result = await client(queue.adapter).answerConsent!({ cwd: "/repo", consent, answer: "declined" });
125
+ assert.equal(result.kind, "declined");
126
+ assert.equal("lineageId" in result, false);
127
+ assert.deepEqual(queue.calls.at(-1), consent.choices[1].invocation.split(" ").slice(1));
128
+ });
@@ -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
  });
@@ -9,14 +9,16 @@ import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
9
9
  import {
10
10
  NATIVE_REVIEW_ERROR_CODE,
11
11
  NativeReviewCliError,
12
+ NativeReviewConsentRequiredError,
12
13
  NativeReviewCliV213 as NativeReviewCliV213Production,
13
14
  setNativeCliContractForTesting,
14
15
  type ExecFileAdapter,
15
16
  type NativeReviewCli,
17
+ type NativeReviewConsentAnswer,
16
18
  } from "../lib/native-review-cli.ts";
17
19
  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";
20
+ import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
21
+ import type { AuthorityRepairAssessmentV1, ReviewConsentV2, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
20
22
 
21
23
  // Queued-adapter clients never execute a real process; default to a fixed
22
24
  // absolute package-local path so these tests do not depend on an installed
@@ -407,18 +409,23 @@ interface RegisteredCommandFixture {
407
409
  handler: (args: string, ctx: ExtensionContext) => Promise<void>;
408
410
  }
409
411
 
410
- function runtime(nativeReviewCli: NativeReviewCli | null): { controller: RegisteredTool; commands: Map<string, RegisteredCommandFixture> } {
412
+ interface RegisteredEventFixture {
413
+ (event: unknown, ctx: ExtensionContext): Promise<unknown> | unknown;
414
+ }
415
+
416
+ function runtime(nativeReviewCli: NativeReviewCli | null): { controller: RegisteredTool; commands: Map<string, RegisteredCommandFixture>; events: Map<string, RegisteredEventFixture> } {
411
417
  const tools = new Map<string, RegisteredTool>();
412
418
  const commands = new Map<string, RegisteredCommandFixture>();
419
+ const events = new Map<string, RegisteredEventFixture>();
413
420
  const dependencies = { nativeReviewCli, candidateViews: new CandidateViewRegistry() } as unknown as Parameters<typeof createGentleAiExtension>[0];
414
421
  createGentleAiExtension(dependencies)({
415
- on() {},
422
+ on(name: string, handler: RegisteredEventFixture) { events.set(name, handler); },
416
423
  registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
417
424
  registerCommand(name: string, definition: RegisteredCommandFixture) { commands.set(name, definition); },
418
425
  } as unknown as ExtensionAPI);
419
426
  const controller = tools.get("gentle_review");
420
427
  assert.ok(controller);
421
- return { controller: controller!, commands };
428
+ return { controller: controller!, commands, events };
422
429
  }
423
430
 
424
431
  function headlessContext(cwd: string, notices: Array<{ message: string; type?: string }> = []): ExtensionContext {
@@ -447,6 +454,7 @@ test("kill-switch: effective off returns a non-failure skipped envelope and neve
447
454
  const result = await execStart(controller, "kill-switch-off", headlessContext(cwd));
448
455
  assert.equal(result.status, "skipped");
449
456
  assert.equal(result.outcome, "review-mode-disabled");
457
+ assert.equal(result.delivery, "disabled/unmanaged");
450
458
  assert.equal(result.mutation_performed, false);
451
459
  assert.equal(state.startCalls, 0, "native start must not be called once the kill switch reports off");
452
460
  });
@@ -516,67 +524,168 @@ test("kill-switch: an unexpected reviewMode failure maps to the existing native-
516
524
  assert.equal(result.outcome, "native-operation-failed");
517
525
  });
518
526
 
519
- test("consent: an existing latch skips the prompt and proceeds with actor_binding", async (t) => {
527
+ function candidateConsent(cwd: string): ReviewConsentV2 {
528
+ const targetIdentity = `sha256:${"a".repeat(64)}`;
529
+ const choices = [
530
+ { 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` },
531
+ { 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` },
532
+ ] as const;
533
+ 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" } };
534
+ 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
+ }
536
+
537
+ function relayedConsentNative(cwd: string): { native: NativeReviewCli; answers: NativeReviewConsentAnswer[] } {
538
+ const { native } = fakeOrganicNative();
539
+ const consent = candidateConsent(cwd);
540
+ const answers: NativeReviewConsentAnswer[] = [];
541
+ native.start = async () => { throw new NativeReviewConsentRequiredError(consent); };
542
+ native.answerConsent = async (request) => {
543
+ answers.push(request.answer);
544
+ 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
+ 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
+ };
547
+ return { native, answers };
548
+ }
549
+
550
+ async function answerConsent(controller: RegisteredTool, binding: unknown, answer: unknown, ctx: ExtensionContext): Promise<Record<string, unknown>> {
551
+ const { details } = await controller.execute("consent-answer", { operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer }) }, undefined, undefined, ctx);
552
+ return details as Record<string, unknown>;
553
+ }
554
+
555
+ async function blockedConsent(controller: RegisteredTool, id: string, ctx: ExtensionContext): Promise<Record<string, unknown>> {
556
+ const result = await execStart(controller, id, ctx);
557
+ assert.equal(result.status, "blocked");
558
+ assert.equal(result.outcome, "native-review-consent-required");
559
+ assert.equal(typeof result.consent_binding, "string");
560
+ return result;
561
+ }
562
+
563
+ test("consent relay returns the identical complete parent-visible envelope with or without UI and never answers internally", async (t) => {
520
564
  const cwd = repository(t);
521
565
  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);
566
+ for (const ctx of [
567
+ { cwd, hasUI: true, ui: { select: async () => { throw new Error("internal consent UI must not run"); }, notify: () => {} } } as unknown as ExtensionContext,
568
+ headlessContext(cwd),
569
+ ]) {
570
+ const { native, answers } = relayedConsentNative(cwd);
571
+ const result = await blockedConsent(runtime(native).controller, "consent-blocked", ctx);
572
+ assert.deepEqual(result.consent, candidateConsent(cwd).raw);
573
+ assert.deepEqual(answers, []);
574
+ assert.equal(result.lineage_created, false);
575
+ }
527
576
  });
528
577
 
529
- test("consent: headless never blocks, always surfaces a notice, and leaves the latch untouched", async (t) => {
578
+ test("explicit consent follow-up grants or declines exactly once", async (t) => {
530
579
  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");
580
+ for (const answer of ["granted", "declined"] as const) {
581
+ const { native, answers } = relayedConsentNative(cwd);
582
+ const { controller } = runtime(native);
583
+ const blocked = await blockedConsent(controller, `consent-${answer}`, headlessContext(cwd));
584
+ const result = await answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd));
585
+ assert.deepEqual(answers, [answer]);
586
+ if (answer === "granted") assert.ok(result.actor_binding);
587
+ else {
588
+ assert.equal(result.outcome, "consent-declined-this-candidate");
589
+ assert.equal(result.lineage_created, false);
590
+ assert.equal(result.actor_binding, undefined);
591
+ }
592
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd)), /unknown, expired, or already consumed/);
593
+ }
539
594
  });
540
595
 
541
- test("consent: accepting the prompt records the latch and proceeds with actor_binding", async (t) => {
596
+ test("consent follow-up rejects invalid token, unknown id, changed cwd, and changed target binding", async (t) => {
597
+ const cwd = repository(t);
598
+ const consent = candidateConsent(cwd);
599
+ const { native, answers } = relayedConsentNative(cwd);
600
+ native.start = async () => { throw new NativeReviewConsentRequiredError(consent); };
601
+ const { controller } = runtime(native);
602
+ const blocked = await blockedConsent(controller, "consent-invalid", headlessContext(cwd));
603
+ await assert.rejects(() => controller.execute("malformed", { operation: "answer-consent", input: "not-json" }, undefined, undefined, headlessContext(cwd)), /input is not valid JSON/);
604
+ 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/);
605
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "yes", headlessContext(cwd)), /answer must be granted or declined/);
606
+ await assert.rejects(() => answerConsent(controller, "missing", "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
607
+ const other = repository(t);
608
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(other)), /repository binding changed/);
609
+ const originalTarget = consent.targetIdentity;
610
+ (consent as { targetIdentity: string }).targetIdentity = `sha256:${"b".repeat(64)}`;
611
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
612
+ (consent as { targetIdentity: string }).targetIdentity = originalTarget;
613
+ (consent as { projection: string }).projection = "staged";
614
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
615
+ (consent as { projection: string }).projection = "workspace";
616
+ (consent.choices[0] as { invocation: string }).invocation = consent.choices[0].invocation.replace("native-lineage", "changed-lineage");
617
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
618
+ assert.deepEqual(answers, []);
619
+ });
620
+
621
+ test("ambiguous consent mutation consumes the one-shot binding and requires status instead of blind replay", async (t) => {
542
622
  const cwd = repository(t);
543
- const { native } = fakeOrganicNative({ riskEvidence: ["payments in billing.ts"] });
623
+ const { native } = relayedConsentNative(cwd);
624
+ let statusCalls = 0;
625
+ const targetStatus = native.targetStatus!;
626
+ native.targetStatus = async (request) => { statusCalls += 1; return await targetStatus(request); };
627
+ native.answerConsent = async () => { throw Object.assign(new Error("ambiguous"), { mutationOutcome: "unknown", nextAction: "review.status" }); };
544
628
  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);
629
+ const blocked = await blockedConsent(controller, "consent-ambiguous", headlessContext(cwd));
630
+ await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
631
+ assert.equal(statusCalls, 2, "ambiguous consent must reconcile through target status");
632
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
549
633
  });
550
634
 
551
- test("consent: declining persists nothing, applies only to this work unit, and withholds actor_binding", async (t) => {
635
+ test("session shutdown clears pending candidate consent bindings and is idempotent", async (t) => {
552
636
  const cwd = repository(t);
553
- const { native } = fakeOrganicNative({ riskEvidence: ["authentication in auth.ts"] });
554
- 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");
637
+ const { native, answers } = relayedConsentNative(cwd);
638
+ const { controller, events } = runtime(native);
639
+ const blocked = await blockedConsent(controller, "consent-before-shutdown", headlessContext(cwd));
640
+ const shutdown = events.get("session_shutdown");
641
+ assert.ok(shutdown);
642
+ await shutdown({}, headlessContext(cwd));
643
+ await shutdown({}, headlessContext(cwd));
644
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
645
+ assert.deepEqual(answers, []);
646
+ });
647
+
648
+ test("successful explicit disable clears pending candidate consent binding even after re-enable", async (t) => {
649
+ const cwd = repository(t);
650
+ const { native, answers } = relayedConsentNative(cwd);
651
+ let effective: "on" | "off" = "on";
652
+ native.reviewMode = async (request) => {
653
+ if (request.operation === "disable") effective = "off";
654
+ if (request.operation === "enable") effective = "on";
655
+ return { operation: request.operation, scope: "clone", status: { global: "", cloneLocal: effective === "off" ? "off" : "", effective, source: effective === "off" ? "clone_local" : "default" } };
656
+ };
657
+ const { controller, commands } = runtime(native);
658
+ const blocked = await blockedConsent(controller, "consent-before-disable", headlessContext(cwd));
659
+ const command = commands.get("gentle:review-mode")!;
660
+ await command.handler("disable", headlessContext(cwd));
661
+ await command.handler("enable", headlessContext(cwd));
662
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
663
+ assert.deepEqual(answers, []);
559
664
  });
560
665
 
561
- test("consent: an unreadable answer (confirm throws) still runs the review, leaves the latch untouched, and surfaces a notice", async (t) => {
666
+ test("candidate-scoped decline creates no authority and the next candidate asks again", async (t) => {
562
667
  const cwd = repository(t);
563
- const { native } = fakeOrganicNative({ riskEvidence: ["an executable permission change in run.sh"] });
668
+ const { native, answers } = relayedConsentNative(cwd);
564
669
  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);
670
+ for (const id of ["decline-one", "decline-two"]) {
671
+ const blocked = await blockedConsent(controller, id, headlessContext(cwd));
672
+ const result = await answerConsent(controller, blocked.consent_binding, "declined", headlessContext(cwd));
673
+ assert.equal(result.outcome, "consent-declined-this-candidate");
674
+ assert.equal(result.lineage_created, false);
675
+ assert.equal(result.actor_binding, undefined);
676
+ }
677
+ assert.deepEqual(answers, ["declined", "declined"]);
569
678
  });
570
679
 
571
- test("consent stays dark (capability-gated) when the native start result carries no riskEvidence", async (t) => {
680
+ test("low-risk zero-lens START remains silent", async (t) => {
572
681
  const cwd = repository(t);
573
- const { native } = fakeOrganicNative({ riskEvidence: undefined, lensesRequired: true });
682
+ const { native } = fakeOrganicNative({ riskEvidence: undefined, lensesRequired: false });
574
683
  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);
684
+ let selected = false;
685
+ const ctx = { cwd, hasUI: true, ui: { select: async () => { selected = true; return undefined; }, notify: () => {} } } as unknown as ExtensionContext;
686
+ const result = await execStart(controller, "consent-low-risk", ctx);
687
+ assert.equal(selected, false);
688
+ assert.ok(result.result);
580
689
  });
581
690
 
582
691
  // ---------------------------------------------------------------------------
@@ -212,7 +212,8 @@ test("installed commit transaction runner loads JavaScript only and has determin
212
212
  assert.equal(packageJson.scripts?.["test:packed-runner"], "node scripts/test-packed-runner.mjs");
213
213
  assert.match(packageJson.scripts?.prepublishOnly ?? "", /pnpm run test:packed-runner/);
214
214
  assert.match(ci, /pnpm run test:packed-runner/);
215
- assert.doesNotMatch(packedRunner, /\["install"[^\]]*"--ignore-scripts"/s);
215
+ assert.match(packedRunner, /\["install"[^\]]*"--ignore-scripts=false"/s, "packed install must explicitly enable postinstall");
216
+ assert.doesNotMatch(packedRunner, /\["install"[^\]]*"--ignore-scripts"(?!\=false)/s);
216
217
  assert.match(packedRunner, /execFileSync\("where\.exe", \["npm"\]/);
217
218
  assert.match(packedRunner, /could not resolve npm-cli\.js without a command shell/);
218
219
  assert.doesNotMatch(packedRunner, /ComSpec|cmd\.exe/);
@@ -275,7 +276,13 @@ test("package verification binds the published Gentle AI v2.2.2 runtime pin", ()
275
276
  const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
276
277
 
277
278
  assert.match(installer, /INSTALLER_VERSION = "2\.2\.2"/);
279
+ assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
280
+ assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:YZcI5dRvoHm82I2CULvgBkB2M3UQQGarYO\/u\/Nt5LSc="/);
281
+ assert.match(installer, /GOTOOLCHAIN: "local"/);
282
+ assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
278
283
  assert.match(binary, /GENTLE_AI_VERSION = "2\.2\.2"/);
284
+ assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
285
+ assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
279
286
  assert.match(verifier, /v2\.2\.2/);
280
287
  });
281
288
 
@@ -1130,9 +1137,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
1130
1137
  assert.match(wrapper, /quietToolsEnabled/);
1131
1138
  });
1132
1139
 
1133
- test("v2.0.0 release package and runtime stop before publication", () => {
1140
+ test("v2.1.0 release package and runtime stop before publication", () => {
1134
1141
  const packageJson = readPackageJson();
1135
- assert.equal(packageJson.version, "2.0.0", "the release manifest must remain explicitly pinned to v2.0.0");
1142
+ assert.equal(packageJson.version, "2.1.0", "the release manifest must remain explicitly pinned to v2.1.0");
1136
1143
  assert.equal(
1137
1144
  packageJson.scripts?.test,
1138
1145
  "node --experimental-strip-types --test tests/*.test.ts && pnpm run test:harness",
@@ -584,6 +584,49 @@ test("native projections reconstruct symlink, intended-untracked, and gitlink id
584
584
  assert.deepEqual(projection.gitlinks, { "vendor/dependency": gitlinkCommit });
585
585
  });
586
586
 
587
+ test("native staged projections restore the exact current index over HEAD", (t) => {
588
+ const contributorRoot = repository(t);
589
+ const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
590
+ writeFileSync(join(contributorRoot, "tracked.txt"), "staged candidate\n");
591
+ git(contributorRoot, "add", "tracked.txt");
592
+ const candidateTree = git(contributorRoot, "write-tree");
593
+ const registry = new CandidateViewRegistry();
594
+
595
+ registry.restoreProjectionFromNative("staged-index-projection", contributorRoot, {
596
+ baseTree,
597
+ currentCandidateTree: candidateTree,
598
+ paths: ["tracked.txt"],
599
+ intendedUntracked: [],
600
+ projection: "staged",
601
+ });
602
+
603
+ const projection = registry.resolveProjection("staged-index-projection", contributorRoot);
604
+ assert.equal(projection.baseTree, baseTree);
605
+ assert.equal(projection.candidateTree, candidateTree);
606
+ assert.equal(projection.committedOnly, false);
607
+ });
608
+
609
+ test("native staged projections reject a workspace snapshot that differs from the current index", (t) => {
610
+ const contributorRoot = repository(t);
611
+ const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
612
+ writeFileSync(join(contributorRoot, "tracked.txt"), "workspace candidate\n");
613
+ const workspace = new CandidateViewRegistry().create({ contributorRoot });
614
+ try {
615
+ assert.throws(
616
+ () => new CandidateViewRegistry().restoreProjectionFromNative("workspace-as-staged", contributorRoot, {
617
+ baseTree,
618
+ currentCandidateTree: workspace.candidateTree,
619
+ paths: ["tracked.txt"],
620
+ intendedUntracked: [],
621
+ projection: "staged",
622
+ }),
623
+ (error: unknown) => error instanceof CandidateViewError && error.reason === "projection-kind-drift",
624
+ );
625
+ } finally {
626
+ workspace.cleanup();
627
+ }
628
+ });
629
+
587
630
  test("native projections recover a committed range base from its frozen tree", (t) => {
588
631
  const contributorRoot = repository(t);
589
632
  const baseCommit = git(contributorRoot, "rev-parse", "HEAD");