pi-gauntlet 5.7.0 → 5.8.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 +5 -0
- package/README.md +4 -3
- package/extensions/lib/gauntlet-settings.test.ts +79 -4
- package/extensions/lib/gauntlet-settings.ts +69 -0
- package/extensions/lib/telemetry-collect.test.ts +50 -0
- package/extensions/lib/telemetry-collect.ts +59 -0
- package/extensions/lib/telemetry-paths.test.ts +98 -0
- package/extensions/lib/telemetry-paths.ts +145 -0
- package/extensions/lib/telemetry-record.test.ts +173 -0
- package/extensions/lib/telemetry-record.ts +181 -0
- package/extensions/lib/telemetry-ship.test.ts +23 -0
- package/extensions/lib/telemetry-ship.ts +17 -0
- package/extensions/telemetry.test.ts +686 -0
- package/extensions/telemetry.ts +780 -0
- package/extensions/verify-before-ship.ts +6 -16
- package/package.json +6 -2
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry extension (#33): records one committed YAML record per gauntlet run,
|
|
3
|
+
* keyed by spec path, from pi events alone (no skill cooperation), and blocks
|
|
4
|
+
* `write` into an already-shipped spec while brainstorm is in progress.
|
|
5
|
+
* Pure helpers: extensions/lib/telemetry-*.ts. The default export takes a `deps`
|
|
6
|
+
* seam ({ fs, git, now, settings }) for the harness tests.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import * as nodeFs from "node:fs";
|
|
12
|
+
import { dirname, join, posix } from "node:path";
|
|
13
|
+
import * as piRuntime from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { DEFAULT_TEST_COMMANDS, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
|
|
17
|
+
import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
|
|
18
|
+
import { CONTEXT_DRAFT_MARKER } from "./lib/phase-tracker-helpers.ts";
|
|
19
|
+
import { sha256 } from "./lib/plan-check.ts";
|
|
20
|
+
import { SUPERSEDED_BY_RE, aggregateNumstat, isPlanPath, isSpecPath, isSupersededByBanner, matchDiscardStatement, matchShipStatement, matchTestStatement, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
|
|
21
|
+
import { COUNTED_USER_PHASES, REVIEWER_AGENTS, countFindings, countOpenGaps, hasReopen, insertedText, planTotals, textOf, usageToTokens } from "./lib/telemetry-collect.ts";
|
|
22
|
+
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";
|
|
23
|
+
import { BASE_REFS, guardReason, modifiedFilesFrom } from "./lib/telemetry-ship.ts";
|
|
24
|
+
|
|
25
|
+
// ---- deps seam -------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface FsPort {
|
|
28
|
+
readFile(p: string): string | undefined;
|
|
29
|
+
writeFile(p: string, text: string): void;
|
|
30
|
+
rename(from: string, to: string): void;
|
|
31
|
+
mkdirp(p: string): void;
|
|
32
|
+
exists(p: string): boolean;
|
|
33
|
+
unlink(p: string): void;
|
|
34
|
+
}
|
|
35
|
+
export interface GitResult {
|
|
36
|
+
code: number;
|
|
37
|
+
stdout: string;
|
|
38
|
+
stderr: string;
|
|
39
|
+
}
|
|
40
|
+
export interface SettingsSnapshot {
|
|
41
|
+
telemetry: TelemetryResolved;
|
|
42
|
+
errors: string[];
|
|
43
|
+
agentOverrides?: Record<string, unknown>;
|
|
44
|
+
versions: Record<string, string>;
|
|
45
|
+
testCommands?: string[];
|
|
46
|
+
}
|
|
47
|
+
export interface Deps {
|
|
48
|
+
fs: FsPort;
|
|
49
|
+
git: (args: string[], cwd: string) => Promise<GitResult>;
|
|
50
|
+
now: () => string;
|
|
51
|
+
settings: (cwd: string) => SettingsSnapshot;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const realFs: FsPort = {
|
|
55
|
+
readFile: (p) => {
|
|
56
|
+
try {
|
|
57
|
+
return nodeFs.readFileSync(p, "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
writeFile: (p, text) => nodeFs.writeFileSync(p, text),
|
|
63
|
+
rename: (a, b) => nodeFs.renameSync(a, b),
|
|
64
|
+
mkdirp: (p) => nodeFs.mkdirSync(p, { recursive: true }),
|
|
65
|
+
exists: (p) => nodeFs.existsSync(p),
|
|
66
|
+
unlink: (p) => nodeFs.rmSync(p, { force: true }),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Bounded, never throws.
|
|
70
|
+
export const realGit = (args: string[], cwd: string): Promise<GitResult> =>
|
|
71
|
+
new Promise((res) => {
|
|
72
|
+
execFile("git", args, { cwd, timeout: 10_000, encoding: "utf8" }, (err, stdout, stderr) => {
|
|
73
|
+
const code = err ? (typeof (err as { code?: unknown }).code === "number" ? ((err as { code: number }).code) : 1) : 0;
|
|
74
|
+
res({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? err?.message ?? "") });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const isoNow = (): string => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
79
|
+
|
|
80
|
+
const readPkgVersion = (p: string): string | undefined => {
|
|
81
|
+
try {
|
|
82
|
+
const v = JSON.parse(nodeFs.readFileSync(p, "utf8")).version;
|
|
83
|
+
return typeof v === "string" ? v : undefined;
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function realVersions(): Record<string, string> {
|
|
90
|
+
const out: Record<string, string> = {};
|
|
91
|
+
const piVersion = (piRuntime as { VERSION?: unknown }).VERSION;
|
|
92
|
+
if (typeof piVersion === "string") out.pi = piVersion;
|
|
93
|
+
const self = readPkgVersion(join(dirname(new URL(import.meta.url).pathname), "..", "package.json"));
|
|
94
|
+
if (self) out["pi-gauntlet"] = self;
|
|
95
|
+
for (const pkg of ["pi-cohort", "pi-quiver", "pi-condense"]) {
|
|
96
|
+
const v = readPkgVersion(join(getAgentDir(), "npm", "node_modules", pkg, "package.json"));
|
|
97
|
+
if (v) out[pkg] = v;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function realSettings(cwd: string): SettingsSnapshot {
|
|
103
|
+
const { gauntlet, errors } = loadGauntletSettings(cwd);
|
|
104
|
+
const telemetry = resolveTelemetry(gauntlet);
|
|
105
|
+
const sm = SettingsManager.create(cwd, getAgentDir());
|
|
106
|
+
const layer = (s: unknown) => ((s as { subagents?: { agentOverrides?: unknown } })?.subagents?.agentOverrides ?? undefined) as Record<string, unknown> | undefined;
|
|
107
|
+
const preset = layer(sm.getGlobalSettings());
|
|
108
|
+
const repo = layer(sm.getProjectSettings());
|
|
109
|
+
const agentOverrides = preset || repo ? { ...(preset ?? {}), ...(repo ?? {}) } : undefined;
|
|
110
|
+
const rawCmds = gauntlet.verifyBeforeShip?.testCommands;
|
|
111
|
+
const testCommands = Array.isArray(rawCmds) && rawCmds.length > 0 && rawCmds.every((c) => typeof c === "string" && c.trim()) ? (rawCmds as string[]) : undefined;
|
|
112
|
+
return { telemetry, errors, agentOverrides, versions: realVersions(), testCommands };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const realDeps: Deps = { fs: realFs, git: realGit, now: isoNow, settings: realSettings };
|
|
116
|
+
|
|
117
|
+
// ---- replay ------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
interface ReplayResult {
|
|
120
|
+
phases: PhaseMap;
|
|
121
|
+
planCheckSpec?: string; // absolute
|
|
122
|
+
lastSpecWrite?: string; // as passed to the tool
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Walks the session branch like phase-tracker.ts: successful phase_tracker results
|
|
126
|
+
// set the phase map; plan_check pass specPath and successful spec writes feed binding.
|
|
127
|
+
// Silent: no events are emitted (each was already flushed when it happened).
|
|
128
|
+
export function replayBranch(entries: Iterable<unknown>): ReplayResult {
|
|
129
|
+
const out: ReplayResult = { phases: emptyPhases() };
|
|
130
|
+
const calls = new Map<string, { name: string; args: unknown }>();
|
|
131
|
+
for (const raw of entries) {
|
|
132
|
+
const entry = raw as { type?: string; message?: { role?: string; content?: unknown; toolName?: string; toolCallId?: string; isError?: boolean; details?: unknown } };
|
|
133
|
+
if (entry.type !== "message" || !entry.message) continue;
|
|
134
|
+
const msg = entry.message;
|
|
135
|
+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
136
|
+
for (const c of msg.content as { type?: string; id?: string; name?: string; arguments?: unknown }[])
|
|
137
|
+
if (c?.type === "toolCall" && c.id && c.name) calls.set(c.id, { name: c.name, args: c.arguments });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (msg.role !== "toolResult") continue;
|
|
141
|
+
if (msg.toolName === "phase_tracker") {
|
|
142
|
+
const d = msg.details as { phases?: PhaseMap; error?: string } | undefined;
|
|
143
|
+
if (d?.phases && !d.error) out.phases = d.phases;
|
|
144
|
+
} else if (msg.toolName === "plan_check") {
|
|
145
|
+
const d = msg.details as { status?: string; specPath?: string } | undefined;
|
|
146
|
+
if (d?.status === "pass" && typeof d.specPath === "string") out.planCheckSpec = d.specPath;
|
|
147
|
+
} else if (msg.toolName === "write" && !msg.isError && msg.toolCallId) {
|
|
148
|
+
const p = (calls.get(msg.toolCallId)?.args as { path?: unknown } | undefined)?.path;
|
|
149
|
+
if (typeof p === "string" && isSpecPath(toPosix(p))) out.lastSpecWrite = p;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- extension -----------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
const isSubagentChild = (): boolean => Number(process.env.PI_SUBAGENT_DEPTH ?? "0") > 0;
|
|
158
|
+
|
|
159
|
+
export default function (pi: ExtensionAPI, deps: Deps = realDeps) {
|
|
160
|
+
let phases: PhaseMap = emptyPhases();
|
|
161
|
+
let toplevel: string | undefined;
|
|
162
|
+
let inGit = false;
|
|
163
|
+
let boundSpec: string | undefined; // repo-relative
|
|
164
|
+
let record: TelemetryRecord | undefined;
|
|
165
|
+
const predecessorLinks = new Set<string>();
|
|
166
|
+
let recordRel: string | undefined; // repo-relative record path
|
|
167
|
+
let oldRecordRel: string | undefined; // pending rename partner for the next commit
|
|
168
|
+
let buffer: TelemetryEvent[] = [];
|
|
169
|
+
let pending: Accumulators = emptyAccumulators();
|
|
170
|
+
let lastCommitted = "";
|
|
171
|
+
let frozen = false;
|
|
172
|
+
let settingsWarned = false;
|
|
173
|
+
let sessionId = "";
|
|
174
|
+
|
|
175
|
+
// Per-event settings read; undefined => this event is a no-op.
|
|
176
|
+
const enabledSettings = (ctx: ExtensionContext): SettingsSnapshot | undefined => {
|
|
177
|
+
if (isSubagentChild()) return undefined;
|
|
178
|
+
const s = deps.settings(ctx.cwd);
|
|
179
|
+
if (!s.telemetry.enabled) return undefined;
|
|
180
|
+
return s;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const ensureToplevel = async (ctx: ExtensionContext): Promise<string> => {
|
|
184
|
+
if (toplevel) return toplevel;
|
|
185
|
+
const r = await deps.git(["rev-parse", "--show-toplevel"], ctx.cwd);
|
|
186
|
+
inGit = r.code === 0 && r.stdout.trim() !== "";
|
|
187
|
+
toplevel = inGit ? r.stdout.trim() : ctx.cwd;
|
|
188
|
+
return toplevel;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const live = (): Accumulators => record
|
|
192
|
+
? (record.accumulators[sessionId] ??= emptyAccumulators())
|
|
193
|
+
: pending;
|
|
194
|
+
|
|
195
|
+
const abs = (rel: string): string => join(toplevel!, rel);
|
|
196
|
+
const phaseNow = (): PhaseKey => currentPhase(phases);
|
|
197
|
+
|
|
198
|
+
const pushEvent = (e: Omit<TelemetryEvent, "ts" | "session" | "phase"> & Partial<BaseEvent>): TelemetryEvent => {
|
|
199
|
+
const full = { ts: deps.now(), session: sessionId, phase: phaseNow(), ...e } as TelemetryEvent;
|
|
200
|
+
if (record) record.events.push(full);
|
|
201
|
+
else buffer.push(full);
|
|
202
|
+
return full;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const warn = (message: string) => pushEvent({ kind: "warning", message });
|
|
206
|
+
|
|
207
|
+
// ---- record store ----
|
|
208
|
+
const writeRecord = () => {
|
|
209
|
+
if (!record || !recordRel || frozen) return;
|
|
210
|
+
if (!toplevel || !deps.fs.exists(toplevel)) {
|
|
211
|
+
frozen = true;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const capped = capEvents(record.events);
|
|
215
|
+
if (capped.dropped) {
|
|
216
|
+
record.events = capped.events;
|
|
217
|
+
live().events_dropped += capped.dropped;
|
|
218
|
+
}
|
|
219
|
+
record.derived = derive(record, deps.now());
|
|
220
|
+
const target = abs(recordRel);
|
|
221
|
+
try {
|
|
222
|
+
deps.fs.mkdirp(dirname(target));
|
|
223
|
+
deps.fs.writeFile(target + ".tmp", serializeRecord(record));
|
|
224
|
+
deps.fs.rename(target + ".tmp", target);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
pushEvent({ kind: "warning", message: `record write failed: ${String((e as Error).message ?? e).split("\n")[0]}` });
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const commitRecord = async () => {
|
|
231
|
+
if (!record || !recordRel || frozen || !inGit) return;
|
|
232
|
+
const text = deps.fs.readFile(abs(recordRel)) ?? "";
|
|
233
|
+
if (text === lastCommitted) return;
|
|
234
|
+
const paths = [recordRel, ...(oldRecordRel ? [oldRecordRel] : [])];
|
|
235
|
+
const add = await deps.git(["add", "-f", "--", ...paths], toplevel!);
|
|
236
|
+
let commit = add.code === 0 ? await deps.git(["commit", "-q", "-m", `telemetry: ${record.spec}`, "--", ...paths], toplevel!) : add;
|
|
237
|
+
if (commit.code !== 0 && /index\.lock/.test(commit.stderr)) {
|
|
238
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
239
|
+
commit = await deps.git(["commit", "-q", "-m", `telemetry: ${record.spec}`, "--", ...paths], toplevel!);
|
|
240
|
+
}
|
|
241
|
+
if (commit.code !== 0) {
|
|
242
|
+
warn(`git commit failed: ${(commit.stderr.trim().split("\n")[0] || "unknown error")}`);
|
|
243
|
+
writeRecord();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
lastCommitted = text;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Write point = every event / accumulator change on a boundary; checkpoint = also commit.
|
|
250
|
+
const flush = async (checkpoint: boolean) => {
|
|
251
|
+
if (!record) return;
|
|
252
|
+
writeRecord();
|
|
253
|
+
if (checkpoint) await commitRecord();
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
let currentDir = "";
|
|
257
|
+
|
|
258
|
+
const loadOrCreate = (specRel: string): TelemetryRecord => {
|
|
259
|
+
const rel = recordPathFor(currentDir, specRel);
|
|
260
|
+
const existing = deps.fs.readFile(abs(rel));
|
|
261
|
+
const parsed = existing ? parseRecord(existing) : undefined;
|
|
262
|
+
if (parsed) {
|
|
263
|
+
// fold stale live blocks (sessions that died without shutdown) into total
|
|
264
|
+
for (const k of Object.keys(parsed.accumulators)) {
|
|
265
|
+
if (k === "total" || k === sessionId) continue;
|
|
266
|
+
parsed.accumulators.total = foldAccumulators(parsed.accumulators.total ?? emptyAccumulators(), parsed.accumulators[k]);
|
|
267
|
+
delete parsed.accumulators[k];
|
|
268
|
+
}
|
|
269
|
+
if (!parsed.sessions.includes(sessionId)) parsed.sessions.push(sessionId);
|
|
270
|
+
parsed.spec = specRel;
|
|
271
|
+
return parsed;
|
|
272
|
+
}
|
|
273
|
+
return newRecord({ spec: specRel, session: sessionId, now: buffer[0]?.ts ?? deps.now(), runId: randomUUID() });
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const bind = async (specRel: string, snap: SettingsSnapshot) => {
|
|
277
|
+
currentDir = snap.telemetry.dir;
|
|
278
|
+
boundSpec = specRel;
|
|
279
|
+
recordRel = recordPathFor(currentDir, specRel);
|
|
280
|
+
predecessorLinks.clear();
|
|
281
|
+
record = loadOrCreate(specRel);
|
|
282
|
+
for (const link of record.supersedes ?? []) {
|
|
283
|
+
if (!isSafeSpecLink(link)) continue;
|
|
284
|
+
const predecessor = deps.fs.readFile(abs(link));
|
|
285
|
+
if (predecessor !== undefined && isSupersededByBanner(predecessor, record.spec)) predecessorLinks.add(link);
|
|
286
|
+
}
|
|
287
|
+
if (!record.author || !record.branch) {
|
|
288
|
+
const [name, email, branch] = await Promise.all([
|
|
289
|
+
deps.git(["config", "user.name"], toplevel!),
|
|
290
|
+
deps.git(["config", "user.email"], toplevel!),
|
|
291
|
+
deps.git(["rev-parse", "--abbrev-ref", "HEAD"], toplevel!),
|
|
292
|
+
]);
|
|
293
|
+
const author = compact({ name: name.stdout.trim() || undefined, email: email.stdout.trim() || undefined });
|
|
294
|
+
if (Object.keys(author).length) record.author ??= author;
|
|
295
|
+
if (branch.code === 0 && branch.stdout.trim()) record.branch ??= branch.stdout.trim();
|
|
296
|
+
}
|
|
297
|
+
if (Object.keys(snap.versions).length) record.versions ??= snap.versions;
|
|
298
|
+
if (snap.agentOverrides) record.agent_overrides ??= snap.agentOverrides;
|
|
299
|
+
record.events.push(...buffer);
|
|
300
|
+
record.accumulators[sessionId] = foldAccumulators(pending, record.accumulators[sessionId] ?? emptyAccumulators());
|
|
301
|
+
pending = emptyAccumulators();
|
|
302
|
+
const approval = record.events.find((e) => e.kind === "phase" && e.name === "brainstorm" && (e.action === "complete" || e.action === "skip"));
|
|
303
|
+
if (approval) record.approved_at ??= approval.ts;
|
|
304
|
+
buffer = [];
|
|
305
|
+
if (!inGit) warn("not a git repository: record is written but never committed");
|
|
306
|
+
if (!settingsWarned && (snap.errors.length || snap.telemetry.warning)) {
|
|
307
|
+
settingsWarned = true;
|
|
308
|
+
if (snap.errors.length) warn(settingsErrorWarning(snap.errors));
|
|
309
|
+
if (snap.telemetry.warning) warn(snap.telemetry.warning);
|
|
310
|
+
}
|
|
311
|
+
refreshLinks();
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const unbind = () => {
|
|
315
|
+
boundSpec = undefined;
|
|
316
|
+
record = undefined;
|
|
317
|
+
recordRel = undefined;
|
|
318
|
+
oldRecordRel = undefined;
|
|
319
|
+
lastCommitted = "";
|
|
320
|
+
frozen = false;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const isSafeSpecLink = (link: string): boolean => {
|
|
324
|
+
const normalized = posix.normalize(link);
|
|
325
|
+
return isSpecPath(link) && !posix.isAbsolute(link) && normalized !== ".." && !normalized.startsWith("../");
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const refreshLinks = () => {
|
|
329
|
+
if (!record || !boundSpec) return;
|
|
330
|
+
const body = deps.fs.readFile(abs(boundSpec));
|
|
331
|
+
if (body === undefined) return;
|
|
332
|
+
const links = parseSpecLinks(body);
|
|
333
|
+
const supersedes = [...new Set([...links.supersedes, ...predecessorLinks])];
|
|
334
|
+
record.supersedes = supersedes.length ? supersedes : undefined;
|
|
335
|
+
record.fixes = links.fixes.length ? links.fixes : undefined;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const isDraftOrMissing = (specRel: string): boolean => {
|
|
339
|
+
const body = deps.fs.readFile(abs(specRel));
|
|
340
|
+
return body === undefined || body.split("\n")[0] === CONTEXT_DRAFT_MARKER;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const rebind = async (newSpec: string): Promise<boolean> => {
|
|
344
|
+
const from = recordRel!;
|
|
345
|
+
const to = recordPathFor(currentDir, newSpec);
|
|
346
|
+
if (deps.fs.exists(abs(to))) {
|
|
347
|
+
warn(`record move skipped: ${to} already exists`);
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
const trackedResult = inGit ? await deps.git(["ls-files", "--error-unmatch", "--", from], toplevel!) : undefined;
|
|
351
|
+
const tracked = trackedResult?.code === 0 && trackedResult.stdout.trim() !== "";
|
|
352
|
+
try {
|
|
353
|
+
deps.fs.mkdirp(dirname(abs(to)));
|
|
354
|
+
if (tracked) {
|
|
355
|
+
const mv = await deps.git(["mv", "--", from, to], toplevel!);
|
|
356
|
+
if (mv.code !== 0) deps.fs.rename(abs(from), abs(to));
|
|
357
|
+
} else deps.fs.rename(abs(from), abs(to));
|
|
358
|
+
} catch (e) {
|
|
359
|
+
warn(`record move failed: ${String((e as Error).message ?? e).split("\n")[0]}`);
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
pushEvent({ kind: "spec_renamed", from: boundSpec!, to: newSpec });
|
|
363
|
+
oldRecordRel = from;
|
|
364
|
+
boundSpec = newSpec;
|
|
365
|
+
recordRel = to;
|
|
366
|
+
record!.spec = newSpec;
|
|
367
|
+
lastCommitted = "";
|
|
368
|
+
return true;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// ---- phase mirror ----
|
|
372
|
+
const applyPhaseDetails = async (details: { action?: string; phases?: PhaseMap; error?: string; rounds?: number } | undefined, snap: SettingsSnapshot) => {
|
|
373
|
+
if (!details?.phases || details.error) return;
|
|
374
|
+
const transitions = diffPhases(phases, details.phases, details.action ?? "");
|
|
375
|
+
if (details.action === "grant_fix_rounds") {
|
|
376
|
+
live().gates.fix_round_grants += 1;
|
|
377
|
+
pushEvent({ kind: "gate", gate: "fix_round_grant", rounds: typeof details.rounds === "number" ? details.rounds : undefined });
|
|
378
|
+
await flush(false);
|
|
379
|
+
}
|
|
380
|
+
for (const t of transitions) {
|
|
381
|
+
const model = ctxModel();
|
|
382
|
+
const e = pushEvent(
|
|
383
|
+
t.action === "start"
|
|
384
|
+
? { kind: "phase", action: "start", name: t.name, model, thinking: ctxThinking() }
|
|
385
|
+
: t.action === "reset"
|
|
386
|
+
? { kind: "phase", action: "reset" }
|
|
387
|
+
: { kind: "phase", action: t.action, name: t.name },
|
|
388
|
+
);
|
|
389
|
+
phases = details.phases;
|
|
390
|
+
if (record && t.name === "brainstorm" && (t.action === "complete" || t.action === "skip")) record.approved_at ??= e.ts;
|
|
391
|
+
if (record && t.name === "ship" && t.action === "complete") await onShipKeep(snap);
|
|
392
|
+
await flush(true);
|
|
393
|
+
if (t.action === "reset") unbind();
|
|
394
|
+
}
|
|
395
|
+
phases = details.phases;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
let lastCtx: ExtensionContext | undefined;
|
|
399
|
+
const ctxModel = (): string | undefined => (lastCtx?.model ? `${lastCtx.model.provider}/${lastCtx.model.id}` : undefined);
|
|
400
|
+
const ctxThinking = (): string | undefined => lastCtx?.thinkingLevel;
|
|
401
|
+
|
|
402
|
+
// Keep-branch ship: shipped_at at `phase complete ship`. Defined in block 3.
|
|
403
|
+
let onShipKeep: (snap: SettingsSnapshot) => Promise<void>;
|
|
404
|
+
|
|
405
|
+
// ---- handlers ----
|
|
406
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
407
|
+
lastCtx = ctx;
|
|
408
|
+
const snap = enabledSettings(ctx);
|
|
409
|
+
if (!snap) return;
|
|
410
|
+
sessionId = ctx.sessionManager.getSessionId();
|
|
411
|
+
await ensureToplevel(ctx);
|
|
412
|
+
currentDir = snap.telemetry.dir;
|
|
413
|
+
const replay = replayBranch(ctx.sessionManager.getBranch());
|
|
414
|
+
phases = replay.phases;
|
|
415
|
+
const candidate = replay.planCheckSpec ?? replay.lastSpecWrite;
|
|
416
|
+
const rel = candidate ? repoRelativeToolPath(toplevel!, ctx.cwd, candidate) : undefined;
|
|
417
|
+
if (rel && isSpecPath(rel)) {
|
|
418
|
+
await bind(rel, snap);
|
|
419
|
+
await flush(false);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
424
|
+
const snap = enabledSettings(ctx);
|
|
425
|
+
if (!snap || !record) return;
|
|
426
|
+
const liveBlock = record.accumulators[sessionId];
|
|
427
|
+
if (liveBlock) {
|
|
428
|
+
record.accumulators.total = foldAccumulators(record.accumulators.total ?? emptyAccumulators(), liveBlock);
|
|
429
|
+
delete record.accumulators[sessionId];
|
|
430
|
+
}
|
|
431
|
+
await flush(true);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
435
|
+
lastCtx = ctx;
|
|
436
|
+
const snap = enabledSettings(ctx);
|
|
437
|
+
if (!snap) return undefined;
|
|
438
|
+
if (!sessionId) sessionId = ctx.sessionManager.getSessionId();
|
|
439
|
+
await ensureToplevel(ctx);
|
|
440
|
+
if (event.toolName === "phase_tracker" && !event.isError) {
|
|
441
|
+
await applyPhaseDetails(event.details as never, snap);
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
if (event.toolName === "plan_check" && !event.isError) {
|
|
445
|
+
const d = event.details as { status?: string; specPath?: string; planPath?: string } | undefined;
|
|
446
|
+
const spec = d?.specPath ? repoRelativeToolPath(toplevel!, ctx.cwd, d.specPath) : undefined;
|
|
447
|
+
const plan = d?.planPath ? repoRelativeToolPath(toplevel!, ctx.cwd, d.planPath) : undefined;
|
|
448
|
+
const pass = d?.status === "pass";
|
|
449
|
+
if (pass && spec && spec !== boundSpec) {
|
|
450
|
+
if (record) unbind();
|
|
451
|
+
await bind(spec, snap);
|
|
452
|
+
}
|
|
453
|
+
live().gates.plan_rounds += 1;
|
|
454
|
+
pushEvent({ kind: "plan_check", pass, spec, plan });
|
|
455
|
+
await flush(pass);
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
if ((event.toolName === "write" || event.toolName === "edit" || event.toolName === "read") && !event.isError) {
|
|
459
|
+
const rawPath = (event.input as { path?: unknown }).path;
|
|
460
|
+
const rel = typeof rawPath === "string" ? repoRelativeToolPath(toplevel!, ctx.cwd, rawPath) : undefined;
|
|
461
|
+
if (!rel) return undefined;
|
|
462
|
+
return onSpecInteraction(event.toolName, rel, event, snap);
|
|
463
|
+
}
|
|
464
|
+
if (event.toolName === "subagent" && !event.isError) {
|
|
465
|
+
await onSubagentResult(event.details, event.content);
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
if (event.toolName === "plan_tracker" && !event.isError) {
|
|
469
|
+
await onPlanTracker(event.details, snap);
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
if (event.toolName === "bash") {
|
|
473
|
+
if (pendingShip && pendingShip.id === event.toolCallId) {
|
|
474
|
+
pendingShip = undefined;
|
|
475
|
+
if (event.isError) {
|
|
476
|
+
pushEvent({ kind: "ship_failed" });
|
|
477
|
+
clearShipState();
|
|
478
|
+
await flush(false);
|
|
479
|
+
} else {
|
|
480
|
+
frozen = true;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const matched = pendingTest.get(event.toolCallId);
|
|
484
|
+
if (matched && record) {
|
|
485
|
+
pendingTest.delete(event.toolCallId);
|
|
486
|
+
record.derived.tests = { command: truncateCommand(matched), result: event.isError ? "fail" : "pass" };
|
|
487
|
+
await flush(false);
|
|
488
|
+
}
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return undefined;
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Binding + spec-write bookkeeping; the bound-spec hooks are defined in block 2.
|
|
496
|
+
const onSpecInteraction = async (tool: "write" | "edit" | "read", rel: string, event: { input: unknown; content: unknown[] }, snap: SettingsSnapshot): Promise<unknown> => {
|
|
497
|
+
if (!boundSpec) {
|
|
498
|
+
if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
|
|
499
|
+
await bind(rel, snap);
|
|
500
|
+
const patch = onBoundSpecWrite(tool, rel, event);
|
|
501
|
+
await flush(true);
|
|
502
|
+
return patch;
|
|
503
|
+
} else if (isPlanPath(rel)) {
|
|
504
|
+
const spec = planSpecHeader(deps.fs.readFile(abs(rel)) ?? "");
|
|
505
|
+
const specRel = spec ? repoRelativeToolPath(toplevel!, toplevel!, spec) : undefined;
|
|
506
|
+
if (specRel && deps.fs.exists(abs(specRel))) {
|
|
507
|
+
await bind(specRel, snap);
|
|
508
|
+
await flush(true);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
if (tool === "read") return undefined;
|
|
514
|
+
if (rel === boundSpec) {
|
|
515
|
+
const patch = onBoundSpecWrite(tool, rel, event);
|
|
516
|
+
await flush(false);
|
|
517
|
+
return patch;
|
|
518
|
+
}
|
|
519
|
+
if (tool === "write" && isSpecPath(rel) && phases.brainstorm.status === "in_progress" && isDraftOrMissing(boundSpec)) {
|
|
520
|
+
if (await rebind(rel)) onBoundSpecWrite(tool, rel, event);
|
|
521
|
+
await flush(true);
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
if (tool === "edit" && isSpecPath(rel)) {
|
|
525
|
+
onOtherSpecEdit(rel, event);
|
|
526
|
+
await flush(false);
|
|
527
|
+
}
|
|
528
|
+
return undefined;
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
// Defined in block 2.
|
|
532
|
+
let onBoundSpecWrite: (tool: "write" | "edit", rel: string, event: { input: unknown; content: unknown[] }) => unknown;
|
|
533
|
+
let onOtherSpecEdit: (rel: string, event: { input: unknown }) => void;
|
|
534
|
+
|
|
535
|
+
let amendmentOpen = false;
|
|
536
|
+
let lastPlanTasks: { status: string }[] | undefined;
|
|
537
|
+
const pendingTest = new Map<string, string>(); // toolCallId -> matched statement
|
|
538
|
+
|
|
539
|
+
const phaseAcc = (k: PhaseKey = phaseNow()): PhaseAcc => (live().phases[k] ??= {});
|
|
540
|
+
|
|
541
|
+
const addPhaseTokens = (t: Tokens | undefined, k: PhaseKey = phaseNow()) => {
|
|
542
|
+
if (!t) return;
|
|
543
|
+
const acc = phaseAcc(k);
|
|
544
|
+
acc.tokens = addTokens(acc.tokens, t);
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const onSubagentResult = async (details: unknown, content: unknown) => {
|
|
548
|
+
const results = (details as { results?: unknown[] } | undefined)?.results;
|
|
549
|
+
if (!Array.isArray(results) || results.length === 0) return;
|
|
550
|
+
const acc = live();
|
|
551
|
+
const text = textOf(content);
|
|
552
|
+
for (const raw of results) {
|
|
553
|
+
const r = raw as { agent?: unknown; exitCode?: unknown; model?: unknown; usage?: unknown };
|
|
554
|
+
const agent = typeof r.agent === "string" ? r.agent : "unknown";
|
|
555
|
+
const exit = typeof r.exitCode === "number" ? r.exitCode : undefined;
|
|
556
|
+
const model = typeof r.model === "string" ? r.model : undefined;
|
|
557
|
+
pushEvent({ kind: "dispatch", agent, model, exit: exit === 0 ? undefined : exit });
|
|
558
|
+
const tokens = usageToTokens(r.usage);
|
|
559
|
+
addPhaseTokens(tokens);
|
|
560
|
+
const persona = (acc.personas[agent] ??= { dispatches: 0, models: [] });
|
|
561
|
+
persona.dispatches += 1;
|
|
562
|
+
if (model && !persona.models.includes(model)) persona.models.push(model);
|
|
563
|
+
if (tokens) persona.tokens = addTokens(persona.tokens, tokens);
|
|
564
|
+
if (agent === "spec-summarizer") acc.gates.spec_rounds += 1;
|
|
565
|
+
if (REVIEWER_AGENTS.has(agent)) {
|
|
566
|
+
const review = (acc.reviews[agent] ??= { dispatches: 0, nonzero_exit: 0 });
|
|
567
|
+
review.dispatches += 1;
|
|
568
|
+
if (exit !== undefined && exit !== 0) review.nonzero_exit += 1;
|
|
569
|
+
const findings = countFindings(text);
|
|
570
|
+
if (findings) {
|
|
571
|
+
review.findings ??= { blocker: 0, major: 0, minor: 0 };
|
|
572
|
+
review.findings.blocker += findings.blocker;
|
|
573
|
+
review.findings.major += findings.major;
|
|
574
|
+
review.findings.minor += findings.minor;
|
|
575
|
+
}
|
|
576
|
+
if (agent === "conformance-reviewer") {
|
|
577
|
+
if (phases.verify.status === "in_progress") acc.conformance_loops += 1;
|
|
578
|
+
const openGaps = countOpenGaps(text);
|
|
579
|
+
if (openGaps !== undefined) acc.conformance_open_gaps = openGaps;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
await flush(false);
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const onPlanTracker = async (details: unknown, snap: SettingsSnapshot) => {
|
|
587
|
+
const d = details as { tasks?: { name?: string; status: string }[]; error?: string; action?: string } | undefined;
|
|
588
|
+
if (!d || d.error || !Array.isArray(d.tasks)) return;
|
|
589
|
+
if (d.action === "update" && hasReopen(lastPlanTasks, d.tasks)) {
|
|
590
|
+
live().gates.task_reopens += 1;
|
|
591
|
+
pushEvent({ kind: "gate", gate: "task_reopen" });
|
|
592
|
+
}
|
|
593
|
+
lastPlanTasks = d.tasks;
|
|
594
|
+
if (record && implementAutoCompletes(phases, d.tasks)) {
|
|
595
|
+
record.derived.plan = planTotals(d.tasks);
|
|
596
|
+
const next: PhaseMap = { ...phases, implement: { status: "complete" } };
|
|
597
|
+
await applyPhaseDetails({ action: "complete", phases: next }, snap);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (record) await flush(false);
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
let pendingShip: { id: string; option: "squash" | "pr" | "discard" } | undefined;
|
|
604
|
+
|
|
605
|
+
const computeDiff = async (snap: SettingsSnapshot) => {
|
|
606
|
+
if (!record || !inGit) return;
|
|
607
|
+
let baseRef: string | undefined;
|
|
608
|
+
for (const ref of BASE_REFS) {
|
|
609
|
+
if ((await deps.git(["rev-parse", "--verify", "--quiet", ref], toplevel!)).code === 0) {
|
|
610
|
+
baseRef = ref;
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (!baseRef) {
|
|
615
|
+
warn("diff omitted: no base ref among origin/HEAD, main, master");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const mb = await deps.git(["merge-base", "HEAD", baseRef], toplevel!);
|
|
619
|
+
if (mb.code !== 0 || !mb.stdout.trim()) {
|
|
620
|
+
warn(`diff omitted: merge-base failed: ${mb.stderr.trim().split("\n")[0]}`);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const base = mb.stdout.trim();
|
|
624
|
+
const names = await deps.git(["diff", "--name-only", `${base}...HEAD`], toplevel!);
|
|
625
|
+
const files = modifiedFilesFrom(names.stdout, record.spec, currentDir);
|
|
626
|
+
const numstat = await deps.git(["diff", "--numstat", `${base}...HEAD`], toplevel!);
|
|
627
|
+
const count = await deps.git(["rev-list", "--count", "--invert-grep", "--grep=^telemetry: ", `${base}..HEAD`], toplevel!);
|
|
628
|
+
record.derived.modified_files = files;
|
|
629
|
+
record.derived.diff = { base, commits: Number(count.stdout.trim()) || 0, buckets: aggregateNumstat(numstat.stdout, new Set(files), snap.telemetry.buckets) };
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const clearShipState = () => {
|
|
633
|
+
if (!record) return;
|
|
634
|
+
record.shipped_at = undefined;
|
|
635
|
+
record.abandoned_at = undefined;
|
|
636
|
+
record.status = "in_progress";
|
|
637
|
+
record.derived.diff = undefined;
|
|
638
|
+
record.derived.modified_files = undefined;
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const shipAttempt = async (option: "squash" | "pr" | "keep" | "discard", command: string | undefined, snap: SettingsSnapshot) => {
|
|
642
|
+
if (!record) return;
|
|
643
|
+
const ts = deps.now();
|
|
644
|
+
if (option === "discard") {
|
|
645
|
+
record.status = "abandoned";
|
|
646
|
+
record.abandoned_at = ts;
|
|
647
|
+
} else {
|
|
648
|
+
record.status = "shipped";
|
|
649
|
+
record.shipped_at = ts;
|
|
650
|
+
await computeDiff(snap);
|
|
651
|
+
}
|
|
652
|
+
pushEvent({ kind: "ship", option, command: command ? truncateCommand(command) : undefined });
|
|
653
|
+
await flush(true);
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
onShipKeep = async (snap) => {
|
|
657
|
+
if (!record || liveShipEvent(record.events)) return;
|
|
658
|
+
await shipAttempt("keep", undefined, snap);
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
662
|
+
lastCtx = ctx;
|
|
663
|
+
const snap = enabledSettings(ctx);
|
|
664
|
+
if (!snap) return undefined;
|
|
665
|
+
if (event.toolName === "write") {
|
|
666
|
+
const rawPath = (event.input as { path?: unknown }).path;
|
|
667
|
+
if (typeof rawPath === "string" && phases.brainstorm.status === "in_progress") {
|
|
668
|
+
await ensureToplevel(ctx);
|
|
669
|
+
const rel = repoRelativeToolPath(toplevel!, ctx.cwd, rawPath);
|
|
670
|
+
if (rel && isSpecPath(rel)) {
|
|
671
|
+
const recRel = recordPathFor(snap.telemetry.dir, rel);
|
|
672
|
+
const existing = deps.fs.readFile(join(toplevel!, recRel));
|
|
673
|
+
const parsed = existing ? parseRecord(existing) : undefined;
|
|
674
|
+
if (parsed?.shipped_at) return { block: true, reason: guardReason(rel, parsed.shipped_at, recRel) };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return undefined;
|
|
678
|
+
}
|
|
679
|
+
if (event.toolName === "bash") {
|
|
680
|
+
const command = String((event.input as { command?: unknown }).command ?? "");
|
|
681
|
+
if (record && !frozen && phases.ship.status === "in_progress" && !liveShipEvent(record.events)) {
|
|
682
|
+
const ship = matchShipStatement(command);
|
|
683
|
+
if (ship) {
|
|
684
|
+
pendingShip = { id: event.toolCallId, option: ship.option };
|
|
685
|
+
await shipAttempt(ship.option, ship.statement, snap);
|
|
686
|
+
return undefined;
|
|
687
|
+
}
|
|
688
|
+
const discard = matchDiscardStatement(command);
|
|
689
|
+
if (discard) {
|
|
690
|
+
pendingShip = { id: event.toolCallId, option: "discard" };
|
|
691
|
+
await shipAttempt("discard", discard, snap);
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (record && (phases.verify.status === "in_progress" || phases.ship.status === "in_progress")) {
|
|
696
|
+
const statement = matchTestStatement(command, snap.testCommands ?? DEFAULT_TEST_COMMANDS);
|
|
697
|
+
if (statement) pendingTest.set(event.toolCallId, statement);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return undefined;
|
|
701
|
+
});
|
|
702
|
+
pi.on("message_end", async (event, ctx) => {
|
|
703
|
+
lastCtx = ctx;
|
|
704
|
+
if (!enabledSettings(ctx)) return;
|
|
705
|
+
const msg = event.message as { role?: string; usage?: unknown };
|
|
706
|
+
if (msg.role !== "assistant") return;
|
|
707
|
+
addPhaseTokens(usageToTokens(msg.usage));
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
711
|
+
if (!enabledSettings(ctx)) return;
|
|
712
|
+
const tokens = ctx.getContextUsage?.()?.tokens;
|
|
713
|
+
if (typeof tokens !== "number") return;
|
|
714
|
+
const acc = phaseAcc();
|
|
715
|
+
acc.peak_context = Math.max(acc.peak_context ?? 0, tokens);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
pi.on("input", async (event, ctx) => {
|
|
719
|
+
if (!enabledSettings(ctx)) return undefined;
|
|
720
|
+
amendmentOpen = false;
|
|
721
|
+
if (!record || event.source === "extension" || event.text.trimStart().startsWith("/")) return undefined;
|
|
722
|
+
const k = phaseNow();
|
|
723
|
+
if (COUNTED_USER_PHASES.has(k)) {
|
|
724
|
+
const acc = phaseAcc(k);
|
|
725
|
+
acc.user_messages = (acc.user_messages ?? 0) + 1;
|
|
726
|
+
}
|
|
727
|
+
return undefined;
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
pi.on("model_select", async (event, ctx) => {
|
|
731
|
+
lastCtx = ctx;
|
|
732
|
+
if (!enabledSettings(ctx) || phaseNow() === "unphased") return;
|
|
733
|
+
pushEvent({ kind: "config_change", model: `${event.model.provider}/${event.model.id}` });
|
|
734
|
+
await flush(false);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
pi.on("thinking_level_select", async (event, ctx) => {
|
|
738
|
+
lastCtx = ctx;
|
|
739
|
+
if (!enabledSettings(ctx) || phaseNow() === "unphased") return;
|
|
740
|
+
pushEvent({ kind: "config_change", thinking: String(event.level) });
|
|
741
|
+
await flush(false);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
745
|
+
if (!enabledSettings(ctx)) return;
|
|
746
|
+
const acc = phaseAcc();
|
|
747
|
+
acc.compactions = (acc.compactions ?? 0) + 1;
|
|
748
|
+
await flush(false);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
onBoundSpecWrite = (tool, rel, event) => {
|
|
752
|
+
if (!record) return undefined;
|
|
753
|
+
if (tool === "edit" && SUPERSEDED_BY_RE.test(insertedText(event.input))) return undefined;
|
|
754
|
+
const body = deps.fs.readFile(abs(rel));
|
|
755
|
+
if (body === undefined) return undefined;
|
|
756
|
+
const acc = live();
|
|
757
|
+
const k = phaseNow();
|
|
758
|
+
const cur = acc.spec_writes[k] ?? { count: 0, last_sha256: "" };
|
|
759
|
+
acc.spec_writes[k] = { count: cur.count + 1, last_sha256: sha256(new TextEncoder().encode(body)) };
|
|
760
|
+
refreshLinks();
|
|
761
|
+
if (record.shipped_at) {
|
|
762
|
+
acc.spec_edits_after_ship += 1;
|
|
763
|
+
const warning = `⚠️ spec shipped at ${record.shipped_at}; write a follow-up spec that supersedes it`;
|
|
764
|
+
return { content: [{ type: "text" as const, text: warning }, ...(event.content as { type: string }[])] };
|
|
765
|
+
}
|
|
766
|
+
if (record.approved_at && !amendmentOpen) {
|
|
767
|
+
acc.amendments += 1;
|
|
768
|
+
amendmentOpen = true;
|
|
769
|
+
}
|
|
770
|
+
return undefined;
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
onOtherSpecEdit = (rel, event) => {
|
|
774
|
+
if (!record || !boundSpec || !isSafeSpecLink(rel)) return;
|
|
775
|
+
if (!isSupersededByBanner(insertedText(event.input), boundSpec)) return;
|
|
776
|
+
predecessorLinks.add(rel);
|
|
777
|
+
refreshLinks();
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
}
|