pi-gauntlet 5.20.0 → 5.22.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.
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { buildCouncil, memberSlugOf, normalizeRaisedBy, parseAudit, parseChairReport, resolveMembers, slug } from "./telemetry-council.ts";
4
+
5
+ const zero = { blocker: 0, major: 0, minor: 0 };
6
+ test("slug and member filename normalization", () => {
7
+ assert.equal(slug("p/alpha:xhigh"), "p-alpha-xhigh");
8
+ assert.equal(memberSlugOf("/tmp/retry/member-2-p-beta-high.md"), "p-beta-high");
9
+ assert.equal(memberSlugOf("/tmp/member-0-p-alpha"), "p-alpha");
10
+ assert.equal(memberSlugOf(undefined), undefined);
11
+ assert.equal(memberSlugOf("chair.md"), undefined);
12
+ assert.equal(normalizeRaisedBy("member-0-p-alpha"), "p-alpha");
13
+ assert.equal(normalizeRaisedBy("p-alpha.md"), "p-alpha");
14
+ assert.equal(normalizeRaisedBy(" p/alpha:xhigh "), "p-alpha-xhigh");
15
+ });
16
+ const chairText = [
17
+ "consensus: needs-work",
18
+ "clusters:",
19
+ "- [blocker] issue — raised-by: [p-alpha] — fix",
20
+ "- [Major] issue - raised-by: [p-alpha, p-beta-high, p-alpha] - fix",
21
+ "- [minor] issue -- raised-by: [p-beta-high] -- cut",
22
+ "resolved:",
23
+ "- [blocker] ignored — raised-by: [nobody]",
24
+ ].join("\n");
25
+ test("chair clusters parse only before resolved and require attributes", () => {
26
+ assert.deepEqual(parseChairReport(chairText), { clusters: [
27
+ { severity: "blocker", raisedBy: ["p-alpha"] },
28
+ { severity: "major", raisedBy: ["p-alpha", "p-beta-high"] },
29
+ { severity: "minor", raisedBy: ["p-beta-high"] },
30
+ ] });
31
+ assert.equal(parseChairReport("clusters:\n"), null);
32
+ assert.equal(parseChairReport("consensus: x\n"), null);
33
+ assert.deepEqual(parseChairReport("consensus: sound\nclusters:\nresolved:\n"), { clusters: [] });
34
+ assert.equal(parseChairReport("consensus: x\nclusters:\n- [major] no raiser"), null);
35
+ });
36
+ const auditText = [
37
+ "Applied: [blocker] issue — raised-by: [p-alpha] -> fix",
38
+ "- Applied: [major] over-spec — raised-by: [p-alpha, p-beta-high] -> cut",
39
+ "Applied: [minor] issue — raised-by: [p-beta-high] -> open question (missing)",
40
+ "Deferred: [major] x — raised-by: [p-alpha] -> later",
41
+ "Rejected: [minor] issue — raised-by: [p-beta-high] -> no",
42
+ ].join("\n");
43
+ test("audit reads dispositions, none and cut/open question", () => {
44
+ assert.deepEqual(parseAudit(auditText), [
45
+ { disposition: "applied", severity: "blocker", raisedBy: ["p-alpha"] },
46
+ { disposition: "applied", severity: "major", raisedBy: ["p-alpha", "p-beta-high"] },
47
+ { disposition: "applied", severity: "minor", raisedBy: ["p-beta-high"] },
48
+ { disposition: "deferred", severity: "major", raisedBy: ["p-alpha"] },
49
+ { disposition: "rejected", severity: "minor", raisedBy: ["p-beta-high"] },
50
+ ]);
51
+ assert.equal(parseAudit("Applied: [major] x — raised-by: [p-alpha]\nDeferred: none"), null);
52
+ assert.equal(parseAudit("Applied: x — raised-by: [p-alpha]\nDeferred: none\nRejected: none"), null);
53
+ assert.equal(parseAudit("Applied: [major] x\nDeferred: none\nRejected: none"), null);
54
+ assert.deepEqual(parseAudit("Applied: none\nDeferred: none\nRejected: none"), []);
55
+ });
56
+ const results = [
57
+ { model: "p/alpha:xhigh", exitCode: 0, savedOutputPath: "/tmp/member-0-p-alpha.md" },
58
+ { model: "p/beta:high", exitCode: 1 },
59
+ { model: "p/beta:high", exitCode: 0, savedOutputPath: "/tmp/retry/member-1-p-beta-high.md" },
60
+ ];
61
+ test("member retry counts dispatches and ambiguous slug fails", () => {
62
+ const members = resolveMembers(results)!;
63
+ assert.deepEqual([...members.dispatches], [["p/alpha:xhigh", 1], ["p/beta:high", 2]]);
64
+ assert.deepEqual([...members.slugToModel], [["p-alpha", "p/alpha:xhigh"], ["p-beta-high", "p/beta:high"]]);
65
+ assert.equal(resolveMembers([...results, { model: "p/other", exitCode: 0, savedOutputPath: "/tmp/member-3-p-alpha.md" }]), null);
66
+ assert.deepEqual([...resolveMembers([{ exitCode: 0, savedOutputPath: "/tmp/member-0-x.md" }])!.dispatches], []);
67
+ });
68
+ const clusters = [{ severity: "blocker" as const, raisedBy: ["p-alpha"] }, { severity: "major" as const, raisedBy: ["p-alpha", "p-beta-high"] }, { severity: "minor" as const, raisedBy: ["p-beta-high"] }];
69
+ const audit = [{ disposition: "applied" as const, severity: "blocker" as const, raisedBy: ["p-alpha"] }, { disposition: "deferred" as const, severity: "major" as const, raisedBy: ["p-beta-high", "p-alpha"] }, { disposition: "rejected" as const, severity: "minor" as const, raisedBy: ["p-beta-high"] }];
70
+ const input = () => ({ chair: { model: "p/chair:medium", dispatches: 2, clusters }, members: resolveMembers(results)!, audit });
71
+ test("council joins multiset and counts each severity per member", () => {
72
+ const block = buildCouncil(input())!;
73
+ assert.deepEqual(block.chair, { model: "p/chair:medium", dispatches: 2, clusters: 3, members_reported: 2 });
74
+ assert.deepEqual(block.members["p/alpha:xhigh"], { dispatches: 1, total: { blocker: 1, major: 1, minor: 0 }, unique: { blocker: 1, major: 0, minor: 0 }, applied: { blocker: 1, major: 0, minor: 0 }, unique_applied: { blocker: 1, major: 0, minor: 0 }, deferred: { blocker: 0, major: 1, minor: 0 }, rejected: zero });
75
+ assert.deepEqual(block.members["p/beta:high"], { dispatches: 2, total: { blocker: 0, major: 1, minor: 1 }, unique: { blocker: 0, major: 0, minor: 1 }, applied: zero, unique_applied: zero, deferred: { blocker: 0, major: 1, minor: 0 }, rejected: { blocker: 0, major: 0, minor: 1 } });
76
+ for (const member of Object.values(block.members)) for (const severity of ["blocker", "major", "minor"] as const) assert.equal(member.total[severity], member.applied[severity] + member.deferred[severity] + member.rejected[severity]);
77
+ });
78
+ test("zero-member and zero-finding completeness", () => {
79
+ const members = resolveMembers([...results, { model: "p/gamma:high", exitCode: 0, savedOutputPath: "/tmp/member-2-p-gamma-high.md" }])!;
80
+ const block = buildCouncil({ ...input(), members })!;
81
+ assert.deepEqual(block.members["p/gamma:high"], { dispatches: 1, total: zero, unique: zero, applied: zero, unique_applied: zero, deferred: zero, rejected: zero });
82
+ assert.equal(block.chair.members_reported, 2);
83
+ assert.deepEqual(buildCouncil({ ...input(), chair: { model: "p/chair:medium", dispatches: 1, clusters: [] }, audit: [] })!.chair, { model: "p/chair:medium", dispatches: 1, clusters: 0, members_reported: 0 });
84
+ });
85
+ test("incomplete batches return null", () => {
86
+ assert.equal(buildCouncil({ ...input(), audit: [...audit, { disposition: "rejected", severity: "minor", raisedBy: ["p-nobody"] }] }), null);
87
+ assert.equal(buildCouncil({ ...input(), chair: { ...input().chair, clusters: [...clusters, { severity: "minor", raisedBy: ["p-nobody"] }] } }), null);
88
+ assert.equal(buildCouncil({ ...input(), audit: [...audit, { disposition: "rejected", severity: "minor", raisedBy: ["p-alpha"] }] }), null);
89
+ assert.equal(buildCouncil({ ...input(), audit: audit.slice(0, 2) }), null);
90
+ assert.equal(buildCouncil({ ...input(), audit: [{ ...audit[0], severity: "major" }, audit[1], audit[2]] }), null);
91
+ });
@@ -0,0 +1,121 @@
1
+ export type Severity = "blocker" | "major" | "minor";
2
+ export type Disposition = "applied" | "deferred" | "rejected";
3
+ export type SeverityCounts = Record<Severity, number>;
4
+ export interface Cluster { severity: Severity; raisedBy: string[] }
5
+ export interface AuditItem extends Cluster { disposition: Disposition }
6
+ export interface CouncilMember {
7
+ dispatches: number; total: SeverityCounts; unique: SeverityCounts; applied: SeverityCounts;
8
+ unique_applied: SeverityCounts; deferred: SeverityCounts; rejected: SeverityCounts;
9
+ }
10
+ export interface CouncilBlock {
11
+ chair: { model: string; dispatches: number; clusters: number; members_reported: number };
12
+ members: Record<string, CouncilMember>;
13
+ }
14
+ export interface MemberResult { model?: string; exitCode?: number; savedOutputPath?: string }
15
+ export interface ResolvedMembers { dispatches: Map<string, number>; slugToModel: Map<string, string> }
16
+ const zero = (): SeverityCounts => ({ blocker: 0, major: 0, minor: 0 });
17
+ export const slug = (model: string): string => model.replace(/[^A-Za-z0-9]/g, "-");
18
+ export function memberSlugOf(path: string | undefined): string | undefined {
19
+ if (!path) return undefined;
20
+ return /^member-\d+-(.+?)(\.md)?$/.exec(path.split(/[\\/]/).pop() ?? "")?.[1];
21
+ }
22
+ export const normalizeRaisedBy = (token: string): string => slug(token.trim().replace(/^member-\d+-/, "").replace(/\.md$/, ""));
23
+ const attributes = (line: string): Cluster | undefined => {
24
+ const severity = /\[(blocker|major|minor)\]/i.exec(line);
25
+ const raisers = /raised-by:\s*\[([^\]]*)\]/i.exec(line);
26
+ if (!severity || !raisers) return undefined;
27
+ const raisedBy = [...new Set(raisers[1].split(",").map(normalizeRaisedBy).filter((x) => x && x !== "-"))];
28
+ return raisedBy.length ? { severity: severity[1].toLowerCase() as Severity, raisedBy } : undefined;
29
+ };
30
+ export function parseChairReport(text: string): { clusters: Cluster[] } | null {
31
+ if (!/^consensus:/m.test(text)) return null;
32
+ const lines = text.split("\n");
33
+ const start = lines.findIndex((line) => /^clusters:\s*$/.test(line));
34
+ if (start < 0) return null;
35
+ const clusters: Cluster[] = [];
36
+ for (const line of lines.slice(start + 1)) {
37
+ if (/^resolved:/.test(line)) break;
38
+ if (!/^\s*[-*]\s+/.test(line)) continue;
39
+ const item = attributes(line);
40
+ if (!item) return null;
41
+ clusters.push(item);
42
+ }
43
+ return { clusters };
44
+ }
45
+ export function parseAudit(text: string): AuditItem[] | null {
46
+ const seen = new Set<string>();
47
+ const items: AuditItem[] = [];
48
+ for (const line of text.split("\n")) {
49
+ const match = /^\s*(?:[-*]\s*)?(Applied|Deferred|Rejected):\s*(.*)$/.exec(line);
50
+ if (!match) continue;
51
+ const disposition = match[1].toLowerCase() as Disposition;
52
+ seen.add(disposition);
53
+ if (match[2].trim().toLowerCase() === "none") continue;
54
+ const item = attributes(match[2]);
55
+ if (!item) return null;
56
+ items.push({ disposition, ...item });
57
+ }
58
+ return seen.size === 3 ? items : null;
59
+ }
60
+ export function resolveMembers(results: MemberResult[]): ResolvedMembers | null {
61
+ const dispatches = new Map<string, number>();
62
+ const slugToModel = new Map<string, string>();
63
+ for (const result of results) {
64
+ if (typeof result.model !== "string") continue;
65
+ dispatches.set(result.model, (dispatches.get(result.model) ?? 0) + 1);
66
+ const memberSlug = result.exitCode === 0 ? memberSlugOf(result.savedOutputPath) : undefined;
67
+ if (!memberSlug) continue;
68
+ const previous = slugToModel.get(memberSlug);
69
+ if (previous !== undefined && previous !== result.model) return null;
70
+ slugToModel.set(memberSlug, result.model);
71
+ }
72
+ return { dispatches, slugToModel };
73
+ }
74
+ export interface CouncilInput {
75
+ chair: { model: string; dispatches: number; clusters: Cluster[] };
76
+ members: ResolvedMembers;
77
+ audit: AuditItem[];
78
+ }
79
+ export function buildCouncil(input: CouncilInput): CouncilBlock | null {
80
+ const resolve = (slugs: string[]): string[] | undefined => {
81
+ const models = new Set<string>();
82
+ for (const memberSlug of slugs) {
83
+ const model = input.members.slugToModel.get(memberSlug);
84
+ if (model === undefined) return undefined;
85
+ models.add(model);
86
+ }
87
+ return [...models].sort();
88
+ };
89
+ const key = (severity: Severity, models: string[]) => JSON.stringify([severity, models]);
90
+ const reported = new Set<string>();
91
+ const clusterKeys: string[] = [];
92
+ for (const cluster of input.chair.clusters) {
93
+ const models = resolve(cluster.raisedBy);
94
+ if (!models) return null;
95
+ for (const model of models) reported.add(model);
96
+ clusterKeys.push(key(cluster.severity, models));
97
+ }
98
+ const resolvedAudit: { item: AuditItem; models: string[] }[] = [];
99
+ for (const item of input.audit) {
100
+ const models = resolve(item.raisedBy);
101
+ if (!models) return null;
102
+ resolvedAudit.push({ item, models });
103
+ }
104
+ if (JSON.stringify(clusterKeys.sort()) !== JSON.stringify(resolvedAudit.map(({ item, models }) => key(item.severity, models)).sort())) return null;
105
+ const members: Record<string, CouncilMember> = {};
106
+ for (const [model, dispatches] of [...input.members.dispatches].sort(([a], [b]) => a.localeCompare(b))) {
107
+ members[model] = { dispatches, total: zero(), unique: zero(), applied: zero(), unique_applied: zero(), deferred: zero(), rejected: zero() };
108
+ }
109
+ for (const { item, models } of resolvedAudit) {
110
+ for (const model of models) {
111
+ const member = members[model];
112
+ member.total[item.severity]++;
113
+ member[item.disposition][item.severity]++;
114
+ if (models.length === 1) {
115
+ member.unique[item.severity]++;
116
+ if (item.disposition === "applied") member.unique_applied[item.severity]++;
117
+ }
118
+ }
119
+ }
120
+ return { chair: { model: input.chair.model, dispatches: input.chair.dispatches, clusters: input.chair.clusters.length, members_reported: reported.size }, members };
121
+ }
@@ -4,6 +4,7 @@ import {
4
4
  capEvents, derive, diffPhases, emptyAccumulators, emptyPhases, foldAccumulators, implementAutoCompletes,
5
5
  newRecord, parseRecord, serializeRecord, type Accumulators, type TelemetryEvent,
6
6
  } from "./telemetry-record.ts";
