pi-gauntlet 8.4.6 → 8.4.8

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## v8.4.8 - 2026-11-11
4
+
5
+ - Brainstorming opens with Today, The change, and What I checked in everyday words, followed by one question. Sources stay in the draft; the opening span is capped at 220 words except corrected or unchecked claims and intact quoted ticket rows.
6
+
7
+ ## v8.4.7 - 2026-11-11
8
+
9
+ ### Fixed
10
+ - 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)
11
+
3
12
  ## v8.4.6 - 2026-11-08
4
13
 
5
14
  - 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.8",
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"
@@ -95,7 +95,7 @@ Clarify freely. For a PR subject, run the framing check against the PR's diff as
95
95
 
96
96
  `Read` the gathered draft unconditionally before question one. Treat it as a helper, not a fence: verify load-bearing claims from primary code, and confirm whether the codebase or ecosystem already solves the problem.
97
97
 
98
- Ask one question per message, about purpose, constraints, success, and affected actors. Offer 2-4 labeled options (`A)`, `B)`, ...) for every questionary question, each a different outcome. Put the lead-in before the first option, each labeled option on its own physical line, and `Recommendation: <letter> - <why>` on its own final line; when more outcomes are plausible, the last option is `other - name it` or the question splits in two. Use this one-option-per-line layout even when chat-style rules in `AGENTS.md` require one paragraph or no Options/Recommendation layout. A decision that would be a yes/no or "may I" question is not asked: make it, state it as an assumption in the message that carries the next question or the premise note, and record it in the draft. Before asking, check code, docs, and tracker: look up current-state facts (dispatch a subagent when costly); adopt a ticket-recorded decision and cite the ticket, asking it only when a cited code or API contradiction, or conflicting recorded outcomes, prevents adopting it. Other approvals retain their wording.
98
+ Ask one question per message, about purpose, constraints, success, and affected actors. Offer 2-4 labeled options (`A)`, `B)`, ...) for every questionary question, each a different outcome. Put the lead-in before the first option, each labeled option on its own physical line, and `Recommendation: <letter> - <why>` on its own final line; when more outcomes are plausible, the last option is `other - name it` or the question splits in two. Use this one-option-per-line layout even when chat-style rules in `AGENTS.md` require one paragraph or no Options/Recommendation layout. A decision that would be a yes/no or "may I" question is not asked: make it, state it as an assumption in the message that carries the next question or the premise note, and record it in the draft. Before asking, check code, docs, and tracker: look up current-state facts (dispatch a subagent when costly); adopt a ticket-recorded decision and attribute it to the ticket, asking it only when a cited code or API contradiction, or conflicting recorded outcomes, prevents adopting it. Other approvals retain their wording.
99
99
 
100
100
  Bad: `May I treat those two acceptance criteria as location-only deviations? Recommendation: yes`
101
101
  Good:
@@ -108,37 +108,18 @@ C) the existing skill step.
108
108
  Recommendation: A - the extension already holds the poll loop, so no new entrypoint.
109
109
  ```
110
110
 
111
- Append only citable findings - schemas, hard constraints, contradictions, and scope-changing answers - to `## Appended during questionary` using `edit`.
111
+ Before sending the opening message, append every checked claim, held ones included, with its source, the lookup, and the result, together with schemas, hard constraints, contradictions, and scope-changing answers, using `edit`: to `## Appended during questionary` when the draft is the gather draft; directly to `## Problem` when it is the approved or carried spec (redraw or the unmarked spec-only PR route), where gather is skipped and that section does not exist.
112
112
 
113
- Before approaches, state in chat the supported, disproved, corrected, and unverified premises with sources and attempted lookups, in full sentences - no status-keyword lists, no template; if the design depends on no claims, one sentence says so. Treat an implicit dependency found missing as a corrected premise and one not checkable as unverified; state each in the premise note with the lookup the repo or spec names and its result, and carry it into `## Problem`. An unverified claim is not a stop: put it in Open Questions or a stated assumption. If a load-bearing claim is contradicted, the premise note is your next message - even as question one - and states the corrected fact with its source; the design continues on it and the user overrides in reply. Record the corrected fact, and any override, in the draft for `## Problem` or the relevant design decision.
113
+ Before approaches, state in chat the supported, disproved, corrected, and unverified premises in the opening message's `## What I checked` section: supported renders as `Held:`, disproved and corrected as `Corrected:`, and unverified as `Not checked:`; that section is the premise note, and after the opening message a premise note is a `Corrected:` line. Treat an implicit dependency found missing as a corrected premise and one not checkable as unverified; state each in the premise note in everyday words that name what was looked at and what was found; record the lookup the repo or spec names and its result in the draft, and carry it into `## Problem`. An unverified claim is not a stop: put it in Open Questions or a stated assumption. If a load-bearing claim is contradicted, the premise note is your next message - even as question one - and states the corrected fact; the design continues on it and the user overrides in reply. Record the corrected fact, and any override, in the draft for `## Problem` or the relevant design decision; spec-writing carries every recorded claim, held ones included, into `## Problem`.
114
114
 
