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
@@ -200,6 +200,22 @@ const CAPABILITIES_SCHEMA_IDENTITIES
200
200
  requiredMandatoryFeatures: REQUIRED_MANDATORY_FEATURES_V23,
201
201
  optionalFeatureFloor: 14,
202
202
  }),
203
+ // Ground-truthed against the published v3.4.0 binary: capabilities/v2.6
204
+ // advertises the same required surface as v2.5 (status/v6 is still
205
+ // advertised for compatibility) plus the status/v7 and status/v8 schemas,
206
+ // which are superset-checked additions, not requirements --
207
+ // decodeReviewStatusV3 already accepts v7/v8/v9 as additive extensions of
208
+ // v6, so the required-schema floor stays unchanged. `review assess` also
209
+ // gained review_due/review_due_reason/next_transition, which is unrelated
210
+ // to this negotiated capabilities surface. The v3.4.0 binary advertised
211
+ // 15 optional features, same as v2.5's binary (floor stays at 14, its
212
+ // established minimum).
213
+ "gentle-ai.review-integration.capabilities/v2.6": Object.freeze({
214
+ protocolMinor: 6,
215
+ requiredSchemas: Object.freeze([...REQUIRED_SCHEMAS_COMMON_V23, "gentle-ai.review-integration.capabilities/v2.6", "gentle-ai.review-integration.consent/v3", "gentle-ai.review-integration.start/v4", "gentle-ai.review-integration.status/v6", "gentle-ai.review-intended-untracked-selection/v1"]),
216
+ requiredMandatoryFeatures: REQUIRED_MANDATORY_FEATURES_V23,
217
+ optionalFeatureFloor: 14,
218
+ }),
203
219
  });
