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.
- package/CHANGELOG.md +17 -0
- package/README.md +16 -4
- package/agents/code-reviewer.md +2 -0
- package/agents/implementer.md +2 -0
- package/agents/loop-implementer.md +10 -0
- package/agents/plan-writer.md +1 -1
- package/agents/spec-reviewer.md +2 -0
- package/bin/gauntlet-telemetry-seal.mjs +18 -3
- package/bin/gauntlet-telemetry-seal.test.mjs +30 -0
- package/extensions/lib/plan-check.test.ts +116 -0
- package/extensions/lib/plan-check.ts +149 -20
- package/extensions/lib/telemetry-record.test.ts +11 -0
- package/extensions/lib/telemetry-record.ts +7 -0
- package/extensions/phase-tracker.test.ts +201 -3
- package/extensions/phase-tracker.ts +77 -9
- package/extensions/verify-before-ship.test.ts +240 -2
- package/extensions/verify-before-ship.ts +180 -25
- package/package.json +2 -2
- package/skills/brainstorming/SKILL.md +2 -2
- package/skills/brainstorming/gatherer.md +20 -7
- package/skills/brainstorming/reference/amendment-surface.md +13 -1
- package/skills/brainstorming/reference/legs.md +187 -0
- package/skills/brainstorming/reference/pr-subject.md +15 -9
- package/skills/brainstorming/reference/spec-finalization.md +16 -2
- package/skills/brainstorming/reference/ticket-acceptance.md +2 -2
- package/skills/finishing-a-development-branch/SKILL.md +21 -22
- package/skills/finishing-a-development-branch/reference/legs.md +76 -0
- package/skills/gauntlet-handoff/SKILL.md +2 -2
- package/skills/gauntlet-resume/SKILL.md +3 -2
- package/skills/gauntlet-resume/reference/brief-contract.md +40 -6
- package/skills/gauntlet-resume/reference/reconstruction.md +9 -7
- package/skills/gauntlet-resume/reference/seed.md +2 -0
- package/skills/loop-implementation/SKILL.md +89 -2
- package/skills/subagent-driven-development/SKILL.md +12 -0
- package/skills/using-git-worktrees/SKILL.md +59 -22
- package/skills/using-git-worktrees/reference/pr-head.md +36 -15
- package/skills/writing-plans/SKILL.md +16 -1
- 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
|
-
|
|
61
|
-
let
|
|
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
|
|
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
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
addShipWarning(event.toolCallId,
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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;
|