gentle-pi 3.2.0 → 3.3.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 (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
@@ -56,11 +56,12 @@ const VALIDATOR_TOKENS = Object.freeze([`--lineage=${ROLE_LINEAGE}`, `--expected
56
56
  const VALIDATION_REQUEST = Object.freeze({ schema: "gentle-ai.review-targeted-validation-request/v1", requestHash: ROLE_REQUEST_HASH });
57
57
  const ROLE_ARTIFACT = Object.freeze({ schema: "gentle-ai.review-provider-role-capture/v1", lineage_id: ROLE_LINEAGE, target_identity: ROLE_TARGET, role: "refuter", captured: true });
58
58
 
59
+ const REVIEWER_SELECTION = "test-provider/test-model";
59
60
  function descriptor(overrides = {}) {
60
61
  return {
61
62
  schema: DESCRIPTOR_SCHEMA,
62
63
  gentleAiExecutable: PLACEHOLDER_BINARY,
63
- piExecutable: "pi",
64
+ reviewerSelection: REVIEWER_SELECTION,
64
65
  cases: [{ name: "baseline-negative-control", kind: "relay-unavailable", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }],
65
66
  ...overrides,
66
67
  };
@@ -121,7 +122,7 @@ function roleDescriptor(kind: "provider-role-refuter" | "provider-role-validator
121
122
  const tokens = kind === "provider-role-refuter" ? REFUTER_TOKENS : VALIDATOR_TOKENS;
122
123
  const entry: Record<string, unknown> = { name: `${kind}-case`, kind, argumentTokens: [...tokens] };
123
124
  if (kind === "provider-role-validator") entry.validationRequest = structuredClone(VALIDATION_REQUEST);
124
- return { schema: DESCRIPTOR_SCHEMA, gentleAiExecutable: PLACEHOLDER_BINARY, piExecutable: "pi", cases: [entry], ...overrides };
125
+ return { schema: DESCRIPTOR_SCHEMA, gentleAiExecutable: PLACEHOLDER_BINARY, reviewerSelection: REVIEWER_SELECTION, cases: [entry], ...overrides };
125
126
  }
126
127
  const ROLE_STUB_BINARY = sandboxPath("gentle-ai-role-stub");
127
128
  writeFileSync(ROLE_STUB_BINARY, "stub");
@@ -355,7 +356,6 @@ function capableStubHarness(t: test.TestContext) {
355
356
  t.after(() => rmSync(directory, { recursive: true, force: true }));
356
357
  const materializeLog = join(directory, "materialized");
357
358
  const submitLog = join(directory, "submitted");
358
- const piLog = join(directory, "pi-launched");
359
359
  const gentleAi = join(directory, "gentle-ai");
360
360
  // Deliberately CAPABLE: it honours --materialize=true and would accept a
361
361
  // submission, exactly like a binary a maintainer mis-declared as incapable.
@@ -375,152 +375,114 @@ process.exit(0);
375
375
  `,
376
376
  );
377
377
  chmodSync(gentleAi, 0o755);
378
- const pi = join(directory, "pi");
379
- writeFileSync(
380
- pi,
381
- `#!/usr/bin/env node
382
- const fs = require("node:fs");
383
- fs.writeFileSync(${JSON.stringify(piLog)}, "launched");
384
- const chunks = [];
385
- process.stdin.on("data", (chunk) => chunks.push(chunk));
386
- process.stdin.on("end", () => { process.stdout.write("pi-result-bytes"); process.exit(0); });
387
- `,
388
- );
389
- chmodSync(pi, 0o755);
390
- return { gentleAi, pi, materializeLog, submitLog, piLog };
378
+ return { gentleAi, materializeLog, submitLog };
391
379
  }
392
- test("a relay-unavailable case against a CAPABLE binary fails closed at pi: zero pi launch, zero submission", async (t) => {
380
+ // gentle-pi#311 P4: lens captures no longer spawn a pi child, so the
381
+ // mis-declared-capable negative control no longer needs an "unlaunchable pi"
382
+ // fixture -- it must instead fail closed at the reviewer-registry stage
383
+ // (`unresolvableReviewerRegistry`, provider-relay-matrix.mjs), before any
384
+ // completion or submission.
385
+ test("a relay-unavailable case against a CAPABLE binary fails closed at the reviewer stage: zero completion, zero submission", async (t) => {
393
386
  const stub = capableStubHarness(t);
394
- const [verdict] = await runMatrix(validateDescriptor({ ...descriptor(), gentleAiExecutable: stub.gentleAi, piExecutable: stub.pi }));
387
+ const [verdict] = await runMatrix(validateDescriptor({ ...descriptor(), gentleAiExecutable: stub.gentleAi }));
395
388
  // The declared binary really is capable, so the negative control cannot
396
389
  // pass — but it must fail WITHOUT mutating anything.
397
390
  assert.equal(verdict!.verdict, "fail");
398
- assert.match(verdict!.reason, /kind=pi-launch-failed stage=pi mutationOutcome=none/);
399
- // The load-bearing assertions: the real pi never ran and the real
400
- // submission never fired.
401
- assert.equal(existsSync(stub.piLog), false, "pi must never launch for a relay-unavailable case");
391
+ assert.match(verdict!.reason, /kind=reviewer-model-not-found stage=pi mutationOutcome=none/);
392
+ // The load-bearing assertion: the real submission never fired.
402
393
  assert.equal(existsSync(stub.submitLog), false, "a relay-unavailable case must never reach submit");
403
394
  // Materialize still happens: it is the honest capability probe, and it is
404
395
  // read-only (mutationOutcome stays "none" before submit).
405
396
  assert.equal(existsSync(stub.materializeLog), true);
406
397
  });
407
398
  // ---------------------------------------------------------------------------
408
- // Resolve-once (POSIX subprocess proof) — a bare Pi declaration is resolved
409
- // exactly once at the precheck and never re-resolved between precheck and
410
- // launch. Deterministic local stub scenario: the first PATH candidate is
411
- // resolved at precheck, a capable gentle-ai removes it during materialization,
412
- // and a second same-name PATH candidate remains. The fixed code must attempt
413
- // only the first concrete path (now gone) and fail closed — never fall through
414
- // to launch the second. This is a POSIX shebang/executable fixture: it spawns
415
- // real subprocesses with `shell:false`, which cannot execute `.bat`/`.cmd`
416
- // launchers on Windows. Windows native launcher execution is #311 P8 evidence,
417
- // not silently claimed here.
399
+ // gentle-pi#311 P4: the two "resolve-once" tests this section held (a POSIX
400
+ // subprocess proof and a platform-neutral boundary proof) verified that a
401
+ // bare `pi` PATH declaration was resolved exactly once and never re-resolved
402
+ // between precheck and launch (issue #324). Lens captures now run in-process
403
+ // through an injected reviewer registry — there is no executable to resolve
404
+ // or re-resolve, so that TOCTOU class no longer exists for this transport and
405
+ // the coverage was removed rather than kept as dead assertions. The armed
406
+ // positive-lens journey's new wiring (the in-process reviewer registry built
407
+ // from `descriptor.reviewerSelection`) is covered below instead.
418
408
  // ---------------------------------------------------------------------------
419
- test("a bare Pi declaration is resolved once: the relay never falls through to a second PATH candidate after materialization removes the first", { skip: process.platform === "win32" && "POSIX shebang/executable subprocess fixture; Windows .cmd launcher execution (shell:false) is #311 P8 evidence, not claimed here" }, async (t) => {
420
- const root = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-resolve-once-"));
421
- chmodSync(root, 0o700);
422
- t.after(() => rmSync(root, { recursive: true, force: true }));
423
- const dirA = join(root, "path-a");
424
- const dirB = join(root, "path-b");
425
- mkdirSync(dirA, { recursive: true });
426
- mkdirSync(dirB, { recursive: true });
427
- chmodSync(dirA, 0o700);
428
- chmodSync(dirB, 0o700);
429
- const firstPiLog = join(root, "pi-first-launched");
430
- const secondPiLog = join(root, "pi-second-launched");
431
- const firstPi = join(dirA, "pi");
432
- const secondPi = join(dirB, "pi");
433
- // First PATH candidate: the one the precheck resolves.
434
- writeFileSync(firstPi, `#!/usr/bin/env node\nrequire("node:fs").writeFileSync(${JSON.stringify(firstPiLog)}, "launched");\nprocess.exit(0);\n`);
435
- chmodSync(firstPi, 0o755);
436
- // Second PATH candidate: must NEVER launch under the fix.
437
- writeFileSync(secondPi, `#!/usr/bin/env node\nrequire("node:fs").writeFileSync(${JSON.stringify(secondPiLog)}, "launched");\nprocess.exit(0);\n`);
438
- chmodSync(secondPi, 0o755);
439
- // A capable gentle-ai that removes the FIRST pi candidate during
440
- // materialization, simulating a provider that cleans up its own runtime
441
- // while a same-name second candidate remains on PATH.
442
- const gentleAi = join(root, "gentle-ai");
443
- writeFileSync(gentleAi, `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst argv = process.argv.slice(2);\nif (argv.includes("--materialize=true")) {\n\ttry { fs.rmSync(${JSON.stringify(firstPi)}, { force: true }); } catch {}\n\tprocess.stdout.write("prompt-bytes");\n\tprocess.exit(0);\n}\nprocess.stdout.write(JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" }));\nprocess.exit(0);\n`);
409
+ test("armed positive-lens: runMatrix completes end-to-end through the in-process reviewer registry against a stub gentle-ai binary", async (t) => {
410
+ const directory = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-armed-positive-"));
411
+ chmodSync(directory, 0o700);
412
+ t.after(() => rmSync(directory, { recursive: true, force: true }));
413
+ const materializeLog = join(directory, "materialized");
414
+ const submitLog = join(directory, "submitted");
415
+ const gentleAi = join(directory, "gentle-ai");
416
+ // A capable gentle-ai: materialize returns a frozen prompt carrying the
417
+ // real GENTLE_AI_REVIEW_BINDING and GENTLE_AI_REVIEW_CONTEXT lines
418
+ // (exactly what Go embeds in production) so the armed in-process reviewer
419
+ // can echo the subject_hash and the changed-path manifest the submission
420
+ // expects. Submit does NOT accept unconditionally: it reads the --input
421
+ // payload and refuses any result missing a field Go admission requires
422
+ // (subject_hash, inspection.status, inspection.paths, findings,
423
+ // evidence), so a faux reviewer that drifts from the reviewer contract
424
+ // fails here instead of only against the real binary.
425
+ const subjectHash = `sha256:${"a".repeat(64)}`;
426
+ const manifestPaths = ["lib/review-host-relay.ts", "docs/review-integration.md"];
427
+ const bindingLine = `GENTLE_AI_REVIEW_BINDING {"subject_hash":"${subjectHash}"}`;
428
+ const contextLine = `GENTLE_AI_REVIEW_CONTEXT ${JSON.stringify({ changed_path_manifest: manifestPaths.map((path) => ({ path, status: "modified" })) })}`;
429
+ writeFileSync(
430
+ gentleAi,
431
+ `#!/usr/bin/env node
432
+ const fs = require("node:fs");
433
+ const argv = process.argv.slice(2);
434
+ if (argv.includes("--materialize=true")) {
435
+ fs.writeFileSync(${JSON.stringify(materializeLog)}, JSON.stringify(argv));
436
+ process.stdout.write(${JSON.stringify(`${bindingLine}\n${contextLine}\nreview this diff\n`)});
437
+ process.exit(0);
438
+ }
439
+ const inputToken = argv.find((token) => token.startsWith("--input="));
440
+ const payload = JSON.parse(fs.readFileSync(inputToken.slice("--input=".length), "utf8"));
441
+ fs.writeFileSync(${JSON.stringify(submitLog)}, JSON.stringify({ argv, payload }));
442
+ const missing = [];
443
+ if (payload.subject_hash !== ${JSON.stringify(subjectHash)}) missing.push("subject_hash");
444
+ if (payload.inspection?.status !== "completed") missing.push("inspection.status");
445
+ if (!Array.isArray(payload.inspection?.paths) || payload.inspection.paths.length === 0) missing.push("inspection.paths");
446
+ if (!Array.isArray(payload.findings)) missing.push("findings");
447
+ if (!Array.isArray(payload.evidence)) missing.push("evidence");
448
+ if (missing.length > 0) {
449
+ process.stderr.write("reviewer result is missing required fields: " + missing.join(", "));
450
+ process.exit(1);
451
+ }
452
+ process.stdout.write(JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" }));
453
+ process.exit(0);
454
+ `,
455
+ );
444
456
  chmodSync(gentleAi, 0o755);
445
- const savedPath = process.env.PATH;
446
- process.env.PATH = [dirA, dirB, savedPath].join(delimiter);
447
- t.after(() => { process.env.PATH = savedPath; });
448
457
  const [verdict] = await runMatrix(validateDescriptor({
449
458
  ...descriptor(),
450
459
  gentleAiExecutable: gentleAi,
451
- piExecutable: "pi",
452
- cases: [{ name: "resolve-once", kind: "positive-lens", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }],
460
+ cases: [{ name: "armed-positive-lens", kind: "positive-lens", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }],
453
461
  }), { armPositive: true });
454
- // The first pi was removed during materialization, so the relay must
455
- // fail closed at pi-launch — never fall through to the second candidate.
456
- assert.equal(verdict!.verdict, "fail");
457
- assert.match(verdict!.reason, /pi-launch-failed/);
458
- // The load-bearing assertion: the second PATH candidate never launched.
459
- assert.equal(existsSync(secondPiLog), false, "the relay must never fall through to a second PATH candidate; the bare declaration is resolved once at precheck");
460
- assert.equal(existsSync(firstPiLog), false, "the first candidate was removed during materialization and must not launch");
461
- });
462
- // ---------------------------------------------------------------------------
463
- // Resolve-once (platform-neutral boundary proof) — runs on Windows AND POSIX.
464
- // Proves runMatrix passes the first resolved concrete Pi path into the relay
465
- // boundary, not the bare declaration and not a second PATH candidate. Uses the
466
- // smallest dependency-injection seam in the existing options object: an
467
- // optional `relay` function defaulting to the real relay. The injected fake
468
- // records request.piExecutable and never executes a real subprocess, so no
469
- // shebang/chmod/.cmd execution is involved. No real model or network.
470
- // ---------------------------------------------------------------------------
471
- test("platform-neutral: runMatrix passes the first resolved concrete Pi path into the relay boundary (no second resolution)", async (t) => {
472
- // Keep the fixture on the same Windows volume as process.cwd() so the
473
- // first PATH component genuinely exercises relative-path normalization.
474
- const root = mkdtempSync(join(process.cwd(), ".gentle-pi-maintainer-portable-"));
475
- chmodSync(root, 0o700);
476
- t.after(() => rmSync(root, { recursive: true, force: true }));
477
- const dirA = join(root, "path-a");
478
- const dirB = join(root, "path-b");
479
- mkdirSync(dirA, { recursive: true });
480
- mkdirSync(dirB, { recursive: true });
481
- chmodSync(dirA, 0o700);
482
- chmodSync(dirB, 0o700);
483
- const firstPi = join(dirA, "pi");
484
- const secondPi = join(dirB, "pi");
485
- // Ordinary files (no shebang/chmod needed): the injected relay never
486
- // executes them. Only the precheck's existsSync/statSync touches them.
487
- writeFileSync(firstPi, "first");
488
- writeFileSync(secondPi, "second");
489
- const gentleAi = join(root, "gentle-ai");
490
- writeFileSync(gentleAi, "gentle-ai");
491
- // First PATH component is RELATIVE to process.cwd(): a relative component
492
- // must still normalize to the same absolute firstPi the precheck resolved.
493
- const dirARelative = relative(process.cwd(), dirA);
494
- assert.equal(isAbsolute(dirARelative), false, "fixture must keep the first PATH component relative");
495
- const savedPath = process.env.PATH;
496
- process.env.PATH = [dirARelative, dirB, savedPath].join(delimiter);
497
- t.after(() => { process.env.PATH = savedPath; });
498
- let receivedPiExecutable: string | undefined;
462
+ assert.equal(verdict!.verdict, "pass", JSON.stringify(verdict));
463
+ assert.ok(verdict!.promptByteLength! > 0);
464
+ assert.ok(verdict!.resultByteLength! > 0);
465
+ assert.equal(existsSync(materializeLog), true);
466
+ assert.equal(existsSync(submitLog), true, "an armed, successful positive-lens case must reach submit");
467
+ // The submitted payload must carry every field Go admission requires and
468
+ // inspect exactly the frozen manifest, so the faux reviewer can never
469
+ // drift from the reviewer contract unnoticed.
470
+ const submitted = JSON.parse(readFileSync(submitLog, "utf8")) as { payload: Record<string, unknown> };
471
+ assert.equal(submitted.payload["subject_hash"], subjectHash);
472
+ assert.deepEqual(submitted.payload["inspection"], { status: "completed", paths: manifestPaths });
473
+ assert.deepEqual(submitted.payload["findings"], []);
474
+ assert.ok(Array.isArray(submitted.payload["evidence"]) && (submitted.payload["evidence"] as unknown[]).length > 0);
475
+ });
476
+ test("armed positive-lens: a malformed reviewerSelection blocks with a clear reason, never a synthesized model", async (t) => {
477
+ const stub = capableStubHarness(t);
499
478
  const [verdict] = await runMatrix(validateDescriptor({
500
- ...descriptor(),
501
- gentleAiExecutable: gentleAi,
502
- piExecutable: "pi",
503
- cases: [{ name: "portable-resolve-once", kind: "positive-lens", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }],
504
- }), {
505
- armPositive: true,
506
- relay: async (request) => {
507
- receivedPiExecutable = request.piExecutable;
508
- // Simulate materialization removing the first candidate; the
509
- // path was captured at precheck and must not be re-resolved.
510
- rmSync(firstPi, { force: true });
511
- return { promptByteLength: 1, resultByteLength: 1, submission: "{}" };
512
- },
513
- });
514
- // The relay received the EXACT first concrete resolved path — equality to
515
- // the absolute fixture proves normalization held under a relative PATH.
516
- assert.equal(receivedPiExecutable, firstPi);
517
- assert.notEqual(receivedPiExecutable, secondPi);
518
- assert.notEqual(receivedPiExecutable, "pi");
519
- assert.equal(verdict!.verdict, "pass");
520
- // The first candidate was removed during the relay call; the second is
521
- // untouched, proving no fallthrough resolution occurred.
522
- assert.equal(existsSync(firstPi), false);
523
- assert.equal(existsSync(secondPi), true);
479
+ ...descriptor({ reviewerSelection: "not-a-provider-slash-id" }),
480
+ gentleAiExecutable: stub.gentleAi,
481
+ cases: [{ name: "malformed-reviewer-selection", kind: "positive-lens", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }],
482
+ }), { armPositive: true });
483
+ assert.equal(verdict!.verdict, "fail");
484
+ assert.match(verdict!.reason, /reviewerSelection/);
485
+ assert.equal(existsSync(stub.submitLog), false, "a malformed reviewer selection must never reach submit");
524
486
  });
525
487
 
526
488
  // ---------------------------------------------------------------------------
@@ -549,7 +511,7 @@ test("negative control: the real relay returns a typed relay-unavailable error b
549
511
  // pi launch; RELAY_UNAVAILABLE at materialize proves Pi never launched.
550
512
  let caught: unknown;
551
513
  try {
552
- await runReviewHostRelaySlot({ captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION), gentleAiExecutable: baselineBinary!, piExecutable: "pi" });
514
+ await runReviewHostRelaySlot({ captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION), gentleAiExecutable: baselineBinary! });
553
515
  } catch (error) {
554
516
  caught = error;
555
517
  }
@@ -568,16 +530,18 @@ test("negative control: an armed positive-lens against the baseline blocks becau
568
530
  });