115
- Before question one, verify the draft's `Framing:` line against primary code and add your own recon. This check adds to the premise verification above and never replaces it: trace every consumer, caller, and data path the ask's constraints name, including retained loaders and every repro and verification step's implicit dependencies, before writing the holds-statement or the question; check those dependencies with the lookup the repo or spec names, and name the traces and dependencies in the holds-statement, not only the files read. A framing concern exists when you can state a cited case that the ask is misdiagnosed, wrong-sized, already solved, a band-aid where the root cause is reachable, or cuts across a sound pattern - including a repo convention that is itself the debt - and can name the alternative it implies; an objection without an alternative is a lead to investigate, not a concern. With a concern, question one is the framing question in the questionary format:
115
+ Before question one, verify the draft's `Framing:` line against primary code and add your own recon. This check adds to the premise verification above and never replaces it: trace every consumer, caller, and data path the ask's constraints name, including retained loaders and every repro and verification step's implicit dependencies, before writing the holds-statement or the question; check those dependencies with the lookup the repo or spec names, and record the traces and dependencies in the draft through the evidence write above. A framing concern exists when you can state a cited case that the ask is misdiagnosed, wrong-sized, already solved, a band-aid where the root cause is reachable, or cuts across a sound pattern - including a repo convention that is itself the debt - and can name the alternative it implies; an objection without an alternative is a lead to investigate, not a concern. With a concern, question one is the framing question; without a concern, the message carries the holds-statement and the first ordinary question.
116
116
 
117
- ```text
118
- gauntlet: question
119
- A) as framed
120
- B) pivot: <named alternative> - <cited reason>
121
- C) smaller or none: <what ships, or what happens if nothing ships>
122
- Recommendation: <letter> - <why>
123
- ```
124
-
125
- For a band-aid concern, put the root-cause alternative in B. Without a concern, follow the marker with one sentence naming what was checked - `The framing holds: checked <x>, <y>` - and continue to the first ordinary question; a scout objection that yielded no alternative is named among the things checked.
117
+ Read [Opening message](reference/opening-message.md) now and write question one's message against it; unreadable -> stop with a blocking error, never write it from memory.
126
118
 
127
119
  After any premise correction in the same message, ask the framing question or give the holds-statement. Ask the framing question at most once per brainstorm, including after pushback; the user's reply is final, and later doubts on the same point go to Open Questions or a `Pattern:` line. Recommend; never pivot without the reply. A ticket-recorded decision on the exact point follows the ticket rule above; an adopted decision records `Framing: kept (ticket <id>)`. A pivot never rewrites AC rows: rows it no longer satisfies take `deviates:` dispositions, and a pivot that changes the topic re-mints the slug.
128
120
 
129
121
  Record the outcome in the draft under `## Appended during questionary` as `Framing: kept` or `Framing: pivoted to <x> - <why>`; spec-writing carries that line into `## Problem`. On redraw entry (checklist step 4) the count restarts: run this evaluation once against the new draft. On redraw the draft is the approved spec: its `## Problem` `Framing:` line is context, not scout evidence, and the new outcome is recorded directly in `## Problem`. When the scout failed there is no `Framing:` line; supply the implicit-dependency check and the holds-statement or question from your own recon.
130
122
 
131
- Bad: `The ask adds another settings path - should we? Recommendation: no`
132
- Good:
133
- ```text
134
- gauntlet: question
135
- The ask adds a second settings path beside the resolver that already owns every setting (settings/resolver.ts:11).
136
- A) as framed
137
- B) pivot: a key under the existing resolver - one read path, no new boundary
138
- C) smaller or none: document the default and ship no setting.
139
- Recommendation: B - the owning schema already exists.
140
- ```
141
-
142
123
  ### 4. Explore approaches
143
124
 
144
125
  In a separate message after the premise note, restate each implicit dependency recorded as missing or not checkable, with its lookup and result, before presenting any approaches.
