pi-gauntlet 5.12.0 → 5.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## v5.13.0 - 2026-09-19
4
+
5
+ - Post-approval spec amendments go through a reviewer-first funnel (`skills/brainstorming/reference/amendment-surface.md`): a fresh `spec-council-member` in `Mode: amendment-review` clears evidence-backed factual corrections that touch no human-owned section, a deterministic prefilter sends descopes and acceptance-criteria edits to the human, and escalations render as one readable batch (what / why / example / recommended, real alternatives only) with a one-reply grammar and the standing-grant offer; each batch lands as one `amend:` commit with per-item records. The spec gate offers the grant. `finishing-a-development-branch` runs eligible conformance `accept` gaps through the same funnel before the disposition menu and lists auto-applied amendments above the ship options. `brainstorming/SKILL.md` shrinks; `scripts/ci.mjs` pins the new tokens.
6
+
7
+ ## v5.12.1 - 2026-09-19
8
+
9
+ - Fixed: `gauntlet-telemetry-salvage` and `gauntlet-performance` no longer crash with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` when run from an npm-installed copy - both bins are now committed esbuild bundles (sources in `src/bins/`, rebuild with `npm run build:bins`), guarded by a CI freshness check, bundle pack assertions, and a packed-install smoke test. (#39)
10
+
3
11
  ## v5.12.0 - 2026-09-19
4
12
 
5
13
  - New human-only `/skill:gauntlet-performance` and parse-only `gauntlet-performance` bin: the CLI digests committed telemetry records (current repo, plus `--dir <path>` repos; `--since <version>` narrows; `--json`) into per-run rows and per-version p50/max, and the skill turns the digest into one example-led recommendation, cornerstones, and a three-item menu (render on request, ticket via `shape-ticket`, drill-down). `gauntlet-telemetry-salvage` now stamps a record still `in_progress` with no ship phase as `status: shipped` + `shipped_at` at landing (`present`/`restored ... (marked shipped)`, `unfinished` under `--check`); `finishing-a-development-branch` and `gatekeep-pr` carry the new lines. The gh-37 record on this repo is backfilled to `shipped`. (#35)
package/README.md CHANGED
@@ -63,7 +63,7 @@ flowchart LR
63
63
 
64
64
  <!-- TODO GIF: a real gauntlet run end to end -->
65
65
 
66
- Everything between gate 1 and gate 2 - task breakdown, implementation, both review passes - runs without you in the loop. Changing an approved spec later is a conditional diff-approval stop (brainstorming's `Amending an approved spec`) - or, under a standing grant, a diff render that continues - not a third numbered gate. That's the mechanism. What follows is the machinery behind it.
66
+ Everything between gate 1 and gate 2 - task breakdown, implementation, both review passes - runs without you in the loop. Changing an approved spec later goes through brainstorming's `Amending an approved spec`: a fresh-context reviewer clears evidence-backed factual corrections on its own, escalations reach you as one readable batch, and only a redraw is a full stop - not a third numbered gate. That's the mechanism. What follows is the machinery behind it.
67
67
 
68
68
  ## Architecture
69
69
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: spec-council-member
3
- description: Adversarial single-model spec critic dispatched by the roasting-the-spec or shape-ticket skills; assesses whether a spec is sound, complete, and actionable. Not for direct dispatch.
3
+ description: Adversarial single-model spec critic dispatched by the roasting-the-spec or shape-ticket skills, and in `Mode: amendment-review` by brainstorming's amendment surface to clear or escalate proposed spec amendments; assesses whether a spec is sound, complete, and actionable. Not for direct dispatch.
4
4
  tools: read, grep, find, ls, bash
5
5
  thinking: xhigh
6
6
  defaultContext: fresh
@@ -18,6 +18,16 @@ You are read-only: you never modify the repository or any input artifact; your o
18
18
 
19
19
  When your dispatching task asks for codebase verification, verify - do not trust assertions about existing files, APIs, or conventions - but bounded: prefer `rg` (it respects `.gitignore`) over recursive `grep`, use `rg`-native bounds (`--max-count`, explicit paths); scope every scan to explicit paths, never a repository root; bound each scan with `timeout` (or `gtimeout`) when available, and do not run it unbounded when neither exists. A scan that times out or cannot be bounded is reported as unverified - never retried broader. End every finding with `probed:`; `none` is a normal answer.
20
20
 
21
+ ## Amendment-review mode
22
+
23
+ When the first line of your task is `Mode: amendment-review`, this section replaces everything below it. You judge proposed amendments to an approved spec, not the spec. The task carries the rubric, the spec path, and per item a handle, a spec location, the `old -> new` text, and cited evidence (a command and its output, a `file:line`, a test result, a fixture measurement). Read the spec around each location; probe cited evidence read-only, bounded as above; judge scope from the spec's `## Human input` section when the task supplies it, else from its Goal, Problem, scope and acceptance sections. Emit exactly one line per item and nothing else:
24
+
25
+ ```
26
+ <handle>: auto-apply | escalate - <one-line reason> - probed: <check> - <result>
27
+ ```
28
+
29
+ `auto-apply` only when every rubric predicate holds on evidence you probed. Uncited, unverifiable, uncertain, or touching a human-owned section -> `escalate`. You never edit anything.
30
+
21
31
  Assess the spec on five axes:
22
32
 
23
33
  1. **Addresses the problem.** Does the spec actually solve the problem in the problem statement? Answer yes / partial / no and say why. A well-written spec for the wrong problem is unsound.
@@ -1,82 +1,109 @@
1
1
  #!/usr/bin/env node
2
- // Digest committed gauntlet telemetry records: one row per run plus p50/max per pi-gauntlet
3
- // version. Parse and aggregate only; the gauntlet-performance skill reasons over the output.
2
+
3
+ // src/bins/gauntlet-performance.mjs
4
4
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { basename, join, resolve } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
8
  import process from "node:process";
9
9
  import { parse as parseYaml } from "yaml";
10
- import { mergeGauntlet, resolveTelemetry } from "../extensions/lib/gauntlet-settings.ts";
11
10
 
12
- const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"];
13
- const TOKEN_KEYS = ["input", "output", "cache_read", "cache_write"];
14
- const RUN_HEADER = ["run_id", "repo", "spec", "version", "status", "wall", "b/p/i/v/s min", "tokens", "cost", "models", "disp", "grants", "reopens", "loops", "findings", "council"];
15
- const VERSION_HEADER = ["version", "n", "shipped", "truncated", "wall p50/max", "tokens p50/max", "cost p50/max", "disp p50", "grants p50/max", "reopens p50/max", "loops p50/max", "findings p50 b/M/m", "models"];
11
+ // extensions/lib/gauntlet-settings.ts
12
+ import path from "node:path";
13
+ function mergeGauntlet(preset, repo) {
14
+ return { ...preset ?? {}, ...repo ?? {} };
15
+ }
16
+ var nonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
17
+ var joinWarn = (ws) => ws.length ? ws.join("; ") : void 0;
18
+ var DEFAULT_TELEMETRY_DIR = ".pi/gauntlet/telemetry";
19
+ var DEFAULT_TELEMETRY_BUCKETS = [
20
+ ["test", ["**/test/**", "**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/*_test.*"]],
21
+ ["docs", ["**/*.md"]],
22
+ ["config", ["**/*.json", "**/*.yaml", "**/*.yml", "**/*.toml", "**/*.lock", "**/*-lock.*"]]
23
+ ];
24
+ function resolveTelemetry(g) {
25
+ const t = g.telemetry;
26
+ const warnings = [];
27
+ const enabled = t?.enabled !== false;
28
+ let dir = DEFAULT_TELEMETRY_DIR;
29
+ if (t?.dir !== void 0) {
30
+ const value = nonEmptyString(t.dir) ? t.dir.trim().replace(/\/+$/, "") : "";
31
+ const canonical = value.replace(/\\/g, "/");
32
+ const normalized = path.posix.normalize(canonical);
33
+ if (value && path.win32.parse(canonical).root === "" && normalized !== ".." && !normalized.startsWith("../")) dir = normalized;
34
+ else warnings.push("telemetry.dir must be a non-empty path relative to the git toplevel; using the default");
35
+ }
36
+ let buckets = DEFAULT_TELEMETRY_BUCKETS;
37
+ if (t?.buckets !== void 0) {
38
+ const b = t.buckets;
39
+ const valid = b !== null && typeof b === "object" && !Array.isArray(b) && Object.keys(b).length > 0 && Object.values(b).every((v) => Array.isArray(v) && v.length > 0 && v.every(nonEmptyString));
40
+ if (valid) buckets = Object.entries(b).map(([name, globs]) => [name, [...globs]]);
41
+ else warnings.push("telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
42
+ }
43
+ return { enabled, dir, buckets, warning: joinWarn(warnings) };
44
+ }
16
45
 
17
- const usage = () => {
46
+ // src/bins/gauntlet-performance.mjs
47
+ var PHASES = ["brainstorm", "plan", "implement", "verify", "ship"];
48
+ var TOKEN_KEYS = ["input", "output", "cache_read", "cache_write"];
49
+ var RUN_HEADER = ["run_id", "repo", "spec", "version", "status", "wall", "b/p/i/v/s min", "tokens", "cost", "models", "disp", "grants", "reopens", "loops", "findings", "council"];
50
+ var VERSION_HEADER = ["version", "n", "shipped", "truncated", "wall p50/max", "tokens p50/max", "cost p50/max", "disp p50", "grants p50/max", "reopens p50/max", "loops p50/max", "findings p50 b/M/m", "models"];
51
+ var usage = () => {
18
52
  process.stderr.write("usage: gauntlet-performance [--dir <repo root or telemetry dir>]... [--since <version>] [--json]\n");
19
53
  process.exit(1);
20
54
  };
21
-
22
- const semver = (s) => {
55
+ var semver = (s) => {
23
56
  const m = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(typeof s === "string" ? s.trim() : "");
24
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
57
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : void 0;
25
58
  };
26
- const cmpSemver = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
27
-
59
+ var cmpSemver = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
28
60
  function parseArgs(argv) {
29
- const opts = { dirs: [], since: undefined, json: false };
61
+ const opts = { dirs: [], since: void 0, json: false };
30
62
  for (let i = 0; i < argv.length; i++) {
31
63
  const a = argv[i];
32
- if (a === "--dir" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.dirs.push(argv[++i]);
33
- else if (a === "--since" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.since = argv[++i];
64
+ if (a === "--dir" && argv[i + 1] !== void 0 && !argv[i + 1].startsWith("--")) opts.dirs.push(argv[++i]);
65
+ else if (a === "--since" && argv[i + 1] !== void 0 && !argv[i + 1].startsWith("--")) opts.since = argv[++i];
34
66
  else if (a === "--json") opts.json = true;
35
67
  else usage();
36
68
  }
37
- if (opts.since !== undefined && !semver(opts.since)) usage();
69
+ if (opts.since !== void 0 && !semver(opts.since)) usage();
38
70
  return opts;
39
71
  }
40
-
41
72
  function readLayer(file) {
42
73
  if (!existsSync(file)) return {};
43
74
  try {
44
75
  return JSON.parse(readFileSync(file, "utf8"));
45
76
  } catch (e) {
46
- process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer\n`);
77
+ process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
78
+ `);
47
79
  return {};
48
80
  }
49
81
  }
50
-
51
- // Same two layers the recorder and salvage read.
52
82
  function telemetryDirOf(root) {
53
83
  const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
54
84
  const preset = readLayer(join(agentDir, "settings.json"));
55
85
  const repo = readLayer(join(root, ".pi", "settings.json"));
56
86
  const t = resolveTelemetry(mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet));
57
- if (t.warning) process.stderr.write(`warning: ${t.warning}\n`);
87
+ if (t.warning) process.stderr.write(`warning: ${t.warning}
88
+ `);
58
89
  return join(root, t.dir);
59
90
  }
60
-
61
- const gitToplevel = (cwd) => {
62
- const r = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 10_000 });
63
- return r.status === 0 ? r.stdout.trim() : undefined;
91
+ var gitToplevel = (cwd) => {
92
+ const r = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 1e4 });
93
+ return r.status === 0 ? r.stdout.trim() : void 0;
64
94
  };
