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
@@ -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];
@@ -184,6 +184,44 @@ test("failure enforces exact keys, enums, and identifiers", () => {
184
184
  assert.throws(() => decodeReviewFailureV2(badCode), /code/);
185
185
  });
186
186
 
187
+ test("failure with code managed_assets_outdated decodes its continuation, degrades without one, and forbids it elsewhere", () => {
188
+ const source: JsonObject = {
189
+ schema: "gentle-ai.review-integration.failure/v2",
190
+ contract: REVIEW_INTEGRATION_CONTRACT,
191
+ operation: "review.start",
192
+ phase: "preflight",
193
+ code: "managed_assets_outdated",
194
+ message: "the managed asset set is stale",
195
+ mutation_outcome: "not_started",
196
+ authority_applicability: "current_target",
197
+ retry_safe: true,
198
+ replayability: "manual_action_required",
199
+ required_inputs: [],
200
+ next_action: "explicit-maintainer-action",
201
+ continuation: { operation: "sync", command: "gentle-ai sync --agent claude-code", agent: "claude-code", stale_assets: ["orchestration/claude-code.md"] },
202
+ };
203
+ assert.deepEqual(decodeReviewFailureV2(source).continuation, { operation: "sync", command: "gentle-ai sync --agent claude-code", agent: "claude-code", staleAssets: ["orchestration/claude-code.md"] });
204
+
205
+ const missingCommand = clone(source);
206
+ delete (missingCommand.continuation as JsonObject).command;
207
+ assert.throws(() => decodeReviewFailureV2(missingCommand), /command.*required|required.*command/);
208
+
209
+ // an older gentle-ai may emit managed_assets_outdated with no continuation
210
+ // at all; decode must not refuse the whole envelope for that
211
+ const missingContinuation = clone(source);
212
+ delete missingContinuation.continuation;
213
+ assert.equal(decodeReviewFailureV2(missingContinuation).continuation, undefined);
214
+
215
+ const unexpectedContinuation: JsonObject = { ...clone(source), code: "gate_scope_changed" };
216
+ assert.throws(() => decodeReviewFailureV2(unexpectedContinuation), /continuation is only valid/);
217
+
218
+ // an envelope without continuation, on a code that does not require one, is unchanged
219
+ const withoutContinuation = clone(source);
220
+ withoutContinuation.code = "gate_scope_changed";
221
+ delete withoutContinuation.continuation;
222
+ assert.equal(decodeReviewFailureV2(withoutContinuation).continuation, undefined);
223
+ });
224
+
187
225
  test("net-new decoders: consent, next-transition, and artifact-subject reject malformed payloads", () => {
188
226
  const consentSource = fixture<JsonObject>("consent.fixture.json");
189
227
  assertRequired(decodeReviewConsentV2, consentSource, ["schema", "contract", "operation", "action", "blocking", "target_identity", "projection", "risk_level", "changed_files", "changed_lines", "headline", "reason", "value", "risk_evidence", "choices", "off_path"]);
@@ -405,6 +443,41 @@ test("next_transition decodes an execute variant and rejects a stop that carries
405
443
  assert.throws(() => decodeReviewNextTransitionV3(stopWithExecute), /stop cannot carry/);
406
444
  });
407
445
 
446
+ // gentle-pi#627: gentle-ai reports a stale managed-asset set as a typed stop
447
+ // carrying the exact `gentle-ai sync` invocation that resolves it.
448
+ function managedAssetsContinuation(overrides: Partial<JsonObject> = {}): JsonObject {
449
+ return { operation: "sync", command: "gentle-ai sync --agent claude-code", agent: "claude-code", stale_assets: ["orchestration/claude-code.md"], ...overrides };
450
+ }
451
+
452
+ test("next_transition stop decodes a managed_assets_outdated continuation and rejects a malformed one", () => {
453
+ const stop: JsonObject = { kind: "stop", reason_code: "managed_assets_outdated", continuation: managedAssetsContinuation() };
454
+ const decoded = decodeReviewNextTransitionV3(stop);
455
+ assert.deepEqual(decoded.continuation, { operation: "sync", command: "gentle-ai sync --agent claude-code", agent: "claude-code", staleAssets: ["orchestration/claude-code.md"] });
456
+
457
+ const bare: JsonObject = { kind: "stop", reason_code: "managed_assets_outdated", continuation: { operation: "sync", command: "gentle-ai sync" } };
458
+ assert.deepEqual(decodeReviewNextTransitionV3(bare).continuation, { operation: "sync", command: "gentle-ai sync" });
459
+
460
+ const missingCommand = clone(stop);
461
+ delete (missingCommand.continuation as JsonObject).command;
462
+ assert.throws(() => decodeReviewNextTransitionV3(missingCommand), /command.*required|required.*command/);
463
+
464
+ const emptyCommand = clone(stop);
465
+ (emptyCommand.continuation as JsonObject).command = "";
466
+ assert.throws(() => decodeReviewNextTransitionV3(emptyCommand), /command/);
467
+
468
+ // an older gentle-ai may emit this stop with no continuation at all; decode
469
+ // must not refuse the whole envelope for that
470
+ const missingForReasonCode = { kind: "stop", reason_code: "managed_assets_outdated" };
471
+ assert.equal(decodeReviewNextTransitionV3(missingForReasonCode).continuation, undefined);
472
+
473
+ const unexpectedOnOtherReasonCode = { kind: "stop", reason_code: "rdd_disabled", continuation: managedAssetsContinuation() };
474
+ assert.throws(() => decodeReviewNextTransitionV3(unexpectedOnOtherReasonCode), /continuation is only valid/);
475
+
476
+ // envelopes without continuation stay unchanged
477
+ const plainStop = decodeReviewNextTransitionV3({ kind: "stop", reason_code: "rdd_disabled" });
478
+ assert.equal(plainStop.continuation, undefined);
479
+ });
480
+
408
481
  function approvedAcknowledgementTransition(): JsonObject {
409
482
  return {
410
483
  kind: "execute",
@@ -700,6 +773,40 @@ test("v5 targeted-validator collect inputs carry the exact provider-owned valida
700
773
  assert.throws(() => decodeReviewNextTransitionV3(weakenedRequest, { v5: true }), /policy_content/);
701
774
  });
702
775
 
776
+ test("status.frozen carries the optional frozen manifest digest", () => {
777
+ const current = devFixture<JsonObject>("status-v5-repository-context.captured.json");
778
+ delete current.receipt;
779
+ if (current.action === "finalize") {
780
+ current.action = "stop";
781
+ delete current.next_transition;
782
+ delete current.forecast;
783
+ }
784
+ assert.equal(typeof (current.frozen as JsonObject | undefined)?.tier, "string", "the captured status carries frozen");
785
+ const digest = `sha256:${"ab".repeat(32)}`;
786
+ (current.frozen as JsonObject).changed_path_manifest_sha256 = digest;
787
+ assert.equal(decodeReviewStatusV3(current).frozen?.changedPathManifestSha256, digest);
788
+ (current.frozen as JsonObject).changed_path_manifest_sha256 = "not-a-digest";
789
+ assert.throws(() => decodeReviewStatusV3(current), /changed_path_manifest_sha256/);
790
+ delete (current.frozen as JsonObject).changed_path_manifest_sha256;
791
+ assert.equal(decodeReviewStatusV3(current).frozen?.changedPathManifestSha256, undefined);
792
+ });
793
+
794
+ test("a capture-result collect input decodes without changed_path_manifest, which the artifact subject digest already binds", () => {
795
+ const status = fixture<JsonObject>("status.fixture.json");
796
+ const transition = clone((status.next_transition ?? {}) as JsonObject);
797
+ const inputs = ((transition.collect as JsonObject | undefined)?.inputs ?? []) as JsonObject[];
798
+ const captureInput = inputs.find((input) => input.capture_operation === "review.capture-result");
799
+ assert.ok(captureInput, "the status fixture carries a capture-result collect input");
800
+ assert.doesNotThrow(() => decodeReviewNextTransitionV3(transition, { v5: true, v6: true }), "with the manifest");
801
+ delete captureInput.changed_path_manifest;
802
+ const decoded = decodeReviewNextTransitionV3(transition, { v5: true, v6: true });
803
+ const decodedInput = decoded.collect?.inputs.find((input) => input.captureOperation === "review.capture-result");
804
+ assert.ok(decodedInput?.artifactSubject?.changedPathManifestSha256, "the digest on the artifact subject is the binding");
805
+ assert.equal(decodedInput?.changedPathManifest, undefined);
806
+ delete captureInput.artifact_subject;
807
+ assert.throws(() => decodeReviewNextTransitionV3(transition, { v5: true, v6: true }), /requires artifact_subject, base_tree, and candidate_tree/);
808
+ });
809
+
703
810
  test("status enforces authority/frozen/receipt conditionals and decodes the required repair field", () => {
704
811
  const current = devFixture<JsonObject>("status-v5-repository-context.captured.json");
705
812
  delete current.receipt;
@@ -711,6 +818,11 @@ test("status enforces authority/frozen/receipt conditionals and decodes the requ
711
818
  const decoded = decodeReviewStatusV3(current);
712
819
  assert.equal(decoded.repair.status, "unsupported");
713
820
  assert.equal(decoded.authority?.state, "reviewing");
821
+ for (const action of ["collect", "execute"]) {
822
+ const projected = clone(current);
823
+ projected.action = action;
824
+ assert.equal(decodeReviewStatusV3(projected).action, action, "a live transaction's root action names its mandated transition kind");
825
+ }
714
826
 
715
827
  const missingAuthority = clone(current);
716
828
  delete missingAuthority.authority;
@@ -331,7 +331,7 @@ test("terminal capture closes directly, nonterminal capture does not auto-follow
331
331
  assert.equal(statusCalls, 2, "only initial STATUS plus one ambiguity reconciliation");
332
332
  });
333
333
 
334
- test("unknown-capture reconciliation validates the target-scoped lineage without replay", async () => {
334
+ test("unknown-capture reconciliation preserves agentless legacy callers and forwards an explicit Pi agent", async () => {
335
335
  const calls: Array<Record<string, unknown>> = [];
336
336
  const native = {
337
337
  targetStatus: async (request: Record<string, unknown>) => {
@@ -342,7 +342,12 @@ test("unknown-capture reconciliation validates the target-scoped lineage without
342
342
  } as unknown as NativeReviewCli;
343
343
  const result = await reconcileUnknownReviewLastEventCapture(native, "/repo", { lineageId: "reconcile-lineage", targetIdentity: SHA });
344
344
  await reconcileUnknownReviewLastEventCapture(native, "/repo", { lineageId: "reconcile-lineage", targetIdentity: SHA }, { baseRef: "refs/heads/main", committedOnly: true });
345
- assert.deepEqual(calls, [{ cwd: "/repo", lineageId: "reconcile-lineage" }, { cwd: "/repo", lineageId: "reconcile-lineage", baseRef: "refs/heads/main", committedOnly: true }]);
345
+ await reconcileUnknownReviewLastEventCapture(native, "/repo", { lineageId: "reconcile-lineage", targetIdentity: SHA }, { agent: "pi" });
346
+ assert.deepEqual(calls, [
347
+ { cwd: "/repo", lineageId: "reconcile-lineage" },
348
+ { cwd: "/repo", lineageId: "reconcile-lineage", baseRef: "refs/heads/main", committedOnly: true },
349
+ { cwd: "/repo", lineageId: "reconcile-lineage", agent: "pi" },
350
+ ]);
346
351
  assert.equal(result.targetIdentity, SHA);
347
352
  let launches = 0;
348
353
  const strictNative = new nativeReviewCliModule.NativeReviewCliV216(async () => { launches += 1; throw new Error("must not launch"); }, "/package/.gentle-ai/gentle-ai");
@@ -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
 
@@ -0,0 +1,26 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
5
+ import { declareReviewRelayHandshake, GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../lib/review-relay-contract.ts";
6
+
7
+ // gentle-pi#550: the session shell must inherit the relay handshake, so a
8
+ // `gentle-ai review ... --agent pi` typed in a shell tool is eligible without
9
+ // the operator reading package source for the value.
10
+
11
+ test("declareReviewRelayHandshake exports the exact contract and reports whether it changed anything", () => {
12
+ const env: NodeJS.ProcessEnv = {};
13
+ assert.equal(declareReviewRelayHandshake(env), true);
14
+ assert.equal(env[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV], GENTLE_PI_REVIEW_RELAY_CONTRACT);
15
+ assert.equal(declareReviewRelayHandshake(env), false, "already declared");
16
+ const stale: NodeJS.ProcessEnv = { [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: "gentle-pi.review-relay/v0" };
17
+ assert.equal(declareReviewRelayHandshake(stale), true, "a wrong value is replaced: this extension is the host");
18
+ assert.equal(stale[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV], GENTLE_PI_REVIEW_RELAY_CONTRACT);
19
+ });
20
+
21
+ test("loading the extension declares the handshake in the session environment", () => {
22
+ const processEnv: NodeJS.ProcessEnv = { PATH: "/bin" };
23
+ createGentleAiExtension({ nativeReviewCli: null, processEnv })({ on() {}, registerTool() {}, registerCommand() {} } as unknown as ExtensionAPI);
24
+ assert.equal(processEnv[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV], GENTLE_PI_REVIEW_RELAY_CONTRACT);
25
+ assert.equal(processEnv.PATH, "/bin");
26
+ });
@@ -4,7 +4,7 @@ import { cpSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import test from "node:test";
7
- import { resolveRepositoryAuthorityV1, setReviewRepositoryIdentityRetryHookForTesting } from "../lib/review-repository.ts";
7
+ import { resolveRepositoryAuthorityV1, reviewGitEnvironment, setReviewRepositoryIdentityRetryHookForTesting } from "../lib/review-repository.ts";
8
8
  import { REVIEW_MODE, ReviewTransactionStore, createReviewState, setReviewMutationLockPlatformForTesting } from "../lib/review-transaction.ts";
9
9
  import { REVIEW_LENS, REVIEW_ROUTE } from "../lib/review-triggers.ts";
10
10
  import { qualifiedReviewLockPlatform, testSnapshot } from "./review-test-fixtures.ts";
@@ -49,6 +49,33 @@ function addOrphanRoot(root: string, branch: string, file: string): void {
49
49
  git(root, "checkout", "main");
50
50
  }
51
51
 
52
+ test("review Git environment uses a Git-compatible null config path on Windows", () => {
53
+ const environment = reviewGitEnvironment();
54
+ assert.equal(environment.GIT_CONFIG_GLOBAL, "/dev/null");
55
+ assert.equal(environment.GIT_CONFIG_SYSTEM, "/dev/null");
56
+ });
57
+
58
+ test("review Git environment runs a real Git authority probe", (t) => {
59
+ const root = repository(t);
60
+ const commonDirectory = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
61
+ cwd: root,
62
+ encoding: "utf8",
63
+ env: reviewGitEnvironment(),
64
+ }).trim();
65
+ assert.equal(resolve(commonDirectory), resolve(git(root, "rev-parse", "--path-format=absolute", "--git-common-dir")));
66
+ });
67
+
68
+ test("review Git environment rejects inherited unsafe Git overrides", () => {
69
+ const prior = process.env.GIT_DIR;
70
+ try {
71
+ process.env.GIT_DIR = "/unsafe";
72
+ assert.throws(() => reviewGitEnvironment(), /REVIEW_GIT_ENV_UNSAFE/);
73
+ } finally {
74
+ if (prior === undefined) delete process.env.GIT_DIR;
75
+ else process.env.GIT_DIR = prior;
76
+ }
77
+ });
78
+
52
79
  test("a Windows drive-letter Git common directory resolves repository authority", { skip: process.platform !== "win32" }, (t) => {
53
80
  const root = repository(t);
54
81
  const commonDirectory = git(root, "rev-parse", "--path-format=absolute", "--git-common-dir");