gentle-pi 1.0.5 → 1.0.6
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/extensions/gentle-ai.ts +16 -7
- package/lib/native-review-cli.ts +2 -2
- package/lib/review-candidate-view.ts +21 -1
- package/package.json +1 -1
- package/tests/native-review-cli.test.ts +29 -3
- package/tests/native-review-parity-runtime.test.ts +201 -2
- package/tests/package-manifest.test.ts +2 -2
- package/tests/review-candidate-view.test.ts +65 -0
- package/tests/review-controller-native-routing.test.ts +24 -0
package/extensions/gentle-ai.ts
CHANGED
|
@@ -4499,9 +4499,13 @@ function nativeStartRejection(reason: string, field?: string): Record<string, un
|
|
|
4499
4499
|
}
|
|
4500
4500
|
|
|
4501
4501
|
function nativeOperationFailure(operation: ReviewControllerOperation, error: unknown): Record<string, unknown> {
|
|
4502
|
-
const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; launchAttempted?: unknown };
|
|
4502
|
+
const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; launchAttempted?: unknown; candidateViewPreNative?: unknown };
|
|
4503
4503
|
const mutationOutcome = value.mutationOutcome === "unknown" ? "unknown" : "none";
|
|
4504
|
-
const diagnostics = error instanceof NativeReviewCliError
|
|
4504
|
+
const diagnostics = error instanceof NativeReviewCliError
|
|
4505
|
+
? error.diagnostics
|
|
4506
|
+
: operation === REVIEW_CONTROLLER_OPERATION.START && error instanceof CandidateViewError && value.candidateViewPreNative === true
|
|
4507
|
+
? { code: error.reason, message: "candidate view rejected before native START" }
|
|
4508
|
+
: undefined;
|
|
4505
4509
|
return {
|
|
4506
4510
|
operation,
|
|
4507
4511
|
status: "blocked",
|
|
@@ -4892,8 +4896,10 @@ async function executeReviewControllerOperation(
|
|
|
4892
4896
|
}
|
|
4893
4897
|
const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null });
|
|
4894
4898
|
let candidateView: ReturnType<CandidateViewRegistry["create"]> | undefined;
|
|
4899
|
+
let nativeStartAttempted = false;
|
|
4895
4900
|
try {
|
|
4896
4901
|
candidateView = candidateViews?.createOrReuse({ contributorRoot: defaultCwd, replayKey, ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }) });
|
|
4902
|
+
nativeStartAttempted = true;
|
|
4897
4903
|
const result = await nativeReviewCli.start({
|
|
4898
4904
|
cwd: candidateView?.root ?? defaultCwd,
|
|
4899
4905
|
...(canonicalBaseRef === undefined
|
|
@@ -4914,13 +4920,16 @@ async function executeReviewControllerOperation(
|
|
|
4914
4920
|
if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason);
|
|
4915
4921
|
const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
|
|
4916
4922
|
const provenNoMutation = value.mutationOutcome === "none";
|
|
4917
|
-
|
|
4923
|
+
const preNativeCandidateFailure = !nativeStartAttempted && error instanceof CandidateViewError;
|
|
4924
|
+
if (candidateView && candidateViews && (provenNoMutation || preNativeCandidateFailure)) candidateViews.cleanup(candidateView.token);
|
|
4918
4925
|
const failure = provenNoMutation
|
|
4919
4926
|
? error
|
|
4920
|
-
:
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4927
|
+
: preNativeCandidateFailure
|
|
4928
|
+
? Object.assign(error, { candidateViewPreNative: true })
|
|
4929
|
+
: Object.assign(error instanceof Error ? error : new Error(String(error)), {
|
|
4930
|
+
mutationOutcome: "unknown",
|
|
4931
|
+
nextAction: "replay-exact-native-operation",
|
|
4932
|
+
});
|
|
4924
4933
|
return nativeOperationFailure(parameters.operation, failure);
|
|
4925
4934
|
}
|
|
4926
4935
|
}
|
package/lib/native-review-cli.ts
CHANGED
|
@@ -35,7 +35,7 @@ export const NATIVE_REVIEW_ERROR_CODE = {
|
|
|
35
35
|
} as const;
|
|
36
36
|
export type NativeReviewErrorCode = (typeof NATIVE_REVIEW_ERROR_CODE)[keyof typeof NATIVE_REVIEW_ERROR_CODE];
|
|
37
37
|
|
|
38
|
-
export interface ExecFileRequest { file: string; arguments: readonly string[]; cwd: string; timeoutMs: number; maxBufferBytes: number; signal?: AbortSignal; }
|
|
38
|
+
export interface ExecFileRequest { file: string; arguments: readonly string[]; cwd: string; timeoutMs: number | undefined; maxBufferBytes: number; signal?: AbortSignal; }
|
|
39
39
|
export interface ExecFileResult { stdout: string; stderr: string; exitCode: number; signal: NodeJS.Signals | null; timedOut: boolean; outputLimitExceeded: boolean; }
|
|
40
40
|
export type ExecFileAdapter = (request: ExecFileRequest) => Promise<ExecFileResult>;
|
|
41
41
|
|
|
@@ -622,7 +622,7 @@ export class NativeReviewCliV214 {
|
|
|
622
622
|
|
|
623
623
|
private async execute(operation: NativeReviewOperation, cwd: string, arguments_: readonly string[], mutating: boolean, signal?: AbortSignal): Promise<NativeJsonExecution> {
|
|
624
624
|
let result: ExecFileResult;
|
|
625
|
-
try { result = await this.adapter({ file: this.executablePath(operation, mutating), arguments: arguments_, cwd, timeoutMs: this.timeoutMs, maxBufferBytes: this.maxBufferBytes, signal }); }
|
|
625
|
+
try { result = await this.adapter({ file: this.executablePath(operation, mutating), arguments: arguments_, cwd, timeoutMs: mutating ? undefined : this.timeoutMs, maxBufferBytes: this.maxBufferBytes, signal }); }
|
|
626
626
|
catch (error) {
|
|
627
627
|
if (error instanceof NativeReviewCliError) throw nativeError(error.code, operation, mutating, error.message, undefined, error.launchAttempted);
|
|
628
628
|
if (error instanceof Error && error.name === "AbortError") throw nativeError(NATIVE_REVIEW_ERROR_CODE.CANCELLED, operation, mutating, "native process was cancelled");
|
|
@@ -147,6 +147,26 @@ function decodeCanonicalPath(value: Buffer): string {
|
|
|
147
147
|
return path;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
function assertSafeSymlinkTarget(root: string, entryPath: string, value: Buffer): void {
|
|
151
|
+
const target = value.toString("utf8");
|
|
152
|
+
if (
|
|
153
|
+
!Buffer.from(target, "utf8").equals(value) ||
|
|
154
|
+
target.length === 0 ||
|
|
155
|
+
isAbsolute(target) ||
|
|
156
|
+
/^[A-Za-z]:\//.test(target) ||
|
|
157
|
+
target.includes("\\") ||
|
|
158
|
+
/[\u0000-\u001f\u007f]/.test(target) ||
|
|
159
|
+
target.split("/").some((segment) => segment.length === 0 || segment === ".")
|
|
160
|
+
) {
|
|
161
|
+
throw new CandidateViewError("candidate view symlink target is unsafe");
|
|
162
|
+
}
|
|
163
|
+
const resolvedTarget = resolve(dirname(join(root, entryPath)), target);
|
|
164
|
+
const metadata = join(root, ".git");
|
|
165
|
+
if (!isWithin(root, resolvedTarget) || resolvedTarget === metadata || isWithin(metadata, resolvedTarget)) {
|
|
166
|
+
throw new CandidateViewError("candidate view symlink target escapes its frozen root or enters metadata");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
150
170
|
function splitNulTerminated(raw: Buffer, errorMessage: string): Buffer[] {
|
|
151
171
|
if (raw.length === 0) return [];
|
|
152
172
|
if (raw.at(-1) !== 0) throw new CandidateViewError(errorMessage);
|
|
@@ -221,7 +241,7 @@ function entryContentHash(root: string, entry: CandidateViewEntry): string {
|
|
|
221
241
|
if (!item.isSymbolicLink()) throw new CandidateViewError("candidate view symlink does not match its frozen tree");
|
|
222
242
|
const target = readlinkSync(path, "buffer");
|
|
223
243
|
const bytes = Buffer.isBuffer(target) ? target : Buffer.from(target);
|
|
224
|
-
|
|
244
|
+
assertSafeSymlinkTarget(root, entry.path, bytes);
|
|
225
245
|
return createHash("sha256").update(bytes).digest("hex");
|
|
226
246
|
}
|
|
227
247
|
if (!item.isFile() || item.isSymbolicLink()) throw new CandidateViewError("candidate view entry does not match its frozen tree");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,8 +21,8 @@ interface QueuedResult {
|
|
|
21
21
|
outputLimitExceeded?: boolean;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function queuedAdapter(results: QueuedResult[]): { adapter: ExecFileAdapter; calls: Array<{ file: string; arguments: readonly string[]; cwd: string }> } {
|
|
25
|
-
const calls: Array<{ file: string; arguments: readonly string[]; cwd: string }> = [];
|
|
24
|
+
function queuedAdapter(results: QueuedResult[]): { adapter: ExecFileAdapter; calls: Array<{ file: string; arguments: readonly string[]; cwd: string; timeoutMs: number | undefined; maxBufferBytes: number }> } {
|
|
25
|
+
const calls: Array<{ file: string; arguments: readonly string[]; cwd: string; timeoutMs: number | undefined; maxBufferBytes: number }> = [];
|
|
26
26
|
return {
|
|
27
27
|
calls,
|
|
28
28
|
adapter: async (request) => {
|
|
@@ -236,6 +236,29 @@ test("native mutation uncertainty requires exact replay", async () => {
|
|
|
236
236
|
);
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
+
test("native mutating commands omit the automatic timeout while preserving output caps", async () => {
|
|
240
|
+
const queue = queuedAdapter([
|
|
241
|
+
VERSION,
|
|
242
|
+
START,
|
|
243
|
+
VERSION,
|
|
244
|
+
{ stdout: await fixture("finalize") },
|
|
245
|
+
VERSION,
|
|
246
|
+
{ stdout: await fixture("bind-sdd") },
|
|
247
|
+
]);
|
|
248
|
+
const client = new NativeReviewCliV213(queue.adapter, "/package/.gentle-ai/v2.1.4/gentle-ai", 321, 654);
|
|
249
|
+
await client.start({ cwd: "/repo" });
|
|
250
|
+
await client.finalize({ cwd: "/repo", lineageId: "lineage-1" });
|
|
251
|
+
await client.bindSdd({ cwd: "/repo", change: "native-review-authority-parity", lineage: "issue136-contract-runtime", expectedBindingRevision: "" });
|
|
252
|
+
assert.deepEqual(queue.calls.map((call) => call.timeoutMs), [321, undefined, 321, undefined, 321, undefined]);
|
|
253
|
+
assert.deepEqual(queue.calls.map((call) => call.maxBufferBytes), [654, 654, 654, 654, 654, 654]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("native read-only commands and version checks retain the automatic timeout", async () => {
|
|
257
|
+
const queue = queuedAdapter([VERSION, { stdout: await fixture("validate-allow") }]);
|
|
258
|
+
await new NativeReviewCliV213(queue.adapter, "/package/.gentle-ai/v2.1.4/gentle-ai", 321).validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" });
|
|
259
|
+
assert.deepEqual(queue.calls.map((call) => call.timeoutMs), [321, 321]);
|
|
260
|
+
});
|
|
261
|
+
|
|
239
262
|
test("native process failures retain bounded sanitized process diagnostics and parsed denial evidence", async () => {
|
|
240
263
|
const denial = JSON.parse(await fixture("validate-deny")) as Record<string, unknown>;
|
|
241
264
|
const queue = queuedAdapter([VERSION, {
|
|
@@ -877,11 +900,13 @@ test("node execFile adapter passes AbortSignal to child_process", async () => {
|
|
|
877
900
|
await assert.rejects(pending, (error: unknown) => error instanceof Error && error.name === "AbortError");
|
|
878
901
|
});
|
|
879
902
|
|
|
880
|
-
test("native adapter receives the controller AbortSignal
|
|
903
|
+
test("native adapter receives the controller AbortSignal without an automatic mutation timeout", async () => {
|
|
881
904
|
const controller = new AbortController();
|
|
882
905
|
controller.abort();
|
|
906
|
+
let mutationTimeoutMs: number | undefined;
|
|
883
907
|
const adapter: ExecFileAdapter = async (request) => {
|
|
884
908
|
if (request.arguments[0] === "version") return { stdout: "gentle-ai 2.1.4\n", stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
909
|
+
mutationTimeoutMs = request.timeoutMs;
|
|
885
910
|
if (request.signal?.aborted) {
|
|
886
911
|
const error = new Error("cancelled");
|
|
887
912
|
error.name = "AbortError";
|
|
@@ -896,4 +921,5 @@ test("native adapter receives the controller AbortSignal and preserves mutating
|
|
|
896
921
|
&& error.mutationOutcome === "unknown"
|
|
897
922
|
&& error.nextAction === "replay-exact-native-operation",
|
|
898
923
|
);
|
|
924
|
+
assert.equal(mutationTimeoutMs, undefined);
|
|
899
925
|
});
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
|
-
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import { dirname, join } from "node:path";
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
10
12
|
import { resolveGentleAiBinary } from "../lib/gentle-ai-binary.ts";
|
|
13
|
+
import { NativeReviewCliV214 } from "../lib/native-review-cli.ts";
|
|
14
|
+
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
11
15
|
|
|
12
16
|
const execFileAsync = promisify(execFile);
|
|
13
17
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -25,9 +29,31 @@ interface CommandResult {
|
|
|
25
29
|
stderr: string;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
interface RegisteredController {
|
|
33
|
+
execute: (toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<{ details?: unknown }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
interface ReviewStart {
|
|
29
37
|
lineage_id: string;
|
|
30
38
|
selected_lenses: string[];
|
|
39
|
+
action?: string;
|
|
40
|
+
state?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ReviewFinalize {
|
|
44
|
+
lineage_id: string;
|
|
45
|
+
state: string;
|
|
46
|
+
store_revision: string;
|
|
47
|
+
receipt_path: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ReviewAuthorityEntry {
|
|
51
|
+
version: string;
|
|
52
|
+
lineage_id: string;
|
|
53
|
+
status: string;
|
|
54
|
+
state: string;
|
|
55
|
+
revision: string;
|
|
56
|
+
problems: unknown[];
|
|
31
57
|
}
|
|
32
58
|
|
|
33
59
|
interface ReviewGateContext {
|
|
@@ -90,6 +116,42 @@ async function restoreCandidate(repository: string, candidateTree: string): Prom
|
|
|
90
116
|
await assertPublishedProjection(repository, candidateTree);
|
|
91
117
|
}
|
|
92
118
|
|
|
119
|
+
async function reviewStatus(repository: string): Promise<Record<string, unknown>> {
|
|
120
|
+
return JSON.parse((await run(binary, ["review", "status", "--cwd", repository], repository)).stdout) as Record<string, unknown>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function authorityInventory(status: Record<string, unknown>): ReviewAuthorityEntry[] {
|
|
124
|
+
assert.ok(Array.isArray(status.entries), "review status must expose authority entries");
|
|
125
|
+
return status.entries.map((entry) => {
|
|
126
|
+
assert.ok(entry !== null && typeof entry === "object", "authority entry must be an object");
|
|
127
|
+
const candidate = entry as Record<string, unknown>;
|
|
128
|
+
for (const key of ["version", "lineage_id", "status", "state", "revision"] as const) {
|
|
129
|
+
assert.equal(typeof candidate[key], "string", `authority entry ${key} must be stable text`);
|
|
130
|
+
}
|
|
131
|
+
assert.ok(Array.isArray(candidate.problems), "authority entry problems must be an array");
|
|
132
|
+
return {
|
|
133
|
+
version: candidate.version as string,
|
|
134
|
+
lineage_id: candidate.lineage_id as string,
|
|
135
|
+
status: candidate.status as string,
|
|
136
|
+
state: candidate.state as string,
|
|
137
|
+
revision: candidate.revision as string,
|
|
138
|
+
problems: candidate.problems,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function finalizeEmptyReview(repository: string, artifacts: string, started: ReviewStart, evidenceName: string): Promise<CommandResult> {
|
|
144
|
+
const evidence = join(artifacts, evidenceName);
|
|
145
|
+
await writeFile(evidence, `evidence for ${started.lineage_id}\n`);
|
|
146
|
+
const resultFiles: string[] = [];
|
|
147
|
+
for (const [index] of started.selected_lenses.entries()) {
|
|
148
|
+
const result = join(artifacts, `${evidenceName}-lens-${index}.json`);
|
|
149
|
+
await writeFile(result, JSON.stringify({ findings: [], evidence: ["reviewed frozen candidate"] }));
|
|
150
|
+
resultFiles.push(result);
|
|
151
|
+
}
|
|
152
|
+
return run(binary, ["review", "finalize", "--cwd", repository, "--lineage", started.lineage_id, ...resultFiles.flatMap((result) => ["--result", result]), "--evidence", evidence], repository);
|
|
153
|
+
}
|
|
154
|
+
|
|
93
155
|
test("official v2.1.5 package runtime authorizes an unchanged linked-view candidate and denies a changed staging tree", async (t) => {
|
|
94
156
|
assert.equal(createHash("sha256").update(await readFile(binary)).digest("hex"), OFFICIAL_BINARY_SHA256);
|
|
95
157
|
assert.deepEqual(await run(binary, ["version"], packageRoot), { exitCode: 0, stdout: "gentle-ai 2.1.5\n", stderr: "" });
|
|
@@ -168,3 +230,140 @@ test("official v2.1.5 package runtime authorizes an unchanged linked-view candid
|
|
|
168
230
|
await chmod(join(repository, "tracked.txt"), 0o644);
|
|
169
231
|
await restoreCandidate(repository, candidateTree);
|
|
170
232
|
});
|
|
233
|
+
|
|
234
|
+
test("official v2.1.5 package runtime keeps frozen candidate lineages and receipts isolated across replay and replacement", async (t) => {
|
|
235
|
+
const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-lineage-"));
|
|
236
|
+
const repository = join(workspace, "repository");
|
|
237
|
+
const artifacts = join(workspace, "artifacts");
|
|
238
|
+
t.after(async () => rm(workspace, { recursive: true, force: true }));
|
|
239
|
+
|
|
240
|
+
await mkdir(repository);
|
|
241
|
+
await mkdir(artifacts);
|
|
242
|
+
await run("git", ["init", "--initial-branch=main"], repository);
|
|
243
|
+
await run("git", ["config", "user.email", "test@example.invalid"], repository);
|
|
244
|
+
await run("git", ["config", "user.name", "Gentle Pi test"], repository);
|
|
245
|
+
await writeFile(join(repository, "tracked.txt"), "base\n");
|
|
246
|
+
await run("git", ["add", "--", "tracked.txt"], repository);
|
|
247
|
+
await run("git", ["commit", "-m", "base"], repository);
|
|
248
|
+
|
|
249
|
+
await writeFile(join(repository, "tracked.txt"), "candidate one\n");
|
|
250
|
+
const first = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
|
|
251
|
+
const firstInventory = authorityInventory(await reviewStatus(repository));
|
|
252
|
+
const firstReplay = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
|
|
253
|
+
const firstReplayInventory = authorityInventory(await reviewStatus(repository));
|
|
254
|
+
assert.equal(firstReplay.lineage_id, first.lineage_id, "replaying an exact START must reuse the frozen lineage");
|
|
255
|
+
assert.deepEqual(firstReplayInventory, firstInventory, "replaying an exact START must not create durable authority");
|
|
256
|
+
|
|
257
|
+
const firstFinalized = JSON.parse((await finalizeEmptyReview(repository, artifacts, first, "first-evidence.txt")).stdout) as ReviewFinalize;
|
|
258
|
+
const firstFinalizedInventory = authorityInventory(await reviewStatus(repository));
|
|
259
|
+
const firstFinalizeReplay = JSON.parse((await finalizeEmptyReview(repository, artifacts, first, "first-evidence.txt")).stdout) as ReviewFinalize;
|
|
260
|
+
const firstFinalizeReplayInventory = authorityInventory(await reviewStatus(repository));
|
|
261
|
+
assert.equal(firstFinalized.lineage_id, first.lineage_id);
|
|
262
|
+
assert.equal(firstFinalized.state, "approved");
|
|
263
|
+
assert.match(firstFinalized.store_revision, /^sha256:[a-f0-9]{64}$/);
|
|
264
|
+
assert.equal(firstFinalizeReplay.store_revision, firstFinalized.store_revision, "replaying an exact FINALIZE must reuse its receipt revision");
|
|
265
|
+
assert.equal(firstFinalizeReplay.receipt_path, firstFinalized.receipt_path, "replaying an exact FINALIZE must reuse its receipt location");
|
|
266
|
+
assert.deepEqual(firstFinalizeReplayInventory, firstFinalizedInventory, "replaying an exact FINALIZE must not create a durable receipt or authority");
|
|
267
|
+
assert.deepEqual(firstFinalizedInventory, [{ version: "compact-v2", lineage_id: first.lineage_id, status: "approved", state: "approved", revision: firstFinalized.store_revision, problems: [] }]);
|
|
268
|
+
|
|
269
|
+
await run("git", ["add", "--", "tracked.txt"], repository);
|
|
270
|
+
const firstCandidateTree = (await run("git", ["write-tree"], repository)).stdout.trim();
|
|
271
|
+
const firstAllowed = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", first.lineage_id], repository)).stdout) as ReviewGateResult;
|
|
272
|
+
assert.equal(firstAllowed.result, "allow");
|
|
273
|
+
assert.equal(firstAllowed.context.candidate_tree, firstCandidateTree);
|
|
274
|
+
|
|
275
|
+
await writeFile(join(repository, "tracked.txt"), "candidate two\n");
|
|
276
|
+
await run("git", ["add", "--", "tracked.txt"], repository);
|
|
277
|
+
const secondCandidateTree = (await run("git", ["write-tree"], repository)).stdout.trim();
|
|
278
|
+
assert.notEqual(secondCandidateTree, firstCandidateTree);
|
|
279
|
+
const authorityBeforeCompetingStart = authorityInventory(await reviewStatus(repository));
|
|
280
|
+
const competingStart = await run(binary, ["review", "start", "--cwd", repository, "--lineage", first.lineage_id], repository);
|
|
281
|
+
const competingStartResult = JSON.parse(competingStart.stdout) as ReviewStart;
|
|
282
|
+
const authorityAfterCompetingStart = authorityInventory(await reviewStatus(repository));
|
|
283
|
+
assert.equal(competingStartResult.action, "blocked-scope-action", "a frozen lineage must return a structured scope-action block for a competing candidate");
|
|
284
|
+
assert.equal(competingStartResult.lineage_id, first.lineage_id);
|
|
285
|
+
assert.equal(competingStartResult.state, "approved");
|
|
286
|
+
assert.deepEqual(authorityAfterCompetingStart, authorityBeforeCompetingStart, "a blocked scope action must not mutate approved authority");
|
|
287
|
+
const second = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
|
|
288
|
+
const secondInventory = authorityInventory(await reviewStatus(repository));
|
|
289
|
+
assert.equal(second.action, "created");
|
|
290
|
+
assert.equal(second.state, "reviewing");
|
|
291
|
+
assert.notEqual(second.lineage_id, first.lineage_id, "a distinct candidate must establish a distinct lineage");
|
|
292
|
+
const secondReplay = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
|
|
293
|
+
const secondReplayInventory = authorityInventory(await reviewStatus(repository));
|
|
294
|
+
assert.equal(secondReplay.lineage_id, second.lineage_id, "replaying the second START must reuse its lineage");
|
|
295
|
+
assert.deepEqual(secondReplayInventory, secondInventory, "replaying the second START must not duplicate durable authority");
|
|
296
|
+
await finalizeEmptyReview(repository, artifacts, second, "second-evidence.txt");
|
|
297
|
+
|
|
298
|
+
const secondAllowed = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", second.lineage_id], repository)).stdout) as ReviewGateResult;
|
|
299
|
+
assert.equal(secondAllowed.result, "allow");
|
|
300
|
+
assert.equal(secondAllowed.context.candidate_tree, secondCandidateTree);
|
|
301
|
+
await assertScopeChanged(repository, first.lineage_id);
|
|
302
|
+
|
|
303
|
+
await writeFile(join(repository, "tracked.txt"), "candidate one\n");
|
|
304
|
+
await run("git", ["add", "--", "tracked.txt"], repository);
|
|
305
|
+
assert.equal((await run("git", ["write-tree"], repository)).stdout.trim(), firstCandidateTree);
|
|
306
|
+
const firstRestored = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", first.lineage_id], repository)).stdout) as ReviewGateResult;
|
|
307
|
+
assert.equal(firstRestored.result, "allow", "the old receipt must remain valid for its exact frozen candidate");
|
|
308
|
+
|
|
309
|
+
await writeFile(join(repository, "tracked.txt"), "candidate two\n");
|
|
310
|
+
await run("git", ["add", "--", "tracked.txt"], repository);
|
|
311
|
+
assert.equal((await run("git", ["write-tree"], repository)).stdout.trim(), secondCandidateTree);
|
|
312
|
+
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.");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("registered gentle_review START materializes a safe internal skill symlink before invoking native authority", async (t) => {
|
|
316
|
+
const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-symlink-candidate-"));
|
|
317
|
+
const repository = join(workspace, "repository");
|
|
318
|
+
t.after(async () => rm(workspace, { recursive: true, force: true }));
|
|
319
|
+
|
|
320
|
+
await mkdir(join(repository, ".agents", "skills", "example"), { recursive: true });
|
|
321
|
+
await mkdir(join(repository, ".agent", "skills"), { recursive: true });
|
|
322
|
+
await writeFile(join(repository, "tracked.txt"), "base\n");
|
|
323
|
+
await writeFile(join(repository, ".agents", "skills", "example", "SKILL.md"), "---\nname: example\n---\n");
|
|
324
|
+
const link = join(repository, ".agent", "skills", "example");
|
|
325
|
+
const linkTarget = "../../.agents/skills/example";
|
|
326
|
+
await symlink(linkTarget, link);
|
|
327
|
+
const lexicalTarget = resolve(dirname(link), linkTarget);
|
|
328
|
+
const lexicalRelative = relative(repository, lexicalTarget);
|
|
329
|
+
assert.ok(lexicalRelative !== "" && !lexicalRelative.startsWith("..") && !isAbsolute(lexicalRelative), "the internal symlink target must resolve lexically inside the repository");
|
|
330
|
+
|
|
331
|
+
await run("git", ["init", "--initial-branch=main"], repository);
|
|
332
|
+
await run("git", ["config", "user.email", "test@example.invalid"], repository);
|
|
333
|
+
await run("git", ["config", "user.name", "Gentle Pi test"], repository);
|
|
334
|
+
await run("git", ["add", "--", "tracked.txt", ".agents", ".agent"], repository);
|
|
335
|
+
await run("git", ["commit", "-m", "base with internal skill symlink"], repository);
|
|
336
|
+
await writeFile(join(repository, "tracked.txt"), "candidate\n");
|
|
337
|
+
|
|
338
|
+
const candidateViews = new CandidateViewRegistry();
|
|
339
|
+
let nativeStartReached = false;
|
|
340
|
+
const native = new NativeReviewCliV214(async (request) => {
|
|
341
|
+
if (request.arguments[0] === "review" && request.arguments[1] === "start") nativeStartReached = true;
|
|
342
|
+
const command = await run(binary, request.arguments, request.cwd, true);
|
|
343
|
+
return { ...command, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
344
|
+
});
|
|
345
|
+
const tools = new Map<string, RegisteredController>();
|
|
346
|
+
createGentleAiExtension({ nativeReviewCli: native, candidateViews } as Parameters<typeof createGentleAiExtension>[0])({
|
|
347
|
+
on() {},
|
|
348
|
+
registerTool(definition: RegisteredController & { name: string }) { tools.set(definition.name, definition); },
|
|
349
|
+
registerCommand() {},
|
|
350
|
+
} as unknown as ExtensionAPI);
|
|
351
|
+
const controller = tools.get("gentle_review");
|
|
352
|
+
assert.ok(controller);
|
|
353
|
+
|
|
354
|
+
let returned: { details?: unknown } | undefined;
|
|
355
|
+
let thrown: unknown;
|
|
356
|
+
try {
|
|
357
|
+
returned = await controller.execute("issue-146-start", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, { cwd: repository, hasUI: false, ui: { confirm: async () => true } } as unknown as ExtensionContext);
|
|
358
|
+
} catch (caught) {
|
|
359
|
+
thrown = caught;
|
|
360
|
+
}
|
|
361
|
+
const error = thrown instanceof Error ? { name: thrown.name, message: thrown.message } : thrown === undefined ? undefined : String(thrown);
|
|
362
|
+
t.diagnostic(JSON.stringify({ returned: returned?.details, error, nativeStartReached }));
|
|
363
|
+
assert.equal(thrown, undefined, "safe internal symlink materialization must not throw before START");
|
|
364
|
+
assert.equal(nativeStartReached, true, "safe internal symlink materialization must reach native START");
|
|
365
|
+
const result = (returned?.details as { result?: Record<string, unknown> } | undefined)?.result;
|
|
366
|
+
assert.equal(typeof result?.lineage_id, "string", "safe internal symlink materialization must return native review authority");
|
|
367
|
+
assert.equal(result?.state, "reviewing");
|
|
368
|
+
candidateViews.cleanup(candidateViews.resolveForLens(result!.lineage_id as string, "review-reliability").token);
|
|
369
|
+
});
|
|
@@ -1015,9 +1015,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
|
|
|
1015
1015
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1016
1016
|
});
|
|
1017
1017
|
|
|
1018
|
-
test("v1.0.
|
|
1018
|
+
test("v1.0.6 release package and runtime stop before delivery or publication", () => {
|
|
1019
1019
|
const packageJson = readPackageJson();
|
|
1020
|
-
assert.equal(packageJson.version, "1.0.
|
|
1020
|
+
assert.equal(packageJson.version, "1.0.6", "the release manifest must remain explicitly pinned to v1.0.6");
|
|
1021
1021
|
assert.equal(
|
|
1022
1022
|
packageJson.scripts?.test,
|
|
1023
1023
|
"node --experimental-strip-types --test tests/*.test.ts && pnpm run test:harness",
|
|
@@ -404,6 +404,71 @@ test("candidate view fails closed before dispatch when the changed scope itself
|
|
|
404
404
|
}
|
|
405
405
|
});
|
|
406
406
|
|
|
407
|
+
test("candidate view accepts internal relative symlink targets and rejects unsafe lexical targets", (t) => {
|
|
408
|
+
const acceptedRoot = repository(t);
|
|
409
|
+
const acceptedTarget = "../../.agents/skills/example";
|
|
410
|
+
const acceptedLink = join(acceptedRoot, ".agent", "skills", "example");
|
|
411
|
+
mkdirSync(join(acceptedRoot, ".agents", "skills", "example"), { recursive: true });
|
|
412
|
+
mkdirSync(join(acceptedRoot, ".agent", "skills"), { recursive: true });
|
|
413
|
+
writeFileSync(join(acceptedRoot, ".agents", "skills", "example", "SKILL.md"), "example\n");
|
|
414
|
+
try {
|
|
415
|
+
symlinkSync(acceptedTarget, acceptedLink);
|
|
416
|
+
} catch {
|
|
417
|
+
t.skip("platform does not support symlinks");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const accepted = createCandidateView({ contributorRoot: acceptedRoot });
|
|
421
|
+
try {
|
|
422
|
+
assert.equal(lstatSync(acceptedLink).isSymbolicLink(), true);
|
|
423
|
+
assert.equal(readFileSync(join(accepted.root, ".agent", "skills", "example", "SKILL.md"), "utf8"), "example\n");
|
|
424
|
+
accepted.verify();
|
|
425
|
+
} finally {
|
|
426
|
+
accepted.cleanup();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for (const [name, target] of [
|
|
430
|
+
["escape", "../escape"],
|
|
431
|
+
["absolute", "/absolute-target"],
|
|
432
|
+
["Windows drive absolute", "C:/absolute-target"],
|
|
433
|
+
["lowercase Windows drive absolute", "c:/absolute-target"],
|
|
434
|
+
["metadata", ".git"],
|
|
435
|
+
["control", "unsafe\ntarget"],
|
|
436
|
+
["backslash", "unsafe\\target"],
|
|
437
|
+
["empty segment", "unsafe//target"],
|
|
438
|
+
] as const) {
|
|
439
|
+
const contributorRoot = repository(t);
|
|
440
|
+
try {
|
|
441
|
+
symlinkSync(target, join(contributorRoot, "candidate-link"));
|
|
442
|
+
} catch {
|
|
443
|
+
t.skip("platform does not support symlinks");
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
assert.throws(() => createCandidateView({ contributorRoot }), (error: unknown) => error instanceof CandidateViewError, name);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("candidate view detects symlink target-byte tampering after materialization", (t) => {
|
|
451
|
+
const contributorRoot = repository(t);
|
|
452
|
+
const link = join(contributorRoot, "candidate-link");
|
|
453
|
+
try {
|
|
454
|
+
symlinkSync("safe-target", link);
|
|
455
|
+
} catch {
|
|
456
|
+
t.skip("platform does not support symlinks");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const view = createCandidateView({ contributorRoot });
|
|
460
|
+
try {
|
|
461
|
+
const frozenLink = join(view.root, "candidate-link");
|
|
462
|
+
chmodSync(view.root, 0o755);
|
|
463
|
+
rmSync(frozenLink);
|
|
464
|
+
symlinkSync("other-target", frozenLink);
|
|
465
|
+
chmodSync(view.root, 0o555);
|
|
466
|
+
assert.throws(() => view.verify(), CandidateViewError);
|
|
467
|
+
} finally {
|
|
468
|
+
view.cleanup();
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
407
472
|
test("candidate view retains a valid dangling symlink through bind and finalize resolution", (t) => {
|
|
408
473
|
const contributorRoot = repository(t);
|
|
409
474
|
try {
|
|
@@ -702,6 +702,30 @@ test("native error has no compact fallback and ambiguous mutation demands exact
|
|
|
702
702
|
assert.deepEqual(result.details, { operation: "start", status: "blocked", outcome: "native-operation-failed", mutation_performed: false, mutation_outcome: "unknown", next_action: "replay-exact-native-operation" });
|
|
703
703
|
});
|
|
704
704
|
|
|
705
|
+
test("native START preserves a candidate-view diagnostic before native invocation", async (t) => {
|
|
706
|
+
const cwd = repository(t);
|
|
707
|
+
try {
|
|
708
|
+
symlinkSync("../escape", join(cwd, "unsafe-link"));
|
|
709
|
+
} catch {
|
|
710
|
+
t.skip("platform does not support symlinks");
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
let starts = 0;
|
|
714
|
+
const { controller } = runtime(fakeNative({
|
|
715
|
+
start: async () => {
|
|
716
|
+
starts += 1;
|
|
717
|
+
return { lineageId: "must-not-start", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true };
|
|
718
|
+
},
|
|
719
|
+
}), undefined, undefined, undefined, new CandidateViewRegistry());
|
|
720
|
+
const result = await controller.execute("unsafe-symlink-start", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
721
|
+
const details = result.details as Record<string, unknown>;
|
|
722
|
+
assert.equal(details.outcome, "native-operation-failed");
|
|
723
|
+
assert.equal(details.mutation_outcome, "none");
|
|
724
|
+
assert.equal(details.next_action, "resolve-native-operation-failure");
|
|
725
|
+
assert.deepEqual(details.diagnostics, { code: "candidate-view-invalid", message: "candidate view rejected before native START" });
|
|
726
|
+
assert.equal(starts, 0);
|
|
727
|
+
});
|
|
728
|
+
|
|
705
729
|
test("native START uses the default policy or a canonical safe policy path, and rejects unsafe policy inputs before native calls", async (t) => {
|
|
706
730
|
const cwd = repository(t);
|
|
707
731
|
const policyDirectory = join(cwd, ".gentle-ai", "policies");
|