569
531
  // ---------------------------------------------------------------------------
570
532
  // Honest positive arming/skip — the organic journey needs a real capable
571
- // binary, a real pi, and a real review session (lifecycle machinery beyond
572
- // this work unit). The runner blocks the positive leg before materialize
573
- // unless armed. These tests run WITHOUT the arm (the verifier arms it).
533
+ // binary and a real review session (lifecycle machinery beyond this work
534
+ // unit); the reviewer leg itself runs through the in-process registry built
535
+ // from `descriptor.reviewerSelection` (gentle-pi#311 P4), never a real pi
536
+ // child. The runner blocks the positive leg before materialize unless armed.
537
+ // These tests run WITHOUT the arm (the verifier arms it).
574
538
  // ---------------------------------------------------------------------------
575
539
  const capableArmed = typeof capableBinary === "string" && capableBinary.length > 0 && capableBinary.startsWith("/") && existsSync(capableBinary);
576
540
  const capableReason = () => `${CAPABLE_ENV} is unset or not an existing absolute path; supply a capable gentle-ai binary (local build of origin/main with the pi host-relay surface), or set ${REQUIRE_ENV}=1 to fail instead of skip.`;
577
541
  if (!capableArmed && armed) throw new Error(capableReason());
578
542
  if (!capableArmed) console.log(`tests/maintainer/provider-relay.maintest.ts: ${capableReason()}`);
