pi-gauntlet 7.40.1 → 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 +11 -0
- package/README.md +14 -2
- 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 +1 -1
- package/skills/brainstorming/gatherer.md +14 -1
- package/skills/brainstorming/reference/amendment-surface.md +13 -1
- package/skills/brainstorming/reference/legs.md +187 -0
- package/skills/brainstorming/reference/spec-finalization.md +14 -0
- package/skills/brainstorming/reference/ticket-acceptance.md +2 -2
- package/skills/finishing-a-development-branch/SKILL.md +2 -2
- 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 +37 -3
- package/skills/gauntlet-resume/reference/reconstruction.md +4 -0
- package/skills/gauntlet-resume/reference/seed.md +2 -0
- package/skills/loop-implementation/SKILL.md +89 -2
- package/skills/subagent-driven-development/SKILL.md +10 -0
- package/skills/using-git-worktrees/SKILL.md +58 -21
- package/skills/writing-plans/SKILL.md +8 -0
- package/skills/writing-plans/reference/plan-contract.md +9 -1
|
@@ -13,8 +13,8 @@ export interface PlanCheckFinding {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface FsPort {
|
|
16
|
-
exists(path: string): boolean;
|
|
17
|
-
glob(pattern: string): string[];
|
|
16
|
+
exists(path: string, leg?: string): boolean;
|
|
17
|
+
glob(pattern: string, leg?: string): string[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export function sha256(bytes: Uint8Array): string {
|
|
@@ -43,6 +43,7 @@ interface TestsBullet {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
interface Task {
|
|
46
|
+
leg?: string;
|
|
46
47
|
number: number;
|
|
47
48
|
line: number;
|
|
48
49
|
text: string;
|
|
@@ -221,7 +222,7 @@ function parsePlan(planText: string): ParsedPlan {
|
|
|
221
222
|
|
|
222
223
|
for (let k = i; k <= bodyEndIdx; k++) {
|
|
223
224
|
const line = lines[k];
|
|
224
|
-
if (filesLine === undefined &&
|
|
225
|
+
if (filesLine === undefined && /^(?:\*\*Files:\*\*|Files:)/.test(line)) {
|
|
225
226
|
filesLine = k + 1;
|
|
226
227
|
let p = k + 1;
|
|
227
228
|
while (p <= bodyEndIdx) {
|
|
@@ -509,7 +510,7 @@ function checkTestsBlock(parsed: ParsedPlan, fs: FsPort): PlanCheckFinding[] {
|
|
|
509
510
|
const push = (task: Task, line: number, text: string, reason: string) =>
|
|
510
511
|
findings.push({ check: "tests-block", line, text, reason: `Task ${task.number}: ${reason}` });
|
|
511
512
|
const createPaths = new Set(
|
|
512
|
-
parsed.tasks.flatMap((t) => t.files.filter((f) => f.kind === "create").map((f) => stripLineSuffix(f.path))),
|
|
513
|
+
parsed.tasks.flatMap((t) => t.files.filter((f) => f.kind === "create").map((f) => `${t.leg ?? ""}\0${stripLineSuffix(f.path)}`)),
|
|
513
514
|
);
|
|
514
515
|
const header = headerSegments(parsed);
|
|
515
516
|
|
|
@@ -519,7 +520,7 @@ function checkTestsBlock(parsed: ParsedPlan, fs: FsPort): PlanCheckFinding[] {
|
|
|
519
520
|
|
|
520
521
|
for (const f of testEntries) {
|
|
521
522
|
const p = stripLineSuffix(f.path);
|
|
522
|
-
if (!createPaths.has(p) && !fs.exists(p)) push(task, f.line, f.text, `unknown \`Test:\` path "${p}" (neither exists nor is a Create: path of any task)`);
|
|
523
|
+
if (!createPaths.has(`${task.leg ?? ""}\0${p}`) && !fs.exists(p, task.leg)) push(task, f.line, f.text, `unknown \`Test:\` path "${p}" (neither exists nor is a Create: path of any task)`);
|
|
523
524
|
}
|
|
524
525
|
|
|
525
526
|
if (task.testsMisplacedLines.length > 0) {
|
|
@@ -599,7 +600,7 @@ function checkBaselineLines(parsed: ParsedPlan, fs: FsPort): PlanCheckFinding[]
|
|
|
599
600
|
const found = anchors(segment);
|
|
600
601
|
const created = [...creates].filter(p => segment.split(" ").some(token => token === p || token.startsWith(p + "::") || token.startsWith(p + "#") || token.startsWith(p + ":")));
|
|
601
602
|
for (const path of created) push(bullet, `baseline: pass via \`${match[2]}\` segment \`${segment}\` anchors Create: path ${path}`);
|
|
602
|
-
if (!found.some(p => !creates.has(p) && fs.exists(p))) push(bullet, `baseline: pass via \`${match[2]}\` segment \`${segment}\` anchors no existing Test: path`);
|
|
603
|
+
if (!found.some(p => !creates.has(p) && fs.exists(p, task.leg))) push(bullet, `baseline: pass via \`${match[2]}\` segment \`${segment}\` anchors no existing Test: path`);
|
|
603
604
|
}
|
|
604
605
|
}
|
|
605
606
|
}
|
|
@@ -877,7 +878,7 @@ function checkPathsExist(parsed: ParsedPlan, fs: FsPort): PlanCheckFinding[] {
|
|
|
877
878
|
if (isGlob(path)) {
|
|
878
879
|
let matches: string[];
|
|
879
880
|
try {
|
|
880
|
-
matches = fs.glob(path);
|
|
881
|
+
matches = fs.glob(path, task.leg);
|
|
881
882
|
} catch (err) {
|
|
882
883
|
findings.push({
|
|
883
884
|
check: "paths-exist",
|
|
@@ -895,7 +896,7 @@ function checkPathsExist(parsed: ParsedPlan, fs: FsPort): PlanCheckFinding[] {
|
|
|
895
896
|
reason: `Modify: glob "${path}" matched no files`,
|
|
896
897
|
});
|
|
897
898
|
}
|
|
898
|
-
} else if (!fs.exists(path)) {
|
|
899
|
+
} else if (!fs.exists(path, task.leg)) {
|
|
899
900
|
findings.push({
|
|
900
901
|
check: "paths-exist",
|
|
901
902
|
line: f.line,
|
|
@@ -967,16 +968,17 @@ function fileEntries(task: Task): { path: string; kind: "literal" | "glob"; role
|
|
|
967
968
|
function checkWaveFileDisjointness(parsed: ParsedPlan, fs: FsPort): PlanCheckFinding[] {
|
|
968
969
|
const findings: PlanCheckFinding[] = [];
|
|
969
970
|
const globCache = new Map<string, string[] | Error>();
|
|
970
|
-
const expand = (pattern: string): string[] | Error => {
|
|
971
|
-
const
|
|
971
|
+
const expand = (pattern: string, leg?: string): string[] | Error => {
|
|
972
|
+
const key = `${leg ?? ""}\0${pattern}`;
|
|
973
|
+
const cached = globCache.get(key);
|
|
972
974
|
if (cached !== undefined) return cached;
|
|
973
975
|
let result: string[] | Error;
|
|
974
976
|
try {
|
|
975
|
-
result = fs.glob(pattern);
|
|
977
|
+
result = fs.glob(pattern, leg);
|
|
976
978
|
} catch (err) {
|
|
977
979
|
result = err instanceof Error ? err : new Error(String(err));
|
|
978
980
|
}
|
|
979
|
-
globCache.set(
|
|
981
|
+
globCache.set(key, result);
|
|
980
982
|
return result;
|
|
981
983
|
};
|
|
982
984
|
|
|
@@ -985,6 +987,7 @@ function checkWaveFileDisjointness(parsed: ParsedPlan, fs: FsPort): PlanCheckFin
|
|
|
985
987
|
if (tasks.length < 2) continue;
|
|
986
988
|
for (let i = 0; i < tasks.length; i++) {
|
|
987
989
|
for (let j = i + 1; j < tasks.length; j++) {
|
|
990
|
+
if (tasks[i].leg !== tasks[j].leg) continue;
|
|
988
991
|
const a = fileEntries(tasks[i]);
|
|
989
992
|
const b = fileEntries(tasks[j]);
|
|
990
993
|
for (const ea of a) {
|
|
@@ -995,16 +998,16 @@ function checkWaveFileDisjointness(parsed: ParsedPlan, fs: FsPort): PlanCheckFin
|
|
|
995
998
|
if (ea.kind === "literal" && eb.kind === "literal") {
|
|
996
999
|
overlap = ea.path === eb.path;
|
|
997
1000
|
} else if (ea.kind === "glob" && eb.kind === "literal") {
|
|
998
|
-
const exp = expand(ea.path);
|
|
1001
|
+
const exp = expand(ea.path, tasks[i].leg);
|
|
999
1002
|
if (exp instanceof Error) errFinding = globErrFinding(wave, ea.path, exp);
|
|
1000
1003
|
else overlap = exp.includes(eb.path);
|
|
1001
1004
|
} else if (ea.kind === "literal" && eb.kind === "glob") {
|
|
1002
|
-
const exp = expand(eb.path);
|
|
1005
|
+
const exp = expand(eb.path, tasks[j].leg);
|
|
1003
1006
|
if (exp instanceof Error) errFinding = globErrFinding(wave, eb.path, exp);
|
|
1004
1007
|
else overlap = exp.includes(ea.path);
|
|
1005
1008
|
} else {
|
|
1006
|
-
const expA = expand(ea.path);
|
|
1007
|
-
const expB = expand(eb.path);
|
|
1009
|
+
const expA = expand(ea.path, tasks[i].leg);
|
|
1010
|
+
const expB = expand(eb.path, tasks[j].leg);
|
|
1008
1011
|
if (expA instanceof Error) errFinding = globErrFinding(wave, ea.path, expA);
|
|
1009
1012
|
else if (expB instanceof Error) errFinding = globErrFinding(wave, eb.path, expB);
|
|
1010
1013
|
else overlap = expA.some((p) => expB.includes(p));
|
|
@@ -1134,6 +1137,101 @@ function checkWaiverLiteral(parsed: ParsedPlan): PlanCheckFinding[] {
|
|
|
1134
1137
|
return findings;
|
|
1135
1138
|
}
|
|
1136
1139
|
|
|
1140
|
+
export function parseLegs(specText: string): { repo: string; body: string; line: number }[] {
|
|
1141
|
+
const lines = specText.split("\n");
|
|
1142
|
+
const mask = fenceMask(lines);
|
|
1143
|
+
const legs: { repo: string; body: string; line: number }[] = [];
|
|
1144
|
+
let inside = false;
|
|
1145
|
+
let current: typeof legs[number] | undefined;
|
|
1146
|
+
for (const [i, line] of lines.entries()) {
|
|
1147
|
+
if (mask[i]) continue;
|
|
1148
|
+
if (/^## /.test(line)) { inside = /^## Legs\s*$/.test(line); current = undefined; continue; }
|
|
1149
|
+
if (!inside) continue;
|
|
1150
|
+
const match = /^### Leg: (\S+)\s*$/.exec(line);
|
|
1151
|
+
if (match) { current = { repo: match[1].toLowerCase(), body: "", line: i + 1 }; legs.push(current); }
|
|
1152
|
+
else if (current) current.body += line + "\n";
|
|
1153
|
+
}
|
|
1154
|
+
return legs;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
export function legOwnership(specText: string) {
|
|
1158
|
+
const legs = parseLegs(specText);
|
|
1159
|
+
const findings: PlanCheckFinding[] = [];
|
|
1160
|
+
const lines = specText.split("\n");
|
|
1161
|
+
const mask = fenceMask(lines);
|
|
1162
|
+
let inAcceptance = false;
|
|
1163
|
+
let count = 0;
|
|
1164
|
+
for (const [i, line] of lines.entries()) {
|
|
1165
|
+
if (mask[i]) continue;
|
|
1166
|
+
if (/^##\s+/.test(line)) inAcceptance = /^## Acceptance criteria\s*$/i.test(line);
|
|
1167
|
+
else if (inAcceptance && /^- \[[ xX]\]\s+/.test(line)) count++;
|
|
1168
|
+
}
|
|
1169
|
+
const owners = Array.from({ length: count }, () => [] as string[]);
|
|
1170
|
+
if (legs.length > 1) {
|
|
1171
|
+
const seen = new Set<string>();
|
|
1172
|
+
for (const leg of legs) {
|
|
1173
|
+
const scopes = leg.body.split("\n").filter(l => /^scope:/.test(l));
|
|
1174
|
+
const value = scopes[0]?.slice(6).trim() ?? "";
|
|
1175
|
+
const ids = [...value.matchAll(/AC ([1-9]\d*)/g)].map(m => Number(m[1]));
|
|
1176
|
+
if (seen.has(leg.repo) || scopes.length !== 1 || (value !== "none" && (!/^AC [1-9]\d*(?:, AC [1-9]\d*)*$/.test(value) || new Set(ids).size !== ids.length || ids.some(n => n > count || !Number.isSafeInteger(n))))) {
|
|
1177
|
+
findings.push({ check: "leg-scope", line: leg.line, text: leg.repo, reason: "expected exactly one scope: none or scope: AC n, AC m with unique original AC indices" });
|
|
1178
|
+
} else for (const n of ids) owners[n - 1].push(leg.repo);
|
|
1179
|
+
seen.add(leg.repo);
|
|
1180
|
+
}
|
|
1181
|
+
for (const owner of owners) if (!owner.length) owner.push(legs[0].repo);
|
|
1182
|
+
}
|
|
1183
|
+
return { legs, owners, findings };
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function coordinatedMetadata(lines: string[], legs: ReturnType<typeof parseLegs>, pairs: { ac: number; leg: string }[], add: (i: number, reason: string) => void) {
|
|
1187
|
+
const header = (key: string) => lines.flatMap((l, i) => l.startsWith(key + ":") ? [{ value: l.slice(key.length + 1).trim(), i }] : []);
|
|
1188
|
+
const one = (key: string, pattern: RegExp) => {
|
|
1189
|
+
const rows = header(key);
|
|
1190
|
+
if (rows.length !== 1 || !pattern.test(rows[0]?.value ?? "")) add(rows[0]?.i ?? 0, `invalid or missing ${key} header`);
|
|
1191
|
+
return rows[0]?.value;
|
|
1192
|
+
};
|
|
1193
|
+
const sha = /^[a-f0-9]{7,40}$/;
|
|
1194
|
+
const base = one("base", sha);
|
|
1195
|
+
const iterations = one("iterations", /^(?:0|[1-9]\d*)$/);
|
|
1196
|
+
const cap = one("cap", /^[1-9]\d*$/);
|
|
1197
|
+
if (!Number.isSafeInteger(Number(iterations))) add(0, "iterations must be a safe integer");
|
|
1198
|
+
if (!Number.isSafeInteger(Number(cap))) add(0, "cap must be a safe integer");
|
|
1199
|
+
for (const [i, l] of lines.entries()) {
|
|
1200
|
+
const m = /^(base|seeded) ([^:]+):/.exec(l);
|
|
1201
|
+
if (m && !legs.some(leg => leg.repo === m[2].toLowerCase())) add(i, "foreign member header");
|
|
1202
|
+
}
|
|
1203
|
+
for (const [index, leg] of legs.entries()) {
|
|
1204
|
+
for (const key of ["base", "seeded"]) {
|
|
1205
|
+
const rows = lines.flatMap((l, i) => {
|
|
1206
|
+
const m = /^(base|seeded) ([^:]+):\s*(.*)$/.exec(l);
|
|
1207
|
+
return m && m[1] === key && m[2].toLowerCase() === leg.repo ? [{ value: m[3], i }] : [];
|
|
1208
|
+
});
|
|
1209
|
+
if (rows.length !== 1 || !(key === "base" ? sha : /^(no|yes)$/).test(rows[0]?.value ?? "")) add(rows[0]?.i ?? 0, `invalid or missing ${key} ${leg.repo}`);
|
|
1210
|
+
if (key === "base" && index === 0 && rows[0]?.value !== base) add(rows[0]?.i ?? 0, "home base mismatch");
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
const pending = header("pending");
|
|
1214
|
+
if (pending.length > 1) add(pending[1].i, "duplicate pending tuple");
|
|
1215
|
+
for (const row of pending) {
|
|
1216
|
+
const m = /^(seed|[1-9]\d*) \| (\S+) \| ([a-f0-9]{7,40})$/.exec(row.value);
|
|
1217
|
+
const repo = m?.[2].toLowerCase();
|
|
1218
|
+
if (!m || !legs.slice(1).some(l => l.repo === repo) || (m[1] !== "seed" && pairs[Number(m[1]) - 1]?.leg !== repo)) add(row.i, "invalid pending tuple or target leg");
|
|
1219
|
+
}
|
|
1220
|
+
const mapping = lines.findIndex(l => l === "## Leg mapping");
|
|
1221
|
+
if (lines.filter(l => l === "## Leg mapping").length > 1) add(mapping, "duplicate leg mapping section");
|
|
1222
|
+
let i = mapping + 1;
|
|
1223
|
+
while (lines[i]?.trim() === "") i++;
|
|
1224
|
+
if (mapping < 0 || !/^\|\s*row\s*\|\s*ac\s*\|\s*leg\s*\|$/.test(lines[i] ?? "") || !/^\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|$/.test(lines[i + 1] ?? "")) { add(Math.max(mapping, 0), "missing leg mapping table"); return; }
|
|
1225
|
+
let count = 0;
|
|
1226
|
+
for (i += 2; lines[i]?.startsWith("|"); i++) {
|
|
1227
|
+
const cells = lines[i].split("|").slice(1, -1).map(c => c.trim());
|
|
1228
|
+
const expected = pairs[count];
|
|
1229
|
+
if (!expected || cells.length !== 3 || cells[0] !== String(count + 1) || cells[1] !== String(expected.ac) || cells[2].toLowerCase() !== expected.leg) add(i, "leg mapping must match original AC and membership order exactly once");
|
|
1230
|
+
count++;
|
|
1231
|
+
}
|
|
1232
|
+
if (count !== pairs.length) add(Math.max(mapping, 0), "missing leg mapping entries");
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1137
1235
|
function checkLedger(planText: string, specText: string): PlanCheckFinding[] {
|
|
1138
1236
|
const findings: PlanCheckFinding[] = [];
|
|
1139
1237
|
const dispositions: boolean[] = [];
|
|
@@ -1150,8 +1248,13 @@ function checkLedger(planText: string, specText: string): PlanCheckFinding[] {
|
|
|
1150
1248
|
if (/^- \[[ xX]\]\s+/.test(line)) dispositions.push(false);
|
|
1151
1249
|
else if (dispositions.length && /^\s*(?:deviates|deferred|elsewhere):/.test(line)) dispositions[dispositions.length - 1] = true;
|
|
1152
1250
|
}
|
|
1251
|
+
const ownership = legOwnership(specText);
|
|
1252
|
+
const pairs = ownership.owners.flatMap((owners, i) => owners.map(leg => ({ ac: i + 1, leg })));
|
|
1253
|
+
const coordinated = ownership.legs.length > 1;
|
|
1254
|
+
const expectedDispositions = coordinated ? pairs.map(pair => dispositions[pair.ac - 1]) : dispositions;
|
|
1153
1255
|
const lines = planText.split("\n");
|
|
1154
1256
|
const add = (i: number, reason: string) => findings.push({ check: "ledger", line: i + 1, text: lines[i] ?? "", reason });
|
|
1257
|
+
if (coordinated) coordinatedMetadata(lines, ownership.legs, pairs, add);
|
|
1155
1258
|
const header = lines.findIndex(l => /^\|\s*row\s*\|\s*status\s*\|\s*attempts\s*\|\s*evidence\s*\|$/.test(l));
|
|
1156
1259
|
if (header < 0) {
|
|
1157
1260
|
add(0, "missing ledger table");
|
|
@@ -1164,22 +1267,35 @@ function checkLedger(planText: string, specText: string): PlanCheckFinding[] {
|
|
|
1164
1267
|
const [row, status, attempts, evidence] = cells;
|
|
1165
1268
|
count++;
|
|
1166
1269
|
if (cells.length !== 4 || !lines[i].endsWith("|")) { add(i, "expected four ledger cells"); continue; }
|
|
1167
|
-
if (row !== String(count) || count >
|
|
1270
|
+
if (row !== String(count) || count > expectedDispositions.length) add(i, "expected exactly one row per spec AC in order");
|
|
1168
1271
|
if (!["open", "done", "failed", "cut"].includes(status)) add(i, "invalid ledger status");
|
|
1169
|
-
else if ((status === "cut") !==
|
|
1272
|
+
else if ((status === "cut") !== expectedDispositions[count - 1]) add(i, "status does not match spec disposition");
|
|
1170
1273
|
if (!/^\d+$/.test(attempts) || !Number.isSafeInteger(Number(attempts))) add(i, "attempts must be a non-negative integer");
|
|
1171
1274
|
if (!evidence && status !== "open") add(i, "evidence is required on non-open rows");
|
|
1172
1275
|
if (/<[^>]*>|\b(?:TODO|TBD|probably)\b|etc\./i.test(evidence)) add(i, "placeholder in ledger evidence");
|
|
1173
1276
|
}
|
|
1174
|
-
if (count !==
|
|
1277
|
+
if (count !== expectedDispositions.length || count === 0) add(header, "expected exactly one row per spec AC");
|
|
1175
1278
|
return findings;
|
|
1176
1279
|
}
|
|
1177
1280
|
|
|
1178
1281
|
export function checkPlan(planText: string, specText: string, fs: FsPort): PlanCheckFinding[] {
|
|
1179
1282
|
try {
|
|
1283
|
+
const ownership = legOwnership(specText);
|
|
1284
|
+
if (ownership.findings.length) return ownership.findings;
|
|
1180
1285
|
if (planText.split("\n", 1)[0] === "# Ledger - loop engine") return checkLedger(planText, specText);
|
|
1181
1286
|
const parsed = parsePlan(planText);
|
|
1182
1287
|
const findings: PlanCheckFinding[] = [];
|
|
1288
|
+
if (ownership.legs.length) {
|
|
1289
|
+
for (const task of parsed.tasks) {
|
|
1290
|
+
const first = parsed.lines[task.line];
|
|
1291
|
+
const explicit = /^Leg: (\S+)\s*$/.exec(first ?? "");
|
|
1292
|
+
task.leg = explicit?.[1].toLowerCase() ?? ownership.legs[0].repo;
|
|
1293
|
+
if (!ownership.legs.some(l => l.repo === task.leg)) findings.push({ check: "leg-unknown", line: task.line + 1, text: first, reason: `unknown task leg ${task.leg}` });
|
|
1294
|
+
if (parsed.lines.slice(task.line + (explicit ? 1 : 0), task.bodyEndLine).some(l => /^Leg:/.test(l))) findings.push({ check: "leg-unknown", line: task.line, text: task.text, reason: "Leg: must be the first line after the task heading" });
|
|
1295
|
+
}
|
|
1296
|
+
if (findings.length) return findings;
|
|
1297
|
+
for (const wave of parsed.waves) if (new Set(parsed.tasks.filter(t => t.waveNumber === wave.number).map(t => t.leg)).size > 1) findings.push({ check: "wave-leg", line: wave.line, text: wave.text, reason: "wave mixes task legs" });
|
|
1298
|
+
}
|
|
1183
1299
|
|
|
1184
1300
|
if (parsed.waves.length === 0 && parsed.tasks.length === 0) {
|
|
1185
1301
|
if (parsed.waves.length === 0) {
|
|
@@ -1206,7 +1322,20 @@ export function checkPlan(planText: string, specText: string, fs: FsPort): PlanC
|
|
|
1206
1322
|
findings.push(...checkAnchorResolution(parsed, specLines));
|
|
1207
1323
|
findings.push(...checkPathsExist(parsed, fs));
|
|
1208
1324
|
findings.push(...checkTestsBlock(parsed, fs));
|
|
1209
|
-
|
|
1325
|
+
if (ownership.legs.length) {
|
|
1326
|
+
const declarations = parsed.lines.slice(0, parsed.header.separatorLine ?? 0).flatMap((line, i) => {
|
|
1327
|
+
const m = /^Baseline (\S+): ([a-f0-9]{7,40})$/.exec(line);
|
|
1328
|
+
return m ? [{ repo: m[1].toLowerCase(), sha: m[2], line: i + 1 }] : [];
|
|
1329
|
+
});
|
|
1330
|
+
for (const leg of ownership.legs) {
|
|
1331
|
+
const tasks = parsed.tasks.filter(t => t.leg === leg.repo);
|
|
1332
|
+
findings.push(...checkBaselineLines({ ...parsed, tasks }, fs));
|
|
1333
|
+
const declarationsForLeg = declarations.filter(d => d.repo === leg.repo);
|
|
1334
|
+
if (declarationsForLeg.length !== 1) findings.push({ check: "baseline-line", line: 0, text: leg.repo, reason: "expected one Baseline declaration per leg" });
|
|
1335
|
+
for (const task of tasks) for (const bullet of task.testsBaseline) if (bullet.value.split(" ")[0] !== declarationsForLeg[0]?.sha) findings.push({ check: "baseline-line", line: bullet.line, text: bullet.text, reason: `Task ${task.number}: baseline differs from ${leg.repo} declaration` });
|
|
1336
|
+
}
|
|
1337
|
+
for (const declaration of declarations) if (!ownership.legs.some(l => l.repo === declaration.repo)) findings.push({ check: "baseline-line", line: declaration.line, text: declaration.repo, reason: "unknown baseline leg" });
|
|
1338
|
+
} else findings.push(...checkBaselineLines(parsed, fs));
|
|
1210
1339
|
findings.push(...parsed.openQuestions.findings);
|
|
1211
1340
|
const requiredLiterals = computeRequiredLiteralsPerTask(parsed, specLines);
|
|
1212
1341
|
findings.push(...checkPlaceholderScan(parsed, requiredLiterals));
|
|
@@ -228,3 +228,14 @@ test("derive engine uses the latest event, defaults legacy runs, and counts loop
|
|
|
228
228
|
assert.equal(derive(back, now).iterations, 3);
|
|
229
229
|
assert.ok(capEvents(rec.events, 0).events.some((e) => e.kind === "engine"));
|
|
230
230
|
});
|
|
231
|
+
|
|
232
|
+
test("legs round-trip and malformed stored legs are rejected", () => {
|
|
233
|
+
const rec = newRecord({ spec: "x", session: "s", now: "now", runId: "r" });
|
|
234
|
+
for (const legs of [[{ repo: "one", sha: "abc" }, { repo: "two", pr: "https://example.test/pull/1" }]]) {
|
|
235
|
+
(rec as any).legs = legs;
|
|
236
|
+
assert.deepEqual((parseRecord(serializeRecord(rec)) as any).legs, legs);
|
|
237
|
+
}
|
|
238
|
+
for (const legs of [null, {}, [{ repo: "" , sha: "abc" }], [{ repo: "one" }], [{ repo: "one", sha: "" }]]) {
|
|
239
|
+
assert.equal(parseRecord(serializeRecord({ ...rec, legs } as any)), undefined);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
@@ -97,7 +97,13 @@ export interface Derived {
|
|
|
97
97
|
spec_edits_after_ship: number; diff?: DiffSummary; modified_files?: string[]; council?: CouncilBlock; spec_writes: Accumulators["spec_writes"]; events_dropped: number;
|
|
98
98
|
}
|
|
99
99
|
export type RecordStatus = "in_progress" | "shipped" | "abandoned";
|
|
100
|
+
export type TelemetryLeg = { repo: string; sha: string; pr?: never } | { repo: string; pr: string; sha?: never };
|
|
101
|
+
export const validLegs = (legs: unknown): legs is TelemetryLeg[] => Array.isArray(legs) && legs.every((leg) =>
|
|
102
|
+
leg && typeof leg.repo === "string" && leg.repo.trim().length > 0 &&
|
|
103
|
+
((typeof leg.sha === "string" && leg.sha.trim().length > 0 && leg.pr === undefined) ||
|
|
104
|
+
(typeof leg.pr === "string" && /^https?:\/\/\S+$/.test(leg.pr) && leg.sha === undefined)));
|
|
100
105
|
export interface TelemetryRecord {
|
|
106
|
+
legs?: TelemetryLeg[];
|
|
101
107
|
schema: 1; spec: string; run_id: string; branch?: string; status: RecordStatus; created_at: string; approved_at?: string; shipped_at?: string; abandoned_at?: string;
|
|
102
108
|
supersedes?: string[]; fixes?: string[]; versions?: Record<string, string>; author?: { name?: string; email?: string }; agent_overrides?: Record<string, unknown>;
|
|
103
109
|
sessions: string[]; derived: Derived; accumulators: Record<string, Accumulators>; events: TelemetryEvent[];
|
|
@@ -179,6 +185,7 @@ export function parseRecord(text: string): TelemetryRecord | undefined {
|
|
|
179
185
|
if (!doc || typeof doc !== "object") return undefined;
|
|
180
186
|
const d = doc as Partial<TelemetryRecord>;
|
|
181
187
|
if (d.schema !== 1 || typeof d.spec !== "string" || typeof d.run_id !== "string") return undefined;
|
|
188
|
+
if (d.legs !== undefined && !validLegs(d.legs)) return undefined;
|
|
182
189
|
const accumulators = Object.fromEntries(Object.entries(d.accumulators ?? {}).flatMap(([session, block]) =>
|
|
183
190
|
block && typeof block === "object" && !Array.isArray(block)
|
|
184
191
|
? [[session, { ...emptyAccumulators(), ...block }]]
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { after, test } from "node:test";
|
|
3
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { after, mock, test } from "node:test";
|
|
3
|
+
import childProcess, { execFileSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
4
6
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, realpathSync } from "node:fs";
|
|
5
7
|
import { createHash } from "node:crypto";
|
|
6
8
|
import { tmpdir } from "node:os";
|
|
@@ -1546,7 +1548,7 @@ test("shape guard: a one-task tasks wave and a chain step are not the lone shape
|
|
|
1546
1548
|
assert.equal(await firstCallResult(h, "s2", { chain: [{ agent: "implementer", task: "fix" }] }), undefined);
|
|
1547
1549
|
});
|
|
1548
1550
|
|
|
1549
|
-
const waveResult = (id: string, results: { agent: string; exitCode: number }[], isError = false) => ({
|
|
1551
|
+
const waveResult = (id: string, results: { agent: string; exitCode: number; interrupted?: boolean }[], isError = false) => ({
|
|
1550
1552
|
toolName: "subagent",
|
|
1551
1553
|
toolCallId: id,
|
|
1552
1554
|
isError,
|
|
@@ -1555,6 +1557,95 @@ const waveResult = (id: string, results: { agent: string; exitCode: number }[],
|
|
|
1555
1557
|
});
|
|
1556
1558
|
const okWave = (id: string) => waveResult(id, [{ agent: "implementer", exitCode: 0 }]);
|
|
1557
1559
|
|
|
1560
|
+
for (const delivery of ["live", "session_start", "session_switch", "session_fork", "session_tree"]) {
|
|
1561
|
+
test(`closure completion excludes interrupted children per result: ${delivery}`, async () => {
|
|
1562
|
+
const cwd = tempCwd();
|
|
1563
|
+
const reviewer = { agent: "conformance-reviewer", exitCode: 0 };
|
|
1564
|
+
for (const row of [
|
|
1565
|
+
{ name: "interrupted single/resumed child", results: [{ ...reviewer, interrupted: true }], qualifies: false },
|
|
1566
|
+
{ name: "legacy success", results: [reviewer], qualifies: true },
|
|
1567
|
+
{ name: "explicit uninterrupted", results: [{ ...reviewer, interrupted: false }], qualifies: true },
|
|
1568
|
+
{ name: "nonzero exit", results: [{ ...reviewer, exitCode: 1 }], qualifies: false },
|
|
1569
|
+
{ name: "exact agent match", results: [{ agent: "conformance-reviewer-extra", exitCode: 0 }], qualifies: false },
|
|
1570
|
+
{ name: "empty async/management", results: [], qualifies: false },
|
|
1571
|
+
{ name: "mixed parallel", results: [{ ...reviewer, interrupted: true }, reviewer], qualifies: true },
|
|
1572
|
+
{ name: "earlier success in errored chain", results: [reviewer, { agent: "implementer", exitCode: 1 }], isError: true, qualifies: true },
|
|
1573
|
+
]) {
|
|
1574
|
+
const result = waveResult("review", row.results, row.isError);
|
|
1575
|
+
const h = harness({ cwd, branch: verifyBranch() });
|
|
1576
|
+
await h.emit("session_start");
|
|
1577
|
+
if (delivery === "live") await h.emitEvent("tool_result", result);
|
|
1578
|
+
else {
|
|
1579
|
+
h.setBranch(verifyBranch([{ type: "message", message: { role: "toolResult", ...result } }]));
|
|
1580
|
+
await h.emit(delivery);
|
|
1581
|
+
}
|
|
1582
|
+
assert.equal((await completePhase(h, "verify")).details.error,
|
|
1583
|
+
row.qualifies ? undefined : "no conformance-reviewer dispatch observed", row.name);
|
|
1584
|
+
}
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
test(`cadence completion excludes interrupted children and preserves ties: ${delivery}`, async () => {
|
|
1588
|
+
const cwd = tempCwd();
|
|
1589
|
+
const implementer = { agent: "implementer", exitCode: 0 };
|
|
1590
|
+
for (const row of [
|
|
1591
|
+
{ name: "interrupted implementer", results: [{ ...implementer, interrupted: true }], prior: false, warns: false },
|
|
1592
|
+
{ name: "interrupted SR", results: [{ agent: "spec-reviewer", exitCode: 0, interrupted: true }], prior: true, warns: true },
|
|
1593
|
+
{ name: "interrupted CR", results: [{ agent: "code-reviewer", exitCode: 0, interrupted: true }], prior: true, warns: true },
|
|
1594
|
+
{ name: "legacy implementer", results: [implementer], prior: false, warns: true },
|
|
1595
|
+
{ name: "explicit uninterrupted SR", results: [{ agent: "spec-reviewer", exitCode: 0, interrupted: false }], prior: true, warns: false },
|
|
1596
|
+
{ name: "failed reviewer", results: [{ agent: "code-reviewer", exitCode: 1 }], prior: true, warns: true },
|
|
1597
|
+
{ name: "wrong agent", results: [{ agent: "spec-reviewer-extra", exitCode: 0 }], prior: true, warns: true },
|
|
1598
|
+
{ name: "empty async/management", results: [], prior: true, warns: true },
|
|
1599
|
+
{ name: "mixed parallel tie", results: [implementer, { agent: "spec-reviewer", exitCode: 0 }, { agent: "code-reviewer", exitCode: 0, interrupted: true }], prior: false, warns: false },
|
|
1600
|
+
{ name: "earlier review in errored chain", results: [{ agent: "code-reviewer", exitCode: 0 }, { ...implementer, interrupted: true }], prior: true, isError: true, warns: false },
|
|
1601
|
+
]) {
|
|
1602
|
+
const prior = row.prior ? [subagentResult(["implementer"])] : [];
|
|
1603
|
+
const result = waveResult("work", row.results, row.isError);
|
|
1604
|
+
const h = harness({ cwd, branch: implementBranch(prior) });
|
|
1605
|
+
await h.emit("session_start");
|
|
1606
|
+
if (delivery === "live") await h.emitEvent("tool_result", result);
|
|
1607
|
+
else {
|
|
1608
|
+
h.setBranch(implementBranch([...prior, { type: "message", message: { role: "toolResult", ...result } }]));
|
|
1609
|
+
await h.emit(delivery);
|
|
1610
|
+
}
|
|
1611
|
+
await h.emitEvent("tool_call", commitCall("commit"));
|
|
1612
|
+
const [warning] = await h.emitEvent("tool_result", commitResult("commit")) as ({ content: { text: string }[] } | undefined)[];
|
|
1613
|
+
assert.equal(warning !== undefined, row.warns, row.name);
|
|
1614
|
+
if (warning) assert.match(warning.content[0].text, /no spec-reviewer or code-reviewer observed/, row.name);
|
|
1615
|
+
}
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
test("interrupted implementer waves still spend rounds and granted credits and nudge open tasks live and on replay", async () => {
|
|
1620
|
+
const cwd = tempCwd({ piGauntlet: { closureReview: { maxFixRounds: 1 } } });
|
|
1621
|
+
const interrupted = waveResult("paused", [{ agent: "implementer", exitCode: 0, interrupted: true }]);
|
|
1622
|
+
for (const delivery of ["live", "session_start"]) {
|
|
1623
|
+
const trail = [subagentResult(["conformance-reviewer"]), taskSnapshot([{ name: "repair", status: "in_progress" }])];
|
|
1624
|
+
const h = harness({ cwd, branch: verifyBranch(trail) });
|
|
1625
|
+
await h.emit("session_start");
|
|
1626
|
+
if (delivery === "live") {
|
|
1627
|
+
const [nudge] = await h.emitEvent("tool_result", interrupted) as { content: { text: string }[] }[];
|
|
1628
|
+
assert.match(nudge.content[0].text, /0: repair \(in_progress\)/);
|
|
1629
|
+
} else {
|
|
1630
|
+
h.setBranch(verifyBranch([...trail, { type: "message", message: { role: "toolResult", ...interrupted } }]));
|
|
1631
|
+
await h.emit(delivery);
|
|
1632
|
+
}
|
|
1633
|
+
assert.equal((await firstCallResult(h, "blocked", implementerWave()))?.block, true);
|
|
1634
|
+
assert.equal((await grant(h, "grant", { rounds: 1, reason: "one more repair" })).details.error, undefined);
|
|
1635
|
+
assert.equal(await firstCallResult(h, "funded", implementerWave()), undefined);
|
|
1636
|
+
if (delivery === "live") await h.emitEvent("tool_result", interrupted);
|
|
1637
|
+
else {
|
|
1638
|
+
h.setBranch(verifyBranch([...trail,
|
|
1639
|
+
{ type: "message", message: { role: "toolResult", ...interrupted } }, grantResult(1),
|
|
1640
|
+
{ type: "message", message: { role: "toolResult", ...interrupted } },
|
|
1641
|
+
]));
|
|
1642
|
+
await h.emit(delivery);
|
|
1643
|
+
}
|
|
1644
|
+
assert.equal((await firstCallResult(h, "spent", implementerWave()))?.block, true);
|
|
1645
|
+
assert.equal((await grant(h, "again", { rounds: 1, reason: "another repair" })).details.error, undefined);
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1558
1649
|
const guardedHarness = (settings: unknown, extra: unknown[] = []) =>
|
|
1559
1650
|
harness({ cwd: tempCwd(settings), branch: verifyBranch([subagentResult(["conformance-reviewer"]), ...extra]) });
|
|
1560
1651
|
|
|
@@ -2451,3 +2542,110 @@ for (const event of ["session_start", "session_switch", "session_fork", "session
|
|
|
2451
2542
|
});
|
|
2452
2543
|
}
|
|
2453
2544
|
}
|
|
2545
|
+
|
|
2546
|
+
for (const pattern of [".worktrees/<name>", "../companion-<name>"]) test(`plan_check resolves clone URL companions with ${pattern}`, async (t) => {
|
|
2547
|
+
const primary = gitPrimary().primary;
|
|
2548
|
+
const companion = gitPrimary().primary;
|
|
2549
|
+
const unselected = gitPrimary().primary;
|
|
2550
|
+
const name = companion.split("/").at(-1)!;
|
|
2551
|
+
const repo = `team/${name}`;
|
|
2552
|
+
const cloneUrl = `git@example.test:${repo}.git`;
|
|
2553
|
+
execFileSync("git", ["config", "remote.origin.url", `https://example.test/${repo}.git`], { cwd: companion });
|
|
2554
|
+
const branch = "feature/legs";
|
|
2555
|
+
const companionRoot = join(companion, pattern.replace("<name>", "feature-legs"));
|
|
2556
|
+
execFileSync("git", ["worktree", "add", "-b", branch, companionRoot], { cwd: companion, stdio: "pipe" });
|
|
2557
|
+
if (pattern.startsWith("..")) tempDirs.push(companionRoot);
|
|
2558
|
+
mkdirSync(join(primary, ".pi"), { recursive: true });
|
|
2559
|
+
writeFileSync(join(primary, ".pi/gauntlet-overrides.md"), `## shape-ticket\n- sibling: ${cloneUrl}\n- sibling: git@example.test:other/${unselected.split("/").at(-1)!}.git\n`);
|
|
2560
|
+
const { planPath } = writePlanFixture(primary, {
|
|
2561
|
+
mutateSpec: s => s + `\n## Legs\n### Leg: team/repo-a\nscope: none\nbranch: ${branch}\n### Leg: ${repo}\nscope: none\nbranch: ${branch}\ncontract: home: ${pattern}\n`,
|
|
2562
|
+
mutatePlan: s => s.replace("---", `Baseline team/repo-a: abc1234\nBaseline ${repo}: def5678\n---`).replace("### Task 2: Implement naming", `## Wave 2 - Companion\n\n### Task 2: Implement naming\nLeg: ${repo.toUpperCase()}`).replace("abc1234 skipped - lib/task2", "def5678 skipped - lib/task2"),
|
|
2563
|
+
});
|
|
2564
|
+
rmSync(join(primary, "file-b.ts"));
|
|
2565
|
+
writeFileSync(join(companionRoot, "file-b.ts"), "companion only\n");
|
|
2566
|
+
const h = harness({ cwd: primary });
|
|
2567
|
+
await h.emit("session_start");
|
|
2568
|
+
const tool = h.tools.find(t => t.name === "plan_check")!;
|
|
2569
|
+
const probes: string[] = [];
|
|
2570
|
+
const spies: { mock: { restore(): void } }[] = [];
|
|
2571
|
+
if (unselected) {
|
|
2572
|
+
for (const method of ["existsSync", "realpathSync", "statSync", "readFileSync", "readdirSync"] as const) {
|
|
2573
|
+
const original = fs[method];
|
|
2574
|
+
spies.push(mock.method(fs, method, (...args: any[]) => {
|
|
2575
|
+
if (String(args[0]).startsWith(unselected)) probes.push(`${method}: ${args[0]}`);
|
|
2576
|
+
return (original as Function)(...args);
|
|
2577
|
+
}));
|
|
2578
|
+
}
|
|
2579
|
+
const original = childProcess.execFileSync;
|
|
2580
|
+
spies.push(mock.method(childProcess, "execFileSync", (...args: any[]) => {
|
|
2581
|
+
if (String(args[2]?.cwd).startsWith(unselected)) probes.push(`git: ${args[2].cwd}`);
|
|
2582
|
+
return (original as Function)(...args);
|
|
2583
|
+
}));
|
|
2584
|
+
syncBuiltinESMExports();
|
|
2585
|
+
}
|
|
2586
|
+
try {
|
|
2587
|
+
const result = await tool.execute("legs", { planPath }, undefined, undefined, h.ctx) as { details: { status: string; findings?: unknown } };
|
|
2588
|
+
assert.equal(result.details.status, "pass", JSON.stringify(result.details.findings));
|
|
2589
|
+
assert.deepEqual(probes, [], "unselected sibling must receive no filesystem or Git probes");
|
|
2590
|
+
writeFileSync(join(primary, ".pi/gauntlet-overrides.md"), `## shape-ticket\n- sibling: https://user@example.test:7999/${repo}.git/\n`);
|
|
2591
|
+
const https = await tool.execute("https", { planPath }, undefined, undefined, h.ctx) as { details: { status: string } };
|
|
2592
|
+
assert.equal(https.details.status, "pass");
|
|
2593
|
+
writeFileSync(join(primary, ".pi/gauntlet-overrides.md"), "## shape-ticket\n- sibling: ../viewer\n");
|
|
2594
|
+
const invalid = await tool.execute("invalid", { planPath }, undefined, undefined, h.ctx) as { details: { status: string; findings: { check: string; reason: string }[] } };
|
|
2595
|
+
assert.equal(invalid.details.status, "fail");
|
|
2596
|
+
assert.ok(invalid.details.findings.some(f => f.check === "input" && f.reason.includes("- sibling: ../viewer") && f.reason.includes("invalid configuration")));
|
|
2597
|
+
const plain = realpathSync(tempCwd());
|
|
2598
|
+
const missingName = `${name}-missing`;
|
|
2599
|
+
const cases = [
|
|
2600
|
+
{ name: "PATH not found", value: `https://example.test/team/${missingName}.git`, reason: "PATH not found" },
|
|
2601
|
+
{ name: "not a checkout", value: `https://example.test/team/${plain.split("/").at(-1)}.git`, reason: "not a checkout" },
|
|
2602
|
+
{ name: "no origin", value: cloneUrl, origin: null, reason: "no origin" },
|
|
2603
|
+
{ name: "unparseable stored origin", value: cloneUrl, origin: "local-origin", reason: "unparseable origin" },
|
|
2604
|
+
{ name: "wrong repo", value: cloneUrl, origin: `https://example.test/other/${name}.git`, reason: "wrong repo" },
|
|
2605
|
+
{ name: "home primary", value: `https://example.test/team/${primary.split("/").at(-1)}.git`, reason: "PATH equals home primary" },
|
|
2606
|
+
...["viewer", "/srv/git/viewer.git", "file:///srv/git/viewer.git"].map(value => ({ name: `invalid ${value}`, value, reason: "invalid configuration" })),
|
|
2607
|
+
{ name: "stored trailing slash", value: cloneUrl, origin: `https://example.test/${repo}.git/` },
|
|
2608
|
+
{ name: "stored origin with insteadOf", value: cloneUrl, origin: `https://example.test/${repo}.git`, rewrite: true },
|
|
2609
|
+
];
|
|
2610
|
+
for (const row of cases) await t.test(row.name, async () => {
|
|
2611
|
+
const stored = "origin" in row ? row.origin : `https://example.test/${repo}.git`;
|
|
2612
|
+
if (stored === null) execFileSync("git", ["config", "--unset", "remote.origin.url"], { cwd: companion });
|
|
2613
|
+
else execFileSync("git", ["config", "remote.origin.url", stored!], { cwd: companion });
|
|
2614
|
+
const rewriteKey = "url.https://rewritten.test/other/.insteadOf";
|
|
2615
|
+
if ("rewrite" in row) execFileSync("git", ["config", rewriteKey, "https://example.test/team/"], { cwd: companion });
|
|
2616
|
+
const line = `- sibling: ${row.value}`;
|
|
2617
|
+
writeFileSync(join(primary, ".pi/gauntlet-overrides.md"), `## shape-ticket\n${line}\n`);
|
|
2618
|
+
try {
|
|
2619
|
+
const result = await tool.execute(row.name, { planPath }, undefined, undefined, h.ctx) as { details: { status: string; findings?: { check: string; reason: string }[] } };
|
|
2620
|
+
assert.equal(result.details.status, row.reason ? "fail" : "pass", JSON.stringify(result.details.findings));
|
|
2621
|
+
if (row.reason) assert.ok(result.details.findings?.some(f => f.check === "input" && f.reason.includes(line) && f.reason.includes(row.reason!)), JSON.stringify(result.details.findings));
|
|
2622
|
+
} finally {
|
|
2623
|
+
if ("rewrite" in row) execFileSync("git", ["config", "--unset", rewriteKey], { cwd: companion });
|
|
2624
|
+
}
|
|
2625
|
+
});
|
|
2626
|
+
execFileSync("git", ["config", "remote.origin.url", `https://example.test/${repo}.git`], { cwd: companion });
|
|
2627
|
+
await t.test("identity precedes shared NAME without probing the other declaration", async () => {
|
|
2628
|
+
writeFileSync(join(primary, ".pi/gauntlet-overrides.md"), `## shape-ticket\n- sibling: git@example.test:other/${name}.git\n- sibling: ${cloneUrl}\n`);
|
|
2629
|
+
const result = await tool.execute("identity", { planPath }, undefined, undefined, h.ctx) as { details: { status: string; findings?: unknown } };
|
|
2630
|
+
assert.equal(result.details.status, "pass", JSON.stringify(result.details.findings));
|
|
2631
|
+
});
|
|
2632
|
+
writeFileSync(join(primary, ".pi/gauntlet-overrides.md"), `## shape-ticket\n- sibling: ${cloneUrl}\n`);
|
|
2633
|
+
} finally {
|
|
2634
|
+
for (const spy of spies) spy.mock.restore();
|
|
2635
|
+
syncBuiltinESMExports();
|
|
2636
|
+
}
|
|
2637
|
+
const originalPlan = readFileSync(planPath, "utf8");
|
|
2638
|
+
const foreignSpec = join(companionRoot, "foreign-spec.md");
|
|
2639
|
+
writeFileSync(foreignSpec, readFileSync(join(primary, "doc/specs/spec.md")));
|
|
2640
|
+
writeFileSync(planPath, originalPlan.replace("doc/specs/spec.md", foreignSpec));
|
|
2641
|
+
const foreign = await tool.execute("foreign-spec", { planPath }, undefined, undefined, h.ctx) as { details: { status: string; findings: { check: string }[] } };
|
|
2642
|
+
assert.equal(foreign.details.status, "fail");
|
|
2643
|
+
assert.ok(foreign.details.findings.some(f => f.check === "input"));
|
|
2644
|
+
writeFileSync(planPath, originalPlan);
|
|
2645
|
+
rmSync(join(companionRoot, "file-b.ts"));
|
|
2646
|
+
const failure = await tool.execute("legs-missing", { planPath }, undefined, undefined, h.ctx) as { details: { status: string; findings: { check: string }[] } };
|
|
2647
|
+
assert.equal(failure.details.status, "fail");
|
|
2648
|
+
execFileSync("git", ["worktree", "remove", "--force", companionRoot], { cwd: companion, stdio: "pipe" });
|
|
2649
|
+
const missing = await tool.execute("legs-missing-root", { planPath }, undefined, undefined, h.ctx) as { details: { findings: { check: string }[] } };
|
|
2650
|
+
assert.ok(missing.details.findings.some(f => f.check === "input"));
|
|
2651
|
+
});
|