pi-gauntlet 7.40.1 → 7.41.1

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 (40) hide show
  1. package/CHANGELOG.md +18 -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 +1 -1
  20. package/skills/brainstorming/gatherer.md +14 -1
  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 +1 -1
  24. package/skills/brainstorming/reference/spec-finalization.md +14 -0
  25. package/skills/brainstorming/reference/spec-pr.md +42 -5
  26. package/skills/brainstorming/reference/ticket-acceptance.md +2 -2
  27. package/skills/finishing-a-development-branch/SKILL.md +2 -2
  28. package/skills/finishing-a-development-branch/reference/legs.md +76 -0
  29. package/skills/finishing-a-development-branch/reference/spec-pr.md +12 -4
  30. package/skills/gauntlet-handoff/SKILL.md +2 -2
  31. package/skills/gauntlet-resume/SKILL.md +3 -2
  32. package/skills/gauntlet-resume/reference/brief-contract.md +37 -3
  33. package/skills/gauntlet-resume/reference/reconstruction.md +4 -0
  34. package/skills/gauntlet-resume/reference/seed.md +2 -0
  35. package/skills/loop-implementation/SKILL.md +89 -2
  36. package/skills/subagent-driven-development/SKILL.md +10 -0
  37. package/skills/using-git-worktrees/SKILL.md +58 -21
  38. package/skills/using-git-worktrees/reference/pr-head.md +25 -1
  39. package/skills/writing-plans/SKILL.md +8 -0
  40. 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 && /^\*\*Files:\*\*/.test(line)) {
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 cached = globCache.get(pattern);
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(pattern, result);
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 > dispositions.length) add(i, "expected exactly one row per spec AC in order");
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") !== dispositions[count - 1]) add(i, "status does not match spec disposition");
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 !== dispositions.length || count === 0) add(header, "expected exactly one row per spec AC");
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
- findings.push(...checkBaselineLines(parsed, fs));
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
+ });