579
- function positiveDescriptor(gentleAi = capableBinary, pi = "pi") {
580
- return validateDescriptor({ ...descriptor(), gentleAiExecutable: gentleAi!, piExecutable: pi, cases: [{ name: "positive-lens-relay", kind: "positive-lens", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }] });
543
+ function positiveDescriptor(gentleAi = capableBinary) {
544
+ return validateDescriptor({ ...descriptor(), gentleAiExecutable: gentleAi!, cases: [{ name: "positive-lens-relay", kind: "positive-lens", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }] });
581
545
  }
582
546
  test("positive arming/skip: an unarmed positive leg blocks loudly and never fakes green", { skip: !capableArmed }, async () => {
583
547
  const [verdict] = await runMatrix(positiveDescriptor());
@@ -588,12 +552,6 @@ test("positive arming/skip: an unarmed positive leg blocks loudly and never fake
588
552
  assert.notEqual(verdict!.verdict, "pass");
589
553
  assert.notEqual(verdict!.verdict, "fail");
590
554
  });
591
- test("positive arming/skip: a missing pi blocks at the pi pre-arm before materialize", { skip: !capableArmed }, async () => {
592
- const [verdict] = await runMatrix(positiveDescriptor(capableBinary, sandboxPath("not-a-real-pi")));
593
- assert.equal(verdict!.verdict, "blocked");
594
- assert.match(verdict!.reason, /piExecutable/);
595
- assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND);
596
- });
597
555
  test("positive arming/skip: the exact next evidence command is surfaced for the separate verifier", { skip: !capableArmed }, async () => {
598
556
  const [verdict] = await runMatrix(positiveDescriptor());
599
557
  assert.match(verdict!.command!, /node --experimental-strip-types scripts\/maintainer\/provider-relay-matrix.mjs --descriptor/);
@@ -288,11 +288,42 @@ test("3.1.0 repeats 3.0.1 because the negotiated lane Pi consumes is unchanged",
288
288
  assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.0.1"] as Record<string, boolean>);
289
289
  });
290
290
 
291
+ test("3.2.1 repeats 3.1.0 because the negotiated lane Pi consumes is unchanged", () => {
292
+ // v3.2.1 changed the ODD orchestrator contract only (gentle-ai #4714
293
+ // follow-up). Diffing contracts/review-integration/v2 and
294
+ // contracts/review-provider-contract between the v3.1.0 and v3.2.1 tags
295
+ // in the gentle-ai source tree showed zero byte changes (provider contract
296
+ // stays 1.2.0), so this row repeats 3.1.0. riskEvidence and hint remain
297
+ // dark because neither is proven to reach Pi's negotiated START path.
298
+ const contract = NATIVE_CLI_CONTRACTS["3.2.1"] as Record<string, boolean>;
299
+ assert.equal(contract.riskEvidence, false);
300
+ assert.equal(contract.hint, false);
301
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.1.0"] as Record<string, boolean>);
302
+ });
303
+
304
+ test("3.4.0 repeats 3.2.1 because the negotiated lane Pi consumes is unchanged", () => {
305
+ // v3.4.0 (gentle-pi never pinned the intervening v3.3.0 tag, so it gets no
306
+ // row) added capabilities/v2.6, status/v8-v9, and the `review assess`
307
+ // review_due/review_due_reason/next_transition fields. Diffing
308
+ // contracts/review-integration/v2 and contracts/review-provider-contract
309
+ // between the v3.2.1 and v3.4.0 tags in the gentle-ai source tree showed
310
+ // the provider contract stays byte-identical at 1.2.0, and every
311
+ // review-integration/v2 change is an additive superset that
312
+ // decodeReviewStatusV3 and the capabilities negotiator already accept
313
+ // without touching the closed START/STATUS fields this row negotiates, so
314
+ // this row repeats 3.2.1. riskEvidence and hint remain dark because
315
+ // neither is proven to reach Pi's negotiated START path.
316
+ const contract = NATIVE_CLI_CONTRACTS["3.4.0"] as Record<string, boolean>;
317
+ assert.equal(contract.riskEvidence, false);
318
+ assert.equal(contract.hint, false);
319
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.2.1"] as Record<string, boolean>);
320
+ });
321
+
291
322
  test("no shipped version key was added beyond the pin bump", () => {
292
323
  // Rows are promises to consumers, so a new key only ever appears in a
293
324
  // dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
294
325
  // v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
295
326
  // so they get no row: a row asserts ground truth measured against a binary
296
327
  // Pi actually ran, and the table only has to be ascending, not gapless.
297
- assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0"]);
328
+ assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0"]);
298
329
  });