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.
@@ -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 ? error.diagnostics : undefined;
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
- if (candidateView && candidateViews && provenNoMutation) candidateViews.cleanup(candidateView.token);
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
- : Object.assign(error instanceof Error ? error : new Error(String(error)), {
4921
- mutationOutcome: "unknown",
4922
- nextAction: "replay-exact-native-operation",
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
  }
@@ -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
- decodeCanonicalPath(bytes);
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.5",
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 and preserves mutating replay guidance", async () => {
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.5 release package and runtime stop before delivery or publication", () => {
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.5", "the release manifest must remain explicitly pinned to v1.0.5");
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");