7
+ import type { CouncilBlock } from "./telemetry-council.ts";
7
8
 
8
9
  const ev = (over: Partial<TelemetryEvent> & { kind: string }): TelemetryEvent =>
9
10
  ({ ts: "2026-09-17T10:00:00Z", session: "s1", phase: "unphased", ...over }) as TelemetryEvent;
@@ -165,3 +166,28 @@ test("parseRecord drops malformed accumulator blocks and fills missing fields",
165
166
  assert.equal(derive(parsed, "2026-09-17T10:01:00Z").amendments, 2);
166
167
  });
167
168
 
169
+ const COUNCIL: CouncilBlock = {
170
+ chair: { model: "p/chair:medium", dispatches: 1, clusters: 2, members_reported: 2 },
171
+ members: {
172
+ "p/alpha:xhigh": { dispatches: 1, total: { blocker: 0, major: 1, minor: 1 }, unique: { blocker: 0, major: 1, minor: 0 }, applied: { blocker: 0, major: 1, minor: 0 }, unique_applied: { blocker: 0, major: 1, minor: 0 }, deferred: { blocker: 0, major: 0, minor: 0 }, rejected: { blocker: 0, major: 0, minor: 1 } },
173
+ "p/beta:high": { dispatches: 2, total: { blocker: 0, major: 0, minor: 1 }, unique: { blocker: 0, major: 0, minor: 0 }, applied: { blocker: 0, major: 0, minor: 0 }, unique_applied: { blocker: 0, major: 0, minor: 0 }, deferred: { blocker: 0, major: 0, minor: 0 }, rejected: { blocker: 0, major: 0, minor: 1 } },
174
+ },
175
+ };
176
+
177
+ test("derive carries derived.council through like tests/diff; absent stays absent", () => {
178
+ const rec = newRecord({ spec: "doc/specs/a.md", session: "s1", now: "2026-09-17T10:00:00Z", runId: "r1" });
179
+ assert.equal(derive(rec, "2026-09-17T10:01:00Z").council, undefined);
180
+ rec.derived.council = COUNCIL;
181
+ assert.deepEqual(derive(rec, "2026-09-17T10:01:00Z").council, COUNCIL);
182
+ });
183
+
184
+ test("serializeRecord writes council.chair and each member as flow maps under block keys; parseRecord round-trips", () => {
185
+ const rec = newRecord({ spec: "doc/specs/a.md", session: "s1", now: "2026-09-17T10:00:00Z", runId: "r1" });
186
+ rec.derived.council = COUNCIL;
187
+ const text = serializeRecord(rec);
188
+ assert.match(text, /^ council:\n chair: \{ model: p\/chair:medium, dispatches: 1, clusters: 2, members_reported: 2 \}\n members:\n p\/alpha:xhigh: \{ dispatches: 1, total: \{ blocker: 0, major: 1, minor: 1 \}/m);
189
+ assert.match(text, /^ p\/beta:high: \{ dispatches: 2, /m);
190
+ assert.deepEqual(parseRecord(text)!.derived.council, COUNCIL);
191
+ const plain = newRecord({ spec: "doc/specs/a.md", session: "s1", now: "2026-09-17T10:00:00Z", runId: "r2" });
192
+ assert.equal(parseRecord(serializeRecord(plain))!.derived.council, undefined);
193
+ });
@@ -1,6 +1,7 @@
1
1
  // Telemetry record model (#33): phases, events, accumulators, derive, YAML.
2
2
  import { Document, isCollection, isMap, isSeq, parse as parseYaml, stringify as stringifyYaml, type YAMLMap } from "yaml";
3
3
  import type { BucketStat, ShipOption } from "./telemetry-paths.ts";
4
+ import type { CouncilBlock } from "./telemetry-council.ts";
4
5
 
5
6
  export const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
6
7
  export type Phase = (typeof PHASES)[number];
@@ -91,7 +92,7 @@ export interface Derived {
91
92
  duration_s: number; phases: Partial<Record<PhaseKey, PhaseAcc & { started_at?: string; completed_at?: string; duration_s?: number; model?: string; thinking?: string }>>;
92
93
  personas: Record<string, PersonaAcc>; reviews?: Record<string, ReviewAcc>; conformance_loops: number; conformance_open_gaps?: number; gates: Gates & { ship_option?: string };
93
94
  plan?: { tasks: number; complete: number; failed: number; skipped: number }; tests?: { command: string; result: "pass" | "fail" }; amendments: number;
94
- spec_edits_after_ship: number; diff?: DiffSummary; modified_files?: string[]; spec_writes: Accumulators["spec_writes"]; events_dropped: number;
95
+ spec_edits_after_ship: number; diff?: DiffSummary; modified_files?: string[]; council?: CouncilBlock; spec_writes: Accumulators["spec_writes"]; events_dropped: number;
95
96
  }
96
97
  export type RecordStatus = "in_progress" | "shipped" | "abandoned";
97
98
  export interface TelemetryRecord {
@@ -121,7 +122,7 @@ export function derive(rec: TelemetryRecord, now: string): Derived {
121
122
  }
122
123
  for (const k of Object.keys(acc.phases) as PhaseKey[]) phases[k] = compact({ ...(phases[k] ?? {}), ...acc.phases[k] });
123
124
  const live = liveShipEvent(rec.events);
124
- return compact({ duration_s: seconds(rec.created_at, rec.shipped_at ?? rec.abandoned_at ?? now), phases, personas: acc.personas, reviews: Object.keys(acc.reviews).length ? acc.reviews : undefined, conformance_loops: acc.conformance_loops, conformance_open_gaps: acc.conformance_open_gaps, gates: compact({ ...acc.gates, ship_option: live?.option }), plan: rec.derived.plan, tests: rec.derived.tests, amendments: acc.amendments, spec_edits_after_ship: acc.spec_edits_after_ship, diff: rec.derived.diff, modified_files: rec.derived.modified_files, spec_writes: acc.spec_writes, events_dropped: acc.events_dropped }) as Derived;
125
+ return compact({ duration_s: seconds(rec.created_at, rec.shipped_at ?? rec.abandoned_at ?? now), phases, personas: acc.personas, reviews: Object.keys(acc.reviews).length ? acc.reviews : undefined, conformance_loops: acc.conformance_loops, conformance_open_gaps: acc.conformance_open_gaps, gates: compact({ ...acc.gates, ship_option: live?.option }), plan: rec.derived.plan, tests: rec.derived.tests, amendments: acc.amendments, spec_edits_after_ship: acc.spec_edits_after_ship, diff: rec.derived.diff, modified_files: rec.derived.modified_files, council: rec.derived.council, spec_writes: acc.spec_writes, events_dropped: acc.events_dropped }) as Derived;
125
126
  }
126
127
 
127
128
  const stripUndefined = (v: unknown): unknown => {
@@ -156,7 +157,11 @@ export function serializeRecord(rec: TelemetryRecord): string {
156
157
  const body = stripUndefined({ ...head, derived, accumulators }) as Record<string, unknown>;
157
158
  const doc = new Document(body);
158
159
  const derivedNode = doc.get("derived", true);
159
- if (isMap(derivedNode)) flowLeafChildren(derivedNode, new Set(["modified_files"]));
160
+ if (isMap(derivedNode)) {
161
+ flowLeafChildren(derivedNode, new Set(["modified_files", "council"]));
162
+ const council = derivedNode.get("council", true);
163
+ if (isMap(council)) flowLeafChildren(council);
164
+ }
160
165
  const accumulatorNode = doc.get("accumulators", true);
161
166
  if (isMap(accumulatorNode)) {
162
167
  for (const session of accumulatorNode.items) if (isMap(session.value)) flowLeafChildren(session.value);
@@ -511,6 +511,96 @@
511
511
 
512
512
  const subagentResult = (results: unknown[], content = "") => ({ toolName: "subagent", toolCallId: "sa", input: {}, content: [{ type: "text", text: content }], isError: false, details: { results } });
513
513
 
514
+ async function boundInBrainstorm() {
515
+ const h = harness();
516
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
517
+ await h.writeSpec("doc/specs/a.md");
518
+ return h;
519
+ }
520
+ const memberBatch = (isError = false) => ({ ...subagentResult([
521
+ { agent: "spec-council-member", exitCode: 0, model: "p/alpha:xhigh", savedOutputPath: "/tmp/c/member-0-p-alpha.md", usage: usage(1) },
522
+ { agent: "spec-council-member", exitCode: 1, model: "p/beta:high", usage: usage(1) },
523
+ { agent: "spec-council-member", exitCode: 0, model: "p/beta:high", savedOutputPath: "/tmp/c/retry/member-1-p-beta-high.md", usage: usage(1) },
524
+ ]), isError });
525
+ const CHAIR_TEXT = "consensus: needs-work\nclusters:\n- [major] a - raised-by: [p-alpha] - x\n- [minor] b - raised-by: [p-alpha, p-beta-high] - x\nresolved:\n";
526
+ const chairResult = (text = CHAIR_TEXT, exitCode = 0) => ({ ...subagentResult([{ agent: "spec-council-synthesizer", exitCode, model: "p/chair:medium", usage: usage(1) }], text), isError: exitCode !== 0 });
527
+ const AUDIT_TEXT = "Applied: [major] a - raised-by: [p-alpha] -> edit\nDeferred: none\nRejected: [minor] b - raised-by: [p-alpha, p-beta-high] -> reason";
528
+ const assistant = (text: string) => ({ type: "message_end", message: { role: "assistant", usage: usage(1), content: [{ type: "text", text }] } });
529
+ const counts = (major = 0, minor = 0) => ({ blocker: 0, major, minor });
530
+ const EXPECTED_COUNCIL = {
531
+ chair: { model: "p/chair:medium", dispatches: 1, clusters: 2, members_reported: 2 },
532
+ members: {
533
+ "p/alpha:xhigh": { dispatches: 1, total: counts(1, 1), unique: counts(1), applied: counts(1), unique_applied: counts(1), deferred: counts(), rejected: counts(0, 1) },
534
+ "p/beta:high": { dispatches: 2, total: counts(0, 1), unique: counts(), applied: counts(), unique_applied: counts(), deferred: counts(), rejected: counts(0, 1) },
535
+ },
536
+ };
537
+
538
+ test("council captures failed member batch, chair, audit and persists on later flush", async () => {
539
+ const h = await boundInBrainstorm();
540
+ await h.emit("tool_result", memberBatch(true));
541
+ await h.emit("tool_result", chairResult());
542
+ assert.equal(h.readRecord().derived.council, undefined);
543
+ await h.emit("message_end", assistant(AUDIT_TEXT));
544
+ assert.deepEqual(h.readRecord().derived.council, EXPECTED_COUNCIL);
545
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
546
+ assert.deepEqual(h.readRecord().derived.council, EXPECTED_COUNCIL);
547
+ assert.equal(h.readRecord().derived.personas["spec-council-member"], undefined);
548
+ });
549
+ test("council defaults missing chair model and survives unrelated result before audit", async () => {
550
+ const h = await boundInBrainstorm();
551
+ await h.emit("tool_result", memberBatch());
552
+ await h.emit("tool_result", subagentResult([{ agent: "spec-council-synthesizer", exitCode: 0, usage: usage(1) }], CHAIR_TEXT));
553
+ await h.emit("tool_result", subagentResult([{ agent: "spec-summarizer", exitCode: 0, model: "p/s", usage: usage(1) }]));
554
+ await h.emit("message_end", assistant(AUDIT_TEXT));
555
+ assert.deepEqual(h.readRecord().derived.council, { ...EXPECTED_COUNCIL, chair: { ...EXPECTED_COUNCIL.chair, model: "unknown" } });
556
+ });
557
+ test("council ignores plan phase", async () => {
558
+ const h = await boundInPlan();
559
+ await h.emit("tool_result", memberBatch());
560
+ await h.emit("tool_result", chairResult());
561
+ await h.emit("message_end", assistant(AUDIT_TEXT));
562
+ assert.equal(h.readRecord().derived.council, undefined);
563
+ });
564
+ test("council counts failed chair retries and retains last usable clusters", async () => {
565
+ const h = await boundInBrainstorm();
566
+ await h.emit("tool_result", memberBatch());
567
+ await h.emit("tool_result", chairResult("Failed", 1));
568
+ await h.emit("tool_result", chairResult());
569
+ await h.emit("tool_result", chairResult("no consensus here", 1));
570
+ await h.emit("message_end", assistant(AUDIT_TEXT));
571
+ assert.deepEqual(h.readRecord().derived.council!.chair, { ...EXPECTED_COUNCIL.chair, dispatches: 3 });
572
+ });
573
+ test("council retains pending on incomplete audit and ignores audit before chair", async () => {
574
+ const h = await boundInBrainstorm();
575
+ await h.emit("message_end", assistant(AUDIT_TEXT));
576
+ await h.emit("tool_result", memberBatch());
577
+ await h.emit("tool_result", chairResult());
578
+ await h.emit("message_end", assistant("Applied: [major] a - raised-by: [p-alpha] -> edit\nDeferred: none\nRejected: none"));
579
+ assert.equal(h.readRecord().derived.council, undefined);
580
+ await h.emit("message_end", assistant("Applied: a -> edit\nDeferred: none\nRejected: none"));
581
+ assert.equal(h.readRecord().derived.council, undefined);
582
+ await h.emit("message_end", assistant(AUDIT_TEXT));
583
+ assert.deepEqual(h.readRecord().derived.council, EXPECTED_COUNCIL);
584
+ });
585
+ test("council reset clears pending and new member batch supersedes held chair", async () => {
586
+ const h = await boundInBrainstorm();
587
+ await h.emit("tool_result", memberBatch());
588
+ await h.emit("tool_result", chairResult());
589
+ await h.phaseResult("reset", P());
590
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
591
+ await h.writeSpec("doc/specs/a.md");
592
+ await h.emit("message_end", assistant(AUDIT_TEXT));
593
+ assert.equal(h.readRecord().derived.council, undefined);
594
+ await h.emit("tool_result", memberBatch());
595
+ await h.emit("tool_result", chairResult());
596
+ await h.emit("tool_result", memberBatch());
597
+ await h.emit("message_end", assistant(AUDIT_TEXT));
598
+ assert.equal(h.readRecord().derived.council, undefined);
599
+ await h.emit("tool_result", chairResult());
600
+ await h.emit("message_end", assistant(AUDIT_TEXT));
601
+ assert.deepEqual(h.readRecord().derived.council, EXPECTED_COUNCIL);
602
+ });
603
+
514
604
  test("subagent results: dispatch events, persona tokens, spec_rounds, reviews, conformance_loops; async results: [] contribute nothing", async () => {
515
605
  const h = await boundInPlan();
516
606
  await h.emit("tool_result", subagentResult([
@@ -24,6 +24,7 @@
24
24
  import { COUNTED_USER_PHASES, REVIEWER_AGENTS, countFindings, countOpenGaps, hasReopen, insertedText, planTotals, textOf, usageToTokens } from "./lib/telemetry-collect.ts";
25
25
  import { addTokens, capEvents, compact, currentPhase, derive, diffPhases, emptyAccumulators, emptyPhases, foldAccumulators, implementAutoCompletes, liveShipEvent, newRecord, parseRecord, serializeRecord, type Accumulators, type BaseEvent, type PhaseAcc, type PhaseKey, type PhaseMap, type TelemetryEvent, type TelemetryRecord, type Tokens } from "./lib/telemetry-record.ts";
26
26
  import { guardReason } from "./lib/telemetry-ship.ts";
27
+ import { buildCouncil, parseAudit, parseChairReport, resolveMembers, type Cluster, type MemberResult } from "./lib/telemetry-council.ts";
27
28
 
28
29
  // ---- deps seam -------------------------------------------------------------------
29
30
 
@@ -204,6 +205,7 @@
204
205
  let settingsWarned = false;
205
206
  let sessionId = "";
206
207
  let currentDir = "";
208
+ let councilPending: { memberResults: MemberResult[]; chair?: { model: string; dispatches: number; clusters?: Cluster[] } } | undefined;
207
209
 
208
210
  // Per-event settings read; undefined => this event is a no-op.
209
211
  const enabledSettings = (ctx: ExtensionContext): SettingsSnapshot | undefined => {
@@ -364,6 +366,7 @@
364
366
  record = undefined;
365
367
  recordRel = undefined;
366
368
  frozen = false;
369
+ councilPending = undefined;
367
370
  };
368
371
 
369
372
  const isSafeSpecLink = (link: string): boolean => {
@@ -432,6 +435,7 @@
432
435
  ? { kind: "phase", action: "reset" }
433
436
  : { kind: "phase", action: t.action, name: t.name },
434
437
  );
438
+ if (t.action === "start" && t.name === "brainstorm") councilPending = undefined;
435
439
  phases = details.phases;
436
440
  if (record && t.name === "brainstorm" && (t.action === "complete" || t.action === "skip")) record.approved_at ??= e.ts;
437
441
  if (record && t.name === "ship" && t.action === "complete" && checkoutVia === "jj") await onShipKeep(snap);
@@ -538,8 +542,9 @@
538
542
  }
539
543
  return onSpecInteraction(event.toolName, loc, event, snap);
540
544
  }
541
- if (event.toolName === "subagent" && !event.isError) {
542
- await onSubagentResult(event.details, event.content);
545
+ if (event.toolName === "subagent") {
546
+ if (phaseNow() === "brainstorm") onCouncilResults(event.details, event.content);
547
+ if (!event.isError) await onSubagentResult(event.details, event.content);
543
548
  return undefined;
544
549
  }
545
550
  if (event.toolName === "plan_tracker" && !event.isError) {
@@ -625,6 +630,42 @@
625
630
  acc.tokens = addTokens(acc.tokens, t);
626
631
  };
627
632
 
633
+ const onCouncilResults = (details: unknown, content: unknown) => {
634
+ const results = (details as { results?: unknown[] } | undefined)?.results;
635
+ if (!Array.isArray(results) || !results.length) return;
636
+ const typed = results.map((raw) => raw as { agent?: unknown; exitCode?: unknown; model?: unknown; savedOutputPath?: unknown });
637
+ if (councilPending?.chair && typed.some((r) => r.agent === "spec-council-member")) councilPending = undefined;
638
+ for (const r of typed) {
639
+ if (r.agent === "spec-council-member") {
640
+ (councilPending ??= { memberResults: [] }).memberResults.push({
641
+ model: typeof r.model === "string" ? r.model : undefined,
642
+ exitCode: typeof r.exitCode === "number" ? r.exitCode : undefined,
643
+ savedOutputPath: typeof r.savedOutputPath === "string" ? r.savedOutputPath : undefined,
644
+ });
645
+ } else if (r.agent === "spec-council-synthesizer") {
646
+ const chair = ((councilPending ??= { memberResults: [] }).chair ??= { model: "unknown", dispatches: 0 });
647
+ if (typeof r.model === "string") chair.model = r.model;
648
+ chair.dispatches += 1;
649
+ const parsed = parseChairReport(textOf(content));
650
+ if (parsed) chair.clusters = parsed.clusters;
651
+ }
652
+ }
653
+ };
654
+
655
+ const onCouncilAudit = (content: unknown) => {
656
+ const pending = councilPending;
657
+ const clusters = pending?.chair?.clusters;
658
+ if (!record || !pending?.chair || !clusters) return;
659
+ const audit = parseAudit(textOf(content));
660
+ if (!audit) return;
661
+ const members = resolveMembers(pending.memberResults);
662
+ const block = members && buildCouncil({ chair: { ...pending.chair, clusters }, members, audit });
663
+ if (!block) return;
664
+ record.derived.council = block;
665
+ councilPending = undefined;
666
+ flush();
667
+ };
668
+
628
669
  const onSubagentResult = async (details: unknown, content: unknown) => {
629
670
  const results = (details as { results?: unknown[] } | undefined)?.results;
630
671
  if (!Array.isArray(results) || results.length === 0) return;
@@ -723,9 +764,10 @@
723
764
  pi.on("message_end", async (event, ctx) => {
724
765
  lastCtx = ctx;
725
766
  if (!enabledSettings(ctx)) return;
726
- const msg = event.message as { role?: string; usage?: unknown };
767
+ const msg = event.message as { role?: string; usage?: unknown; content?: unknown };
727
768
  if (msg.role !== "assistant") return;
728
769
  addPhaseTokens(usageToTokens(msg.usage));
770
+ if (phaseNow() === "brainstorm") onCouncilAudit(msg.content);
729
771
  });
730
772
 
731
773
  pi.on("turn_end", async (_event, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "5.20.0",
3
+ "version": "5.22.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",
@@ -44,7 +44,7 @@ Work through the items below **in order**. This is your own checklist to follow,
44
44
  4. **Understand the idea against the draft** - see [Understand the idea](#3-understand-the-idea).
45
45
  5. **Propose 2-3 approaches** - see [Explore approaches](#4-explore-approaches).
46
46
  6. **Present the design** - see [Present the design in two rounds](#6-present-the-design-in-two-rounds).
47
- 7. **Write the spec** - follow [Spec Self-Review](#spec-self-review-before-user-review-gate) steps 1-4.
47
+ 7. **Write the spec** - follow [Spec Self-Review](#spec-self-review-before-user-review-gate) steps 1-5.
48
48
  8. **Spec self-review (lint)** - run the inline checks in [Spec Self-Review](#spec-self-review-before-user-review-gate).
49
49
  9. **Critique pass (auto-dispatched)** - use [Spec Council](#spec-council-optional).
50
50
  10. **Re-run placeholder scan** - follow [Spec Self-Review](#spec-self-review-before-user-review-gate).
@@ -163,7 +163,21 @@ Spec-writing replaces the context draft, in this exact order:
163
163
  `# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing` — before
164
164
  dispatching lint, critique, council, or summarizer. The phase-tracker commit
165
165
  guard is a backstop, not the primary check.
166
- 4. **After the line-1 check and before the inline lint**, `edit` any known
166
+ 4. **After the line-1 check**, run the second predecessor pass. Compose a query from
167
+ the spec's H1 terms, then the terms of every H2 that is not a template heading
168
+ (Problem, Acceptance criteria, Design, Errors and edge cases, Tests, Documentation
169
+ impact, Out of scope, Open questions), then the `**Goal:**` line's terms - same
170
+ token rules as the scout, deduplicated, cut at 15. Run
171
+ `(cd <abs worktree path> && node <SPEC_INDEX> --query '<terms>' --limit 5 --exclude <spec path relative to the worktree>)`
172
+ with `<SPEC_INDEX>` resolved as in `gatherer.md`. Take the `live` rows and drop every
173
+ path the draft's `Predecessor:` line(s) named (retained from the step-1 read). Carry
174
+ the remainder to the gate as one adjacent line:
175
+ `New predecessor candidates at spec-writing: <path> (<title>), ... - index rows are a hint; code is the source and an absent row proves nothing.`
176
+ or `New predecessor candidates at spec-writing: none.` A non-zero exit degrades to
177
+ `Second predecessor pass unavailable: <first stderr line>`; it never blocks the gate
178
+ or the commit. "yes, <path> is a predecessor" at the gate is a change request that
179
+ adds the `supersedes <path>, <scope>` clause and the banner, then re-presents the gate.
180
+ 5. **After the second predecessor pass and before the inline lint**, `edit` any known
167
181
  predecessor spec to insert its supersession banner (see
168
182
  [Marking superseded specs](reference/superseding.md)). This position is fixed:
169
183
  the banner is written after any slug rename, so it always cites the final path.
@@ -238,12 +252,13 @@ Paste the summary verbatim, unedited in the template below; use adjacent lines f
238
252
  Spec written and committed to <project>/doc/specs/<filename>.md (worktree: <path>).
239
253
 
240
254
  Coverage: <N> of <M> members reported; <slug>: <reason> (line present only when coverage was partial)
241
- Applied: <cluster -> edit>, ...
242
- Deferred: <cluster -> where it belongs>, ...
243
- Rejected: <cluster -> one-line reason>, ...
244
- (omit the audit lines above when the worker path ran, not the council)
255
+ Applied: [<severity>] <cluster> — raised-by: [<slugs>] -> <edit>
256
+ Deferred: [<severity>] <cluster> — raised-by: [<slugs>] -> <where it belongs>
257
+ Rejected: [<severity>] <cluster> — raised-by: [<slugs>] -> <one-line reason>
258
+ (one line per item, exactly as returned by roasting-the-spec - `Applied: none` / `Deferred: none` / `Rejected: none` when a list is empty; omit the audit lines when the worker path ran, not the council)
245
259
 
246
260
  <unresolved ambiguities; every gap-footer entry from the summary>
261
+ New predecessor candidates at spec-writing: <path> (<title>), ... - index rows are a hint; code is the source and an absent row proves nothing.
247
262
 
248
263
  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.
249
264
  ```
@@ -288,6 +303,7 @@ One question at a time, YAGNI, 2-3 approaches, two design rounds, clarify freely
288
303
  - Proposed-change execution before approval ([owner](#hard-constraint)).
289
304
  - Plan before approval; brainstorming invocation for an amend ([owner](#user-review-gate)).
290
305
  - Missing predecessor banner; invalid multi-spec split ([owner](#spec-self-review-before-user-review-gate); [owner](#2-scope-check)).
306
+ - Gate reached without the second predecessor pass ([owner](#spec-self-review-before-user-review-gate)).
291
307
  - Approaches while a contradicted premise remains unresolved ([owner](#3-understand-the-idea)).
292
308
  - Amend without `reference/amendment-surface.md`; waiting after an amend grant; auto-applying a redraw ([owner](#amending-an-approved-spec)).
293
309
 
@@ -53,11 +53,13 @@ Scout (always dispatched):
53
53
  > instead; cite the old spec only for its unsuperseded sections (banner contract:
54
54
  > `reference/superseding.md`). Predecessor check: compose a 5-15 term keyword query
55
55
  > from the request (topic nouns, component names, file names - not stop words; if
56
- > the request is only a ticket reference, take the terms from the ticket title via
56
+ > the request is only a ticket reference, take the terms from the ticket title and body via
57
57
  > the tracker CLI when one is available, otherwise use the fallback below). Run
58
58
  > `node <SPEC_INDEX> --query '<keywords>' --limit 10` from the worktree root,
59
59
  > keeping the keywords inside single quotes, and treat its rows as the candidate
60
- > list. If the command fails, fall back to listing the project's spec directory
60
+ > list; zero rows means the index found no evidence, not that no predecessor exists -
61
+ > judge `Predecessor: none` from the code recon. If the command fails, fall back to
62
+ > listing the project's spec directory
61
63
  > and reading titles and `**Goal:**` lines, and write
62
64
  > `Spec index unavailable - predecessor check used directory listing.` in your
63
65
  > handoff. Either way open at
@@ -67,7 +69,8 @@ Scout (always dispatched):
67
69
  > Judge by topic; shared file paths never decide.
68
70
  > The `files` column of each candidate row is the `;`-separated list of repo-relative
69
71
  > paths that predecessor's ship modified and that still exist, or the literal `missing`,
70
- > or blank; do not recompute it from git or telemetry. After the `Predecessor:` line(s),
72
+ > or blank; do not recompute it from git or telemetry. The `state` column is `live` or
73
+ > `superseded`; cite a `superseded` row only through its successor. After the `Predecessor:` line(s),
71
74
  > and only when at least one predecessor is named, render a `Predecessor anchors`
72
75
  > section: list every attributed path exactly once, attributed to the first named
73
76
  > predecessor in index output order whose cell lists it, with no per-path commentary
@@ -18,13 +18,14 @@ The CLI parses and aggregates; you reason. Never open a telemetry YAML record yo
18
18
 
19
19
  ## Read the digest
20
20
 
21
- `runs` has one row per record; `shipped*` marks a truncated run (no ship phase recorded - an older salvage stamped it at landing; its `wall` is `-` and it feeds no aggregate). `by version` groups by pi-gauntlet version: `n` counts every row, `shipped` counts the rows behind the p50/max columns. `grants` is `fix_round_grants` - the fix-round proxy (human-granted extra review rounds); schema 1 has no code-review round count.
21
+ `runs` has one row per record; `shipped*` marks a truncated run (no ship phase recorded - an older salvage stamped it at landing; its `wall` is `-` and it feeds no aggregate). `by version` groups by pi-gauntlet version: `n` counts every row, `shipped` counts the rows behind the p50/max columns. `grants` is `fix_round_grants` - the fix-round proxy (human-granted extra review rounds); schema 1 has no code-review round count. Read `council` by roster: counts are blocker/major/minor, `uniq_appl_nonminor/run` counts unique-and-applied blocker/major findings per run, and `chair:` summarizes synthesis and retries. Read severity as the chair's consolidated severity (the highest any co-raiser assigned), not the member's own. A roster with fewer than five runs is not assessed, and older records can have no council data.
22
22
 
23
23
  ## Reply - exactly this, in this order
24
24
 
25
25
  1. **Recommendation** (2-4 sentences). One claim, led by the run that exemplifies it: quote its `spec` slug, `run_id`, and the 1-3 numbers that carry the claim. When `grants` is the evidence, call it the fix-round proxy. If no version group has `shipped >= 2`, the recommendation is "sample too small" with the `n`/`shipped` counts per version.
26
26
  2. **Cornerstones**: 3-5 bullets of aggregate facts from `by version` - corpus size, truncated count, the p50s and model tallies that moved between versions.
27
- 3. **Menu**, numbered, at most 3 items, rendered exactly as:
27
+ 3. **Council assessment**: Render this block independently of the version sample gate, even when item 1 says "sample too small". If the digest prints `no council data in selected records`, render only `council: no data`. If the most recent roster has fewer than 5 runs, render only `council: <roster> - not enough runs to assess (N of 5)`. Otherwise render one line per `flag:` of the most recent roster as the roster-change recommendations; when an older roster exists, add one line comparing the two rosters' `uniq_appl_nonminor/run` and rejected share as groups. Compare rosters as groups, never a member across rosters.
28
+ 4. **Menu**, numbered, at most 3 items, rendered exactly as:
28
29
  - `1. render report` - ask for a target path; write markdown there: the digest verbatim, then the recommendation and cornerstones above. If the file exists, ask before overwriting. Write nothing unless this item is chosen.
29
30
  - `2. open recommendation as ticket` - hand the claim and its numbers to `/skill:shape-ticket`; never create a ticket directly.
30
31
  - `3. drill into <slug>` - re-run the CLI with `--json` and show that run's fields.
@@ -126,7 +126,7 @@ For each cluster in the chair's report, decide one of:
126
126
 
127
127
  Also inline any `external-ref:` cluster you have context for (e.g. a ticket fetched during brainstorming) as part of the apply-set — this is your call, same as any other cluster.
128
128
 
129
- An `over-spec:` cluster decided **apply** is executed as deletion or shrink of the quoted clause **and** any acceptance-criteria or testing-approach line that exists only for it. Its audit line reads `Applied: over-spec: <clause> -> cut (was adds: M files / N tests / K ACs)` or `Applied: over-spec: <clause> -> shrunk to <replacement> (was adds: ...)`, so the gate shows what was removed. `defer`/`reject` are unchanged.
129
+ An `over-spec:` cluster decided **apply** is executed as deletion or shrink of the quoted clause **and** any acceptance-criteria or testing-approach line that exists only for it. Its audit line reads `Applied: [<severity>] over-spec: <clause> — raised-by: [<slugs>] -> cut (was adds: M files / N tests / K ACs)` or `Applied: [<severity>] over-spec: <clause> — raised-by: [<slugs>] -> shrunk to <replacement> (was adds: ...)`, so the gate shows what was removed. `defer`/`reject` are unchanged.
130
130
 
131
131
  You are the advocate — decide on scope grounds — and, unlike a dispatched subagent, also the executor: you hold `edit`/`write` tools directly, so apply the edit yourself instead of proposing it for someone else to make. Do this **before** returning to brainstorming.
132
132
 
@@ -135,9 +135,9 @@ You are the advocate — decide on scope grounds — and, unlike a dispatched su
135
135
  Return a structured audit, gate-only (not a committed spec section) — a coverage line plus three labelled lists:
136
136
 
137
137
  - `Coverage:` — `N of M members reported; <slug>: <reason>` — present only when member coverage was partial; omitted at full coverage.
138
- - `Applied:` — one of `Applied: <cluster> -> <edit> (grounded: <member probe>)`, `Applied: <cluster> -> <edit> (probed: <check> - <result>)` for a confirmed hypothesis, `Applied: <cluster> -> open question (<not found | inconclusive: <check> | contradicted: <result>>)`. The probe rides on the audit line because member files are removed in section 5.
139
- - `Deferred:` — cluster -> where it belongs.
140
- - `Rejected:` — cluster -> one-line reason.
138
+ - `Applied:` — one line per applied cluster: `Applied: [<severity>] <cluster> — raised-by: [<slugs>] -> <edit> (grounded: <member probe>)`, `... -> <edit> (probed: <check> - <result>)` for a confirmed hypothesis, or `... -> open question (<not found | inconclusive: <check> | contradicted: <result>>)`. Copy `[<severity>]` and `raised-by: [...]` verbatim from the cluster line - telemetry joins on them. No applied cluster -> the single line `Applied: none`. The probe rides on the audit line because member files are removed in section 5.
139
+ - `Deferred:` — one line per deferred cluster, same `[<severity>] <cluster> — raised-by: [<slugs>]` prefix, then `-> <where it belongs>`. None -> `Deferred: none`.
140
+ - `Rejected:` — one line per rejected cluster, same prefix, then `-> <one-line reason>`. None -> `Rejected: none`.
141
141
 
142
142
  Hand this audit to brainstorming along with the now-final spec. brainstorming writes it into the **spec commit message body** (git-native, readable pre-squash) so it survives for finish-time revert visibility, then shows it to the user alongside the final spec at its one review gate. The user can revert any applied edit there — that gate, not this skill, is where ratification happens.
143
143