gentle-pi 3.2.1 → 3.4.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.
Files changed (108) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -1,19 +1,74 @@
1
1
  import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { execFileSync } from "node:child_process";
3
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
5
  import { tmpdir } from "node:os";
5
- import { delimiter, join, resolve } from "node:path";
6
+ import { join, resolve } from "node:path";
6
7
  import test from "node:test";
8
+ import type { Api, AssistantMessage, Context, Model } from "@earendil-works/pi-ai";
9
+ import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai/compat";
7
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
11
  import { __testing, createGentleAiExtension } from "../../extensions/gentle-ai.ts";
9
12
  import { resolveGentleAiBinary } from "../../lib/gentle-ai-binary.ts";
10
- import { OPAQUE_PI_REVIEWER_ARGV } from "../../lib/opaque-pi-reviewer-adapter.ts";
13
+ import type { InProcessReviewerRegistry } from "../../lib/inprocess-reviewer.ts";
11
14
  import { NativeReviewCliV216, type ExecFileAdapter, type NativeReviewCli } from "../../lib/native-review-cli.ts";
12
15
  import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, reviewHostRelaySlots, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts";
13
16
  import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../../lib/review-relay-contract.ts";
14
17
  import { decodeReviewStatusV3 } from "../../lib/review-integration-v2.ts";
15
18
  import { requireDevBinary } from "../support/native-binary-gate.ts";
16
19
 
20
+ // gentle-pi#311 P4 / P3: lens captures run one in-process reviewer completion
21
+ // through the live model registry (lib/inprocess-reviewer.ts), with no child
22
+ // process, no `piExecutable`/`-e` forwarding, and no extension allowlist.
23
+ // gentle-ai's v9 contract makes the refuter and targeted-validator role
24
+ // captures host-mediated the same way (P3): each carries a submission
25
+ // descriptor alongside `--materialize=true`, runs through the SAME relay
26
+ // seam, and reaches the SAME `fauxReviewerFor` fixture — there is no
27
+ // Go-owned `pi` subprocess left to fake on PATH for either role. The devtests
28
+ // below inject a fake reviewer registry instead of a fake `pi` binary for
29
+ // every one of these legs.
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Fake reviewer registry (gentle-pi#311 P4) — registers pi-ai's own faux
33
+ // provider, the SAME api-registry the real `completeSimple` dispatches
34
+ // through in production, so the real `runInProcessReviewer` -> real
35
+ // `completeSimple` path runs end-to-end with no network and no child
36
+ // process. The scripted response reads the frozen prompt's
37
+ // `GENTLE_AI_REVIEW_BINDING` line for the subject_hash the real Go admission
38
+ // requires, exactly like the fake pi child this replaced. `enqueue` must be
39
+ // called once per expected completion (the faux provider's response queue is
40
+ // FIFO and errors once exhausted).
41
+ // ---------------------------------------------------------------------------
42
+ interface FauxReviewerCall {
43
+ promptText: string;
44
+ subjectHash: string | undefined;
45
+ }
46
+ function fauxReviewerFor(buildResponseText: (subjectHash: string | undefined, promptText: string) => string) {
47
+ const id = randomUUID();
48
+ const provider = `dev-binary-faux-reviewer-${id}`;
49
+ const faux = registerFauxProvider({ api: provider, provider, models: [{ id: "dev-fixture" }] });
50
+ const calls: FauxReviewerCall[] = [];
51
+ const enqueue = () => {
52
+ faux.appendResponses([(context: Context): AssistantMessage => {
53
+ const content = context.messages[0]?.content;
54
+ const textPart = Array.isArray(content) ? content.find((part) => part.type === "text") : undefined;
55
+ const promptText = (textPart as { text?: string } | undefined)?.text ?? "";
56
+ const newline = promptText.indexOf("\n");
57
+ const firstLine = newline === -1 ? promptText : promptText.slice(0, newline);
58
+ const prefix = "GENTLE_AI_REVIEW_BINDING ";
59
+ const subjectHash = firstLine.startsWith(prefix) ? (JSON.parse(firstLine.slice(prefix.length)) as { subject_hash?: string }).subject_hash : undefined;
60
+ calls.push({ promptText, subjectHash });
61
+ return fauxAssistantMessage(buildResponseText(subjectHash, promptText));
62
+ }]);
63
+ };
64
+ const model = faux.getModel() as unknown as Model<Api>;
65
+ const registry: InProcessReviewerRegistry = {
66
+ find: () => model,
67
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "faux-key" }),
68
+ };
69
+ return { registry, selection: `${model.provider}/${model.id}`, routingKey: "review-risk", enqueue, calls };
70
+ }
71
+
17
72
  const DEV_BINARY = process.env.GENTLE_AI_DEV_BINARY;
18
73
  const RELAY_DEV_BINARY = process.env.GENTLE_PI_GENTLE_AI_DEV_BINARY;
19
74
  const POSIX = process.platform !== "win32";
@@ -264,50 +319,6 @@ function grantedConsentInvocation(value: unknown): string {
264
319
  return stringValue(granted!.invocation, "granted consent invocation");
265
320
  }
266
321
 
