gentle-pi 1.2.0 → 2.1.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 +32 -10
- package/assets/agents/review-readability.md +0 -1
- package/assets/agents/review-reliability.md +0 -1
- package/assets/agents/review-resilience.md +0 -1
- package/assets/agents/review-risk.md +0 -1
- package/contracts/review-integration/v1/fixtures/binding-revision-conflict.fixture.json +25 -0
- package/contracts/review-integration/v1/fixtures/capabilities-v1.2.fixture.json +263 -0
- package/contracts/review-integration/v1/fixtures/capabilities-v1.3.fixture.json +301 -0
- package/contracts/review-integration/v1/fixtures/capabilities-v1.4.fixture.json +312 -0
- package/contracts/review-integration/v1/fixtures/capabilities-v1.5.fixture.json +321 -0
- package/contracts/review-integration/v1/fixtures/consent.fixture.json +36 -0
- package/contracts/review-integration/v1/fixtures/final-verification-incident.fixture.json +1 -0
- package/contracts/review-integration/v1/fixtures/operation.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/repair-preflight.fixture.json +54 -0
- package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +97 -0
- package/contracts/review-integration/v1/fixtures/start.fixture.json +25 -1
- package/contracts/review-integration/v1/fixtures/status-ambiguous.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/status-corrupted.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/status-recover.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/status-unrelated.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/status-v2-ambiguous.fixture.json +110 -0
- package/contracts/review-integration/v1/fixtures/status-v2-corrupted.fixture.json +107 -0
- package/contracts/review-integration/v1/fixtures/status-v2-final-verification-retry.fixture.json +192 -0
- package/contracts/review-integration/v1/fixtures/status-v2-recover.fixture.json +130 -0
- package/contracts/review-integration/v1/fixtures/status-v2-repair.fixture.json +161 -0
- package/contracts/review-integration/v1/fixtures/status-v2-unrelated.fixture.json +103 -0
- package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +193 -0
- package/contracts/review-integration/v1/fixtures/status.fixture.json +19 -5
- package/contracts/review-integration/v1/fixtures/verification-evidence.fixture.json +1 -0
- package/contracts/review-integration/v1/schemas/admitted-result.schema.json +35 -0
- package/contracts/review-integration/v1/schemas/artifact-subject.schema.json +33 -0
- package/contracts/review-integration/v1/schemas/authority-repair-assessment.schema.json +247 -0
- package/contracts/review-integration/v1/schemas/capabilities-v1.2.schema.json +249 -0
- package/contracts/review-integration/v1/schemas/capabilities-v1.3.schema.json +429 -0
- package/contracts/review-integration/v1/schemas/capabilities-v1.4.schema.json +432 -0
- package/contracts/review-integration/v1/schemas/capabilities-v1.5.schema.json +154 -0
- package/contracts/review-integration/v1/schemas/consent.schema.json +86 -0
- package/contracts/review-integration/v1/schemas/failure.schema.json +54 -8
- package/contracts/review-integration/v1/schemas/final-verification-incident.schema.json +51 -0
- package/contracts/review-integration/v1/schemas/operation.schema.json +204 -31
- package/contracts/review-integration/v1/schemas/repair.schema.json +251 -0
- package/contracts/review-integration/v1/schemas/result-artifact-v2.schema.json +38 -0
- package/contracts/review-integration/v1/schemas/start-v2.schema.json +216 -0
- package/contracts/review-integration/v1/schemas/start.schema.json +79 -3
- package/contracts/review-integration/v1/schemas/status-v2.schema.json +313 -0
- package/contracts/review-integration/v1/schemas/status.schema.json +37 -10
- package/contracts/review-integration/v1/schemas/targeted-validation-request.schema.json +46 -0
- package/contracts/review-integration/v1/schemas/verification-evidence.schema.json +64 -0
- package/contracts/review-integration/v2/fixtures/capabilities.fixture.json +111 -0
- package/contracts/review-integration/v2/fixtures/consent.fixture.json +36 -0
- package/contracts/review-integration/v2/fixtures/start.fixture.json +93 -0
- package/contracts/review-integration/v2/fixtures/status.fixture.json +113 -0
- package/contracts/review-integration/v2/schemas/admitted-result.schema.json +14 -0
- package/contracts/review-integration/v2/schemas/artifact-subject.schema.json +36 -0
- package/contracts/review-integration/v2/schemas/capabilities.schema.json +111 -0
- package/contracts/review-integration/v2/schemas/consent.schema.json +70 -0
- package/contracts/review-integration/v2/schemas/failure.schema.json +35 -0
- package/contracts/review-integration/v2/schemas/operation.schema.json +46 -0
- package/contracts/review-integration/v2/schemas/repair.schema.json +22 -0
- package/contracts/review-integration/v2/schemas/start.schema.json +106 -0
- package/contracts/review-integration/v2/schemas/status.schema.json +104 -0
- package/docs/native-authority-architecture.md +18 -3
- package/docs/review-integration.md +175 -27
- package/extensions/gentle-ai.ts +647 -58
- package/lib/gentle-ai-binary.ts +65 -18
- package/lib/native-review-cli.ts +824 -56
- package/lib/review-candidate-view.ts +135 -1
- package/lib/review-compact-contract.ts +12 -3
- package/lib/review-consent-latch.ts +51 -0
- package/lib/review-correction-lifecycle.ts +170 -0
- package/lib/review-integration-v2.ts +1567 -0
- package/package.json +15 -11
- package/runtime/gentle-ai-binary.mjs +65 -18
- package/runtime/native-review-cli.mjs +808 -40
- package/runtime/review-integration-v2.mjs +1568 -0
- package/scripts/build-git-commit-transaction-runner.mjs +1 -1
- package/scripts/gentle-ai-installer.mjs +464 -76
- package/scripts/install-gentle-ai.mjs +7 -3
- package/scripts/test-packed-runner.mjs +3 -3
- package/scripts/verify-package-files.mjs +216 -64
- package/skills/_shared/review-ledger-contract.md +3 -1
- package/skills/release/SKILL.md +38 -11
- package/tests/devbinary/native-review-parity.devtest.ts +348 -0
- package/tests/gentle-ai-binary.test.ts +77 -3
- package/tests/gentle-ai-installer.test.ts +561 -22
- package/tests/gentle-ai.test.ts +2 -2
- package/tests/native-binary-gate.test.ts +145 -0
- package/tests/native-review-capability-contract.test.ts +87 -0
- package/tests/native-review-cli.test.ts +44 -1
- package/tests/native-review-consent.test.ts +128 -0
- package/tests/native-review-parity-runtime.test.ts +85 -29
- package/tests/native-review-parity.test.ts +782 -0
- package/tests/native-risk-evidence-parity.test.ts +91 -0
- package/tests/package-manifest.test.ts +80 -8
- package/tests/review-actor-tool-deny.test.ts +20 -5
- package/tests/review-candidate-view.test.ts +374 -1
- package/tests/review-compact-contract.test.ts +5 -0
- package/tests/review-consent-latch.test.ts +67 -0
- package/tests/review-controller-native-recovery.test.ts +180 -5
- package/tests/review-controller-native-routing.test.ts +632 -18
- package/tests/review-controller-workspace-root.test.ts +26 -6
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-correction-lifecycle.test.ts +174 -0
- package/tests/review-integration-v2.test.ts +548 -0
- package/tests/runtime-harness.mjs +211 -12
- package/tests/support/native-binary-gate.ts +86 -0
- package/tests/verify-package-files.test.ts +119 -0
- package/lib/review-integration-v1.ts +0 -795
- package/runtime/review-integration-v1.mjs +0 -796
- package/tests/native-review-integration-v1.test.ts +0 -102
- package/tests/review-integration-v1.test.ts +0 -530
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
9
|
+
import {
|
|
10
|
+
NATIVE_REVIEW_ERROR_CODE,
|
|
11
|
+
NativeReviewCliError,
|
|
12
|
+
NativeReviewConsentRequiredError,
|
|
13
|
+
NativeReviewCliV213 as NativeReviewCliV213Production,
|
|
14
|
+
setNativeCliContractForTesting,
|
|
15
|
+
type ExecFileAdapter,
|
|
16
|
+
type NativeReviewCli,
|
|
17
|
+
type NativeReviewConsentAnswer,
|
|
18
|
+
} from "../lib/native-review-cli.ts";
|
|
19
|
+
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
20
|
+
import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
|
|
21
|
+
import type { AuthorityRepairAssessmentV1, ReviewConsentV2, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
22
|
+
|
|
23
|
+
// Queued-adapter clients never execute a real process; default to a fixed
|
|
24
|
+
// absolute package-local path so these tests do not depend on an installed
|
|
25
|
+
// binary.
|
|
26
|
+
class NativeReviewCliV213 extends NativeReviewCliV213Production {
|
|
27
|
+
constructor(...parameters: ConstructorParameters<typeof NativeReviewCliV213Production>) {
|
|
28
|
+
const [adapter, executable, ...rest] = parameters;
|
|
29
|
+
super(adapter, executable ?? "/package/.gentle-ai/gentle-ai", ...rest);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface QueuedResult {
|
|
34
|
+
stdout: string;
|
|
35
|
+
stderr?: string;
|
|
36
|
+
exitCode?: number;
|
|
37
|
+
timedOut?: boolean;
|
|
38
|
+
signal?: NodeJS.Signals | null;
|
|
39
|
+
outputLimitExceeded?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function queuedAdapter(results: QueuedResult[]): { adapter: ExecFileAdapter; calls: Array<{ file: string; arguments: readonly string[]; cwd: string }> } {
|
|
43
|
+
const calls: Array<{ file: string; arguments: readonly string[]; cwd: string }> = [];
|
|
44
|
+
return {
|
|
45
|
+
calls,
|
|
46
|
+
adapter: async (request) => {
|
|
47
|
+
calls.push(request);
|
|
48
|
+
const result = results.shift();
|
|
49
|
+
if (!result) throw new Error("unexpected native invocation");
|
|
50
|
+
return {
|
|
51
|
+
stdout: result.stdout,
|
|
52
|
+
stderr: result.stderr ?? "",
|
|
53
|
+
exitCode: result.exitCode ?? 0,
|
|
54
|
+
signal: result.signal ?? null,
|
|
55
|
+
timedOut: result.timedOut ?? false,
|
|
56
|
+
outputLimitExceeded: result.outputLimitExceeded ?? false,
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A synthetic testing-only version whose capability row (in the testing
|
|
63
|
+
// overlay, never in the shipped NATIVE_CLI_CONTRACTS table) reports every
|
|
64
|
+
// organic-parity column true, so decode paths that are dark for every real
|
|
65
|
+
// shipped version — including the pinned 2.1.11 — can still be exercised.
|
|
66
|
+
const CAPABLE_VERSION = "9.9.9";
|
|
67
|
+
const CAPABLE_VERSION_LINE = { stdout: `gentle-ai ${CAPABLE_VERSION}\n` };
|
|
68
|
+
const DARK_VERSION = { stdout: "gentle-ai 2.1.11\n" };
|
|
69
|
+
|
|
70
|
+
test.beforeEach(() => {
|
|
71
|
+
setNativeCliContractForTesting(CAPABLE_VERSION, {
|
|
72
|
+
start: true, finalize: true, validate: true, bindSdd: true, sddStatus: true, status: true, inventory: true,
|
|
73
|
+
reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true,
|
|
74
|
+
mode: true, riskEvidence: true, hint: true, delivery: true,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
test.afterEach(() => {
|
|
78
|
+
setNativeCliContractForTesting(CAPABLE_VERSION, undefined);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const START = { stdout: JSON.stringify({ operation: "review/start", lineage_id: "lineage-1", state: "reviewing", risk_level: "medium", selected_lenses: ["review-reliability"], changed_files: 1, changed_lines: 2, correction_budget: 1, action: "created", lenses_required: true, projection: "workspace" }) };
|
|
82
|
+
|
|
83
|
+
function reviewModeStatusBody(effective: "on" | "off", overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
84
|
+
return {
|
|
85
|
+
schema: "gentle-ai.review-mode/v1",
|
|
86
|
+
operation: "status",
|
|
87
|
+
scope: "both",
|
|
88
|
+
status: {
|
|
89
|
+
schema: "gentle-ai.rdd-mode-status/v1",
|
|
90
|
+
global: "",
|
|
91
|
+
clone_local: "",
|
|
92
|
+
effective,
|
|
93
|
+
source: "default",
|
|
94
|
+
...overrides,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test("reviewMode requires the mode capability and fails closed for a version without it", async () => {
|
|
100
|
+
const queue = queuedAdapter([DARK_VERSION]);
|
|
101
|
+
await assert.rejects(
|
|
102
|
+
() => new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" }),
|
|
103
|
+
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("reviewMode status uses the exact fixed argv and decodes the effective mode", async () => {
|
|
108
|
+
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("on")) }]);
|
|
109
|
+
const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" });
|
|
110
|
+
assert.deepEqual(queue.calls[1]?.arguments, ["review", "mode", "status", "--cwd", "/repo", "--json"]);
|
|
111
|
+
assert.equal(result.status.effective, "on");
|
|
112
|
+
assert.equal(result.scope, "both");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("reviewMode status decodes an off effective mode with its deciding source", async () => {
|
|
116
|
+
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("off", { clone_local: "off", source: "clone_local", revision: "sha256:deadbeef" })) }]);
|
|
117
|
+
const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" });
|
|
118
|
+
assert.equal(result.status.effective, "off");
|
|
119
|
+
assert.equal(result.status.source, "clone_local");
|
|
120
|
+
assert.equal(result.status.cloneLocal, "off");
|
|
121
|
+
assert.equal(result.status.revision, "sha256:deadbeef");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("reviewMode enable and disable pass --scope clone and mutate without a timeout", async () => {
|
|
125
|
+
const queue = queuedAdapter([
|
|
126
|
+
CAPABLE_VERSION_LINE, { stdout: JSON.stringify({ schema: "gentle-ai.review-mode/v1", operation: "disable", scope: "clone", status: { schema: "gentle-ai.rdd-mode-status/v1", global: "", clone_local: "off", effective: "off", source: "clone_local" } }) },
|
|
127
|
+
CAPABLE_VERSION_LINE, { stdout: JSON.stringify({ schema: "gentle-ai.review-mode/v1", operation: "enable", scope: "clone", status: { schema: "gentle-ai.rdd-mode-status/v1", global: "", clone_local: "", effective: "on", source: "default" } }) },
|
|
128
|
+
]);
|
|
129
|
+
const client = new NativeReviewCliV213(queue.adapter);
|
|
130
|
+
const disabled = await client.reviewMode({ cwd: "/repo", operation: "disable" });
|
|
131
|
+
const enabled = await client.reviewMode({ cwd: "/repo", operation: "enable" });
|
|
132
|
+
assert.deepEqual(queue.calls[1]?.arguments, ["review", "mode", "disable", "--cwd", "/repo", "--scope", "clone", "--json"]);
|
|
133
|
+
assert.deepEqual(queue.calls[3]?.arguments, ["review", "mode", "enable", "--cwd", "/repo", "--scope", "clone", "--json"]);
|
|
134
|
+
assert.equal(disabled.status.effective, "off");
|
|
135
|
+
assert.equal(enabled.status.effective, "on");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("reviewMode rejects a response whose operation discriminator does not match the request", async () => {
|
|
139
|
+
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("on")) }]);
|
|
140
|
+
await assert.rejects(
|
|
141
|
+
() => new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "disable" }),
|
|
142
|
+
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("native START decodes optional riskEvidence (a phrase array, matching gentle-ai's []string wire shape) and hint only when present", async () => {
|
|
147
|
+
const start = JSON.parse(START.stdout) as Record<string, unknown>;
|
|
148
|
+
const withEvidence = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify({ ...start, risk_evidence: ["service credentials in .env.example"], hint: "no reviewable candidate: every changed path is documentation." }) }]);
|
|
149
|
+
const result = await new NativeReviewCliV213(withEvidence.adapter).start({ cwd: "/repo" });
|
|
150
|
+
assert.deepEqual(result.riskEvidence, ["service credentials in .env.example"]);
|
|
151
|
+
assert.equal(result.hint, "no reviewable candidate: every changed path is documentation.");
|
|
152
|
+
|
|
153
|
+
const withoutEvidence = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: START.stdout }]);
|
|
154
|
+
const bare = await new NativeReviewCliV213(withoutEvidence.adapter).start({ cwd: "/repo" });
|
|
155
|
+
assert.equal(bare.riskEvidence, undefined);
|
|
156
|
+
assert.equal(bare.hint, undefined);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("native START rejects a scalar risk_evidence: the wire shape is always a phrase array, even with one phrase", async () => {
|
|
160
|
+
const start = JSON.parse(START.stdout) as Record<string, unknown>;
|
|
161
|
+
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify({ ...start, risk_evidence: "not an array" }) }]);
|
|
162
|
+
await assert.rejects(
|
|
163
|
+
() => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
|
|
164
|
+
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("native VALIDATE decodes the disabled/unmanaged delivery alternate discriminator at exit 0", async () => {
|
|
169
|
+
const body = {
|
|
170
|
+
schema: "gentle-ai.review-gate-result/v1",
|
|
171
|
+
result: "invalidated",
|
|
172
|
+
allowed: false,
|
|
173
|
+
action: "repository-policy",
|
|
174
|
+
reason: "receipt-driven development is disabled and no receipt governs this candidate, so delivery follows ordinary repository policy",
|
|
175
|
+
// gentle-ai's RDDDeliveryDisabledUnmanaged is a single literal
|
|
176
|
+
// "disabled/unmanaged" — never split into two separate enum values.
|
|
177
|
+
delivery: "disabled/unmanaged",
|
|
178
|
+
context: { gate: "pre-commit", lineage_id: "", generation: 0, base_tree: "", candidate_tree: "", paths_digest: "", fix_delta_hash: "", policy_hash: "", ledger_hash: "", evidence_hash: "", base_relationship_valid: true },
|
|
179
|
+
};
|
|
180
|
+
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(body), exitCode: 0 }]);
|
|
181
|
+
const result = await new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "pre-commit" });
|
|
182
|
+
assert.equal(result.result, "invalidated");
|
|
183
|
+
assert.equal(result.allowed, false);
|
|
184
|
+
assert.equal(result.action, "repository-policy");
|
|
185
|
+
assert.equal(result.delivery, "disabled/unmanaged");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("native VALIDATE rejects a split disabled-only or unmanaged-only delivery value: the wire literal is always the combined string", async () => {
|
|
189
|
+
for (const delivery of ["disabled", "unmanaged"]) {
|
|
190
|
+
const body = {
|
|
191
|
+
schema: "gentle-ai.review-gate-result/v1", result: "invalidated", allowed: false, action: "repository-policy",
|
|
192
|
+
reason: "receipt-driven development is disabled and no receipt governs this candidate, so delivery follows ordinary repository policy",
|
|
193
|
+
delivery,
|
|
194
|
+
context: { gate: "pre-commit", lineage_id: "", generation: 0, base_tree: "", candidate_tree: "", paths_digest: "", fix_delta_hash: "", policy_hash: "", ledger_hash: "", evidence_hash: "", base_relationship_valid: true },
|
|
195
|
+
};
|
|
196
|
+
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(body), exitCode: 0 }]);
|
|
197
|
+
await assert.rejects(
|
|
198
|
+
() => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "pre-commit" }),
|
|
199
|
+
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
|
|
200
|
+
`delivery ${JSON.stringify(delivery)} must still fail closed`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("native VALIDATE without delivery keeps the strict exit-code/action pairing unchanged", async () => {
|
|
206
|
+
const body = {
|
|
207
|
+
schema: "gentle-ai.review-gate-result/v1",
|
|
208
|
+
result: "invalidated",
|
|
209
|
+
allowed: false,
|
|
210
|
+
action: "repository-policy",
|
|
211
|
+
reason: "should require exit 1 because delivery is absent",
|
|
212
|
+
context: { gate: "pre-commit", lineage_id: "", generation: 0, base_tree: "", candidate_tree: "", paths_digest: "", fix_delta_hash: "", policy_hash: "", ledger_hash: "", evidence_hash: "", base_relationship_valid: true },
|
|
213
|
+
};
|
|
214
|
+
// Without `delivery`, an "invalidated" result with exit 0 is not the
|
|
215
|
+
// alternate discriminator shape and must still fail closed.
|
|
216
|
+
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(body), exitCode: 0 }]);
|
|
217
|
+
await assert.rejects(
|
|
218
|
+
() => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "pre-commit" }),
|
|
219
|
+
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("START tolerates the exact review-consent-skipped stderr line only when the mode capability is true", async () => {
|
|
224
|
+
const start = JSON.parse(START.stdout) as Record<string, unknown>;
|
|
225
|
+
const notice = "Gentle AI reviewed this change without asking, because this session has no terminal to answer on. Run 'gentle-ai review mode disable' to turn reviews off, or 'gentle-ai review mode status' to see the current setting.";
|
|
226
|
+
|
|
227
|
+
const tolerated = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(start), stderr: notice }]);
|
|
228
|
+
const result = await new NativeReviewCliV213(tolerated.adapter).start({ cwd: "/repo" });
|
|
229
|
+
assert.equal(result.lineageId, "lineage-1");
|
|
230
|
+
|
|
231
|
+
// Same exact stderr text, but the negotiated version's `mode` capability is
|
|
232
|
+
// false (the pinned 2.1.11 row), so the notice must still fail closed.
|
|
233
|
+
const notTolerated = queuedAdapter([DARK_VERSION, { stdout: JSON.stringify(start), stderr: notice }]);
|
|
234
|
+
await assert.rejects(
|
|
235
|
+
() => new NativeReviewCliV213(notTolerated.adapter).start({ cwd: "/repo" }),
|
|
236
|
+
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("START rejects near-miss, prefixed, or extra-line stderr even when the mode capability is true", async () => {
|
|
241
|
+
const start = JSON.parse(START.stdout) as Record<string, unknown>;
|
|
242
|
+
const notice = "Gentle AI reviewed this change without asking, because this session has no terminal to answer on. Run 'gentle-ai review mode disable' to turn reviews off, or 'gentle-ai review mode status' to see the current setting.";
|
|
243
|
+
for (const stderr of [
|
|
244
|
+
`prefix: ${notice}`,
|
|
245
|
+
`${notice}\nextra line`,
|
|
246
|
+
notice.slice(0, -1),
|
|
247
|
+
]) {
|
|
248
|
+
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(start), stderr }]);
|
|
249
|
+
await assert.rejects(
|
|
250
|
+
() => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
|
|
251
|
+
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
|
|
252
|
+
`stderr ${JSON.stringify(stderr)} must still fail closed`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Phase 3 (kill switch) + Phase 4 (consent) integration through the
|
|
259
|
+
// `gentle_review` controller tool and the `gentle:review-mode` command,
|
|
260
|
+
// against a fake NativeReviewCli (no binary needed).
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
function git(cwd: string, ...args: string[]): string {
|
|
264
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function repository(t: test.TestContext): string {
|
|
268
|
+
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-organic-parity-"));
|
|
269
|
+
// A materialized (retained) candidate view chmods its worktree read-only
|
|
270
|
+
// (0o444/0o555) to prevent tampering while lenses run; restore write
|
|
271
|
+
// permission recursively before cleanup so a retained-but-never-cleaned-up
|
|
272
|
+
// candidate view does not leak an unremovable temp directory.
|
|
273
|
+
t.after(() => {
|
|
274
|
+
try { execFileSync("chmod", ["-R", "u+w", cwd]); } catch { /* best effort */ }
|
|
275
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
276
|
+
});
|
|
277
|
+
git(cwd, "init", "-b", "main");
|
|
278
|
+
git(cwd, "config", "user.email", "tests@example.com");
|
|
279
|
+
git(cwd, "config", "user.name", "Tests");
|
|
280
|
+
writeFileSync(join(cwd, "app.ts"), "export const value = 1;\n");
|
|
281
|
+
git(cwd, "add", ".");
|
|
282
|
+
git(cwd, "commit", "-m", "initial");
|
|
283
|
+
writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
|
|
284
|
+
return cwd;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const UNSUPPORTED_REPAIR_ASSESSMENT: AuthorityRepairAssessmentV1 = {
|
|
288
|
+
schema: "gentle-ai.review-authority-repair-assessment/v1",
|
|
289
|
+
status: "unsupported",
|
|
290
|
+
counts: { lineages: 0, compactLineages: 0, legacyLineages: 0, events: 0, bytes: 0, eligibleCandidates: 0, unsupportedLineages: 0, conflicts: 0 },
|
|
291
|
+
supportedOperations: ["review/complete-fix", "review/validate-fix"],
|
|
292
|
+
authorizationSchema: "gentle-ai.review-repair-authorization/v1",
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
function unrelatedStartTargetStatus(): ReviewStatusV3 {
|
|
296
|
+
const sha = `sha256:${"a".repeat(64)}`;
|
|
297
|
+
const tree = "b".repeat(40);
|
|
298
|
+
const projection = {
|
|
299
|
+
schema: "gentle-ai.review-integration.projection/v1" as const,
|
|
300
|
+
kind: "current-changes" as const,
|
|
301
|
+
projection: "workspace" as const,
|
|
302
|
+
baseTree: tree,
|
|
303
|
+
initialReviewTree: tree,
|
|
304
|
+
currentCandidateTree: tree,
|
|
305
|
+
pathsDigest: sha,
|
|
306
|
+
paths: ["app.ts"],
|
|
307
|
+
intendedUntracked: [],
|
|
308
|
+
intendedUntrackedProof: sha,
|
|
309
|
+
initialSnapshotIdentity: sha,
|
|
310
|
+
currentSnapshotIdentity: sha,
|
|
311
|
+
};
|
|
312
|
+
const rawRepair = {
|
|
313
|
+
schema: UNSUPPORTED_REPAIR_ASSESSMENT.schema,
|
|
314
|
+
status: UNSUPPORTED_REPAIR_ASSESSMENT.status,
|
|
315
|
+
counts: {
|
|
316
|
+
lineages: 0, compact_lineages: 0, legacy_lineages: 0, events: 0, bytes: 0,
|
|
317
|
+
eligible_candidates: 0, unsupported_lineages: 0, conflicts: 0,
|
|
318
|
+
},
|
|
319
|
+
supported_operations: UNSUPPORTED_REPAIR_ASSESSMENT.supportedOperations,
|
|
320
|
+
authorization_schema: UNSUPPORTED_REPAIR_ASSESSMENT.authorizationSchema,
|
|
321
|
+
};
|
|
322
|
+
return {
|
|
323
|
+
contract: "gentle-ai.review-integration/v2",
|
|
324
|
+
applicability: "unrelated",
|
|
325
|
+
receipt: { status: "not_applicable" },
|
|
326
|
+
action: "start",
|
|
327
|
+
replayability: "not_replayable",
|
|
328
|
+
targetIdentity: sha,
|
|
329
|
+
projection,
|
|
330
|
+
repair: UNSUPPORTED_REPAIR_ASSESSMENT,
|
|
331
|
+
candidates: [],
|
|
332
|
+
raw: {
|
|
333
|
+
schema: "gentle-ai.review-integration.status/v3", contract: "gentle-ai.review-integration/v2", operation: "review.status",
|
|
334
|
+
applicability: "unrelated", receipt: { status: "not_applicable" }, action: "start", replayability: "not_replayable", target_identity: sha,
|
|
335
|
+
repair: rawRepair,
|
|
336
|
+
projection: { schema: projection.schema, kind: projection.kind, projection: projection.projection, base_tree: tree, initial_review_tree: tree, current_candidate_tree: tree, paths_digest: sha, paths: ["app.ts"], intended_untracked: [], intended_untracked_proof: sha, initial_snapshot_identity: sha, current_snapshot_identity: sha },
|
|
337
|
+
candidates: [],
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
interface FakeOrganicNativeOptions {
|
|
343
|
+
reviewModeCapable?: boolean;
|
|
344
|
+
reviewModeEffective?: "on" | "off";
|
|
345
|
+
reviewModeSource?: "default" | "global" | "clone_local";
|
|
346
|
+
reviewModeThrows?: boolean;
|
|
347
|
+
lensesRequired?: boolean;
|
|
348
|
+
riskEvidence?: readonly string[];
|
|
349
|
+
hint?: string;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function fakeOrganicNative(options: FakeOrganicNativeOptions = {}): { native: NativeReviewCli; state: { startCalls: number } } {
|
|
353
|
+
const state = { startCalls: 0 };
|
|
354
|
+
const reviewModeCapable = options.reviewModeCapable ?? true;
|
|
355
|
+
const native = {
|
|
356
|
+
async start() {
|
|
357
|
+
state.startCalls += 1;
|
|
358
|
+
return {
|
|
359
|
+
lineageId: "native-lineage", state: "reviewing", riskLevel: "high",
|
|
360
|
+
selectedLenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"],
|
|
361
|
+
changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created",
|
|
362
|
+
lensesRequired: options.lensesRequired ?? true,
|
|
363
|
+
...(options.riskEvidence === undefined ? {} : { riskEvidence: options.riskEvidence }),
|
|
364
|
+
...(options.hint === undefined ? {} : { hint: options.hint }),
|
|
365
|
+
};
|
|
366
|
+
},
|
|
367
|
+
async finalize(): Promise<never> { throw new Error("finalize not used in this test"); },
|
|
368
|
+
async validate(): Promise<never> { throw new Error("validate not used in this test"); },
|
|
369
|
+
async bindSdd(): Promise<never> { throw new Error("bindSdd not used in this test"); },
|
|
370
|
+
async sddStatus(): Promise<never> { throw new Error("sddStatus not used in this test"); },
|
|
371
|
+
async reviewStatus(): Promise<never> { throw new Error("reviewStatus not used in this test"); },
|
|
372
|
+
async targetStatus() {
|
|
373
|
+
return unrelatedStartTargetStatus();
|
|
374
|
+
},
|
|
375
|
+
...(reviewModeCapable
|
|
376
|
+
? {
|
|
377
|
+
async reviewMode(request: { operation: string }) {
|
|
378
|
+
if (options.reviewModeThrows === true) throw new Error("native review mode process failed");
|
|
379
|
+
const effective = options.reviewModeEffective ?? "on";
|
|
380
|
+
const source = options.reviewModeSource ?? "default";
|
|
381
|
+
return {
|
|
382
|
+
operation: request.operation,
|
|
383
|
+
scope: "both",
|
|
384
|
+
status: {
|
|
385
|
+
global: source === "global" ? effective : "",
|
|
386
|
+
cloneLocal: source === "clone_local" && effective === "off" ? "off" : "",
|
|
387
|
+
effective,
|
|
388
|
+
source,
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
: {}),
|
|
394
|
+
} as unknown as NativeReviewCli;
|
|
395
|
+
return { native, state };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
interface RegisteredTool {
|
|
399
|
+
execute: (
|
|
400
|
+
toolCallId: string,
|
|
401
|
+
params: unknown,
|
|
402
|
+
signal: AbortSignal | undefined,
|
|
403
|
+
onUpdate: undefined,
|
|
404
|
+
ctx: ExtensionContext,
|
|
405
|
+
) => Promise<{ details?: unknown }>;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
interface RegisteredCommandFixture {
|
|
409
|
+
handler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
interface RegisteredEventFixture {
|
|
413
|
+
(event: unknown, ctx: ExtensionContext): Promise<unknown> | unknown;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function runtime(nativeReviewCli: NativeReviewCli | null): { controller: RegisteredTool; commands: Map<string, RegisteredCommandFixture>; events: Map<string, RegisteredEventFixture> } {
|
|
417
|
+
const tools = new Map<string, RegisteredTool>();
|
|
418
|
+
const commands = new Map<string, RegisteredCommandFixture>();
|
|
419
|
+
const events = new Map<string, RegisteredEventFixture>();
|
|
420
|
+
const dependencies = { nativeReviewCli, candidateViews: new CandidateViewRegistry() } as unknown as Parameters<typeof createGentleAiExtension>[0];
|
|
421
|
+
createGentleAiExtension(dependencies)({
|
|
422
|
+
on(name: string, handler: RegisteredEventFixture) { events.set(name, handler); },
|
|
423
|
+
registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
|
|
424
|
+
registerCommand(name: string, definition: RegisteredCommandFixture) { commands.set(name, definition); },
|
|
425
|
+
} as unknown as ExtensionAPI);
|
|
426
|
+
const controller = tools.get("gentle_review");
|
|
427
|
+
assert.ok(controller);
|
|
428
|
+
return { controller: controller!, commands, events };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function headlessContext(cwd: string, notices: Array<{ message: string; type?: string }> = []): ExtensionContext {
|
|
432
|
+
return { cwd, hasUI: false, ui: { notify: (message: string, type?: string) => { notices.push({ message, type }); } } } as unknown as ExtensionContext;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function confirmContext(cwd: string, answer: boolean): ExtensionContext {
|
|
436
|
+
return { cwd, hasUI: true, ui: { confirm: async () => answer, notify: () => {} } } as unknown as ExtensionContext;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function throwingConfirmContext(cwd: string): ExtensionContext {
|
|
440
|
+
return { cwd, hasUI: true, ui: { confirm: async () => { throw new Error("no answer available"); }, notify: () => {} } } as unknown as ExtensionContext;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const START_ORDINARY = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) };
|
|
444
|
+
|
|
445
|
+
async function execStart(controller: RegisteredTool, id: string, ctx: ExtensionContext): Promise<Record<string, unknown>> {
|
|
446
|
+
const { details } = await controller.execute(id, START_ORDINARY, undefined, undefined, ctx);
|
|
447
|
+
return details as Record<string, unknown>;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
test("kill-switch: effective off returns a non-failure skipped envelope and never calls native start", async (t) => {
|
|
451
|
+
const cwd = repository(t);
|
|
452
|
+
const { native, state } = fakeOrganicNative({ reviewModeEffective: "off" });
|
|
453
|
+
const { controller } = runtime(native);
|
|
454
|
+
const result = await execStart(controller, "kill-switch-off", headlessContext(cwd));
|
|
455
|
+
assert.equal(result.status, "skipped");
|
|
456
|
+
assert.equal(result.outcome, "review-mode-disabled");
|
|
457
|
+
assert.equal(result.delivery, "disabled/unmanaged");
|
|
458
|
+
assert.equal(result.mutation_performed, false);
|
|
459
|
+
assert.equal(state.startCalls, 0, "native start must not be called once the kill switch reports off");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Parity with gentle-ai's RDDDisabledError.Error()
|
|
463
|
+
// (internal/reviewtransaction/rdd_mode.go): a refusal names the situation, the
|
|
464
|
+
// source that actually decided, and a continuation scoped to that source. Pi
|
|
465
|
+
// never blocks here — the envelope is a non-failure skip — but it must not
|
|
466
|
+
// throw away which source decided, nor leave the caller without a way back on.
|
|
467
|
+
test("kill-switch: a clone-local off names the deciding source and the Pi command that turns reviews back on", async (t) => {
|
|
468
|
+
const cwd = repository(t);
|
|
469
|
+
const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "clone_local" });
|
|
470
|
+
const { controller } = runtime(native);
|
|
471
|
+
const result = await execStart(controller, "kill-switch-clone-local", headlessContext(cwd));
|
|
472
|
+
assert.equal(result.status, "skipped");
|
|
473
|
+
assert.equal(result.outcome, "review-mode-disabled");
|
|
474
|
+
assert.equal(result.mode_source, "clone_local");
|
|
475
|
+
assert.equal(result.reason, "receipt-driven development is disabled: start is skipped because the clone_local mode source keeps it off");
|
|
476
|
+
assert.equal(result.next_action, "Run /gentle:review-mode enable to turn reviews back on for this clone.");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// gentle-ai maps RDDModeSourceGlobal onto `--scope=global`, and a clone-local
|
|
480
|
+
// override may only ever disable (cloneLocalRDDOverrideValue rejects RDDModeOn).
|
|
481
|
+
// Pi's own /gentle:review-mode always passes --scope clone, so it CANNOT clear a
|
|
482
|
+
// global off. Naming it here would be naming a dead end, so the continuation has
|
|
483
|
+
// to be the native command that actually resolves it.
|
|
484
|
+
test("kill-switch: a global off names the native global-scope command, never Pi's clone-scope one", async (t) => {
|
|
485
|
+
const cwd = repository(t);
|
|
486
|
+
const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "global" });
|
|
487
|
+
const { controller } = runtime(native);
|
|
488
|
+
const result = await execStart(controller, "kill-switch-global", headlessContext(cwd));
|
|
489
|
+
assert.equal(result.mode_source, "global");
|
|
490
|
+
assert.equal(result.reason, "receipt-driven development is disabled: start is skipped because the global mode source keeps it off");
|
|
491
|
+
assert.equal(
|
|
492
|
+
result.next_action,
|
|
493
|
+
"Run `gentle-ai review mode enable --scope=global` to turn reviews back on; /gentle:review-mode enable only clears the clone-local setting, which cannot override a global off.",
|
|
494
|
+
);
|
|
495
|
+
assert.ok(!/\/gentle:review-mode enable to turn/.test(String(result.next_action)), "a global off must never be sent to Pi's clone-scope command");
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// Parity with reviewModeScopeForSource: the default source expresses no opinion
|
|
499
|
+
// and can never be what keeps reviews off, so it gets no guessed continuation.
|
|
500
|
+
test("kill-switch: an off with the default source names no continuation rather than guessing one", async (t) => {
|
|
501
|
+
const cwd = repository(t);
|
|
502
|
+
const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "default" });
|
|
503
|
+
const { controller } = runtime(native);
|
|
504
|
+
const result = await execStart(controller, "kill-switch-default", headlessContext(cwd));
|
|
505
|
+
assert.equal(result.mode_source, "default");
|
|
506
|
+
assert.equal(result.next_action, undefined);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("kill-switch: capability-absent (no reviewMode) leaves today's path unchanged", async (t) => {
|
|
510
|
+
const cwd = repository(t);
|
|
511
|
+
const { native } = fakeOrganicNative({ reviewModeCapable: false, lensesRequired: false });
|
|
512
|
+
const { controller } = runtime(native);
|
|
513
|
+
const result = await execStart(controller, "kill-switch-absent", headlessContext(cwd));
|
|
514
|
+
assert.notEqual(result.status, "skipped");
|
|
515
|
+
assert.ok(result.result);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("kill-switch: an unexpected reviewMode failure maps to the existing native-operation-failed envelope", async (t) => {
|
|
519
|
+
const cwd = repository(t);
|
|
520
|
+
const { native } = fakeOrganicNative({ reviewModeThrows: true });
|
|
521
|
+
const { controller } = runtime(native);
|
|
522
|
+
const result = await execStart(controller, "kill-switch-error", headlessContext(cwd));
|
|
523
|
+
assert.equal(result.status, "blocked");
|
|
524
|
+
assert.equal(result.outcome, "native-operation-failed");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
function candidateConsent(cwd: string): ReviewConsentV2 {
|
|
528
|
+
const targetIdentity = `sha256:${"a".repeat(64)}`;
|
|
529
|
+
const choices = [
|
|
530
|
+
{ answer: "granted" as const, label: "Run the review now", effect: "Review this exact candidate only.", invocation: `gentle-ai review start --contract gentle-ai.review-integration/v2 --cwd ${cwd} --target ${targetIdentity} --projection workspace --lineage native-lineage --consent granted` },
|
|
531
|
+
{ answer: "declined" as const, label: "Not now, just this once", effect: "Create no authority and ask again next candidate.", invocation: `gentle-ai review start --contract gentle-ai.review-integration/v2 --cwd ${cwd} --target ${targetIdentity} --projection workspace --lineage native-lineage --consent declined` },
|
|
532
|
+
] as const;
|
|
533
|
+
const raw = { schema: "gentle-ai.review-integration.consent/v2", contract: "gentle-ai.review-integration/v2", operation: "review.start", action: "consent_required", blocking: true, target_identity: targetIdentity, projection: "workspace", risk_level: "high", changed_files: 1, changed_lines: 1, headline: "Review this candidate", reason: "It changes a process boundary.", value: "Review catches regressions.", risk_evidence: ["shell process"], choices: choices.map((choice) => ({ ...choice })), off_path: { note: "Disable reviews separately.", command: "gentle-ai review mode disable" } };
|
|
534
|
+
return { schema: "gentle-ai.review-integration.consent/v2", contract: "gentle-ai.review-integration/v2", operation: "review.start", action: "consent_required", blocking: true, targetIdentity, projection: "workspace", riskLevel: "high", changedFiles: 1, changedLines: 1, headline: "Review this candidate", reason: "It changes a process boundary.", value: "Review catches regressions.", riskEvidence: ["shell process"], choices, offPath: { note: "Disable reviews separately.", command: "gentle-ai review mode disable" }, raw };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function relayedConsentNative(cwd: string): { native: NativeReviewCli; answers: NativeReviewConsentAnswer[] } {
|
|
538
|
+
const { native } = fakeOrganicNative();
|
|
539
|
+
const consent = candidateConsent(cwd);
|
|
540
|
+
const answers: NativeReviewConsentAnswer[] = [];
|
|
541
|
+
native.start = async () => { throw new NativeReviewConsentRequiredError(consent); };
|
|
542
|
+
native.answerConsent = async (request) => {
|
|
543
|
+
answers.push(request.answer);
|
|
544
|
+
if (request.answer === "declined") return { kind: "declined", targetIdentity: consent.targetIdentity, projection: "workspace", riskLevel: "high", changedFiles: 1, changedLines: 1, consent: "declined_this_candidate", raw: { operation: "review/start", action: "declined", consent: "declined_this_candidate" } };
|
|
545
|
+
return { kind: "started", start: { lineageId: "native-lineage", state: "reviewing", riskLevel: "high", selectedLenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true } };
|
|
546
|
+
};
|
|
547
|
+
return { native, answers };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function answerConsent(controller: RegisteredTool, binding: unknown, answer: unknown, ctx: ExtensionContext): Promise<Record<string, unknown>> {
|
|
551
|
+
const { details } = await controller.execute("consent-answer", { operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer }) }, undefined, undefined, ctx);
|
|
552
|
+
return details as Record<string, unknown>;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function blockedConsent(controller: RegisteredTool, id: string, ctx: ExtensionContext): Promise<Record<string, unknown>> {
|
|
556
|
+
const result = await execStart(controller, id, ctx);
|
|
557
|
+
assert.equal(result.status, "blocked");
|
|
558
|
+
assert.equal(result.outcome, "native-review-consent-required");
|
|
559
|
+
assert.equal(typeof result.consent_binding, "string");
|
|
560
|
+
return result;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
test("consent relay returns the identical complete parent-visible envelope with or without UI and never answers internally", async (t) => {
|
|
564
|
+
const cwd = repository(t);
|
|
565
|
+
recordReviewConsentLatch(cwd);
|
|
566
|
+
for (const ctx of [
|
|
567
|
+
{ cwd, hasUI: true, ui: { select: async () => { throw new Error("internal consent UI must not run"); }, notify: () => {} } } as unknown as ExtensionContext,
|
|
568
|
+
headlessContext(cwd),
|
|
569
|
+
]) {
|
|
570
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
571
|
+
const result = await blockedConsent(runtime(native).controller, "consent-blocked", ctx);
|
|
572
|
+
assert.deepEqual(result.consent, candidateConsent(cwd).raw);
|
|
573
|
+
assert.deepEqual(answers, []);
|
|
574
|
+
assert.equal(result.lineage_created, false);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("explicit consent follow-up grants or declines exactly once", async (t) => {
|
|
579
|
+
const cwd = repository(t);
|
|
580
|
+
for (const answer of ["granted", "declined"] as const) {
|
|
581
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
582
|
+
const { controller } = runtime(native);
|
|
583
|
+
const blocked = await blockedConsent(controller, `consent-${answer}`, headlessContext(cwd));
|
|
584
|
+
const result = await answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd));
|
|
585
|
+
assert.deepEqual(answers, [answer]);
|
|
586
|
+
if (answer === "granted") assert.ok(result.actor_binding);
|
|
587
|
+
else {
|
|
588
|
+
assert.equal(result.outcome, "consent-declined-this-candidate");
|
|
589
|
+
assert.equal(result.lineage_created, false);
|
|
590
|
+
assert.equal(result.actor_binding, undefined);
|
|
591
|
+
}
|
|
592
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("consent follow-up rejects invalid token, unknown id, changed cwd, and changed target binding", async (t) => {
|
|
597
|
+
const cwd = repository(t);
|
|
598
|
+
const consent = candidateConsent(cwd);
|
|
599
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
600
|
+
native.start = async () => { throw new NativeReviewConsentRequiredError(consent); };
|
|
601
|
+
const { controller } = runtime(native);
|
|
602
|
+
const blocked = await blockedConsent(controller, "consent-invalid", headlessContext(cwd));
|
|
603
|
+
await assert.rejects(() => controller.execute("malformed", { operation: "answer-consent", input: "not-json" }, undefined, undefined, headlessContext(cwd)), /input is not valid JSON/);
|
|
604
|
+
await assert.rejects(() => controller.execute("extra", { operation: "answer-consent", input: JSON.stringify({ consentBinding: blocked.consent_binding, answer: "granted", target: "substitute" }) }, undefined, undefined, headlessContext(cwd)), /exactly consentBinding and answer/);
|
|
605
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "yes", headlessContext(cwd)), /answer must be granted or declined/);
|
|
606
|
+
await assert.rejects(() => answerConsent(controller, "missing", "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
607
|
+
const other = repository(t);
|
|
608
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(other)), /repository binding changed/);
|
|
609
|
+
const originalTarget = consent.targetIdentity;
|
|
610
|
+
(consent as { targetIdentity: string }).targetIdentity = `sha256:${"b".repeat(64)}`;
|
|
611
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
|
|
612
|
+
(consent as { targetIdentity: string }).targetIdentity = originalTarget;
|
|
613
|
+
(consent as { projection: string }).projection = "staged";
|
|
614
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
|
|
615
|
+
(consent as { projection: string }).projection = "workspace";
|
|
616
|
+
(consent.choices[0] as { invocation: string }).invocation = consent.choices[0].invocation.replace("native-lineage", "changed-lineage");
|
|
617
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
|
|
618
|
+
assert.deepEqual(answers, []);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("ambiguous consent mutation consumes the one-shot binding and requires status instead of blind replay", async (t) => {
|
|
622
|
+
const cwd = repository(t);
|
|
623
|
+
const { native } = relayedConsentNative(cwd);
|
|
624
|
+
let statusCalls = 0;
|
|
625
|
+
const targetStatus = native.targetStatus!;
|
|
626
|
+
native.targetStatus = async (request) => { statusCalls += 1; return await targetStatus(request); };
|
|
627
|
+
native.answerConsent = async () => { throw Object.assign(new Error("ambiguous"), { mutationOutcome: "unknown", nextAction: "review.status" }); };
|
|
628
|
+
const { controller } = runtime(native);
|
|
629
|
+
const blocked = await blockedConsent(controller, "consent-ambiguous", headlessContext(cwd));
|
|
630
|
+
await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
|
|
631
|
+
assert.equal(statusCalls, 2, "ambiguous consent must reconcile through target status");
|
|
632
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("session shutdown clears pending candidate consent bindings and is idempotent", async (t) => {
|
|
636
|
+
const cwd = repository(t);
|
|
637
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
638
|
+
const { controller, events } = runtime(native);
|
|
639
|
+
const blocked = await blockedConsent(controller, "consent-before-shutdown", headlessContext(cwd));
|
|
640
|
+
const shutdown = events.get("session_shutdown");
|
|
641
|
+
assert.ok(shutdown);
|
|
642
|
+
await shutdown({}, headlessContext(cwd));
|
|
643
|
+
await shutdown({}, headlessContext(cwd));
|
|
644
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
645
|
+
assert.deepEqual(answers, []);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("successful explicit disable clears pending candidate consent binding even after re-enable", async (t) => {
|
|
649
|
+
const cwd = repository(t);
|
|
650
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
651
|
+
let effective: "on" | "off" = "on";
|
|
652
|
+
native.reviewMode = async (request) => {
|
|
653
|
+
if (request.operation === "disable") effective = "off";
|
|
654
|
+
if (request.operation === "enable") effective = "on";
|
|
655
|
+
return { operation: request.operation, scope: "clone", status: { global: "", cloneLocal: effective === "off" ? "off" : "", effective, source: effective === "off" ? "clone_local" : "default" } };
|
|
656
|
+
};
|
|
657
|
+
const { controller, commands } = runtime(native);
|
|
658
|
+
const blocked = await blockedConsent(controller, "consent-before-disable", headlessContext(cwd));
|
|
659
|
+
const command = commands.get("gentle:review-mode")!;
|
|
660
|
+
await command.handler("disable", headlessContext(cwd));
|
|
661
|
+
await command.handler("enable", headlessContext(cwd));
|
|
662
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
663
|
+
assert.deepEqual(answers, []);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("candidate-scoped decline creates no authority and the next candidate asks again", async (t) => {
|
|
667
|
+
const cwd = repository(t);
|
|
668
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
669
|
+
const { controller } = runtime(native);
|
|
670
|
+
for (const id of ["decline-one", "decline-two"]) {
|
|
671
|
+
const blocked = await blockedConsent(controller, id, headlessContext(cwd));
|
|
672
|
+
const result = await answerConsent(controller, blocked.consent_binding, "declined", headlessContext(cwd));
|
|
673
|
+
assert.equal(result.outcome, "consent-declined-this-candidate");
|
|
674
|
+
assert.equal(result.lineage_created, false);
|
|
675
|
+
assert.equal(result.actor_binding, undefined);
|
|
676
|
+
}
|
|
677
|
+
assert.deepEqual(answers, ["declined", "declined"]);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("low-risk zero-lens START remains silent", async (t) => {
|
|
681
|
+
const cwd = repository(t);
|
|
682
|
+
const { native } = fakeOrganicNative({ riskEvidence: undefined, lensesRequired: false });
|
|
683
|
+
const { controller } = runtime(native);
|
|
684
|
+
let selected = false;
|
|
685
|
+
const ctx = { cwd, hasUI: true, ui: { select: async () => { selected = true; return undefined; }, notify: () => {} } } as unknown as ExtensionContext;
|
|
686
|
+
const result = await execStart(controller, "consent-low-risk", ctx);
|
|
687
|
+
assert.equal(selected, false);
|
|
688
|
+
assert.ok(result.result);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
// Phase 5 (tier/hint/delivery passthrough): mapNativeStartResult renders
|
|
693
|
+
// risk_tier/risk_evidence/hint verbatim from the native start result, with
|
|
694
|
+
// zero local derivation (Design Decision #8, organic-rdd-parity).
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
test("mapNativeStartResult passes risk_evidence through verbatim as the native phrase array, alongside the unmodified risk_tier passthrough", async (t) => {
|
|
698
|
+
const cwd = repository(t);
|
|
699
|
+
const { native } = fakeOrganicNative({ riskEvidence: ["shell scripting in .github/workflows/deploy.yml"] });
|
|
700
|
+
const { controller } = runtime(native);
|
|
701
|
+
const result = await execStart(controller, "risk-evidence-passthrough", confirmContext(cwd, true));
|
|
702
|
+
const rendered = result.result as Record<string, unknown>;
|
|
703
|
+
assert.equal(rendered.risk_tier, "high", "risk_tier is native's riskLevel, verbatim, with no local recomputation");
|
|
704
|
+
assert.deepEqual(rendered.risk_evidence, ["shell scripting in .github/workflows/deploy.yml"]);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("mapNativeStartResult surfaces the empty-candidate hint verbatim and omits risk_evidence when the native result carries none", async (t) => {
|
|
708
|
+
const cwd = repository(t);
|
|
709
|
+
const { native } = fakeOrganicNative({
|
|
710
|
+
lensesRequired: false,
|
|
711
|
+
riskEvidence: undefined,
|
|
712
|
+
hint: "the candidate has no pending changes; already-committed work can be reviewed by rerunning review start with --base-ref <commit> naming the base to compare against",
|
|
713
|
+
});
|
|
714
|
+
const { controller } = runtime(native);
|
|
715
|
+
const result = await execStart(controller, "hint-passthrough", headlessContext(cwd));
|
|
716
|
+
const rendered = result.result as Record<string, unknown>;
|
|
717
|
+
assert.equal(rendered.hint, "the candidate has no pending changes; already-committed work can be reviewed by rerunning review start with --base-ref <commit> naming the base to compare against");
|
|
718
|
+
assert.equal(rendered.risk_evidence, undefined, "no local risk_evidence is ever fabricated when the native result omits it");
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("mapNativeStartResult never fabricates risk_evidence or hint when the native result omits both", async (t) => {
|
|
722
|
+
const cwd = repository(t);
|
|
723
|
+
const { native } = fakeOrganicNative({ riskEvidence: undefined });
|
|
724
|
+
const { controller } = runtime(native);
|
|
725
|
+
const result = await execStart(controller, "no-evidence-no-hint", confirmContext(cwd, true));
|
|
726
|
+
const rendered = result.result as Record<string, unknown>;
|
|
727
|
+
assert.equal(rendered.risk_evidence, undefined);
|
|
728
|
+
assert.equal(rendered.hint, undefined);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("gentle:review-mode command reports status, disables, and enables through explicit user invocation", async (t) => {
|
|
732
|
+
const cwd = repository(t);
|
|
733
|
+
const { native } = fakeOrganicNative({ reviewModeEffective: "off" });
|
|
734
|
+
const { commands } = runtime(native);
|
|
735
|
+
const command = commands.get("gentle:review-mode");
|
|
736
|
+
assert.ok(command, "gentle:review-mode must be registered");
|
|
737
|
+
const notices: Array<{ message: string; type?: string }> = [];
|
|
738
|
+
await command!.handler("status", headlessContext(cwd, notices) as unknown as ExtensionContext);
|
|
739
|
+
assert.ok(notices.at(-1)?.message.includes("off"));
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
// Ground-truthed against a real gentle-ai build: with the global source off,
|
|
743
|
+
// `review mode enable --scope clone` exits 0, reports operation "enable", and
|
|
744
|
+
// leaves effective "off" — because cloneLocalRDDOverrideValue maps "on" onto
|
|
745
|
+
// "inherit" and a clone-local override may only ever disable. Pi hard-codes
|
|
746
|
+
// --scope clone by design, so this request can never succeed; reporting the
|
|
747
|
+
// bare status would let the user believe /gentle:review-mode enable is the way
|
|
748
|
+
// back on when it is a dead end.
|
|
749
|
+
test("gentle:review-mode: an enable that cannot take effect says so and names the command that can", async (t) => {
|
|
750
|
+
const cwd = repository(t);
|
|
751
|
+
const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "global" });
|
|
752
|
+
const { commands } = runtime(native);
|
|
753
|
+
const command = commands.get("gentle:review-mode")!;
|
|
754
|
+
const notices: Array<{ message: string; type?: string }> = [];
|
|
755
|
+
await command.handler("enable", headlessContext(cwd, notices) as unknown as ExtensionContext);
|
|
756
|
+
const notice = notices.at(-1)!;
|
|
757
|
+
assert.equal(notice.type, "warning", "a request that did not take effect is not an informational result");
|
|
758
|
+
assert.equal(
|
|
759
|
+
notice.message,
|
|
760
|
+
"receipt-driven development: off (decided by global)\nThat did not turn reviews back on: /gentle:review-mode only sets clone scope, and a clone-local setting can never override a global off. Run `gentle-ai review mode enable --scope=global` to turn them back on.",
|
|
761
|
+
);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("gentle:review-mode: an enable that does take effect keeps the existing informational report", async (t) => {
|
|
765
|
+
const cwd = repository(t);
|
|
766
|
+
const { native } = fakeOrganicNative({ reviewModeEffective: "on", reviewModeSource: "default" });
|
|
767
|
+
const { commands } = runtime(native);
|
|
768
|
+
const command = commands.get("gentle:review-mode")!;
|
|
769
|
+
const notices: Array<{ message: string; type?: string }> = [];
|
|
770
|
+
await command.handler("enable", headlessContext(cwd, notices) as unknown as ExtensionContext);
|
|
771
|
+
assert.deepEqual(notices, [{ message: "receipt-driven development: on (decided by default)", type: "info" }]);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test("gentle:review-mode command reports unavailability without throwing when the capability is dark", async (t) => {
|
|
775
|
+
const cwd = repository(t);
|
|
776
|
+
const { native } = fakeOrganicNative({ reviewModeCapable: false });
|
|
777
|
+
const { commands } = runtime(native);
|
|
778
|
+
const command = commands.get("gentle:review-mode")!;
|
|
779
|
+
const notices: Array<{ message: string; type?: string }> = [];
|
|
780
|
+
await command.handler("status", headlessContext(cwd, notices) as unknown as ExtensionContext);
|
|
781
|
+
assert.equal(notices.length, 1);
|
|
782
|
+
});
|