204
220
  const OPTIONAL_FEATURE_NAMES = Object.freeze([
205
221
  "base_ref_workspace_overlay",
@@ -439,11 +455,21 @@ const REPOSITORY_CONTEXT_OUTCOMES = ["applied", "pending", "blocked_conflict", "
439
455
 
440
456
 
441
457
 
442
- // The two Go-owned non-lens provider role capture operations (gentle-pi#311
443
- // P4-roles; provider side gentle-ai#3264). Their collect inputs are
444
- // self-contained authority-advancing vectors: binding tokens plus
445
- // `--agent=pi --execute=true`, with NO submission descriptor. The known set
446
- // is closed — an unknown role capture operation is never executed.
458
+ // The two non-lens provider role capture operations (gentle-pi#311 P4-roles;
459
+ // provider side gentle-ai#3264). An older gentle-ai still renders each one as
460
+ // a self-contained authority-advancing vector: binding tokens plus
461
+ // `--agent=pi --execute=true`, with NO submission descriptor — executing the
462
+ // exact rendered tokens makes Go materialize the role prompt, run its own
463
+ // locked-down pi subprocess, and admit the verdict itself.
464
+ //
465
+ // gentle-ai's v9 contract makes both roles host-mediated exactly like a lens
466
+ // materialize slot instead (gentle-pi#311 P3): the collect input carries
467
+ // `--materialize=true` (never alongside `--execute`) plus a provider-owned
468
+ // `submission` descriptor, and the host completes the frozen prompt
469
+ // in-process and submits the result through that exact descriptor. The two
470
+ // wire forms are mutually exclusive on one input; see the gating in
471
+ // decodeCollectInput below. The known operation set is closed — an unknown
472
+ // role capture operation is never executed either way.
447
473
  export const REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION = {
448
474
  CAPTURE_REFUTER: "review.capture-refuter",
449
475
  CAPTURE_VALIDATION: "review.capture-validation",
@@ -1452,11 +1478,18 @@ function decodeCaptureSubmission(value , label , v5 , v6
1452
1478
  // one-entry values array the host relay already consumes. A payload
1453
1479
  // carrying both wire forms at once matches no captured shape and falls
1454
1480
  // through to the legacy decoder, which rejects the unknown `value` key.
1481
+ //
1482
+ // gentle-ai's v9 contract renders the same singular-value shape for the
1483
+ // two host-mediated provider role operations (capture-refuter,
1484
+ // capture-validation): they carry a `schema` key exactly like
1485
+ // capture-result, since both bind one artifact-path-or-stdin value, never
1486
+ // the correction-plan's numeric bounds (gentle-pi#311 P3).
1455
1487
  if (v5 && typeof value === "object" && value !== null && "value" in value && !("values" in value)) {
1456
1488
  const submission = exactRecord(value, label, ["operation_token", "argument_tokens", "value"]);
1457
- const operationToken = enumeration(submission.operation_token, ["capture-result", "capture-correction-plan"] , `${label}.operation_token`);
1489
+ const operationToken = enumeration(submission.operation_token, ["capture-result", "capture-correction-plan", "capture-refuter", "capture-validation"] , `${label}.operation_token`);
1458
1490
  const argumentTokens = stringArray(submission.argument_tokens, `${label}.argument_tokens`, { minimum: 1 });
1459
- const row = operationToken === "capture-result"
1491
+ const bindsArtifactSchema = operationToken === "capture-result" || operationToken === "capture-refuter" || operationToken === "capture-validation";
1492
+ const row = bindsArtifactSchema
1460
1493
  ? exactRecord(submission.value, `${label}.value`, ["slot", "domain", "schema", "substitution_location"])
1461
1494
  : exactRecord(submission.value, `${label}.value`, ["slot", "domain", "minimum", "maximum", "substitution_location"]);
1462
1495
  return {
@@ -1465,7 +1498,11 @@ function decodeCaptureSubmission(value , label , v5 , v6
1465
1498
  values: [{
1466
1499
  slot: operationToken === "capture-result"
1467
1500
  ? enumeration(row.slot, ["reviewer_result"] , `${label}.value.slot`)
1468
- : enumeration(row.slot, ["correction_lines"] , `${label}.value.slot`),
1501
+ : operationToken === "capture-correction-plan"
1502
+ ? enumeration(row.slot, ["correction_lines"] , `${label}.value.slot`)
1503
+ : operationToken === "capture-refuter"
1504
+ ? enumeration(row.slot, ["provider_refuter"] , `${label}.value.slot`)
1505
+ : enumeration(row.slot, ["provider_targeted_validator"] , `${label}.value.slot`),
1469
1506
  domain: nonempty(row.domain, `${label}.value.domain`),
1470
1507
  ...(row.schema === undefined ? {} : { schema: nonempty(row.schema, `${label}.value.schema`) }),
1471
1508
  ...(row.minimum === undefined ? {} : { minimum: integer(row.minimum, `${label}.value.minimum`, 1, 200) }),
@@ -1606,7 +1643,7 @@ const CORRECTION_REQUEST_REASON_CODES = Object.freeze(["correction_plan_required
1606
1643
  // v5 capture operations that must carry a submission descriptor.
1607
1644
  const V5_SUBMISSION_CAPTURE_OPERATIONS = Object.freeze(["review.capture-correction-plan"] );
1608
1645
 
1609
- function decodeCollectInput(value , label , v5 , v6 ) {
1646
+ function decodeCollectInput(value , label , v5 , v6 , v9 ) {
1610
1647
  const input = exactRecord(value, label, ["name", "schema", "capture_operation", "arguments"], ["artifact_subject", "base_tree", "candidate_tree", "changed_path_manifest", "submission", ...(v5 ? ["provider_task", "validation_request"] : [])]);
1611
1648
  const name = text(input.name, `${label}.name`, { minimum: 1, pattern: /^[a-z0-9_]+$/ });
1612
1649
  const schema = nonempty(input.schema, `${label}.schema`);
@@ -1657,13 +1694,18 @@ function decodeCollectInput(value , label , v5 , v6
1657
1694
  sha256(argumentsList[5] .value, `${label}.arguments[5].value`);
1658
1695
  }
1659
1696
 
1660
- // gentle-pi#311 P4-roles: the two Go-owned non-lens provider role capture
1661
- // operations render SELF-CONTAINED authority-advancing vectors. Executing
1697
+ // gentle-pi#311 P4-roles / P3: the two non-lens provider role capture
1698
+ // operations render either wire form on one input, never both at once.
1699
+ // An older gentle-ai renders a SELF-CONTAINED authority-advancing vector
1700
+ // (binding tokens + --agent=pi --execute=true, no submission): executing
1662
1701
  // the exact rendered tokens makes Go materialize the role prompt, run its
1663
- // own locked-down pi subprocess, and admit the raw verdict — so a
1664
- // submission descriptor (the host-mediated completing form) on one of
1665
- // these inputs would hand the caller a way to author the verdict and is
1666
- // rejected as a provider contract violation.
1702
+ // own locked-down pi subprocess, and admit the raw verdict. gentle-ai's
1703
+ // v9 contract instead renders a HOST-MEDIATED form: the same binding
1704
+ // tokens plus --materialize=true (never --execute) and a provider-owned
1705
+ // submission descriptor, exactly like a lens capture-result materialize
1706
+ // slot — the host completes the frozen prompt in-process and submits
1707
+ // through that descriptor.
1708
+ const isRoleCaptureOperation = (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS ).includes(captureOperation);
1667
1709
  if (captureOperation === REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION.CAPTURE_REFUTER && schema !== "https://gentle-ai.dev/schema/review/refuter/v1") {
1668
1710
  throw new TypeError(`${label}.schema must be https://gentle-ai.dev/schema/review/refuter/v1`);
1669
1711
  }
@@ -1694,8 +1736,30 @@ function decodeCollectInput(value , label , v5 , v6
1694
1736
  if (providerArgument("target") !== validationRequest.correctionTargetIdentity) throw new TypeError(`${label}.arguments target must bind validation_request.correction_target_identity`);
1695
1737
  if (providerArgument("request-hash") !== validationRequest.requestHash) throw new TypeError(`${label}.arguments request-hash must bind validation_request.request_hash`);
1696
1738
  }
1697
- if (input.submission !== undefined && (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS ).includes(captureOperation)) {
1698
- throw new TypeError(`${label}.submission is not allowed on the self-contained ${captureOperation} vector`);
1739
+ if (isRoleCaptureOperation && input.submission !== undefined) {
1740
+ // A submission descriptor only ever rides the v9 host-mediated form
1741
+ // (gentle-ai's v9 provider contract; the sibling branch ahead of the
1742
+ // currently-released v8). A v8-or-earlier provider (status/v3..v8) never
1743
+ // renders one, even if it happened to carry a submission-shaped payload,
1744
+ // so this is gated on v9 specifically — not on v5, which v8 already
1745
+ // satisfies. Within a v9 payload the discriminator is exactly the
1746
+ // materialize slot's --materialize=true with no --execute, the same one
1747
+ // a lens capture-result materialize slot already uses. Mixing a
1748
+ // submission with the old self-contained --execute=true vector, or with
1749
+ // neither flag, is a malformed vector.
1750
+ if (!v9) {
1751
+ throw new TypeError(`${label}.submission requires the v9 provider contract for the host-mediated ${captureOperation} form`);
1752
+ }
1753
+ const argumentNamed = (argumentName ) => {
1754
+ const matches = argumentsList.filter((argument) => argument.name === argumentName);
1755
+ return matches.length === 1 ? matches[0] .value : undefined;
1756
+ };
1757
+ if (argumentNamed("materialize") !== "true") {
1758
+ throw new TypeError(`${label}.submission requires --materialize=true on the host-mediated ${captureOperation} form`);
1759
+ }
1760
+ if (argumentNamed("execute") !== undefined) {
1761
+ throw new TypeError(`${label} must not carry --execute alongside a submission descriptor on ${captureOperation}`);
1762
+ }
1699
1763
  }
1700
1764
 
1701
1765
  if (captureOperation === "review.capture-result") {
@@ -1712,7 +1776,12 @@ function decodeCollectInput(value , label , v5 , v6
1712
1776
  const submissionOperations = v5
1713
1777
  ? ["review.capture-result", "review.capture-correction-plan", ...(v6 ? ["external.select_intended_untracked"] : [])]
1714
1778
  : ["review.capture-result"];
1715
- if (input.submission !== undefined && !submissionOperations.includes(captureOperation)) {
1779
+ // Role operations are gated above by the materialize/execute discriminator
1780
+ // instead of this allowlist: an older gentle-ai's self-contained vector
1781
+ // legitimately carries no submission regardless of v5/v6, so they cannot
1782
+ // simply join the list the way every other submission-carrying operation
1783
+ // does.
1784
+ if (input.submission !== undefined && !isRoleCaptureOperation && !submissionOperations.includes(captureOperation)) {
1716
1785
  throw new TypeError(v5 ? `${label}.submission is only valid for ${submissionOperations.join(", ")}` : `${label}.submission is only valid for review.capture-result`);
1717
1786
  }
1718
1787
  if (intendedUntracked && input.submission === undefined) throw new TypeError(`${label}.submission is required for external.select_intended_untracked`);
@@ -1750,8 +1819,14 @@ export function decodeReviewManagedAssetsContinuationV1(value , label
1750
1819
  return { operation, command, ...(agent === undefined ? {} : { agent }), ...(staleAssets === undefined ? {} : { staleAssets }) };
1751
1820
  }
1752
1821
 
1753
- export function decodeReviewNextTransitionV3(value , options = {}) {
1754
- const v6 = options.v6 === true;
1822
+ export function decodeReviewNextTransitionV3(value , options = {}) {
1823
+ // v9 (gentle-ai's next contract, ahead of the currently-released v8) is
1824
+ // the only rung this sub-decoder distinguishes beyond v6: v7 and v8 add
1825
+ // nothing to next_transition/collect-input decoding (v8 only extended the
1826
+ // reviewer-result transition for OpenCode provider tasks at the top
1827
+ // status level), so they decode on the exact v6 surface here.
1828
+ const v9 = options.v9 === true;
1829
+ const v6 = options.v6 === true || v9;
1755
1830
  const v5 = options.v5 === true || v6;
1756
1831
  const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect", ...(v5 ? ["correction_request"] : []), "continuation", "unachievable_lens_slots"]);
1757
1832
  const kind = enumeration(transition.kind, ["execute", "collect", "stop"] , "next_transition.kind");
@@ -1813,7 +1888,7 @@ export function decodeReviewNextTransitionV3(value , options
1813
1888
  }
1814
1889
  if (kind === "collect") {
1815
1890
  const collect = exactRecord(transition.collect, "next_transition.collect", ["inputs"]);
1816
- const inputs = array(collect.inputs, "next_transition.collect.inputs", (entry, label) => decodeCollectInput(entry, label, v5, v6), { minimum: 1 });
1891
+ const inputs = array(collect.inputs, "next_transition.collect.inputs", (entry, label) => decodeCollectInput(entry, label, v5, v6, v9), { minimum: 1 });
1817
1892
  if (transition.execute !== undefined) throw new TypeError("next_transition.execute is incompatible with collect");
1818
1893
  return { kind, reasonCode, collect: { inputs }, ...(correctionRequest === undefined ? {} : { correctionRequest }) };
1819
1894
  }
@@ -1926,16 +2001,25 @@ export function decodeReviewStatusV3(value ) {
1926
2001
  // surfaces. status/v6 adds the intended-untracked selection; status/v7
1927
2002
  // (gentle-ai v2.6.0, advertised through capabilities/v2.5 alongside v6)
1928
2003
  // adds optional `eligible_untracked_inventory` and escalation metadata,
1929
- // so it is decoded on the v6 surface. v3 keeps rejecting every v5/v6/v7-only
1930
- // field.
2004
+ // so it is decoded on the v6 surface. status/v8 (gentle-ai main, PR #4765;
2005
+ // the current released contract) only extended the reviewer-result
2006
+ // transition for OpenCode provider tasks -- no new top-level key -- so it
2007
+ // decodes on the exact v7 surface too. status/v9 (the sibling gentle-ai
2008
+ // branch's contract, ahead of the released v8) adds nothing at this top
2009
+ // level either: its one addition is the host-mediated role `submission`
2010
+ // on a next_transition.collect input (gentle-pi#311 P3), gated on v9 in
2011
+ // decodeCollectInput, not here. v3 keeps rejecting every
2012
+ // v5/v6/v7/v8/v9-only field.
1931
2013
  const schema = typeof value === "object" && value !== null ? (value ).schema : undefined;
1932
- const v7 = schema === "gentle-ai.review-integration.status/v7";
2014
+ const v9 = schema === "gentle-ai.review-integration.status/v9";
2015
+ const v8 = v9 || schema === "gentle-ai.review-integration.status/v8";
2016
+ const v7 = v8 || schema === "gentle-ai.review-integration.status/v7";
1933
2017
  const v6 = v7 || schema === "gentle-ai.review-integration.status/v6";
1934
2018
  const v5 = v6 || schema === "gentle-ai.review-integration.status/v5";
1935
2019
  const body = exactRecord(value, "status", [
1936
2020
  "schema", "contract", "operation", "applicability", "action", "replayability", "target_identity", "projection", "repair", "candidates",
1937
2021
  ], ["authority", "frozen", "action_disposition", "eligibility", "next_transition", "authority_target_identity", ...(v5 ? ["receipt", "forecast", "repository_context", "validation_request"] : []), ...(v7 ? ["eligible_untracked_inventory", "escalation"] : [])]);
1938
- requireIdentity(body, v7 ? "gentle-ai.review-integration.status/v7" : v6 ? "gentle-ai.review-integration.status/v6" : v5 ? "gentle-ai.review-integration.status/v5" : "gentle-ai.review-integration.status/v3", REVIEW_INTEGRATION_OPERATION.STATUS);
2022
+ requireIdentity(body, v9 ? "gentle-ai.review-integration.status/v9" : v8 ? "gentle-ai.review-integration.status/v8" : v7 ? "gentle-ai.review-integration.status/v7" : v6 ? "gentle-ai.review-integration.status/v6" : v5 ? "gentle-ai.review-integration.status/v5" : "gentle-ai.review-integration.status/v3", REVIEW_INTEGRATION_OPERATION.STATUS);
1939
2023
 
1940
2024
  const applicability = enumeration(body.applicability, ["current_target", "unrelated", "ambiguous", "corrupted"] , "status.applicability");
1941
2025
  let receipt ;
@@ -1983,7 +2067,7 @@ export function decodeReviewStatusV3(value ) {
1983
2067
  if (action === "recover" && actionDisposition === undefined) throw new TypeError("recover status requires action_disposition");
1984
2068
  if (action !== "recover" && actionDisposition !== undefined) throw new TypeError("status.action_disposition is only valid for the recover action");
1985
2069
  if (body.eligibility !== undefined) decodeEligibility(body.eligibility, "status.eligibility");
1986
- const nextTransition = body.next_transition === undefined ? undefined : decodeReviewNextTransitionV3(body.next_transition, { v5, v6 });
2070
+ const nextTransition = body.next_transition === undefined ? undefined : decodeReviewNextTransitionV3(body.next_transition, { v5, v6, v9 });
1987
2071
  const validationRequest = v5 && body.validation_request !== undefined
1988
2072
  ? decodeReviewTargetedValidationRequestV1(body.validation_request, "status.validation_request")
1989
2073
  : undefined;
@@ -36,7 +36,7 @@ const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
36
36
  // version check below) derives from this constant instead of repeating the
37
37
  // literal, so a pin bump cannot leave a stale copy behind. See
38
38
  // scripts/install-gentle-ai.mjs for the incident that motivated this.
39
- export const INSTALLER_VERSION = "3.1.0";
39
+ export const INSTALLER_VERSION = "3.4.0";
40
40
  export const RELEASE_BASE_URL = `https://github.com/Gentleman-Programming/gentle-ai/releases/download/v${INSTALLER_VERSION}/`;
41
41
  export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
42
42
  SIGNED_RELEASE_ASSET: "signed-release-asset",
@@ -45,10 +45,10 @@ export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
45
45
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH = "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai";
46
46
  export const GENTLE_AI_WINDOWS_SOURCE_MODULE = "github.com/gentleman-programming/gentle-ai/v3";
47
47
  export const GENTLE_AI_WINDOWS_SOURCE_TAG = `v${INSTALLER_VERSION}`;
48
- // `go mod download -json github.com/gentleman-programming/gentle-ai/v3@v3.1.0`
48
+ // `go mod download -json github.com/gentleman-programming/gentle-ai/v3@v3.4.0`
49
49
  // with GOSUMDB=sum.golang.org reports this exact module SumDB checksum, and the
50
- // tag resolves to commit cfc415ce, the published v3.1.0 release head.
51
- export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:CrZlui5N8/RSmxvn/7usKe6q1EvRt7y6dyfEwjstmJw=";
50
+ // tag resolves to commit 82a6de96, the published v3.4.0 release head.
51
+ export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:bQOM+Xa3WN5+idTagEk15rgEEQQWVhZvq8BPF/FHtSs=";
52
52
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE = `${GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH}@${GENTLE_AI_WINDOWS_SOURCE_TAG}`;
53
53
  export const GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION = "1.25.10";
54
54
  export const GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE = "GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE";
@@ -67,7 +67,7 @@ export class GentleAiInstallerError extends Error {
67
67
  // Sentinel used while a re-pinned gentle-ai release is not yet published. A
68
68
  // sentinel digest can never match a real SHA-256, so installation fails closed,
69
69
  // and verify-package-files.mjs refuses to pack/publish while any digest below
70
- // still holds it. The v3.1.0 digests are pinned from the published release:
70
+ // still holds it. The v3.4.0 digests are pinned from the published release:
71
71
  // archive sha256 values verified against the minisign-signed checksums.txt and
72
72
  // freshly computed hashes; binary sha256 values computed from the extracted
73
73
  // executables.
@@ -109,15 +109,15 @@ async function downloadPinnedGentleAiAsset(asset, destination, options) {
109
109
  }
110
110
 
111
111
  // Windows is absent from signed release archives on purpose. gentle-ai stopped
112
- // distributing unsigned Windows builds in c4b764d0, so v3.1.0 publishes signed
112
+ // distributing unsigned Windows builds in c4b764d0, so v3.4.0 publishes signed
113
113
  // Darwin/Linux archives only. Windows x64/arm64 uses the separately verified
114
114
  // exact-tag Go SumDB source-build path below; restore archive rows only when
115
115
  // upstream ships signed Windows assets.
116
116
  export const GENTLE_AI_RELEASE_ASSETS = Object.freeze({
117
- "darwin/amd64": asset("gentle-ai_3.1.0_darwin_amd64.tar.gz", "613f0e11adeebb421daae4c68cb9f207f55c559a70595549ff25f988559226e4", "98340df0102825072431a2c0373ea4d9db1bacafced234f51fb58661ed3d731f", "gentle-ai"),
118
- "darwin/arm64": asset("gentle-ai_3.1.0_darwin_arm64.tar.gz", "bfcbf8df2682fcf1535b26c604e8dbb445df0ca00a651de204fdc2d013dfe472", "3cdc9689ea0d71186b896341b4181e2df13a82b64d236a26a3273171150d802f", "gentle-ai"),
119
- "linux/amd64": asset("gentle-ai_3.1.0_linux_amd64.tar.gz", "dc55c44a2eb46212a38eca0dfd4d778481ec37e765f40d5a0752d03c28e1ee49", "70e335d25809a0d358c12f48b2f0d1da00741e725584ceeb8c1318c60d0a6e9e", "gentle-ai"),
120
- "linux/arm64": asset("gentle-ai_3.1.0_linux_arm64.tar.gz", "3a89d5f5a549004cc2b01949014ed59f9c28e1ff0c9958531bb539504286e407", "6cf9f20fc390b13e2b9b427ca1c2df404d1bbb9248201f9a592c7f0a37ef5416", "gentle-ai"),
117
+ "darwin/amd64": asset("gentle-ai_3.4.0_darwin_amd64.tar.gz", "7d13fa45489098ff9e82dee2232204f72bf1c04ef998ceadfe1ea972697f2e56", "d9096b4bb13e56aae1251dfacd2487821a15c40b7c9397555ad615ede47e658a", "gentle-ai"),
118
+ "darwin/arm64": asset("gentle-ai_3.4.0_darwin_arm64.tar.gz", "b9052d8dc02923663d4451a4b031c4ef7328cd866c0cb6729e8ff2b9853603f0", "0aca1239cafd87ce63b194ab69da90e762801d82a96418fd6b7b12ea3806dc07", "gentle-ai"),
119
+ "linux/amd64": asset("gentle-ai_3.4.0_linux_amd64.tar.gz", "c287289a514420381e890991bb3fbea4a2c36d7b4b1774fcf6bc4deb83378915", "309d9aafb48de5ef90ba0a212e8a98e8fdbb82d0852e24015e36a5ce0fe04c06", "gentle-ai"),
120
+ "linux/arm64": asset("gentle-ai_3.4.0_linux_arm64.tar.gz", "dfd1fe70acfe577d2a36e3a658895f481467518a2923b7200403dbcca0181399", "83ba7d27d250019115690be51664bd356e09085da8858761e362e6053c6290d3", "gentle-ai"),
121
121
  });
122
122
 
123
123
  // A pinned asset is either a signed archive or, for a prerelease pin only,
@@ -9,10 +9,11 @@
9
9
  // `pnpm test` never imports it; `pnpm run test:maintainer` runs the tests;
10
10
  // the CLI is the entry point the separate verifier uses for the real organic
11
11
  // positive journey after a user-visible forecast.
12
+ import { randomUUID } from "node:crypto";
12
13
  import { spawn, spawnSync } from "node:child_process";
13
- import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
14
- import { tmpdir } from "node:os";
15
- import { delimiter, isAbsolute, join, resolve } from "node:path";
14
+ import { existsSync, readFileSync, statSync } from "node:fs";
15
+ import { delimiter, isAbsolute, resolve } from "node:path";
16
+ import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai/compat";
16
17
  import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, classifyReviewHostRelayRefusal, resolveReviewHostRelaySubmission, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts";
17
18
  import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../../lib/review-relay-contract.ts";
18
19
  export const DESCRIPTOR_SCHEMA = "gentle-pi.maintainer.provider-relay-descriptor/v1";
@@ -88,13 +89,17 @@ const exactKeys = (obj, allowed, label) => {
88
89
  // the completing form is provably bindable before any process launches.
89
90
  export function validateDescriptor(value) {
90
91
  if (!isObj(value)) fail("descriptor must be a JSON object");
91
- exactKeys(value, new Set(["schema", "gentleAiExecutable", "piExecutable", "cases"]), "descriptor");
92
+ exactKeys(value, new Set(["schema", "gentleAiExecutable", "reviewerSelection", "cases"]), "descriptor");
92
93
  if (value.schema !== DESCRIPTOR_SCHEMA) fail(`descriptor.schema must be exactly "${DESCRIPTOR_SCHEMA}"`);
93
94
  if (!isStr(value.gentleAiExecutable) || !isAbsolute(value.gentleAiExecutable)) fail("descriptor.gentleAiExecutable must be a non-empty absolute path; the runner never re-resolves the production binary");
94
- if (!isStr(value.piExecutable)) fail("descriptor.piExecutable must be a non-empty string");
95
+ // gentle-pi#311 P4: the positive-lens journey used to need a declared real
96
+ // `pi` binary; lens captures now run in-process, so it instead needs a
97
+ // "provider/id" reviewer selection the armed run resolves through a
98
+ // scripted-but-real completion (never a fake pi child).
99
+ if (!isStr(value.reviewerSelection)) fail("descriptor.reviewerSelection must be a non-empty string");
95
100
  if (!Array.isArray(value.cases) || value.cases.length === 0) fail("descriptor.cases must be a non-empty array");
96
101
  const seen = new Set();
97
- return { schema: value.schema, gentleAiExecutable: value.gentleAiExecutable, piExecutable: value.piExecutable, cases: value.cases.map((entry, index) => {
102
+ return { schema: value.schema, gentleAiExecutable: value.gentleAiExecutable, reviewerSelection: value.reviewerSelection, cases: value.cases.map((entry, index) => {
98
103
  if (!isObj(entry)) fail(`descriptor.cases[${index}] must be an object`);
99
104
  if (!isStr(entry.name)) fail(`descriptor.cases[${index}].name must be a non-empty string`);
100
105
  if (seen.has(entry.name)) fail(`descriptor.cases[${index}].name "${entry.name}" is duplicated`);
@@ -205,13 +210,77 @@ export function resolveDeclaredExecutable(executable) {
205
210
  return null;
206
211
  }
207
212
  const missingReason = (executable, label) => `${label} "${executable}" is not an existing executable; arm the descriptor with a real binary, or set GENTLE_PI_REQUIRE_MAINTAINER=1 to fail instead of block.`;
208
- // A guaranteed-nonexistent pi path inside a fresh private directory. mkdtemp
209
- // picks an unpredictable name and 0700 keeps it ours, so nothing can race a
210
- // file into the slot between the mkdtemp and the spawn.
211
- function unlaunchablePi() {
212
- const directory = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-no-pi-"));
213
- chmodSync(directory, 0o700);
214
- return { directory, executable: join(directory, "pi-must-never-launch") };
213
+ // A reviewer registry whose `find` always misses. Mirrors the pre-in-process
214
+ // "unlaunchable pi" trick this replaced: the maintainer-declared `kind` is
215
+ // not evidence that the declared gentle-ai binary is actually incapable, so
216
+ // a `relay-unavailable` negative control against a mis-declared CAPABLE
217
+ // binary must still never complete a reviewer or submit anything. Resolving
218
+ // nothing guarantees a typed MODEL_NOT_FOUND refusal before any completion
219
+ // or submission, so the control cannot become a hidden real mutation even
220
+ // against a binary that turns out to be capable (gentle-pi#311 P4).
221
+ function unresolvableReviewerRegistry() {
222
+ return {
223
+ find: () => undefined,
224
+ getApiKeyAndHeaders: async () => ({ ok: false, error: "the relay-unavailable negative control never resolves a reviewer" }),
225
+ };
226
+ }
227
+ // The armed, organic positive-lens journey needs the real gentle-ai binary's
228
+ // materialize/submit protocol exercised end-to-end, but this harness cannot
229
+ // assume a maintainer's real provider credentials. Instead of a fake pi
230
+ // child, this registers pi-ai's own faux provider -- the SAME api-registry
231
+ // the real `completeSimple` dispatches through in production -- so the real
232
+ // `runInProcessReviewer` -> real `completeSimple` path runs with no network.
233
+ // The scripted response reads the frozen prompt's `GENTLE_AI_REVIEW_BINDING`
234
+ // line for the subject_hash and its `GENTLE_AI_REVIEW_CONTEXT` line for the
235
+ // changed-path manifest, and answers with every field the reviewer result
236
+ // contract requires -- subject_hash, a completed inspection over exactly the
237
+ // frozen paths, findings, evidence -- so the real Go admission accepts it
238
+ // (gentle-pi#311 P4). Not unregistered: each
239
+ // call uses a fresh random api id, and this runs at most once per short-lived
240
+ // process (the CLI's single `main()` or one test), so a leaked entry in the
241
+ // process-global api-registry is harmless.
242
+ // Reads the one-line JSON that follows a frozen prompt marker (the binding
243
+ // and context lines Go writes at the top of every reviewer task). Returns
244
+ // undefined when the marker is absent or its JSON is malformed, so the faux
245
+ // reviewer answers with an incomplete result that real admission refuses
246
+ // instead of throwing inside the provider and masking the contract drift.
247
+ function frozenPromptMarkerJson(promptText, marker) {
248
+ const prefix = `${marker} `;
249
+ for (const line of promptText.split("\n")) {
250
+ if (!line.startsWith(prefix)) continue;
251
+ try { return JSON.parse(line.slice(prefix.length)); } catch { return undefined; }
252
+ }
253
+ return undefined;
254
+ }
255
+ function armedFauxReviewerRegistry(reviewerSelection) {
256
+ const separatorIndex = reviewerSelection.indexOf("/");
257
+ if (separatorIndex <= 0 || separatorIndex === reviewerSelection.length - 1) {
258
+ throw new DescriptorValidationError(`descriptor.reviewerSelection "${reviewerSelection}" must have the "provider/id" shape`);
259
+ }
260
+ const provider = reviewerSelection.slice(0, separatorIndex);
261
+ const modelId = reviewerSelection.slice(separatorIndex + 1);
262
+ const api = `gentle-pi-maintainer-faux-reviewer-${randomUUID()}`;
263
+ const faux = registerFauxProvider({ api, provider, models: [{ id: modelId }] });
264
+ faux.setResponses([(context) => {
265
+ const content = context.messages[0]?.content;
266
+ const textPart = Array.isArray(content) ? content.find((part) => part.type === "text") : undefined;
267
+ const promptText = textPart?.text ?? "";
268
+ const binding = frozenPromptMarkerJson(promptText, "GENTLE_AI_REVIEW_BINDING");
269
+ const frozenContext = frozenPromptMarkerJson(promptText, "GENTLE_AI_REVIEW_CONTEXT");
270
+ const manifest = Array.isArray(frozenContext?.changed_path_manifest) ? frozenContext.changed_path_manifest : [];
271
+ const paths = manifest.map((entry) => entry?.path).filter((path) => typeof path === "string" && path.length > 0);
272
+ return fauxAssistantMessage(JSON.stringify({
273
+ subject_hash: binding?.subject_hash,
274
+ inspection: { status: "completed", paths },
275
+ findings: [],
276
+ evidence: ["armed maintainer faux reviewer inspected every frozen candidate path"],
277
+ }));
278
+ }]);
279
+ const model = faux.getModel();
280
+ return {
281
+ find: () => model,
282
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "faux-key" }),
283
+ };
215
284
  }
216
285
  function terminateRoleProcessTree(child) {
217
286
  if (child.pid === undefined) return false;
@@ -317,39 +386,41 @@ export async function runMatrix(descriptor, options = {}) {
317
386
  }
318
387
  continue;
319
388
  }
320
- // The positive lens needs a real pi AND an explicit arm; the negative
321
- // control never launches pi (fails closed at materialize). The precheck
322
- // resolves the declared Pi executable ONCE and reuses that exact
323
- // concrete path for launch; a bare declaration is never re-resolved
324
- // between precheck and spawn, so the relay launches exactly the
325
- // executable the harness checked (issue #324).
326
- let piPath = null;
327
- if (caseEntry.kind === "positive-lens") {
328
- piPath = resolveDeclaredExecutable(descriptor.piExecutable);
329
- if (piPath === null) {
330
- verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: missingReason(descriptor.piExecutable, "piExecutable"), command: POSITIVE_JOURNEY_COMMAND });
331
- continue;
332
- }
333
- if (!positiveArmed) {
334
- verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: `positive-lens case is not explicitly armed; set ${ARM_POSITIVE_ENV}=1 with a real capable gentle-ai binary, a real pi, and a real review session (real binding tokens from \`gentle-ai review status --next-transition\`) to run the organic journey. The runner never synthesizes a provider result.`, command: POSITIVE_JOURNEY_COMMAND });
335
- continue;
336
- }
389
+ // The positive lens needs an explicit arm; the negative control never
390
+ // resolves a reviewer (fails closed at the reviewer stage or earlier).
391
+ if (caseEntry.kind === "positive-lens" && !positiveArmed) {
392
+ verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: `positive-lens case is not explicitly armed; set ${ARM_POSITIVE_ENV}=1 with a real capable gentle-ai binary and a real review session (real binding tokens from \`gentle-ai review status --next-transition\`) to run the organic journey. The runner never synthesizes a provider result.`, command: POSITIVE_JOURNEY_COMMAND });
393
+ continue;
337
394
  }
338
- // `relay-unavailable` is a NEGATIVE CONTROL: it must never launch pi and
339
- // never submit. The maintainer-declared `kind` is not evidence that the
340
- // declared binary is actually incapable, so the arm gate cannot key off
341
- // the declaration alone. Pointed at a mis-declared CAPABLE binary, a
342
- // declaration-only gate materializes, runs a REAL pi model, and executes
343
- // a REAL `capture-result --input=...` submission, and only then reports
344
- // `fail` — the mutation already happened. So give the negative control a
345
- // pi that cannot exist: an incapable binary still classifies
346
- // relay-unavailable at materialize (the control stays genuine, since it
347
- // never reached pi anyway), while a capable one fails closed at the pi
348
- // stage as kind=pi-launch-failed stage=pi mutationOutcome=none, with
349
- // zero pi launch and zero submission.
350
- const negativeControl = caseEntry.kind === "relay-unavailable" ? unlaunchablePi() : null;
351
- const request = { captureArgumentTokens: caseEntry.captureArgumentTokens, submission: caseEntry.submission, gentleAiExecutable: gentleAiPath, piExecutable: negativeControl === null ? piPath : negativeControl.executable, ...(options.signal === undefined ? {} : { signal: options.signal }) };
395
+ // `relay-unavailable` is a NEGATIVE CONTROL: it must never complete a
396
+ // reviewer and never submit. The maintainer-declared `kind` is not
397
+ // evidence that the declared binary is actually incapable, so the arm
398
+ // gate cannot key off the declaration alone. Pointed at a mis-declared
399
+ // CAPABLE binary, a declaration-only gate materializes, runs a REAL
400
+ // reviewer completion, and executes a REAL `capture-result --input=...`
401
+ // submission, and only then reports `fail` — the mutation already
402
+ // happened. So give the negative control a reviewer registry that can
403
+ // never resolve: an incapable binary still classifies relay-unavailable
404
+ // at materialize (the control stays genuine, since it never reached the
405
+ // reviewer stage anyway), while a capable one fails closed at the
406
+ // reviewer stage as kind=reviewer-model-not-found stage=pi
407
+ // mutationOutcome=none, with zero completion and zero submission
408
+ // (gentle-pi#311 P4, superseding the pre-in-process "unlaunchable pi").
352
409
  try {
410
+ // A malformed `reviewerSelection` (caught here rather than at
411
+ // descriptor-validation time, since it is only ever exercised by an
412
+ // armed positive-lens case) must fail this case the same way a real
413
+ // relay error would, never crash the whole matrix.
414
+ const reviewerRegistry = caseEntry.kind === "relay-unavailable" ? unresolvableReviewerRegistry() : armedFauxReviewerRegistry(descriptor.reviewerSelection);
415
+ const request = {
416
+ captureArgumentTokens: caseEntry.captureArgumentTokens,
417
+ submission: caseEntry.submission,
418
+ gentleAiExecutable: gentleAiPath,
419
+ reviewerRegistry,
420
+ selection: descriptor.reviewerSelection,
421
+ routingKey: "review-relay-matrix",
422
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
423
+ };
353
424
  const result = await relay(request);
354
425
  if (caseEntry.kind === "relay-unavailable") {
355
426
  verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: "expected relay-unavailable (runtime without --materialize) but the relay succeeded; the descriptor's binary is unexpectedly capable" });
@@ -357,7 +428,9 @@ export async function runMatrix(descriptor, options = {}) {
357
428
  verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submissionByteLength: result.submission.length });
358
429
  }
359
430
  } catch (error) {
360
- if (error instanceof ReviewHostRelayError) {
431
+ if (error instanceof DescriptorValidationError) {
432
+ verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: error.message });
433
+ } else if (error instanceof ReviewHostRelayError) {
361
434
  if (caseEntry.kind === "relay-unavailable") {
362
435
  if (error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE && error.stage === "materialize" && error.mutationOutcome === "none") {
363
436
  verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", stage: error.stage, mutationOutcome: error.mutationOutcome, reason: REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE });
@@ -372,8 +445,6 @@ export async function runMatrix(descriptor, options = {}) {
372
445
  } else {
373
446
  verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `unexpected non-relay error: ${error instanceof Error ? error.message : String(error)}` });
374
447
  }
375
- } finally {
376
- if (negativeControl !== null) rmSync(negativeControl.directory, { recursive: true, force: true });
377
448
  }
378
449
  }
379
450
  return verdicts;