267
- const FAKE_POSIX_PI = `#!/usr/bin/env node
268
- import fs from "node:fs";
269
- const expectedArgv = JSON.parse(process.env.OPAQUE_PI_REVIEWER_ARGV);
270
- const expectedPaths = JSON.parse(process.env.OPAQUE_PI_REVIEWER_PATHS);
271
- const chunks = [];
272
- process.stdin.on("data", (chunk) => chunks.push(chunk));
273
- process.stdin.on("end", () => {
274
- const prompt = Buffer.concat(chunks);
275
- const promptText = prompt.toString("utf8");
276
- const targetedValidatorResult = process.env.OPAQUE_PI_TARGETED_VALIDATOR_RESULT;
277
- let subjectHash;
278
- if (targetedValidatorResult === undefined) {
279
- const newline = prompt.indexOf(0x0a);
280
- if (newline < 0) throw new Error("missing binding line");
281
- const firstLine = prompt.subarray(0, newline).toString("utf8");
282
- const prefix = "GENTLE_AI_REVIEW_BINDING ";
283
- if (!firstLine.startsWith(prefix)) throw new Error("missing binding prefix");
284
- const binding = JSON.parse(firstLine.slice(prefix.length));
285
- if (typeof binding.subject_hash !== "string" || !/^sha256:[0-9a-f]{64}$/.test(binding.subject_hash)) throw new Error("invalid binding subject_hash");
286
- if (JSON.stringify(process.argv.slice(2)) !== JSON.stringify(expectedArgv)) throw new Error("unexpected opaque Pi argv");
287
- subjectHash = binding.subject_hash;
288
- } else if (promptText.length === 0) {
289
- throw new Error("missing targeted-validator prompt");
290
- }
291
- const prior = fs.existsSync(process.env.OPAQUE_PI_REVIEWER_LOG) ? JSON.parse(fs.readFileSync(process.env.OPAQUE_PI_REVIEWER_LOG, "utf8")) : { calls: [] };
292
- const calls = Array.isArray(prior.calls) ? prior.calls : [];
293
- calls.push({
294
- argv: process.argv.slice(2), cwd: process.cwd(), entries: fs.readdirSync(process.cwd()), ...(subjectHash === undefined ? {} : { subject_hash: subjectHash }), prompt: promptText,
295
- role: targetedValidatorResult === undefined ? "reviewer" : "targeted-validator",
296
- });
297
- fs.writeFileSync(process.env.OPAQUE_PI_REVIEWER_LOG, JSON.stringify({ calls }));
298
- if (targetedValidatorResult !== undefined) {
299
- process.stdout.write(targetedValidatorResult);
300
- return;
301
- }
302
- process.stdout.write(JSON.stringify({
303
- subject_hash: subjectHash,
304
- inspection: { status: "completed", paths: expectedPaths },
305
- findings: process.env.OPAQUE_PI_REVIEWER_FINDINGS === undefined ? [] : JSON.parse(process.env.OPAQUE_PI_REVIEWER_FINDINGS),
306
- evidence: ["inspected every frozen candidate path"],
307
- }));
308
- });
309
- `;
310
-
311
322
  // This A -> B journey deliberately stops immediately after one Go-admitted
312
323
  // reviewer capture. It proves real Pi relay transport and root continuity, but
313
324
  // does not manufacture the remaining reviewer, refuter, validator, or approval
