pi-gauntlet 7.40.0 → 7.41.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +16 -4
  3. package/agents/code-reviewer.md +2 -0
  4. package/agents/implementer.md +2 -0
  5. package/agents/loop-implementer.md +10 -0
  6. package/agents/plan-writer.md +1 -1
  7. package/agents/spec-reviewer.md +2 -0
  8. package/bin/gauntlet-telemetry-seal.mjs +18 -3
  9. package/bin/gauntlet-telemetry-seal.test.mjs +30 -0
  10. package/extensions/lib/plan-check.test.ts +116 -0
  11. package/extensions/lib/plan-check.ts +149 -20
  12. package/extensions/lib/telemetry-record.test.ts +11 -0
  13. package/extensions/lib/telemetry-record.ts +7 -0
  14. package/extensions/phase-tracker.test.ts +201 -3
  15. package/extensions/phase-tracker.ts +77 -9
  16. package/extensions/verify-before-ship.test.ts +240 -2
  17. package/extensions/verify-before-ship.ts +180 -25
  18. package/package.json +2 -2
  19. package/skills/brainstorming/SKILL.md +2 -2
  20. package/skills/brainstorming/gatherer.md +20 -7
  21. package/skills/brainstorming/reference/amendment-surface.md +13 -1
  22. package/skills/brainstorming/reference/legs.md +187 -0
  23. package/skills/brainstorming/reference/pr-subject.md +15 -9
  24. package/skills/brainstorming/reference/spec-finalization.md +16 -2
  25. package/skills/brainstorming/reference/ticket-acceptance.md +2 -2
  26. package/skills/finishing-a-development-branch/SKILL.md +21 -22
  27. package/skills/finishing-a-development-branch/reference/legs.md +76 -0
  28. package/skills/gauntlet-handoff/SKILL.md +2 -2
  29. package/skills/gauntlet-resume/SKILL.md +3 -2
  30. package/skills/gauntlet-resume/reference/brief-contract.md +40 -6
  31. package/skills/gauntlet-resume/reference/reconstruction.md +9 -7
  32. package/skills/gauntlet-resume/reference/seed.md +2 -0
  33. package/skills/loop-implementation/SKILL.md +89 -2
  34. package/skills/subagent-driven-development/SKILL.md +12 -0
  35. package/skills/using-git-worktrees/SKILL.md +59 -22
  36. package/skills/using-git-worktrees/reference/pr-head.md +36 -15
  37. package/skills/writing-plans/SKILL.md +16 -1
  38. package/skills/writing-plans/reference/plan-contract.md +9 -1
@@ -9,8 +9,8 @@
9
9
  * Use phase_tracker for "what stage am I in?", plan_tracker for "which task?".
10
10
  */
11
11
 
12
- import { existsSync, globSync, readdirSync, readFileSync, readFileSync as readFileBytes } from "node:fs";
13
- import { isAbsolute, join, resolve } from "node:path";
12
+ import { existsSync, realpathSync, globSync, readdirSync, readFileSync, readFileSync as readFileBytes } from "node:fs";
13
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
14
14
  import { StringEnum } from "@earendil-works/pi-ai";
15
15
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
16
16
  import { Text } from "@earendil-works/pi-tui";
@@ -29,7 +29,7 @@ import {
29
29
  } from "./lib/gauntlet-settings.ts";
30
30
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
31
31
  import { checkoutOf, gitSync } from "./lib/checkout.ts";
