pi-gauntlet 4.3.1 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Pure logic for phase-tracker's substep action and marker commit guard.
3
+ * No fs/git access here — file access is injected so node --test covers it
4
+ * (registered in scripts/ci.mjs alongside the settings resolvers).
5
+ */
6
+
7
+ import { resolve } from "node:path";
8
+
9
+ export const CONTEXT_DRAFT_MARKER = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
10
+
11
+ export type SubstepCheck = { ok: true } | { ok: false; error: string };
12
+
13
+ export function checkSubstep(phaseStatus: string): SubstepCheck {
14
+ if (phaseStatus === "in_progress") return { ok: true };
15
+ return { ok: false, error: `substep requires an in_progress phase (status is ${phaseStatus})` };
16
+ }
17
+
18
+ export function phaseLabel(name: string, substep?: string): string {
19
+ return substep ? `${name}(${substep})` : name;
20
+ }
21
+
22
+ // Same statement-start anchor as the branch guards in phase-tracker.ts. Exported
23
+ // so Wave 2 wiring in phase-tracker.ts can drop its duplicate copy.
24
+ export const STMT_START = "(?:^|[\\n;&|(])\\s*";
25
+ // `git commit`, tolerating any global flags between `git` and `commit` (e.g.
26
+ // `-c user.email=x`, common in CI per-commit identity). Group 1 captures the
27
+ // whole flags span as ONE opaque block rather than -C directly: nesting a
28
+ // capture inside a repeated alternation resets it to undefined on iterations
29
+ // that take the other branch (JS regex semantics), so -C after a later flag
30
+ // (e.g. `-C /wt -c user.email=x commit`) would silently lose its capture.
31
+ // DASH_C below re-extracts -C from that span once, outside any repetition.
32
+ // `commit` must be followed by whitespace or end-of-string, not just a word
33
+ // boundary, so `commit-graph` / `commit-tree` don't false-positive.
34
+ const GIT_COMMIT = new RegExp(
35
+ STMT_START + "git\\s+((?:-\\S+(?:\\s+\\S+)?\\s+)*)commit(?=\\s|$)",
36
+ );
37
+ const DASH_C = /(?:^|\s)-C\s+(\S+)/;
38
+ // Global, lookaround-delimited (not consuming) so adjacent `cd a && cd b &&`
39
+ // statements don't eat each other's anchor/`&&` and hide the second match.
40
+ // parseGitCommit picks the LAST cd before the matched git-commit position, so
41
+ // `cd /a && cd /b && git commit` resolves against /b, not the first cd found.
42
+ const LEADING_CD = /(?<=^|[\n;&|(])\s*cd\s+([^\s)]+)\s*(?=&&|\|\||[;)\n]|$)/g;
43
+
44
+ export interface CommitForm {
45
+ cPath: string | undefined;
46
+ cdPath: string | undefined;
47
+ }
48
+
49
+ // Textual match anchored at statement starts (^ ; & | ( or newline). Quoted text
50
+ // can still match when preceded by such a char (e.g. sh -c 'x; git commit') —
51
+ // accepted heuristic, same tolerance as the existing Guard 3 mutation checks.
52
+ export function parseGitCommit(command: string): CommitForm | undefined {
53
+ const m = GIT_COMMIT.exec(command);
54
+ if (!m) return undefined;
55
+ let cdPath: string | undefined;
56
+ for (const cd of command.matchAll(LEADING_CD)) {
57
+ if (cd.index! < m.index!) cdPath = cd[1];
58
+ }
59
+ return { cPath: DASH_C.exec(m[1])?.[1], cdPath };
60
+ }
61
+
62
+ export function resolveRepoDir(form: CommitForm, sessionCwd: string): string {
63
+ const base = form.cdPath ? resolve(sessionCwd, form.cdPath) : sessionCwd;
64
+ return form.cPath ? resolve(base, form.cPath) : base;
65
+ }
66
+
67
+ // Line-1 anchoring prevents false positives on specs that QUOTE the marker.
68
+ // Working-tree read (not index) is an accepted false-negative window for a
69
+ // backstop whose primary check lives in the brainstorming skill.
70
+ export function findMarkerFile(
71
+ repoDir: string,
72
+ specDirs: string[],
73
+ listFiles: (dir: string) => string[],
74
+ readFirstLine: (file: string) => string | undefined,
75
+ ): string | undefined {
76
+ for (const dir of specDirs) {
77
+ for (const file of listFiles(resolve(repoDir, dir))) {
78
+ if (readFirstLine(file) === CONTEXT_DRAFT_MARKER) return file;
79
+ }
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ // Transitions always build fresh state: a substep never survives start/complete/skip/reset.
85
+ export function transitionPhaseState(status: string, reason?: string): { status: string; reason?: string } {
86
+ return reason === undefined ? { status } : { status, reason };
87
+ }
88
+
89
+ export function markerGuardApplies(flowGuardsEnforced: boolean, brainstormStatus: string): boolean {
90
+ return flowGuardsEnforced && brainstormStatus === "in_progress";
91
+ }
92
+
93
+ export const markerBlockReason = (file: string): string =>
94
+ `Blocked: ${file} still begins with the context-draft marker - the spec-writing ` +
95
+ `overwrite has not happened. Overwrite the draft with the real spec (write tool, ` +
96
+ `full replacement) before committing. ` +
97
+ `To override, set piGauntlet.flowGuards.enforce: false.`;
@@ -10,6 +10,8 @@
10
10
  */
11
11
 
12
12
  import { execSync } from "node:child_process";
13
+ import { readdirSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
13
15
  import { StringEnum } from "@earendil-works/pi-ai";
14
16
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
15
17
  import { Text } from "@earendil-works/pi-tui";
@@ -21,6 +23,16 @@ import {
21
23
  settingsErrorWarning,
22
24
  } from "./lib/gauntlet-settings.ts";
23
25
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
26
+ import {
27
+ checkSubstep,
28
+ findMarkerFile,
29
+ markerBlockReason,
30
+ parseGitCommit,
31
+ phaseLabel,
32
+ resolveRepoDir,
33
+ STMT_START,
34
+ transitionPhaseState,
35
+ } from "./lib/phase-tracker-helpers.ts";
24
36
 
25
37
  const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
26
38
  type Phase = (typeof PHASES)[number];
@@ -29,12 +41,13 @@ type PhaseStatus = "pending" | "in_progress" | "complete" | "skipped";
29
41
  interface PhaseState {
30
42
  status: PhaseStatus;
31
43
  reason?: string;
44
+ substep?: string;
32
45
  }
33
46
 
34
47
  type PhaseMap = Record<Phase, PhaseState>;
35
48
 
36
49
  interface PhaseTrackerDetails {
37
- action: "start" | "complete" | "skip" | "status" | "reset";
50
+ action: "start" | "complete" | "skip" | "status" | "reset" | "substep";
38
51
  phases: PhaseMap;
39
52
  error?: string;
40
53
  }
@@ -58,12 +71,12 @@ const CLOSURE_GATE_ERROR =
58
71
  ' phase_tracker({ action: "skip", phase: "verify", reason: "<user waiver>" })';
59
72
 
60
73
  const SHIP_ADVISORY =
61
- "Verify is complete and ship is pending. If the conformance verdict is resolved\n" +
62
- "(CONFORMS, or every gap dispositioned and approved), invoke\n" +
63
- "/skill:finishing-a-development-branch now - do not add a 'ready to finish?' prompt;\n" +
64
- "its squash/PR/keep/discard menu is the human gate. If a requirement decision is\n" +
65
- "still open, you should not have completed verify - reopen it and surface the open\n" +
66
- "decision instead.";
74
+ "Verify is complete and ship is pending. Verify may complete when the conformance\n" +
75
+ "verdict is CONFORMS, or every gap is either fixed or carried open as a deferred\n" +
76
+ "accept/rescope/unauthorized decision. Invoke /skill:finishing-a-development-branch\n" +
77
+ "now - do not add a 'ready to finish?' prompt; its disposition + squash/PR/keep/discard\n" +
78
+ "menu is the human gate that resolves any carried-open decision. Only reopen verify if\n" +
79
+ "a `fix` gap was left unresolved (neither fixed nor deferred).";
67
80
 
68
81
  // --- Flow guards (spec 2026-06-17-gauntlet-flow-guards) ---
69
82
 
@@ -72,7 +85,6 @@ const GUARD_PHASES: Phase[] = ["brainstorm", "plan", "implement"];
72
85
  // Guard 2 — branch ops in place. `git switch` never targets a file path;
73
86
  // `git checkout -b/-B` is explicit branch creation. Bare `git checkout <x>`
74
87
  // is excluded (ambiguous with file checkout). `git worktree ...` is exempt.
75
- const STMT_START = "(?:^|[\\n;&|(])\\s*";
76
88
  const BRANCH_SWITCH = new RegExp(STMT_START + "git\\s+switch\\b");
77
89
  const BRANCH_CHECKOUT = new RegExp(STMT_START + "git\\s+checkout\\s+-[bB]\\b");
78
90
  const GIT_WORKTREE = /\bgit\s+worktree\b/;
@@ -155,7 +167,7 @@ const pathInSpecDirs = (rawPath: string, specDirs: string[]): boolean => {
155
167
  };
156
168
 
157
169
  const PhaseTrackerParams = Type.Object({
158
- action: StringEnum(["start", "complete", "skip", "status", "reset"] as const, {
170
+ action: StringEnum(["start", "complete", "skip", "status", "reset", "substep"] as const, {
159
171
  description: "Action to perform",
160
172
  }),
161
173
  phase: Type.Optional(
@@ -173,6 +185,11 @@ const PhaseTrackerParams = Type.Object({
173
185
  description: "Reset and re-start a phase that is already complete or skipped (rare; default false)",
174
186
  }),
175
187
  ),
188
+ substep: Type.Optional(
189
+ Type.Union([Type.String(), Type.Null()], {
190
+ description: "Substep label for action=substep on an in_progress phase; null or omitted clears it",
191
+ }),
192
+ ),
176
193
  });
177
194
 
178
195
  export type PhaseTrackerInput = Static<typeof PhaseTrackerParams>;
@@ -201,7 +218,8 @@ function hasActivity(phases: PhaseMap): boolean {
201
218
  function formatWidget(phases: PhaseMap, theme: Theme): string {
202
219
  const parts = PHASES.map((p) => {
203
220
  const icon = phaseIcon(phases[p].status, theme);
204
- const name = phases[p].status === "skipped" ? theme.fg("dim", p) : p;
221
+ const labeled = phaseLabel(p, phases[p].status === "in_progress" ? phases[p].substep : undefined);
222
+ const name = phases[p].status === "skipped" ? theme.fg("dim", labeled) : labeled;
205
223
  return `${icon} ${name}`;
206
224
  });
207
225
  return `${theme.fg("muted", "Phases:")} ${parts.join(theme.fg("dim", " → "))}`;
@@ -213,7 +231,7 @@ function formatStatus(phases: PhaseMap): string {
213
231
  const s = phases[p];
214
232
  const icon = s.status === "complete" ? "✓" : s.status === "in_progress" ? "→" : s.status === "skipped" ? "⊘" : "○";
215
233
  const suffix = s.reason ? ` (${s.reason})` : "";
216
- lines.push(` ${icon} ${p}${suffix}`);
234
+ lines.push(` ${icon} ${phaseLabel(p, s.status === "in_progress" ? s.substep : undefined)}${suffix}`);
217
235
  }
218
236
  return lines.join("\n");
219
237
  }
@@ -386,6 +404,41 @@ export default function (pi: ExtensionAPI) {
386
404
  return { block: true, reason: branchBlockReason(gphase) };
387
405
  }
388
406
 
407
+ // Marker commit guard — the context draft (brainstorming gather step) must be
408
+ // overwritten by the real spec before any commit lands. Backstop to the skill's
409
+ // own post-write check. Blocked, like Guard 2; skipped entirely when
410
+ // flowGuards.enforce is false (checked above).
411
+ // gating contract: markerGuardApplies (see helpers) - enforce checked once above
412
+ // (flowGuardsEnforced()); re-checking it here would be belt-and-suspenders.
413
+ if (phases.brainstorm.status === "in_progress") {
414
+ const commit = parseGitCommit(command);
415
+ if (commit) {
416
+ const repoDir = resolveRepoDir(commit, ctx.cwd);
417
+ const listFiles = (dir: string): string[] => {
418
+ const walk = (d: string): string[] => {
419
+ return readdirSync(d, { withFileTypes: true }).flatMap((e) => {
420
+ const p = join(d, e.name);
421
+ return e.isDirectory() ? walk(p) : e.isFile() ? [p] : [];
422
+ });
423
+ };
424
+ try {
425
+ return walk(dir);
426
+ } catch {
427
+ return [];
428
+ }
429
+ };
430
+ const readFirstLine = (file: string): string | undefined => {
431
+ try {
432
+ return readFileSync(file, "utf8").split("\n", 1)[0];
433
+ } catch {
434
+ return undefined;
435
+ }
436
+ };
437
+ const hit = findMarkerFile(repoDir, specDirs(), listFiles, readFirstLine);
438
+ if (hit) return { block: true, reason: markerBlockReason(hit) };
439
+ }
440
+ }
441
+
389
442
  // Guard 3 — bash mutation outside the spec dir during brainstorm.
390
443
  if (phases.brainstorm.status === "in_progress" && !firedGuards.get("brainstorm-write")) {
391
444
  // Redirect target is cleanly extractable: judge it directly against the spec dirs,
@@ -474,7 +527,7 @@ export default function (pi: ExtensionAPI) {
474
527
  description:
475
528
  "Track workflow phase progress (brainstorm → plan → implement → verify → ship). " +
476
529
  "Actions: start (mark phase in_progress), complete (mark phase complete), " +
477
- "skip (mark phase skipped with reason), status (show all phases), reset (clear all phases).",
530
+ "skip (mark phase skipped with reason), status (show all phases), reset (clear all phases), substep (set/clear a substep label on an in_progress phase).",
478
531
  parameters: PhaseTrackerParams,
479
532
 
480
533
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -518,7 +571,7 @@ export default function (pi: ExtensionAPI) {
518
571
  } as PhaseTrackerDetails,
519
572
  };
520
573
  }
521
- phases = { ...phases, [params.phase]: { status: "in_progress" } };
574
+ phases = { ...phases, [params.phase]: transitionPhaseState("in_progress") as PhaseState };
522
575
  firedGuards.clear();
523
576
  updateWidget(ctx);
524
577
  return {
@@ -559,7 +612,7 @@ export default function (pi: ExtensionAPI) {
559
612
  } as PhaseTrackerDetails,
560
613
  };
561
614
  }
562
- phases = { ...phases, [params.phase]: { status: "complete" } };
615
+ phases = { ...phases, [params.phase]: transitionPhaseState("complete") as PhaseState };
563
616
  firedGuards.clear();
564
617
  updateWidget(ctx);
565
618
  const advisory =
@@ -591,7 +644,7 @@ export default function (pi: ExtensionAPI) {
591
644
  };
592
645
  }
593
646
  const reason = params.reason;
594
- phases = { ...phases, [params.phase]: { status: "skipped", reason } };
647
+ phases = { ...phases, [params.phase]: transitionPhaseState("skipped", reason) as PhaseState };
595
648
  firedGuards.clear();
596
649
  updateWidget(ctx);
597
650
  return {
@@ -602,6 +655,33 @@ export default function (pi: ExtensionAPI) {
602
655
  };
603
656
  }
604
657
 
658
+ case "substep": {
659
+ if (!params.phase) {
660
+ return {
661
+ content: [{ type: "text", text: "Error: phase required for substep" }],
662
+ details: { action: "substep", phases: { ...phases }, error: "phase required" } as PhaseTrackerDetails,
663
+ };
664
+ }
665
+ const check = checkSubstep(phases[params.phase].status);
666
+ if (!check.ok) {
667
+ return {
668
+ content: [{ type: "text", text: `Error: phase "${params.phase}": ${check.error}` }],
669
+ details: { action: "substep", phases: { ...phases }, error: check.error } as PhaseTrackerDetails,
670
+ };
671
+ }
672
+ const entry: PhaseState = { status: "in_progress" };
673
+ if (typeof params.substep === "string" && params.substep.trim()) entry.substep = params.substep.trim();
674
+ phases = { ...phases, [params.phase]: entry };
675
+ updateWidget(ctx);
676
+ const label = entry.substep
677
+ ? `Phase "${params.phase}" substep → ${entry.substep}`
678
+ : `Phase "${params.phase}" substep cleared`;
679
+ return {
680
+ content: [{ type: "text", text: `${label}\n${formatStatus(phases)}` }],
681
+ details: { action: "substep", phases: { ...phases } } as PhaseTrackerDetails,
682
+ };
683
+ }
684
+
605
685
  case "status": {
606
686
  return {
607
687
  content: [{ type: "text", text: formatStatus(phases) }],
@@ -610,7 +690,9 @@ export default function (pi: ExtensionAPI) {
610
690
  }
611
691
 
612
692
  case "reset": {
613
- phases = emptyPhases();
693
+ phases = Object.fromEntries(
694
+ PHASES.map((p) => [p, transitionPhaseState("pending")]),
695
+ ) as PhaseMap;
614
696
  conformanceDispatched = false;
615
697
  firedGuards.clear();
616
698
  updateWidget(ctx);
@@ -667,6 +749,11 @@ export default function (pi: ExtensionAPI) {
667
749
  );
668
750
  case "skip":
669
751
  return new Text(theme.fg("dim", "⊘ ") + theme.fg("muted", "phase skipped"), 0, 0);
752
+ case "substep": {
753
+ const active = PHASES.find((ph) => p[ph].status === "in_progress");
754
+ const label = active ? phaseLabel(active, p[active].substep) : "";
755
+ return new Text(theme.fg("warning", "→ ") + theme.fg("muted", label), 0, 0);
756
+ }
670
757
  case "status": {
671
758
  let text = theme.fg("muted", "Phases:");
672
759
  for (const ph of PHASES) {
@@ -129,7 +129,7 @@ export default function (pi: ExtensionAPI) {
129
129
  name: "plan_tracker",
130
130
  label: "Plan Tracker",
131
131
  description:
132
- "Track progress while EXECUTING an implementation plan the implement phase only. Actions: init (set task list), update (change task status), status (show current state), clear (remove plan). Do NOT use for brainstorming, research, or planning checklists: those phases are open-ended and a bounded task list misrepresents them as a fixed N-step process.",
132
+ "Track progress while EXECUTING an implementation plan (the implement phase) or a verify-phase conformance fix wave. Actions: init (set task list), update (change task status), status (show current state), clear (remove plan). Do NOT use for brainstorming, research, or planning checklists: those phases are open-ended and a bounded task list misrepresents them as a fixed N-step process.",
133
133
  parameters: PlanTrackerParams,
134
134
 
135
135
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "4.3.1",
3
+ "version": "4.4.1",
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",
@@ -49,16 +49,20 @@ Work through the items below **in order**. This is your own checklist to follow,
49
49
 
50
50
  Re-entering while a brainstorm is already in progress is safe: mid-brainstorm there are no tasks or later phases to lose, so the reset just re-establishes the same clean slate.
51
51
  2. **Set up the worktree** — see [Worktree First](#worktree-first)
52
- 3. **Explore project context** — files, docs, recent commits, current behaviour
53
- 4. **Ask clarifying questions** one at a time
52
+ 3. **Gather context** — follow `gatherer.md` (same directory): set substep `gather`,
53
+ dispatch the builders, assemble the context draft at the spec path, clear the
54
+ substep. Unconditional, foreground, no user interaction — the next thing the user
55
+ sees is a questionary question. This produces the draft; the step below consumes it.
56
+ 4. **Understand the idea against the draft** — `Read` the draft, verify load-bearing
57
+ claims against real code, ask questions one at a time, append citable findings
54
58
  5. **Propose 2-3 approaches** — with trade-offs and a recommendation
55
59
  6. **Present the design** — in sections, get approval after each
56
60
  7. **Write the spec** — to `doc/specs/` (see [Filename Convention](#filename-convention))
57
61
  8. **Spec self-review (lint)** — placeholder scan + internal consistency + documentation named, run inline
58
- 9. **Critique pass (auto-dispatched)** — scope + ambiguity; the spec council via `/skill:roasting-the-spec` when `gauntlet_setting` returns verdict `council`, else a fresh `worker` (see [Spec Council](#spec-council-optional))
59
- 10. **Re-run placeholder scan** — after the critique pass returns, first inline any `external-ref:` flags it raised (see [Spec Self-Review](#spec-self-review-before-user-review-gate)), then re-scan for placeholders its edits may have introduced; surface any ambiguity the worker could not safely resolve at the user gate
60
- 11. **Generate spec summary** — dispatch a fresh, spec-only `spec-summarizer` writing to an absolute temp-dir path via `outputMode: "file-only"`, then `Read` that file back as the **last content-producing** tool call before composing the gate and render its contents **verbatim** at the top of the gate message — do not paraphrase, condense, re-section, or rewrite it (see [User Review Gate](#user-review-gate)); this is part of the existing gate, not a new one
61
- 12. **User review gate** — user reviews the committed spec
62
+ 9. **Critique pass (auto-dispatched)** — scope + ambiguity; the spec council via `/skill:roasting-the-spec` when `gauntlet_setting` returns verdict `council` (it applies its apply-set, including any external-ref inlining, to the spec before returning — see [Spec Council](#spec-council-optional)), else a fresh `worker` that applies its own fixes in place
63
+ 10. **Re-run placeholder scan** — after the critique pass returns, re-scan the **applied** spec for placeholders its edits may have introduced; surface any ambiguity the critique could not safely resolve at the user gate
64
+ 11. **Generate spec summary** — dispatch a fresh, spec-only `spec-summarizer` over the **final (post-apply)** spec, writing to an absolute temp-dir path via `outputMode: "file-only"`, then `Read` that file back as the **last content-producing** tool call before composing the gate and render its contents **verbatim** at the top of the gate message — do not paraphrase, condense, re-section, or rewrite it (see [User Review Gate](#user-review-gate)); this is part of the existing gate, not a new one
65
+ 12. **User review gate** — user reviews the applied spec's verbatim summary plus the council audit (Applied/Deferred/Rejected), with a revert valve for any applied council edit
62
66
  13. **Transition** — only after approval, invoke `/skill:writing-plans`
63
67
 
64
68
  ## Project Routing
@@ -112,12 +116,26 @@ Don't try to design a multi-subsystem monolith in one spec doc.
112
116
 
113
117
  ### 3. Understand the idea
114
118
 
115
- - Check the current project state first: files, docs, recent commits, neighboring services.
116
- - **Check if the codebase or ecosystem already solves this** before designing from scratch. Grep, read existing AGENTS.md, look at `doc/` and `doc/specs/`.
117
- - Ask questions **one at a time** to refine the idea.
118
- - Prefer multiple-choice questions; open-ended is fine when needed.
119
- - One question per message. If a topic needs more exploration, split into multiple turns.
120
- - Focus on: purpose, constraints, success criteria, who/what it touches.
119
+ The gather step (see `gatherer.md`) has already assembled a context draft at the spec
120
+ path.
121
+
122
+ - `Read` the draft **unconditionally before composing question one**. The on-disk
123
+ copy is canonical this one rule defeats both a turn-boundary prune after assembly
124
+ and a session restart.
125
+ - The draft is a **helper, not a fence**: judgment still drives exploration. Verify
126
+ load-bearing claims (schemas, contracts, the code being changed) against real code
127
+ via targeted reads (`read_symbol`-grade, not scout's paraphrase) before designing
128
+ against them.
129
+ - **Check if the codebase or ecosystem already solves this** — the draft's recon
130
+ section starts that answer; confirm it before designing from scratch.
131
+ - Ask questions **one at a time** to refine the idea. Prefer multiple-choice; one
132
+ question per message. Focus on: purpose, constraints, success criteria, who/what
133
+ it touches.
134
+ - **Append bar:** append to the draft's `## Appended during questionary` only
135
+ findings the spec will cite — schema shapes, hard constraints, ticket-vs-code
136
+ contradictions, user answers that changed scope. Not a log of every grep.
137
+ (Appending uses `edit`; the `edit` prohibition in the spec-writing step applies
138
+ only there.)
121
139
 
122
140
  ### 4. Explore approaches
123
141
 
@@ -197,8 +215,25 @@ Spec lives in the project's `doc/specs/` (see [Project Routing](#project-routing
197
215
 
198
216
  `<topic>` is a short kebab-case slug (3–6 words). Do **not** append `-design` or any other suffix.
199
217
 
218
+ The slug is minted **once, at gather time**, from the initial prompt; the spec-writing
219
+ overwrite reuses the path. If the questionary invalidated the slug, rename at
220
+ spec-writing: write the spec at the new path **and delete the old draft file**
221
+ (nothing was committed, so this is free).
222
+
200
223
  ## Spec Self-Review (Before User Review Gate)
201
224
 
225
+ Spec-writing replaces the context draft, in this exact order:
226
+
227
+ 1. `Read` the draft in full — **immediately before** the overwrite. Without this, a
228
+ pruned questionary plus a full-replacement `write` destroys the only copy of the
229
+ gathered context at the moment it feeds the spec.
230
+ 2. Write the spec with the `write` tool (**full replacement**) at the spec path.
231
+ Using `edit` at this step is a red flag.
232
+ 3. **Immediately after the write**, confirm line 1 of the file is no longer
233
+ `# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing` — before
234
+ dispatching lint, critique, council, or summarizer. The phase-tracker commit
235
+ guard is a backstop, not the primary check.
236
+
202
237
  After writing the spec to `<project>/doc/specs/<filename>.md` (per [Filename Convention](#filename-convention)) and before showing it to the user, run a self-review pass. **Read all five bullets first, then act:** only the **first three** run here at the main loop (the inline lint); the **last two** (scope + ambiguity) do **not** run inline — they are the dispatched critique pass (checklist item 9). Do not apply scope/ambiguity edits yourself.
203
238
 
204
239
  - **Placeholder scan.** Any `TODO`, `TBD`, `<fill in>`, `[example]`, `xxx`? Either resolve them or convert to explicit "Open Questions" with names.
@@ -209,7 +244,7 @@ After writing the spec to `<project>/doc/specs/<filename>.md` (per [Filename Con
209
244
 
210
245
  The first three checks — **placeholder scan**, **internal consistency**, and **documentation named** — are the inline **lint**: run them here at the main loop and fix what they surface. The last two — **scope** and **ambiguity** — are **not** run inline; they are the **critique pass**, auto-dispatched (per [Spec Council](#spec-council-optional)):
211
246
 
212
- - **Council** (`gauntlet_setting({ key: "specCouncil" })` returns verdict `council`) → invoke `/skill:roasting-the-spec`; it runs the critique and proposes dispositions.
247
+ - **Council** (`gauntlet_setting({ key: "specCouncil" })` returns verdict `council`) → invoke `/skill:roasting-the-spec`; it runs the critique, derives dispositions, and **applies the apply-set to the spec** (including inlining any `external-ref:` cluster it has context for) **before returning** — see that skill for apply mechanics. It returns a structured audit: `Applied:` / `Deferred:` / `Rejected:`.
213
248
  - **Otherwise** → dispatch one fresh `worker` that applies the scope + ambiguity checks and fixes them in place:
214
249
 
215
250
  ```
@@ -218,23 +253,24 @@ The first three checks — **placeholder scan**, **internal consistency**, and *
218
253
  "Read the spec at <abs path to doc/specs/...>. Edit ONLY that file. Apply two checks and\n" +
219
254
  "fix what you find in place: (1) Scope — does every paragraph serve the goal? Cut filler;\n" +
220
255
  "state out-of-scope explicitly. (2) Ambiguity — is every 'we should' a concrete decision?\n" +
221
- "Replace 'we could probably' with 'we will'/'we won't'. Also flag (do NOT fetch) any\n" +
222
- "load-bearing external reference (ticket AC, commit SHA, doc) the spec relies on but does\n" +
223
- "not inline, and recommend inlining it. Return a summary of what you changed, and flag any\n" +
224
- "ambiguity you could NOT safely resolve." })
256
+ "Replace 'we could probably' with 'we will'/'we won't'. Also inline any load-bearing\n" +
257
+ "external reference (ticket AC, commit SHA, doc) already given to you in the problem\n" +
258
+ "statement above; if the spec relies on one not provided here, flag it (do NOT fetch) in\n" +
259
+ "your summary. Return a summary of what you changed, and flag any ambiguity you could NOT\n" +
260
+ "safely resolve." })
225
261
  ```
226
262
 
227
263
  `worker`'s model resolves from `subagents.agentOverrides.worker.model` in `settings.json` (unset → inherits the main loop); the dispatch passes no `model:`.
228
264
 
229
- After the critique pass returns, scan it for load-bearing external references before re-running the placeholder scan: in the council path, look for chair clusters whose theme is prefixed `external-ref:`; in the worker path, look for the worker's external-ref flag. For each, inline the referenced content you have context for (e.g. the ticket fetched during brainstorming) via the normal disposition/edit path - you hold the ticket, the critics do not. Then re-run the placeholder scan to catch anything the edits introduced. If the worker flagged ambiguities it could not safely resolve, surface them in the [User Review Gate](#user-review-gate) message so the user decides - the worker auto-applies fixes but never silently swallows an open question.
265
+ Both paths apply their fixes **before returning** the council auto-applies inside `/skill:roasting-the-spec`, the worker edits the spec file directly. After the critique pass returns, re-run the placeholder scan over the **applied** spec to catch anything the edits introduced. If the worker flagged ambiguities it could not safely resolve, surface them in the [User Review Gate](#user-review-gate) message so the user decides - the worker auto-applies fixes but never silently swallows an open question.
230
266
 
231
267
  ## Spec Council (Optional)
232
268
 
233
- After the inline lint and before the user review gate, **brainstorming owns the critique-pass gate**. Resolve the council with `gauntlet_setting({ key: "specCouncil" })` - the tool returns the merged (repo-over-preset) value as `{ verdict, members, chair, malformed, warning, errors }`. **Do not** hand-roll a settings read. When `verdict` is `"council"`, the council *is* the critique pass - invoke `/skill:roasting-the-spec` automatically (no offer, no prompt), passing `members`/`chair`. When `verdict` is `"worker"`, run the fresh-`worker` critique instead (see [Spec Self-Review](#spec-self-review-before-user-review-gate)). If `malformed` is true or `errors` is non-empty, emit the `warning`/error as one line, then branch strictly on `verdict` - `malformed` can accompany *either* verdict (e.g. a bad `chair` with valid `members` still returns `council`), so never infer the worker path from `malformed` alone. If `gauntlet_setting` is unavailable, stop and report - never fall back to a manual bash/JSON settings merge. Approved council edits (or the worker's in-place fixes) ride in the same worktree commit. The conceptual precedence rule lives in `verification-before-completion/reference/settings-precedence.md`.
269
+ After the inline lint and before the user review gate, **brainstorming owns the critique-pass gate**; council **apply mechanics** live in `/skill:roasting-the-spec` (single source of truth - link, don't restate). Resolve the council with `gauntlet_setting({ key: "specCouncil" })` - the tool returns the merged (repo-over-preset) value as `{ verdict, members, chair, malformed, warning, errors }`. **Do not** hand-roll a settings read. When `verdict` is `"council"`, the council *is* the critique pass - invoke `/skill:roasting-the-spec` automatically (no offer, no prompt), passing `members`/`chair`; it applies its apply-set and returns the audit (Applied/Deferred/Rejected). When `verdict` is `"worker"`, run the fresh-`worker` critique instead (see [Spec Self-Review](#spec-self-review-before-user-review-gate)). If `malformed` is true or `errors` is non-empty, emit the `warning`/error as one line, then branch strictly on `verdict` - `malformed` can accompany *either* verdict (e.g. a bad `chair` with valid `members` still returns `council`), so never infer the worker path from `malformed` alone. If `gauntlet_setting` is unavailable, stop and report - never fall back to a manual bash/JSON settings merge. The already-applied council edits (or the worker's in-place fixes) ride in the same worktree commit. The conceptual precedence rule lives in `verification-before-completion/reference/settings-precedence.md`.
234
270
 
235
271
  ## User Review Gate
236
272
 
237
- After self-review (and council review, if configured) and after inlining any external-ref flags, dispatch the spec-only summarizer, then commit the spec on the worktree branch and stop. This is the **same** single human gate - the summary is folded into it, not a new gate.
273
+ After self-review and the critique pass (council or worker, both already applied to the spec - see [Spec Council](#spec-council-optional)), dispatch the spec-only summarizer over the **applied** spec, then commit the spec on the worktree branch and stop. This is the **same** single human gate - the summary is folded into it, not a new gate.
238
274
 
239
275
  Mint an absolute temp path outside the worktree (so it is never committed), then dispatch the summarizer on a fresh context, reading only the spec, writing to that path via file-only output (no `model:` - it inherits the main loop unless a preset sets `subagents.agentOverrides.spec-summarizer.model`):
240
276
 
@@ -250,7 +286,7 @@ subagent({ agent: "spec-summarizer", context: "fresh", cwd: "<abs worktree path,
250
286
 
251
287
  `<SUMMARY_PATH>` above is a placeholder in the dispatch object; it means substitute the value of the shell variable `$SUMMARY_PATH` set above. The steps below use `$SUMMARY_PATH` (the shell form) once the value is in hand.
252
288
 
253
- Then commit the spec — this commit is **unconditional**: the summary is only a gate aid, so a degraded or missing summary never blocks it. Evaluate the summary in two stages (the **Degrade path** referenced in each is defined just below):
289
+ Then commit the spec — this commit is **unconditional**: the summary is only a gate aid, so a degraded or missing summary never blocks it. If the council path ran, include its audit (`Applied:` / `Deferred:` / `Rejected:`, verbatim from `/skill:roasting-the-spec`'s return) in the **commit message body** - this is the durable, non-contractual record a finish-time revert reads back; the audit is never a committed spec section. Evaluate the summary in two stages (the **Degrade path** referenced in each is defined just below):
254
290
 
255
291
  1. **From the dispatch tool result, before the `Read`.** If the result is **not** an `"Output saved to: <path> (<N> KB, <M> lines)"` reference (e.g. an exit-0 save error returns the full inline output plus an "Output file error" line — the prunable shape, no file to read), or the reference reports under ~500 bytes, or a size grossly disproportionate to the spec (under ~2% of its byte size), or over ~45 KB (the `Read` truncates at 50KB / 2000 lines, so a larger file cannot render whole) — skip the `Read` and take the degrade path. Use the reference's reported figures; do not re-derive them.
256
292
  2. **The `Read` itself, as the last content-producing tool call before composing the gate.** `Read` `$SUMMARY_PATH` and paste its contents verbatim at the top of the gate. If the `Read` fails, returns 0 bytes, or reports truncation — take the degrade path. The `Read` must be last: pi-condense does not protect a `/tmp` read, so any turn boundary between the `Read` and the render lets the ~9KB read result be pruned, reproducing the bug.
@@ -259,21 +295,28 @@ Then commit the spec — this commit is **unconditional**: the summary is only a
259
295
 
260
296
  Either way — summary rendered or degraded — then `rm "$SUMMARY_PATH"` (unconditional cleanup; harmless if the file was never created, since it lives outside the worktree under the OS temp dir).
261
297
 
262
- Render the temp file's contents **verbatim** first — paste it as-is, do **not** paraphrase, condense, re-section, drop sections, or merge it with council output. "Fold into the gate" means *place it inside the gate message*, not *rewrite it*. After the verbatim block, append the commit confirmation, then — as their **own** adjacent lines, not edits to the summary — any council outcome, critique-pass-unresolved ambiguities, and every entry from the summarizer's gap/external-context footer (surface **all** of them, not just the top risk):
298
+ Render the temp file's contents **verbatim** first — paste it as-is, do **not** paraphrase, condense, re-section, drop sections, or merge it with the council audit. "Fold into the gate" means *place it inside the gate message*, not *rewrite it*. This summary is of the **final (post-apply)** spec, since both critique paths already applied before this dispatch. After the verbatim block, append the commit confirmation, then — as their **own** adjacent lines, not edits to the summary — the council audit (if the council path ran: `Applied:` / `Deferred:` / `Rejected:`, one line each), critique-pass-unresolved ambiguities, and every entry from the summarizer's gap/external-context footer (surface **all** of them, not just the top risk):
263
299
 
264
300
  ```
265
301
  <spec-only summary read back from the temp file — pasted verbatim, unedited>
266
302
 
267
303
  Spec written and committed to <project>/doc/specs/<filename>.md (worktree: <path>).
268
304
 
269
- <council outcome, if any; unresolved ambiguities; every gap-footer entry from the summary>
305
+ Applied: <cluster -> edit>, ...
306
+ Deferred: <cluster -> where it belongs>, ...
307
+ Rejected: <cluster -> one-line reason>, ...
308
+ (omit the three lines above when the worker path ran, not the council)
270
309
 
271
- Please review. Approve to proceed, or tell me what to change in the spec.
310
+ <unresolved ambiguities; every gap-footer entry from the summary>
311
+
312
+ 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.
272
313
  ```
273
314
 
274
315
  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.
275
316
 
276
- Wait for the user. On a change request, 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.
317
+ **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.
318
+
319
+ 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.
277
320
 
278
321
  After approval, mark the brainstorm phase complete:
279
322
 
@@ -294,6 +337,10 @@ phase_tracker({ action: "complete", phase: "brainstorm" })
294
337
  ## Red Flags — STOP
295
338
 
296
339
  - About to write code or start a non-spec edit while this skill is active
340
+ - About to dispatch lint, critique, council, or summarizer while the spec file's line 1 is still the context-draft marker
341
+ - About to run the spec-writing overwrite without re-reading the draft in the same turn
342
+ - About to use `edit` instead of `write` for the spec-writing overwrite
343
+ - About to insert a human gate, announcement, or question between gather dispatch and questionary question one
297
344
  - About to run, deploy, or validate the **proposed change** (vs. observing current behaviour) before the user approved the design
298
345
  - About to skip the critique pass (council if configured, else fresh worker)
299
346
  - Critique dispatch (council or worker) failed to complete and you proceeded to the gate anyway