gentle-pi 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -12
- package/extensions/gentle-ai.ts +313 -119
- package/lib/gentle-ai-binary.ts +64 -17
- package/lib/native-review-cli.ts +158 -12
- package/lib/review-candidate-view.ts +9 -5
- package/lib/review-integration-v2.ts +5 -1
- package/package.json +1 -1
- package/runtime/gentle-ai-binary.mjs +64 -17
- package/runtime/native-review-cli.mjs +158 -12
- package/runtime/review-integration-v2.mjs +5 -1
- package/scripts/gentle-ai-installer.mjs +454 -71
- package/scripts/test-packed-runner.mjs +1 -1
- package/skills/_shared/review-ledger-contract.md +3 -1
- package/tests/gentle-ai-binary.test.ts +70 -3
- package/tests/gentle-ai-installer.test.ts +543 -7
- package/tests/gentle-ai.test.ts +2 -2
- package/tests/native-review-consent.test.ts +128 -0
- package/tests/native-review-parity-runtime.test.ts +15 -6
- package/tests/native-review-parity.test.ts +155 -46
- package/tests/package-manifest.test.ts +10 -3
- package/tests/review-candidate-view.test.ts +43 -0
- package/tests/review-controller-native-routing.test.ts +247 -6
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-integration-v2.test.ts +10 -1
- package/tests/runtime-harness.mjs +24 -12
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { GENTLE_AI_VERSION } from "../lib/gentle-ai-binary.ts";
|
|
6
|
+
import {
|
|
7
|
+
NativeReviewCliV216,
|
|
8
|
+
NativeReviewConsentRequiredError,
|
|
9
|
+
clearNativeReviewCapabilitiesCacheForTesting,
|
|
10
|
+
type ExecFileAdapter,
|
|
11
|
+
} from "../lib/native-review-cli.ts";
|
|
12
|
+
import type { ReviewConsentV2 } from "../lib/review-integration-v2.ts";
|
|
13
|
+
|
|
14
|
+
const fixtureRoot = join(process.cwd(), "contracts", "review-integration", "v2", "fixtures");
|
|
15
|
+
const fixture = <T = Record<string, unknown>>(name: string): T => JSON.parse(readFileSync(join(fixtureRoot, name), "utf8")) as T;
|
|
16
|
+
const executableDigest = "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705";
|
|
17
|
+
|
|
18
|
+
function capabilities(): Record<string, unknown> {
|
|
19
|
+
const value = fixture<Record<string, unknown>>("capabilities.fixture.json");
|
|
20
|
+
(value.package as Record<string, unknown>).version = GENTLE_AI_VERSION;
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function unrelatedStatus(targetIdentity: string): Record<string, unknown> {
|
|
25
|
+
const status = fixture<Record<string, unknown>>("status.fixture.json");
|
|
26
|
+
status.applicability = "unrelated";
|
|
27
|
+
status.receipt = { status: "not_applicable" };
|
|
28
|
+
status.action = "start";
|
|
29
|
+
status.replayability = "not_replayable";
|
|
30
|
+
status.target_identity = targetIdentity;
|
|
31
|
+
status.candidates = [];
|
|
32
|
+
delete status.authority;
|
|
33
|
+
delete status.frozen;
|
|
34
|
+
delete status.next_transition;
|
|
35
|
+
const projection = status.projection as Record<string, unknown>;
|
|
36
|
+
projection.initial_snapshot_identity = targetIdentity;
|
|
37
|
+
projection.current_snapshot_identity = targetIdentity;
|
|
38
|
+
return status;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function queuedAdapter(outputs: readonly Record<string, unknown>[]): { adapter: ExecFileAdapter; calls: Array<readonly string[]> } {
|
|
42
|
+
const queue = [...outputs];
|
|
43
|
+
const calls: Array<readonly string[]> = [];
|
|
44
|
+
return {
|
|
45
|
+
calls,
|
|
46
|
+
adapter: async (request) => {
|
|
47
|
+
calls.push(request.arguments);
|
|
48
|
+
const body = queue.shift();
|
|
49
|
+
if (body === undefined) throw new Error("unexpected native invocation");
|
|
50
|
+
return { stdout: JSON.stringify(body), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function client(adapter: ExecFileAdapter): NativeReviewCliV216 {
|
|
56
|
+
clearNativeReviewCapabilitiesCacheForTesting();
|
|
57
|
+
return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => executableDigest);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("negotiated ordinary START declares relay and preserves the complete target-bound consent envelope", async () => {
|
|
61
|
+
const consent = fixture<Record<string, unknown>>("consent.fixture.json");
|
|
62
|
+
const target = String(consent.target_identity);
|
|
63
|
+
const queue = queuedAdapter([
|
|
64
|
+
capabilities(),
|
|
65
|
+
unrelatedStatus(target),
|
|
66
|
+
consent,
|
|
67
|
+
]);
|
|
68
|
+
await assert.rejects(
|
|
69
|
+
() => client(queue.adapter).start({ cwd: "/repo" }),
|
|
70
|
+
(error: unknown) => {
|
|
71
|
+
assert.ok(error instanceof NativeReviewConsentRequiredError);
|
|
72
|
+
assert.deepEqual(error.consent.raw, consent);
|
|
73
|
+
assert.equal(error.consent.targetIdentity, target);
|
|
74
|
+
return true;
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
assert.deepEqual(queue.calls.at(-1), [
|
|
78
|
+
"review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
|
|
79
|
+
"--target", target, "--projection", "workspace", "--consent", "relay",
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("consent follow-up executes the provider-named invocation exactly once and refuses a changed target binding", async () => {
|
|
84
|
+
const consent = fixture<ReviewConsentV2 extends never ? never : Record<string, unknown>>("consent.fixture.json");
|
|
85
|
+
const decodedConsent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(consent);
|
|
86
|
+
const started = JSON.parse(JSON.stringify(fixture<Record<string, unknown>>("start.fixture.json"))
|
|
87
|
+
.replaceAll("review-start-fixture", "review-consent-fixture")) as Record<string, unknown>;
|
|
88
|
+
const queue = queuedAdapter([capabilities(), started]);
|
|
89
|
+
const native = client(queue.adapter);
|
|
90
|
+
const result = await native.answerConsent!({ cwd: "/repo", consent: decodedConsent, answer: "granted" });
|
|
91
|
+
assert.equal(result.kind, "started");
|
|
92
|
+
assert.deepEqual(queue.calls.at(-1), decodedConsent.choices[0].invocation.split(" ").slice(1));
|
|
93
|
+
assert.equal(queue.calls.filter((arguments_) => arguments_.includes("granted")).length, 1);
|
|
94
|
+
|
|
95
|
+
const changed = structuredClone(decodedConsent);
|
|
96
|
+
changed.targetIdentity = `sha256:${"b".repeat(64)}`;
|
|
97
|
+
await assert.rejects(
|
|
98
|
+
() => native.answerConsent!({ cwd: "/repo", consent: changed, answer: "declined" }),
|
|
99
|
+
/target|binding/,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("declined consent decodes the provider's explicit empty authority fields without creating a lineage", async () => {
|
|
104
|
+
const rawConsent = fixture<Record<string, unknown>>("consent.fixture.json");
|
|
105
|
+
const consent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(rawConsent);
|
|
106
|
+
const declined = {
|
|
107
|
+
operation: "review/start",
|
|
108
|
+
action: "declined",
|
|
109
|
+
lenses_required: false,
|
|
110
|
+
lineage_id: "",
|
|
111
|
+
state: "",
|
|
112
|
+
risk_level: "high",
|
|
113
|
+
selected_lenses: [],
|
|
114
|
+
lens_bindings: [],
|
|
115
|
+
projection: "workspace",
|
|
116
|
+
target_identity: consent.targetIdentity,
|
|
117
|
+
changed_files: 1,
|
|
118
|
+
changed_lines: 1,
|
|
119
|
+
correction_budget: 0,
|
|
120
|
+
risk_evidence: ["shell scripting in scripts/deploy.sh"],
|
|
121
|
+
consent: "declined_this_candidate",
|
|
122
|
+
};
|
|
123
|
+
const queue = queuedAdapter([capabilities(), declined]);
|
|
124
|
+
const result = await client(queue.adapter).answerConsent!({ cwd: "/repo", consent, answer: "declined" });
|
|
125
|
+
assert.equal(result.kind, "declined");
|
|
126
|
+
assert.equal("lineageId" in result, false);
|
|
127
|
+
assert.deepEqual(queue.calls.at(-1), consent.choices[1].invocation.split(" ").slice(1));
|
|
128
|
+
});
|
|
@@ -378,7 +378,7 @@ test("official pinned package runtime keeps frozen candidate lineages and receip
|
|
|
378
378
|
t.diagnostic("pre-push, pre-pr, and release require remote/publication evidence; their network-aware gate contracts remain covered by dedicated gate integration tests rather than this hermetic binary E2E.");
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
-
test("registered gentle_review START materializes a safe internal skill symlink
|
|
381
|
+
test("registered gentle_review START materializes a safe internal skill symlink and cleans pending consent on session shutdown", async (t) => {
|
|
382
382
|
const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-symlink-candidate-"));
|
|
383
383
|
const repository = join(workspace, "repository");
|
|
384
384
|
t.after(async () => rm(workspace, { recursive: true, force: true }));
|
|
@@ -412,8 +412,9 @@ test("registered gentle_review START materializes a safe internal skill symlink
|
|
|
412
412
|
return { ...command, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
413
413
|
});
|
|
414
414
|
const tools = new Map<string, RegisteredController>();
|
|
415
|
+
let sessionShutdown: ((event: unknown, context: ExtensionContext) => Promise<void> | void) | undefined;
|
|
415
416
|
createGentleAiExtension({ nativeReviewCli: native, candidateViews } as Parameters<typeof createGentleAiExtension>[0])({
|
|
416
|
-
on() {},
|
|
417
|
+
on(name: string, handler: (event: unknown, context: ExtensionContext) => Promise<void> | void) { if (name === "session_shutdown") sessionShutdown = handler; },
|
|
417
418
|
registerTool(definition: RegisteredController & { name: string }) { tools.set(definition.name, definition); },
|
|
418
419
|
registerCommand() {},
|
|
419
420
|
} as unknown as ExtensionAPI);
|
|
@@ -434,8 +435,16 @@ test("registered gentle_review START materializes a safe internal skill symlink
|
|
|
434
435
|
t.diagnostic(JSON.stringify({ returned: returned?.details, error, nativeStartReached }));
|
|
435
436
|
assert.equal(thrown, undefined, "safe internal symlink materialization must not throw before START");
|
|
436
437
|
assert.equal(nativeStartReached, true, "safe internal symlink materialization must reach native START");
|
|
437
|
-
const result =
|
|
438
|
-
assert.equal(
|
|
439
|
-
assert.equal(result?.
|
|
440
|
-
|
|
438
|
+
const result = returned?.details as Record<string, unknown> | undefined;
|
|
439
|
+
assert.equal(result?.status, "blocked");
|
|
440
|
+
assert.equal(result?.outcome, "native-review-consent-required");
|
|
441
|
+
assert.equal(typeof result?.consent_binding, "string");
|
|
442
|
+
assert.equal(result?.lineage_created, false);
|
|
443
|
+
assert.ok(sessionShutdown, "extension must register session_shutdown cleanup");
|
|
444
|
+
const context = { cwd: repository, hasUI: false, ui: { notify: () => {} } } as unknown as ExtensionContext;
|
|
445
|
+
await sessionShutdown({}, context);
|
|
446
|
+
await assert.rejects(
|
|
447
|
+
controller.execute("answer-after-shutdown", { operation: "answer-consent", input: JSON.stringify({ consentBinding: result!.consent_binding, answer: "granted" }) }, undefined, undefined, context),
|
|
448
|
+
/unknown, expired, or already consumed/,
|
|
449
|
+
);
|
|
441
450
|
});
|
|
@@ -9,14 +9,16 @@ import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
|
9
9
|
import {
|
|
10
10
|
NATIVE_REVIEW_ERROR_CODE,
|
|
11
11
|
NativeReviewCliError,
|
|
12
|
+
NativeReviewConsentRequiredError,
|
|
12
13
|
NativeReviewCliV213 as NativeReviewCliV213Production,
|
|
13
14
|
setNativeCliContractForTesting,
|
|
14
15
|
type ExecFileAdapter,
|
|
15
16
|
type NativeReviewCli,
|
|
17
|
+
type NativeReviewConsentAnswer,
|
|
16
18
|
} from "../lib/native-review-cli.ts";
|
|
17
19
|
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
18
|
-
import {
|
|
19
|
-
import type { AuthorityRepairAssessmentV1, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
20
|
+
import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
|
|
21
|
+
import type { AuthorityRepairAssessmentV1, ReviewConsentV2, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
20
22
|
|
|
21
23
|
// Queued-adapter clients never execute a real process; default to a fixed
|
|
22
24
|
// absolute package-local path so these tests do not depend on an installed
|
|
@@ -407,18 +409,23 @@ interface RegisteredCommandFixture {
|
|
|
407
409
|
handler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
408
410
|
}
|
|
409
411
|
|
|
410
|
-
|
|
412
|
+
interface RegisteredEventFixture {
|
|
413
|
+
(event: unknown, ctx: ExtensionContext): Promise<unknown> | unknown;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function runtime(nativeReviewCli: NativeReviewCli | null): { controller: RegisteredTool; commands: Map<string, RegisteredCommandFixture>; events: Map<string, RegisteredEventFixture> } {
|
|
411
417
|
const tools = new Map<string, RegisteredTool>();
|
|
412
418
|
const commands = new Map<string, RegisteredCommandFixture>();
|
|
419
|
+
const events = new Map<string, RegisteredEventFixture>();
|
|
413
420
|
const dependencies = { nativeReviewCli, candidateViews: new CandidateViewRegistry() } as unknown as Parameters<typeof createGentleAiExtension>[0];
|
|
414
421
|
createGentleAiExtension(dependencies)({
|
|
415
|
-
on() {},
|
|
422
|
+
on(name: string, handler: RegisteredEventFixture) { events.set(name, handler); },
|
|
416
423
|
registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
|
|
417
424
|
registerCommand(name: string, definition: RegisteredCommandFixture) { commands.set(name, definition); },
|
|
418
425
|
} as unknown as ExtensionAPI);
|
|
419
426
|
const controller = tools.get("gentle_review");
|
|
420
427
|
assert.ok(controller);
|
|
421
|
-
return { controller: controller!, commands };
|
|
428
|
+
return { controller: controller!, commands, events };
|
|
422
429
|
}
|
|
423
430
|
|
|
424
431
|
function headlessContext(cwd: string, notices: Array<{ message: string; type?: string }> = []): ExtensionContext {
|
|
@@ -447,6 +454,7 @@ test("kill-switch: effective off returns a non-failure skipped envelope and neve
|
|
|
447
454
|
const result = await execStart(controller, "kill-switch-off", headlessContext(cwd));
|
|
448
455
|
assert.equal(result.status, "skipped");
|
|
449
456
|
assert.equal(result.outcome, "review-mode-disabled");
|
|
457
|
+
assert.equal(result.delivery, "disabled/unmanaged");
|
|
450
458
|
assert.equal(result.mutation_performed, false);
|
|
451
459
|
assert.equal(state.startCalls, 0, "native start must not be called once the kill switch reports off");
|
|
452
460
|
});
|
|
@@ -516,67 +524,168 @@ test("kill-switch: an unexpected reviewMode failure maps to the existing native-
|
|
|
516
524
|
assert.equal(result.outcome, "native-operation-failed");
|
|
517
525
|
});
|
|
518
526
|
|
|
519
|
-
|
|
527
|
+
function candidateConsent(cwd: string): ReviewConsentV2 {
|
|
528
|
+
const targetIdentity = `sha256:${"a".repeat(64)}`;
|
|
529
|
+
const choices = [
|
|
530
|
+
{ answer: "granted" as const, label: "Run the review now", effect: "Review this exact candidate only.", invocation: `gentle-ai review start --contract gentle-ai.review-integration/v2 --cwd ${cwd} --target ${targetIdentity} --projection workspace --lineage native-lineage --consent granted` },
|
|
531
|
+
{ answer: "declined" as const, label: "Not now, just this once", effect: "Create no authority and ask again next candidate.", invocation: `gentle-ai review start --contract gentle-ai.review-integration/v2 --cwd ${cwd} --target ${targetIdentity} --projection workspace --lineage native-lineage --consent declined` },
|
|
532
|
+
] as const;
|
|
533
|
+
const raw = { schema: "gentle-ai.review-integration.consent/v2", contract: "gentle-ai.review-integration/v2", operation: "review.start", action: "consent_required", blocking: true, target_identity: targetIdentity, projection: "workspace", risk_level: "high", changed_files: 1, changed_lines: 1, headline: "Review this candidate", reason: "It changes a process boundary.", value: "Review catches regressions.", risk_evidence: ["shell process"], choices: choices.map((choice) => ({ ...choice })), off_path: { note: "Disable reviews separately.", command: "gentle-ai review mode disable" } };
|
|
534
|
+
return { schema: "gentle-ai.review-integration.consent/v2", contract: "gentle-ai.review-integration/v2", operation: "review.start", action: "consent_required", blocking: true, targetIdentity, projection: "workspace", riskLevel: "high", changedFiles: 1, changedLines: 1, headline: "Review this candidate", reason: "It changes a process boundary.", value: "Review catches regressions.", riskEvidence: ["shell process"], choices, offPath: { note: "Disable reviews separately.", command: "gentle-ai review mode disable" }, raw };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function relayedConsentNative(cwd: string): { native: NativeReviewCli; answers: NativeReviewConsentAnswer[] } {
|
|
538
|
+
const { native } = fakeOrganicNative();
|
|
539
|
+
const consent = candidateConsent(cwd);
|
|
540
|
+
const answers: NativeReviewConsentAnswer[] = [];
|
|
541
|
+
native.start = async () => { throw new NativeReviewConsentRequiredError(consent); };
|
|
542
|
+
native.answerConsent = async (request) => {
|
|
543
|
+
answers.push(request.answer);
|
|
544
|
+
if (request.answer === "declined") return { kind: "declined", targetIdentity: consent.targetIdentity, projection: "workspace", riskLevel: "high", changedFiles: 1, changedLines: 1, consent: "declined_this_candidate", raw: { operation: "review/start", action: "declined", consent: "declined_this_candidate" } };
|
|
545
|
+
return { kind: "started", start: { lineageId: "native-lineage", state: "reviewing", riskLevel: "high", selectedLenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true } };
|
|
546
|
+
};
|
|
547
|
+
return { native, answers };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function answerConsent(controller: RegisteredTool, binding: unknown, answer: unknown, ctx: ExtensionContext): Promise<Record<string, unknown>> {
|
|
551
|
+
const { details } = await controller.execute("consent-answer", { operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer }) }, undefined, undefined, ctx);
|
|
552
|
+
return details as Record<string, unknown>;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function blockedConsent(controller: RegisteredTool, id: string, ctx: ExtensionContext): Promise<Record<string, unknown>> {
|
|
556
|
+
const result = await execStart(controller, id, ctx);
|
|
557
|
+
assert.equal(result.status, "blocked");
|
|
558
|
+
assert.equal(result.outcome, "native-review-consent-required");
|
|
559
|
+
assert.equal(typeof result.consent_binding, "string");
|
|
560
|
+
return result;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
test("consent relay returns the identical complete parent-visible envelope with or without UI and never answers internally", async (t) => {
|
|
520
564
|
const cwd = repository(t);
|
|
521
565
|
recordReviewConsentLatch(cwd);
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
566
|
+
for (const ctx of [
|
|
567
|
+
{ cwd, hasUI: true, ui: { select: async () => { throw new Error("internal consent UI must not run"); }, notify: () => {} } } as unknown as ExtensionContext,
|
|
568
|
+
headlessContext(cwd),
|
|
569
|
+
]) {
|
|
570
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
571
|
+
const result = await blockedConsent(runtime(native).controller, "consent-blocked", ctx);
|
|
572
|
+
assert.deepEqual(result.consent, candidateConsent(cwd).raw);
|
|
573
|
+
assert.deepEqual(answers, []);
|
|
574
|
+
assert.equal(result.lineage_created, false);
|
|
575
|
+
}
|
|
527
576
|
});
|
|
528
577
|
|
|
529
|
-
test("consent
|
|
578
|
+
test("explicit consent follow-up grants or declines exactly once", async (t) => {
|
|
530
579
|
const cwd = repository(t);
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
580
|
+
for (const answer of ["granted", "declined"] as const) {
|
|
581
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
582
|
+
const { controller } = runtime(native);
|
|
583
|
+
const blocked = await blockedConsent(controller, `consent-${answer}`, headlessContext(cwd));
|
|
584
|
+
const result = await answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd));
|
|
585
|
+
assert.deepEqual(answers, [answer]);
|
|
586
|
+
if (answer === "granted") assert.ok(result.actor_binding);
|
|
587
|
+
else {
|
|
588
|
+
assert.equal(result.outcome, "consent-declined-this-candidate");
|
|
589
|
+
assert.equal(result.lineage_created, false);
|
|
590
|
+
assert.equal(result.actor_binding, undefined);
|
|
591
|
+
}
|
|
592
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
593
|
+
}
|
|
539
594
|
});
|
|
540
595
|
|
|
541
|
-
test("consent
|
|
596
|
+
test("consent follow-up rejects invalid token, unknown id, changed cwd, and changed target binding", async (t) => {
|
|
597
|
+
const cwd = repository(t);
|
|
598
|
+
const consent = candidateConsent(cwd);
|
|
599
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
600
|
+
native.start = async () => { throw new NativeReviewConsentRequiredError(consent); };
|
|
601
|
+
const { controller } = runtime(native);
|
|
602
|
+
const blocked = await blockedConsent(controller, "consent-invalid", headlessContext(cwd));
|
|
603
|
+
await assert.rejects(() => controller.execute("malformed", { operation: "answer-consent", input: "not-json" }, undefined, undefined, headlessContext(cwd)), /input is not valid JSON/);
|
|
604
|
+
await assert.rejects(() => controller.execute("extra", { operation: "answer-consent", input: JSON.stringify({ consentBinding: blocked.consent_binding, answer: "granted", target: "substitute" }) }, undefined, undefined, headlessContext(cwd)), /exactly consentBinding and answer/);
|
|
605
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "yes", headlessContext(cwd)), /answer must be granted or declined/);
|
|
606
|
+
await assert.rejects(() => answerConsent(controller, "missing", "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
607
|
+
const other = repository(t);
|
|
608
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(other)), /repository binding changed/);
|
|
609
|
+
const originalTarget = consent.targetIdentity;
|
|
610
|
+
(consent as { targetIdentity: string }).targetIdentity = `sha256:${"b".repeat(64)}`;
|
|
611
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
|
|
612
|
+
(consent as { targetIdentity: string }).targetIdentity = originalTarget;
|
|
613
|
+
(consent as { projection: string }).projection = "staged";
|
|
614
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
|
|
615
|
+
(consent as { projection: string }).projection = "workspace";
|
|
616
|
+
(consent.choices[0] as { invocation: string }).invocation = consent.choices[0].invocation.replace("native-lineage", "changed-lineage");
|
|
617
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
|
|
618
|
+
assert.deepEqual(answers, []);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("ambiguous consent mutation consumes the one-shot binding and requires status instead of blind replay", async (t) => {
|
|
542
622
|
const cwd = repository(t);
|
|
543
|
-
const { native } =
|
|
623
|
+
const { native } = relayedConsentNative(cwd);
|
|
624
|
+
let statusCalls = 0;
|
|
625
|
+
const targetStatus = native.targetStatus!;
|
|
626
|
+
native.targetStatus = async (request) => { statusCalls += 1; return await targetStatus(request); };
|
|
627
|
+
native.answerConsent = async () => { throw Object.assign(new Error("ambiguous"), { mutationOutcome: "unknown", nextAction: "review.status" }); };
|
|
544
628
|
const { controller } = runtime(native);
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
assert.
|
|
548
|
-
assert.
|
|
629
|
+
const blocked = await blockedConsent(controller, "consent-ambiguous", headlessContext(cwd));
|
|
630
|
+
await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
|
|
631
|
+
assert.equal(statusCalls, 2, "ambiguous consent must reconcile through target status");
|
|
632
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
549
633
|
});
|
|
550
634
|
|
|
551
|
-
test("
|
|
635
|
+
test("session shutdown clears pending candidate consent bindings and is idempotent", async (t) => {
|
|
552
636
|
const cwd = repository(t);
|
|
553
|
-
const { native } =
|
|
554
|
-
const { controller } = runtime(native);
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
assert.
|
|
558
|
-
|
|
637
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
638
|
+
const { controller, events } = runtime(native);
|
|
639
|
+
const blocked = await blockedConsent(controller, "consent-before-shutdown", headlessContext(cwd));
|
|
640
|
+
const shutdown = events.get("session_shutdown");
|
|
641
|
+
assert.ok(shutdown);
|
|
642
|
+
await shutdown({}, headlessContext(cwd));
|
|
643
|
+
await shutdown({}, headlessContext(cwd));
|
|
644
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
645
|
+
assert.deepEqual(answers, []);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("successful explicit disable clears pending candidate consent binding even after re-enable", async (t) => {
|
|
649
|
+
const cwd = repository(t);
|
|
650
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
651
|
+
let effective: "on" | "off" = "on";
|
|
652
|
+
native.reviewMode = async (request) => {
|
|
653
|
+
if (request.operation === "disable") effective = "off";
|
|
654
|
+
if (request.operation === "enable") effective = "on";
|
|
655
|
+
return { operation: request.operation, scope: "clone", status: { global: "", cloneLocal: effective === "off" ? "off" : "", effective, source: effective === "off" ? "clone_local" : "default" } };
|
|
656
|
+
};
|
|
657
|
+
const { controller, commands } = runtime(native);
|
|
658
|
+
const blocked = await blockedConsent(controller, "consent-before-disable", headlessContext(cwd));
|
|
659
|
+
const command = commands.get("gentle:review-mode")!;
|
|
660
|
+
await command.handler("disable", headlessContext(cwd));
|
|
661
|
+
await command.handler("enable", headlessContext(cwd));
|
|
662
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
663
|
+
assert.deepEqual(answers, []);
|
|
559
664
|
});
|
|
560
665
|
|
|
561
|
-
test("
|
|
666
|
+
test("candidate-scoped decline creates no authority and the next candidate asks again", async (t) => {
|
|
562
667
|
const cwd = repository(t);
|
|
563
|
-
const { native } =
|
|
668
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
564
669
|
const { controller } = runtime(native);
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
670
|
+
for (const id of ["decline-one", "decline-two"]) {
|
|
671
|
+
const blocked = await blockedConsent(controller, id, headlessContext(cwd));
|
|
672
|
+
const result = await answerConsent(controller, blocked.consent_binding, "declined", headlessContext(cwd));
|
|
673
|
+
assert.equal(result.outcome, "consent-declined-this-candidate");
|
|
674
|
+
assert.equal(result.lineage_created, false);
|
|
675
|
+
assert.equal(result.actor_binding, undefined);
|
|
676
|
+
}
|
|
677
|
+
assert.deepEqual(answers, ["declined", "declined"]);
|
|
569
678
|
});
|
|
570
679
|
|
|
571
|
-
test("
|
|
680
|
+
test("low-risk zero-lens START remains silent", async (t) => {
|
|
572
681
|
const cwd = repository(t);
|
|
573
|
-
const { native } = fakeOrganicNative({ riskEvidence: undefined, lensesRequired:
|
|
682
|
+
const { native } = fakeOrganicNative({ riskEvidence: undefined, lensesRequired: false });
|
|
574
683
|
const { controller } = runtime(native);
|
|
575
|
-
let
|
|
576
|
-
const ctx = { cwd, hasUI: true, ui: {
|
|
577
|
-
const result = await execStart(controller, "consent-
|
|
578
|
-
assert.equal(
|
|
579
|
-
assert.ok(result.
|
|
684
|
+
let selected = false;
|
|
685
|
+
const ctx = { cwd, hasUI: true, ui: { select: async () => { selected = true; return undefined; }, notify: () => {} } } as unknown as ExtensionContext;
|
|
686
|
+
const result = await execStart(controller, "consent-low-risk", ctx);
|
|
687
|
+
assert.equal(selected, false);
|
|
688
|
+
assert.ok(result.result);
|
|
580
689
|
});
|
|
581
690
|
|
|
582
691
|
// ---------------------------------------------------------------------------
|
|
@@ -212,7 +212,8 @@ test("installed commit transaction runner loads JavaScript only and has determin
|
|
|
212
212
|
assert.equal(packageJson.scripts?.["test:packed-runner"], "node scripts/test-packed-runner.mjs");
|
|
213
213
|
assert.match(packageJson.scripts?.prepublishOnly ?? "", /pnpm run test:packed-runner/);
|
|
214
214
|
assert.match(ci, /pnpm run test:packed-runner/);
|
|
215
|
-
assert.
|
|
215
|
+
assert.match(packedRunner, /\["install"[^\]]*"--ignore-scripts=false"/s, "packed install must explicitly enable postinstall");
|
|
216
|
+
assert.doesNotMatch(packedRunner, /\["install"[^\]]*"--ignore-scripts"(?!\=false)/s);
|
|
216
217
|
assert.match(packedRunner, /execFileSync\("where\.exe", \["npm"\]/);
|
|
217
218
|
assert.match(packedRunner, /could not resolve npm-cli\.js without a command shell/);
|
|
218
219
|
assert.doesNotMatch(packedRunner, /ComSpec|cmd\.exe/);
|
|
@@ -275,7 +276,13 @@ test("package verification binds the published Gentle AI v2.2.2 runtime pin", ()
|
|
|
275
276
|
const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
|
|
276
277
|
|
|
277
278
|
assert.match(installer, /INSTALLER_VERSION = "2\.2\.2"/);
|
|
279
|
+
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
|
|
280
|
+
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:YZcI5dRvoHm82I2CULvgBkB2M3UQQGarYO\/u\/Nt5LSc="/);
|
|
281
|
+
assert.match(installer, /GOTOOLCHAIN: "local"/);
|
|
282
|
+
assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
|
|
278
283
|
assert.match(binary, /GENTLE_AI_VERSION = "2\.2\.2"/);
|
|
284
|
+
assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
|
|
285
|
+
assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
|
|
279
286
|
assert.match(verifier, /v2\.2\.2/);
|
|
280
287
|
});
|
|
281
288
|
|
|
@@ -1130,9 +1137,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
|
|
|
1130
1137
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1131
1138
|
});
|
|
1132
1139
|
|
|
1133
|
-
test("v2.
|
|
1140
|
+
test("v2.1.0 release package and runtime stop before publication", () => {
|
|
1134
1141
|
const packageJson = readPackageJson();
|
|
1135
|
-
assert.equal(packageJson.version, "2.
|
|
1142
|
+
assert.equal(packageJson.version, "2.1.0", "the release manifest must remain explicitly pinned to v2.1.0");
|
|
1136
1143
|
assert.equal(
|
|
1137
1144
|
packageJson.scripts?.test,
|
|
1138
1145
|
"node --experimental-strip-types --test tests/*.test.ts && pnpm run test:harness",
|
|
@@ -584,6 +584,49 @@ test("native projections reconstruct symlink, intended-untracked, and gitlink id
|
|
|
584
584
|
assert.deepEqual(projection.gitlinks, { "vendor/dependency": gitlinkCommit });
|
|
585
585
|
});
|
|
586
586
|
|
|
587
|
+
test("native staged projections restore the exact current index over HEAD", (t) => {
|
|
588
|
+
const contributorRoot = repository(t);
|
|
589
|
+
const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
|
|
590
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "staged candidate\n");
|
|
591
|
+
git(contributorRoot, "add", "tracked.txt");
|
|
592
|
+
const candidateTree = git(contributorRoot, "write-tree");
|
|
593
|
+
const registry = new CandidateViewRegistry();
|
|
594
|
+
|
|
595
|
+
registry.restoreProjectionFromNative("staged-index-projection", contributorRoot, {
|
|
596
|
+
baseTree,
|
|
597
|
+
currentCandidateTree: candidateTree,
|
|
598
|
+
paths: ["tracked.txt"],
|
|
599
|
+
intendedUntracked: [],
|
|
600
|
+
projection: "staged",
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const projection = registry.resolveProjection("staged-index-projection", contributorRoot);
|
|
604
|
+
assert.equal(projection.baseTree, baseTree);
|
|
605
|
+
assert.equal(projection.candidateTree, candidateTree);
|
|
606
|
+
assert.equal(projection.committedOnly, false);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("native staged projections reject a workspace snapshot that differs from the current index", (t) => {
|
|
610
|
+
const contributorRoot = repository(t);
|
|
611
|
+
const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
|
|
612
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "workspace candidate\n");
|
|
613
|
+
const workspace = new CandidateViewRegistry().create({ contributorRoot });
|
|
614
|
+
try {
|
|
615
|
+
assert.throws(
|
|
616
|
+
() => new CandidateViewRegistry().restoreProjectionFromNative("workspace-as-staged", contributorRoot, {
|
|
617
|
+
baseTree,
|
|
618
|
+
currentCandidateTree: workspace.candidateTree,
|
|
619
|
+
paths: ["tracked.txt"],
|
|
620
|
+
intendedUntracked: [],
|
|
621
|
+
projection: "staged",
|
|
622
|
+
}),
|
|
623
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "projection-kind-drift",
|
|
624
|
+
);
|
|
625
|
+
} finally {
|
|
626
|
+
workspace.cleanup();
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
587
630
|
test("native projections recover a committed range base from its frozen tree", (t) => {
|
|
588
631
|
const contributorRoot = repository(t);
|
|
589
632
|
const baseCommit = git(contributorRoot, "rev-parse", "HEAD");
|