32
- import { checkPlan, sha256, type FsPort, type PlanCheckFinding } from "./lib/plan-check.ts";
32
+ import { checkPlan, legOwnership, sha256, type FsPort, type PlanCheckFinding } from "./lib/plan-check.ts";
33
33
  import {
34
34
  checkSubstep,
35
35
  dispatchLabel,
@@ -89,9 +89,9 @@ interface PlanCheckDetails {
89
89
  // child run in the result details (pi-cohort SingleResult: { agent, exitCode }).
90
90
  // Management mode and async dispatches return results: [] and fail this check.
91
91
  const qualifiesAsClosureDispatch = (details: unknown): boolean => {
92
- const d = details as { results?: { agent?: unknown; exitCode?: unknown }[] } | undefined;
92
+ const d = details as { results?: { agent?: unknown; exitCode?: unknown; interrupted?: unknown }[] } | undefined;
93
93
  if (!d || !Array.isArray(d.results)) return false;
94
- return d.results.some((r) => r?.agent === "conformance-reviewer" && r?.exitCode === 0);
94
+ return d.results.some((r) => r?.agent === "conformance-reviewer" && r?.exitCode === 0 && r?.interrupted !== true);
95
95
  };
96
96
 
97
97
  // One conformance fix round = one non-error subagent result carrying at least one
@@ -140,11 +140,11 @@ const CADENCE_AGENTS = ["implementer", "spec-reviewer", "code-reviewer"] as cons
140
140
  type CadenceAgent = (typeof CADENCE_AGENTS)[number];
141
141
 
142
142
  const completedCadenceAgents = (details: unknown): CadenceAgent[] => {
143
- const d = details as { results?: { agent?: unknown; exitCode?: unknown }[] } | undefined;
143
+ const d = details as { results?: { agent?: unknown; exitCode?: unknown; interrupted?: unknown }[] } | undefined;
144
144
  if (!d || !Array.isArray(d.results)) return [];
145
145
  const seen = new Set<CadenceAgent>();
146
146
  for (const r of d.results) {
147
- if (r?.exitCode === 0 && (CADENCE_AGENTS as readonly unknown[]).includes(r?.agent)) {
147
+ if (r?.exitCode === 0 && r?.interrupted !== true && (CADENCE_AGENTS as readonly unknown[]).includes(r?.agent)) {
148
148
  seen.add(r.agent as CadenceAgent);
149
149
  }
150
150
  }
@@ -982,9 +982,77 @@ export default function (pi: ExtensionAPI) {
982
982
  return fail([{ check: "input", line: 0, text: "", reason: `spec file unreadable at ${specAbs}` }], planAbs);
983
983
  }
984
984
  const specText = specBytes.toString("utf8");
985
+ const ownership = legOwnership(specText);
986
+ if (ownership.findings.length) return fail(ownership.findings, planAbs);
987
+ const specRelative = relative(root, specAbs);
988
+ if (ownership.legs.length && (specRelative === ".." || specRelative.startsWith("../") || isAbsolute(specRelative))) return fail([{ check: "input", line: 0, text: specPathRaw, reason: "coordinated spec must stay in the home checkout" }], planAbs);
989
+ const roots = new Map<string, string>();
990
+ if (ownership.legs.length) roots.set(ownership.legs[0].repo, root);
991
+ if (ownership.legs.length > 1) {
992
+ const inputFailure = (reason: string) => fail([{ check: "input", line: 0, text: "", reason }], planAbs);
993
+ const worktrees = gitSync(["worktree", "list", "--porcelain"], root);
994
+ const primary = /^worktree (.+)$/m.exec(worktrees.stdout)?.[1];
995
+ if (worktrees.code !== 0 || !primary) return inputFailure("cannot resolve home primary");
996
+ const overridesPath = [".pi/gauntlet-overrides.md", "gauntlet-overrides.md", "doc/gauntlet-overrides.md"].map(p => resolve(primary, p)).find(p => existsSync(p));
997
+ const overrides = overridesPath ? readFileSync(overridesPath, "utf8") : "";
998
+ const section = /^## shape-ticket\s*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m.exec(overrides)?.[1] ?? "";
999
+ const parseClone = (value: string) => {
1000
+ let host: string, path: string;
1001
+ if (/^(ssh|https?|git):\/\//.test(value)) {
1002
+ try {
1003
+ const url = new URL(value);
1004
+ host = url.hostname;
1005
+ path = url.pathname.slice(1);
1006
+ if (url.search || url.hash) return undefined;
1007
+ } catch { return undefined; }
1008
+ } else {
1009
+ if (value.includes("://")) return undefined;
1010
+ const scp = /^(?:[^@/:\s]+@)?([^/:\s]+):([^\s]+)$/.exec(value);
1011
+ if (!scp) return undefined;
1012
+ [, host, path] = scp;
1013
+ }
1014
+ path = path.replace(/\/+$/, "").replace(/\.git$/, "");
1015
+ const name = path.split("/").at(-1);
1016
+ if (!host || !name || host === "." || host === ".." || name === "." || name === "..") return undefined;
1017
+ return { name, identity: `${host}/${path}`.toLowerCase() };
1018
+ };
1019
+ const siblings = [...section.matchAll(/^- sibling:([^\n]*)$/gm)].map(m => ({ line: m[0], parsed: parseClone(m[1].trim()) }));
1020
+ for (const leg of ownership.legs.slice(1)) {
1021
+ const identifier = leg.repo.toLowerCase();
1022
+ const byIdentity = siblings.filter(s => s.parsed?.identity.split("/").slice(-2).join("/") === identifier);
1023
+ const matching = byIdentity.length ? byIdentity : siblings.filter(s => s.parsed?.name.toLowerCase() === basename(identifier));
1024
+ const candidates: string[] = [];
1025
+ for (const sibling of matching.length ? matching : siblings) {
1026
+ const reject = (reason: string) => inputFailure(`${sibling.line}: ${reason}`);
1027
+ if (!sibling.parsed) return reject("invalid configuration");
1028
+ const path = join(dirname(primary), sibling.parsed.name);
1029
+ if (!existsSync(path)) return reject(`PATH not found: ${path}`);
1030
+ if (realpathSync(path) === realpathSync(primary)) return reject("PATH equals home primary");
1031
+ const top = gitSync(["rev-parse", "--show-toplevel"], path);
1032
+ if (top.code !== 0 || top.stdout.trim() !== realpathSync(path)) return reject("not a checkout");
1033
+ const origin = gitSync(["config", "--get", "remote.origin.url"], path);
1034
+ if (origin.code !== 0) return reject("no origin");
1035
+ const parsedOrigin = parseClone(origin.stdout.trim());
1036
+ if (!parsedOrigin) return reject("unparseable origin");
1037
+ if (parsedOrigin.identity !== sibling.parsed.identity) return reject("wrong repo");
1038
+ const repo = parsedOrigin.identity.split("/").slice(-2).join("/");
1039
+ if (repo === identifier) candidates.push(path);
1040
+ }
1041
+ if (candidates.length !== 1) return inputFailure(`cannot resolve selected sibling ${leg.repo}`);
1042
+ const branch = /^branch:\s*(\S+)\s*$/m.exec(leg.body)?.[1];
1043
+ const home = /^(?:contract:\s*)?(?:-\s*)?home:\s*(\S+)\s*$/m.exec(leg.body)?.[1];
1044
+ if (!branch || !home || home.split("<name>").length !== 2 || /<[^>]+>/.test(home.replace("<name>", ""))) return inputFailure(`invalid recorded branch or home pattern for ${leg.repo}`);
1045
+ const legRoot = resolve(candidates[0], home.replace("<name>", branch.replaceAll("/", "-")));
1046
+ const checkout = existsSync(legRoot) ? await checkoutOf(legRoot, gitSync) : undefined;
1047
+ const expected = gitSync(["rev-parse", "--path-format=absolute", "--git-common-dir"], candidates[0]);
1048
+ const actual = checkout ? gitSync(["rev-parse", "--path-format=absolute", "--git-common-dir"], legRoot) : undefined;
1049
+ if (!checkout || checkout.toplevel !== legRoot || expected.code !== 0 || actual?.code !== 0 || actual.stdout.trim() !== expected.stdout.trim()) return inputFailure(`cannot resolve worktree for ${leg.repo}`);
1050
+ roots.set(leg.repo, legRoot);
1051
+ }
1052
+ }
985
1053
  const port: FsPort = {
986
- exists: (p) => existsSync(resolve(root, p)),
987
- glob: (pattern) => globSync(pattern, { cwd: root }),
1054
+ exists: (p, leg) => existsSync(resolve(leg ? roots.get(leg)! : root, p)),
1055
+ glob: (pattern, leg) => globSync(pattern, { cwd: leg ? roots.get(leg)! : root }),
988
1056
  };
989
1057
  const findings = checkPlan(planText, specText, port);
990
1058
  if (findings.length > 0) return fail(findings, planAbs);
@@ -1,9 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, test } from "node:test";
3
- import { mkdtempSync, rmSync } from "node:fs";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import registerVerifyBeforeShip from "./verify-before-ship.ts";
7
+ import { execFileSync } from "node:child_process";
7
8
 
8
9
  const tempDirs: string[] = [];
9
10
  after(() => {
@@ -28,7 +29,7 @@ function harness() {
28
29
  for (const handler of handlers.get(name) ?? []) results.push(await handler(event, ctx));
29
30
  return results;
30
31
  };
31
- return { emitEvent };
32
+ return { emitEvent, dir };
32
33
  }
33
34
 
34
35
  const bashCall = (id: string, command: string) => ({ toolName: "bash", toolCallId: id, input: { command } });
@@ -106,3 +107,240 @@ test("a failing recognised run does not clear", async () => {
106
107
  const warning = warningOf(await h.emitEvent("tool_result", bashResult("p1")));
107
108
  assert.match(warning ?? "", /ran without verification/);
108
109
  });
110
+
111
+ test("verification belongs to its checkout and interleaved calls retain ownership", async () => {
112
+ const h = harness();
113
+ await h.emitEvent("tool_call", bashCall("a", "(cd /tmp/home-leg && npm test)"));
114
+ await h.emitEvent("tool_call", bashCall("b", "(cd /tmp/other-leg && npm test)"));
115
+ await h.emitEvent("tool_result", bashResult("a"));
116
+ await h.emitEvent("tool_result", bashResult("b", true));
117
+ await h.emitEvent("tool_call", bashCall("p", "git -C /tmp/other-leg push"));
118
+ assert.match(warningOf(await h.emitEvent("tool_result", bashResult("p"))) ?? "", /ran without verification/);
119
+ await h.emitEvent("tool_call", bashCall("q", "git -C /tmp/home-leg push"));
120
+ assert.equal(warningOf(await h.emitEvent("tool_result", bashResult("q"))), undefined);
121
+ });
122
+
123
+
124
+ test("checkout policies and source invalidation stay local; chained shipping requires prior evidence", async () => {
125
+ const h = harness();
126
+ const other = mkdtempSync(join(tmpdir(), "verify-other-")); tempDirs.push(other);
127
+ mkdirSync(join(other, ".pi"));
128
+ writeFileSync(join(other, ".pi/settings.json"), JSON.stringify({ piGauntlet: { verifyBeforeShip: { testCommands: ["custom-check"] } } }));
129
+ for (const [id, command] of [["home", "npm test"], ["other", `(cd ${other} && custom-check)`]]) {
130
+ await h.emitEvent("tool_call", bashCall(id, command));
131
+ await h.emitEvent("tool_result", bashResult(id));
132
+ }
133
+ await h.emitEvent("tool_call", writeCall("edit", join(other, "source.ts")));
134
+ await h.emitEvent("tool_call", bashCall("p", `git -C ${other} push`));
135
+ assert.match(warningOf(await h.emitEvent("tool_result", bashResult("p"))) ?? "", /custom-check/);
136
+ await h.emitEvent("tool_call", bashCall("h", "git push"));
137
+ assert.equal(warningOf(await h.emitEvent("tool_result", bashResult("h"))), undefined);
138
+ await h.emitEvent("tool_call", bashCall("chain", `(cd ${other} && custom-check && git push)`));
139
+ assert.match(warningOf(await h.emitEvent("tool_result", bashResult("chain"))) ?? "", /ran without verification/);
140
+ });
141
+
142
+ test("unresolved shipping targets never borrow home verification", async () => {
143
+ const h = harness();
144
+ await h.emitEvent("tool_call", bashCall("test", "npm test"));
145
+ await h.emitEvent("tool_result", bashResult("test"));
146
+ for (const command of ['git -C "$X" push', 'cd $X && git push']) {
147
+ await h.emitEvent("tool_call", bashCall("ship", command));
148
+ assert.match(warningOf(await h.emitEvent("tool_result", bashResult("ship"))) ?? "", /ran without verification/, command);
149
+ }
150
+ });
151
+
152
+ test("quoted test mentions do not supply evidence", async () => {
153
+ for (const command of ['git commit -m "npm test"', "printf 'npm test'", 'rg "npm test" .']) {
154
+ const h = harness();
155
+ await h.emitEvent("tool_call", bashCall("mention", command));
156
+ await h.emitEvent("tool_result", bashResult("mention"));
157
+ await h.emitEvent("tool_call", bashCall("ship", "git push"));
158
+ assert.match(warningOf(await h.emitEvent("tool_result", bashResult("ship"))) ?? "", /ran without verification/, command);
159
+ }
160
+ });
161
+
162
+ test("skipped or masked companion tests never supply verification", async () => {
163
+ // `unreached` rows never run the test in real bash; the others stay uncredited even with a successful tool result.
164
+ const rows: { wrap: (target: string) => string; unreached: boolean }[] = [
165
+ { wrap: (target) => `false && (cd ${target} && npm test); true`, unreached: true },
166
+ { wrap: (target) => `f() (cd ${target} && npm test)`, unreached: true },
167
+ { wrap: (target) => `f() { cd ${target} && npm test; }`, unreached: true },
168
+ { wrap: (target) => `function f { cd ${target} && npm test; }`, unreached: true },
169
+ { wrap: (target) => `if false; then (cd ${target} && npm test); fi`, unreached: true },
170
+ { wrap: (target) => `(cd ${target} && npm test) || true`, unreached: false },
171
+ { wrap: (target) => `false || (cd ${target} && npm test)`, unreached: false },
172
+ { wrap: (target) => `(cd ${target} && npm test | tail -50)`, unreached: false },
173
+ { wrap: (target) => `set +o pipefail; (cd ${target} && npm test | tail -50)`, unreached: false },
174
+ { wrap: (target) => `set -o pipefail | cat; (cd ${target} && npm test | tail -50)`, unreached: false },
175
+ { wrap: (target) => `set -o pipefail; (cd ${target} && npm test | tail -50)`, unreached: false },
176
+ { wrap: (target) => `(set -o pipefail; cd ${target} && npm test | tail -50)`, unreached: false },
177
+ { wrap: (target) => `set -o pipefail; (set +o pipefail); (cd ${target} && npm test | tail -50)`, unreached: false },
178
+ { wrap: (target) => `set -eo pipefail\n(cd ${target} && npm test | tail -50)`, unreached: false },
179
+ { wrap: (target) => `(cd ${target} && npm test; true)`, unreached: false },
180
+ ];
181
+ for (const { wrap, unreached } of rows) {
182
+ const h = harness();
183
+ const companion = mkdtempSync(join(tmpdir(), "verify-skipped-"));
184
+ tempDirs.push(companion);
185
+ const command = wrap(companion);
186
+ if (unreached) assert.equal(execFileSync("bash", ["-c", command], { encoding: "utf8" }), "", command);
187
+ await h.emitEvent("tool_call", bashCall("masked", command));
188
+ await h.emitEvent("tool_result", bashResult("masked"));
189
+ await h.emitEvent("tool_call", bashCall("ship", `git -C ${companion} push`));
190
+ assert.match(warningOf(await h.emitEvent("tool_result", bashResult("ship"))) ?? "", /ran without verification/, command);
191
+ }
192
+ });
193
+
194
+ test("unconditional companion test statements supply verification", async () => {
195
+ for (const prefix of ["true; ", "true\n", "true && "]) {
196
+ const h = harness();
197
+ const companion = mkdtempSync(join(tmpdir(), "verify-unconditional-"));
198
+ tempDirs.push(companion);
199
+ await h.emitEvent("tool_call", bashCall("test", `${prefix}(cd ${companion} && npm test)`));
200
+ await h.emitEvent("tool_result", bashResult("test"));
201
+ await h.emitEvent("tool_call", bashCall("ship", `git -C ${companion} push`));
202
+ assert.equal(warningOf(await h.emitEvent("tool_result", bashResult("ship"))), undefined, prefix);
203
+ }
204
+ });
205
+
206
+ test("masked pipeline exits 0 in bash while npm test fails, and still warns", async () => {
207
+ const h = harness();
208
+ const companion = mkdtempSync(join(tmpdir(), "verify-pipeline-"));
209
+ tempDirs.push(companion);
210
+ const command = `set +o pipefail; (cd ${companion} && npm test | tail -50)`;
211
+ assert.equal(execFileSync("bash", ["-c", `${command}; echo status=$?`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(), "status=0");
212
+ assert.throws(() => execFileSync("bash", ["-c", `cd ${companion} && npm test`], { stdio: "ignore" }));
213
+ await h.emitEvent("tool_call", bashCall("masked", command));
214
+ await h.emitEvent("tool_result", bashResult("masked"));
215
+ await h.emitEvent("tool_call", bashCall("ship", `git -C ${companion} push`));
216
+ assert.match(warningOf(await h.emitEvent("tool_result", bashResult("ship"))) ?? "", /ran without verification/);
217
+ });
218
+
219
+ test("redirections, pipelines, and env-assignment prefixes keep baseline credit", async () => {
220
+ const rows: { command: (leg: string) => string; leg: boolean }[] = [
221
+ { command: () => "npm test 2>&1", leg: false },
222
+ { command: () => "npm test > log", leg: false },
223
+ { command: () => "cat /dev/null | npm test", leg: false },
224
+ { command: (leg) => `(cd ${leg} && tail -50 | npm test)`, leg: true },
225
+ { command: () => "CI=1 npm test", leg: false },
226
+ { command: (leg) => `(cd ${leg} && CI=1 npm test 2>&1)`, leg: true },
227
+ ];
228
+ for (const row of rows) {
229
+ const h = harness();
230
+ const leg = mkdtempSync(join(tmpdir(), "verify-redirect-"));
231
+ tempDirs.push(leg);
232
+ const command = row.command(leg);
233
+ await h.emitEvent("tool_call", bashCall("test", command));
234
+ await h.emitEvent("tool_result", bashResult("test"));
235
+ await h.emitEvent("tool_call", bashCall("ship", row.leg ? `git -C ${leg} push` : "git push"));
236
+ assert.equal(warningOf(await h.emitEvent("tool_result", bashResult("ship"))), undefined, command);
237
+ }
238
+ });
239
+
240
+ test("settings errors appear once per chained call", async () => {
241
+ const h = harness();
242
+ mkdirSync(join(h.dir, ".pi"));
243
+ writeFileSync(join(h.dir, ".pi/settings.json"), "{");
244
+ await h.emitEvent("tool_call", bashCall("chain", "npm test && git push"));
245
+ const warning = warningOf(await h.emitEvent("tool_result", bashResult("chain"))) ?? "";
246
+ assert.equal(warning.split("gauntlet settings load error").length - 1, 1, warning);
247
+ });
248
+
249
+ test("coordinated linked worktree verification uses its primary's custom policy", async () => {
250
+ const h = harness();
251
+ const git = (...args: string[]) => execFileSync("git", ["-C", h.dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
252
+ git("init", "-b", "main");
253
+ git("-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "base");
254
+ const worktree = join(h.dir, "linked-feature");
255
+ git("worktree", "add", "-b", "feature", worktree);
256
+ mkdirSync(join(h.dir, ".pi"));
257
+ writeFileSync(join(h.dir, ".pi/settings.json"), JSON.stringify({ piGauntlet: { verifyBeforeShip: { testCommands: ["custom-check"] } } }));
258
+ git("remote", "add", "origin", "https://example.invalid/team/companion.git");
259
+ const spec = join(h.dir, "spec.md");
260
+ writeFileSync(spec, "## Legs\n### Leg: team/home\n### Leg: team/companion\nbranch: feature\nhome: linked-<name>\n");
261
+ await h.emitEvent("tool_result", { toolName: "plan_check", isError: false, details: { status: "pass", specPath: spec } });
262
+ for (const [id, command, warns] of [["default", "npm test", true], ["custom", "custom-check", false]] as const) {
263
+ await h.emitEvent("tool_call", bashCall(id, `(cd ${worktree} && ${command})`));
264
+ await h.emitEvent("tool_result", bashResult(id));
265
+ await h.emitEvent("tool_call", bashCall("ship", `git -C ${worktree} push`));
266
+ const warning = warningOf(await h.emitEvent("tool_result", bashResult("ship")));
267
+ if (warns) assert.match(warning ?? "", /custom-check/);
268
+ else assert.equal(warning, undefined);
269
+ }
270
+ });
271
+
272
+ test("real linked worktree evidence stays separate from primary post-squash verification", async () => {
273
+ const h = harness();
274
+ const git = (cwd: string, ...args: string[]) => execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
275
+ git(h.dir, "init", "-b", "main");
276
+ git(h.dir, "config", "user.name", "Test");
277
+ git(h.dir, "config", "user.email", "test@example.invalid");
278
+ writeFileSync(join(h.dir, "source.ts"), "export const value = 1;\n");
279
+ git(h.dir, "add", ".");
280
+ git(h.dir, "commit", "-m", "base");
281
+ const worktree = join(h.dir, "linked");
282
+ git(h.dir, "worktree", "add", "-b", "feature", worktree);
283
+ writeFileSync(join(worktree, "source.ts"), "export const value = 2;\n");
284
+ git(worktree, "commit", "-am", "change");
285
+ await h.emitEvent("tool_call", bashCall("feature-test", `(cd ${worktree} && npm test)`));
286
+ await h.emitEvent("tool_result", bashResult("feature-test"));
287
+ git(h.dir, "merge", "--squash", "feature");
288
+ git(h.dir, "commit", "-m", "squash");
289
+ await h.emitEvent("tool_call", bashCall("primary-ship", "git push"));
290
+ assert.match(warningOf(await h.emitEvent("tool_result", bashResult("primary-ship"))) ?? "", /ran without verification/);
291
+ await h.emitEvent("tool_call", bashCall("post-squash", "npm test"));
292
+ await h.emitEvent("tool_result", bashResult("post-squash", true));
293
+ await h.emitEvent("tool_call", bashCall("failed-ship", "git push"));
294
+ assert.match(warningOf(await h.emitEvent("tool_result", bashResult("failed-ship"))) ?? "", /ran without verification/);
295
+ await h.emitEvent("tool_call", bashCall("retry", "npm test"));
296
+ await h.emitEvent("tool_result", bashResult("retry"));
297
+ await h.emitEvent("tool_call", bashCall("verified-ship", "git push"));
298
+ assert.equal(warningOf(await h.emitEvent("tool_result", bashResult("verified-ship"))), undefined);
299
+ await h.emitEvent("tool_call", writeCall("feature-edit", join(worktree, "source.ts")));
300
+ await h.emitEvent("tool_call", bashCall("primary-still-verified", "git push"));
301
+ assert.equal(warningOf(await h.emitEvent("tool_result", bashResult("primary-still-verified"))), undefined);
302
+ await h.emitEvent("tool_call", bashCall("feature-stale", `git -C ${worktree} push`));
303
+ assert.match(warningOf(await h.emitEvent("tool_result", bashResult("feature-stale"))) ?? "", /ran without verification/);
304
+ });
305
+
306
+ test("fresh-session ship retry restores the home primary's policy from resume arming", async () => {
307
+ const h = harness();
308
+ const git = (...args: string[]) => execFileSync("git", ["-C", h.dir, ...args], { stdio: "ignore" });
309
+ git("init", "-b", "main");
310
+ git("-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "base");
311
+ const worktree = join(h.dir, "linked");
312
+ git("worktree", "add", "-b", "feature", worktree);
313
+ mkdirSync(join(h.dir, ".pi"));
314
+ writeFileSync(join(h.dir, ".pi/settings.json"), JSON.stringify({ piGauntlet: { verifyBeforeShip: { testCommands: ["custom-check"] } } }));
315
+ const spec = join(worktree, "spec.md");
316
+ writeFileSync(spec, "## Legs\n### Leg: team/home\n### Leg: team/companion\nbranch: feature\nhome: linked-<name>\n");
317
+ await h.emitEvent("tool_result", { toolName: "phase_tracker", details: { action: "start", phases: { brainstorm: { status: "in_progress" } } } });
318
+ await h.emitEvent("tool_result", { toolName: "phase_tracker", details: { action: "skip", phases: { brainstorm: { status: "skipped", reason: `resume: ${spec}` } } } });
319
+ for (const [id, command, warns] of [["default", "npm test", true], ["custom", "custom-check", false]] as const) {
320
+ await h.emitEvent("tool_call", bashCall(id, `(cd ${worktree} && ${command})`));
321
+ await h.emitEvent("tool_result", bashResult(id));
322
+ await h.emitEvent("tool_call", bashCall("ship", `git -C ${worktree} push`));
323
+ const warning = warningOf(await h.emitEvent("tool_result", bashResult("ship")));
324
+ if (warns) assert.match(warning ?? "", /custom-check/);
325
+ else assert.equal(warning, undefined);
326
+ }
327
+ });
328
+
329
+ test("legacy linked worktree verification uses its local custom policy", async () => {
330
+ const h = harness();
331
+ const git = (...args: string[]) => execFileSync("git", ["-C", h.dir, ...args], { stdio: "ignore" });
332
+ git("init", "-b", "main");
333
+ git("-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "base");
334
+ const worktree = join(h.dir, "linked");
335
+ git("worktree", "add", "-b", "feature", worktree);
336
+ mkdirSync(join(worktree, ".pi"));
337
+ writeFileSync(join(worktree, ".pi/settings.json"), JSON.stringify({ piGauntlet: { verifyBeforeShip: { testCommands: ["custom-check"] } } }));
338
+ for (const [id, command, warns] of [["default", "npm test", true], ["custom", "custom-check", false]] as const) {
339
+ await h.emitEvent("tool_call", bashCall(id, `(cd ${worktree} && ${command})`));
340
+ await h.emitEvent("tool_result", bashResult(id));
341
+ await h.emitEvent("tool_call", bashCall("ship", `git -C ${worktree} push`));
342
+ const warning = warningOf(await h.emitEvent("tool_result", bashResult("ship")));
343
+ if (warns) assert.match(warning ?? "", /custom-check/);
344
+ else assert.equal(warning, undefined);
345
+ }
346
+ });
@@ -25,6 +25,9 @@
25
25
  * `testCommands` to match your project's conventions.
26
26
  */
27
27
 
28
+ import { resolve, dirname } from "node:path";
29
+ import { existsSync, realpathSync, readFileSync } from "node:fs";
30
+ import { spawnSync } from "node:child_process";
28
31
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
32
  import {
30
33
  DEFAULT_TEST_COMMANDS,
@@ -34,6 +37,8 @@ import {
34
37
  } from "./lib/gauntlet-settings.ts";
35
38
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
36
39
  import { matchShipStatement } from "./lib/telemetry-paths.ts";
40
+ import { checkoutOfSync, gitSync } from "./lib/checkout.ts";
41
+ import { parseLegs } from "./lib/plan-check.ts";
37
42
 
38
43
  const SOURCE_EXT = /\.(ts|tsx|js|jsx|py|rb|go|rs|java|swift|kt)$/;
39
44
  const TEST_PATH = /(^|\/)(tests?|__tests__)\/|\.(test|spec)\.|_test\.(py|go|rb)$/;
@@ -56,9 +61,159 @@ const formatWarning = (command: string, testCommands: string[], reference: strin
56
61
  );
57
62
  };
58
63
 
64
+ const checkout = (path: string): string => {
65
+ let existing = resolve(path);
66
+ while (!existsSync(existing) && dirname(existing) !== existing) existing = dirname(existing);
67
+ const result = spawnSync("git", ["-C", existing, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
68
+ const root = result.status === 0 ? result.stdout.trim() : resolve(path);
69
+ try { return realpathSync(root); } catch { return root; }
70
+ };
71
+
72
+ // Only literal shell directories can supply evidence; expansions remain untrusted.
73
+ const statements = (command: string, cwd: string): { command: string; testCommand: string; target: string; trusted: boolean }[] => {
74
+ const tokens = command.match(/"[^"\n]*"|'[^'\n]*'|&&|\|\||[;()\n]|[^\s;()]+/g) ?? [];
75
+ // Allow only literal simple commands and subshells. Only the final list in
76
+ // each sequence can prove success: earlier failures can be masked by a separator.
77
+ const literalWord = /^(?:[a-zA-Z0-9_./:=+,%@-]+|'[^'\\\n$`]*'|"[^"\\\n$`]*")$/;
78
+ // Redirections and leading assignments do not mask a command's exit status.
79
+ const redirection = /^(?:[0-9]*[<>]{1,2}(?:&[0-9]*)?|&>>?)[a-zA-Z0-9_./-]*$/;
80
+ const assignment = /^[a-zA-Z_][a-zA-Z0-9_]*=/;
81
+ const reservedWord = /^(?:if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|time|coproc)$/;
82
+ let position = 0;
83
+ const parseCommand = (): boolean => {
84
+ if (tokens[position] === "(") {
85
+ position++;
86
+ if (!parseSequence() || tokens[position] !== ")") return false;
87
+ position++;
88
+ return true;
89
+ }
90
+ let named = false;
91
+ for (; position < tokens.length; position++) {
92
+ const token = tokens[position];
93
+ if (redirection.test(token)) continue;
94
+ if (!literalWord.test(token)) break;
95
+ if (named || assignment.test(token)) continue;
96
+ if (reservedWord.test(token) || token === "false") return false;
97
+ named = true;
98
+ }
99
+ return named;
100
+ };
101
+ // Earlier pipeline segments cannot prove success because their exit status may be masked.
102
+ const masked = new Set<number>();
103
+ const parsePipeline = (): boolean => {
104
+ const start = position;
105
+ let lastStart = position;
106
+ if (!parseCommand()) return false;
107
+ while (tokens[position] === "|") {
108
+ position++;
109
+ lastStart = position;
110
+ if (!parseCommand()) return false;
111
+ }
112
+ for (let i = start; i < lastStart; i++) masked.add(i);
113
+ return true;
114
+ };
115
+ const parseAndList = (): boolean => {
116
+ if (!parsePipeline()) return false;
117
+ while (tokens[position] === "&&") {
118
+ position++;
119
+ if (!parsePipeline()) return false;
120
+ }
121
+ return true;
122
+ };
123
+ const parseSequence = (): boolean => {
124
+ let start = position;
125
+ if (!parseAndList()) return false;
126
+ while (tokens[position] === ";" || tokens[position] === "\n") {
127
+ position++;
128
+ for (let i = start; i < position; i++) masked.add(i);
129
+ start = position;
130
+ if (position !== tokens.length && !parseAndList()) return false;
131
+ }
132
+ return true;
133
+ };
134
+ const canCredit = parseSequence() && position === tokens.length;
135
+ const out: { command: string; testCommand: string; target: string; trusted: boolean }[] = [];
136
+ const stack: string[] = [];
137
+ let dir = cwd, words: string[] = [], testWords: string[] = [];
138
+ const flush = () => {
139
+ if (!words.length) return;
140
+ const literal = (word: string) => !/[$`]/.test(word);
141
+ if (words[0] === "cd" && words.length === 2) {
142
+ dir = literal(words[1]) ? resolve(dir, words[1]) : "";
143
+ } else {
144
+ let target = dir;
145
+ if (words[0] === "git") {
146
+ for (let i = 1; i < words.length - 1; i++) if (words[i] === "-C") target = literal(words[i + 1]) ? resolve(target, words[++i]) : "";
147
+ }
148
+ out.push({ command: words.join(" "), testCommand: canCredit ? testWords.join(" ") : "", target: checkout(target || cwd), trusted: !!target });
149
+ }
150
+ words = [];
151
+ testWords = [];
152
+ };
153
+ for (const [index, token] of tokens.entries()) {
154
+ if (["&&", "||", ";", "\n", "(", ")"].includes(token)) {
155
+ flush();
156
+ if (token === "(") stack.push(dir);
157
+ if (token === ")") dir = stack.pop() ?? cwd;
158
+ } else {
159
+ const quoted = /^["']/.test(token);
160
+ words.push(quoted ? token.slice(1, -1) : token);
161
+ testWords.push(quoted || masked.has(index) ? '""' : token);
162
+ }
163
+ }
164
+ flush();
165
+ return out;
166
+ };
167
+
59
168
  export default function (pi: ExtensionAPI) {
60
- let verified = false;
61
- let pendingTestCallId: string | null = null;
169
+ const verified = new Set<string>();
170
+ let homeSpec: string | undefined;
171
+ const rememberPlan = (event: { toolName: string; isError?: boolean; details?: unknown }, cwd: string) => {
172
+ if (event.toolName === "phase_tracker" && !homeSpec && !event.isError) {
173
+ const details = event.details as { action?: string; error?: string; phases?: Record<string, { status?: string; reason?: string }> } | undefined;
174
+ if (details?.action === "skip" && !details.error) {
175
+ for (const phase of ["brainstorm", "plan"]) {
176
+ const state = details.phases?.[phase];
177
+ const spec = state?.status === "skipped" ? /^resume: (.+?)(?: \(loop engine\))?$/.exec(state.reason ?? "")?.[1] : undefined;
178
+ if (spec) {
179
+ homeSpec = resolve(cwd, spec);
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ }
185
+ if (event.toolName !== "plan_check") return;
186
+ const details = event.details as { status?: string; specPath?: string } | undefined;
187
+ homeSpec = !event.isError && details?.status === "pass" ? details.specPath : undefined;
188
+ };
189
+ pi.on("session_start", async (_event, ctx) => {
190
+ homeSpec = undefined;
191
+ for (const entry of ctx.sessionManager.getBranch()) {
192
+ if (entry.type === "message" && entry.message.role === "toolResult") rememberPlan(entry.message, ctx.cwd);
193
+ }
194
+ });
195
+ const coordinatedMember = (target: string, primary: string): boolean => {
196
+ if (!homeSpec) return false;
197
+ let legs: ReturnType<typeof parseLegs>;
198
+ try { legs = parseLegs(readFileSync(homeSpec, "utf8")); } catch { return false; }
199
+ if (legs.length < 2) return false;
200
+ if (target === checkout(dirname(homeSpec))) return true;
201
+ const remote = gitSync(["remote", "get-url", "origin"], primary);
202
+ let repo = remote.stdout.trim().replace(/\.git$/, "").replace(/^.*[:/]([^/:]+\/[^/]+)$/, "$1").toLowerCase();
203
+ if (remote.code !== 0) {
204
+ const overridesPath = [".pi/gauntlet-overrides.md", "gauntlet-overrides.md", "doc/gauntlet-overrides.md"].map(p => resolve(primary, p)).find(p => existsSync(p));
205
+ const overrides = overridesPath ? readFileSync(overridesPath, "utf8") : "";
206
+ const tracker = /^## Issue tracker\s*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m.exec(overrides)?.[1] ?? "";
207
+ repo = (/^- repo:\s*(\S+)\s*$/m.exec(tracker)?.[1] ?? primary.split("/").at(-1) ?? "").toLowerCase();
208
+ }
209
+ return legs.slice(1).some(leg => {
210
+ const branch = /^branch:\s*(\S+)\s*$/m.exec(leg.body)?.[1];
211
+ const home = /^(?:contract:\s*)?(?:-\s*)?home:\s*(\S+)\s*$/m.exec(leg.body)?.[1];
212
+ return leg.repo === repo && !!branch && !!home && home.split("<name>").length === 2 &&
213
+ target === checkout(resolve(primary, home.replace("<name>", branch.replaceAll("/", "-"))));
214
+ });
215
+ };
216
+ const pendingTests = new Map<string, Set<string>>();
62
217
  const pendingShipWarnings = new Map<string, string>();
63
218
 
64
219
  const getCommand = (event: { input: unknown }): string => {
@@ -73,7 +228,7 @@ export default function (pi: ExtensionAPI) {
73
228
 
74
229
  pi.on("tool_call", async (event, ctx) => {
75
230
  if (event.toolName === "write" || event.toolName === "edit") {
76
- if (isSourceWrite(getPath(event))) verified = false;
231
+ if (isSourceWrite(getPath(event))) verified.delete(checkout(dirname(resolve(ctx.cwd, getPath(event)!))));
77
232
  return undefined;
78
233
  }
79
234
 
@@ -86,38 +241,38 @@ export default function (pi: ExtensionAPI) {
86
241
  pendingShipWarnings.set(id, prior ? prior + "\n\n" + text : text);
87
242
  };
88
243
 
89
- const { gauntlet, errors } = loadGauntletSettings(ctx.cwd);
90
- const { testCommands, warningReference } = resolveVerifyBeforeShip(gauntlet, DEFAULT_TEST_COMMANDS);
91
- const settingsWarned = errors.length > 0 ? settingsErrorWarning(errors) : undefined;
244
+ const targets = new Set<string>();
92
245
  let settingsErrShown = false;
93
- const testRegex = buildTestCmdRegex(testCommands);
94
-
95
- if (testRegex.test(command)) {
96
- pendingTestCallId = event.toolCallId;
97
- if (settingsWarned && !settingsErrShown) {
98
- addShipWarning(event.toolCallId, settingsWarned);
99
- settingsErrShown = true;
246
+ for (const statement of statements(command, ctx.cwd)) {
247
+ const targetCheckout = checkoutOfSync(statement.target);
248
+ let settingsRoot = statement.target;
249
+ if (targetCheckout?.via === "git" && !targetCheckout.isPrimary) {
250
+ const common = gitSync(["rev-parse", "--path-format=absolute", "--git-common-dir"], statement.target);
251
+ if (common.code === 0 && coordinatedMember(statement.target, dirname(common.stdout.trim()))) settingsRoot = dirname(common.stdout.trim());
100
252
  }
101
- return undefined;
102
- }
103
-
104
- if (matchShipStatement(command) && !verified) {
105
- if (settingsWarned && !settingsErrShown) {
106
- addShipWarning(event.toolCallId, settingsWarned);
253
+ const { gauntlet, errors } = loadGauntletSettings(settingsRoot);
254
+ const { testCommands, warningReference } = resolveVerifyBeforeShip(gauntlet, DEFAULT_TEST_COMMANDS);
255
+ const isTest = buildTestCmdRegex(testCommands).test(statement.testCommand) && !/^(?:echo|printf|rg|grep)\b/.test(statement.command);
256
+ if (isTest && statement.trusted) targets.add(statement.target);
257
+ if ((isTest || matchShipStatement(statement.command)) && errors.length && !settingsErrShown) {
258
+ addShipWarning(event.toolCallId, settingsErrorWarning(errors));
107
259
  settingsErrShown = true;
108
260
  }
109
- addShipWarning(event.toolCallId, formatWarning(command, testCommands, warningReference));
261
+ if (matchShipStatement(statement.command) && (!statement.trusted || !verified.has(statement.target))) {
262
+ addShipWarning(event.toolCallId, formatWarning(command, testCommands, warningReference));
263
+ }
110
264
  }
265
+ if (targets.size) pendingTests.set(event.toolCallId, targets);
111
266
  return undefined;
112
267
  });
113
268
 
114
- pi.on("tool_result", async (event) => {
269
+ pi.on("tool_result", async (event, ctx) => {
270
+ rememberPlan(event, ctx.cwd);
115
271
  if (event.toolName !== "bash") return undefined;
116
272
 
117
- if (pendingTestCallId === event.toolCallId) {
118
- pendingTestCallId = null;
119
- if (!event.isError) verified = true;
120
- }
273
+ const targets = pendingTests.get(event.toolCallId);
274
+ pendingTests.delete(event.toolCallId);
275
+ if (!event.isError) for (const target of targets ?? []) verified.add(target);
121
276
 
122
277
  const warning = pendingShipWarnings.get(event.toolCallId);
123
278
  if (!warning) return undefined;