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.
- package/README.md +195 -11
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +21 -10
- package/assets/orchestrator.md +8 -12
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
- package/docs/delegated-verification.md +25 -0
- package/docs/review-integration.md +1 -1
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +1421 -143
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agent-home.ts +8 -0
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +103 -12
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +92 -13
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/sdd-preflight.ts +2 -2
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +102 -11
- package/runtime/review-integration-v2.mjs +92 -13
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +18 -13
- package/skills/_shared/review-ledger-contract.md +9 -1
- package/skills/issue-creation/SKILL.md +53 -93
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
- package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
- package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +31 -14
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +33 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +71 -2
- package/tests/orchestrator-rdd-ownership.test.ts +10 -1
- package/tests/package-manifest.test.ts +134 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +142 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-routing.test.ts +229 -11
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/runtime-harness.mjs +1 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -0
- package/tests/writer-edit-surface-scope.test.ts +153 -17
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
-
|
|
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 = "
|
|
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");
|