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.
- package/CHANGELOG.md +20 -0
- package/README.md +3 -3
- package/bin/gauntlet-performance.mjs +233 -115
- package/bin/gauntlet-performance.test.mjs +100 -0
- package/bin/gauntlet-spec-index.mjs +83 -22
- package/bin/gauntlet-spec-index.test.mjs +181 -61
- package/bin/gauntlet-telemetry-seal.mjs +6 -2
- package/bin/gauntlet-telemetry-seal.test.mjs +10 -0
- package/extensions/lib/telemetry-council.test.ts +91 -0
- package/extensions/lib/telemetry-council.ts +121 -0
- package/extensions/lib/telemetry-record.test.ts +26 -0
- package/extensions/lib/telemetry-record.ts +8 -3
- package/extensions/telemetry.test.ts +90 -0
- package/extensions/telemetry.ts +45 -3
- package/package.json +1 -1
- package/skills/brainstorming/SKILL.md +22 -6
- package/skills/brainstorming/gatherer.md +6 -3
- package/skills/gauntlet-performance/SKILL.md +3 -2
- package/skills/roasting-the-spec/SKILL.md +4 -4
|
@@ -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))
|
|
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([
|
package/extensions/telemetry.ts
CHANGED
|
@@ -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"
|
|
542
|
-
|
|
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
|
@@ -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-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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. **
|
|
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
|
|
139
|
-
- `Deferred:` — cluster
|
|
140
|
-
- `Rejected:` — cluster
|
|
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
|
|