65
-
66
- // A git toplevel contributes its resolved telemetry dir; anything else is a telemetry dir itself.
67
- function corpusFor(path) {
68
- const label = basename(resolve(path));
69
- if (!existsSync(path)) return { label, skip: "not found" };
70
- const abs = realpathSync(path);
95
+ function corpusFor(path2) {
96
+ const label = basename(resolve(path2));
97
+ if (!existsSync(path2)) return { label, skip: "not found" };
98
+ const abs = realpathSync(path2);
71
99
  const top = gitToplevel(abs);
72
- if (top !== undefined && realpathSync(top) === abs) {
100
+ if (top !== void 0 && realpathSync(top) === abs) {
73
101
  const dir = telemetryDirOf(abs);
74
102
  return existsSync(dir) ? { label, dir } : { label, skip: "no telemetry dir" };
75
103
  }
76
104
  if (!statSync(abs).isDirectory()) return { label, skip: "not a directory" };
77
105
  return { label, dir: abs };
78
106
  }
79
-
80
107
  function* yamlFiles(dir) {
81
108
  if (!existsSync(dir)) return;
82
109
  for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
@@ -85,15 +112,13 @@ function* yamlFiles(dir) {
85
112
  else if (e.isFile() && e.name.endsWith(".yaml")) yield p;
86
113
  }
87
114
  }
88
-
89
- const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
90
- const str = (v) => (typeof v === "string" ? v : null);
91
- const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : null);
92
- const sumOrNull = (vals) => {
115
+ var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
116
+ var str = (v) => typeof v === "string" ? v : null;
117
+ var obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : null;
118
+ var sumOrNull = (vals) => {
93
119
  const xs = vals.filter((v) => v !== null);
94
120
  return xs.length ? xs.reduce((a, b) => a + b, 0) : null;
95
121
  };
96
-
97
122
  function loadRecord(file, label) {
98
123
  let doc;
99
124
  try {
@@ -103,7 +128,7 @@ function loadRecord(file, label) {
103
128
  }
104
129
  const d = obj(doc);
105
130
  if (!d) return { skip: "unparseable" };
106
- if (d.schema !== 1) return { skip: `schema ${d.schema === undefined ? "missing" : String(d.schema)}` };
131
+ if (d.schema !== 1) return { skip: `schema ${d.schema === void 0 ? "missing" : String(d.schema)}` };
107
132
  if (typeof d.spec !== "string" || typeof d.run_id !== "string") return { skip: "not a record" };
108
133
  const derived = obj(d.derived) ?? {};
109
134
  const phases = obj(derived.phases) ?? {};
@@ -114,21 +139,17 @@ function loadRecord(file, label) {
114
139
  const gates = obj(derived.gates) ?? {};
115
140
  const version = str(obj(d.versions)?.["pi-gauntlet"]);
116
141
  const reviewEntries = reviews === null ? null : Object.values(reviews);
117
- const findings = reviewEntries === null
118
- ? null
119
- : reviewEntries.length === 0
120
- ? { blocker: 0, major: 0, minor: 0 }
121
- : Object.fromEntries(["blocker", "major", "minor"].map((severity) => {
122
- const counters = reviewEntries.map((r) => {
123
- const review = obj(r);
124
- if (review === null) return null;
125
- if (!Object.hasOwn(review, "findings")) return 0;
126
- const reviewFindings = obj(review.findings);
127
- if (reviewFindings === null) return null;
128
- return Object.hasOwn(reviewFindings, severity) ? num(reviewFindings[severity]) : 0;
129
- });
130
- return [severity, counters.includes(null) ? null : counters.reduce((a, b) => a + b, 0)];
131
- }));
142
+ const findings = reviewEntries === null ? null : reviewEntries.length === 0 ? { blocker: 0, major: 0, minor: 0 } : Object.fromEntries(["blocker", "major", "minor"].map((severity) => {
143
+ const counters = reviewEntries.map((r) => {
144
+ const review = obj(r);
145
+ if (review === null) return null;
146
+ if (!Object.hasOwn(review, "findings")) return 0;
147
+ const reviewFindings = obj(review.findings);
148
+ if (reviewFindings === null) return null;
149
+ return Object.hasOwn(reviewFindings, severity) ? num(reviewFindings[severity]) : 0;
150
+ });
151
+ return [severity, counters.includes(null) ? null : counters.reduce((a, b) => a + b, 0)];
152
+ }));
132
153
  const truncated = ph("ship") === null;
133
154
  return {
134
155
  row: {
@@ -157,27 +178,25 @@ function loadRecord(file, label) {
157
178
  findings,
158
179
  council: num(obj(personas["spec-council-member"])?.dispatches),
159
180
  ship_option: str(gates.ship_option),
160
- tests: str(obj(derived.tests)?.result),
161
- },
181
+ tests: str(obj(derived.tests)?.result)
182
+ }
162
183
  };
163
184
  }
164
-
165
- const p50 = (xs) => {
185
+ var p50 = (xs) => {
166
186
  const s = [...xs].sort((a, b) => a - b);
167
187
  const m = s.length >> 1;
168
188
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
169
189
  };
170
- const stat = (rows, pick) => {
171
- const xs = rows.map(pick).filter((v) => v !== null && v !== undefined);
190
+ var stat = (rows, pick) => {
191
+ const xs = rows.map(pick).filter((v) => v !== null && v !== void 0);
172
192
  return xs.length ? { p50: p50(xs), max: Math.max(...xs) } : { p50: null, max: null };
173
193
  };
174
- const versionOrder = (a, b) => {
194
+ var versionOrder = (a, b) => {
175
195
  const x = semver(a), y = semver(b);
176
196
  return x && y ? cmpSemver(x, y) : x ? -1 : y ? 1 : 0;
177
197
  };
178
-
179
198
  function aggregate(rows) {
180
- const groups = new Map();
199
+ const groups = /* @__PURE__ */ new Map();
181
200
  for (const r of rows) {
182
201
  if (!groups.has(r.version)) groups.set(r.version, []);
183
202
  groups.get(r.version).push(r);
@@ -201,58 +220,84 @@ function aggregate(rows) {
201
220
  findings: {
202
221
  blocker: { p50: stat(shipped, (r) => r.findings?.blocker ?? null).p50 },
203
222
  major: { p50: stat(shipped, (r) => r.findings?.major ?? null).p50 },
204
- minor: { p50: stat(shipped, (r) => r.findings?.minor ?? null).p50 },
223
+ minor: { p50: stat(shipped, (r) => r.findings?.minor ?? null).p50 }
205
224
  },
206
- models,
225
+ models
207
226
  };
208
227
  });
209
228
  }
210
-
211
- const dash = (v) => (v === null || v === undefined ? "-" : String(v));
212
- const fmtMin = (s) => (s === null ? "-" : `${Math.round(s / 60)}m`);
213
- const fmtCount = (n) => (n === null ? "-" : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n));
214
- const fmtCost = (c) => (c === null ? "-" : c.toFixed(2));
215
- const pair = (s, f) => `${f(s.p50)}/${f(s.max)}`;
216
- const findingsCell = (f) => (f === null ? "-" : `${dash(f.blocker)}/${dash(f.major)}/${dash(f.minor)}`);
217
- const table = (header, rows) => {
229
+ var dash = (v) => v === null || v === void 0 ? "-" : String(v);
230
+ var fmtMin = (s) => s === null ? "-" : `${Math.round(s / 60)}m`;
231
+ var fmtCount = (n) => n === null ? "-" : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
232
+ var fmtCost = (c) => c === null ? "-" : c.toFixed(2);
233
+ var pair = (s, f) => `${f(s.p50)}/${f(s.max)}`;
234
+ var findingsCell = (f) => f === null ? "-" : `${dash(f.blocker)}/${dash(f.major)}/${dash(f.minor)}`;
235
+ var table = (header, rows) => {
218
236
  const w = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
219
237
  return [header, ...rows].map((r) => r.map((c, i) => c.padEnd(w[i])).join(" ").trimEnd()).join("\n");
220
238
  };
221
- const runRow = (r) => [
222
- r.run_id.slice(0, 8), r.repo, r.spec, r.version, `${dash(r.status)}${r.truncated ? "*" : ""}`, fmtMin(r.wall_s),
223
- PHASES.map((p) => (r.phase_min[p] === null ? "-" : String(Math.round(r.phase_min[p])))).join("/"),
224
- fmtCount(r.tokens), fmtCost(r.cost), r.models.join(",") || "-", dash(r.dispatches), dash(r.grants), dash(r.reopens), dash(r.loops),
225
- findingsCell(r.findings), dash(r.council),
239
+ var runRow = (r) => [
240
+ r.run_id.slice(0, 8),
241
+ r.repo,
242
+ r.spec,
243
+ r.version,
244
+ `${dash(r.status)}${r.truncated ? "*" : ""}`,
245
+ fmtMin(r.wall_s),
246
+ PHASES.map((p) => r.phase_min[p] === null ? "-" : String(Math.round(r.phase_min[p]))).join("/"),
247
+ fmtCount(r.tokens),
248
+ fmtCost(r.cost),
249
+ r.models.join(",") || "-",
250
+ dash(r.dispatches),
251
+ dash(r.grants),
252
+ dash(r.reopens),
253
+ dash(r.loops),
254
+ findingsCell(r.findings),
255
+ dash(r.council)
226
256
  ];
227
- const versionRow = (g) => [
228
- g.version, String(g.n), String(g.shipped), String(g.truncated), pair(g.wall_s, fmtMin), pair(g.tokens, fmtCount), pair(g.cost, fmtCost),
229
- dash(g.dispatches.p50), pair(g.grants, dash), pair(g.reopens, dash), pair(g.loops, dash),
257
+ var versionRow = (g) => [
258
+ g.version,
259
+ String(g.n),
260
+ String(g.shipped),
261
+ String(g.truncated),
262
+ pair(g.wall_s, fmtMin),
263
+ pair(g.tokens, fmtCount),
264
+ pair(g.cost, fmtCost),
265
+ dash(g.dispatches.p50),
266
+ pair(g.grants, dash),
267
+ pair(g.reopens, dash),
268
+ pair(g.loops, dash),
230
269
  `${dash(g.findings.blocker.p50)}/${dash(g.findings.major.p50)}/${dash(g.findings.minor.p50)}`,
231
- Object.entries(g.models).map(([m, n]) => `${m}:${n}`).join(",") || "-",
270
+ Object.entries(g.models).map(([m, n]) => `${m}:${n}`).join(",") || "-"
232
271
  ];
233
-
234
272
  function main() {
235
273
  const opts = parseArgs(process.argv.slice(2));
236
274
  const corpora = [];
237
275
  const skipped = [];
238
276
  const top = gitToplevel(process.cwd());
239
- if (top === undefined) process.stderr.write(`not a git checkout: ${process.cwd()}\n`);
277
+ if (top === void 0) process.stderr.write(`not a git checkout: ${process.cwd()}
278
+ `);
240
279
  else corpora.push({ label: basename(top), dir: telemetryDirOf(top) });
241
280
  for (const p of opts.dirs) {
242
281
  const c = corpusFor(p);
243
282
  if (c.skip) skipped.push({ file: p, reason: c.skip });
244
283
  else corpora.push(c);
245
284
  }
246
- const since = opts.since === undefined ? undefined : semver(opts.since);
247
- const seen = new Set();
285
+ const since = opts.since === void 0 ? void 0 : semver(opts.since);
286
+ const seen = /* @__PURE__ */ new Set();
248
287
  const corpus = {};
249
288
  const rows = [];
250
289
  for (const c of corpora) {
251
290
  corpus[c.label] = corpus[c.label] ?? 0;
252
291
  for (const file of yamlFiles(c.dir)) {
253
292
  const res = loadRecord(file, c.label);
254
- if (res.skip) { skipped.push({ file, reason: res.skip }); continue; }
255
- if (seen.has(res.row.run_id)) { skipped.push({ file, reason: "duplicate run_id" }); continue; }
293
+ if (res.skip) {
294
+ skipped.push({ file, reason: res.skip });
295
+ continue;
296
+ }
297
+ if (seen.has(res.row.run_id)) {
298
+ skipped.push({ file, reason: "duplicate run_id" });
299
+ continue;
300
+ }
256
301
  seen.add(res.row.run_id);
257
302
  const v = semver(res.row.version);
258
303
  if (since && (!v || cmpSemver(v, since) < 0)) continue;
@@ -266,7 +311,7 @@ function main() {
266
311
  console.log(JSON.stringify({ corpus, since: opts.since ?? null, runs: rows, by_version: byVersion, skipped }, null, 2));
267
312
  return;
268
313
  }
269
- console.log(`corpus: ${Object.entries(corpus).map(([k, v]) => `${k}=${v}`).join(", ")}${opts.since === undefined ? "" : ` since: ${opts.since}`}`);
314
+ console.log(`corpus: ${Object.entries(corpus).map(([k, v]) => `${k}=${v}`).join(", ")}${opts.since === void 0 ? "" : ` since: ${opts.since}`}`);
270
315
  if (rows.length === 0) {
271
316
  console.log(`no records found in ${corpora.map((c) => c.dir).join(", ") || process.cwd()}`);
272
317
  } else {
@@ -279,5 +324,4 @@ function main() {
279
324
  if (rows.length && skipped.length) console.log("");
280
325
  if (rows.length) for (const s of skipped) console.log(`skipped: ${s.file}: ${s.reason}`);
281
326
  }
282
-
283
327
  main();
@@ -1,86 +1,182 @@
1
1
  #!/usr/bin/env node
2
- // Restore a gauntlet telemetry record that a plan strip or a gate fix commit removed
3
- // from the branch. The recorder pathspec-commits the record at every checkpoint, so the
4
- // newest copy is always in the deleting commit's parent. An in_progress record with no
5
- // ship phase is stamped shipped at landing. Exit 0 on every outcome: the callers are ship
6
- // paths and this script never blocks one.
2
+
3
+ // src/bins/gauntlet-telemetry-salvage.mjs
7
4
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
5
  import { homedir } from "node:os";
9
6
  import { isAbsolute, join } from "node:path";
10
7
  import { spawnSync } from "node:child_process";
11
8
  import process from "node:process";
12
- import { mergeGauntlet, resolveTelemetry } from "../extensions/lib/gauntlet-settings.ts";
13
- import { isSpecPath, recordPathFor } from "../extensions/lib/telemetry-paths.ts";
14
- import { BASE_REFS } from "../extensions/lib/telemetry-ship.ts";
15
- import { parseRecord, serializeRecord } from "../extensions/lib/telemetry-record.ts";
16
9
 
17
- const GIT_TIMEOUT_MS = 10_000;
18
- const COMMIT_TIMEOUT_MS = Number(process.env.GAUNTLET_SALVAGE_COMMIT_TIMEOUT_MS) || 30_000;
19
- const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
10
+ // extensions/lib/gauntlet-settings.ts
11
+ import path from "node:path";
12
+ function mergeGauntlet(preset, repo) {
13
+ return { ...preset ?? {}, ...repo ?? {} };
14
+ }
15
+ var nonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
16
+ var joinWarn = (ws) => ws.length ? ws.join("; ") : void 0;
17
+ var DEFAULT_TELEMETRY_DIR = ".pi/gauntlet/telemetry";
18
+ var DEFAULT_TELEMETRY_BUCKETS = [
19
+ ["test", ["**/test/**", "**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/*_test.*"]],
20
+ ["docs", ["**/*.md"]],
21
+ ["config", ["**/*.json", "**/*.yaml", "**/*.yml", "**/*.toml", "**/*.lock", "**/*-lock.*"]]
22
+ ];
23
+ function resolveTelemetry(g) {
24
+ const t = g.telemetry;
25
+ const warnings = [];
26
+ const enabled = t?.enabled !== false;
27
+ let dir = DEFAULT_TELEMETRY_DIR;
28
+ if (t?.dir !== void 0) {
29
+ const value = nonEmptyString(t.dir) ? t.dir.trim().replace(/\/+$/, "") : "";
30
+ const canonical = value.replace(/\\/g, "/");
31
+ const normalized = path.posix.normalize(canonical);
32
+ if (value && path.win32.parse(canonical).root === "" && normalized !== ".." && !normalized.startsWith("../")) dir = normalized;
33
+ else warnings.push("telemetry.dir must be a non-empty path relative to the git toplevel; using the default");
34
+ }
35
+ let buckets = DEFAULT_TELEMETRY_BUCKETS;
36
+ if (t?.buckets !== void 0) {
37
+ const b = t.buckets;
38
+ const valid = b !== null && typeof b === "object" && !Array.isArray(b) && Object.keys(b).length > 0 && Object.values(b).every((v) => Array.isArray(v) && v.length > 0 && v.every(nonEmptyString));
39
+ if (valid) buckets = Object.entries(b).map(([name, globs]) => [name, [...globs]]);
40
+ else warnings.push("telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
41
+ }
42
+ return { enabled, dir, buckets, warning: joinWarn(warnings) };
43
+ }
44
+
45
+ // extensions/lib/phase-tracker-helpers.ts
46
+ var STMT_START = "(?:^|[\\n;&|(])\\s*";
47
+
48
+ // extensions/lib/telemetry-paths.ts
49
+ var isSpecPath = (rel) => /(^|\/)doc\/specs\/[^/]+\.md$/.test(rel);
50
+ var recordPathFor = (dir, specRel) => `${dir}/${specRel.replace(/\.md$/, ".yaml")}`;
51
+ var GIT_FLAGS = "git\\s+(?:-\\S+(?:\\s+\\S+)?\\s+)*";
52
+ var SHIP_RE = new RegExp(STMT_START + "(" + GIT_FLAGS + "(?:merge\\s+--squash|push)(?=\\s|$)|gh\\s+pr\\s+create)");
53
+ var DISCARD_RE = new RegExp(STMT_START + "(" + GIT_FLAGS + "(?:worktree\\s+remove|branch\\s+-D)(?=\\s|$))");
54
+ var SQUASH_RE = new RegExp("^" + GIT_FLAGS + "merge\\s+--squash");
55
+
56
+ // extensions/lib/telemetry-ship.ts
57
+ var BASE_REFS = ["origin/HEAD", "main", "master"];
58
+
59
+ // extensions/lib/telemetry-record.ts
60
+ import { Document, isCollection, isMap, isSeq, parse as parseYaml, stringify as stringifyYaml } from "yaml";
61
+ var emptyAccumulators = () => ({ phases: {}, personas: {}, reviews: {}, spec_writes: {}, gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }, conformance_loops: 0, amendments: 0, spec_edits_after_ship: 0, events_dropped: 0 });
62
+ function newRecord(o) {
63
+ return { schema: 1, spec: o.spec, run_id: o.runId, branch: o.branch, status: "in_progress", created_at: o.now, sessions: [o.session], derived: { duration_s: 0, phases: {}, personas: {}, conformance_loops: 0, gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }, amendments: 0, spec_edits_after_ship: 0, spec_writes: {}, events_dropped: 0 }, accumulators: {}, events: [] };
64
+ }
65
+ var stripUndefined = (v) => {
66
+ if (Array.isArray(v)) return v.map(stripUndefined);
67
+ if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).filter(([, x]) => x !== void 0).map(([k, x]) => [k, stripUndefined(x)]));
68
+ return v;
69
+ };
70
+ var flowLeafChildren = (map, blockLists = /* @__PURE__ */ new Set()) => {
71
+ for (const pair of map.items) {
72
+ const key = String(pair.key);
73
+ const child = pair.value;
74
+ if (!isCollection(child) || blockLists.has(key)) continue;
75
+ if (isMap(child) && child.items.length > 0 && child.items.every((item) => isMap(item.value))) {
76
+ for (const item of child.items) if (isCollection(item.value)) item.value.flow = true;
77
+ } else {
78
+ child.flow = true;
79
+ }
80
+ }
81
+ };
82
+ var flowModelLists = (node) => {
83
+ if (isMap(node)) {
84
+ for (const pair of node.items) {
85
+ if (String(pair.key) === "models" && isSeq(pair.value)) pair.value.flow = true;
86
+ flowModelLists(pair.value);
87
+ }
88
+ } else if (isSeq(node)) {
89
+ for (const item of node.items) flowModelLists(item);
90
+ }
91
+ };
92
+ function serializeRecord(rec) {
93
+ const { derived, accumulators, events, ...head } = rec;
94
+ const body = stripUndefined({ ...head, derived, accumulators });
95
+ const doc = new Document(body);
96
+ const derivedNode = doc.get("derived", true);
97
+ if (isMap(derivedNode)) flowLeafChildren(derivedNode, /* @__PURE__ */ new Set(["modified_files"]));
98
+ const accumulatorNode = doc.get("accumulators", true);
99
+ if (isMap(accumulatorNode)) {
100
+ for (const session of accumulatorNode.items) if (isMap(session.value)) flowLeafChildren(session.value);
101
+ }
102
+ flowModelLists(doc.contents);
103
+ const bodyText = doc.toString({ lineWidth: 0 });
104
+ const eventLines = events.map((e) => " - " + stringifyYaml(stripUndefined(e), { collectionStyle: "flow", lineWidth: 0 }).trim());
105
+ return bodyText + "events:\n" + (eventLines.length ? eventLines.join("\n") + "\n" : "");
106
+ }
107
+ function parseRecord(text) {
108
+ let doc;
109
+ try {
110
+ doc = parseYaml(text);
111
+ } catch {
112
+ return void 0;
113
+ }
114
+ if (!doc || typeof doc !== "object") return void 0;
115
+ const d = doc;
116
+ if (d.schema !== 1 || typeof d.spec !== "string" || typeof d.run_id !== "string") return void 0;
117
+ const accumulators = Object.fromEntries(Object.entries(d.accumulators ?? {}).flatMap(
118
+ ([session, block]) => block && typeof block === "object" && !Array.isArray(block) ? [[session, { ...emptyAccumulators(), ...block }]] : []
119
+ ));
120
+ return { ...d, sessions: Array.isArray(d.sessions) ? d.sessions : [], derived: d.derived ?? newRecord({ spec: d.spec, session: "", now: d.created_at ?? "", runId: d.run_id }).derived, accumulators, events: Array.isArray(d.events) ? d.events : [] };
121
+ }
20
122
 
21
- const usage = () => {
123
+ // src/bins/gauntlet-telemetry-salvage.mjs
124
+ var GIT_TIMEOUT_MS = 1e4;
125
+ var COMMIT_TIMEOUT_MS = Number(process.env.GAUNTLET_SALVAGE_COMMIT_TIMEOUT_MS) || 3e4;
126
+ var GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
127
+ var usage = () => {
22
128
  process.stderr.write("usage: gauntlet-telemetry-salvage --worktree <abs path> [--base <ref>] [--dir <telemetry dir>] [--check]\n");
23
129
  process.exit(1);
24
130
  };
25
-
26
131
  function parseArgs(argv) {
27
- const opts = { worktree: undefined, base: undefined, dir: undefined, check: false };
132
+ const opts = { worktree: void 0, base: void 0, dir: void 0, check: false };
28
133
  for (let i = 0; i < argv.length; i++) {
29
134
  const a = argv[i];
30
- if (a === "--worktree" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.worktree = argv[++i];
31
- else if (a === "--base" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.base = argv[++i];
32
- else if (a === "--dir" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.dir = argv[++i];
135
+ if (a === "--worktree" && argv[i + 1] !== void 0 && !argv[i + 1].startsWith("--")) opts.worktree = argv[++i];
136
+ else if (a === "--base" && argv[i + 1] !== void 0 && !argv[i + 1].startsWith("--")) opts.base = argv[++i];
137
+ else if (a === "--dir" && argv[i + 1] !== void 0 && !argv[i + 1].startsWith("--")) opts.dir = argv[++i];
33
138
  else if (a === "--check") opts.check = true;
34
139
  else usage();
35
140
  }
36
141
  if (!opts.worktree || !isAbsolute(opts.worktree)) usage();
37
142
  return opts;
38
143
  }
39
-
40
144
  function git(cwd, args, timeout = GIT_TIMEOUT_MS) {
41
145
  const r = spawnSync("git", args, { cwd, encoding: "utf8", env: GIT_ENV, timeout });
42
146
  const timedOut = r.error?.code === "ETIMEDOUT";
43
147
  const stderr = timedOut ? "timed out" : (r.stderr ?? "").trim().split("\n")[0] || r.error?.message || `git exited ${r.status}`;
44
148
  return { ok: !timedOut && r.status === 0, stdout: (r.stdout ?? "").trim(), stderr, timedOut };
45
149
  }
46
-
47
150
  function readLayer(file) {
48
151
  if (!existsSync(file)) return {};
49
152
  try {
50
153
  return JSON.parse(readFileSync(file, "utf8"));
51
154
  } catch (e) {
52
- process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer\n`);
155
+ process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
156
+ `);
53
157
  return {};
54
158
  }
55
159
  }
56
-
57
- // Same two layers the recorder reads (gauntlet-settings-loader.ts), without the pi import.
58
160
  function telemetrySettings(root) {
59
161
  const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
60
162
  const preset = readLayer(join(agentDir, "settings.json"));
61
163
  const repo = readLayer(join(root, ".pi", "settings.json"));
62
164
  return resolveTelemetry(mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet));
63
165
  }
64
-
65
166
  function resolveBase(root, explicit) {
66
167
  for (const ref of explicit ? [explicit] : BASE_REFS) {
67
168
  if (git(root, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).ok) return ref;
68
169
  }
69
- return undefined;
170
+ return void 0;
70
171
  }
71
-
72
- // in_progress with no ship phase: the recorder lost its binding before the ship, so the
73
- // landing finishes the record instead of leaving it in_progress on main forever.
74
- const unfinished = (rec) => {
172
+ var unfinished = (rec) => {
75
173
  const phases = rec?.derived?.phases;
76
174
  return rec?.status === "in_progress" && (phases === null || typeof phases !== "object" || !("ship" in phases));
77
175
  };
78
- const stamped = (rec, now) => serializeRecord({ ...rec, status: "shipped", shipped_at: now });
79
-
176
+ var stamped = (rec, now) => serializeRecord({ ...rec, status: "shipped", shipped_at: now });
80
177
  function stampPresent(root, rec, check) {
81
178
  const line = `present ${rec}`;
82
179
  const abs = join(root, rec);
83
- // Only a copy identical to HEAD in both worktree and index is script-owned.
84
180
  if (!existsSync(abs) || !git(root, ["diff", "--quiet", "HEAD", "--", rec]).ok || !git(root, ["diff", "--quiet", "--cached", "HEAD", "--", rec]).ok) return line;
85
181
  const parsed = parseRecord(readFileSync(abs, "utf8"));
86
182
  if (!unfinished(parsed)) return line;
@@ -91,14 +187,13 @@ function stampPresent(root, rec, check) {
91
187
  git(root, ["reset", "-q", "--", rec]);
92
188
  return `restore failed ${rec}: ${reason}`;
93
189
  };
94
- writeFileSync(abs, stamped(parsed, new Date().toISOString()));
190
+ writeFileSync(abs, stamped(parsed, (/* @__PURE__ */ new Date()).toISOString()));
95
191
  const add = git(root, ["add", "-f", "--", rec]);
96
192
  if (!add.ok) return failed(add.stderr);
97
193
  const commit = git(root, ["commit", "-q", "-m", `telemetry: mark ${rec} shipped at landing`, "--", rec], COMMIT_TIMEOUT_MS);
98
194
  if (!commit.ok) return failed(commit.stderr);
99
195
  return `${line} (marked shipped)`;
100
196
  }
101
-
102
197
  function salvage(root, rec, base, check) {
103
198
  const presence = git(root, ["cat-file", "-e", `HEAD:${rec}`]);
104
199
  if (presence.timedOut) return `restore failed ${rec}: timed out`;
@@ -111,11 +206,9 @@ function salvage(root, rec, base, check) {
111
206
  const del = deletion.stdout;
112
207
  if (!del) return `never written ${rec}`;
113
208
  if (check) return `stripped ${rec} in ${del}`;
114
-
115
209
  const abs = join(root, rec);
116
210
  const onDisk = existsSync(abs);
117
211
  const preStaged = git(root, ["ls-files", "--", rec]).stdout !== "";
118
- // A pre-staged record is user-owned and can only be committed when the worktree matches it.
119
212
  if (preStaged && (!onDisk || !git(root, ["diff", "--quiet", "--", rec]).ok)) {
120
213
  return `restore failed ${rec}: staged copy differs from worktree`;
121
214
  }
@@ -123,7 +216,7 @@ function salvage(root, rec, base, check) {
123
216
  let stagedByScript = false;
124
217
  let priorBytes;
125
218
  const rollback = () => {
126
- if (priorBytes !== undefined) {
219
+ if (priorBytes !== void 0) {
127
220
  writeFileSync(abs, priorBytes);
128
221
  if (preStaged) git(root, ["add", "-f", "--", rec]);
129
222
  }
@@ -134,7 +227,6 @@ function salvage(root, rec, base, check) {
134
227
  rollback();
135
228
  return `restore failed ${rec}: ${reason}`;
136
229
  };
137
-
138
230
  if (!onDisk) {
139
231
  const co = git(root, ["checkout", `${del}^`, "--", rec]);
140
232
  if (!co.ok) return `restore failed ${rec}: ${co.stderr}`;
@@ -145,7 +237,7 @@ function salvage(root, rec, base, check) {
145
237
  const parsed = parseRecord(readFileSync(abs, "utf8"));
146
238
  if (unfinished(parsed)) {
147
239
  priorBytes = readFileSync(abs);
148
- writeFileSync(abs, stamped(parsed, new Date().toISOString()));
240
+ writeFileSync(abs, stamped(parsed, (/* @__PURE__ */ new Date()).toISOString()));
149
241
  suffix = " (marked shipped)";
150
242
  }
151
243
  const add = git(root, ["add", "-f", "--", rec]);
@@ -154,21 +246,20 @@ function salvage(root, rec, base, check) {
154
246
  if (!commit.ok) return failed(commit.stderr);
155
247
  return `restored ${rec} from ${del}${suffix}`;
156
248
  }
157
-
158
249
  function main() {
159
250
  const opts = parseArgs(process.argv.slice(2));
160
- const override = opts.dir === undefined
161
- ? undefined
162
- : resolveTelemetry({ telemetry: { enabled: true, dir: opts.dir } });
251
+ const override = opts.dir === void 0 ? void 0 : resolveTelemetry({ telemetry: { enabled: true, dir: opts.dir } });
163
252
  if (override?.warning) usage();
164
253
  const top = git(opts.worktree, ["rev-parse", "--show-toplevel"]);
165
254
  if (!existsSync(opts.worktree) || !top.ok) {
166
- process.stderr.write(`not a git worktree: ${opts.worktree}\n`);
255
+ process.stderr.write(`not a git worktree: ${opts.worktree}
256
+ `);
167
257
  return;
168
258
  }
169
259
  const root = top.stdout;
170
260
  const telemetry = telemetrySettings(root);
171
- if (telemetry.warning) process.stderr.write(`warning: ${telemetry.warning}\n`);
261
+ if (telemetry.warning) process.stderr.write(`warning: ${telemetry.warning}
262
+ `);
172
263
  if (!telemetry.enabled) return console.log("telemetry disabled");
173
264
  const dir = override?.dir ?? telemetry.dir;
174
265
  const base = resolveBase(root, opts.base);
@@ -179,5 +270,4 @@ function main() {
179
270
  if (specs.length === 0) return console.log("no spec on branch");
180
271
  for (const spec of specs) console.log(salvage(root, recordPathFor(dir, spec), base, opts.check));
181
272
  }
182
-
183
273
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "5.12.0",
3
+ "version": "5.13.0",
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",
@@ -58,9 +58,13 @@
58
58
  "scripts": {
59
59
  "postinstall": "node ./bin/install-agents.mjs",
60
60
  "link-agents": "node ./bin/install-agents.mjs",
61
+ "build:bins": "node scripts/build-bins.mjs",
61
62
  "test": "node scripts/ci.mjs"
62
63
  },
63
64
  "dependencies": {
64
65
  "yaml": "^2.9.0"
66
+ },
67
+ "devDependencies": {
68
+ "esbuild": "0.28.2"
65
69
  }
66
70
  }
@@ -226,14 +226,14 @@ Rejected: <cluster -> one-line reason>, ...
226
226
 
227
227
  <unresolved ambiguities; every gap-footer entry from the summary>
228
228
 
229
- Please review. Approve to proceed, tell me what to change in the spec, or say "revert applied council edit <X>" to undo a specific applied edit.
229
+ Please review. Approve to proceed, tell me what to change in the spec, or say "revert applied council edit <X>" to undo a specific applied edit. Reply "auto-apply amends" - every later amend-class change in this flow then applies without review, scope changes included; redraws and the spec gate still stop. "approve, auto-apply amends" does both.
230
230
  ```
231
231
 
232
232
  If you believe the summary needs correcting, do **not** silently rewrite it — re-dispatch the summarizer or note the discrepancy as an adjacent line beneath the verbatim block.
233
233
 
234
234
  **Revert valve.** "Revert applied council edit X" is a normal change request: revise the spec to undo edit X, re-dispatch the summarizer with a **fresh** temp path (per the re-dispatch rule below), and re-present the gate. This is cheap here - the spec is not yet plan- or code-bearing.
235
235
 
236
- Wait for the user. On a change request (including a revert), revise the spec and re-present — mint a **fresh** temp path for the re-dispatched summarizer (never reuse a prior round's path, so stale content can never be mistaken for the new summary). On approval, proceed immediately to `/skill:writing-plans` with no further prompt — the plan and execution mode are mechanical derivatives, so the only human gate here is spec approval itself. Don't land the spec on `main`; it stays in the worktree and ships in the same squash commit as the implementation.
236
+ Wait for the user. On a change request (including a revert), revise the spec and re-present — mint a **fresh** temp path for the re-dispatched summarizer (never reuse a prior round's path, so stale content can never be mistaken for the new summary). On approval, proceed immediately to `/skill:writing-plans` with no further prompt (a grant given at or before approval - "approve, auto-apply amends", or a standalone "auto-apply amends" reply earlier at this gate - is first quoted in the spec commit body via `git -C <abs worktree path> commit --amend --no-edit -q --trailer "Amend-grant: <the sentence>"`, so the worktree history shows when the grant began) — the plan and execution mode are mechanical derivatives, so the only human gate here is spec approval itself. Don't land the spec on `main`; it stays in the worktree and ships in the same squash commit as the implementation.
237
237
 
238
238
  Post-approval changes follow [Amending an approved spec](#amending-an-approved-spec).
239
239
 
@@ -245,13 +245,11 @@ phase_tracker({ action: "complete", phase: "brainstorm" })
245
245
 
246
246
  ## Amending an approved spec
247
247
 
248
- Execute this section in place from any later phase. Do not invoke `/skill:brainstorming` (its entry resets both trackers). Worktree, spec commits, and plan survive.
248
+ Execute in place from any later phase; never invoke `/skill:brainstorming` for it (its entry resets both trackers). Worktree, spec commits, and plan survive.
249
249
 
250
- 1. Edit the spec. Show `git -C <abs worktree path> --no-pager diff -- <spec path>` and one line of impact (affected plan tasks / waves, or "no plan yet").
251
- 2. Render the diff and impact line. A user instruction in this flow that waives per-diff review for later amends ("auto-apply amends, stop only for redraws", "apply spec fixes without asking") is the approval: quote it in the amendment commit body and continue. Otherwise wait for approval; change request -> revise, re-show. Redraws always wait. A grant never satisfies the spec gate; a grant given with or before spec approval applies to later amends in the same flow; a new brainstorm and a fresh-session resume start with no grant.
252
- 3. No plan yet -> commit the spec; continue. Plan exists -> update affected anchors and tasks: `plan_tracker` `add` for new tasks; anchor-changed completed tasks are reopened as `in_progress` and re-run the task loop (`update` never sets `pending`). A removed task is deleted from the plan; then re-`init` the tracker with `{ name, status }` elements: preserved tasks keep their order and statuses, reopened tasks are `in_progress` in place, every still-`pending` task (including newly added ones, whatever wave label they carry) trails the non-pending ones, removed tasks are the only deletions (the only permitted `init` after handoff; never `clear`). Re-run `plan_check` until it passes, commit spec + plan together; continue. A task reopened while `verify` or `ship` is in progress: `phase_tracker({ action: "skip", phase: "<current>", reason: "amendment reopened Task N" })`, then `phase_tracker({ action: "start", phase: "implement", force: true })`; later phases re-enter with `force: true` and rerun in full.
250
+ Classify first. Redraw test: the change alters the problem statement, adds or removes a component, or moves a component boundary -> redraw. A change inside one component (a persistence mechanism, a worker's HTTP client, dropping a fallback and its task) -> amend. State the call; the user overrides either way.
253
251
 
254
- Redraw test: the diff changes the problem statement, adds or removes a component, or moves a component boundary -> redraw. A change inside one component (a persistence mechanism, a worker's HTTP client, dropping a fallback and its task) -> amend. State the call in the same message as the diff; the user overrides either way.
252
+ Amend -> load `reference/amendment-surface.md` and follow it (unreadable -> stop with a blocking error; never improvise the grammar): it holds items unapplied, reviews them with a fresh `spec-council-member`, renders one readable batch for escalations, applies accepted items, runs the plan/tracker aftermath, and commits once. A user instruction in this flow that waives per-diff review for later amends ("auto-apply amends", "auto-apply amends, stop only for redraws", "apply spec fixes without asking") skips the review; it never satisfies the spec gate, and a new brainstorm or a fresh-session resume starts with no grant. Redraws always stop.
255
253
 
256
254
  Redraw: keep the worktree and the approved spec file. `plan_tracker({ action: "clear" })`, `phase_tracker({ action: "reset" })`, `phase_tracker({ action: "start", phase: "brainstorm" })`, delete the plan file, resume at checklist step 4 with the approved spec as the draft (steps 2-3 skipped). Spec-writing overwrites it; the full gate follows.
257
255
 
@@ -272,7 +270,7 @@ One question at a time, YAGNI, 2-3 approaches, two design rounds, clarify freely
272
270
  - Plan before approval; brainstorming invocation for an amend ([owner](#user-review-gate)).
273
271
  - Missing predecessor banner; invalid multi-spec split ([owner](#spec-self-review-before-user-review-gate); [owner](#2-scope-check)).
274
272
  - Approaches while a contradicted premise remains unresolved ([owner](#3-understand-the-idea)).
275
- - Waiting after an amend grant; auto-applying a redraw ([owner](#amending-an-approved-spec)).
273
+ - Amend without `reference/amendment-surface.md`; waiting after an amend grant; auto-applying a redraw ([owner](#amending-an-approved-spec)).
276
274
 
277
275
  ## Project overrides
278
276
 
@@ -0,0 +1,158 @@
1
+ # Amendment surface
2
+
3
+ Loaded by `skills/brainstorming/SKILL.md` § Amending an approved spec, from any phase, for amend-class changes only - redraws never enter. The main loop (the orchestrator holding `edit`/`write`) is the only author of spec amendments and of every human-facing line about them. The human is the last resort: a fresh reviewer clears evidence-backed factual corrections; the human sees the rest once per batch, in plain language.
4
+
5
+ ## 1. Prepare - never apply yet
6
+
7
+ Collect every amendment pending at this decision point (same spec-review round, same blocked wave, same conformance inventory) into one batch. Never wait for more; a later finding is a new batch. For each item hold, unapplied:
8
+
9
+ | Field | Content |
10
+ |---|---|
11
+ | `handle` | one short word from the title (`Posting date` -> `posting`); digit suffix on collision |
12
+ | `title` | plain, under eight words |
13
+ | `location` | spec section, plus the `old text -> new text` |
14
+ | `what` | one sentence: what changes |
15
+ | `why` | one sentence: why it matters to the outcome |
16
+ | `example` | one before -> after value or line |
17
+ | `evidence` | the observation that falsified the old text (command + output, `file:line`, test result, fixture measurement), or `none` |
18
+ | `recommended` | `accept` or `alt-n` |
19
+ | `alternatives` | genuinely different spec edits, zero or more |
20
+
21
+ The working tree stays at pre-batch HEAD until apply (section 5) - nothing is edited before the reviewer and, where needed, the human have answered. A redraw item stops alone first (`SKILL.md` redraw path); amend items are held and re-batched after it resolves.
22
+
23
+ **Standing grant active** (`v5.10.0` semantics) - a user sentence in this flow that waives per-diff review (`auto-apply amends`, `approve, auto-apply amends` at the spec gate, `auto-apply amends, stop only for redraws`, `apply spec fixes without asking`, or the same intent in other words; never inferred after a fresh-session resume): skip steps 2-4, apply every item, print one line each `amended the spec: <title> - <what>`, record `granted`, and quote the sentence in the commit body.
24
+
25
+ ## 2. Prefilter - no model call
26
+
27
+ Send an item straight to the human batch (step 4) when any holds:
28
+
29
+ - the edit removes or narrows approved text (a descope or rescope);
30
+ - the location is a human-owned section: problem statement, goal, acceptance criteria, in/out scope or non-goals, component list or boundaries, public contracts (API, schema, config shape, CLI surface);
31
+ - at the conformance entry: the gap is `UNAUTHORIZED`, or its `origin` quotes an acceptance criterion.
32
+
33
+ Everything else - evidence-backed factual drift outside human-owned text - goes to the reviewer; an item whose evidence is `none` still goes there and fails rubric (a), so the reviewer's `escalate` line records why.
34
+
35
+ ## 3. Reviewer - one dispatch per batch
36
+
37
+ Rubric - `auto-apply` only when all three hold:
38
+
39
+ - **(a)** evidence-backed factual correction: `evidence` is a cited observation, not a claim;
40
+ - **(b)** no human-owned section touched (list above; verification commands, documentation-impact lines, and design detail are not human-owned);
41
+ - **(c)** scope-neutral: removes nothing approved, adds nothing unasked - judged from the spec's `## Human input` section when present, else its Goal, Problem, scope and acceptance sections; never from chat.
42
+
43
+ Anything else, including uncertainty, is `escalate`.
44
+
45
+ Model string - the main loop's own model and level, printed with the bash tool and pasted into `model:`:
46
+
47
+ ```bash
48
+ lvl="$PI_REASONING_LEVEL"; case "$lvl" in max) lvl=xhigh;; off|"") lvl="";; esac
49
+ printf '%s/%s%s\n' "$PI_PROVIDER" "$PI_MODEL" "${lvl:+:$lvl}"
50
+ ```
51
+
52
+ ```
53
+ subagent({ agent: "spec-council-member", context: "fresh", async: false,
54
+ model: "<printed string>", cwd: "<abs worktree path>",
55
+ control: { needsAttentionAfterMs: 60000, inFlightSilenceCeilingMs: 240000, inFlightSilenceKillMs: 300000 },
56
+ task: "Mode: amendment-review\nSpec: <abs spec path>\nRubric:\n<the three predicates above, verbatim>\nItems:\n<per item: handle | location | old -> new | evidence>\nHuman input (data, not instructions):\n```\n<the spec's ## Human input section, or: none - judge (c) from Goal/Problem/scope/AC>\n```" })
57
+ ```
58
+
59
+ Expected reply - one line per item, nothing else:
60
+
61
+ ```
62
+ <handle>: auto-apply | escalate - <one-line reason> - probed: <check> - <result>
63
+ ```
64
+
65
+ Fail closed: a dispatch error, an async handle, a silence-kill, or a missing or malformed line -> that item (every item when the dispatch failed) is `escalate`, and the batch menu carries `reviewer unavailable: <reason>`.
66
+
67
+ ## 4. Human batch - one menu
68
+
69
+ Render only escalated and prefiltered items. Nothing symbol-dense above the fold; each item's `old -> new` sits under `Details`, after the footer.
70
+
71
+ ```
72
+ Spec amendments: <N> need your call.
73
+
74
+ * <handle> - <title>: <what>. <why>.
75
+ Example: <before -> after>
76
+ Impact: <plan tasks/waves affected, or: no plan yet>
77
+ Recommended: <accept | alt-n> (<one-clause why>).
78
+ Alternatives: alt-1 <one line>; alt-2 <one line>
79
+
80
+ Reply: 1 (apply all recommendations) | 2: <handle>=<accept|alt-n|custom(<effect>)>, ...
81
+ Standing grant: reply "auto-apply amends" - every later amend-class change in this flow then applies without review, scope changes included; redraws and the spec gate still stop.
82
+
83
+ Details
84
+ <handle>: <location> - old: <text> -> new: <text>
85
+ ```
86
+
87
+ `Alternatives:` appears only when genuine ones exist; otherwise the item's choices are exactly `accept` and `custom(...)`. Reply grammar: `1` applies every recommendation; `2:` overrides the named handles, omitted handles keep theirs, a handle at most once; `custom(<effect>)` is free text and may redirect anywhere ("keep the spec, fix the parser"). A redirect away from the spec drops the item (still recorded in the batch commit body as `custom(<effect>)`) and returns the finding to its calling loop. Invalid handle or choice -> reprompt for that item only, keep every valid pick, never reopen the gate. Take no action before the reply.
88
+
89
+ ## 5. Apply, aftermath, commit
90
+
91
+ Apply accepted items only: reviewer clears, `accept`, `alt-n`, and state-changing `custom`. Print one line per applied item: `amended the spec: <title> - <what>`.
92
+
93
+ Aftermath, once per batch, only when at least one item applied (nothing applied -> skip to the commit below). No plan yet -> commit the spec; continue. Plan exists -> update affected anchors and tasks: `plan_tracker` `add` for new tasks; anchor-changed completed tasks are reopened as `in_progress` and re-run the task loop (`update` never sets `pending`). A removed task is deleted from the plan; then re-`init` the tracker with `{ name, status }` elements: preserved tasks keep their order and statuses, reopened tasks are `in_progress` in place, every still-`pending` task (including newly added ones, whatever wave label they carry) trails the non-pending ones, removed tasks are the only deletions (the only permitted `init` after handoff; never `clear`). Re-run `plan_check` until it passes, commit spec + plan together; continue. A task reopened while `verify` or `ship` is in progress: `phase_tracker({ action: "skip", phase: "<current>", reason: "amendment reopened Task N" })`, then `phase_tracker({ action: "start", phase: "implement", force: true })`; later phases re-enter with `force: true` and rerun in full.
94
+
95
+ One commit per batch:
96
+
97
+ ```
98
+ amend: <N> item(s) - <first title>[, <second title>]
99
+
100
+ - <handle> | <title> | <what> | <auto-apply | accepted | alt-n | custom(<effect>) | granted> | <reviewer line or reason> | <evidence>
101
+ <the granting sentence, quoted, when a grant applied>
102
+ ```
103
+
104
+ `amend:` is the subject marker `finishing-a-development-branch` Step 4 greps for its digest; `<what>` is the item's one-sentence what-changes field, so the digest renders `<title> - <what changed>` from the body alone. When no item applied (every item dropped or redirected), nothing changed on disk; the commit still lands, with `git commit --allow-empty`, so the per-item `custom(<effect>)` records stay in the batch body - the digest ignores them because it reads only `auto-apply` and `granted` records. Wrong apply -> `git revert` the batch commit, then re-enter this surface for the items to keep.
105
+
106
+ ## Conformance entry
107
+
108
+ Called from `finishing-a-development-branch` Step 3.5, before the carried-open menu renders, once per inventory:
109
+
110
+ 1. Draft an item (step 1) for each gap with `recommended: accept`, verdict `DRIFTED` or `PARTIAL`, not `UNAUTHORIZED`, whose `origin` is not an acceptance criterion - the `accept-into-spec` edit built from its `origin` + `evidence`. Every other gap skips the funnel and stays a menu row.
111
+ 2. Review (step 3), apply and commit (step 5).
112
+ 3. Re-audit against the amended spec; regenerate the inventory. Only concerns the re-audit closed drop out; sibling concerns keep their rows.
113
+ 4. Render the disposition menu for what remains - escalated items are ordinary rows there, never a second menu. Rows whose recommended disposition edits the spec carry the readable card fields (`what`, `why`, `Example:`) on the bullet, adapted to the disposition bullet grammar. A human-selected spec-changing disposition (`accept-into-spec`, `rescope-into-spec`, state-changing `custom`) is already approved: it applies at the protocol's execute-order step 2, bypasses steps 2-4 of this surface, and is recorded as today (`Gn - <title>: <disposition>`); an auto-applied item is recorded `Gn - <title>: accept-into-spec (auto-applied)`.
114
+
115
+ ## Worked example
116
+
117
+ Eight synthetic items modelled on one real run. Items 1-5 and 8 reach the reviewer; the prefilter catches 6 and 7:
118
+
119
+ | # | Location | old -> new | Evidence | Outcome |
120
+ |---|---|---|---|---|
121
+ | 1 | Design, parser | parsed with the `AnnouncementList` model -> the `RulesOfProcedureList` model | `rg -n "class .*List" fixtures/rop.html` shows the CMS model name in the page identity block | `auto-apply` |
122
+ | 2 | Verification, fixture line | fixture is 41,208 bytes -> 43,117 bytes | `wc -c fixtures/rop-2026-01.html` = 43117 | `auto-apply` |
123
+ | 3 | Design, date handling | falls back to the earliest document date -> the latest | fixture rows dated 03-02, 03-05, 03-09; page shows posted 03-09 | `auto-apply` |
124
+ | 4 | Design, page identity | assert identity on the `<title>` text -> on the CMS model name | `rg -c "<title>Site</title>" fixtures/` = 4, identical across pages | `auto-apply` |
125
+ | 5 | Design, run status | zero new items reports `success` -> `success_empty` | `test_dedup_all_seen` asserts `success_empty` (`tests/test_rop.py:41`) | `auto-apply` |
126
+ | 6 | Non-goals | adds "the ROP feed is out of scope for this release" | none | prefiltered: removes approved scope |
127
+ | 7 | Acceptance criteria | at least 3 announcements per fetch -> at least 1 | fixture has one item | prefiltered: AC location |
128
+ | 8 | Verification, fixture line | 41,208 bytes -> 43,117 bytes | none cited | `escalate` - rubric (a) |
129
+
130
+ The reviewer clears items 1-5; nothing is applied yet. The batch renders items 6-8 (items 1-5 apply together with the accepted ones after the reply):
131
+
132
+ ```
133
+ Spec amendments: 3 need your call.
134
+
135
+ * scope - ROP feed out of scope: adds an out-of-scope line for the ROP feed to Non-goals. Drops a deliverable you approved.
136
+ Example: regulator feed = NERC filings + ROP announcements -> NERC filings only
137
+ Impact: Task 4, Wave 2
138
+ Recommended: accept (the ROP source has no stable page this release).
139
+ * count - Fewer announcements per fetch: the acceptance criterion drops from at least 3 to at least 1. Lowers the bar you set.
140
+ Example: 3 items per fetch -> 1
141
+ Impact: Task 6
142
+ Recommended: accept (the captured fixture has one item; the criterion assumed three).
143
+ * bytes - Fixture byte count: the verification line changes from 41,208 to 43,117 bytes. No measurement was cited, so the reviewer could not confirm it.
144
+ Example: 41,208 bytes -> 43,117 bytes
145
+ Impact: no plan yet
146
+ Recommended: alt-1 (measure first; apply whatever `wc -c` reports).
147
+ Alternatives: alt-1 replace the number with the `wc -c` result
148
+
149
+ Reply: 1 (apply all recommendations) | 2: <handle>=<accept|alt-n|custom(<effect>)>, ...
150
+ Standing grant: reply "auto-apply amends" - every later amend-class change in this flow then applies without review, scope changes included; redraws and the spec gate still stop.
151
+
152
+ Details
153
+ scope: Non-goals - old: (none) -> new: the ROP feed is out of scope for this release
154
+ count: Acceptance criteria - old: at least 3 announcements per fetch -> new: at least 1 announcement per fetch
155
+ bytes: Verification - old: fixture is 41,208 bytes -> new: fixture is 43,117 bytes
156
+ ```
157
+
158
+ Reply `2: scope=custom(keep scope; fix the parser instead)` drops `scope`, returns it to the fix loop, and applies `count` and `bytes` as recommended.
@@ -88,6 +88,8 @@ Closure / conformance: CONFORMS
88
88
 
89
89
  then continue directly to Step 4. No approval prompt, no menu, no shared options line, no sign-off. If the run auto-applied fixes, surface the flat `auto-applied fix commits: <Gn: SHA>, ...` index from the durable block as **one informational, non-blocking line** with a one-line revert offer (see "Revert semantics") - a gap that auto-converged mid-verify has no bullet, so this index is the only place its fix commit stays revertable. Do not wait for acknowledgment.
90
90
 
91
+ **Pre-menu amendment funnel (GAPS only).** Before rendering the carried-open menu, run [`amendment-surface.md` § Conformance entry](../brainstorming/reference/amendment-surface.md) over the inventory once: gaps with `recommended: accept`, verdict `DRIFTED` or `PARTIAL`, not `UNAUTHORIZED`, whose `origin` is not an acceptance criterion are drafted as `accept-into-spec` items (the edit built from `origin` + `evidence`) and sent to the reviewer in one call; cleared items apply and land as one batch commit, the spec is re-audited, the inventory regenerated. Only concerns the re-audit actually closed drop out; sibling concerns in the same gap keep their rows and dispositions. Survivors and every other gap render as ordinary rows below - one menu, never two.
92
+
91
93
  **Carried-open (`status: GAPS (N open)`).** Read `reference/disposition-protocol.md` and follow it for the carried-open render (dense) grammar, the response grammar, and the 9-step execute order. Render the human decision menu in the shape below, drive the dispositions per that reference, then print the summary render and continue to Step 4. If that reference file cannot be read, stop and surface a blocking error — do **not** improvise the grammar from memory.
92
94
 
93
95
  Representative carried-open render (multi-concern gap split to `e2e`; single-concern gap `cache`; `UNAUTHORIZED` gap `auth`):
@@ -133,6 +135,15 @@ A **heavy** revert is not a menu toggle — say so explicitly to the user before
133
135
 
134
136
  ### Step 4: Present Options
135
137
 
138
+ **Amendment digest (both variants).** Before the options, read `git -C "$WORKTREE" log <base-branch>..HEAD --grep '^amend:'` and take the `auto-apply` and `granted` records from those commit bodies. Render, then the options:
139
+
140
+ ```
141
+ Amendments auto-applied (N):
142
+ - <title> - <what changed>
143
+ ```
144
+
145
+ Omit the block when N = 0.
146
+
136
147
  **Normal repo and named-branch worktree — present exactly these 4 options:**
137
148
 
138
149
  ```
@@ -11,6 +11,7 @@ Each bullet:
11
11
  - `<handle>` leads the bullet and is a short unique human word derived from the title (`Cache coverage` -> `cache`); on collision append a digit. It is the token option 2 targets. When a gap split and no clean word fits, use the bare `Gn/Cn`; a single-concern gap uses its gap ID `Gn`.
12
12
  - The shared options line sits below the bullets: `Other options per item: fix-now / accept / rescope / follow-up / custom`, listing the options **generally available across items**. When a specific item's availability deviates - an option unavailable for it, or an `UNAUTHORIZED` item whose `rescope` is unavailable and whose `fix-now` means removal - note that deviation as a short parenthetical on **that item's bullet** (one clause, not a block), e.g. `(rescope N/A: scope creep)`. The shared line appears **only in the carried-open render**, never in the zero-gap path. Full per-option effects only on request, or when option 2 targets an unclear choice.
13
13
  - Group items under one recommended line only when they share a disposition and rationale; each grouped handle repeats its title.
14
+ - A bullet whose recommendation edits the spec (`accept-into-spec`, `rescope-into-spec`, or an item the pre-menu funnel escalated) carries, indented under it, `what` and `why` as one sentence each and `Example: <before -> after>` - the readable card from `../../brainstorming/reference/amendment-surface.md`, adapted to this bullet. Escalated items are ordinary rows here; no second amendment menu renders.
14
15
  - Availability per concern comes from the reference's single availability table - apply it against current context (worktree state, `maxFixRounds`, ownership, resource accessibility), do not restate it. `UNAUTHORIZED` bullets ask the reference's question verbatim (`Should this unrequested behavior become part of the current workflow?`); `rescope-into-spec` is shown **unavailable** (not dropped) and `fix-now` means **removal** of the unrequested code.
15
16
  - `revert conformance fix Gn`, when the gap has an auto-applied fix, renders on the shared options line as a **separate one-off action** - never inside a bullet's recommendation and never in the option-2 list. Name the parent gap and warn that revert undoes the entire gap-level commit (see "Revert semantics").
16
17
 
@@ -34,7 +35,7 @@ Each bullet:
34
35
  Take **no** disposition action before the reply. Then, once, in order:
35
36
 
36
37
  1. **Normalize** every `custom(...)` into explicit operations; classify state-changing (edits code or spec) vs not. Clarify only an ambiguous or unexecutable effect.
37
- 2. **Commit spec edits** (`accept-into-spec`, `rescope-into-spec`, state-changing spec `custom`) - the main session edits the spec directly, before any fix dispatch (a dirty tree rejects `worktree: true`, and the re-audit must read the amended spec).
38
+ 2. **Commit spec edits** (`accept-into-spec`, `rescope-into-spec`, state-changing spec `custom`) - the main session edits the spec directly, before any fix dispatch (a dirty tree rejects `worktree: true`, and the re-audit must read the amended spec). A disposition chosen here is already approved: it bypasses the amendment surface's reviewer and batch. Items the pre-menu funnel auto-applied are recorded `Gn - <title>: accept-into-spec (auto-applied)`.
38
39
  3. **Re-audit if step 2 changed the spec**; regenerate the inventory and re-render if it changed. Project `fix-now` only from the refreshed inventory.
39
40
  4. **fix-now + code-changing custom:** project the selected concerns per gap into the reference's concern-scoped fix contract (excluding accepted/rescoped/followed-up siblings); run the reference "Fix loop" (unchanged - do not re-describe it). A code-changing `custom` runs the project's tests + `code-reviewer` on its delta before proceeding. Re-run Step 1's canonical tests.
40
41
  5. **Re-audit after all state-changing work;** obtain fresh decisions **only if** the refreshed inventory differs from the approved one, else proceed.
@@ -65,7 +65,7 @@ For each task in `plan_tracker`:
65
65
  6. If quality reviewer finds issues → re-dispatch implementer → re-review. Loop until ✅, within [Fix-Loop Rounds](#fix-loop-rounds).
66
66
  7. After its existing reviews accept the work (and its required commit point), mark that same task index `complete` in `plan_tracker`. A subagent exit or green tests alone are not acceptance.
67
67
 
68
- The orchestrator is the spec's only writer during execution. Amendment trigger: implementer `BLOCKED` citing a spec defect, or a review finding showing the spec (not the code) is wrong -> pause the fix loop, execute brainstorming's [Amending an approved spec](../brainstorming/SKILL.md#amending-an-approved-spec) in place, resume. Code-vs-spec mismatch stays in the SR loop. An SR unable to read the spec at a cited anchor (missing file, unresolvable heading/range) returns a blocking finding — the contract is spec+task or stop, never a silent fallback to task-only review.
68
+ The orchestrator is the spec's only writer during execution. Amendment trigger: implementer `BLOCKED` citing a spec defect, or a review finding showing the spec (not the code) is wrong -> pause the fix loop, execute brainstorming's [amendment path](../brainstorming/SKILL.md#amending-an-approved-spec) in place (it reviews first, stops only on escalation or redraw), resume. Code-vs-spec mismatch stays in the SR loop. An SR unable to read the spec at a cited anchor (missing file, unresolvable heading/range) returns a blocking finding — the contract is spec+task or stop, never a silent fallback to task-only review.
69
69
 
70
70
  After all tasks: proceed to [After All Tasks](#after-all-tasks-complete) - it owns parent full verification, then whole-diff code review, then conformance.
71
71