@@ -383,21 +394,25 @@ test("dev-binary: POSIX Pi host relay captures one real B-target slot from an A-
383
394
  assert.ok(slot.submission, "the real Pi slot must include Go's provider-owned submission form");
384
395
  assert.ok(slot.subjectHash, "the real Pi slot must include its artifact subject hash");
385
396
 
386
- const fakePi = join(sessionA, "fake-pi");
387
- const fakePiLog = join(sessionA, "fake-pi-log.json");
388
- writeFileSync(fakePi, FAKE_POSIX_PI);
389
- chmodSync(fakePi, 0o755);
397
+ // gentle-pi#311 P4: the reviewer completion runs in-process — a fake
398
+ // registry replaces the fake `pi` binary this devtest used to spawn for
399
+ // the lens capture. There is no child process and no scratch sandbox to
400
+ // isolate or clean up for this leg any more.
401
+ const reviewer = fauxReviewerFor((subjectHash) => JSON.stringify({
402
+ subject_hash: subjectHash,
403
+ inspection: { status: "completed", paths: expectedPaths },
404
+ findings: [],
405
+ evidence: ["inspected every frozen candidate path"],
406
+ }));
407
+ reviewer.enqueue();
390
408
  const relay = await runReviewHostRelaySlot({
391
409
  captureArgumentTokens: slot.captureArgumentTokens,
392
410
  submission: slot.submission,
393
411
  targetCwd: canonicalB,
394
- piExecutable: fakePi,
395
- environment: {
396
- ...environment,
397
- OPAQUE_PI_REVIEWER_ARGV: JSON.stringify(OPAQUE_PI_REVIEWER_ARGV),
398
- OPAQUE_PI_REVIEWER_LOG: fakePiLog,
399
- OPAQUE_PI_REVIEWER_PATHS: JSON.stringify(expectedPaths),
400
- },
412
+ environment,
413
+ reviewerRegistry: reviewer.registry,
414
+ selection: reviewer.selection,
415
+ routingKey: reviewer.routingKey,
401
416
  gentleAiTimeoutMs: 30_000,
402
417
  piTimeoutMs: 30_000,
403
418
  });
@@ -405,17 +420,8 @@ test("dev-binary: POSIX Pi host relay captures one real B-target slot from an A-
405
420
  assert.ok(relay.resultByteLength > 0);
406
421
  assert.equal(record(JSON.parse(relay.submission) as unknown, "capture submission").admission_decision, "completed");
407
422
 
408
- const fakePiLogRecord = record(JSON.parse(readFileSync(fakePiLog, "utf8")) as unknown, "fake Pi log");
409
- assert.ok(Array.isArray(fakePiLogRecord.calls), "fake Pi log must record its subprocess calls");
410
- assert.equal(fakePiLogRecord.calls.length, 1);
411
- const fakePiResult = record(fakePiLogRecord.calls[0], "fake Pi result");
412
- assert.deepEqual(fakePiResult.argv, OPAQUE_PI_REVIEWER_ARGV);
413
- assert.deepEqual(fakePiResult.entries, []);
414
- assert.equal(fakePiResult.subject_hash, slot.subjectHash);
415
- const scratchCwd = stringValue(fakePiResult.cwd, "fake Pi scratch cwd");
416
- assert.notEqual(scratchCwd, canonicalB);
417
- assert.notEqual(scratchCwd, sessionA);
418
- assert.equal(existsSync(scratchCwd), false, "opaque Pi scratch cwd must be removed after the subprocess exits");
423
+ assert.equal(reviewer.calls.length, 1, "the in-process reviewer must complete exactly once");
424
+ assert.equal(reviewer.calls[0]!.subjectHash, slot.subjectHash, "the reviewer completion must receive the real frozen binding's subject hash");
419
425
 
420
426
  const advanced = candidateStatus(RELAY_DEV_BINARY!, sessionA, canonicalB, environment, lineage);
421
427
  const reoffered = reviewHostRelaySlots(advanced.nextTransition?.collect?.inputs ?? []).some((candidate) =>
@@ -470,14 +476,25 @@ test("dev-binary: a garbage reviewer result is refused at admission as a proven
470
476
  const reviewerBinding = collectBindingsFor(status, "review.capture-result")[0];
471
477
  assert.ok(reviewerBinding, "current STATUS must publish a reviewer capture binding");
472
478
  const subjectHash = collectBindingArgument(reviewerBinding!, "subject-hash");
473
- const fakePi = join(scratch, "fake-pi");
474
- writeFileSync(fakePi, "#!/usr/bin/env node\nprocess.stdin.resume();\nprocess.stdin.on('end', () => { process.stdout.write('not json at all'); });\n");
475
- chmodSync(fakePi, 0o755);
479
+ // gentle-pi#311 P4: the in-process reviewer completion produces the
480
+ // garbage text directly (no fake `pi` binary); Go's real admission still
481
+ // refuses it exactly as before.
482
+ const reviewer = fauxReviewerFor(() => "not json at all");
476
483
  let relayError: unknown;
477
484
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
478
485
  __testing.setReviewHostRelayRunnerForTesting(async (request) => {
486
+ reviewer.enqueue();
479
487
  try {
480
- return await runReviewHostRelaySlot({ ...request, gentleAiExecutable: RELAY_DEV_BINARY!, piExecutable: fakePi, environment, gentleAiTimeoutMs: 30_000, piTimeoutMs: 30_000 });
488
+ return await runReviewHostRelaySlot({
489
+ ...request,
490
+ gentleAiExecutable: RELAY_DEV_BINARY!,
491
+ environment,
492
+ reviewerRegistry: reviewer.registry,
493
+ selection: reviewer.selection,
494
+ routingKey: reviewer.routingKey,
495
+ gentleAiTimeoutMs: 30_000,
496
+ piTimeoutMs: 30_000,
497
+ });
481
498
  } catch (error) {
482
499
  relayError = error;
483
500
  throw error;
@@ -514,9 +531,10 @@ test("dev-binary: a garbage reviewer result is refused at admission as a proven
514
531
  });
515
532
 
516
533
  // This completes the same organic A -> B path through correction evidence,
517
- // Go-owned targeted validation, and terminal approval. The only reviewer is the
518
- // fixed fake Pi executable below; no model, provider, or profile is selected.
519
- test("dev-binary: Pi controller keeps an explicit B root and selected-untracked binding through Go-owned validation approval", { skip: !RUNNABLE }, async (t) => {
534
+ // host-mediated targeted validation, and terminal approval. The only
535
+ // reviewers are the fixed faux registries below; no real model, provider, or
536
+ // profile is selected.
537
+ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked binding through host-mediated validation approval", { skip: !RUNNABLE }, async (t) => {
520
538
  const sessionA = repository(t, "gentle-pi-combined-session-a-");
521
539
  const targetB = repository(t, "gentle-pi-combined-target-b-");
522
540
  const nestedTarget = join(targetB, "nested", "target");
@@ -600,16 +618,6 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
600
618
  assert.equal(startedDetails.workspace_root, canonicalB);
601
619
  const lineage = stringValue(record(startedDetails.result, "combined start result").lineage_id, "combined lineage");
602
620
 
603
- const fakePiDirectory = join(sessionA, "fake-pi-bin");
604
- mkdirSync(fakePiDirectory);
605
- const fakePi = join(fakePiDirectory, "pi");
606
- const fakePiLog = join(sessionA, "fake-pi-log.json");
607
- writeFileSync(fakePi, FAKE_POSIX_PI);
608
- chmodSync(fakePi, 0o755);
609
- environment.PATH = [fakePiDirectory, process.env.PATH].filter((entry): entry is string => entry !== undefined && entry.length > 0).join(delimiter);
610
- environment.OPAQUE_PI_REVIEWER_ARGV = JSON.stringify(OPAQUE_PI_REVIEWER_ARGV);
611
- environment.OPAQUE_PI_REVIEWER_LOG = fakePiLog;
612
- environment.OPAQUE_PI_REVIEWER_PATHS = JSON.stringify([".github/workflows/relay.yml", "selected.txt"]);
613
621
  const reviewerFindings = [{
614
622
  location: "selected.txt:1",
615
623
  severity: "BLOCKER",
@@ -618,22 +626,46 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
618
626
  evidence_class: "deterministic",
619
627
  causal_disposition: "introduced",
620
628
  }];
629
+ // gentle-pi#311 P4 / P3: the lens reviewer capture runs in-process through
630
+ // a fake registry. gentle-ai's v9 contract makes the targeted-validator
631
+ // role host-mediated too (there is no Go-owned pi subprocess left to fake
632
+ // on PATH for it), so it reaches the SAME relay seam below through its own
633
+ // faux reviewer, armed once its request-hash and target identity are
634
+ // known further down.
635
+ const reviewer = fauxReviewerFor((subjectHash) => JSON.stringify({
636
+ subject_hash: subjectHash,
637
+ inspection: { status: "completed", paths: [".github/workflows/relay.yml", "selected.txt"] },
638
+ findings: reviewerFindings,
639
+ evidence: ["inspected every frozen candidate path"],
640
+ }));
641
+ let validatorReviewer: ReturnType<typeof fauxReviewerFor> | undefined;
621
642
  const relayTargetRoots: string[] = [];
622
643
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
623
644
  __testing.setReviewHostRelayRunnerForTesting(async (request) => {
624
645
  assert.equal(request.targetCwd, canonicalB, "the host relay must materialize and submit against B");
625
646
  relayTargetRoots.push(request.targetCwd!);
647
+ if (request.routingKey === "review-validator") {
648
+ assert.ok(validatorReviewer, "the targeted-validator reviewer fixture must be armed before its slot reaches the relay");
649
+ validatorReviewer.enqueue();
650
+ return await runReviewHostRelaySlot({
651
+ ...request,
652
+ gentleAiExecutable: RELAY_DEV_BINARY!,
653
+ environment,
654
+ reviewerRegistry: validatorReviewer.registry,
655
+ selection: validatorReviewer.selection,
656
+ routingKey: request.routingKey,
657
+ gentleAiTimeoutMs: 30_000,
658
+ piTimeoutMs: 30_000,
659
+ });
660
+ }
661
+ reviewer.enqueue();
626
662
  return await runReviewHostRelaySlot({
627
663
  ...request,
628
664
  gentleAiExecutable: RELAY_DEV_BINARY!,
629
- piExecutable: fakePi,
630
- environment: {
631
- ...environment,
632
- OPAQUE_PI_REVIEWER_ARGV: JSON.stringify(OPAQUE_PI_REVIEWER_ARGV),
633
- OPAQUE_PI_REVIEWER_LOG: fakePiLog,
634
- OPAQUE_PI_REVIEWER_PATHS: JSON.stringify([".github/workflows/relay.yml", "selected.txt"]),
635
- OPAQUE_PI_REVIEWER_FINDINGS: JSON.stringify(reviewerFindings),
636
- },
665
+ environment,
666
+ reviewerRegistry: reviewer.registry,
667
+ selection: reviewer.selection,
668
+ routingKey: reviewer.routingKey,
637
669
  gentleAiTimeoutMs: 30_000,
638
670
  piTimeoutMs: 30_000,
639
671
  });
@@ -665,10 +697,7 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
665
697
  assert.ok(publicStatusProjectionPaths(reviewerStatusDetails).includes("selected.txt"), "public STATUS must retain the selected path in B's projection");
666
698
  assert.equal(publicStatusProjectionPaths(reviewerStatusDetails).includes("excluded.txt"), false, "public STATUS must retain the excluded path outside B's projection");
667
699
 
668
- const fakeCallsBeforeForecast = existsSync(fakePiLog)
669
- ? record(JSON.parse(readFileSync(fakePiLog, "utf8")) as unknown, "reviewer fake Pi forecast log").calls
670
- : [];
671
- assert.ok(Array.isArray(fakeCallsBeforeForecast), "reviewer fake Pi forecast log must carry calls when present");
700
+ const reviewerCallsBeforeForecast = reviewer.calls.length;
672
701
  const reviewerForecast = await capture.execute(
673
702
  `combined-reviewer-forecast-${attempt}`,
674
703
  { lineageId: lineage, workspaceRoot: nestedTarget, collectBinding: reviewerBinding },
@@ -679,11 +708,7 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
679
708
  const reviewerForecastDetails = record(reviewerForecast.details, "combined reviewer forecast");
680
709
  assert.equal(reviewerForecastDetails.status, "blocked");
681
710
  assert.equal(reviewerForecastDetails.outcome, "reviewer-model-run-forecast");
682
- const fakeCallsAfterForecast = existsSync(fakePiLog)
683
- ? record(JSON.parse(readFileSync(fakePiLog, "utf8")) as unknown, "reviewer fake Pi forecast result").calls
684
- : [];
685
- assert.ok(Array.isArray(fakeCallsAfterForecast), "reviewer fake Pi forecast result must carry calls when present");
686
- assert.equal(fakeCallsAfterForecast.length, fakeCallsBeforeForecast.length, "forecast acknowledgement must not launch a reviewer capture");
711
+ assert.equal(reviewer.calls.length, reviewerCallsBeforeForecast, "forecast acknowledgement must not launch a reviewer completion");
687
712
 
688
713
  const reviewerCapture = await capture.execute(
689
714
  `combined-reviewer-capture-${attempt}`,
@@ -694,9 +719,7 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
694
719
  );
695
720
  const reviewerCaptureDetails = record(reviewerCapture.details, "combined reviewer capture");
696
721
  assert.ok(["captured", "closed"].includes(stringValue(reviewerCaptureDetails.status, "combined reviewer capture status")), "one acknowledged binding must perform exactly one native reviewer capture");
697
- const fakeCallsAfterCapture = record(JSON.parse(readFileSync(fakePiLog, "utf8")) as unknown, "reviewer fake Pi capture result").calls;
698
- assert.ok(Array.isArray(fakeCallsAfterCapture), "reviewer fake Pi capture result must carry calls");
699
- assert.equal(fakeCallsAfterCapture.length, fakeCallsBeforeForecast.length + 1, "one acknowledged binding must launch exactly one reviewer capture");
722
+ assert.equal(reviewer.calls.length, reviewerCallsBeforeForecast + 1, "one acknowledged binding must launch exactly one reviewer completion");
700
723
  reviewerCaptureCount += 1;
701
724
  const closure = reviewerCaptureDetails.closure;
702
725
  if (closure !== undefined) {
@@ -723,17 +746,12 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
723
746
  assert.ok(reviewerCaptureCount > 0);
724
747
  assert.ok(relayTargetRoots.length === reviewerCaptureCount);
725
748
  assert.ok(relayTargetRoots.every((root) => root === canonicalB), "every reviewer relay leg must stay bound to B");
726
- const combinedFakePiLog = record(JSON.parse(readFileSync(fakePiLog, "utf8")) as unknown, "combined fake Pi log");
727
- assert.ok(Array.isArray(combinedFakePiLog.calls), "combined fake Pi log must record reviewer subprocess calls");
728
- const reviewerCalls = combinedFakePiLog.calls.map((call) => record(call, "combined fake Pi reviewer call")).filter((call) => call.role === "reviewer");
729
- assert.equal(reviewerCalls.length, reviewerCaptureCount);
730
- for (const call of reviewerCalls) {
731
- assert.equal(call.subject_hash === undefined, false, "the fake reviewer must receive one provider-bound subject");
732
- assert.deepEqual(call.argv, OPAQUE_PI_REVIEWER_ARGV);
733
- assert.deepEqual(call.entries, [], "the reviewer must run from an empty isolated sandbox");
734
- assert.notEqual(call.cwd, canonicalB, "the reviewer subprocess must not run in B");
735
- assert.notEqual(call.cwd, sessionA, "the reviewer subprocess must not run in A");
736
- assert.equal(existsSync(stringValue(call.cwd, "reviewer fake Pi scratch cwd")), false, "the reviewer sandbox must be removed after the subprocess exits");
749
+ // gentle-pi#311 P4: the reviewer completion runs in-process — there is no
750
+ // subprocess log, argv, or scratch sandbox to assert for this leg any
751
+ // more; `reviewer.calls` is the in-process equivalent record.
752
+ assert.equal(reviewer.calls.length, reviewerCaptureCount);
753
+ for (const call of reviewer.calls) {
754
+ assert.notEqual(call.subjectHash, undefined, "the in-process reviewer must receive one provider-bound subject");
737
755
  }
738
756
 
739
757
  const correctionStatus = await controller.execute(
@@ -775,7 +793,17 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
775
793
  const validatorBinding = collectBindingFor(validationStatus.details, "review.capture-validation");
776
794
  const validatorInput = parsedCollectBinding(validatorBinding);
777
795
  assert.equal(validatorInput.captureOperation, "review.capture-validation");
778
- assert.equal(validatorInput.submission, undefined, "the Go-owned targeted-validator vector must not accept a caller-authored submission");
796
+ // gentle-pi#311 P3: gentle-ai's v9 contract renders the targeted-validator
797
+ // role host-mediated too, carrying a submission descriptor alongside
798
+ // --materialize=true exactly like a lens capture-result materialize slot.
799
+ const validatorSubmission = record(validatorInput.submission, "host-mediated targeted-validator submission");
800
+ assert.equal(validatorSubmission.operationToken, "capture-validation");
801
+ const validatorSubmissionValues = validatorSubmission.values;
802
+ assert.ok(Array.isArray(validatorSubmissionValues) && validatorSubmissionValues.length === 1, "the targeted-validator submission must bind exactly one artifact value");
803
+ const validatorSubmissionValue = record(validatorSubmissionValues[0], "targeted-validator submission value");
804
+ assert.equal(validatorSubmissionValue.slot, "provider_targeted_validator");
805
+ assert.equal(validatorSubmissionValue.domain, "artifact_path_or_stdin");
806
+ assert.equal(validatorSubmissionValue.schema, "https://gentle-ai.dev/schema/review/validator/v1");
779
807
  const validationRequest = record(validatorInput.validationRequest, "targeted-validator validation request");
780
808
  const validatorArgumentTokens = collectBindingArgumentTokens(validatorBinding);
781
809
  const validatorRequestHash = collectBindingArgument(validatorBinding, "request-hash");
@@ -790,20 +818,36 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
790
818
  assert.ok(Array.isArray(validationRequest.fixClassifications) && validationRequest.fixClassifications.length > 0, "the public targeted-validator request must retain Go's classifications");
791
819
  assert.ok(validatorArgumentTokens.includes(`--request-hash=${validatorRequestHash}`), "the public targeted-validator vector must retain its request hash");
792
820
  assert.ok(validatorArgumentTokens.includes("--agent=pi"), "the public targeted-validator vector must retain the Pi binding");
793
- assert.ok(validatorArgumentTokens.includes("--execute=true"), "the public targeted-validator vector must retain Go-owned execution");
821
+ assert.ok(validatorArgumentTokens.includes("--materialize=true"), "the public targeted-validator vector must retain the host-mediated materialize binding");
822
+ assert.equal(validatorArgumentTokens.includes("--execute=true"), false, "the v9 host-mediated form must never carry --execute alongside its submission");
794
823
  assert.ok(publicStatusProjectionPaths(validationStatus.details).includes("selected.txt"), "the targeted-validator STATUS must remain bound to selected.txt");
795
824
  assert.equal(publicStatusProjectionPaths(validationStatus.details).includes("excluded.txt"), false, "the targeted-validator STATUS must remain outside excluded.txt");
796
825
 
797
- environment.OPAQUE_PI_TARGETED_VALIDATOR_RESULT = JSON.stringify({
826
+ // Arm the targeted-validator faux reviewer now that its request hash and
827
+ // target identity are known; the relay override above dispatches to it by
828
+ // routing key.
829
+ validatorReviewer = fauxReviewerFor(() => JSON.stringify({
798
830
  targeted_validation_request_hash: validatorRequestHash,
799
831
  correction_target_identity: validatorTargetIdentity,
800
832
  original_criteria: { passed: true, evidence: ["focused acceptance proof passed"] },
801
833
  correction_regression: { passed: true, evidence: ["focused regression proof passed"] },
802
834
  follow_ups: [],
803
- });
835
+ }));
836
+ const validatorForecast = await capture.execute(
837
+ "combined-targeted-validator-forecast",
838
+ { lineageId: lineage, workspaceRoot: nestedTarget, collectBinding: validatorBinding },
839
+ undefined,
840
+ undefined,
841
+ sessionContext(sessionA),
842
+ );
843
+ const validatorForecastDetails = record(validatorForecast.details, "combined targeted-validator forecast");
844
+ assert.equal(validatorForecastDetails.status, "blocked");
845
+ assert.equal(validatorForecastDetails.outcome, "reviewer-model-run-forecast");
846
+ assert.equal(validatorReviewer.calls.length, 0, "forecast acknowledgement must not launch a reviewer completion");
847
+
804
848
  const providerValidation = await capture.execute(
805
849
  "combined-provider-targeted-validation",
806
- { lineageId: lineage, workspaceRoot: nestedTarget, collectBinding: validatorBinding },
850
+ { lineageId: lineage, workspaceRoot: nestedTarget, collectBinding: validatorBinding, reviewerRunAcknowledged: true },
807
851
  undefined,
808
852
  undefined,
809
853
  sessionContext(sessionA),
@@ -816,22 +860,16 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
816
860
  assert.equal(validationClosure.schema, "gentle-ai.review-last-event-closure/v1");
817
861
  assert.equal(validationClosure.operation, "review/capture-validation");
818
862
  assert.equal(validationClosure.state, "approved");
819
- const validationCaptureCalls = nativeCalls.filter((call) => call.arguments[0] === "review" && call.arguments[1] === "capture-validation");
820
- assert.equal(validationCaptureCalls.length, 1, "the Go-owned targeted validator must capture exactly once");
821
- assert.equal(validationCaptureCalls[0]!.cwd, canonicalB);
822
- assert.deepEqual(validationCaptureCalls[0]!.arguments.slice(2), validatorArgumentTokens, "the Go-owned targeted validator must receive the exact public vector");
823
-
824
- const validatorPiLog = record(JSON.parse(readFileSync(fakePiLog, "utf8")) as unknown, "validator fake Pi log");
825
- assert.ok(Array.isArray(validatorPiLog.calls), "validator fake Pi log must record the Go-owned subprocess");
826
- const validatorCall = validatorPiLog.calls.map((call) => record(call, "validator fake Pi call")).find((call) => call.role === "targeted-validator");
827
- assert.ok(validatorCall, "the fake Pi log must contain the Go-owned targeted-validator subprocess");
828
- assert.deepEqual(validatorCall!.entries, [], "the Go-owned validator must run from an empty isolated sandbox");
829
- assert.notEqual(validatorCall!.cwd, canonicalB, "the Go-owned validator subprocess must not run in B");
830
- assert.notEqual(validatorCall!.cwd, sessionA, "the Go-owned validator subprocess must not run in A");
831
- assert.equal(existsSync(stringValue(validatorCall!.cwd, "validator fake Pi scratch cwd")), false, "the Go-owned validator sandbox must be removed after the subprocess exits");
832
- const validatorPrompt = stringValue(validatorCall!.prompt, "validator fake Pi prompt");
833
- assert.ok(validatorPrompt.length > 0, "the Go-owned validator must receive a provider-rendered prompt");
834
- assert.ok(validatorPrompt.includes(validatorRequestHash), "the Go-owned validator prompt must retain the provider request hash");
863
+
864
+ // gentle-pi#311 P3: the targeted validator now completes in-process
865
+ // through the same host relay seam as the lens reviewer above — there is
866
+ // no Go-owned pi subprocess, no PATH fixture, and no scratch sandbox to
867
+ // assert for this leg any more; it never reaches the native adapter's
868
+ // own `review capture-validation` invocation, since the relay spawns
869
+ // gentle-ai itself for both materialize and submit.
870
+ assert.equal(validatorReviewer.calls.length, 1, "the in-process targeted validator must complete exactly once");
871
+ assert.ok(validatorReviewer.calls[0]!.promptText.includes(validatorRequestHash), "the targeted-validator prompt must retain the provider request hash");
872
+ assert.equal(nativeCalls.some((call) => call.arguments[0] === "review" && call.arguments[1] === "capture-validation"), false, "the targeted validator must not reach the native adapter directly");
835
873
 
836
874
  // Approval no longer burns on its own: it commits one pending
837
875
  // acknowledgement and waits for the host to run that exact invocation
@@ -0,0 +1,194 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test, { after } from "node:test";
6
+ import {
7
+ DOUBLE_ESC_CANCEL_FILE,
8
+ DOUBLE_ESC_CANCEL_SCHEMA,
9
+ DOUBLE_ESC_CANCEL_WINDOW_MS,
10
+ loadDoubleEscCancelPolicy,
11
+ parseDoubleEscCancelPolicyFile,
12
+ resolveDoubleEscCancelPolicy,
13
+ writeDoubleEscCancelPolicy,
14
+ } from "../lib/double-esc-cancel-policy.ts";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Double-esc-cancel policy (issue #1163).
18
+ //
19
+ // Global-only resolver: global file > env var > default off. Mirrors the
20
+ // resolution shape of lib/background-subagents-policy.ts minus the
21
+ // project-file layer, since this preference is deliberately global-only
22
+ // (see the feature scope in odd/tasks/double-esc-cancel.md). Strict schema
23
+ // decode, fail-closed to "off" on any malformed input.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const scratchRoots: string[] = [];
27
+
28
+ function makeScratch(prefix: string): string {
29
+ const dir = mkdtempSync(join(tmpdir(), prefix));
30
+ scratchRoots.push(dir);
31
+ return dir;
32
+ }
33
+
34
+ after(() => {
35
+ for (const dir of scratchRoots) rmSync(dir, { recursive: true, force: true });
36
+ });
37
+
38
+ function writePolicyFile(dir: string, policy: string): void {
39
+ mkdirSync(dir, { recursive: true });
40
+ writeFileSync(join(dir, DOUBLE_ESC_CANCEL_FILE), JSON.stringify({ schema: DOUBLE_ESC_CANCEL_SCHEMA, policy }));
41
+ }
42
+
43
+ const EMPTY_ENV = {} as Record<string, string | undefined>;
44
+
45
+ test("the confirmation window is 1000ms, deliberately double Pi's own 500ms idle double-Esc window", () => {
46
+ assert.equal(DOUBLE_ESC_CANCEL_WINDOW_MS, 1000);
47
+ });
48
+
49
+ // --- strict decode ---------------------------------------------------------
50
+
51
+ test("strict decode accepts exactly the v1 schema with policy on|off", () => {
52
+ assert.equal(parseDoubleEscCancelPolicyFile(`{"schema":"${DOUBLE_ESC_CANCEL_SCHEMA}","policy":"on"}`), "on");
53
+ assert.equal(parseDoubleEscCancelPolicyFile(`{"schema":"${DOUBLE_ESC_CANCEL_SCHEMA}","policy":"off"}`), "off");
54
+ });
55
+
56
+ test("strict decode rejects malformed shapes", () => {
57
+ for (const raw of [
58
+ "not json",
59
+ "[]",
60
+ "null",
61
+ '{"policy":"on"}',
62
+ '{"schema":"gentle-pi.double-esc-cancel/v2","policy":"on"}',
63
+ `{"schema":"${DOUBLE_ESC_CANCEL_SCHEMA}","policy":"ON"}`,
64
+ `{"schema":"${DOUBLE_ESC_CANCEL_SCHEMA}","policy":true}`,
65
+ `{"schema":"${DOUBLE_ESC_CANCEL_SCHEMA}","policy":"on","extra":1}`,
66
+ ]) {
67
+ assert.equal(parseDoubleEscCancelPolicyFile(raw), undefined, `must reject: ${raw}`);
68
+ }
69
+ });
70
+
71
+ // --- cascade: global file > env > default off -------------------------------
72
+
73
+ test("default is off with no file and no env", () => {
74
+ const configHome = join(makeScratch("gp-esc-home-"), "gentle-ai");
75
+ assert.equal(loadDoubleEscCancelPolicy({ gentlePiConfigHome: configHome, env: EMPTY_ENV }), "off");
76
+ });
77
+
78
+ test("the resolver reads GENTLE_PI_CONFIG_HOME from the given env, never from process.env", () => {
79
+ const root = makeScratch("gp-esc-envhome-");
80
+ writePolicyFile(root, "on");
81
+ const result = resolveDoubleEscCancelPolicy({ env: { GENTLE_PI_CONFIG_HOME: root } });
82
+ assert.equal(result.policy, "on");
83
+ assert.equal(result.globalFile, join(root, "double-esc-cancel.json"));
84
+ });
85
+
86
+ test("global file overrides env", () => {
87
+ const configHome = join(makeScratch("gp-esc-home-"), "gentle-ai");
88
+ writePolicyFile(configHome, "on");
89
+ assert.equal(
90
+ loadDoubleEscCancelPolicy({ gentlePiConfigHome: configHome, env: { GENTLE_PI_DOUBLE_ESC_CANCEL: "off" } }),
91
+ "on",
92
+ );
93
+ });
94
+
95
+ test("env var applies only when no policy file exists, and only exact on|off", () => {
96
+ const configHome = join(makeScratch("gp-esc-home-"), "gentle-ai");
97
+ assert.equal(
98
+ loadDoubleEscCancelPolicy({ gentlePiConfigHome: configHome, env: { GENTLE_PI_DOUBLE_ESC_CANCEL: "on" } }),
99
+ "on",
100
+ );
101
+ for (const invalid of ["1", "true", "ON", "yes", ""]) {
102
+ assert.equal(
103
+ loadDoubleEscCancelPolicy({ gentlePiConfigHome: configHome, env: { GENTLE_PI_DOUBLE_ESC_CANCEL: invalid } }),
104
+ "off",
105
+ `env value "${invalid}" must fail closed to off`,
106
+ );
107
+ }
108
+ });
109
+
110
+ test("a malformed global file fails closed to off instead of falling through to env", () => {
111
+ const configHome = join(makeScratch("gp-esc-home-"), "gentle-ai");
112
+ mkdirSync(configHome, { recursive: true });
113
+ writeFileSync(join(configHome, DOUBLE_ESC_CANCEL_FILE), "{malformed");
114
+ assert.equal(
115
+ loadDoubleEscCancelPolicy({ gentlePiConfigHome: configHome, env: { GENTLE_PI_DOUBLE_ESC_CANCEL: "on" } }),
116
+ "off",
117
+ );
118
+ });
119
+
120
+ // --- resolver attribution ---------------------------------------------------
121
+
122
+ test("the resolver attributes the global file when it decides", () => {
123
+ const configHome = join(makeScratch("gp-esc-home-"), "gentle-ai");
124
+ writePolicyFile(configHome, "on");
125
+ const resolution = resolveDoubleEscCancelPolicy({ gentlePiConfigHome: configHome, env: EMPTY_ENV });
126
+ assert.deepEqual(resolution, {
127
+ policy: "on",
128
+ source: "global_file",
129
+ malformed: false,
130
+ globalFile: join(configHome, DOUBLE_ESC_CANCEL_FILE),
131
+ globalFileExists: true,
132
+ envValue: undefined,
133
+ });
134
+ });
135
+
136
+ test("the resolver attributes the environment variable when no file exists", () => {
137
+ const configHome = join(makeScratch("gp-esc-home-"), "gentle-ai");
138
+ const resolution = resolveDoubleEscCancelPolicy({
139
+ gentlePiConfigHome: configHome,
140
+ env: { GENTLE_PI_DOUBLE_ESC_CANCEL: "on" },
141
+ });
142
+ assert.equal(resolution.source, "environment");
143
+ assert.equal(resolution.policy, "on");
144
+ assert.equal(resolution.globalFileExists, false);
145
+ });
146
+
147
+ test("the resolver attributes the built-in default when nothing else decides", () => {
148
+ const configHome = join(makeScratch("gp-esc-home-"), "gentle-ai");
149
+ const resolution = resolveDoubleEscCancelPolicy({ gentlePiConfigHome: configHome, env: EMPTY_ENV });
150
+ assert.equal(resolution.source, "default");
151
+ assert.equal(resolution.policy, "off");
152
+ assert.equal(resolution.malformed, false);
153
+ });
154
+
155
+ test("the resolver attributes a malformed global file to that file and does not fall through", () => {
156
+ const configHome = join(makeScratch("gp-esc-home-"), "gentle-ai");
157
+ mkdirSync(configHome, { recursive: true });
158
+ writeFileSync(join(configHome, DOUBLE_ESC_CANCEL_FILE), "{malformed");
159
+ const resolution = resolveDoubleEscCancelPolicy({
160
+ gentlePiConfigHome: configHome,
161
+ env: { GENTLE_PI_DOUBLE_ESC_CANCEL: "on" },
162
+ });
163
+ assert.equal(resolution.source, "global_file");
164
+ assert.equal(resolution.policy, "off");
165
+ assert.equal(resolution.malformed, true);
166
+ });
167
+
168
+ test("loadDoubleEscCancelPolicy delegates to the resolver so the two can never disagree", () => {
169
+ const configHome = join(makeScratch("gp-esc-home-"), "gentle-ai");
170
+ writePolicyFile(configHome, "on");
171
+ assert.equal(
172
+ loadDoubleEscCancelPolicy({ gentlePiConfigHome: configHome, env: EMPTY_ENV }),
173
+ resolveDoubleEscCancelPolicy({ gentlePiConfigHome: configHome, env: EMPTY_ENV }).policy,
174
+ );
175
+ });
176
+
177
+ // --- writer -------------------------------------------------------------------
178
+
179
+ test("writeDoubleEscCancelPolicy writes the strict v1 shape to the global file, creating the config home", () => {
180
+ const configHome = join(makeScratch("gp-esc-home-"), "gentle-ai", "nested");
181
+ const path = writeDoubleEscCancelPolicy("on", { gentlePiConfigHome: configHome });
182
+ assert.equal(path, join(configHome, DOUBLE_ESC_CANCEL_FILE));
183
+ assert.equal(loadDoubleEscCancelPolicy({ gentlePiConfigHome: configHome, env: EMPTY_ENV }), "on");
184
+ });
185
+
186
+ test("writeDoubleEscCancelPolicy(off) round-trips through the resolver", () => {
187
+ const configHome = join(makeScratch("gp-esc-home-"), "gentle-ai");
188
+ writeDoubleEscCancelPolicy("on", { gentlePiConfigHome: configHome });
189
+ writeDoubleEscCancelPolicy("off", { gentlePiConfigHome: configHome });
190
+ const resolution = resolveDoubleEscCancelPolicy({ gentlePiConfigHome: configHome, env: EMPTY_ENV });
191
+ assert.equal(resolution.policy, "off");
192
+ assert.equal(resolution.source, "global_file");
193
+ assert.equal(resolution.malformed, false);
194
+ });