pi-gauntlet 8.4.6 → 8.4.7

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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## v8.4.7 - 2026-11-11
4
+
5
+ ### Fixed
6
+ - Plan tasks can declare `- Delete:` entries as literal write ownership without an existence requirement; unrecognized Files lines produce `files-block` findings, and rebuild restores Delete paths from the wave base. (#140)
7
+
3
8
  ## v8.4.6 - 2026-11-08
4
9
 
5
10
  - Require pi-cohort >=7.7.0 for model-only `subagent` registrations; remove the now-unreachable scripted-dispatch guard and nested-result exclusion. The codemode guide documents direct-only dispatch under both `on` and `only` and clarifies that skill examples illustrate direct tool calls, not scripts to execute. (#135)
package/README.md CHANGED
@@ -142,7 +142,7 @@ Run by a human against pi-cohort >= 7.1.0, before a pi-gauntlet release claims t
142
142
  ## Requirements
143
143
 
144
144
  - [pi-coding-agent](https://github.com/earendil-works/pi) >= 1.5.0 - supports model-only tracker exposure and sequential tracker execution, so updates persist as transcript tool results rather than nested calls.
145
- - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.7.0 - required peer package, including binary-safe worktree patches; it also lets a persona with `subagent` in `tools:` start with inherited extensions ([pi-cohort #26](https://github.com/jjuraszek/pi-cohort/issues/26)), which multi-PR `gatekeep-pr` needs. Skills that dispatch agents (`requesting-code-review`, `subagent-driven-development`, `dispatching-parallel-agents`, `writing-plans`, `forge-skill`, `shape-ticket`, `roasting-the-spec`) call `subagent({})`, which pi-cohort provides. pi-gauntlet does not vendor the dispatch tool; without pi-cohort those skills have nothing to call. Gauntlet execution dispatches explicitly use `async: false`; do not enable pi-cohort's incompatible `forceTopLevelAsync` setting. See pi-cohort's [dispatch configuration](https://github.com/jjuraszek/pi-cohort/blob/main/doc/configuration.md) for that setting's owner and semantics. A wave of N >= 1 tasks dispatches its implementers and first spec reviews as one chain call. `subagent-driven-development` stops before dispatch on unsupported installs; there is no older-runtime fallback.
145
+ - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.7.3 - required peer package, including binary-safe worktree patches; it also lets a persona with `subagent` in `tools:` start with inherited extensions ([pi-cohort #26](https://github.com/jjuraszek/pi-cohort/issues/26)), which multi-PR `gatekeep-pr` needs. Skills that dispatch agents (`requesting-code-review`, `subagent-driven-development`, `dispatching-parallel-agents`, `writing-plans`, `forge-skill`, `shape-ticket`, `roasting-the-spec`) call `subagent({})`, which pi-cohort provides. pi-gauntlet does not vendor the dispatch tool; without pi-cohort those skills have nothing to call. Gauntlet execution dispatches explicitly use `async: false`; do not enable pi-cohort's incompatible `forceTopLevelAsync` setting. See pi-cohort's [dispatch configuration](https://github.com/jjuraszek/pi-cohort/blob/main/doc/configuration.md) for that setting's owner and semantics. A wave of N >= 1 tasks dispatches its implementers and first spec reviews as one chain call. `subagent-driven-development` stops before dispatch on unsupported installs; there is no older-runtime fallback.
146
146
 
147
147
  Both packages must be listed in your `.pi/settings.json#packages` array (pi adds them automatically when you `pi install`). pi-gauntlet and pi-cohort are versioned independently but release together whenever dispatch semantics change - pin compatible versions of both. At each pi-gauntlet release, set the pi-cohort peer minimum and README floor together to the newest released pi-cohort version; `/skill:release` checks the registry before proceeding.
148
148
 
@@ -1,4 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
2
3
  import test from "node:test";
3
4
  import { checkPlan, renderManifest, sha256, type FsPort, type PlanCheckFinding } from "./plan-check.ts";
4
5
 
@@ -177,6 +178,102 @@ test("valid fixture: checkPlan returns no findings (also covers the placeholder
177
178
  assert.deepEqual(findings, []);
178
179
  });
179
180
 
181
+ test("unknown Files kind fails and later Modify is still checked", () => {
182
+ const missing = "extensions/lib/fixture-missing.ts";
183
+ const malformed = [
184
+ "- Modfy: old.ts",
185
+ "- Rename: old.ts",
186
+ "- Mod-ify: old.ts",
187
+ ...["Create", "Modify", "Test", "Delete"].flatMap(kind => [`- ${kind}: `, `- ${kind}: `]),
188
+ ];
189
+ for (const entry of malformed) {
190
+ const plan = VALID_PLAN.replace(
191
+ "- Modify: extensions/lib/fixture-shared.ts",
192
+ `${entry}\n\n- Modify: ${missing}`,
193
+ );
194
+ const result = checkPlan(plan, SPEC_TEXT, {
195
+ exists: path => path !== missing,
196
+ glob: () => [],
197
+ });
198
+ assert.deepEqual(findingsFor(result.findings, "files-block"), [{
199
+ check: "files-block",
200
+ line: plan.split("\n").indexOf(entry) + 1,
201
+ text: entry,
202
+ reason: `Task 1: Files line "${entry}" is not a Create, Modify, Test or Delete entry with a path`,
203
+ }], entry);
204
+ assert.deepEqual(findingsFor(result.findings, "paths-exist"), [{
205
+ check: "paths-exist",
206
+ line: lineOf(plan, `- Modify: ${missing}`),
207
+ text: `- Modify: ${missing}`,
208
+ reason: `Modify: path "${missing}" does not exist`,
209
+ }], entry);
210
+ assert.deepEqual(result.parsed.tasks[0].files.map(file => file.path), [
211
+ "extensions/lib/fixture-task1.ts", missing, "extensions/lib/fixture-task1.test.ts",
212
+ ], entry);
213
+ assert.deepEqual(findingsFor(result.findings, "tests-block"), [], entry);
214
+ assert.equal(result.findings.length, 2, entry);
215
+ }
216
+ });
217
+
218
+ test("Delete glob fails", () => {
219
+ for (const path of ["src/*.ts", "src/file?.ts", "src/{old,new}.ts", "app/[id]/page.tsx"]) {
220
+ for (const suffix of ["", ":1-2"]) {
221
+ const entry = `- Delete: \`${path}${suffix}\``;
222
+ const plan = VALID_PLAN.replace(
223
+ "- Test: extensions/lib/fixture-task1.test.ts",
224
+ `${entry}\n- Test: extensions/lib/fixture-task1.test.ts`,
225
+ );
226
+ assert.deepEqual(checkPlan(plan, SPEC_TEXT, alwaysTruePort()).findings, [{
227
+ check: "files-block",
228
+ line: lineOf(plan, entry),
229
+ text: entry,
230
+ reason: `Task 1: Delete: path "${path}" is a glob; Delete takes a literal path`,
231
+ }]);
232
+ }
233
+ }
234
+ });
235
+
236
+ test("removal-only plan declares one Delete owner and passes checkPlan", () => {
237
+ const spec = `# Retire unused adapter
238
+
239
+ ## Design
240
+ Remove \`src/legacy.ts\`; no other repository file changes.
241
+ Keep the supported public entrypoint working.
242
+
243
+ ## Acceptance criteria
244
+ - [ ] The unused adapter is absent.
245
+ - [ ] The supported public entrypoint still works.
246
+ `;
247
+ const candidate = JSON.parse(readFileSync(new URL("../../eval/writing-plans/results/removal-lists-delete/candidate.json", import.meta.url), "utf8"));
248
+ const outputs = Object.entries(candidate.outputs) as [string, { status: string; text: string }][];
249
+ assert.equal(outputs.length, 2);
250
+ for (const [model, output] of outputs) {
251
+ assert.equal(output.status, "ok", model);
252
+ const start = output.text.indexOf("```markdown\n");
253
+ const plan = start === -1 ? output.text : output.text.slice(start + "```markdown\n".length, output.text.lastIndexOf("```"));
254
+ const result = checkPlan(plan, spec, alwaysTruePort());
255
+ assert.deepEqual(result.findings, [], model);
256
+ assert.deepEqual(result.parsed.tasks.flatMap(task => task.files).map(({ kind, path }) => ({ kind, path })), [
257
+ { kind: "delete", path: "src/legacy.ts" },
258
+ { kind: "test", path: "tests/retirement.test.mjs" },
259
+ ], model);
260
+ assert.deepEqual(Object.values<{ after: string }>(candidate.judge[model].facts).map(fact => fact.after), Array(4).fill("holds"), model);
261
+ }
262
+ });
263
+
264
+ test("Delete entry passes before and after removal", () => {
265
+ const path = "extensions/lib/fixture-removed.ts";
266
+ const plan = VALID_PLAN.replace("- Test: extensions/lib/fixture-task1.test.ts", `- Delete: ${path}\n- Test: extensions/lib/fixture-task1.test.ts`);
267
+ for (const exists of [true, false]) {
268
+ const result = checkPlan(plan, SPEC_TEXT, {
269
+ exists: p => p !== path || exists,
270
+ glob: () => [],
271
+ });
272
+ assert.deepEqual(result.findings, [], `Delete path exists: ${exists}`);
273
+ assert.ok(result.parsed?.tasks[0].files.some(f => f.kind === "delete" && f.path === path));
274
+ }
275
+ });
276
+
180
277
  test("no gauntlet grammar at all: emits both an absent-wave-headers and an absent-task-headers finding", () => {
181
278
  const plan = "# Just a plan\n\nSome prose with no wave or task headers at all.\n";
182
279
  const findings = checkPlan(plan, SPEC_TEXT, alwaysTruePort()).findings;
@@ -560,6 +657,37 @@ test("wave-file-disjointness: Test/Test allowed; Test vs Modify conflict; Modify
560
657
  assert.ok(WD(modifyModify).some((f) => f.reason.includes("fixture-shared.ts")));
561
658
  });
562
659
 
660
+ test("wave-file-disjointness: Delete conflicts with every kind at every Files position before and after removal", () => {
661
+ const path = "extensions/lib/fixture-removed.ts";
662
+ const entries = [
663
+ "- Create: extensions/lib/fixture-task1.ts",
664
+ "- Modify: extensions/lib/fixture-shared.ts",
665
+ "- Test: extensions/lib/fixture-task1.test.ts",
666
+ ];
667
+ for (const kind of ["Create", "Modify", "Test", "Delete"]) {
668
+ for (let position = 0; position <= entries.length; position++) {
669
+ for (const exists of [true, false]) {
670
+ for (const suffix of ["", ":10-20"]) {
671
+ const files = [...entries];
672
+ files.splice(position, 0, `- Delete: ${path}${suffix}`);
673
+ const plan = VALID_PLAN.replace(entries.join("\n"), files.join("\n"))
674
+ .replace("- Create: extensions/lib/fixture-task2.ts", `- ${kind}: ${path}`);
675
+ const findings = checkPlan(plan, SPEC_TEXT, {
676
+ exists: p => p !== path || exists,
677
+ glob: () => [],
678
+ }).findings;
679
+ assert.deepEqual(findingsFor(findings, "wave-file-disjointness"), [{
680
+ check: "wave-file-disjointness",
681
+ line: lineOf(plan, "## Wave 1"),
682
+ text: plan.split("\n")[lineOf(plan, "## Wave 1") - 1],
683
+ reason: `Task 1 and Task 2 in Wave 1 both declare "${path}" / "${path}"`,
684
+ }], JSON.stringify({ kind, position, exists, suffix }));
685
+ }
686
+ }
687
+ }
688
+ }
689
+ });
690
+
563
691
  test("Verification quote-integrity: task-owned literal check unchanged", () => {
564
692
  const mutated = VALID_PLAN.replace(
565
693
  "- via: `helperFn()`\n\nThis task implements helperFn() for parsing.",
@@ -659,21 +787,23 @@ test("check 3 fail-closed: unparseable **Spec:** anchor line", () => {
659
787
  assert.equal(ar[0].line, lineOf(mutated, "**Spec:** doc/specs/fixture-spec.md § Design without quotes"));
660
788
  });
661
789
 
662
- test("check 4 paths-exist: Modify: literal path does not exist", () => {
790
+ for (const [name, prefix] of [
791
+ ["check 4 paths-exist: Modify: literal path does not exist", ""],
792
+ ["Modify after Delete is still existence-checked", "- Delete: extensions/lib/fixture-removed.ts\n"],
793
+ ]) test(name, () => {
794
+ const entry = "- Modify: extensions/lib/fixture-shared.ts";
795
+ const plan = VALID_PLAN.replace(entry, prefix + entry);
663
796
  const port: FsPort = {
664
797
  exists: (p) => p !== "extensions/lib/fixture-shared.ts",
665
798
  glob: () => [],
666
799
  };
667
- const findings = checkPlan(VALID_PLAN, SPEC_TEXT, port).findings;
668
- const pe = findingsFor(findings, "paths-exist");
669
- assert.ok(
670
- pe.some((f) => f.reason.includes("fixture-shared.ts") && f.reason.includes("does not exist")),
671
- `expected a paths-exist finding, got: ${JSON.stringify(pe)}`,
672
- );
673
- assert.ok(
674
- pe.some((f) => f.line === lineOf(VALID_PLAN, "- Modify: extensions/lib/fixture-shared.ts")),
675
- `expected the paths-exist finding's line to point at the offending Modify: entry, got: ${JSON.stringify(pe)}`,
676
- );
800
+ const findings = checkPlan(plan, SPEC_TEXT, port).findings;
801
+ assert.deepEqual(findingsFor(findings, "paths-exist"), [{
802
+ check: "paths-exist",
803
+ line: lineOf(plan, entry),
804
+ text: entry,
805
+ reason: 'Modify: path "extensions/lib/fixture-shared.ts" does not exist',
806
+ }]);
677
807
  });
678
808
 
679
809
  test("check 4 paths-exist: backtick-wrapped Modify: path is stripped before the existence check", () => {
@@ -929,6 +1059,38 @@ function tb(plan: string, spec = SPEC_TEXT, fs: FsPort = alwaysTruePort()): Plan
929
1059
  return findingsFor(checkPlan(plan, spec, fs).findings, "tests-block");
930
1060
  }
931
1061
 
1062
+ test("unknown kinds outside Files are not Files findings", () => {
1063
+ const nonePlan = VALID_PLAN.replace(T2_FILES_TEST, "")
1064
+ .replace(T2_TESTS + "- baseline: abc1234 pass\n", "**Tests:**\n- none: docs\n");
1065
+ for (const plan of [
1066
+ nonePlan,
1067
+ VALID_PLAN.replace("**Files:**", "- Rename: before.ts\n\n**Files:**"),
1068
+ VALID_PLAN.replace("This task handles naming details.", "- Rename: after.ts"),
1069
+ ...["- [ ] **Step 1: Work**", "- `echo work`"].map(boundary =>
1070
+ VALID_PLAN.replace(T2_FILES_TEST, `${T2_FILES_TEST}${boundary}\n- Modfy: outside.ts\n`)),
1071
+ ]) {
1072
+ const result = checkPlan(plan, SPEC_TEXT, alwaysTruePort());
1073
+ assert.deepEqual(findingsFor(result.findings, "files-block"), []);
1074
+ assert.ok(result.parsed.tasks.every(task => task.filesUnknown.length === 0));
1075
+ if (plan === nonePlan) assert.deepEqual(result.findings, []);
1076
+ }
1077
+ });
1078
+
1079
+ test("unheaded line in the Files run is a files-block finding", () => {
1080
+ const entry = "- none: docs";
1081
+ const plan = VALID_PLAN.replace(T2_FILES_TEST, "")
1082
+ .replace(T2_TESTS + "- baseline: abc1234 pass\n", `${entry}\n`);
1083
+ const findings = checkPlan(plan, SPEC_TEXT, alwaysTruePort()).findings;
1084
+ assert.deepEqual(findingsFor(findings, "files-block"), [{
1085
+ check: "files-block",
1086
+ line: lineOf(plan, entry),
1087
+ text: entry,
1088
+ reason: `Task 2: Files line "${entry}" is not a Create, Modify, Test or Delete entry with a path`,
1089
+ }]);
1090
+ assert.ok(tb(plan).some((f) => f.reason.includes("block missing") && f.reason.includes("Task 2")));
1091
+ assert.equal(findings.length, 2);
1092
+ });
1093
+
932
1094
  test("tests-block: block missing when a task has no **Tests:**", () => {
933
1095
  const mutated = VALID_PLAN.replace(T2_TESTS, "");
934
1096
  assert.ok(tb(mutated).some((f) => f.reason.includes("block missing") && f.reason.includes("Task 2")));
@@ -1409,6 +1571,22 @@ test("coordinated ledger rejects duplicate mapping sections", () => {
1409
1571
  });
1410
1572
 
1411
1573
 
1574
+ test("manifest carries Delete and later entries", () => {
1575
+ const entry = "- Delete: extensions/lib/fixture-removed.ts:1-2";
1576
+ const plan = VALID_PLAN.replace("**Files:**", `**Files:**\n${entry}`);
1577
+ const result = checkPlan(plan, SPEC_TEXT, alwaysTruePort());
1578
+ assert.deepEqual(result.findings, []);
1579
+ assert.ok(result.parsed);
1580
+ const tasks = renderManifest(result.parsed, "/plan", "/spec").split("\n").slice(1).map(line => JSON.parse(line));
1581
+ assert.equal(tasks[0].task, 1);
1582
+ assert.deepEqual(tasks[0].files, [
1583
+ { kind: "Delete", path: "extensions/lib/fixture-removed.ts:1-2" },
1584
+ { kind: "Create", path: "extensions/lib/fixture-task1.ts" },
1585
+ { kind: "Modify", path: "extensions/lib/fixture-shared.ts" },
1586
+ { kind: "Test", path: "extensions/lib/fixture-task1.test.ts" },
1587
+ ]);
1588
+ });
1589
+
1412
1590
  test("manifest preserves ordered task data and optional coordination fields", () => {
1413
1591
  const plan = VALID_PLAN.replaceAll("Task 1", "Task 4").replaceAll("Task 2", "Task 9")
1414
1592
  .replace("Implement helper", 'Implement | "helper"')
@@ -30,7 +30,7 @@ interface Anchor {
30
30
  }
31
31
 
32
32
  interface FileEntry {
33
- kind: "create" | "modify" | "test";
33
+ kind: "create" | "modify" | "test" | "delete";
34
34
  line: number;
35
35
  text: string;
36
36
  path: string;
@@ -53,6 +53,7 @@ interface Task {
53
53
  waveNumber: number;
54
54
  filesBlockMissing: boolean;
55
55
  files: FileEntry[];
56
+ filesUnknown: { line: number; text: string }[];
56
57
  specAnchorLine: number | undefined;
57
58
  anchors: Anchor[];
58
59
  anchorParseError: boolean;
@@ -216,7 +217,9 @@ function parsePlan(planText: string): ParsedPlan {
216
217
  }
217
218
 
218
219
  let filesLine: number | undefined;
220
+ let lastFilesLine = -1;
219
221
  const files: FileEntry[] = [];
222
+ const filesUnknown: Task["filesUnknown"] = [];
220
223
  let specAnchorLine: number | undefined;
221
224
  let anchors: Anchor[] = [];
222
225
  let anchorParseError = false;
@@ -225,6 +228,7 @@ function parsePlan(planText: string): ParsedPlan {
225
228
  const line = lines[k];
226
229
  if (filesLine === undefined && /^(?:\*\*Files:\*\*|Files:)/.test(line)) {
227
230
  filesLine = k + 1;
231
+ lastFilesLine = k;
228
232
  let p = k + 1;
229
233
  while (p <= bodyEndIdx) {
230
234
  const l = lines[p];
@@ -232,14 +236,19 @@ function parsePlan(planText: string): ParsedPlan {
232
236
  p++;
233
237
  continue;
234
238
  }
235
- const fm = /^- (Create|Modify|Test): (.+)$/.exec(l);
236
- if (!fm) break;
237
- files.push({
238
- kind: fm[1].toLowerCase() as FileEntry["kind"],
239
- line: p + 1,
240
- text: l,
241
- path: fm[2].trim().replace(/^`|`$/g, ""),
242
- });
239
+ if (!/^- [^\s:]+: /.test(l)) break;
240
+ lastFilesLine = p;
241
+ const fm = /^- (Create|Modify|Test|Delete): (.*\S.*)$/.exec(l);
242
+ if (fm) {
243
+ files.push({
244
+ kind: fm[1].toLowerCase() as FileEntry["kind"],
245
+ line: p + 1,
246
+ text: l,
247
+ path: fm[2].trim().replace(/^`|`$/g, ""),
248
+ });
249
+ } else {
250
+ filesUnknown.push({ line: p + 1, text: l });
251
+ }
243
252
  p++;
244
253
  }
245
254
  }
@@ -260,15 +269,7 @@ function parsePlan(planText: string): ParsedPlan {
260
269
 
261
270
  let headingIdx = -1;
262
271
  if (filesLine !== undefined) {
263
- let lastEntry = filesLine - 1;
264
- let p = filesLine;
265
- while (p <= bodyEndIdx) {
266
- const l = lines[p];
267
- if (l.trim() === "") { p++; continue; }
268
- if (/^- \w+: /.test(l)) { lastEntry = p; p++; continue; }
269
- break;
270
- }
271
- let q = lastEntry + 1;
272
+ let q = lastFilesLine + 1;
272
273
  while (q <= bodyEndIdx && lines[q].trim() === "") q++;
273
274
  if (q <= bodyEndIdx && !mask[q] && lines[q] === "**Tests:**") {
274
275
  headingIdx = q;
@@ -307,6 +308,7 @@ function parsePlan(planText: string): ParsedPlan {
307
308
  orchestrator: lines.slice(i + 1, bodyEndIdx + 1).map(line => /^Orchestrator: (.+)$/.exec(line)).find(match => match)?.[1],
308
309
  filesBlockMissing: filesLine === undefined,
309
310
  files,
311
+ filesUnknown,
310
312
  specAnchorLine,
311
313
  anchors,
312
314
  anchorParseError,
@@ -872,6 +874,32 @@ function checkAnchorResolution(parsed: ParsedPlan, specLines: string[]): PlanChe
872
874
  return findings;
873
875
  }
874
876
 
877
+ function checkFilesBlock(parsed: ParsedPlan): PlanCheckFinding[] {
878
+ const out: PlanCheckFinding[] = [];
879
+ for (const task of parsed.tasks) {
880
+ for (const entry of task.filesUnknown) {
881
+ out.push({
882
+ check: "files-block",
883
+ line: entry.line,
884
+ text: entry.text,
885
+ reason: `Task ${task.number}: Files line "${entry.text}" is not a Create, Modify, Test or Delete entry with a path`,
886
+ });
887
+ }
888
+ for (const entry of task.files) {
889
+ const path = stripLineSuffix(entry.path);
890
+ if (entry.kind === "delete" && isGlob(path)) {
891
+ out.push({
892
+ check: "files-block",
893
+ line: entry.line,
894
+ text: entry.text,
895
+ reason: `Task ${task.number}: Delete: path "${path}" is a glob; Delete takes a literal path`,
896
+ });
897
+ }
898
+ }
899
+ }
900
+ return out;
901
+ }
902
+
875
903
  function checkPathsExist(parsed: ParsedPlan, fs: FsPort): PlanCheckFinding[] {
876
904
  const findings: PlanCheckFinding[] = [];
877
905
  for (const task of parsed.tasks) {
@@ -1335,6 +1363,7 @@ export function checkPlan(planText: string, specText: string, fs: FsPort): { fin
1335
1363
  findings.push(...checkQuoteIntegrity(parsed, specLines));
1336
1364
  findings.push(...checkAnchorResolution(parsed, specLines));
1337
1365
  findings.push(...checkPathsExist(parsed, fs));
1366
+ findings.push(...checkFilesBlock(parsed));
1338
1367
  findings.push(...checkTestsBlock(parsed, fs));
1339
1368
  const { policies, local } = baselinePolicy(parsed);
1340
1369
  if (policies.length && !local) findings.push({ check: "baseline-line", line: 0, text: policies.join("\n"), reason: "expected one header Baseline policy: implement-local" });
@@ -1374,7 +1403,7 @@ export function checkPlan(planText: string, specText: string, fs: FsPort): { fin
1374
1403
  }
1375
1404
 
1376
1405
  export function renderManifest(parsed: ParsedPlan, planAbs: string, specAbs: string): string {
1377
- const kinds = { create: "Create", modify: "Modify", test: "Test" };
1406
+ const kinds = { create: "Create", modify: "Modify", test: "Test", delete: "Delete" };
1378
1407
  return [
1379
1408
  `manifest: ${planAbs} | spec: ${specAbs} | tasks: ${parsed.tasks.length} | waves: ${parsed.waves.length}`,
1380
1409
  ...parsed.tasks.map(task => JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "8.4.6",
3
+ "version": "8.4.7",
4
4
  "description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
5
5
  "author": "Jacek Juraszek",
6
6
  "type": "module",
@@ -62,7 +62,7 @@
62
62
  "test": "node scripts/ci.mjs"
63
63
  },
64
64
  "peerDependencies": {
65
- "pi-cohort": ">=7.7.0"
65
+ "pi-cohort": ">=7.7.3"
66
66
  },
67
67
  "dependencies": {
68
68
  "yaml": "^2.9.0"
@@ -117,7 +117,7 @@ Run every plan task through this loop in a wave of N >= 1 tasks. Run waves in se
117
117
  - **Lifecycle per task:** `pending → in_progress (wave fan-out) → complete (wave commit)`; keep `in_progress` through exhausted integration or repair attempts pending the human's decision (see [When a Subagent Fails](#when-a-subagent-fails)); use `failed` only for a human-decided terminal failure; `skipped` when the plan drops it as not applicable (terminal, counted done). A rejected `update` names the earlier pending tasks and the legal fixes - record those tasks' true state, never `clear` or re-`init` to move on.
118
118
  - **Widget caveat (known, deliberately unfixed).** The persistent `plan_tracker` widget's icon strip (`○ → ✓`) and `(c/total)` count reflect every task, but its trailing *name* shows only the **first** `in_progress` task. Use the icon strip and the `status` action for the full in-flight view; a richer multi-task widget is a separate extension change, out of scope (YAGNI).
119
119
 
120
- 1. **Independence check.** Assert pairwise-disjoint manifest `files`: Test/Test is allowed, Test/Create, Test/Modify and Modify/Modify overlap. Split overlaps into singleton waves. Trust the plan for runtime-resource disjointness; capture `WAVE_BASE_SHA` with `git -C <worktree> rev-parse HEAD`, print `wave <w> base <sha>` and hold it.
120
+ 1. **Independence check.** Assert pairwise-disjoint manifest `files`: only Test/Test may share a path. Split overlaps into singleton waves. Trust the plan for runtime-resource disjointness; capture `WAVE_BASE_SHA` with `git -C <worktree> rev-parse HEAD`, print `wave <w> base <sha>` and hold it.
121
121
  2. **Dispatch.** One one-step chain whose single `parallel` step contains only implementers, one item per task: `subagent({ async: false, context: "fresh", cwd: <worktree>, chain: [{ parallel: [{ agent: "implementer", as: "impl_<n>", outputSchema: IMPLEMENTER_STATUS_SCHEMA, output: "<REPORT_DIR>/wave<w>-task<n>.md", outputMode: "file-only", task: ... }], worktree: true, concurrency: 4, stopOn: { path: "/status", in: ["BLOCKED", "NEEDS_CONTEXT", "DONE_WITH_CONCERNS"] } }] })`. Use step `stopOn` to expose `## Outputs` structured JSON in the tool-result text; top-level `tasks` has no item schema and is not used. For manifest `"mode":"in-place"`: `cwd` the branch checkout, no `worktree:`, the wave union as `SCOPED_TEST_COMMANDS`. Payload is `planPath`, task anchor, spec path, `Leg:` with additive `Rules:` per [Leg routing](#leg-routing), `SCOPED_TEST_COMMANDS` (the task's own `tests` for isolated waves), status protocol. Dispatch no reviewer step after the implementers.
122
122
  3. **Read and integrate.** Read each child's structured output and report under the bound above; print `wave <w> task <n> <status> report <path> patch <path>`. Isolated waves: before integrating `DONE` / `DONE_WITH_CONCERNS`, require an `Artifacts:` patch path and a successful `test -s <patch>`. A missing or zero-byte initial patch takes the transition-table repair row; apply nothing and retain siblings. A nonempty patch -> `git -C <worktree> apply --index <patch>` in plan index order, print `wave <w> integrated task <n>`; a nonempty initial patch that fails to apply prints `wave <w> task <n> apply failed`, leaves every already-integrated sibling staged, and stops with a stop note listing which tasks are integrated and which failed - no automatic rebuild (the human may ask for one, which runs the rebuild recipe below). In-place waves: nothing to apply; the checkout already carries the work. `BLOCKED` / `NEEDS_CONTEXT` -> the takeable-decision ladder below.
123
123
  4. **Test gate.** Run each manifest test for the wave union separately, logs to `gate-<i>.log`; print `wave <w> gate <i> exit <code>`. A red gate enters step 6 as a gate repair for every task whose `files` the failing test belongs to (the manifest's `tests` owner), before any review; the review in step 5 runs only on a green gate.
@@ -172,7 +172,7 @@ Execution escalation to `implModel` is available whenever `failed[n]` reaches 2,
172
172
 
173
173
  ### Rebuild
174
174
 
175
- A repair delta that fails `git apply --index` prints `rebuild: task <n> - delta apply failed` and counts one failed attempt. Then, in the integrated tree: partition the task's manifest paths - every `Create` path (including a new test file declared as both `Create` and `Test`) is removed (`git -C <worktree> rm -q -f --ignore-unmatch -- <paths>`), and the remaining `Modify` / `Test` paths, deduplicated and excluding every `Create` path, are restored from the wave base (`git -C <worktree> restore --source="$WAVE_BASE_SHA" --staged --worktree -- <paths>`); sibling paths are untouched (step 1 guarantees disjointness). Run the repair-base recipe on that tree, so the snapshot holds the base plus every accepted sibling and nothing of task n; dispatch one `implementer` from `<RP_TREE>` with `worktree: true` and the task's original payload (this dispatch is the next attempt on `failed[n]`'s counter - the escalated round when the counter has reached 2); apply its `Artifacts:` patch to the integrated tree with `git apply --index` (a failure here is a hard stop, not a second rebuild). The rebuilt task rejoins its original wave: re-run the gate for the wave union, one `wave-reviewer` re-review with the marker and the prior report path, and step 7 commits the whole wave once and completes all of its indices together. The human may ask for a rebuild at any stop, which runs this same recipe; nothing else triggers one. `BLOCKED` / `NEEDS_CONTEXT` on a repair never rebuilds or re-snapshots: the ladder answers, then the identical payload is re-dispatched into the existing repair tree with the answer appended.
175
+ A repair delta that fails `git apply --index` prints `rebuild: task <n> - delta apply failed` and counts one failed attempt. Then, in the integrated tree: partition the task's manifest paths, trailing `:N[-M]` stripped. Remove every `Create` path: `git -C <worktree> rm -q -f --ignore-unmatch -- <paths>`. Restore the `Modify` / `Test` paths plus the `Delete` paths `git -C <worktree> ls-tree -r --name-only "$WAVE_BASE_SHA" -- <Delete paths>` prints, deduplicated and excluding every `Create` path: `git -C <worktree> restore --source="$WAVE_BASE_SHA" --staged --worktree -- <paths>`. Skip a command with no paths. Sibling paths are untouched (step 1 guarantees disjointness). Run the repair-base recipe on that tree, so the snapshot holds the base plus every accepted sibling and nothing of task n; dispatch one `implementer` from `<RP_TREE>` with `worktree: true` and the task's original payload (this dispatch is the next attempt on `failed[n]`'s counter - the escalated round when the counter has reached 2); apply its `Artifacts:` patch to the integrated tree with `git apply --index` (a failure here is a hard stop, not a second rebuild). The rebuilt task rejoins its original wave: re-run the gate for the wave union, one `wave-reviewer` re-review with the marker and the prior report path, and step 7 commits the whole wave once and completes all of its indices together. The human may ask for a rebuild at any stop, which runs this same recipe; nothing else triggers one. `BLOCKED` / `NEEDS_CONTEXT` on a repair never rebuilds or re-snapshots: the ladder answers, then the identical payload is re-dispatched into the existing repair tree with the answer appended.
176
176
 
177
177
  ### Escalation and funding
178
178
 
@@ -213,6 +213,7 @@ For both rebase forms, consume every `pending` member in listed order, run `git
213
213
  **Files:**
214
214
  - Create: `exact/path/to/file.py`
215
215
  - Modify: `exact/path/to/existing.py:123-145`
216
+ - Delete: `exact/path/to/removed.py`
216
217
  - Test: `tests/exact/path/to/test.py`
217
218
 
218
219
  **Tests:**
@@ -264,6 +265,8 @@ Every code task carries this step (red -> green -> fmt/lint -> commit). Doc-only
264
265
 
265
266
  Use bare `git` in task commit steps: the orchestrator commits each wave after code review accepts; a task's own commit step runs inside its nested worktree and is captured as its patch.
266
267
 
268
+ Declare `Delete:` as write ownership with a literal path and no existence requirement; follow [the Files contract](reference/plan-contract.md#files-wave-file-disjointness-paths-exist-files-block).
269
+
267
270
  **Anchor rules.** See [reference/plan-contract.md § Spec anchors](reference/plan-contract.md). A task with no anchorable requirement omits the `**Spec:**` line and carries a mechanical-task row in `## Spec coverage` — silence is never valid.
268
271
 
269
272
  ## Spec Coverage Table
@@ -13,15 +13,15 @@ A wave is a maximal set of tasks that have no ordering dependency on each other,
13
13
 
14
14
  Read the home spec's `## Legs` using [the leg contract](../../brainstorming/reference/legs.md). Write `Leg: <repo>` immediately after each task heading, before `**Files:**`; absence means home. Keep the plan and its `**Spec:**` header at home, citing the home spec. Repair a `leg-unknown` task in the plan, never by adding spec membership.
15
15
 
16
- Write one `Baseline <repo>: <sha>` header per member. Match each task's baseline bullets to its own leg's SHA; require equality within a leg, not across legs. Resolve `Modify:`/`Create:`/`Test:` paths and globs against the admitted leg checkout, using its recorded `home:` with shared `NAME` against its declared primary. Key file overlap and cross-task created anchors on `(leg, path)`.
16
+ Write one `Baseline <repo>: <sha>` header per member. Match each task's baseline bullets to its own leg's SHA; require equality within a leg, not across legs. Resolve `Modify:`/`Create:`/`Test:`/`Delete:` paths and globs against the admitted leg checkout, using its recorded `home:` with shared `NAME` against its declared primary. Key file overlap and cross-task created anchors on `(leg, path)`.
17
17
 
18
18
  Split mixed waves into consecutive leg-homogeneous waves, preserving dependencies. Re-walk each task's `Files:` under its leg using the leg contract's Gather rules procedure. Append task `Rules:` for files missing from the spec list; resolve `Rules: root only - paths unresolved`. Read the combined lists before execution and review.
19
19
 
20
- ## Files (`wave-file-disjointness`, `paths-exist`)
20
+ ## Files (`wave-file-disjointness`, `paths-exist`, `files-block`)
21
21
 
22
22
  **File-ownership contract.** The per-task `**Files:**` block *is* the ownership declaration — no new syntax. Rule: **within a wave, the union of every task's declared paths must be pairwise disjoint.** Globs are allowed for `Modify` when exact paths are unknown, but must not overlap another same-wave task's paths. A task that must touch another's file belongs in a later wave.
23
23
 
24
- `Test:` entries are run anchors, not ownership: `Test`/`Test` on the same path across same-wave tasks is allowed; `Test` vs another task's `Create`/`Modify` is a conflict; writer/writer stays a conflict. `Modify:` paths must exist.
24
+ Treat `Create`, `Modify`, and `Delete` as writers and `Test:` entries as run anchors, not ownership. Allow `Test`/`Test` on the same path across same-wave tasks; reject every other same-path pair. Require `Modify:` paths to exist. Declare `Delete:` as write ownership with a literal path and no existence requirement. List a file the task removes as `Delete:` in exactly one task, never also as `Modify:`. Treat any other `- <token>:` line in the Files list as a `files-block` finding.
25
25
 
26
26
  ## Spec anchors (`anchor-resolution`)
27
27