gentle-pi 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +30 -4
  2. package/assets/agents/sdd-explore.md +1 -0
  3. package/assets/orchestrator-delegation.md +2 -1
  4. package/assets/orchestrator.md +6 -10
  5. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  6. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  7. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  10. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  12. package/docs/review-integration.md +1 -1
  13. package/extensions/gentle-ai.ts +530 -71
  14. package/lib/agent-home.ts +8 -0
  15. package/lib/native-review-cli.ts +15 -0
  16. package/lib/provider-contract-bundle.ts +88 -6
  17. package/lib/review-host-relay.ts +146 -60
  18. package/lib/review-integration-v2.ts +34 -5
  19. package/lib/sdd-preflight.ts +2 -2
  20. package/package.json +1 -1
  21. package/runtime/native-review-cli.mjs +15 -0
  22. package/runtime/review-integration-v2.mjs +34 -5
  23. package/scripts/gentle-ai-installer.mjs +10 -10
  24. package/scripts/verify-package-files.mjs +14 -13
  25. package/skills/_shared/review-ledger-contract.md +6 -0
  26. package/skills/issue-creation/SKILL.md +53 -93
  27. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  28. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  29. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  30. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  31. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  32. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  33. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  34. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  35. package/tests/gentle-ai-binary.test.ts +1 -1
  36. package/tests/gentle-ai-installer.test.ts +46 -46
  37. package/tests/gentle-ai.test.ts +3 -2
  38. package/tests/issue-creation-skill.test.ts +103 -0
  39. package/tests/native-review-capability-contract.test.ts +18 -1
  40. package/tests/orchestrator-budget.test.ts +2 -2
  41. package/tests/orchestrator-rdd-ownership.test.ts +1 -1
  42. package/tests/package-manifest.test.ts +123 -9
  43. package/tests/provider-contract-bundle.test.ts +76 -0
  44. package/tests/provider-contract-mirror.test.ts +19 -0
  45. package/tests/review-agent-end-preflight.test.ts +408 -0
  46. package/tests/review-contract-prompt.test.ts +139 -0
  47. package/tests/review-controller.test.ts +1 -1
  48. package/tests/review-host-relay-routing.test.ts +211 -9
  49. package/tests/review-host-relay.test.ts +195 -7
  50. package/tests/review-integration-v2-forward.test.ts +47 -0
  51. package/tests/review-ledger-contract.test.ts +1 -1
  52. package/tests/runtime-harness.mjs +1 -0
  53. package/tests/writer-edit-surface-scope.test.ts +153 -17
  54. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  55. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  56. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  57. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  58. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  59. /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
@@ -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,6 +136,7 @@ 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;
@@ -166,6 +217,157 @@ test("Pi-authored review documents are rejected at the capture input boundary",
166
217
  assert.equal(relayCalls, 0);
167
218
  });
168
219
 