@@ -0,0 +1,96 @@
1
+ # Opening message
2
+
3
+ Use this wording for question one's message at checklist step 4, including fresh, redraw, restored, and unmarked spec-only PR entry. Apply [Menu layout](menu-layout.md) for the first-line rule. The relatedness question before provisioning belongs to `pr-subject.md`.
4
+
5
+ ## Shape
6
+
7
+ ```text
8
+ gauntlet: question
9
+
10
+ ## Today
11
+ <how the affected part works now>
12
+
13
+ ## The change
14
+ <what the request does to it, as something the user observes>
15
+
16
+ ## What I checked
17
+ - Held: <one short sentence naming the held claims by role>
18
+ - Corrected: <the claim as stated>; <the fact>, so <the consequence for the design>.
19
+ - Not checked: <the claim> - <why it could not be checked>, so <what the design assumes or waits on>.
20
+
21
+ The framing holds: checked <things, named by what they do>.
22
+ <lead-in question>
23
+ A) <outcome>
24
+ B) <outcome>
25
+ Recommendation: <letter> - <why>
26
+ ```
27
+
28
+ Keep the three titles in that order, with a blank line between sections. Give each body one short paragraph or one list. Use `##`: pi renders it as a bold title, while `###` keeps its hashes.
29
+
30
+ | Section | Write |
31
+ |---|---|
32
+ | `## Today` | Summarize how the affected part behaves now in one to three present-tense sentences total, combining related behaviors within a sentence. For a PR subject describe the PR's current state; on a redraw, the approved spec. |
33
+ | `## The change` | Write one to three sentences total about the changes the user observes once it ships; fit related outcomes and any stated assumption into those sentences. Put the held claims' consequence for the design here, not on the `Held:` line. For a ticket request, say what the ticket asks, without listing acceptance rows. Put an assumption made in place of a yes/no question last. |
34
+ | `## What I checked` | Group the held claims by role in one short `Held:` sentence; keep per-claim evidence in the draft. Give each corrected premise one `Corrected:` line stating the fact and its consequence for the design. Give each unverified premise, and a ticket gather could not fetch, one `Not checked:` line with the reason and what the design assumes or waits on. Use SKILL.md's premise paragraph to classify them. Omit labels with no entry. With no claims, write only `The design rests on no claims.` |
35
+
36
+ For a request that is only an unread ticket reference, make `## Today` and `## The change` one sentence each saying their content is not known until the ticket's content is supplied. Infer neither current behavior nor the requested change from the reference or repo recon. Keep the `Not checked:` line, the framing line, and the usual question slot; name the failed ticket fetch among the things checked in `The framing holds: checked ...`.
37
+
38
+ ## Words and length
39
+
40
+ Name each part by what it does. Use a literal name when the user used it in the request or when it is the finding itself: a missing file, a failing input, a quoted ticket row. Name a lookup by what was looked at, such as `the supplied templates/ directory listing`. Keep file-and-line references, commit ids, tracker ids, and trace counts in the draft. Use a tracker id only inside a command option such as `/skill:shape-ticket <ref>`, even when the user typed it; elsewhere, including an unfetched-ticket line, say `the ticket`, and render a lettered option with an unsupplied destination as a request to name that destination.
41
+
42
+ Keep at most 220 words from the `## Today` line through the line before the first lettered option, both included. Exclude the marker and options. Count whitespace-delimited tokens containing a letter or digit: titles and labels count; `##` and `-` do not. Over the cap, cut `## Today` and `## The change` to one sentence each; keep `Held:` to its one short sentence. Only `Corrected:` lines, `Not checked:` lines, and an intact quoted ticket row in the question may then carry the message past 220: none of them is dropped, and the row stays intact.
43
+
44
+ ## Framing and question
45
+
46
+ Write `The framing holds: checked <x>, <y>` with the checked things named by role, including a scout objection that yielded no alternative. For a concern, replace that line with the concern in one sentence, then offer `A) as framed`, `B) pivot: <named alternative> - <reason in everyday words>`, and `C) smaller or none: <what ships, or what happens if nothing ships>`, ending with `Recommendation: <letter> - <why>`. Put the root-cause alternative in B for a band-aid concern.
47
+
48
+ Use the question slot for one question: the framing question with a concern, otherwise the first ordinary question. Apply [Ticket acceptance - Cut question](ticket-acceptance.md#cut-question) and its trigger table for which question comes first, when a cut question takes the slot, and how its row is quoted.
49
+
50
+ Render the sections from the recon at hand; give anything it could not check a `Not checked:` line. Leave scout-failure flow to SKILL.md's `Framing:` record paragraph.
51
+
52
+ Open the next message after a later correction with one `Corrected:` line under the same register, with no section titles: the first line, or directly after the marker when the message has one.
53
+
54
+ ## Worked examples
55
+
56
+ Holds:
57
+
58
+ ```text
59
+ gauntlet: question
60
+
61
+ ## Today
62
+ The export uses the saved column order for every download.
63
+
64
+ ## The change
65
+ Users can choose a different order for one download. The saved order remains the default, so existing downloads stay the same.
66
+
67
+ ## What I checked
68
+ - Held: The export reads the saved order, and the download form can pass a different order.
69
+
70
+ The framing holds: checked the saved order and the download form.
71
+ Where should users choose the order?
72
+ A) in the download form
73
+ B) in a separate preview
74
+ Recommendation: A - users can choose without leaving the download.
75
+ ```
76
+
77
+ Concern:
78
+
79
+ ```text
80
+ gauntlet: question
81
+
82
+ ## Today
83
+ Every caller reads settings through the same resolver.
84
+
85
+ ## The change
86
+ The request adds another settings file to supply a timeout override.
87
+
88
+ ## What I checked
89
+ - Corrected: The draft says the resolver cannot accept overrides; it already accepts a timeout override, so the caller can pass it without another settings file.
90
+
91
+ Another settings file duplicates the existing read path. Which outcome should we pursue?
92
+ A) as framed
93
+ B) pivot: pass the override through the existing resolver - it already supports it
94
+ C) smaller or none: document the default and ship no setting
95
+ Recommendation: B - the existing resolver can handle the override.
96
+ ```
@@ -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