gentle-pi 2.3.0 → 2.5.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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -6,9 +6,9 @@ import { join } from "node:path";
6
6
  import test from "node:test";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
9
- import type { NativeReviewCli } from "../lib/native-review-cli.ts";
9
+ import { NativeReviewIntegrationError, type NativeReviewCli } from "../lib/native-review-cli.ts";
10
10
  import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
11
- import type { AuthorityRepairAssessmentV1, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
11
+ import { decodeReviewFailureV2, type AuthorityRepairAssessmentV1, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
12
12
 
13
13
  interface RegisteredTool {
14
14
  execute: (
@@ -240,6 +240,49 @@ test("INSPECT and STATUS operate on the explicit workspace root while the sessio
240
240
  assert.deepEqual(observedCwds, [realpathSync(worktree), realpathSync(worktree), sessionCwd]);
241
241
  });
242
242
 
243
+ // gentle-pi#599: inspect without an explicit workspaceRoot against a parent
244
+ // repository that wraps an untracked nested Git repository fails at the
245
+ // native preflight with a decoded, actionable `invalid_request` envelope. The
246
+ // facade used to discard that envelope's cause, code, retry_safe, and
247
+ // next_action in favor of a generic `native-status-unavailable` outcome
248
+ // indistinguishable from authority corruption. It must preserve the native
249
+ // envelope instead.
250
+ test("inspect without workspaceRoot preserves the native invalid_request cause for a nested foreign Git repository", async (t) => {
251
+ const sessionCwd = repository(t);
252
+ const { controller } = runtime(fakeNative({
253
+ targetStatus: async () => {
254
+ throw new NativeReviewIntegrationError(decodeReviewFailureV2({
255
+ schema: "gentle-ai.review-integration.failure/v2",
256
+ contract: "gentle-ai.review-integration/v2",
257
+ operation: "review.status",
258
+ phase: "preflight",
259
+ code: "invalid_request",
260
+ message: "The negotiated review request is invalid.",
261
+ mutation_outcome: "not_started",
262
+ authority_applicability: "not_evaluated",
263
+ retry_safe: true,
264
+ replayability: "not_replayable",
265
+ required_inputs: [],
266
+ next_action: "correct_request",
267
+ cause: "untracked directory \"engram\" holds another Git repository that is not a linked worktree of this one, so it cannot enter the review candidate: add it to .gitignore, move it outside this repository, or register it as a linked worktree",
268
+ }));
269
+ },
270
+ }));
271
+ const result = await controller.execute("inspect-nested-foreign-repo", { operation: "inspect" }, undefined, undefined, context(sessionCwd));
272
+ const details = result.details as {
273
+ status?: string;
274
+ outcome?: string;
275
+ next_action?: string;
276
+ native_failure?: { code?: string; cause?: string; retry_safe?: boolean };
277
+ };
278
+ assert.equal(details.status, "blocked");
279
+ assert.equal(details.outcome, "native-status-unavailable");
280
+ assert.equal(details.native_failure?.code, "invalid_request");
281
+ assert.equal(details.native_failure?.retry_safe, true);
282
+ assert.match(details.native_failure?.cause ?? "", /engram/);
283
+ assert.equal(details.next_action, "correct_request");
284
+ });
285
+
243
286
  test("START freezes the candidate from the explicit workspace root and returns the actor binding envelope", async (t) => {
244
287
  const sessionCwd = repository(t);
245
288
  writeFileSync(join(sessionCwd, "unrelated.ts"), "export const unrelated = true;\n");
@@ -363,6 +363,7 @@ test("general STATUS returns the typed native-status-unsupported boundary withou
363
363
  mutation_performed: false,
364
364
  inventory_complete: false,
365
365
  next_action: "require-upstream-read-only-native-status-inventory",
366
+ remediation_command: "gentle-ai review status --cwd <repo> --contract gentle-ai.review-integration/v2 --agent pi --next-transition",
366
367
  evidence: {
367
368
  native_contract: "gentle-ai/2.1.4",
368
369
  general_status: "unsupported",
@@ -371,6 +372,30 @@ test("general STATUS returns the typed native-status-unsupported boundary withou
371
372
  });
372
373
  });
373
374
 
375
+ test("gentle-pi#185: general STATUS on a non-negotiated native CLI names the exact status command to run", async (t) => {
376
+ // The legacy `correctionForecast` restoration guard this issue originally
377
+ // reported (extensions/gentle-ai.ts, then around line 5329) was scoped to
378
+ // `targetStatus !== undefined` and skipped restoring a candidate view when
379
+ // the native CLI lacked negotiated STATUS support, reproducing the #176
380
+ // empty-registry failure. That entire manual FINALIZE lifecycle (and the
381
+ // `correctionForecast` guard with it) has since been replaced by the
382
+ // STATUS/capture-driven flow: every operation now checks negotiated STATUS
383
+ // support up front, so no candidate-view restoration is ever attempted on
384
+ // a non-negotiated CLI. This test proves the current equivalent boundary
385
+ // (`nativeStatusUnsupported`) fails closed with an actionable envelope
386
+ // instead of a bare machine token, so a non-negotiated CLI can never
387
+ // surface as a silent empty-registry failure.
388
+ const fixture = createRepository(t);
389
+ const { controller } = registerRuntime();
390
+ const result = await controllerCall(controller, extensionContext(fixture.repository), { operation: "status" });
391
+ assert.equal(result.status, "blocked");
392
+ assert.equal(result.outcome, "native-status-unsupported");
393
+ assert.equal(
394
+ result.remediation_command,
395
+ "gentle-ai review status --cwd <repo> --contract gentle-ai.review-integration/v2 --agent pi --next-transition",
396
+ );
397
+ });
398
+
374
399
  test("failed START gives exact mode and serialization guidance and creates no lineage", async (t) => {
375
400
  const fixture = createRepository(t);
376
401
  const { controller } = registerRuntime();
@@ -421,7 +446,7 @@ test("shipped controller fails closed while static prompts defer RDD lifecycle o
421
446
  assert.match(toolContract, /ambiguous START output.*target-scoped native status.*declared action.*ambiguous gentle_review_capture.*never replays/is);
422
447
  assert.doesNotMatch(toolContract, /START throws.*lineage does not exist/is);
423
448
 
424
- const boundary = "Gentle AI dynamically supplies runtime-specific RDD instructions via generated Pi APPEND_SYSTEM composition. Follow only those exact native instructions; if absent or unsupported, this package does not invent or fall back.";
449
+ const boundary = "This package injects the mirrored provider-bundle review execution contract into this session's system prompt at start; Gentle AI writes nothing into the Pi system prompt, and this package owns everything else here. Absent that mirrored contract, this package invents no lifecycle instructions.";
425
450
  const core = readFileSync("assets/orchestrator.md", "utf8");
426
451
  assert.match(core, new RegExp(boundary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
427
452
 
@@ -1,19 +1,20 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
7
  import { __testing } from "../extensions/gentle-ai.ts";
8
8
  import type { NativeReviewCli } from "../lib/native-review-cli.ts";
9
9
  import { REVIEW_HOST_RELAY_FAILURE, REVIEW_HOST_RELAY_PI_TIMEOUT_ENV, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, type ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
10
- import type { ReviewCaptureSubmissionV1, ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
10
+ import { decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
11
11
 
12
12
  // One-slot capture routing: the host relay runs only when the selected
13
13
  // provider-returned collect input carries the --materialize token. Every
14
14
  // other capture form stays untouched.
15
15
 
16
16
  const SHA = `sha256:${"1".repeat(64)}`;
17
+ const PHASE_REVISION = `sha256:${"3".repeat(64)}`;
17
18
  const TREE = "2".repeat(40);
18
19
 
19
20
  function repository(t: test.TestContext): string {
@@ -26,10 +27,10 @@ function repository(t: test.TestContext): string {
26
27
  return cwd;
27
28
  }
28
29
 
29
- function bindingArguments(lineageId: string, lens: string, order: number): ReviewCollectInputV3["arguments"] {
30
+ function bindingArguments(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, revision = SHA): ReviewCollectInputV3["arguments"] {
30
31
  return [
31
32
  { name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
32
- { name: "expected-revision", value: SHA, token: `--expected-revision=${SHA}` },
33
+ { name: "expected-revision", value: revision, token: `--expected-revision=${revision}` },
33
34
  { name: "target", value: SHA, token: `--target=${SHA}` },
34
35
  { name: "repository-context", value: `rctx1_${"e".repeat(64)}`, token: `--repository-context=rctx1_${"e".repeat(64)}` },
35
36
  { name: "lens", value: lens, token: `--lens=${lens}` },
@@ -38,8 +39,8 @@ function bindingArguments(lineageId: string, lens: string, order: number): Revie
38
39
  ];
39
40
  }
40
41
 
41
- function providerSubmission(lineageId: string, lens: string, order: number): ReviewCaptureSubmissionV1 {
42
- const bindingTokens = bindingArguments(lineageId, lens, order).map((argument) => argument.token!);
42
+ function providerSubmission(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, revision = SHA): ReviewCaptureSubmissionV1 {
43
+ const bindingTokens = bindingArguments(lineageId, lens, order, revision).map((argument) => argument.token!);
43
44
  return {
44
45
  operationToken: "capture-result",
45
46
  argumentTokens: [...bindingTokens, "--input={{value}}"],
@@ -47,22 +48,71 @@ function providerSubmission(lineageId: string, lens: string, order: number): Rev
47
48
  };
48
49
  }
49
50
 
50
- function relayCollectInput(lineageId: string, lens: string, order: number, materialize = true, submission: ReviewCaptureSubmissionV1 | "provider" | "absent" = "provider"): ReviewCollectInputV3 {
51
+ function relayCollectInput(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, materialize = true, submission: ReviewCaptureSubmissionV1 | "provider" | "absent" = "provider", revision = SHA): ReviewCollectInputV3 {
51
52
  return {
52
53
  name: "reviewer_result",
53
54
  schema: "https://gentle-ai.dev/schema/review/reviewer/v1",
54
55
  captureOperation: "review.capture-result",
55
56
  arguments: [
56
- ...bindingArguments(lineageId, lens, order),
57
+ ...bindingArguments(lineageId, lens, order, revision),
57
58
  ...(materialize ? [
58
59
  { name: "agent", value: "pi", token: "--agent=pi" },
59
60
  { name: "materialize", value: "true", token: "--materialize=true" },
60
61
  ] : []),
61
62
  ],
62
- ...(materialize && submission !== "absent" ? { submission: submission === "provider" ? providerSubmission(lineageId, lens, order) : submission } : {}),
63
+ artifactSubject: {
64
+ schema: "gentle-ai.review-artifact-subject/v2", subjectHash: `sha256:${String(order).repeat(64)}`,
65
+ lineageId, authorityRevision: revision, targetIdentity: SHA, baseTree: TREE, candidateTree: TREE,
66
+ changedPathManifestSha256: SHA, lens, selectedOrder: order,
67
+ },
68
+ baseTree: TREE, candidateTree: TREE, changedPathManifest: [],
69
+ ...(materialize && submission !== "absent" ? { submission: submission === "provider" ? providerSubmission(lineageId, lens, order, revision) : submission } : {}),
63
70
  };
64
71
  }
65
72
 
73
+ function capturedGroupedStatus(lineageId: string, authorityRevision = SHA): ReviewStatusV3 {
74
+ const raw = JSON.parse(readFileSync(new URL("./fixtures/devbinary/status-v5-capture-result-submission.captured.json", import.meta.url), "utf8")) as Record<string, unknown>;
75
+ // The captured binary's forward-only `finalize` action is not accepted by
76
+ // this checked-out decoder; retain the v5 capture and normalize only that
77
+ // unrelated routing action before decoding its provider-owned group.
78
+ raw.action = "stop";
79
+ const authority = raw.authority as Record<string, unknown>, repositoryContext = raw.repository_context as Record<string, unknown>;
80
+ authority.lineage_id = lineageId;
81
+ authority.revision = authorityRevision;
82
+ const phaseRevision = String(repositoryContext.revision), targetIdentity = String(raw.target_identity);
83
+ const source = ((((raw.next_transition as Record<string, unknown>).collect as Record<string, unknown>).inputs as Array<Record<string, unknown>>)[0]!);
84
+ const lenses = ["review-risk", "review-resilience", "review-readability", "review-reliability"];
85
+ ((raw.next_transition as Record<string, unknown>).collect as Record<string, unknown>).inputs = lenses.map((lens, order) => {
86
+ const input = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
87
+ const subjectHash = `sha256:${String(order + 1).repeat(64)}`;
88
+ const arguments_ = input.arguments as Array<Record<string, unknown>>;
89
+ for (const argument of arguments_) {
90
+ const value = argument.name === "lineage" ? lineageId
91
+ : argument.name === "lens" ? lens
92
+ : argument.name === "order" ? String(order)
93
+ : argument.name === "subject-hash" ? subjectHash
94
+ : argument.value;
95
+ argument.value = value;
96
+ argument.token = `--${String(argument.name)}=${String(value)}`;
97
+ }
98
+ const submission = input.submission as Record<string, unknown>;
99
+ submission.argument_tokens = [...arguments_.filter((argument) => argument.name !== "agent" && argument.name !== "materialize").map((argument) => argument.token), "--input={{value}}"];
100
+ const artifactSubject = input.artifact_subject as Record<string, unknown>;
101
+ artifactSubject.subject_hash = subjectHash;
102
+ artifactSubject.lineage_id = lineageId;
103
+ artifactSubject.authority_revision = phaseRevision;
104
+ artifactSubject.target_identity = targetIdentity;
105
+ artifactSubject.lens = lens;
106
+ artifactSubject.selected_order = order;
107
+ return input;
108
+ });
109
+ return decodeReviewStatusV3(raw);
110
+ }
111
+
112
+ function replaceArgument(input: ReviewCollectInputV3, name: string, value: string): void {
113
+ input.arguments = input.arguments.map((argument) => argument.name === name ? { ...argument, value, token: `--${name}=${value}` } : argument);
114
+ }
115
+
66
116
  function finalizeStatus(lineageId: string, inputs?: readonly ReviewCollectInputV3[]): ReviewStatusV3 {
67
117
  return {
68
118
  contract: "gentle-ai.review-integration/v2",
@@ -86,14 +136,28 @@ function finalizeStatus(lineageId: string, inputs?: readonly ReviewCollectInputV
86
136
  currentSnapshotIdentity: SHA,
87
137
  },
88
138
  candidates: [],
139
+ repositoryContext: { capability: "review.opaque_repository_context", handle: `rctx1_${"e".repeat(64)}`, revision: SHA, targetIdentity: SHA },
89
140
  ...(inputs === undefined ? {} : { nextTransition: { kind: "collect", reasonCode: "reviewer_results_required", collect: { inputs: [...inputs] } } }),
90
141
  raw: { schema: "gentle-ai.review-integration.status/v3", action: "stop", lineage_id: lineageId },
91
142
  } as unknown as ReviewStatusV3;
92
143
  }
93
144
 
145
+ function providerRefuterRequiredStatus(lineageId: string): ReviewStatusV3 {
146
+ const refuter: ReviewCollectInputV3 = {
147
+ name: "provider_refuter",
148
+ schema: "https://gentle-ai.dev/schema/review/refuter/v1",
149
+ captureOperation: "review.capture-refuter",
150
+ arguments: bindingArguments(lineageId, "review-risk", 0),
151
+ };
152
+ return {
153
+ ...finalizeStatus(lineageId),
154
+ nextTransition: { kind: "collect", reasonCode: "provider_refuter_required", collect: { inputs: [refuter] } },
155
+ };
156
+ }
157
+
94
158
  interface RoutingHarness {
95
159
  statusQueue: ReviewStatusV3[];
96
- statusCalls: Array<{ cwd: string; lineageId?: string }>;
160
+ statusCalls: Array<{ cwd: string; lineageId?: string; agent?: "pi" }>;
97
161
  native: NativeReviewCli;
98
162
  }
99
163
 
@@ -105,7 +169,7 @@ function nativeHarness(statuses: readonly ReviewStatusV3[]): RoutingHarness {
105
169
  };
106
170
  harness.native = {
107
171
  targetStatus: async (request) => {
108
- harness.statusCalls.push({ cwd: request.cwd, ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }) });
172
+ harness.statusCalls.push({ cwd: request.cwd, ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }), ...(request.agent === undefined ? {} : { agent: request.agent }) });
109
173
  const next = harness.statusQueue.shift();
110
174
  if (next === undefined) throw new Error("status queue exhausted");
111
175
  return next;
@@ -166,6 +230,159 @@ test("Pi-authored review documents are rejected at the capture input boundary",
166
230
  assert.equal(relayCalls, 0);
167
231
  });
168
232
 
233
+ function groupInputs(lineageId: string, revision = SHA): ReviewCollectInputV3[] { return ["review-risk", "review-resilience", "review-readability", "review-reliability"].map((lens, order) => relayCollectInput(lineageId, lens, order, true, "provider", revision)); }
234
+
235
+ async function runCaptureGroup(cwd: string, harness: RoutingHarness, lineageId: string, inputs: readonly ReviewCollectInputV3[], reviewerRunAcknowledged = true): Promise<Record<string, unknown>> { return await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged }, cwd, harness.native) as Record<string, unknown>; }
236
+
237
+ function prepared(request: ReviewHostRelayRequest) { return { request, promptByteLength: 64, resultByteLength: 32 }; }
238
+
239
+ test("grouped capture forecasts once, reaches a four-reviewer barrier, and reconciles unclosed submissions", async (t) => {
240
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
241
+ const cwd = repository(t), lineageId = "relay-group", inputs = groupInputs(lineageId), lenses = inputs.map((input) => input.arguments.find((argument) => argument.name === "lens")!.value);
242
+ const harness = nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), ...inputs.map((_input, index) => finalizeStatus(lineageId, index === 1 ? [...inputs.slice(index), { name: "unrelated", schema: "example", captureOperation: "external.example", arguments: [] }] : inputs.slice(index))), providerRefuterRequiredStatus(lineageId)]);
243
+ let ready!: () => void;
244
+ const allStarted = new Promise<void>((resolve) => { ready = resolve; });
245
+ const releases = new Map<string, () => void>(), completed: string[] = [], submitted: string[] = [];
246
+ const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => await Promise.all(requests.map(async (request) => {
247
+ const lens = request.captureArgumentTokens.find((token) => token.startsWith("--lens="))!.slice(7);
248
+ await new Promise<void>((resolve) => { releases.set(lens, resolve); if (releases.size === requests.length) ready(); });
249
+ completed.push(lens); return prepared(request);
250
+ }));
251
+ __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => {
252
+ submitted.push(result.request.captureArgumentTokens.find((token) => token.startsWith("--lens="))!.slice(7));
253
+ return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" };
254
+ });
255
+ const forecast = await runCaptureGroup(cwd, harness, lineageId, inputs, false);
256
+ assert.deepEqual(forecast.cost_forecast, { transport: "pi_host_relay", model_runs: 4, lenses });
257
+ const run = runCaptureGroup(cwd, harness, lineageId, inputs);
258
+ await allStarted;
259
+ assert.deepEqual([...releases.keys()], lenses, "all reviewers start before the group waits");
260
+ for (const lens of [...lenses].reverse()) releases.get(lens!)!();
261
+ const result = await run;
262
+ assert.deepEqual(completed, [...lenses].reverse(), "reviewer completion order is not submission order");
263
+ assert.deepEqual(submitted, lenses);
264
+ assert.deepEqual({ outcome: result.outcome, statusCalls: harness.statusCalls.length, providerAction: result.provider_action }, { outcome: "native-reviewer-group-status-reconciled", statusCalls: 7, providerAction: "stop" });
265
+ assert.equal(harness.statusCalls.at(-1)?.agent, "pi", "post-last-capture reconciliation preserves the Pi host runtime");
266
+ assert.equal((result.next_transition as { kind?: string; reasonCode?: string } | undefined)?.kind, "collect");
267
+ assert.equal((result.next_transition as { kind?: string; reasonCode?: string } | undefined)?.reasonCode, "provider_refuter_required");
268
+ });
269
+
270
+ test("captured v5 STATUS accepts capture-phase Pn when authority has advanced to Rn at the forecast boundary", async (t) => {
271
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
272
+ const cwd = repository(t), lineageId = "relay-group-phase-revision", status = capturedGroupedStatus(lineageId, SHA), inputs = status.nextTransition!.collect!.inputs!;
273
+ let launches = 0;
274
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
275
+
276
+ assert.notEqual(status.authority!.revision, status.repositoryContext!.revision, "captured v5 group keeps capture-phase Pn distinct from authority Rn");
277
+ const forecast = await runCaptureGroup(cwd, nativeHarness([status]), lineageId, inputs, false);
278
+
279
+ assert.equal(forecast.outcome, "reviewer-model-run-forecast");
280
+ assert.deepEqual(forecast.cost_forecast, { transport: "pi_host_relay", model_runs: 4, lenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"] });
281
+ assert.equal(launches, 0, "decoder-valid capture-phase Pn must pass admission before any reviewer model launch");
282
+ });
283
+
284
+ test("group rejects STATUS repository target mismatch and per-slot revision, context, or target drift before launch", async (t) => {
285
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
286
+ const cwd = repository(t), lineageId = "relay-group-slot-consistency";
287
+ const repositoryTargetMismatch = capturedGroupedStatus(lineageId, SHA);
288
+ repositoryTargetMismatch.repositoryContext!.targetIdentity = SHA;
289
+ const revisionMismatch = capturedGroupedStatus(lineageId, SHA);
290
+ replaceArgument(revisionMismatch.nextTransition!.collect!.inputs![1]!, "expected-revision", SHA);
291
+ const contextMismatch = capturedGroupedStatus(lineageId, SHA);
292
+ replaceArgument(contextMismatch.nextTransition!.collect!.inputs![1]!, "repository-context", `rctx1_${"f".repeat(64)}`);
293
+ const targetMismatch = capturedGroupedStatus(lineageId, SHA);
294
+ replaceArgument(targetMismatch.nextTransition!.collect!.inputs![1]!, "target", SHA);
295
+ let launches = 0;
296
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
297
+
298
+ // Each call forwards this STATUS's own current set, so rejection proves an
299
+ // internal STATUS guard rather than stale or caller/canonical mismatch.
300
+ const repositoryTargetResult = await runCaptureGroup(cwd, nativeHarness([repositoryTargetMismatch]), lineageId, repositoryTargetMismatch.nextTransition!.collect!.inputs!);
301
+ assert.deepEqual({ outcome: repositoryTargetResult.outcome, reason: repositoryTargetResult.reason }, {
302
+ outcome: "capture-group-rejected",
303
+ reason: "current STATUS repository context does not match the reviewer group binding",
304
+ });
305
+ const revisionResult = await runCaptureGroup(cwd, nativeHarness([revisionMismatch]), lineageId, revisionMismatch.nextTransition!.collect!.inputs!);
306
+ assert.deepEqual({ outcome: revisionResult.outcome, reason: revisionResult.reason }, {
307
+ outcome: "capture-group-rejected",
308
+ reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
309
+ });
310
+ const contextResult = await runCaptureGroup(cwd, nativeHarness([contextMismatch]), lineageId, contextMismatch.nextTransition!.collect!.inputs!);
311
+ assert.deepEqual({ outcome: contextResult.outcome, reason: contextResult.reason }, {
312
+ outcome: "capture-group-rejected",
313
+ reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
314
+ });
315
+ const targetResult = await runCaptureGroup(cwd, nativeHarness([targetMismatch]), lineageId, targetMismatch.nextTransition!.collect!.inputs!);
316
+ assert.deepEqual({ outcome: targetResult.outcome, reason: targetResult.reason }, {
317
+ outcome: "capture-group-rejected",
318
+ reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
319
+ });
320
+ assert.equal(launches, 0, "current STATUS consistency checks reject before reviewer model launch");
321
+ });
322
+
323
+ test("group rejects absent or mismatched current repository context before launch", async (t) => {
324
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
325
+ const cwd = repository(t), lineageId = "relay-group-context-reject", inputs = groupInputs(lineageId, PHASE_REVISION);
326
+ const revisionMismatch = finalizeStatus(lineageId, inputs);
327
+ const handleMismatch = finalizeStatus(lineageId, inputs);
328
+ const absent = finalizeStatus(lineageId, inputs);
329
+ handleMismatch.repositoryContext = { capability: "review.opaque_repository_context", handle: `rctx1_${"f".repeat(64)}`, revision: PHASE_REVISION, targetIdentity: SHA };
330
+ delete absent.repositoryContext;
331
+ let launches = 0;
332
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
333
+
334
+ for (const status of [revisionMismatch, handleMismatch, absent]) {
335
+ const result = await runCaptureGroup(cwd, nativeHarness([status]), lineageId, inputs);
336
+ assert.equal(result.outcome, "capture-group-rejected");
337
+ }
338
+ assert.equal(launches, 0, "repository-context validation must reject before reviewer model launch");
339
+ });
340
+
341
+ test("group rejects partial, duplicate, reordered, stale, mixed, and late-invalid bindings before launch", async (t) => {
342
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
343
+ const cwd = repository(t), lineageId = "relay-group-reject", inputs = groupInputs(lineageId);
344
+ const stale = JSON.parse(JSON.stringify(inputs[3]!)) as ReviewCollectInputV3;
345
+ stale.arguments = [...stale.arguments, { name: "replacement", value: "stale", token: "--replacement=stale" }];
346
+ const mixed = [...inputs.slice(0, 3), relayCollectInput(lineageId, "review-reliability", 3, false)];
347
+ const malformed = JSON.parse(JSON.stringify(inputs)) as ReviewCollectInputV3[];
348
+ malformed[3]!.submission!.argumentTokens = ["--input={{value}}"];
349
+ const cases = [
350
+ { bindings: inputs.slice(0, 3), current: inputs }, { bindings: [...inputs].reverse(), current: inputs },
351
+ { bindings: [...inputs.slice(0, 3), inputs[2]!], current: inputs }, { bindings: [...inputs.slice(0, 3), stale], current: inputs },
352
+ { bindings: mixed, current: mixed }, { bindings: malformed, current: malformed },
353
+ ];
354
+ let launches = 0;
355
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
356
+ for (const entry of cases) {
357
+ const result = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, entry.current)]), lineageId, entry.bindings);
358
+ assert.equal(result.outcome, "capture-group-rejected");
359
+ }
360
+ assert.equal(launches, 0);
361
+ });
362
+
363
+ test("group preserves earlier admission when a later STATUS drifts, and stops on closure or unknown outcome", async (t) => {
364
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
365
+ const cwd = repository(t), lineageId = "relay-group-stop", inputs = groupInputs(lineageId);
366
+ const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => requests.map(prepared);
367
+ let submissions = 0;
368
+ __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => { submissions += 1; return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" }; });
369
+ const staleStatus = finalizeStatus(lineageId, inputs), stale = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), staleStatus]), lineageId, inputs);
370
+ assert.deepEqual({ outcome: stale.outcome, submissions, mutation: stale.mutation_performed, mutationOutcome: stale.mutation_outcome }, { outcome: "capture-group-authority-drift", submissions: 1, mutation: true, mutationOutcome: "partial" });
371
+ assert.equal(stale.reconciliation, staleStatus.raw);
372
+ submissions = 0;
373
+ const drift = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs.slice(2))]), lineageId, inputs);
374
+ assert.deepEqual({ outcome: drift.outcome, submissions, mutation: drift.mutation_performed, mutationOutcome: drift.mutation_outcome }, { outcome: "capture-group-authority-drift", submissions: 1, mutation: true, mutationOutcome: "partial" });
375
+ assert.deepEqual(((drift.host_relay as { reviewers: Array<{ lens: string }> }).reviewers).map((reviewer) => reviewer.lens), ["review-risk"]);
376
+ submissions = 0;
377
+ __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => { submissions += 1; return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: JSON.stringify({ schema: "gentle-ai.review-last-event-closure/v1", operation: "review/capture-result", lineage_id: lineageId, state: "approved", store_revision: SHA, action: "native last event closed the review" }) }; });
378
+ const terminal = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs)]), lineageId, inputs);
379
+ assert.deepEqual({ status: terminal.status, submissions }, { status: "closed", submissions: 1 });
380
+ submissions = 0;
381
+ __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async () => { submissions += 1; throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "unknown", { timedOut: true }); });
382
+ const unknown = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs)]), lineageId, inputs);
383
+ assert.deepEqual({ status: unknown.status, submissions }, { status: "reconciled", submissions: 1 });
384
+ });
385
+
169
386
  test("an old binary reports the relay as unavailable without touching existing behavior", async (t) => {
170
387
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
171
388
  const cwd = repository(t);
@@ -263,6 +480,7 @@ test("a submission whose outcome is genuinely indeterminate still reconciles thr
263
480
  assert.deepEqual(result.failure, { kind: "submission-refused", stage: "submit", exit_code: null, timed_out: true, elapsed_ms: 120_004, timeout_ms: 120_000 });
264
481
  assert.match(String(result.reason), /exceeded its 120000ms bound/);
265
482
  assert.equal(harness.statusCalls.length, 2, "an indeterminate submission reconciles exactly once through STATUS");
483
+ assert.equal(harness.statusCalls.at(-1)?.agent, "pi", "ordinary host-relay reconciliation preserves the Pi host runtime");
266
484
  });
267
485
 
268
486
  test("a relay timeout reports its one-slot measurements and no auto-follow", async (t) => {