220
+ 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)); }
221
+
222
+ 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>; }
223
+
224
+ function prepared(request: ReviewHostRelayRequest) { return { request, promptByteLength: 64, resultByteLength: 32 }; }
225
+
226
+ test("grouped capture forecasts once, reaches a four-reviewer barrier, and reconciles unclosed submissions", async (t) => {
227
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
228
+ const cwd = repository(t), lineageId = "relay-group", inputs = groupInputs(lineageId), lenses = inputs.map((input) => input.arguments.find((argument) => argument.name === "lens")!.value);
229
+ 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))), finalizeStatus(lineageId)]);
230
+ let ready!: () => void;
231
+ const allStarted = new Promise<void>((resolve) => { ready = resolve; });
232
+ const releases = new Map<string, () => void>(), completed: string[] = [], submitted: string[] = [];
233
+ const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => await Promise.all(requests.map(async (request) => {
234
+ const lens = request.captureArgumentTokens.find((token) => token.startsWith("--lens="))!.slice(7);
235
+ await new Promise<void>((resolve) => { releases.set(lens, resolve); if (releases.size === requests.length) ready(); });
236
+ completed.push(lens); return prepared(request);
237
+ }));
238
+ __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => {
239
+ submitted.push(result.request.captureArgumentTokens.find((token) => token.startsWith("--lens="))!.slice(7));
240
+ return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" };
241
+ });
242
+ const forecast = await runCaptureGroup(cwd, harness, lineageId, inputs, false);
243
+ assert.deepEqual(forecast.cost_forecast, { transport: "pi_host_relay", model_runs: 4, lenses });
244
+ const run = runCaptureGroup(cwd, harness, lineageId, inputs);
245
+ await allStarted;
246
+ assert.deepEqual([...releases.keys()], lenses, "all reviewers start before the group waits");
247
+ for (const lens of [...lenses].reverse()) releases.get(lens!)!();
248
+ const result = await run;
249
+ assert.deepEqual(completed, [...lenses].reverse(), "reviewer completion order is not submission order");
250
+ assert.deepEqual(submitted, lenses);
251
+ assert.deepEqual({ outcome: result.outcome, statusCalls: harness.statusCalls.length, providerAction: result.provider_action }, { outcome: "native-reviewer-group-status-reconciled", statusCalls: 7, providerAction: "stop" });
252
+ assert.equal(result.next_transition, undefined);
253
+ });
254
+
255
+ test("captured v5 STATUS accepts capture-phase Pn when authority has advanced to Rn at the forecast boundary", async (t) => {
256
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
257
+ const cwd = repository(t), lineageId = "relay-group-phase-revision", status = capturedGroupedStatus(lineageId, SHA), inputs = status.nextTransition!.collect!.inputs!;
258
+ let launches = 0;
259
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
260
+
261
+ assert.notEqual(status.authority!.revision, status.repositoryContext!.revision, "captured v5 group keeps capture-phase Pn distinct from authority Rn");
262
+ const forecast = await runCaptureGroup(cwd, nativeHarness([status]), lineageId, inputs, false);
263
+
264
+ assert.equal(forecast.outcome, "reviewer-model-run-forecast");
265
+ assert.deepEqual(forecast.cost_forecast, { transport: "pi_host_relay", model_runs: 4, lenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"] });
266
+ assert.equal(launches, 0, "decoder-valid capture-phase Pn must pass admission before any reviewer model launch");
267
+ });
268
+
269
+ test("group rejects STATUS repository target mismatch and per-slot revision, context, or target drift before launch", async (t) => {
270
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
271
+ const cwd = repository(t), lineageId = "relay-group-slot-consistency";
272
+ const repositoryTargetMismatch = capturedGroupedStatus(lineageId, SHA);
273
+ repositoryTargetMismatch.repositoryContext!.targetIdentity = SHA;
274
+ const revisionMismatch = capturedGroupedStatus(lineageId, SHA);
275
+ replaceArgument(revisionMismatch.nextTransition!.collect!.inputs![1]!, "expected-revision", SHA);
276
+ const contextMismatch = capturedGroupedStatus(lineageId, SHA);
277
+ replaceArgument(contextMismatch.nextTransition!.collect!.inputs![1]!, "repository-context", `rctx1_${"f".repeat(64)}`);
278
+ const targetMismatch = capturedGroupedStatus(lineageId, SHA);
279
+ replaceArgument(targetMismatch.nextTransition!.collect!.inputs![1]!, "target", SHA);
280
+ let launches = 0;
281
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
282
+
283
+ // Each call forwards this STATUS's own current set, so rejection proves an
284
+ // internal STATUS guard rather than stale or caller/canonical mismatch.
285
+ const repositoryTargetResult = await runCaptureGroup(cwd, nativeHarness([repositoryTargetMismatch]), lineageId, repositoryTargetMismatch.nextTransition!.collect!.inputs!);
286
+ assert.deepEqual({ outcome: repositoryTargetResult.outcome, reason: repositoryTargetResult.reason }, {
287
+ outcome: "capture-group-rejected",
288
+ reason: "current STATUS repository context does not match the reviewer group binding",
289
+ });
290
+ const revisionResult = await runCaptureGroup(cwd, nativeHarness([revisionMismatch]), lineageId, revisionMismatch.nextTransition!.collect!.inputs!);
291
+ assert.deepEqual({ outcome: revisionResult.outcome, reason: revisionResult.reason }, {
292
+ outcome: "capture-group-rejected",
293
+ reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
294
+ });
295
+ const contextResult = await runCaptureGroup(cwd, nativeHarness([contextMismatch]), lineageId, contextMismatch.nextTransition!.collect!.inputs!);
296
+ assert.deepEqual({ outcome: contextResult.outcome, reason: contextResult.reason }, {
297
+ outcome: "capture-group-rejected",
298
+ reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
299
+ });
300
+ const targetResult = await runCaptureGroup(cwd, nativeHarness([targetMismatch]), lineageId, targetMismatch.nextTransition!.collect!.inputs!);
301
+ assert.deepEqual({ outcome: targetResult.outcome, reason: targetResult.reason }, {
302
+ outcome: "capture-group-rejected",
303
+ reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
304
+ });
305
+ assert.equal(launches, 0, "current STATUS consistency checks reject before reviewer model launch");
306
+ });
307
+
308
+ test("group rejects absent or mismatched current repository context before launch", async (t) => {
309
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
310
+ const cwd = repository(t), lineageId = "relay-group-context-reject", inputs = groupInputs(lineageId, PHASE_REVISION);
311
+ const revisionMismatch = finalizeStatus(lineageId, inputs);
312
+ const handleMismatch = finalizeStatus(lineageId, inputs);
313
+ const absent = finalizeStatus(lineageId, inputs);
314
+ handleMismatch.repositoryContext = { capability: "review.opaque_repository_context", handle: `rctx1_${"f".repeat(64)}`, revision: PHASE_REVISION, targetIdentity: SHA };
315
+ delete absent.repositoryContext;
316
+ let launches = 0;
317
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
318
+
319
+ for (const status of [revisionMismatch, handleMismatch, absent]) {
320
+ const result = await runCaptureGroup(cwd, nativeHarness([status]), lineageId, inputs);
321
+ assert.equal(result.outcome, "capture-group-rejected");
322
+ }
323
+ assert.equal(launches, 0, "repository-context validation must reject before reviewer model launch");
324
+ });
325
+
326
+ test("group rejects partial, duplicate, reordered, stale, mixed, and late-invalid bindings before launch", async (t) => {
327
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
328
+ const cwd = repository(t), lineageId = "relay-group-reject", inputs = groupInputs(lineageId);
329
+ const stale = JSON.parse(JSON.stringify(inputs[3]!)) as ReviewCollectInputV3;
330
+ stale.arguments = [...stale.arguments, { name: "replacement", value: "stale", token: "--replacement=stale" }];
331
+ const mixed = [...inputs.slice(0, 3), relayCollectInput(lineageId, "review-reliability", 3, false)];
332
+ const malformed = JSON.parse(JSON.stringify(inputs)) as ReviewCollectInputV3[];
333
+ malformed[3]!.submission!.argumentTokens = ["--input={{value}}"];
334
+ const cases = [
335
+ { bindings: inputs.slice(0, 3), current: inputs }, { bindings: [...inputs].reverse(), current: inputs },
336
+ { bindings: [...inputs.slice(0, 3), inputs[2]!], current: inputs }, { bindings: [...inputs.slice(0, 3), stale], current: inputs },
337
+ { bindings: mixed, current: mixed }, { bindings: malformed, current: malformed },
338
+ ];
339
+ let launches = 0;
340
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
341
+ for (const entry of cases) {
342
+ const result = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, entry.current)]), lineageId, entry.bindings);
343
+ assert.equal(result.outcome, "capture-group-rejected");
344
+ }
345
+ assert.equal(launches, 0);
346
+ });
347
+
348
+ test("group preserves earlier admission when a later STATUS drifts, and stops on closure or unknown outcome", async (t) => {
349
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
350
+ const cwd = repository(t), lineageId = "relay-group-stop", inputs = groupInputs(lineageId);
351
+ const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => requests.map(prepared);
352
+ let submissions = 0;
353
+ __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => { submissions += 1; return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" }; });
354
+ const staleStatus = finalizeStatus(lineageId, inputs), stale = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), staleStatus]), lineageId, inputs);
355
+ 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" });
356
+ assert.equal(stale.reconciliation, staleStatus.raw);
357
+ submissions = 0;
358
+ const drift = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs.slice(2))]), lineageId, inputs);
359
+ 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" });
360
+ assert.deepEqual(((drift.host_relay as { reviewers: Array<{ lens: string }> }).reviewers).map((reviewer) => reviewer.lens), ["review-risk"]);
361
+ submissions = 0;
362
+ __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" }) }; });
363
+ const terminal = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs)]), lineageId, inputs);
364
+ assert.deepEqual({ status: terminal.status, submissions }, { status: "closed", submissions: 1 });
365
+ submissions = 0;
366
+ __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async () => { submissions += 1; throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "unknown", { timedOut: true }); });
367
+ const unknown = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs)]), lineageId, inputs);
368
+ assert.deepEqual({ status: unknown.status, submissions }, { status: "reconciled", submissions: 1 });
369
+ });
370
+
169
371
  test("an old binary reports the relay as unavailable without touching existing behavior", async (t) => {
170
372
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
171
373
  const cwd = repository(t);
@@ -18,7 +18,12 @@ import {
18
18
  resolveReviewHostRelayPiTimeoutMs,
19
19
  resolveReviewHostRelaySubmission,
20
20
  reviewHostRelaySlots,
21
+ prepareReviewHostRelaySlot,
22
+ runReviewHostRelayReviewerGroup,
21
23
  runReviewHostRelaySlot,
24
+ submitReviewHostRelayPreparedResult,
25
+ type ReviewHostRelayPreparedResult,
26
+ type ReviewHostRelayRequest,
22
27
  } from "../lib/review-host-relay.ts";
23
28
  import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../lib/review-relay-contract.ts";
24
29
  import { decodeReviewNextTransitionV3, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "../lib/review-integration-v2.ts";
@@ -47,6 +52,8 @@ const inputToken = argv.find((token) => token === "--input" || token.startsWith(
47
52
  if (inputToken !== undefined) {
48
53
  const mode = process.env.RELAY_FAKE_SUBMIT_MODE || "ok";
49
54
  const inputPath = inputToken.startsWith("--input=") ? inputToken.slice("--input=".length) : argv[argv.indexOf("--input") + 1];
55
+ const submitDelayMs = Number(process.env.RELAY_FAKE_SUBMIT_DELAY_MS || "0");
56
+ if (Number.isSafeInteger(submitDelayMs) && submitDelayMs > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, submitDelayMs);
50
57
  if (mode === "ok" || mode === "cleanup-fail") {
51
58
  const bytes = fs.readFileSync(inputPath);
52
59
  if (process.env.RELAY_FAKE_SUBMIT_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_SUBMIT_CAPTURE, bytes);
@@ -95,13 +102,32 @@ process.stdin.on("data", (chunk) => chunks.push(chunk));
95
102
  process.stdin.on("end", () => {
96
103
  const stdin = Buffer.concat(chunks);
97
104
  if (process.env.RELAY_FAKE_PI_STDIN_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_PI_STDIN_CAPTURE, stdin);
98
- const mode = process.env.RELAY_FAKE_PI_MODE || "ok";
99
- if (process.env.RELAY_FAKE_PI_BREAK_CLEANUP === "true") fs.chmodSync(path.dirname(process.cwd()), 0o500);
100
- if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PI_OUTPUT_B64 || "", "base64")); process.exit(0); }
101
- if (mode === "empty") process.exit(0);
102
- if (mode === "hang") { setTimeout(() => process.exit(0), 10_000); return; }
103
- process.stderr.write("pi exploded\\n");
104
- process.exit(4);
105
+ const finish = () => {
106
+ const mode = process.env.RELAY_FAKE_PI_MODE || "ok";
107
+ if (process.env.RELAY_FAKE_PI_BREAK_CLEANUP === "true") fs.chmodSync(path.dirname(process.cwd()), 0o500);
108
+ if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PI_OUTPUT_B64 || "", "base64")); process.exit(0); }
109
+ if (mode === "empty") process.exit(0);
110
+ if (mode === "hang") { setTimeout(() => process.exit(0), 10_000); return; }
111
+ process.stderr.write("pi exploded\\n");
112
+ process.exit(4);
113
+ };
114
+ const barrierDirectory = process.env.RELAY_FAKE_PI_BARRIER_DIR;
115
+ if (barrierDirectory === undefined) { finish(); return; }
116
+ fs.mkdirSync(barrierDirectory, { recursive: true });
117
+ fs.writeFileSync(path.join(barrierDirectory, String(process.pid)), "started");
118
+ const expected = Number(process.env.RELAY_FAKE_PI_BARRIER_COUNT || "4");
119
+ const deadline = Date.now() + 1_000;
120
+ const releaseFile = process.env.RELAY_FAKE_PI_RELEASE_FILE;
121
+ const waitForGroup = () => {
122
+ if (fs.readdirSync(barrierDirectory).length < expected) {
123
+ if (Date.now() >= deadline) { process.stderr.write("reviewer barrier timed out\\n"); process.exit(5); return; }
124
+ setTimeout(waitForGroup, 10);
125
+ return;
126
+ }
127
+ if (releaseFile !== undefined && !fs.existsSync(releaseFile)) { setTimeout(waitForGroup, 10); return; }
128
+ finish();
129
+ };
130
+ waitForGroup();
105
131
  });
106
132
  `;
107
133
 
@@ -151,6 +177,14 @@ function readLog(path: string): Array<{ argv: string[]; contract: string | null;
151
177
  return readFileSync(path, "utf8").split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
152
178
  }
153
179
 
180
+ async function waitFor(condition: () => boolean, message: string): Promise<void> {
181
+ for (let attempt = 0; attempt < 100; attempt += 1) {
182
+ if (condition()) return;
183
+ await new Promise<void>((resolve) => setTimeout(resolve, 10));
184
+ }
185
+ throw new Error(message);
186
+ }
187
+
154
188
  const BINDING_TOKENS = Object.freeze([
155
189
  "--lineage=review-1d5aadacc600e167",
156
190
  `--expected-revision=sha256:${"c".repeat(64)}`,
@@ -161,6 +195,7 @@ const BINDING_TOKENS = Object.freeze([
161
195
  `--subject-hash=sha256:${"a".repeat(64)}`,
162
196
  ]);
163
197
  const CAPTURE_TOKENS = Object.freeze([...BINDING_TOKENS, "--agent=pi", "--materialize=true"]);
198
+ const REVIEWER_GROUP_LENSES = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
164
199
 
165
200
  // The provider-owned completing form: exact operation and argument tokens
166
201
  // with one declared {{value}} substitution slot for the artifact path.
@@ -200,6 +235,20 @@ function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown>
200
235
  };
201
236
  }
202
237
 
238
+ function reviewerGroupRequests(fixture: RelayHarness): ReviewHostRelayRequest[] {
239
+ return REVIEWER_GROUP_LENSES.map((lens, order) => relayRequest(fixture, {
240
+ captureArgumentTokens: CAPTURE_TOKENS.map((token) => token === "--lens=review-reliability"
241
+ ? `--lens=${lens}`
242
+ : token === "--order=0" ? `--order=${order}` : token),
243
+ environment: {
244
+ ...fixture.environment,
245
+ RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
246
+ RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
247
+ },
248
+ }));
249
+ }
250
+
251
+
203
252
  async function rejectsWithRelayError(promise: Promise<unknown>, kind: string, stage: string): Promise<ReviewHostRelayError> {
204
253
  let caught: ReviewHostRelayError | undefined;
205
254
  await assert.rejects(promise, (error: unknown) => {
@@ -305,6 +354,145 @@ test("the pi lockdown argv is pinned exactly with no model or provider selection
305
354
  assert.equal(piCalls[0]!.argv.some((token) => token.startsWith("--model") || token.startsWith("--provider") || token.startsWith("--profile")), false);
306
355
  });
307
356
 
357
+ test("preparation snapshots mutable submission tokens and values before materialization", async (t) => {
358
+ const fixture = harness(t);
359
+ const barrierDirectory = join(fixture.directory, "submission-snapshot-barrier");
360
+ const releaseFile = join(fixture.directory, "submission-snapshot-release");
361
+ const submissionTokens = [...SUBMISSION.argumentTokens];
362
+ const submissionValues = SUBMISSION.values.map((value) => ({ ...value }));
363
+ const submission: ReviewCaptureSubmissionV1 = { ...SUBMISSION, argumentTokens: submissionTokens, values: submissionValues };
364
+ const prepared = prepareReviewHostRelaySlot(relayRequest(fixture, {
365
+ submission,
366
+ environment: {
367
+ ...fixture.environment,
368
+ RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
369
+ RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
370
+ RELAY_FAKE_PI_BARRIER_DIR: barrierDirectory,
371
+ RELAY_FAKE_PI_BARRIER_COUNT: "1",
372
+ RELAY_FAKE_PI_RELEASE_FILE: releaseFile,
373
+ },
374
+ }));
375
+ await waitFor(() => readLog(fixture.piLogPath).length === 1, "reviewer did not reach the snapshot barrier");
376
+ submissionTokens[submissionTokens.length - 1] = "--input=mutated";
377
+ submissionValues[0]!.slot = "mutated";
378
+ writeFileSync(releaseFile, "release");
379
+
380
+ const result = await prepared;
381
+ assert.deepEqual(result.request.submission, SUBMISSION);
382
+ await submitReviewHostRelayPreparedResult(result);
383
+ assert.deepEqual(readFileSync(fixture.submitCapturePath), PI_OUTPUT_BYTES);
384
+ });
385
+
386
+ test("preparation keeps reviewer bytes private through deferred submission", async (t) => {
387
+ const fixture = harness(t);
388
+ const reviewerBytes = Buffer.from(PI_OUTPUT_BYTES);
389
+ const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture), async () => ({
390
+ stdout: reviewerBytes,
391
+ promptByteLength: PROMPT_BYTES.length,
392
+ stdoutByteLength: reviewerBytes.length,
393
+ }));
394
+ reviewerBytes.fill(0);
395
+ const mutablePrepared = prepared as unknown as { resultBytes?: Buffer };
396
+ mutablePrepared.resultBytes?.fill(0);
397
+ assert.throws(() => { mutablePrepared.resultBytes = Buffer.from("fabricated"); }, TypeError);
398
+ await submitReviewHostRelayPreparedResult(prepared);
399
+ assert.deepEqual(readFileSync(fixture.submitCapturePath), PI_OUTPUT_BYTES);
400
+
401
+ const fabricated = { ...prepared, resultBytes: Buffer.from("fabricated") } as unknown as ReviewHostRelayPreparedResult;
402
+ await assert.rejects(submitReviewHostRelayPreparedResult(fabricated), /recognized prepared result/);
403
+ assert.equal(readLog(fixture.logPath).length, 2, "unrecognized results must not launch provider submission");
404
+ });
405
+
406
+ test("the supplied AbortSignal stays live through deferred submission", async (t) => {
407
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_DELAY_MS: "1000" });
408
+ const controller = new AbortController();
409
+ const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture, { signal: controller.signal }));
410
+ const submission = submitReviewHostRelayPreparedResult(prepared);
411
+ await waitFor(() => readLog(fixture.logPath).length === 2, "submission did not start");
412
+ controller.abort();
413
+ await rejectsWithRelayError(submission, REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
414
+ });
415
+
416
+ test("four reviewers cross a shared barrier before any result submission can begin", async (t) => {
417
+ const fixture = harness(t);
418
+ const barrierDirectory = join(fixture.directory, "reviewer-barrier");
419
+ const requests = reviewerGroupRequests(fixture).map((request) => ({
420
+ ...request,
421
+ environment: {
422
+ ...request.environment,
423
+ RELAY_FAKE_PI_BARRIER_DIR: barrierDirectory,
424
+ RELAY_FAKE_PI_BARRIER_COUNT: String(REVIEWER_GROUP_LENSES.length),
425
+ },
426
+ }));
427
+
428
+ const prepared = await runReviewHostRelayReviewerGroup(requests);
429
+
430
+ assert.equal(prepared.length, REVIEWER_GROUP_LENSES.length);
431
+ assert.ok(prepared.every((result) => result.resultByteLength === PI_OUTPUT_BYTES.length));
432
+ assert.equal(readLog(fixture.piLogPath).length, REVIEWER_GROUP_LENSES.length);
433
+ assert.equal(readLog(fixture.logPath).length, REVIEWER_GROUP_LENSES.length, "preparation materializes only; it does not submit");
434
+ });
435
+
436
+ test("four reviewer results retain provider order when their preparation resolves in reverse", async (t) => {
437
+ const requests = reviewerGroupRequests(harness(t));
438
+ const started: number[] = [];
439
+ const complete: Array<() => void> = [];
440
+ const group = runReviewHostRelayReviewerGroup(requests, (request) => new Promise<ReviewHostRelayPreparedResult>((resolve) => {
441
+ const index = requests.indexOf(request);
442
+ assert.notEqual(index, -1);
443
+ started.push(index);
444
+ complete.push(() => resolve({
445
+ request,
446
+ promptByteLength: index + 1,
447
+ resultByteLength: index + 1,
448
+ }));
449
+ }));
450
+
451
+ assert.deepEqual(started, [0, 1, 2, 3], "every reviewer starts before the group waits");
452
+ for (const finish of [...complete].reverse()) finish();
453
+ const prepared = await group;
454
+
455
+ assert.deepEqual(prepared.map((result) => result.resultByteLength), [1, 2, 3, 4]);
456
+ });
457
+
458
+ test("partial reviewer failures wait for a later pending transport and report the first provider-ordered error", async (t) => {
459
+ const requests = reviewerGroupRequests(harness(t));
460
+ const started: number[] = [];
461
+ let groupSettled = false;
462
+ let releaseLaterReviewer: (() => void) | undefined, rejectFirstProvider: ((reason?: unknown) => void) | undefined;
463
+ const firstProviderError = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "first provider error");
464
+
465
+ const group = runReviewHostRelayReviewerGroup(requests, (request) => {
466
+ const index = requests.indexOf(request);
467
+ assert.notEqual(index, -1);
468
+ started.push(index);
469
+ if (index === 1) return new Promise<ReviewHostRelayPreparedResult>((_resolve, reject) => { rejectFirstProvider = reject; });
470
+ if (index === 2) return Promise.reject(new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "later provider error"));
471
+ if (index === 3) {
472
+ return new Promise<ReviewHostRelayPreparedResult>((resolve) => {
473
+ releaseLaterReviewer = () => resolve({
474
+ request,
475
+ promptByteLength: index + 1,
476
+ resultByteLength: index + 1,
477
+ });
478
+ });
479
+ }
480
+ return Promise.resolve({
481
+ request,
482
+ promptByteLength: index + 1,
483
+ resultByteLength: index + 1,
484
+ });
485
+ });
486
+ void group.then(() => { groupSettled = true; }, () => { groupSettled = true; });
487
+
488
+ await new Promise<void>((resolve) => setImmediate(resolve));
489
+ assert.deepEqual(started, [0, 1, 2, 3], "one failed reviewer does not prevent later reviewers from starting");
490
+ rejectFirstProvider!(firstProviderError); await new Promise<void>((resolve) => setImmediate(resolve));
491
+ assert.equal(groupSettled, false, "the group must remain pending until the later reviewer settles");
492
+ releaseLaterReviewer!();
493
+ await assert.rejects(group, (error: unknown) => error === firstProviderError);
494
+ });
495
+
308
496
  // ---------------------------------------------------------------------------
309
497
  // Fail-closed legs — a typed transport error and NO submission.
310
498
  // ---------------------------------------------------------------------------
@@ -393,6 +393,53 @@ test("capabilities/v2.3 retains status/v5 while v2.4 requires status/v6", () =>
393
393
  assert.throws(() => decodeReviewCapabilitiesV2(v22Retired, CAPTURED_DIGEST), /schema/);
394
394
  });
395
395
 
396
+ test("capabilities/v2.5 negotiates the v2.6.0 advertisement and status/v7 decodes the eligible untracked inventory", () => {
397
+ // gentle-ai v2.6.0 advertises capabilities/v2.5: the v2.4 surface plus
398
+ // status/v7, with status/v6 still advertised for compatibility.
399
+ const v25 = clone(fixture(DEV_FIXTURES, "capabilities-v2.2.captured.json") as JsonObject);
400
+ v25.schema = "gentle-ai.review-integration.capabilities/v2.5";
401
+ (v25.protocol as JsonObject).minor = 5;
402
+ const features = v25.features as JsonObject;
403
+ features.mandatory = (features.mandatory as JsonObject[]).filter((feature) =>
404
+ !["exact_receipt_replay", "five_delivery_gates", "sdd_receipt_binding"].includes(feature.name as string));
405
+ v25.schemas = [
406
+ ...(v25.schemas as string[]).map((schema) => schema
407
+ .replace("capabilities/v2.2", "capabilities/v2.5")
408
+ .replace("start/v3", "start/v4")
409
+ .replace("status/v5", "status/v6")),
410
+ "gentle-ai.review-intended-untracked-selection/v1",
411
+ "gentle-ai.review-integration.status/v7",
412
+ ];
413
+ const decoded = decodeReviewCapabilitiesV2(v25, CAPTURED_DIGEST);
414
+ assert.equal(decoded.schemas.has("gentle-ai.review-integration.status/v6"), true);
415
+ // The negotiated set is the v2.5 requirement floor; status/v7 is additive
416
+ // and never required, so an advertisement without it still negotiates.
417
+ assert.equal(decoded.schemas.has("gentle-ai.review-integration.status/v7"), false);
418
+ const withoutStatusV7 = clone(v25);
419
+ withoutStatusV7.schemas = (withoutStatusV7.schemas as string[]).filter((schema) => schema !== "gentle-ai.review-integration.status/v7");
420
+ assert.equal(decodeReviewCapabilitiesV2(withoutStatusV7, CAPTURED_DIGEST).schemas.has("gentle-ai.review-integration.status/v6"), true);
421
+
422
+ // status/v6 stays required: v7 is an additive extension, not a replacement.
423
+ const missingStatusV6 = clone(v25);
424
+ missingStatusV6.schemas = (missingStatusV6.schemas as string[]).filter((schema) => schema !== "gentle-ai.review-integration.status/v6");
425
+ assert.throws(() => decodeReviewCapabilitiesV2(missingStatusV6, CAPTURED_DIGEST), /status\/v6/);
426
+
427
+ // status/v7 adds only the optional top-level eligible untracked inventory digest.
428
+ const v7 = initialIntendedUntrackedStatusV6();
429
+ v7.schema = "gentle-ai.review-integration.status/v7";
430
+ v7.eligible_untracked_inventory = sha("e");
431
+ assert.equal(decodeReviewStatusV3(v7).eligibleUntrackedInventory, sha("e"));
432
+
433
+ const v7Staged = initialIntendedUntrackedStatusV6();
434
+ v7Staged.schema = "gentle-ai.review-integration.status/v7";
435
+ assert.equal(decodeReviewStatusV3(v7Staged).eligibleUntrackedInventory, undefined);
436
+
437
+ // The digest is v7-only: a v6 envelope carrying it is still rejected.
438
+ const v6WithDigest = initialIntendedUntrackedStatusV6();
439
+ v6WithDigest.eligible_untracked_inventory = sha("e");
440
+ assert.throws(() => decodeReviewStatusV3(v6WithDigest), /eligible_untracked_inventory/);
441
+ });
442
+
396
443
  test("status/v6 decodes and enforces the intended-untracked selection submission", () => {
397
444
  const decoded = decodeReviewStatusV3(initialIntendedUntrackedStatusV6());
398
445
  const input = decoded.nextTransition?.collect?.inputs[0];
@@ -311,7 +311,7 @@ test("Judgment Day skill and prompts preserve bounded fix and re-judgment author
311
311
  });
312
312
 
313
313
  test("orchestrator, injected skill, and README defer RDD lifecycle ownership to Gentle AI", () => {
314
- 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.";
314
+ 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.";
315
315
  const orchestrator = union(ORCHESTRATOR);
316
316
  assert.ok(orchestrator.includes(boundary), "orchestrator must carry the sole static ownership boundary");
317
317
 
@@ -314,6 +314,7 @@ async function run() {
314
314
  const capture = lastEventPi.tools.get("gentle_review_capture");
315
315
  assert.ok(controller, "runtime must register the public status controller");
316
316
  assert.ok(capture, "runtime must register the one-slot capture tool");
317
+ assert.equal(lastEventPi.tools.get("gentle_review_capture_group")?.executionMode, "sequential", "runtime must register grouped capture with bounded foreground concurrency");
317
318
  assert.equal(controller.parameters.properties.operation.enum.includes("finalize"), false);
318
319
  assert.equal(controller.parameters.properties.operation.enum.includes("validate"), false);
319
320