gentle-pi 2.2.0 → 2.3.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 +51 -80
- package/assets/agents/review-readability.md +0 -2
- package/assets/agents/review-reliability.md +0 -2
- package/assets/agents/review-resilience.md +0 -2
- package/assets/agents/review-risk.md +0 -2
- package/assets/agents/sdd-apply.md +5 -3
- package/assets/agents/sdd-proposal.md +2 -0
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-status.md +4 -4
- package/assets/agents/sdd-tasks.md +2 -3
- package/assets/agents/sdd-verify.md +21 -1
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +54 -248
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +22 -38
- package/assets/sdd-orchestrator-workflow.md +30 -37
- package/assets/support/sdd-status-contract.md +7 -7
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
- package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
- package/docs/native-authority-architecture.md +9 -11
- package/docs/review-integration.md +27 -373
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +1778 -3673
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +4 -8
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +372 -940
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/review-candidate-view.ts +341 -132
- package/lib/review-host-relay.ts +210 -68
- package/lib/review-integration-v2.ts +839 -307
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/sdd-preflight.ts +177 -66
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -82
- package/runtime/gentle-ai-binary.mjs +1 -1
- package/runtime/native-review-cli.mjs +322 -890
- package/runtime/review-integration-v2.mjs +789 -257
- package/runtime/review-relay-contract.mjs +1 -1
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
- package/scripts/gentle-ai-installer.mjs +75 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
- package/scripts/test-packed-runner.mjs +4 -7
- package/scripts/verify-package-files.mjs +12 -13
- package/skills/_shared/review-ledger-contract.md +8 -14
- package/skills/chained-pr/SKILL.md +3 -0
- package/skills/cognitive-doc-design/SKILL.md +1 -1
- package/skills/comment-writer/SKILL.md +1 -1
- package/skills/gentle-ai/SKILL.md +7 -74
- package/skills/judgment-day/SKILL.md +5 -7
- package/skills/rdd-defect-workflow/SKILL.md +3 -3
- package/skills/release/SKILL.md +3 -3
- package/skills/skill-registry/SKILL.md +1 -1
- package/skills/work-unit-commits/SKILL.md +3 -1
- package/tests/artifact-language.test.ts +24 -11
- package/tests/ask-user-choice.test.ts +264 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +15 -1168
- package/tests/delegated-key-learnings-contract.test.ts +8 -6
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
- package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
- package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary.test.ts +51 -228
- package/tests/gentle-ai-installer.test.ts +137 -47
- package/tests/gentle-ai.test.ts +820 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/maintainer/provider-relay.maintest.ts +340 -4
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +28 -1
- package/tests/native-review-cli.test.ts +586 -962
- package/tests/native-review-consent.test.ts +150 -66
- package/tests/native-review-parity-runtime.test.ts +80 -359
- package/tests/native-review-parity.test.ts +674 -925
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +132 -88
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +37 -35
- package/tests/provider-defect-handoff.test.ts +15 -118
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +409 -3
- package/tests/review-compact-contract.test.ts +4 -30
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +363 -1450
- package/tests/review-controller-native-routing.test.ts +1251 -5642
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +222 -71
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +112 -153
- package/tests/review-dispatch-hydration-gap.test.ts +1 -53
- package/tests/review-gate.test.ts +0 -45
- package/tests/review-host-relay-restart-parity.test.ts +360 -0
- package/tests/review-host-relay-routing.test.ts +117 -82
- package/tests/review-host-relay.test.ts +242 -8
- package/tests/review-integration-v2-forward.test.ts +576 -553
- package/tests/review-integration-v2.test.ts +239 -165
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +97 -35
- package/tests/review-recovered-lineage-routing.test.ts +0 -47
- package/tests/review-relay-transport-agent.test.ts +119 -46
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +361 -196
- package/tests/sdd-agent-tools.test.ts +36 -0
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +5 -8
- package/tests/writer-edit-surface-scope.test.ts +230 -0
- package/themes/Gentleman-Cute.json +94 -0
- package/themes/Gentleman-Sexy.json +92 -0
- package/lib/git-commit-transaction.ts +0 -861
- package/runtime/git-commit-transaction.mjs +0 -862
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
- package/tests/git-commit-transaction.test.ts +0 -530
|
@@ -1,1051 +1,800 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { mkdtempSync, realpathSync, rmSync,
|
|
3
|
+
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
8
|
+
import { __testing, createGentleAiExtension, PendingReviewConsentRegistry } from "../extensions/gentle-ai.ts";
|
|
9
|
+
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
9
10
|
import {
|
|
10
11
|
NATIVE_REVIEW_ERROR_CODE,
|
|
11
|
-
|
|
12
|
+
NATIVE_REVIEW_MODE_OPERATION,
|
|
13
|
+
NATIVE_REVIEW_OPERATION,
|
|
12
14
|
NativeReviewCliError,
|
|
13
|
-
|
|
15
|
+
NativeReviewCliV216,
|
|
16
|
+
NativeReviewIntegrationError,
|
|
14
17
|
NativeReviewConsentRequiredError,
|
|
15
|
-
|
|
18
|
+
nativeRiskEvidencePhrases,
|
|
16
19
|
normalizeNativeReviewCwd,
|
|
17
|
-
setNativeCliContractForTesting,
|
|
18
|
-
type ExecFileAdapter,
|
|
19
20
|
type NativeReviewCli,
|
|
20
|
-
type
|
|
21
|
-
type NativeReviewConsentAnswerRequest,
|
|
22
|
-
type NativeStartRequest,
|
|
21
|
+
type ExecFileAdapter,
|
|
23
22
|
} from "../lib/native-review-cli.ts";
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
import {
|
|
24
|
+
decodeReviewConsentV3,
|
|
25
|
+
decodeReviewFailureV2,
|
|
26
|
+
decodeReviewLastEventClosureV1,
|
|
27
|
+
decodeReviewStartV3,
|
|
28
|
+
type ReviewStatusV3,
|
|
29
|
+
} from "../lib/review-integration-v2.ts";
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
// absolute package-local path so these tests do not depend on an installed
|
|
30
|
-
// binary.
|
|
31
|
-
class NativeReviewCliV213 extends NativeReviewCliV213Production {
|
|
32
|
-
constructor(...parameters: ConstructorParameters<typeof NativeReviewCliV213Production>) {
|
|
33
|
-
const [adapter, executable, ...rest] = parameters;
|
|
34
|
-
super(adapter, executable ?? "/package/.gentle-ai/gentle-ai", ...rest);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
31
|
+
const CAPTURED_FIXTURES = join(process.cwd(), "tests", "fixtures", "devbinary");
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
stderr?: string;
|
|
41
|
-
exitCode?: number;
|
|
42
|
-
timedOut?: boolean;
|
|
43
|
-
signal?: NodeJS.Signals | null;
|
|
44
|
-
outputLimitExceeded?: boolean;
|
|
33
|
+
function captured(name: string): unknown {
|
|
34
|
+
return JSON.parse(readFileSync(join(CAPTURED_FIXTURES, name), "utf8"));
|
|
45
35
|
}
|
|
46
36
|
|
|
47
|
-
|
|
48
|
-
const
|
|
37
|
+
test("captured native start and terminal capture decode as one last-event lifecycle", () => {
|
|
38
|
+
const start = decodeReviewStartV3(captured("start-v3-zero-lens-closed.captured.json"));
|
|
39
|
+
const closure = decodeReviewLastEventClosureV1(captured("last-event-capture-result-approved.captured.json"));
|
|
40
|
+
assert.equal(start.action, "closed");
|
|
41
|
+
assert.equal(start.state, "approved");
|
|
42
|
+
assert.equal(closure.operation, "review/capture-result");
|
|
43
|
+
assert.equal(closure.state, "approved");
|
|
44
|
+
assert.notEqual(closure.operation, "review.finalize");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function queuedAdapter(stdout: readonly string[]): { adapter: ExecFileAdapter; calls: Array<readonly string[]> } {
|
|
48
|
+
const queue = [...stdout];
|
|
49
|
+
const calls: Array<readonly string[]> = [];
|
|
49
50
|
return {
|
|
50
51
|
calls,
|
|
51
52
|
adapter: async (request) => {
|
|
52
|
-
calls.push(request);
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
55
|
-
return {
|
|
56
|
-
stdout: result.stdout,
|
|
57
|
-
stderr: result.stderr ?? "",
|
|
58
|
-
exitCode: result.exitCode ?? 0,
|
|
59
|
-
signal: result.signal ?? null,
|
|
60
|
-
timedOut: result.timedOut ?? false,
|
|
61
|
-
outputLimitExceeded: result.outputLimitExceeded ?? false,
|
|
62
|
-
};
|
|
53
|
+
calls.push(request.arguments);
|
|
54
|
+
const body = queue.shift();
|
|
55
|
+
if (body === undefined) throw new Error("unexpected native invocation");
|
|
56
|
+
return { stdout: body, stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
63
57
|
},
|
|
64
58
|
};
|
|
65
59
|
}
|
|
66
60
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
setNativeCliContractForTesting(CAPABLE_VERSION, {
|
|
77
|
-
start: true, finalize: true, validate: true, bindSdd: true, sddStatus: true, status: true, inventory: true,
|
|
78
|
-
reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true,
|
|
79
|
-
mode: true, riskEvidence: true, hint: true, delivery: true,
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
test.afterEach(() => {
|
|
83
|
-
setNativeCliContractForTesting(CAPABLE_VERSION, undefined);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
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" }) };
|
|
87
|
-
|
|
88
|
-
function reviewModeStatusBody(effective: "on" | "off", overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
89
|
-
return {
|
|
61
|
+
function mode(
|
|
62
|
+
operation: "status" | "enable" | "disable",
|
|
63
|
+
effective: "on" | "off",
|
|
64
|
+
source: "default" | "global" | "clone_local",
|
|
65
|
+
reach?: "machine" | "this_build" | "future",
|
|
66
|
+
): string {
|
|
67
|
+
const global = source === "global" ? (effective === "on" ? "on" : "off") : source === "clone_local" ? "on" : "";
|
|
68
|
+
const cloneLocal = source === "clone_local" ? "off" : "";
|
|
69
|
+
return JSON.stringify({
|
|
90
70
|
schema: "gentle-ai.review-mode/v1",
|
|
91
|
-
operation
|
|
92
|
-
scope: "
|
|
93
|
-
status: {
|
|
94
|
-
|
|
95
|
-
global: "",
|
|
96
|
-
clone_local: "",
|
|
97
|
-
effective,
|
|
98
|
-
source: "default",
|
|
99
|
-
...overrides,
|
|
100
|
-
},
|
|
101
|
-
};
|
|
71
|
+
operation,
|
|
72
|
+
scope: "clone",
|
|
73
|
+
status: { schema: "gentle-ai.rdd-mode-status/v1", global, clone_local: cloneLocal, effective, source, ...(reach === undefined ? {} : { reach }) },
|
|
74
|
+
});
|
|
102
75
|
}
|
|
103
76
|
|
|
104
|
-
test("
|
|
105
|
-
const queue = queuedAdapter([
|
|
106
|
-
await
|
|
107
|
-
() => new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" }),
|
|
108
|
-
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE,
|
|
109
|
-
);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("reviewMode status uses the exact fixed argv and decodes the effective mode", async () => {
|
|
113
|
-
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("on")) }]);
|
|
114
|
-
const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" });
|
|
115
|
-
assert.deepEqual(queue.calls[1]?.arguments, ["review", "mode", "status", "--cwd", "/repo", "--json"]);
|
|
116
|
-
assert.equal(result.status.effective, "on");
|
|
117
|
-
assert.equal(result.scope, "both");
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("reviewMode canonicalizes an existing repository cwd before the version probe and status argv", async (t) => {
|
|
121
|
-
if (process.platform === "win32") return t.skip("directory symlink creation requires elevated Windows privileges");
|
|
122
|
-
const repository = mkdtempSync(join(tmpdir(), "gentle-pi-review-mode-cwd-"));
|
|
123
|
-
const alias = `${repository}-alias`;
|
|
124
|
-
symlinkSync(repository, alias, "dir");
|
|
125
|
-
t.after(() => { rmSync(alias, { force: true }); rmSync(repository, { recursive: true, force: true }); });
|
|
126
|
-
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("off", { global: "off", source: "global" })) }]);
|
|
127
|
-
const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: alias, operation: "status" });
|
|
77
|
+
test("review mode STATUS keeps the provider-decided default-off source", async () => {
|
|
78
|
+
const queue = queuedAdapter([mode("status", "off", "default")]);
|
|
79
|
+
const result = await new NativeReviewCliV216(queue.adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.STATUS });
|
|
128
80
|
assert.equal(result.status.effective, "off");
|
|
129
|
-
assert.equal(
|
|
130
|
-
assert.deepEqual(queue.calls[
|
|
81
|
+
assert.equal(result.status.source, "default");
|
|
82
|
+
assert.deepEqual(queue.calls[0], ["review", "mode", "status", "--cwd", process.cwd(), "--json"]);
|
|
131
83
|
});
|
|
132
84
|
|
|
133
|
-
test("
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
assert.equal(
|
|
137
|
-
assert.equal(
|
|
85
|
+
test("review mode mutations remain clone-scoped and retain their exact operation discriminator", async () => {
|
|
86
|
+
const queue = queuedAdapter([mode("disable", "off", "clone_local"), mode("enable", "on", "global")]);
|
|
87
|
+
const review = new NativeReviewCliV216(queue.adapter, "/package/.gentle-ai/gentle-ai");
|
|
88
|
+
assert.equal((await review.reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.DISABLE })).operation, "disable");
|
|
89
|
+
assert.equal((await review.reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.ENABLE })).operation, "enable");
|
|
90
|
+
assert.deepEqual(queue.calls.map((arguments_) => arguments_.slice(-3)), [["--scope", "clone", "--json"], ["--scope", "clone", "--json"]]);
|
|
138
91
|
});
|
|
139
92
|
|
|
140
|
-
test("
|
|
141
|
-
const queue = queuedAdapter([
|
|
142
|
-
const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" });
|
|
143
|
-
assert.equal(result.status.effective, "off");
|
|
144
|
-
assert.equal(result.status.source, "clone_local");
|
|
145
|
-
assert.equal(result.status.cloneLocal, "off");
|
|
146
|
-
assert.equal(result.status.revision, "sha256:deadbeef");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("reviewMode enable and disable pass --scope clone and mutate without a timeout", async () => {
|
|
150
|
-
const queue = queuedAdapter([
|
|
151
|
-
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" } }) },
|
|
152
|
-
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" } }) },
|
|
153
|
-
]);
|
|
154
|
-
const client = new NativeReviewCliV213(queue.adapter);
|
|
155
|
-
const disabled = await client.reviewMode({ cwd: "/repo", operation: "disable" });
|
|
156
|
-
const enabled = await client.reviewMode({ cwd: "/repo", operation: "enable" });
|
|
157
|
-
assert.deepEqual(queue.calls[1]?.arguments, ["review", "mode", "disable", "--cwd", "/repo", "--scope", "clone", "--json"]);
|
|
158
|
-
assert.deepEqual(queue.calls[3]?.arguments, ["review", "mode", "enable", "--cwd", "/repo", "--scope", "clone", "--json"]);
|
|
159
|
-
assert.equal(disabled.status.effective, "off");
|
|
160
|
-
assert.equal(enabled.status.effective, "on");
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("reviewMode rejects a response whose operation discriminator does not match the request", async () => {
|
|
164
|
-
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("on")) }]);
|
|
93
|
+
test("review mode rejects a response with a foreign operation discriminator", async () => {
|
|
94
|
+
const queue = queuedAdapter([mode("enable", "on", "global")]);
|
|
165
95
|
await assert.rejects(
|
|
166
|
-
() => new
|
|
167
|
-
|
|
96
|
+
() => new NativeReviewCliV216(queue.adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.STATUS }),
|
|
97
|
+
/schema incompatible/,
|
|
168
98
|
);
|
|
169
99
|
});
|
|
170
100
|
|
|
171
|
-
test("
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
assert.deepEqual(result.riskEvidence, ["service credentials in .env.example"]);
|
|
176
|
-
assert.equal(result.hint, "no reviewable candidate: every changed path is documentation.");
|
|
177
|
-
|
|
178
|
-
const withoutEvidence = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: START.stdout }]);
|
|
179
|
-
const bare = await new NativeReviewCliV213(withoutEvidence.adapter).start({ cwd: "/repo" });
|
|
180
|
-
assert.equal(bare.riskEvidence, undefined);
|
|
181
|
-
assert.equal(bare.hint, undefined);
|
|
101
|
+
test("all terminal capture fixtures retain exact, non-interchangeable operation identities", () => {
|
|
102
|
+
assert.equal(decodeReviewLastEventClosureV1(captured("last-event-capture-refuter-approved.captured.json")).operation, "review.capture-refuter");
|
|
103
|
+
assert.equal(decodeReviewLastEventClosureV1(captured("last-event-capture-validation-approved.captured.json")).operation, "review/capture-validation");
|
|
104
|
+
assert.equal(decodeReviewLastEventClosureV1(captured("last-event-capture-result-approved.captured.json")).operation, "review/capture-result");
|
|
182
105
|
});
|
|
183
106
|
|
|
184
|
-
test("native
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
);
|
|
107
|
+
test("native risk evidence derives only published medium and high risk phrases", () => {
|
|
108
|
+
assert.deepEqual(nativeRiskEvidencePhrases("low", [{ code: "process_boundary", path: "runner.ts" }]), []);
|
|
109
|
+
assert.deepEqual(nativeRiskEvidencePhrases("medium", [{ code: "process_boundary", path: "runner.ts" }]), [
|
|
110
|
+
"this change is not purely passive documentation, so it gets one consolidated review.",
|
|
111
|
+
"code that starts other processes in runner.ts",
|
|
112
|
+
]);
|
|
113
|
+
assert.deepEqual(nativeRiskEvidencePhrases("high", [{ code: "hot_path", signal: "auth", path: "auth.ts" }, { code: "unknown", path: "ignored.ts" }]), ["authentication in auth.ts"]);
|
|
191
114
|
});
|
|
192
115
|
|
|
193
|
-
test("native
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
allowed: false,
|
|
198
|
-
action: "repository-policy",
|
|
199
|
-
reason: "receipt-driven development is disabled and no receipt governs this candidate, so delivery follows ordinary repository policy",
|
|
200
|
-
// gentle-ai's RDDDeliveryDisabledUnmanaged is a single literal
|
|
201
|
-
// "disabled/unmanaged" — never split into two separate enum values.
|
|
202
|
-
delivery: "disabled/unmanaged",
|
|
203
|
-
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 },
|
|
204
|
-
};
|
|
205
|
-
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(body), exitCode: 0 }]);
|
|
206
|
-
const result = await new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "pre-commit" });
|
|
207
|
-
assert.equal(result.result, "invalidated");
|
|
208
|
-
assert.equal(result.allowed, false);
|
|
209
|
-
assert.equal(result.action, "repository-policy");
|
|
210
|
-
assert.equal(result.delivery, "disabled/unmanaged");
|
|
116
|
+
test("Windows native cwd normalization preserves drive identity without changing POSIX paths", () => {
|
|
117
|
+
assert.equal(normalizeNativeReviewCwd("/c/Users/example/repo", "win32"), "C:\\Users\\example\\repo");
|
|
118
|
+
assert.equal(normalizeNativeReviewCwd("c:\\Users\\example\\repo", "win32"), "C:\\Users\\example\\repo");
|
|
119
|
+
assert.equal(normalizeNativeReviewCwd("/repo with spaces", "linux"), "/repo with spaces");
|
|
211
120
|
});
|
|
212
121
|
|
|
213
|
-
test("
|
|
214
|
-
for (const
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
delivery,
|
|
219
|
-
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 },
|
|
220
|
-
};
|
|
221
|
-
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(body), exitCode: 0 }]);
|
|
222
|
-
await assert.rejects(
|
|
223
|
-
() => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "pre-commit" }),
|
|
224
|
-
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
|
|
225
|
-
`delivery ${JSON.stringify(delivery)} must still fail closed`,
|
|
226
|
-
);
|
|
122
|
+
test("current review mode keeps canonical reach values and rejects unrecognized wire values", async (t) => {
|
|
123
|
+
for (const reach of ["machine", "this_build"] as const) {
|
|
124
|
+
const queue = queuedAdapter([mode("status", "off", "clone_local", reach)]);
|
|
125
|
+
const result = await new NativeReviewCliV216(queue.adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.STATUS });
|
|
126
|
+
assert.equal(result.status.reach, reach);
|
|
227
127
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
test("native VALIDATE without delivery keeps the strict exit-code/action pairing unchanged", async () => {
|
|
231
|
-
const body = {
|
|
232
|
-
schema: "gentle-ai.review-gate-result/v1",
|
|
233
|
-
result: "invalidated",
|
|
234
|
-
allowed: false,
|
|
235
|
-
action: "repository-policy",
|
|
236
|
-
reason: "should require exit 1 because delivery is absent",
|
|
237
|
-
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 },
|
|
238
|
-
};
|
|
239
|
-
// Without `delivery`, an "invalidated" result with exit 0 is not the
|
|
240
|
-
// alternate discriminator shape and must still fail closed.
|
|
241
|
-
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(body), exitCode: 0 }]);
|
|
128
|
+
const malformed = queuedAdapter([mode("status", "off", "clone_local", "future")]);
|
|
242
129
|
await assert.rejects(
|
|
243
|
-
() => new
|
|
130
|
+
() => new NativeReviewCliV216(malformed.adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.STATUS }),
|
|
244
131
|
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
|
|
245
132
|
);
|
|
133
|
+
if (process.platform !== "win32") {
|
|
134
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-review-mode-")));
|
|
135
|
+
const alias = `${root}-alias`;
|
|
136
|
+
const { symlinkSync } = await import("node:fs");
|
|
137
|
+
symlinkSync(root, alias, "dir");
|
|
138
|
+
t.after(() => {
|
|
139
|
+
rmSync(alias, { force: true });
|
|
140
|
+
rmSync(root, { recursive: true, force: true });
|
|
141
|
+
});
|
|
142
|
+
const observed: string[] = [];
|
|
143
|
+
const adapter: ExecFileAdapter = async (request) => {
|
|
144
|
+
observed.push(request.cwd);
|
|
145
|
+
return { stdout: mode("status", "off", "global"), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
146
|
+
};
|
|
147
|
+
await new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: alias, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS });
|
|
148
|
+
assert.deepEqual(observed, [root]);
|
|
149
|
+
}
|
|
246
150
|
});
|
|
247
151
|
|
|
248
|
-
test("
|
|
249
|
-
const start =
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const tolerated = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(start), stderr: notice }]);
|
|
253
|
-
const result = await new NativeReviewCliV213(tolerated.adapter).start({ cwd: "/repo" });
|
|
254
|
-
assert.equal(result.lineageId, "lineage-1");
|
|
255
|
-
|
|
256
|
-
// Same exact stderr text, but the negotiated version's `mode` capability is
|
|
257
|
-
// false (the pinned 2.1.11 row), so the notice must still fail closed.
|
|
258
|
-
const notTolerated = queuedAdapter([DARK_VERSION, { stdout: JSON.stringify(start), stderr: notice }]);
|
|
259
|
-
await assert.rejects(
|
|
260
|
-
() => new NativeReviewCliV213(notTolerated.adapter).start({ cwd: "/repo" }),
|
|
261
|
-
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
|
|
262
|
-
);
|
|
152
|
+
test("published start/v3 wire rejects scalar risk reasons without Pi lens policy", () => {
|
|
153
|
+
const start = captured("start-v3-zero-lens-closed.captured.json") as Record<string, unknown>;
|
|
154
|
+
assert.throws(() => decodeReviewStartV3({ ...start, risk_reasons: "not-an-array" }), /risk_reasons/);
|
|
263
155
|
});
|
|
264
156
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
assert.ok(
|
|
275
|
-
!REVIEW_CONSENT_NOTICES.some((notice) => notice.startsWith("Reviews are on by default")),
|
|
276
|
-
"the default-provenance notice was deleted upstream in v2.4.0 and must not stay tolerated",
|
|
277
|
-
);
|
|
157
|
+
interface RegisteredControllerTool {
|
|
158
|
+
execute: (
|
|
159
|
+
toolCallId: string,
|
|
160
|
+
parameters: unknown,
|
|
161
|
+
signal: AbortSignal | undefined,
|
|
162
|
+
onUpdate: undefined,
|
|
163
|
+
context: ExtensionContext,
|
|
164
|
+
) => Promise<{ details?: unknown }>;
|
|
165
|
+
}
|
|
278
166
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
await assert.rejects(
|
|
283
|
-
() => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
|
|
284
|
-
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
|
|
285
|
-
);
|
|
286
|
-
});
|
|
167
|
+
interface RegisteredCommand {
|
|
168
|
+
handler: (args: string, context: ExtensionContext) => Promise<void>;
|
|
169
|
+
}
|
|
287
170
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
for (const stderr of [
|
|
292
|
-
`prefix: ${notice}`,
|
|
293
|
-
`${notice}\nextra line`,
|
|
294
|
-
notice.slice(0, -1),
|
|
295
|
-
]) {
|
|
296
|
-
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(start), stderr }]);
|
|
297
|
-
await assert.rejects(
|
|
298
|
-
() => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
|
|
299
|
-
(error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
|
|
300
|
-
`stderr ${JSON.stringify(stderr)} must still fail closed`,
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
});
|
|
171
|
+
interface RegisteredEvent {
|
|
172
|
+
(event: unknown, context: ExtensionContext): Promise<unknown> | unknown;
|
|
173
|
+
}
|
|
304
174
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
175
|
+
interface ParityRuntime {
|
|
176
|
+
controller: RegisteredControllerTool;
|
|
177
|
+
commands: Map<string, RegisteredCommand>;
|
|
178
|
+
events: Map<string, RegisteredEvent>;
|
|
179
|
+
}
|
|
310
180
|
|
|
311
|
-
|
|
312
|
-
|
|
181
|
+
interface ParityRuntimeOptions {
|
|
182
|
+
candidateViews?: CandidateViewRegistry;
|
|
183
|
+
pendingReviewConsentRegistry?: PendingReviewConsentRegistry;
|
|
184
|
+
now?: () => number;
|
|
185
|
+
scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
|
|
186
|
+
writeConsentLatch?: (cwd: string) => void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parityRuntime(nativeReviewCli: NativeReviewCli | null, options: ParityRuntimeOptions = {}): ParityRuntime {
|
|
190
|
+
const tools = new Map<string, RegisteredControllerTool>();
|
|
191
|
+
const commands = new Map<string, RegisteredCommand>();
|
|
192
|
+
const events = new Map<string, RegisteredEvent>();
|
|
193
|
+
const dependencies = {
|
|
194
|
+
nativeReviewCli,
|
|
195
|
+
candidateViews: options.candidateViews ?? new CandidateViewRegistry(),
|
|
196
|
+
pendingReviewConsentRegistry: options.pendingReviewConsentRegistry ?? new PendingReviewConsentRegistry(),
|
|
197
|
+
now: options.now,
|
|
198
|
+
scheduleTimer: options.scheduleTimer,
|
|
199
|
+
} as unknown as Parameters<typeof createGentleAiExtension>[0];
|
|
200
|
+
__testing.createGentleAiExtension(dependencies, options.writeConsentLatch ?? (() => {}))({
|
|
201
|
+
on(name: string, handler: RegisteredEvent) { events.set(name, handler); },
|
|
202
|
+
registerTool(definition: RegisteredControllerTool & { name: string }) { tools.set(definition.name, definition); },
|
|
203
|
+
registerCommand(name: string, definition: RegisteredCommand) { commands.set(name, definition); },
|
|
204
|
+
} as unknown as ExtensionAPI);
|
|
205
|
+
const controller = tools.get("gentle_review");
|
|
206
|
+
assert.ok(controller);
|
|
207
|
+
return { controller: controller!, commands, events };
|
|
313
208
|
}
|
|
314
209
|
|
|
315
210
|
function repository(t: test.TestContext): string {
|
|
316
|
-
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-
|
|
317
|
-
// A materialized (retained) candidate view chmods its worktree read-only
|
|
318
|
-
// (0o444/0o555) to prevent tampering while lenses run; restore write
|
|
319
|
-
// permission recursively before cleanup so a retained-but-never-cleaned-up
|
|
320
|
-
// candidate view does not leak an unremovable temp directory.
|
|
211
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-native-parity-")));
|
|
321
212
|
t.after(() => {
|
|
322
|
-
try { execFileSync("chmod", ["-R", "u+w", cwd]); } catch { /* best effort */ }
|
|
213
|
+
try { execFileSync("chmod", ["-R", "u+w", cwd], { stdio: "ignore" }); } catch { /* best effort */ }
|
|
323
214
|
rmSync(cwd, { recursive: true, force: true });
|
|
324
215
|
});
|
|
325
|
-
git
|
|
326
|
-
git(cwd, "config", "user.email", "tests@example.com");
|
|
327
|
-
git(cwd, "config", "user.name", "Tests");
|
|
216
|
+
execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
|
|
328
217
|
writeFileSync(join(cwd, "app.ts"), "export const value = 1;\n");
|
|
329
|
-
git
|
|
330
|
-
git
|
|
218
|
+
execFileSync("git", ["add", "app.ts"], { cwd, stdio: "ignore" });
|
|
219
|
+
execFileSync("git", ["-c", "user.name=Parity Test", "-c", "user.email=parity@example.invalid", "commit", "-m", "base"], { cwd, stdio: "ignore" });
|
|
331
220
|
writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
|
|
332
221
|
return cwd;
|
|
333
222
|
}
|
|
334
223
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
224
|
+
function startStatus(cwd: string): ReviewStatusV3 {
|
|
225
|
+
const candidates = new CandidateViewRegistry();
|
|
226
|
+
const candidate = candidates.create({ contributorRoot: cwd });
|
|
227
|
+
try {
|
|
228
|
+
return {
|
|
229
|
+
contract: "gentle-ai.review-integration/v2",
|
|
230
|
+
applicability: "unrelated",
|
|
231
|
+
action: "start",
|
|
232
|
+
replayability: "not_replayable",
|
|
233
|
+
targetIdentity: `sha256:${"a".repeat(64)}`,
|
|
234
|
+
projection: {
|
|
235
|
+
schema: "gentle-ai.review-candidate-projection/v1",
|
|
236
|
+
kind: "current-changes",
|
|
237
|
+
projection: "workspace",
|
|
238
|
+
baseTree: candidate.baseTree,
|
|
239
|
+
initialReviewTree: candidate.candidateTree,
|
|
240
|
+
currentCandidateTree: candidate.candidateTree,
|
|
241
|
+
pathsDigest: `sha256:${"a".repeat(64)}`,
|
|
242
|
+
paths: [...candidate.paths],
|
|
243
|
+
intendedUntracked: [],
|
|
244
|
+
intendedUntrackedProof: `sha256:${"a".repeat(64)}`,
|
|
245
|
+
initialSnapshotIdentity: `sha256:${"a".repeat(64)}`,
|
|
246
|
+
currentSnapshotIdentity: `sha256:${"a".repeat(64)}`,
|
|
247
|
+
},
|
|
248
|
+
candidates: [],
|
|
249
|
+
raw: { schema: "gentle-ai.review-integration.status/v5" },
|
|
250
|
+
} as unknown as ReviewStatusV3;
|
|
251
|
+
} finally {
|
|
252
|
+
candidates.cleanup(candidate.token);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
342
255
|
|
|
343
|
-
function
|
|
344
|
-
const sha = `sha256:${"a".repeat(64)}`;
|
|
345
|
-
const candidate = new CandidateViewRegistry().create({ contributorRoot: cwd });
|
|
346
|
-
const tree = candidate.candidateTree;
|
|
347
|
-
const baseTree = candidate.baseTree;
|
|
348
|
-
const paths = candidate.paths;
|
|
349
|
-
candidate.cleanup();
|
|
350
|
-
const projection = {
|
|
351
|
-
schema: "gentle-ai.review-integration.projection/v1" as const,
|
|
352
|
-
kind: "current-changes" as const,
|
|
353
|
-
projection: "workspace" as const,
|
|
354
|
-
baseTree,
|
|
355
|
-
initialReviewTree: tree,
|
|
356
|
-
currentCandidateTree: tree,
|
|
357
|
-
pathsDigest: sha,
|
|
358
|
-
paths,
|
|
359
|
-
intendedUntracked: [],
|
|
360
|
-
intendedUntrackedProof: sha,
|
|
361
|
-
initialSnapshotIdentity: sha,
|
|
362
|
-
currentSnapshotIdentity: sha,
|
|
363
|
-
};
|
|
364
|
-
const rawRepair = {
|
|
365
|
-
schema: UNSUPPORTED_REPAIR_ASSESSMENT.schema,
|
|
366
|
-
status: UNSUPPORTED_REPAIR_ASSESSMENT.status,
|
|
367
|
-
counts: {
|
|
368
|
-
lineages: 0, compact_lineages: 0, legacy_lineages: 0, events: 0, bytes: 0,
|
|
369
|
-
eligible_candidates: 0, unsupported_lineages: 0, conflicts: 0,
|
|
370
|
-
},
|
|
371
|
-
supported_operations: UNSUPPORTED_REPAIR_ASSESSMENT.supportedOperations,
|
|
372
|
-
authorization_schema: UNSUPPORTED_REPAIR_ASSESSMENT.authorizationSchema,
|
|
373
|
-
};
|
|
256
|
+
function context(cwd: string, sessionId = "parity-session", notices: Array<{ message: string; type?: string }> = []): ExtensionContext {
|
|
374
257
|
return {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
targetIdentity: sha,
|
|
381
|
-
projection,
|
|
382
|
-
repair: UNSUPPORTED_REPAIR_ASSESSMENT,
|
|
383
|
-
candidates: [],
|
|
384
|
-
raw: {
|
|
385
|
-
schema: "gentle-ai.review-integration.status/v3", contract: "gentle-ai.review-integration/v2", operation: "review.status",
|
|
386
|
-
applicability: "unrelated", receipt: { status: "not_applicable" }, action: "start", replayability: "not_replayable", target_identity: sha,
|
|
387
|
-
repair: rawRepair,
|
|
388
|
-
projection: { schema: projection.schema, kind: projection.kind, projection: projection.projection, base_tree: baseTree, initial_review_tree: tree, current_candidate_tree: tree, paths_digest: sha, paths, intended_untracked: [], intended_untracked_proof: sha, initial_snapshot_identity: sha, current_snapshot_identity: sha },
|
|
389
|
-
candidates: [],
|
|
390
|
-
},
|
|
391
|
-
};
|
|
258
|
+
cwd,
|
|
259
|
+
hasUI: false,
|
|
260
|
+
ui: { notify: (message: string, type?: string) => { notices.push({ message, type }); } },
|
|
261
|
+
sessionManager: { getSessionId: () => sessionId },
|
|
262
|
+
} as unknown as ExtensionContext;
|
|
392
263
|
}
|
|
393
264
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
reviewModeSource?: "default" | "global" | "clone_local";
|
|
398
|
-
reviewModeThrows?: boolean;
|
|
399
|
-
lensesRequired?: boolean;
|
|
400
|
-
riskEvidence?: readonly string[];
|
|
401
|
-
hint?: string;
|
|
265
|
+
async function executeController(runtime: ParityRuntime, operation: unknown, cwd: string, sessionId = "parity-session"): Promise<Record<string, unknown>> {
|
|
266
|
+
const response = await runtime.controller.execute("parity-controller", operation, undefined, undefined, context(cwd, sessionId));
|
|
267
|
+
return response.details as Record<string, unknown>;
|
|
402
268
|
}
|
|
403
269
|
|
|
404
|
-
function
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
async validate(): Promise<never> { throw new Error("validate not used in this test"); },
|
|
421
|
-
async bindSdd(): Promise<never> { throw new Error("bindSdd not used in this test"); },
|
|
422
|
-
async sddStatus(): Promise<never> { throw new Error("sddStatus not used in this test"); },
|
|
423
|
-
async reviewStatus(): Promise<never> { throw new Error("reviewStatus not used in this test"); },
|
|
424
|
-
async targetStatus(request: { cwd: string }) {
|
|
425
|
-
return unrelatedStartTargetStatus(request.cwd);
|
|
426
|
-
},
|
|
427
|
-
...(reviewModeCapable
|
|
428
|
-
? {
|
|
429
|
-
async reviewMode(request: { operation: string }) {
|
|
430
|
-
if (options.reviewModeThrows === true) throw new Error("native review mode process failed");
|
|
431
|
-
const effective = options.reviewModeEffective ?? "on";
|
|
432
|
-
const source = options.reviewModeSource ?? "default";
|
|
270
|
+
function consentNative(cwd: string): { native: NativeReviewCli; starts: { count: number }; answers: string[] } {
|
|
271
|
+
const consent = decodeReviewConsentV3(captured("consent-v3.captured.json"));
|
|
272
|
+
const starts = { count: 0 };
|
|
273
|
+
const answers: string[] = [];
|
|
274
|
+
return {
|
|
275
|
+
starts,
|
|
276
|
+
answers,
|
|
277
|
+
native: {
|
|
278
|
+
targetStatus: async () => startStatus(cwd),
|
|
279
|
+
start: async () => {
|
|
280
|
+
starts.count += 1;
|
|
281
|
+
throw new NativeReviewConsentRequiredError(consent);
|
|
282
|
+
},
|
|
283
|
+
answerConsent: async (request) => {
|
|
284
|
+
answers.push(request.answer);
|
|
285
|
+
if (request.answer === "declined") {
|
|
433
286
|
return {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
},
|
|
287
|
+
kind: "declined",
|
|
288
|
+
targetIdentity: consent.targetIdentity,
|
|
289
|
+
projection: consent.projection,
|
|
290
|
+
riskLevel: consent.riskLevel,
|
|
291
|
+
changedFiles: consent.changedFiles,
|
|
292
|
+
changedLines: consent.changedLines,
|
|
293
|
+
consent: "declined_this_candidate",
|
|
294
|
+
raw: { operation: "review/start", action: "declined", consent: "declined_this_candidate" },
|
|
442
295
|
};
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
interface RegisteredEventFixture {
|
|
465
|
-
(event: unknown, ctx: ExtensionContext): Promise<unknown> | unknown;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function runtime(
|
|
469
|
-
nativeReviewCli: NativeReviewCli | null,
|
|
470
|
-
writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
|
|
471
|
-
clock?: { now?: () => number; scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void } },
|
|
472
|
-
): { controller: RegisteredTool; commands: Map<string, RegisteredCommandFixture>; events: Map<string, RegisteredEventFixture> } {
|
|
473
|
-
const tools = new Map<string, RegisteredTool>();
|
|
474
|
-
const commands = new Map<string, RegisteredCommandFixture>();
|
|
475
|
-
const events = new Map<string, RegisteredEventFixture>();
|
|
476
|
-
const dependencies = { nativeReviewCli, candidateViews: new CandidateViewRegistry(), ...clock } as unknown as Parameters<typeof createGentleAiExtension>[0];
|
|
477
|
-
__testing.createGentleAiExtension(dependencies, writeReviewConsentLatch)({
|
|
478
|
-
on(name: string, handler: RegisteredEventFixture) { events.set(name, handler); },
|
|
479
|
-
registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
|
|
480
|
-
registerCommand(name: string, definition: RegisteredCommandFixture) { commands.set(name, definition); },
|
|
481
|
-
} as unknown as ExtensionAPI);
|
|
482
|
-
const controller = tools.get("gentle_review");
|
|
483
|
-
assert.ok(controller);
|
|
484
|
-
return { controller: controller!, commands, events };
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function headlessContext(cwd: string, notices: Array<{ message: string; type?: string }> = []): ExtensionContext {
|
|
488
|
-
return { cwd, hasUI: false, ui: { notify: (message: string, type?: string) => { notices.push({ message, type }); } } } as unknown as ExtensionContext;
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
kind: "started",
|
|
299
|
+
start: {
|
|
300
|
+
lineageId: "consent-lineage",
|
|
301
|
+
state: "approved",
|
|
302
|
+
riskLevel: "high",
|
|
303
|
+
selectedLenses: [],
|
|
304
|
+
changedFiles: consent.changedFiles,
|
|
305
|
+
changedLines: consent.changedLines,
|
|
306
|
+
correctionBudget: 0,
|
|
307
|
+
action: "closed",
|
|
308
|
+
lensesRequired: false,
|
|
309
|
+
riskReasons: [],
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
},
|
|
313
|
+
sddStatus: async () => ({ ready: false, artifactStore: "none", artifacts: {}, nextRecommended: "propose" }),
|
|
314
|
+
reviewStatus: async () => ({ schema: "gentle-ai.review-authority-status/v1", repository: cwd, complete: true, authoritative: true, status: "clean", entries: [], locks: [], diagnostics: [], raw: {} }),
|
|
315
|
+
} as unknown as NativeReviewCli,
|
|
316
|
+
};
|
|
489
317
|
}
|
|
490
318
|
|
|
491
|
-
function
|
|
492
|
-
|
|
319
|
+
async function beginConsent(runtime: ParityRuntime, cwd: string, sessionId = "parity-session"): Promise<Record<string, unknown>> {
|
|
320
|
+
const blocked = await executeController(runtime, { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, sessionId);
|
|
321
|
+
assert.equal(blocked.outcome, "native-review-consent-required");
|
|
322
|
+
assert.equal(typeof blocked.consent_binding, "string");
|
|
323
|
+
return blocked;
|
|
493
324
|
}
|
|
494
325
|
|
|
495
|
-
function
|
|
496
|
-
return
|
|
326
|
+
async function answerConsent(runtime: ParityRuntime, cwd: string, binding: unknown, answer: "granted" | "declined", sessionId = "parity-session"): Promise<Record<string, unknown>> {
|
|
327
|
+
return await executeController(runtime, { operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer }) }, cwd, sessionId);
|
|
497
328
|
}
|
|
498
329
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
330
|
+
// gentle-pi#516: an unknown or expired binding is a typed, blocked outcome
|
|
331
|
+
// that names itself and its exit. It still carries the current negotiated
|
|
332
|
+
// STATUS as reconciliation context, but it never reads as a healthy
|
|
333
|
+
// pre-start "ready".
|
|
334
|
+
function assertStaleConsentBinding(outcome: Record<string, unknown>, binding: unknown, code: "consent-binding-unknown" | "consent-binding-expired" | "consent-binding-already-consumed"): void {
|
|
335
|
+
assert.equal(outcome.status, "blocked");
|
|
336
|
+
assert.equal(outcome.outcome, "consent-binding-stale");
|
|
337
|
+
assert.equal(outcome.consent_binding, binding);
|
|
338
|
+
assert.equal((outcome.diagnostics as { code?: unknown } | undefined)?.code, code);
|
|
339
|
+
assert.match(String((outcome.diagnostics as { message?: unknown } | undefined)?.message), /START again/);
|
|
340
|
+
assert.equal(outcome.native_invocation_attempted, false);
|
|
341
|
+
assert.equal(outcome.lineage_created, false);
|
|
342
|
+
assert.equal(outcome.mutation_performed, false);
|
|
343
|
+
assert.equal(outcome.mutation_outcome, "none");
|
|
344
|
+
assert.equal(outcome.provider_action, "start");
|
|
345
|
+
assert.equal(outcome.next_action, "restart-for-fresh-consent");
|
|
504
346
|
}
|
|
505
347
|
|
|
506
|
-
test("
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const cwd = repository(t);
|
|
525
|
-
const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "clone_local" });
|
|
526
|
-
const { controller } = runtime(native);
|
|
527
|
-
const result = await execStart(controller, "kill-switch-clone-local", headlessContext(cwd));
|
|
528
|
-
assert.equal(result.status, "skipped");
|
|
529
|
-
assert.equal(result.outcome, "review-mode-disabled");
|
|
530
|
-
assert.equal(result.mode_source, "clone_local");
|
|
531
|
-
assert.equal(result.reason, "receipt-driven development is disabled: start is skipped because the clone_local mode source keeps it off");
|
|
532
|
-
assert.equal(result.next_action, "Run /gentle:review-mode enable to turn reviews back on for this clone.");
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
// gentle-ai maps RDDModeSourceGlobal onto `--scope=global`, and a clone-local
|
|
536
|
-
// override may only ever disable (cloneLocalRDDOverrideValue rejects RDDModeOn).
|
|
537
|
-
// Pi's own /gentle:review-mode always passes --scope clone, so it CANNOT clear a
|
|
538
|
-
// global off. Naming it here would be naming a dead end, so the continuation has
|
|
539
|
-
// to be the native command that actually resolves it.
|
|
540
|
-
test("kill-switch: a global off names the native global-scope command, never Pi's clone-scope one", async (t) => {
|
|
541
|
-
const cwd = repository(t);
|
|
542
|
-
const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "global" });
|
|
543
|
-
const { controller } = runtime(native);
|
|
544
|
-
const result = await execStart(controller, "kill-switch-global", headlessContext(cwd));
|
|
545
|
-
assert.equal(result.mode_source, "global");
|
|
546
|
-
assert.equal(result.reason, "receipt-driven development is disabled: start is skipped because the global mode source keeps it off");
|
|
547
|
-
assert.equal(
|
|
548
|
-
result.next_action,
|
|
549
|
-
"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.",
|
|
348
|
+
test("review-mode gate retains every off-source continuation and fails closed on native errors", async () => {
|
|
349
|
+
for (const [source, expected] of [
|
|
350
|
+
["clone_local", /clear this clone-local override/],
|
|
351
|
+
["global", /cannot override a global off/],
|
|
352
|
+
["default", /off by default until explicitly enabled/],
|
|
353
|
+
] as const) {
|
|
354
|
+
const native = {
|
|
355
|
+
reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: source === "global" ? "off" : "", cloneLocal: source === "clone_local" ? "off" : "", effective: "off" as const, source } }),
|
|
356
|
+
} as unknown as NativeReviewCli;
|
|
357
|
+
const result = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, process.cwd(), native);
|
|
358
|
+
assert.equal(result.outcome, "review-mode-disabled");
|
|
359
|
+
assert.equal(result.mode_source, source);
|
|
360
|
+
assert.match(String(result.next_action), expected);
|
|
361
|
+
}
|
|
362
|
+
const failed = await __testing.executeReviewControllerOperation(
|
|
363
|
+
{ operation: "start", input: JSON.stringify({ mode: "ordinary" }) },
|
|
364
|
+
process.cwd(),
|
|
365
|
+
{ reviewMode: async () => { throw new Error("native mode failure"); } } as unknown as NativeReviewCli,
|
|
550
366
|
);
|
|
551
|
-
assert.
|
|
367
|
+
assert.equal(failed.outcome, "native-operation-failed");
|
|
552
368
|
});
|
|
553
369
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
370
|
+
test("public gentle:review-mode handler reports current operations, global-off warnings, and unavailability", async () => {
|
|
371
|
+
const calls: string[] = [];
|
|
372
|
+
const native = {
|
|
373
|
+
reviewMode: async ({ operation }: { operation: "status" | "enable" | "disable" }) => {
|
|
374
|
+
calls.push(operation);
|
|
375
|
+
const globalOff = operation === "enable";
|
|
376
|
+
return {
|
|
377
|
+
operation,
|
|
378
|
+
scope: "clone",
|
|
379
|
+
status: { global: globalOff ? "off" : "", cloneLocal: operation === "disable" ? "off" : "", effective: globalOff || operation === "disable" ? "off" as const : "on" as const, source: globalOff ? "global" as const : operation === "disable" ? "clone_local" as const : "default" as const },
|
|
380
|
+
};
|
|
381
|
+
},
|
|
382
|
+
} as unknown as NativeReviewCli;
|
|
383
|
+
const runtime = parityRuntime(native);
|
|
384
|
+
const command = runtime.commands.get("gentle:review-mode");
|
|
385
|
+
assert.ok(command);
|
|
386
|
+
const notices: Array<{ message: string; type?: string }> = [];
|
|
387
|
+
const ctx = context(process.cwd(), "review-mode-command", notices);
|
|
388
|
+
await command!.handler("status", ctx);
|
|
389
|
+
await command!.handler("disable", ctx);
|
|
390
|
+
await command!.handler("enable", ctx);
|
|
391
|
+
assert.deepEqual(calls, ["status", "disable", "enable"]);
|
|
392
|
+
assert.match(notices[0]?.message ?? "", /receipt-driven development: on/);
|
|
393
|
+
assert.match(notices[1]?.message ?? "", /receipt-driven development: off/);
|
|
394
|
+
assert.equal(notices[2]?.type, "warning");
|
|
395
|
+
assert.match(notices[2]?.message ?? "", /gentle-ai review mode enable --scope=global/);
|
|
396
|
+
|
|
397
|
+
const unavailable = parityRuntime({} as NativeReviewCli).commands.get("gentle:review-mode");
|
|
398
|
+
assert.ok(unavailable);
|
|
399
|
+
const unavailableNotices: Array<{ message: string; type?: string }> = [];
|
|
400
|
+
await unavailable!.handler("status", context(process.cwd(), "review-mode-unavailable", unavailableNotices));
|
|
401
|
+
assert.deepEqual(unavailableNotices, [{ message: "Gentle AI review mode is not available with the currently negotiated native version.", type: "info" }]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("public consent relay is session-bound, one-shot, and candidate-scoped", async (t) => {
|
|
565
405
|
const cwd = repository(t);
|
|
566
|
-
const
|
|
567
|
-
const
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
);
|
|
575
|
-
assert.
|
|
576
|
-
|
|
406
|
+
const sharedRegistry = new PendingReviewConsentRegistry();
|
|
407
|
+
const fixture = consentNative(cwd);
|
|
408
|
+
const first = parityRuntime(fixture.native, { pendingReviewConsentRegistry: sharedRegistry });
|
|
409
|
+
const second = parityRuntime(fixture.native, { pendingReviewConsentRegistry: sharedRegistry });
|
|
410
|
+
const sameSession = await beginConsent(first, cwd, "same-session");
|
|
411
|
+
const sameSessionResult = await answerConsent(second, cwd, sameSession.consent_binding, "declined", "same-session");
|
|
412
|
+
assert.equal(sameSessionResult.outcome, "consent-declined-this-candidate");
|
|
413
|
+
|
|
414
|
+
const blockedA = await beginConsent(first, cwd, "session-a");
|
|
415
|
+
assert.deepEqual(blockedA.consent, decodeReviewConsentV3(captured("consent-v3.captured.json")).raw);
|
|
416
|
+
const staleOtherSession = await answerConsent(second, cwd, blockedA.consent_binding, "declined", "session-b");
|
|
417
|
+
assert.equal(staleOtherSession.operation, "answer-consent");
|
|
418
|
+
// gentle-pi#516: a binding this session does not hold must never read as
|
|
419
|
+
// a healthy pre-start STATUS; it names itself and the exit.
|
|
420
|
+
assertStaleConsentBinding(staleOtherSession, blockedA.consent_binding, "consent-binding-unknown");
|
|
421
|
+
assert.deepEqual(fixture.answers, ["declined"]);
|
|
422
|
+
const blockedB = await beginConsent(second, cwd, "session-b");
|
|
423
|
+
const declined = await answerConsent(second, cwd, blockedB.consent_binding, "declined", "session-b");
|
|
424
|
+
assert.equal(declined.outcome, "consent-declined-this-candidate");
|
|
425
|
+
assert.equal(declined.lineage_created, false);
|
|
426
|
+
const staleConsumed = await answerConsent(second, cwd, blockedB.consent_binding, "declined", "session-b");
|
|
427
|
+
assert.equal(staleConsumed.operation, "answer-consent");
|
|
428
|
+
assertStaleConsentBinding(staleConsumed, blockedB.consent_binding, "consent-binding-already-consumed");
|
|
429
|
+
assert.deepEqual(fixture.answers, ["declined", "declined"]);
|
|
430
|
+
|
|
431
|
+
const shutdown = first.events.get("session_shutdown");
|
|
432
|
+
assert.ok(shutdown);
|
|
433
|
+
await shutdown!({}, context(cwd, "session-a"));
|
|
434
|
+
await shutdown!({}, context(cwd, "session-a"));
|
|
435
|
+
const staleShutdown = await answerConsent(first, cwd, blockedA.consent_binding, "declined", "session-a");
|
|
436
|
+
assert.equal(staleShutdown.operation, "answer-consent");
|
|
437
|
+
assertStaleConsentBinding(staleShutdown, blockedA.consent_binding, "consent-binding-unknown");
|
|
577
438
|
|
|
578
|
-
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
439
|
+
writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
|
|
440
|
+
const nextCandidate = await beginConsent(second, cwd, "session-b");
|
|
441
|
+
assert.notEqual(nextCandidate.consent_binding, blockedB.consent_binding);
|
|
442
|
+
fixture.native.reviewMode = async ({ operation }) => ({
|
|
443
|
+
operation,
|
|
444
|
+
scope: "clone",
|
|
445
|
+
status: { global: "", cloneLocal: "off", effective: "off", source: "clone_local" },
|
|
446
|
+
});
|
|
447
|
+
const disable = second.commands.get("gentle:review-mode");
|
|
448
|
+
assert.ok(disable);
|
|
449
|
+
await disable!.handler("disable", context(cwd, "session-b"));
|
|
450
|
+
const staleModeCleared = await answerConsent(second, cwd, nextCandidate.consent_binding, "declined", "session-b");
|
|
451
|
+
assert.equal(staleModeCleared.operation, "answer-consent");
|
|
452
|
+
assertStaleConsentBinding(staleModeCleared, nextCandidate.consent_binding, "consent-binding-unknown");
|
|
453
|
+
assert.equal(fixture.starts.count, 4);
|
|
585
454
|
});
|
|
586
455
|
|
|
587
|
-
test("
|
|
456
|
+
test("already-consumed local consent bindings reconcile exactly once with the native status transition", async (t) => {
|
|
588
457
|
const cwd = repository(t);
|
|
589
|
-
const
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
function candidateConsent(cwd: string): ReviewConsentV2 {
|
|
597
|
-
const targetIdentity = `sha256:${"a".repeat(64)}`;
|
|
598
|
-
const choices = [
|
|
599
|
-
{ 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` },
|
|
600
|
-
{ 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` },
|
|
601
|
-
] as const;
|
|
602
|
-
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" } };
|
|
603
|
-
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 };
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
function relayedConsentNative(cwd: string): { native: NativeReviewCli; answers: NativeReviewConsentAnswer[]; startRequests: NativeStartRequest[]; answerRequests: NativeReviewConsentAnswerRequest[] } {
|
|
607
|
-
const { native } = fakeOrganicNative();
|
|
608
|
-
const consent = candidateConsent(cwd);
|
|
609
|
-
const answers: NativeReviewConsentAnswer[] = [];
|
|
610
|
-
const startRequests: NativeStartRequest[] = [];
|
|
611
|
-
const answerRequests: NativeReviewConsentAnswerRequest[] = [];
|
|
612
|
-
native.start = async (request) => {
|
|
613
|
-
startRequests.push(request);
|
|
614
|
-
throw new NativeReviewConsentRequiredError(consent);
|
|
458
|
+
const fixture = consentNative(cwd);
|
|
459
|
+
const nextTransition = {
|
|
460
|
+
kind: "execute",
|
|
461
|
+
reason_code: "fresh_start_available",
|
|
462
|
+
execute: { operation: "review.start", arguments: ["opaque-native-start-token"] },
|
|
615
463
|
};
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
464
|
+
const reconciledStatus = {
|
|
465
|
+
...startStatus(cwd),
|
|
466
|
+
raw: { schema: "gentle-ai.review-integration.status/v5", next_transition: nextTransition },
|
|
467
|
+
} as ReviewStatusV3;
|
|
468
|
+
const statusRequests: Array<{ agent?: string }> = [];
|
|
469
|
+
fixture.native.targetStatus = async (request) => {
|
|
470
|
+
statusRequests.push(request);
|
|
471
|
+
return reconciledStatus;
|
|
621
472
|
};
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
assert.equal(
|
|
633
|
-
assert.equal(
|
|
634
|
-
assert.
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
test("consent relay returns the identical complete parent-visible envelope with or without UI and never answers internally", async (t) => {
|
|
473
|
+
const runtime = parityRuntime(fixture.native);
|
|
474
|
+
const blocked = await beginConsent(runtime, cwd);
|
|
475
|
+
await answerConsent(runtime, cwd, blocked.consent_binding, "declined");
|
|
476
|
+
|
|
477
|
+
const reconciled = await answerConsent(runtime, cwd, blocked.consent_binding, "declined");
|
|
478
|
+
assert.equal(reconciled.operation, "answer-consent");
|
|
479
|
+
assertStaleConsentBinding(reconciled, blocked.consent_binding, "consent-binding-already-consumed");
|
|
480
|
+
assert.deepEqual(reconciled.result, reconciledStatus.raw);
|
|
481
|
+
assert.deepEqual((reconciled.result as { next_transition?: unknown }).next_transition, nextTransition);
|
|
482
|
+
assert.equal(statusRequests.length, 2, "the stale binding performs one reconciliation after the initial START status");
|
|
483
|
+
assert.equal(statusRequests[1]?.agent, "pi", "stale binding reconciliation must retain the Pi host transport identity");
|
|
484
|
+
assert.equal(fixture.starts.count, 1, "stale binding recovery must not start a new review automatically");
|
|
485
|
+
assert.deepEqual(fixture.answers, ["declined"], "stale binding recovery must not replay answer-consent");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("launched answer-consent failures remain blocked after fresh-target STATUS reconciliation", async (t) => {
|
|
639
489
|
const cwd = repository(t);
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if (answer === "granted") {
|
|
674
|
-
const actorBinding = result.actor_binding as { workspace_root: string; candidate_root: string };
|
|
675
|
-
assert.equal(actorBinding.workspace_root, canonicalCwd);
|
|
676
|
-
assert.notEqual(actorBinding.candidate_root, canonicalCwd);
|
|
677
|
-
assert.equal(readReviewConsentLatch(cwd), true);
|
|
678
|
-
} else {
|
|
679
|
-
assert.equal(result.outcome, "consent-declined-this-candidate");
|
|
680
|
-
assert.equal(result.lineage_created, false);
|
|
681
|
-
assert.equal("actor_binding" in result, false);
|
|
682
|
-
assert.equal("result" in result, false);
|
|
683
|
-
assert.equal(readReviewConsentLatch(cwd), false);
|
|
684
|
-
}
|
|
685
|
-
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
686
|
-
}
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
test("granted consent preserves the completed native start when local latch persistence fails", async (t) => {
|
|
690
|
-
const cwd = repository(t);
|
|
691
|
-
const { native, answers } = relayedConsentNative(cwd);
|
|
692
|
-
const notices: Array<{ message: string; type?: string }> = [];
|
|
693
|
-
let latchWrites = 0;
|
|
694
|
-
const { controller } = runtime(native, () => {
|
|
695
|
-
latchWrites += 1;
|
|
696
|
-
throw new Error("injected consent latch failure");
|
|
490
|
+
const fixture = consentNative(cwd);
|
|
491
|
+
const statusRequests: Array<{ agent?: string }> = [];
|
|
492
|
+
const freshTargetStatus = {
|
|
493
|
+
...startStatus(cwd),
|
|
494
|
+
raw: { schema: "gentle-ai.review-integration.status/v5", next_transition: { kind: "execute", reason_code: "fresh_start_available" } },
|
|
495
|
+
} as ReviewStatusV3;
|
|
496
|
+
fixture.native.targetStatus = async (request) => {
|
|
497
|
+
statusRequests.push(request);
|
|
498
|
+
return freshTargetStatus;
|
|
499
|
+
};
|
|
500
|
+
let answerCalls = 0;
|
|
501
|
+
fixture.native.answerConsent = async () => {
|
|
502
|
+
answerCalls += 1;
|
|
503
|
+
throw new NativeReviewCliError(
|
|
504
|
+
NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
|
|
505
|
+
NATIVE_REVIEW_OPERATION.START,
|
|
506
|
+
true,
|
|
507
|
+
true,
|
|
508
|
+
"native review/start failed after launch",
|
|
509
|
+
);
|
|
510
|
+
};
|
|
511
|
+
const runtime = parityRuntime(fixture.native);
|
|
512
|
+
const blocked = await beginConsent(runtime, cwd);
|
|
513
|
+
|
|
514
|
+
const first = await answerConsent(runtime, cwd, blocked.consent_binding, "granted");
|
|
515
|
+
assert.equal(first.operation, "answer-consent");
|
|
516
|
+
assert.equal(first.status, "blocked");
|
|
517
|
+
assert.equal(first.outcome, "native-mutation-status-reconciled");
|
|
518
|
+
assert.deepEqual(first.diagnostics, {
|
|
519
|
+
operation: NATIVE_REVIEW_OPERATION.START,
|
|
520
|
+
error_code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
|
|
521
|
+
timed_out: false,
|
|
522
|
+
output_limit_exceeded: false,
|
|
697
523
|
});
|
|
698
|
-
|
|
699
|
-
|
|
524
|
+
assert.deepEqual(first.reconciliation, freshTargetStatus.raw);
|
|
525
|
+
assert.equal(statusRequests.length, 2, "the launched failure reconciles exactly once after the initial START status");
|
|
526
|
+
assert.equal(answerCalls, 1, "reconciliation must not replay answer-consent");
|
|
700
527
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
assert.
|
|
704
|
-
assert.equal(
|
|
705
|
-
assert.equal(readReviewConsentLatch(cwd), false);
|
|
706
|
-
assert.equal(notices.length, 1);
|
|
707
|
-
assert.equal(notices[0]?.type, "warning");
|
|
708
|
-
assert.match(notices[0]?.message ?? "", /native review start completed, but Pi could not record the local consent latch/i);
|
|
528
|
+
const stale = await answerConsent(runtime, cwd, blocked.consent_binding, "granted");
|
|
529
|
+
assertStaleConsentBinding(stale, blocked.consent_binding, "consent-binding-already-consumed");
|
|
530
|
+
assert.equal(statusRequests.length, 3, "a later already-consumed binding performs one current STATUS read");
|
|
531
|
+
assert.equal(answerCalls, 1, "the stale binding must not replay answer-consent");
|
|
709
532
|
});
|
|
710
533
|
|
|
711
|
-
|
|
712
|
-
// outage, so the reporter diagnosed a missing --cwd that Pi does forward.
|
|
713
|
-
test("a consent binding mismatch surfaces as an actionable local failure, not an opaque native operation failure", async (t) => {
|
|
534
|
+
test("stale local consent bindings preserve a typed native STATUS failure", async (t) => {
|
|
714
535
|
const cwd = repository(t);
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
536
|
+
const fixture = consentNative(cwd);
|
|
537
|
+
const runtime = parityRuntime(fixture.native);
|
|
538
|
+
const blocked = await beginConsent(runtime, cwd);
|
|
539
|
+
await answerConsent(runtime, cwd, blocked.consent_binding, "declined");
|
|
540
|
+
fixture.native.targetStatus = async () => {
|
|
541
|
+
throw new NativeReviewCliError(
|
|
542
|
+
NATIVE_REVIEW_ERROR_CODE.TIMEOUT,
|
|
543
|
+
NATIVE_REVIEW_OPERATION.STATUS,
|
|
544
|
+
true,
|
|
545
|
+
false,
|
|
546
|
+
"native STATUS timed out",
|
|
547
|
+
);
|
|
718
548
|
};
|
|
719
|
-
const { controller } = runtime(native);
|
|
720
|
-
const blocked = await blockedConsent(controller, "consent-binding", headlessContext(cwd));
|
|
721
|
-
const result = await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
|
|
722
|
-
assert.equal(result.status, "blocked");
|
|
723
|
-
assert.equal(result.outcome, "consent-binding-invalid");
|
|
724
|
-
assert.deepEqual(result.diagnostics, { code: "consent-invocation-cwd-changed", message: "Native consent invocation repository binding changed" });
|
|
725
|
-
assert.equal(result.native_invocation_attempted, false);
|
|
726
|
-
assert.equal(result.lineage_created, false);
|
|
727
|
-
assert.equal(result.mutation_performed, false);
|
|
728
|
-
assert.equal(result.mutation_outcome, "none");
|
|
729
|
-
assert.equal(result.next_action, "resolve-consent-binding");
|
|
730
|
-
assert.deepEqual(answers, []);
|
|
731
|
-
assert.equal(readReviewConsentLatch(cwd), false);
|
|
732
|
-
});
|
|
733
549
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(other)), /repository binding changed/);
|
|
747
|
-
const originalTarget = consent.targetIdentity;
|
|
748
|
-
(consent as { targetIdentity: string }).targetIdentity = `sha256:${"b".repeat(64)}`;
|
|
749
|
-
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
|
|
750
|
-
(consent as { targetIdentity: string }).targetIdentity = originalTarget;
|
|
751
|
-
(consent as { projection: string }).projection = "staged";
|
|
752
|
-
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
|
|
753
|
-
(consent as { projection: string }).projection = "workspace";
|
|
754
|
-
(consent.choices[0] as { invocation: string }).invocation = consent.choices[0].invocation.replace("native-lineage", "changed-lineage");
|
|
755
|
-
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
|
|
756
|
-
assert.deepEqual(answers, []);
|
|
757
|
-
assert.equal(readReviewConsentLatch(cwd), false);
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
test("unavailable consent follow-up leaves the clone latch unset", async (t) => {
|
|
761
|
-
const cwd = repository(t);
|
|
762
|
-
const { native } = relayedConsentNative(cwd);
|
|
763
|
-
delete native.answerConsent;
|
|
764
|
-
const { controller } = runtime(native);
|
|
765
|
-
const blocked = await blockedConsent(controller, "consent-unavailable", headlessContext(cwd));
|
|
766
|
-
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent follow-up is unavailable/);
|
|
767
|
-
assert.equal(readReviewConsentLatch(cwd), false);
|
|
768
|
-
});
|
|
550
|
+
const failed = await answerConsent(runtime, cwd, blocked.consent_binding, "declined");
|
|
551
|
+
assert.equal(failed.operation, "answer-consent");
|
|
552
|
+
assert.equal(failed.status, "blocked");
|
|
553
|
+
assert.equal(failed.outcome, "native-status-unavailable");
|
|
554
|
+
assert.deepEqual(failed.diagnostics, {
|
|
555
|
+
operation: NATIVE_REVIEW_OPERATION.STATUS,
|
|
556
|
+
error_code: NATIVE_REVIEW_ERROR_CODE.TIMEOUT,
|
|
557
|
+
timed_out: false,
|
|
558
|
+
output_limit_exceeded: false,
|
|
559
|
+
});
|
|
560
|
+
assert.equal(fixture.starts.count, 1, "native STATUS failure must not start a new review");
|
|
561
|
+
assert.deepEqual(fixture.answers, ["declined"], "native STATUS failure must not replay answer-consent");
|
|
769
562
|
|
|
770
|
-
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
const { controller } = runtime(native);
|
|
778
|
-
const blocked = await blockedConsent(controller, "consent-ambiguous", headlessContext(cwd));
|
|
779
|
-
await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
|
|
780
|
-
assert.equal(statusCalls, 2, "ambiguous consent must reconcile through target status");
|
|
781
|
-
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
782
|
-
assert.equal(readReviewConsentLatch(cwd), false);
|
|
563
|
+
delete fixture.native.targetStatus;
|
|
564
|
+
const unsupported = await answerConsent(runtime, cwd, blocked.consent_binding, "declined");
|
|
565
|
+
assert.equal(unsupported.operation, "answer-consent");
|
|
566
|
+
assert.equal(unsupported.status, "blocked");
|
|
567
|
+
assert.equal(unsupported.outcome, "native-status-unsupported");
|
|
568
|
+
assert.equal(fixture.starts.count, 1, "unavailable native STATUS must not start a new review");
|
|
569
|
+
assert.deepEqual(fixture.answers, ["declined"], "unavailable native STATUS must not replay answer-consent");
|
|
783
570
|
});
|
|
784
571
|
|
|
785
|
-
test("
|
|
572
|
+
test("stale local consent bindings retain typed Pi transport refusal without an agentless retry", async (t) => {
|
|
786
573
|
const cwd = repository(t);
|
|
787
|
-
const
|
|
788
|
-
const {
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
574
|
+
const fixture = consentNative(cwd);
|
|
575
|
+
const statusRequests: Array<{ agent?: string }> = [];
|
|
576
|
+
fixture.native.targetStatus = async (request) => {
|
|
577
|
+
statusRequests.push(request);
|
|
578
|
+
throw new NativeReviewIntegrationError(decodeReviewFailureV2({
|
|
579
|
+
schema: "gentle-ai.review-integration.failure/v2",
|
|
580
|
+
contract: "gentle-ai.review-integration/v2",
|
|
581
|
+
operation: "review.status",
|
|
582
|
+
phase: "preflight",
|
|
583
|
+
code: "unsupported_agent",
|
|
584
|
+
message: "The native provider does not support Pi host transport.",
|
|
585
|
+
mutation_outcome: "not_started",
|
|
586
|
+
authority_applicability: "not_evaluated",
|
|
587
|
+
retry_safe: true,
|
|
588
|
+
replayability: "not_replayable",
|
|
589
|
+
required_inputs: [],
|
|
590
|
+
next_action: "review.status",
|
|
591
|
+
}));
|
|
592
|
+
};
|
|
593
|
+
const runtime = parityRuntime(fixture.native);
|
|
594
|
+
|
|
595
|
+
const refused = await answerConsent(runtime, cwd, "missing-consent-binding", "declined");
|
|
596
|
+
assert.equal(refused.operation, "answer-consent");
|
|
597
|
+
assert.equal(refused.status, "blocked");
|
|
598
|
+
assert.equal(refused.outcome, "pi-host-relay-transport-unavailable");
|
|
599
|
+
assert.deepEqual(refused.relay_transport, {
|
|
600
|
+
supported: false,
|
|
601
|
+
code: "unsupported_agent",
|
|
602
|
+
message: "The native provider does not support Pi host transport.",
|
|
603
|
+
});
|
|
604
|
+
assert.equal(statusRequests.length, 1, "typed Pi transport refusal must not retry STATUS without an agent");
|
|
605
|
+
assert.equal(statusRequests[0]?.agent, "pi");
|
|
606
|
+
assert.equal(fixture.starts.count, 0, "stale binding transport refusal must not start a review");
|
|
607
|
+
assert.deepEqual(fixture.answers, [], "stale binding transport refusal must not replay answer-consent");
|
|
799
608
|
});
|
|
800
609
|
|
|
801
|
-
test("
|
|
610
|
+
test("unavailable and ambiguous consent follow-ups never replay a consumed binding", async (t) => {
|
|
802
611
|
const cwd = repository(t);
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
assert.
|
|
808
|
-
|
|
612
|
+
const unavailableFixture = consentNative(cwd);
|
|
613
|
+
delete unavailableFixture.native.answerConsent;
|
|
614
|
+
const unavailableRuntime = parityRuntime(unavailableFixture.native);
|
|
615
|
+
const unavailable = await beginConsent(unavailableRuntime, cwd);
|
|
616
|
+
await assert.rejects(
|
|
617
|
+
() => answerConsent(unavailableRuntime, cwd, unavailable.consent_binding, "declined"),
|
|
618
|
+
/consent follow-up is unavailable/,
|
|
619
|
+
);
|
|
809
620
|
|
|
810
|
-
|
|
621
|
+
const ambiguousFixture = consentNative(cwd);
|
|
622
|
+
let statusCalls = 0;
|
|
623
|
+
const targetStatus = ambiguousFixture.native.targetStatus!;
|
|
624
|
+
ambiguousFixture.native.targetStatus = async (request) => {
|
|
625
|
+
statusCalls += 1;
|
|
626
|
+
return await targetStatus(request);
|
|
627
|
+
};
|
|
628
|
+
ambiguousFixture.native.answerConsent = async () => {
|
|
629
|
+
throw Object.assign(new Error("ambiguous provider mutation"), { mutationOutcome: "unknown", nextAction: "review.status" });
|
|
630
|
+
};
|
|
631
|
+
const ambiguousRuntime = parityRuntime(ambiguousFixture.native);
|
|
632
|
+
const ambiguous = await beginConsent(ambiguousRuntime, cwd);
|
|
633
|
+
const outcome = await answerConsent(ambiguousRuntime, cwd, ambiguous.consent_binding, "granted");
|
|
634
|
+
assert.equal(outcome.operation, "answer-consent");
|
|
635
|
+
assert.ok(statusCalls >= 2, "ambiguous consent re-enters read-only STATUS after the initial START status");
|
|
636
|
+
const callsBeforeStaleReconciliation = statusCalls;
|
|
637
|
+
const stale = await answerConsent(ambiguousRuntime, cwd, ambiguous.consent_binding, "granted");
|
|
638
|
+
assert.equal(stale.operation, "answer-consent");
|
|
639
|
+
assertStaleConsentBinding(stale, ambiguous.consent_binding, "consent-binding-already-consumed");
|
|
640
|
+
assert.equal(statusCalls, callsBeforeStaleReconciliation + 1, "already-consumed binding reconciliation performs exactly one additional STATUS call");
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("concurrent answers atomically claim consent before review-mode gating and ignore a queued expiry callback", async (t) => {
|
|
811
644
|
const cwd = repository(t);
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
645
|
+
const fixture = consentNative(cwd);
|
|
646
|
+
const candidateViews = new CandidateViewRegistry();
|
|
647
|
+
const cleanup = candidateViews.cleanup.bind(candidateViews);
|
|
648
|
+
let cleanupCalls = 0;
|
|
649
|
+
candidateViews.cleanup = (token) => {
|
|
650
|
+
cleanupCalls += 1;
|
|
651
|
+
cleanup(token);
|
|
818
652
|
};
|
|
819
|
-
const { controller, commands } = runtime(native);
|
|
820
|
-
const blocked = await blockedConsent(controller, "consent-before-disable", headlessContext(cwd));
|
|
821
|
-
const command = commands.get("gentle:review-mode")!;
|
|
822
|
-
await command.handler("disable", headlessContext(cwd));
|
|
823
|
-
await command.handler("enable", headlessContext(cwd));
|
|
824
|
-
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
825
|
-
assert.deepEqual(answers, []);
|
|
826
|
-
});
|
|
827
|
-
|
|
828
|
-
// Issue #264: an unused consent binding and the candidate view retained
|
|
829
|
-
// exclusively for that binding must expire as one lifecycle unit before any
|
|
830
|
-
// later START may reuse the view. Cleanup is synchronous with respect to the
|
|
831
|
-
// observable TTL; the queued cleanup macrotask is a safety net, not the
|
|
832
|
-
// authority, so a fresh-candidate retry after expiry must get a new binding
|
|
833
|
-
// rather than tripping candidate-target-projection-drift on a stale view.
|
|
834
|
-
const REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
|
|
835
|
-
|
|
836
|
-
function fakeConsentClock(): { now: () => number; scheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void }; advance: (ms: number) => void; scheduled: Array<() => void> } {
|
|
837
|
-
const start = Date.now();
|
|
838
|
-
let nowMs = start;
|
|
839
653
|
const scheduled: Array<() => void> = [];
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
654
|
+
let releaseModeGate: (() => void) | undefined;
|
|
655
|
+
const modeGate = new Promise<void>((resolve) => { releaseModeGate = resolve; });
|
|
656
|
+
let modeCalls = 0;
|
|
657
|
+
const statusRequests: Array<{ agent?: string }> = [];
|
|
658
|
+
fixture.native.targetStatus = async (request) => {
|
|
659
|
+
statusRequests.push(request);
|
|
660
|
+
return startStatus(cwd);
|
|
661
|
+
};
|
|
662
|
+
let releaseProvider: (() => void) | undefined;
|
|
663
|
+
const providerGate = new Promise<void>((resolve) => { releaseProvider = resolve; });
|
|
664
|
+
const nativeAnswer = fixture.native.answerConsent!;
|
|
665
|
+
let nativeAnswerCalls = 0;
|
|
666
|
+
fixture.native.answerConsent = async (request) => {
|
|
667
|
+
nativeAnswerCalls += 1;
|
|
668
|
+
assert.equal(cleanupCalls, 0, "a queued expiry callback must not clean claimed candidate authority before the provider answer");
|
|
669
|
+
await providerGate;
|
|
670
|
+
return await nativeAnswer(request);
|
|
671
|
+
};
|
|
672
|
+
const runtime = parityRuntime(fixture.native, {
|
|
673
|
+
candidateViews,
|
|
674
|
+
scheduleTimer: (callback) => { scheduled.push(callback); return { unref() {} }; },
|
|
675
|
+
});
|
|
676
|
+
const blocked = await beginConsent(runtime, cwd);
|
|
677
|
+
fixture.native.reviewMode = async () => {
|
|
678
|
+
modeCalls += 1;
|
|
679
|
+
if (modeCalls === 1) await modeGate;
|
|
680
|
+
return {
|
|
681
|
+
operation: "status",
|
|
682
|
+
scope: "clone",
|
|
683
|
+
status: { global: "", cloneLocal: "", effective: "on", source: "default" },
|
|
684
|
+
};
|
|
847
685
|
};
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
test("an expired unused binding prunes synchronously so a fresh-candidate retry gets a new binding, not candidate-target-projection-drift", async (t) => {
|
|
851
|
-
const cwd = repository(t);
|
|
852
|
-
const { native, startRequests } = relayedConsentNative(cwd);
|
|
853
|
-
const clock = fakeConsentClock();
|
|
854
|
-
const { controller } = runtime(native, recordReviewConsentLatch, { now: clock.now, scheduleTimer: clock.scheduleTimer });
|
|
855
|
-
// First START: consent-required, binding T1 retained with a queued cleanup timer.
|
|
856
|
-
const first = await blockedConsent(controller, "expired-fresh-1", headlessContext(cwd));
|
|
857
|
-
const firstBinding = first.consent_binding;
|
|
858
|
-
assert.equal(clock.scheduled.length, 1, "the TTL cleanup macrotask is queued for T1");
|
|
859
|
-
// The candidate changes: a later START on a FRESH candidate must not reuse T1.
|
|
860
|
-
writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
|
|
861
|
-
// Advance time to the exact TTL boundary without letting the queued cleanup macrotask fire.
|
|
862
|
-
clock.advance(REVIEW_CONSENT_TTL_MS);
|
|
863
|
-
assert.equal(clock.scheduled.length, 1, "the queued cleanup macrotask has not fired");
|
|
864
|
-
const second = await execStart(controller, "expired-fresh-2", headlessContext(cwd));
|
|
865
|
-
// The fix: a fresh consent-required binding, not candidate-target-projection-drift.
|
|
866
|
-
assert.equal(second.status, "blocked");
|
|
867
|
-
assert.equal(second.outcome, "native-review-consent-required", "the stale view must not trip candidate-target-projection-drift");
|
|
868
|
-
assert.equal(typeof second.consent_binding, "string");
|
|
869
|
-
assert.notEqual(second.consent_binding, firstBinding, "a fresh candidate gets a fresh binding, not the stale one");
|
|
870
|
-
assert.equal(startRequests.length, 2, "native start was attempted for both candidates");
|
|
871
|
-
assert.equal(clock.scheduled.length, 2, "the fresh binding queued its own cleanup macrotask");
|
|
872
|
-
});
|
|
873
686
|
|
|
874
|
-
|
|
687
|
+
const first = answerConsent(runtime, cwd, blocked.consent_binding, "declined");
|
|
688
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
689
|
+
assert.equal(modeCalls, 1, "the first answer pauses at the asynchronous review-mode gate");
|
|
690
|
+
const second = answerConsent(runtime, cwd, blocked.consent_binding, "declined");
|
|
691
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
692
|
+
assert.equal(scheduled.length, 1);
|
|
693
|
+
scheduled[0]!();
|
|
694
|
+
assert.equal(cleanupCalls, 0, "a queued expiry callback is a harmless no-op after the answer claim");
|
|
695
|
+
|
|
696
|
+
releaseModeGate!();
|
|
697
|
+
releaseProvider!();
|
|
698
|
+
const [firstResult, secondResult] = await Promise.all([first, second]);
|
|
699
|
+
assert.equal(firstResult.outcome, "consent-declined-this-candidate");
|
|
700
|
+
assertStaleConsentBinding(secondResult, blocked.consent_binding, "consent-binding-already-consumed");
|
|
701
|
+
assert.equal(nativeAnswerCalls, 1, "only the atomically claimed answer may invoke native answer-consent");
|
|
702
|
+
assert.equal(statusRequests.length, 2, "the losing concurrent answer performs one STATUS reconciliation after the initial START status");
|
|
703
|
+
assert.equal(statusRequests[1]?.agent, "pi", "the losing answer keeps Pi host transport for STATUS reconciliation");
|
|
704
|
+
assert.equal(cleanupCalls, 1, "only the answered binding cleans candidate authority");
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("timer-cleaned and synchronously pruned consent bindings remain expired within their session", async (t) => {
|
|
875
708
|
const cwd = repository(t);
|
|
876
|
-
const
|
|
877
|
-
const
|
|
878
|
-
const
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
const
|
|
709
|
+
const fixture = consentNative(cwd);
|
|
710
|
+
const registry = new PendingReviewConsentRegistry();
|
|
711
|
+
const start = 1_000;
|
|
712
|
+
let now = start;
|
|
713
|
+
const scheduled: Array<() => void> = [];
|
|
714
|
+
const runtime = parityRuntime(fixture.native, {
|
|
715
|
+
pendingReviewConsentRegistry: registry,
|
|
716
|
+
now: () => now,
|
|
717
|
+
scheduleTimer: (callback) => { scheduled.push(callback); return { unref() {} }; },
|
|
718
|
+
});
|
|
719
|
+
const first = await beginConsent(runtime, cwd);
|
|
720
|
+
const reused = await beginConsent(runtime, cwd);
|
|
721
|
+
assert.equal(reused.consent_binding, first.consent_binding);
|
|
722
|
+
assert.equal(scheduled.length, 1);
|
|
723
|
+
now += 10 * 60 * 1000;
|
|
724
|
+
// The real TTL callback drops the frozen candidate immediately. Its
|
|
725
|
+
// session-local disposition remains only to classify this stale answer.
|
|
726
|
+
scheduled[0]!();
|
|
727
|
+
const timerExpired = await answerConsent(runtime, cwd, first.consent_binding, "declined");
|
|
728
|
+
assert.equal(timerExpired.operation, "answer-consent");
|
|
729
|
+
assertStaleConsentBinding(timerExpired, first.consent_binding, "consent-binding-expired");
|
|
730
|
+
assert.match(String((timerExpired.diagnostics as { message?: unknown }).message), /expired after 10 minutes/);
|
|
731
|
+
const second = await beginConsent(runtime, cwd);
|
|
882
732
|
assert.notEqual(second.consent_binding, first.consent_binding);
|
|
883
|
-
assert.equal(
|
|
884
|
-
|
|
885
|
-
});
|
|
886
|
-
|
|
887
|
-
test("a non-expired same-candidate consent request reuses the same binding instead of creating a new one", async (t) => {
|
|
888
|
-
const cwd = repository(t);
|
|
889
|
-
const { native } = relayedConsentNative(cwd);
|
|
890
|
-
const clock = fakeConsentClock();
|
|
891
|
-
const { controller } = runtime(native, recordReviewConsentLatch, { now: clock.now, scheduleTimer: clock.scheduleTimer });
|
|
892
|
-
const first = await blockedConsent(controller, "dedup-1", headlessContext(cwd));
|
|
893
|
-
// Same candidate, same consent, still within TTL: the existing binding is reused.
|
|
894
|
-
const second = await blockedConsent(controller, "dedup-2", headlessContext(cwd));
|
|
895
|
-
assert.equal(second.consent_binding, first.consent_binding, "a non-expired same-candidate request deduplicates the binding");
|
|
896
|
-
assert.equal(clock.scheduled.length, 1, "no new cleanup macrotask is queued when the binding is reused");
|
|
897
|
-
});
|
|
898
|
-
|
|
899
|
-
test("an expired consent binding is rejected on answer-consent even before the queued cleanup macrotask fires", async (t) => {
|
|
900
|
-
const cwd = repository(t);
|
|
901
|
-
const { native } = relayedConsentNative(cwd);
|
|
902
|
-
const clock = fakeConsentClock();
|
|
903
|
-
const { controller } = runtime(native, recordReviewConsentLatch, { now: clock.now, scheduleTimer: clock.scheduleTimer });
|
|
904
|
-
const blocked = await blockedConsent(controller, "expired-answer-1", headlessContext(cwd));
|
|
905
|
-
// Advance to the exact TTL boundary without firing the queued cleanup macrotask.
|
|
906
|
-
clock.advance(REVIEW_CONSENT_TTL_MS);
|
|
907
|
-
assert.equal(clock.scheduled.length, 1, "the queued cleanup macrotask has not fired");
|
|
908
|
-
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
909
|
-
assert.equal(clock.scheduled.length, 1, "rejection was synchronous from the TTL observation, not from a fired macrotask");
|
|
910
|
-
});
|
|
911
|
-
|
|
912
|
-
test("candidate-scoped decline creates no authority and the next candidate asks again", async (t) => {
|
|
913
|
-
const cwd = repository(t);
|
|
914
|
-
const { native, answers } = relayedConsentNative(cwd);
|
|
915
|
-
const { controller } = runtime(native);
|
|
916
|
-
for (const id of ["decline-one", "decline-two"]) {
|
|
917
|
-
const blocked = await blockedConsent(controller, id, headlessContext(cwd));
|
|
918
|
-
const result = await answerConsent(controller, blocked.consent_binding, "declined", headlessContext(cwd));
|
|
919
|
-
assert.equal(result.outcome, "consent-declined-this-candidate");
|
|
920
|
-
assert.equal(result.lineage_created, false);
|
|
921
|
-
assert.equal("actor_binding" in result, false);
|
|
922
|
-
assert.equal("result" in result, false);
|
|
923
|
-
assert.equal(readReviewConsentLatch(cwd), false);
|
|
924
|
-
}
|
|
925
|
-
assert.deepEqual(answers, ["declined", "declined"]);
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
// Upstream gentle-ai.review-integration.consent/v3 consent is per-candidate:
|
|
929
|
-
// the clone-local latch records only that the one-time question was already
|
|
930
|
-
// put to the user, never that any later candidate is pre-approved. A fresh
|
|
931
|
-
// START with the latch already recorded must still relay the complete
|
|
932
|
-
// blocking envelope, and only an explicit ANSWER_CONSENT may resolve it.
|
|
933
|
-
test("a pre-recorded consent latch never suppresses a later candidate's consent envelope or auto-answers it", async (t) => {
|
|
934
|
-
const cwd = repository(t);
|
|
935
|
-
recordReviewConsentLatch(cwd);
|
|
936
|
-
assert.equal(readReviewConsentLatch(cwd), true);
|
|
937
|
-
const { native, answers, startRequests } = relayedConsentNative(cwd);
|
|
938
|
-
const { controller } = runtime(native);
|
|
939
|
-
const blocked = await blockedConsent(controller, "consent-latched-fresh-start", headlessContext(cwd));
|
|
940
|
-
assert.deepEqual(blocked.consent, candidateConsent(cwd).raw, "the full consent envelope is relayed despite the pre-recorded latch");
|
|
941
|
-
assert.deepEqual(answers, [], "the latch must never auto-answer a later candidate's consent");
|
|
942
|
-
assert.equal(blocked.lineage_created, false);
|
|
943
|
-
assert.equal(startRequests.length, 1);
|
|
944
|
-
const result = await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
|
|
945
|
-
assert.deepEqual(answers, ["granted"], "explicit ANSWER_CONSENT is still required to proceed");
|
|
946
|
-
assert.ok(result.result);
|
|
947
|
-
});
|
|
733
|
+
assert.equal(scheduled.length, 2);
|
|
734
|
+
assertStaleConsentBinding(await answerConsent(runtime, cwd, first.consent_binding, "declined"), first.consent_binding, "consent-binding-expired");
|
|
948
735
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
const result = await execStart(controller, "consent-low-risk", ctx);
|
|
956
|
-
assert.equal(selected, false);
|
|
957
|
-
assert.ok(result.result);
|
|
958
|
-
});
|
|
959
|
-
|
|
960
|
-
// ---------------------------------------------------------------------------
|
|
961
|
-
// Phase 5 (tier/hint/delivery passthrough): mapNativeStartResult renders
|
|
962
|
-
// risk_tier/risk_evidence/hint verbatim from the native start result, with
|
|
963
|
-
// zero local derivation (Design Decision #8, organic-rdd-parity).
|
|
964
|
-
// ---------------------------------------------------------------------------
|
|
965
|
-
|
|
966
|
-
test("mapNativeStartResult passes risk_evidence through verbatim as the native phrase array, alongside the unmodified risk_tier passthrough", async (t) => {
|
|
967
|
-
const cwd = repository(t);
|
|
968
|
-
const { native } = fakeOrganicNative({ riskEvidence: ["shell scripting in .github/workflows/deploy.yml"] });
|
|
969
|
-
const { controller } = runtime(native);
|
|
970
|
-
const result = await execStart(controller, "risk-evidence-passthrough", confirmContext(cwd, true));
|
|
971
|
-
const rendered = result.result as Record<string, unknown>;
|
|
972
|
-
assert.equal(rendered.risk_tier, "high", "risk_tier is native's riskLevel, verbatim, with no local recomputation");
|
|
973
|
-
assert.deepEqual(rendered.risk_evidence, ["shell scripting in .github/workflows/deploy.yml"]);
|
|
974
|
-
});
|
|
736
|
+
// No second callback runs: START synchronously prunes this unused binding
|
|
737
|
+
// before it can reuse the frozen candidate, and its answer stays typed expiry.
|
|
738
|
+
now += 10 * 60 * 1000;
|
|
739
|
+
const third = await beginConsent(runtime, cwd);
|
|
740
|
+
assert.notEqual(third.consent_binding, second.consent_binding);
|
|
741
|
+
assertStaleConsentBinding(await answerConsent(runtime, cwd, second.consent_binding, "declined"), second.consent_binding, "consent-binding-expired");
|
|
975
742
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
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",
|
|
982
|
-
});
|
|
983
|
-
const { controller } = runtime(native);
|
|
984
|
-
const result = await execStart(controller, "hint-passthrough", headlessContext(cwd));
|
|
985
|
-
const rendered = result.result as Record<string, unknown>;
|
|
986
|
-
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");
|
|
987
|
-
assert.equal(rendered.risk_evidence, undefined, "no local risk_evidence is ever fabricated when the native result omits it");
|
|
743
|
+
const reloaded = parityRuntime(fixture.native, { pendingReviewConsentRegistry: new PendingReviewConsentRegistry() });
|
|
744
|
+
assertStaleConsentBinding(await answerConsent(reloaded, cwd, first.consent_binding, "declined"), first.consent_binding, "consent-binding-unknown");
|
|
745
|
+
const afterReload = await beginConsent(reloaded, cwd);
|
|
746
|
+
assert.notEqual(afterReload.consent_binding, third.consent_binding);
|
|
747
|
+
assert.equal(fixture.starts.count, 5);
|
|
988
748
|
});
|
|
989
749
|
|
|
990
|
-
test("
|
|
750
|
+
test("native START maps only provider facts and omits absent evidence", async (t) => {
|
|
991
751
|
const cwd = repository(t);
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
752
|
+
const nextTransition = {
|
|
753
|
+
kind: "execute" as const,
|
|
754
|
+
reasonCode: "review_status_required",
|
|
755
|
+
execute: {
|
|
756
|
+
operation: "review.status",
|
|
757
|
+
arguments: [{ name: "lineage", value: "closed-lineage", token: "--lineage=closed-lineage" }],
|
|
758
|
+
preconditions: [],
|
|
759
|
+
binding: { targetIdentity: `sha256:${"d".repeat(64)}` },
|
|
760
|
+
},
|
|
761
|
+
};
|
|
762
|
+
const native = {
|
|
763
|
+
targetStatus: async () => startStatus(cwd),
|
|
764
|
+
start: async () => ({
|
|
765
|
+
lineageId: "closed-lineage",
|
|
766
|
+
state: "approved",
|
|
767
|
+
riskLevel: "low",
|
|
768
|
+
selectedLenses: [],
|
|
769
|
+
changedFiles: 0,
|
|
770
|
+
changedLines: 0,
|
|
771
|
+
correctionBudget: 0,
|
|
772
|
+
action: "closed",
|
|
773
|
+
lensesRequired: false,
|
|
774
|
+
riskReasons: [],
|
|
775
|
+
nextTransition,
|
|
776
|
+
hint: "provider empty-candidate hint",
|
|
777
|
+
}),
|
|
778
|
+
} as unknown as NativeReviewCli;
|
|
779
|
+
const result = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native);
|
|
995
780
|
const rendered = result.result as Record<string, unknown>;
|
|
781
|
+
assert.equal(rendered.hint, "provider empty-candidate hint");
|
|
996
782
|
assert.equal(rendered.risk_evidence, undefined);
|
|
997
|
-
assert.equal(rendered.
|
|
783
|
+
assert.equal(rendered.next_transition, nextTransition);
|
|
998
784
|
});
|
|
999
785
|
|
|
1000
|
-
test("
|
|
786
|
+
test("consent completion stays authoritative when local latch recording fails", async (t) => {
|
|
1001
787
|
const cwd = repository(t);
|
|
1002
|
-
const
|
|
1003
|
-
const { commands } = runtime(native);
|
|
1004
|
-
const command = commands.get("gentle:review-mode");
|
|
1005
|
-
assert.ok(command, "gentle:review-mode must be registered");
|
|
788
|
+
const fixture = consentNative(cwd);
|
|
1006
789
|
const notices: Array<{ message: string; type?: string }> = [];
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
});
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
// back on when it is a dead end.
|
|
1018
|
-
test("gentle:review-mode: an enable that cannot take effect says so and names the command that can", async (t) => {
|
|
1019
|
-
const cwd = repository(t);
|
|
1020
|
-
const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "global" });
|
|
1021
|
-
const { commands } = runtime(native);
|
|
1022
|
-
const command = commands.get("gentle:review-mode")!;
|
|
1023
|
-
const notices: Array<{ message: string; type?: string }> = [];
|
|
1024
|
-
await command.handler("enable", headlessContext(cwd, notices) as unknown as ExtensionContext);
|
|
1025
|
-
const notice = notices.at(-1)!;
|
|
1026
|
-
assert.equal(notice.type, "warning", "a request that did not take effect is not an informational result");
|
|
1027
|
-
assert.equal(
|
|
1028
|
-
notice.message,
|
|
1029
|
-
"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.",
|
|
1030
|
-
);
|
|
1031
|
-
});
|
|
1032
|
-
|
|
1033
|
-
test("gentle:review-mode: an enable that does take effect keeps the existing informational report", async (t) => {
|
|
1034
|
-
const cwd = repository(t);
|
|
1035
|
-
const { native } = fakeOrganicNative({ reviewModeEffective: "on", reviewModeSource: "default" });
|
|
1036
|
-
const { commands } = runtime(native);
|
|
1037
|
-
const command = commands.get("gentle:review-mode")!;
|
|
1038
|
-
const notices: Array<{ message: string; type?: string }> = [];
|
|
1039
|
-
await command.handler("enable", headlessContext(cwd, notices) as unknown as ExtensionContext);
|
|
1040
|
-
assert.deepEqual(notices, [{ message: "receipt-driven development: on (decided by default)", type: "info" }]);
|
|
1041
|
-
});
|
|
1042
|
-
|
|
1043
|
-
test("gentle:review-mode command reports unavailability without throwing when the capability is dark", async (t) => {
|
|
1044
|
-
const cwd = repository(t);
|
|
1045
|
-
const { native } = fakeOrganicNative({ reviewModeCapable: false });
|
|
1046
|
-
const { commands } = runtime(native);
|
|
1047
|
-
const command = commands.get("gentle:review-mode")!;
|
|
1048
|
-
const notices: Array<{ message: string; type?: string }> = [];
|
|
1049
|
-
await command.handler("status", headlessContext(cwd, notices) as unknown as ExtensionContext);
|
|
1050
|
-
assert.equal(notices.length, 1);
|
|
790
|
+
const runtime = parityRuntime(fixture.native, {
|
|
791
|
+
writeConsentLatch: () => { throw new Error("injected latch failure"); },
|
|
792
|
+
});
|
|
793
|
+
const blocked = await beginConsent(runtime, cwd);
|
|
794
|
+
const completed = await answerConsent(runtime, cwd, blocked.consent_binding, "granted");
|
|
795
|
+
assert.equal((completed.result as { lineage_id?: string }).lineage_id, "consent-lineage");
|
|
796
|
+
assert.deepEqual(fixture.answers, ["granted"]);
|
|
797
|
+
assert.deepEqual(notices, []);
|
|
798
|
+
// The extension handler owns UI reporting; the controller's successful native
|
|
799
|
+
// result remains authoritative even when the local best-effort latch fails.
|
|
1051
800
|
});
|