pi-gauntlet 5.18.7 → 5.20.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 +16 -0
- package/README.md +5 -2
- package/bin/gauntlet-telemetry-seal.mjs +346 -0
- package/bin/gauntlet-telemetry-seal.test.mjs +271 -0
- package/extensions/lib/telemetry-diff.test.ts +108 -0
- package/extensions/lib/telemetry-diff.ts +61 -0
- package/extensions/lib/telemetry-paths.test.ts +11 -35
- package/extensions/lib/telemetry-paths.ts +13 -42
- package/extensions/lib/telemetry-record.test.ts +0 -6
- package/extensions/lib/telemetry-ship.test.ts +1 -5
- package/extensions/lib/telemetry-ship.ts +1 -4
- package/extensions/telemetry.test.ts +243 -135
- package/extensions/telemetry.ts +148 -201
- package/package.json +2 -2
- package/skills/brainstorming/SKILL.md +1 -1
- package/skills/finishing-a-development-branch/SKILL.md +19 -16
- package/skills/gatekeep-pr/reference/assessment.md +0 -13
- package/skills/gatekeep-pr/reference/decision-menu.md +1 -2
- package/skills/gatekeep-pr/reference/post-selection-loop.md +3 -3
- package/skills/gauntlet-performance/SKILL.md +1 -1
- package/skills/gauntlet-resume/SKILL.md +46 -32
- package/skills/gauntlet-resume/reference/brief-contract.md +12 -8
- package/skills/gauntlet-resume/reference/reconstruction.md +10 -3
- package/skills/gauntlet-resume/reference/seed.md +47 -0
- package/bin/gauntlet-telemetry-salvage.mjs +0 -273
- package/bin/gauntlet-telemetry-salvage.test.mjs +0 -466
package/extensions/telemetry.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telemetry extension
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Telemetry extension: records one YAML record per gauntlet run, keyed by spec path,
|
|
3
|
+
* from pi events alone. Binds only after `phase_tracker start brainstorm` armed the
|
|
4
|
+
* flow, writes the record to disk without ever staging or committing it (the seal bin
|
|
5
|
+
* commits once at finish), and blocks `write` into an already-shipped spec while
|
|
6
|
+
* brainstorm is in progress. Pure helpers: extensions/lib/telemetry-*.ts. The default
|
|
7
|
+
* export takes a `deps` seam ({ fs, git, jj, now, settings }) for the harness tests.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { execFile } from "node:child_process";
|
|
@@ -16,12 +17,13 @@
|
|
|
16
17
|
import { DEFAULT_TEST_COMMANDS, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
|
|
17
18
|
import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
|
|
18
19
|
import { checkoutOf } from "./lib/checkout.ts";
|
|
19
|
-
import { CONTEXT_DRAFT_MARKER } from "./lib/phase-tracker-helpers.ts";
|
|
20
|
+
import { CONTEXT_DRAFT_MARKER, nextGauntletEntered } from "./lib/phase-tracker-helpers.ts";
|
|
20
21
|
import { sha256 } from "./lib/plan-check.ts";
|
|
21
|
-
import { SUPERSEDED_BY_RE,
|
|
22
|
+
import { SUPERSEDED_BY_RE, isPlanPath, isSpecPath, isSupersededByBanner, matchTestStatement, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
|
|
23
|
+
import { computeJjDiff } from "./lib/telemetry-diff.ts";
|
|
22
24
|
import { COUNTED_USER_PHASES, REVIEWER_AGENTS, countFindings, countOpenGaps, hasReopen, insertedText, planTotals, textOf, usageToTokens } from "./lib/telemetry-collect.ts";
|
|
23
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";
|
|
24
|
-
import {
|
|
26
|
+
import { guardReason } from "./lib/telemetry-ship.ts";
|
|
25
27
|
|
|
26
28
|
// ---- deps seam -------------------------------------------------------------------
|
|
27
29
|
|
|
@@ -131,15 +133,25 @@
|
|
|
131
133
|
|
|
132
134
|
interface ReplayResult {
|
|
133
135
|
phases: PhaseMap;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
gauntletEntered: boolean;
|
|
137
|
+
planCheckSpec?: string;
|
|
138
|
+
lastSpecWrite?: string;
|
|
136
139
|
}
|
|
137
140
|
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
+
// gauntlet-resume's reconstruction skips brainstorm with `resume: <spec path>`; the path
|
|
142
|
+
// is the bind candidate on that route because no spec write follows.
|
|
143
|
+
const RESUME_REASON_RE = /^resume: (\S+)$/;
|
|
144
|
+
export function resumeSpecOf(phases: PhaseMap | undefined): string | undefined {
|
|
145
|
+
const b = phases?.brainstorm;
|
|
146
|
+
const m = b?.status === "skipped" && typeof b.reason === "string" ? RESUME_REASON_RE.exec(b.reason) : null;
|
|
147
|
+
return m && isSpecPath(toPosix(m[1])) ? m[1] : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Walks the session branch like phase-tracker.ts: successful phase_tracker results set
|
|
151
|
+
// the phase map and the flow-entry marker; bind candidates are taken only while armed
|
|
152
|
+
// and dropped on reset. Silent: no events are emitted.
|
|
141
153
|
export function replayBranch(entries: Iterable<unknown>): ReplayResult {
|
|
142
|
-
const out: ReplayResult = { phases: emptyPhases() };
|
|
154
|
+
const out: ReplayResult = { phases: emptyPhases(), gauntletEntered: false };
|
|
143
155
|
const calls = new Map<string, { name: string; args: unknown }>();
|
|
144
156
|
for (const raw of entries) {
|
|
145
157
|
const entry = raw as { type?: string; message?: { role?: string; content?: unknown; toolName?: string; toolCallId?: string; isError?: boolean; details?: unknown } };
|
|
@@ -152,14 +164,22 @@
|
|
|
152
164
|
}
|
|
153
165
|
if (msg.role !== "toolResult") continue;
|
|
154
166
|
if (msg.toolName === "phase_tracker") {
|
|
155
|
-
const d = msg.details as { phases?: PhaseMap; error?: string } | undefined;
|
|
156
|
-
if (d?.phases
|
|
167
|
+
const d = msg.details as { action?: string; phases?: PhaseMap; error?: string } | undefined;
|
|
168
|
+
if (!d?.phases || d.error) continue;
|
|
169
|
+
out.phases = d.phases;
|
|
170
|
+
out.gauntletEntered = nextGauntletEntered(out.gauntletEntered, d.action ?? "", d.phases.brainstorm.status);
|
|
171
|
+
if (d.action === "reset") {
|
|
172
|
+
out.planCheckSpec = undefined;
|
|
173
|
+
out.lastSpecWrite = undefined;
|
|
174
|
+
}
|
|
175
|
+
const resumed = d.action === "skip" ? resumeSpecOf(d.phases) : undefined;
|
|
176
|
+
if (out.gauntletEntered && resumed) out.lastSpecWrite = resumed;
|
|
157
177
|
} else if (msg.toolName === "plan_check") {
|
|
158
178
|
const d = msg.details as { status?: string; specPath?: string } | undefined;
|
|
159
|
-
if (d?.status === "pass" && typeof d.specPath === "string") out.planCheckSpec = d.specPath;
|
|
160
|
-
} else if (msg.toolName === "write" && !msg.isError && msg.toolCallId) {
|
|
179
|
+
if (out.gauntletEntered && d?.status === "pass" && typeof d.specPath === "string") out.planCheckSpec = d.specPath;
|
|
180
|
+
} else if ((msg.toolName === "write" || msg.toolName === "edit") && !msg.isError && msg.toolCallId) {
|
|
161
181
|
const p = (calls.get(msg.toolCallId)?.args as { path?: unknown } | undefined)?.path;
|
|
162
|
-
if (typeof p === "string" && isSpecPath(toPosix(p))) out.lastSpecWrite = p;
|
|
182
|
+
if (out.gauntletEntered && typeof p === "string" && isSpecPath(toPosix(p))) out.lastSpecWrite = p;
|
|
163
183
|
}
|
|
164
184
|
}
|
|
165
185
|
return out;
|
|
@@ -177,14 +197,13 @@
|
|
|
177
197
|
let record: TelemetryRecord | undefined;
|
|
178
198
|
const predecessorLinks = new Set<string>();
|
|
179
199
|
let recordRel: string | undefined; // repo-relative record path
|
|
180
|
-
let oldRecordRel: string | undefined; // pending rename partner for the next commit
|
|
181
200
|
let buffer: TelemetryEvent[] = [];
|
|
182
201
|
let pending: Accumulators = emptyAccumulators();
|
|
183
|
-
let lastCommitted = "";
|
|
184
202
|
let frozen = false;
|
|
203
|
+
let gauntletEntered = false;
|
|
185
204
|
let settingsWarned = false;
|
|
186
|
-
let notGitWarned = false;
|
|
187
205
|
let sessionId = "";
|
|
206
|
+
let currentDir = "";
|
|
188
207
|
|
|
189
208
|
// Per-event settings read; undefined => this event is a no-op.
|
|
190
209
|
const enabledSettings = (ctx: ExtensionContext): SettingsSnapshot | undefined => {
|
|
@@ -203,9 +222,12 @@
|
|
|
203
222
|
return co && rel ? { toplevel: co.toplevel, rel, via: co.via } : undefined;
|
|
204
223
|
};
|
|
205
224
|
|
|
225
|
+
// Disarmed and unbound: activity outside the gauntlet is never charged to the next run.
|
|
206
226
|
const live = (): Accumulators => record
|
|
207
227
|
? (record.accumulators[sessionId] ??= emptyAccumulators())
|
|
208
|
-
: pending;
|
|
228
|
+
: gauntletEntered ? pending : emptyAccumulators();
|
|
229
|
+
|
|
230
|
+
const canBind = (): boolean => gauntletEntered && !frozen && phases.ship.status !== "complete";
|
|
209
231
|
|
|
210
232
|
const abs = (rel: string): string => join(toplevel!, rel);
|
|
211
233
|
const phaseNow = (): PhaseKey => currentPhase(phases);
|
|
@@ -213,26 +235,38 @@
|
|
|
213
235
|
const pushEvent = (e: Omit<TelemetryEvent, "ts" | "session" | "phase"> & Partial<BaseEvent>): TelemetryEvent => {
|
|
214
236
|
const full = { ts: deps.now(), session: sessionId, phase: phaseNow(), ...e } as TelemetryEvent;
|
|
215
237
|
if (record) record.events.push(full);
|
|
216
|
-
else buffer.push(full);
|
|
238
|
+
else if (gauntletEntered) buffer.push(full);
|
|
217
239
|
return full;
|
|
218
240
|
};
|
|
219
241
|
|
|
220
242
|
const warn = (message: string) => pushEvent({ kind: "warning", message });
|
|
221
243
|
|
|
222
244
|
// ---- record store ----
|
|
245
|
+
// The record is the file on disk; nothing here stages or commits. A record the seal bin
|
|
246
|
+
// already stamped is frozen so a later flush cannot overwrite the seal.
|
|
247
|
+
const sealedAt = (path: string): boolean => {
|
|
248
|
+
const text = deps.fs.readFile(path);
|
|
249
|
+
const parsed = text ? parseRecord(text) : undefined;
|
|
250
|
+
return !!parsed && parsed.status !== "in_progress";
|
|
251
|
+
};
|
|
252
|
+
|
|
223
253
|
const writeRecord = () => {
|
|
224
254
|
if (!record || !recordRel || frozen) return;
|
|
225
255
|
if (!toplevel || !deps.fs.exists(toplevel)) {
|
|
226
256
|
frozen = true;
|
|
227
257
|
return;
|
|
228
258
|
}
|
|
259
|
+
const target = abs(recordRel);
|
|
260
|
+
if (sealedAt(target)) {
|
|
261
|
+
frozen = true;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
229
264
|
const capped = capEvents(record.events);
|
|
230
265
|
if (capped.dropped) {
|
|
231
266
|
record.events = capped.events;
|
|
232
267
|
live().events_dropped += capped.dropped;
|
|
233
268
|
}
|
|
234
269
|
record.derived = derive(record, deps.now());
|
|
235
|
-
const target = abs(recordRel);
|
|
236
270
|
try {
|
|
237
271
|
deps.fs.mkdirp(dirname(target));
|
|
238
272
|
deps.fs.writeFile(target + ".tmp", serializeRecord(record));
|
|
@@ -242,43 +276,26 @@
|
|
|
242
276
|
}
|
|
243
277
|
};
|
|
244
278
|
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
263
|
-
commit = await deps.git(["commit", "-q", "-m", `telemetry: ${record.spec}`, "--", ...paths], toplevel!);
|
|
264
|
-
}
|
|
265
|
-
if (commit.code !== 0) {
|
|
266
|
-
warn(`git commit failed: ${(commit.stderr.trim().split("\n")[0] || "unknown error")}`);
|
|
267
|
-
writeRecord();
|
|
268
|
-
return;
|
|
279
|
+
const flush = writeRecord;
|
|
280
|
+
|
|
281
|
+
// Excluded untracked paths are invisible to `git status --porcelain`, skipped by
|
|
282
|
+
// `git add -A`, and never block `git worktree remove`; the seal stages with `git add -f`.
|
|
283
|
+
const ensureExcluded = async () => {
|
|
284
|
+
const common = await deps.git(["rev-parse", "--path-format=absolute", "--git-common-dir"], toplevel!);
|
|
285
|
+
const commonDir = common.stdout.trim();
|
|
286
|
+
if (common.code !== 0 || !commonDir) return void warn(`exclude skipped: git rev-parse --git-common-dir failed: ${common.stderr.trim().split("\n")[0]}`);
|
|
287
|
+
const line = `${currentDir.replace(/\/+$/, "")}/`;
|
|
288
|
+
const file = join(commonDir, "info", "exclude");
|
|
289
|
+
const existing = deps.fs.readFile(file) ?? "";
|
|
290
|
+
if (existing.split("\n").some((l) => l.trim() === line)) return;
|
|
291
|
+
try {
|
|
292
|
+
deps.fs.mkdirp(dirname(file));
|
|
293
|
+
deps.fs.writeFile(file, existing + (existing && !existing.endsWith("\n") ? "\n" : "") + line + "\n");
|
|
294
|
+
} catch (e) {
|
|
295
|
+
warn(`exclude write failed: ${String((e as Error).message ?? e).split("\n")[0]}`);
|
|
269
296
|
}
|
|
270
|
-
lastCommitted = text;
|
|
271
297
|
};
|
|
272
298
|
|
|
273
|
-
// Write point = every event / accumulator change on a boundary; checkpoint = also commit.
|
|
274
|
-
const flush = async (checkpoint: boolean) => {
|
|
275
|
-
if (!record) return;
|
|
276
|
-
writeRecord();
|
|
277
|
-
if (checkpoint) await commitRecord();
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
let currentDir = "";
|
|
281
|
-
|
|
282
299
|
const loadOrCreate = (specRel: string): TelemetryRecord => {
|
|
283
300
|
const rel = recordPathFor(currentDir, specRel);
|
|
284
301
|
const existing = deps.fs.readFile(abs(rel));
|
|
@@ -297,7 +314,10 @@
|
|
|
297
314
|
return newRecord({ spec: specRel, session: sessionId, now: buffer[0]?.ts ?? deps.now(), runId: randomUUID() });
|
|
298
315
|
};
|
|
299
316
|
|
|
317
|
+
const sealedOnDisk = (top: string, rel: string, dir: string): boolean => sealedAt(join(top, recordPathFor(dir, rel)));
|
|
318
|
+
|
|
300
319
|
const bind = async (specRel: string, snap: SettingsSnapshot, specToplevel: string, via: "git" | "jj") => {
|
|
320
|
+
if (sealedOnDisk(specToplevel, specRel, snap.telemetry.dir)) return;
|
|
301
321
|
toplevel = specToplevel;
|
|
302
322
|
checkoutVia = via;
|
|
303
323
|
currentDir = snap.telemetry.dir;
|
|
@@ -333,6 +353,7 @@
|
|
|
333
353
|
if (snap.errors.length) warn(settingsErrorWarning(snap.errors));
|
|
334
354
|
if (snap.telemetry.warning) warn(snap.telemetry.warning);
|
|
335
355
|
}
|
|
356
|
+
if (via === "git") await ensureExcluded();
|
|
336
357
|
refreshLinks();
|
|
337
358
|
};
|
|
338
359
|
|
|
@@ -342,8 +363,6 @@
|
|
|
342
363
|
boundSpec = undefined;
|
|
343
364
|
record = undefined;
|
|
344
365
|
recordRel = undefined;
|
|
345
|
-
oldRecordRel = undefined;
|
|
346
|
-
lastCommitted = "";
|
|
347
366
|
frozen = false;
|
|
348
367
|
};
|
|
349
368
|
|
|
@@ -387,22 +406,22 @@
|
|
|
387
406
|
return false;
|
|
388
407
|
}
|
|
389
408
|
pushEvent({ kind: "spec_renamed", from: boundSpec!, to: newSpec });
|
|
390
|
-
oldRecordRel = from;
|
|
391
409
|
boundSpec = newSpec;
|
|
392
410
|
recordRel = to;
|
|
393
411
|
record!.spec = newSpec;
|
|
394
|
-
lastCommitted = "";
|
|
395
412
|
return true;
|
|
396
413
|
};
|
|
397
414
|
|
|
398
415
|
// ---- phase mirror ----
|
|
399
416
|
const applyPhaseDetails = async (details: { action?: string; phases?: PhaseMap; error?: string; rounds?: number } | undefined, snap: SettingsSnapshot) => {
|
|
400
417
|
if (!details?.phases || details.error) return;
|
|
401
|
-
const
|
|
402
|
-
|
|
418
|
+
const action = details.action ?? "";
|
|
419
|
+
gauntletEntered = nextGauntletEntered(gauntletEntered, action, details.phases.brainstorm.status);
|
|
420
|
+
const transitions = diffPhases(phases, details.phases, action);
|
|
421
|
+
if (action === "grant_fix_rounds") {
|
|
403
422
|
live().gates.fix_round_grants += 1;
|
|
404
423
|
pushEvent({ kind: "gate", gate: "fix_round_grant", rounds: typeof details.rounds === "number" ? details.rounds : undefined });
|
|
405
|
-
|
|
424
|
+
flush();
|
|
406
425
|
}
|
|
407
426
|
for (const t of transitions) {
|
|
408
427
|
const model = ctxModel();
|
|
@@ -415,36 +434,63 @@
|
|
|
415
434
|
);
|
|
416
435
|
phases = details.phases;
|
|
417
436
|
if (record && t.name === "brainstorm" && (t.action === "complete" || t.action === "skip")) record.approved_at ??= e.ts;
|
|
418
|
-
if (record && t.name === "ship" && t.action === "complete") await onShipKeep(snap);
|
|
419
|
-
|
|
420
|
-
if (t.action === "reset")
|
|
437
|
+
if (record && t.name === "ship" && t.action === "complete" && checkoutVia === "jj") await onShipKeep(snap);
|
|
438
|
+
flush();
|
|
439
|
+
if (t.action === "reset") {
|
|
440
|
+
// Epoch ends: nothing recorded before the next `start brainstorm` belongs to a run.
|
|
441
|
+
unbind();
|
|
442
|
+
buffer = [];
|
|
443
|
+
pending = emptyAccumulators();
|
|
444
|
+
}
|
|
421
445
|
}
|
|
422
446
|
phases = details.phases;
|
|
447
|
+
if (action === "skip" && !boundSpec && canBind() && lastCtx) {
|
|
448
|
+
const resumed = resumeSpecOf(details.phases);
|
|
449
|
+
const loc = resumed ? await locate(lastCtx, resumed) : undefined;
|
|
450
|
+
if (loc && isSpecPath(loc.rel)) {
|
|
451
|
+
await bind(loc.rel, snap, loc.toplevel, loc.via);
|
|
452
|
+
flush();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
423
455
|
};
|
|
424
456
|
|
|
425
457
|
let lastCtx: ExtensionContext | undefined;
|
|
426
458
|
const ctxModel = (): string | undefined => (lastCtx?.model ? `${lastCtx.model.provider}/${lastCtx.model.id}` : undefined);
|
|
427
459
|
const ctxThinking = (): string | undefined => lastCtx?.thinkingLevel;
|
|
428
460
|
|
|
429
|
-
//
|
|
461
|
+
// jj keep-path seal on `phase complete ship`; git checkouts are sealed by the bin. Defined in block 3.
|
|
430
462
|
let onShipKeep: (snap: SettingsSnapshot) => Promise<void>;
|
|
431
463
|
|
|
432
464
|
// ---- handlers ----
|
|
433
|
-
|
|
465
|
+
// Same lifecycle set as phase-tracker.ts: every navigation rebuilds marker, phases, and
|
|
466
|
+
// binding from the target branch in order.
|
|
467
|
+
const reconstruct = async (ctx: ExtensionContext) => {
|
|
434
468
|
lastCtx = ctx;
|
|
435
469
|
const snap = enabledSettings(ctx);
|
|
436
470
|
if (!snap) return;
|
|
471
|
+
flush();
|
|
472
|
+
unbind();
|
|
473
|
+
buffer = [];
|
|
474
|
+
pending = emptyAccumulators();
|
|
475
|
+
lastPlanTasks = undefined;
|
|
476
|
+
amendmentOpen = false;
|
|
477
|
+
pendingTest.clear();
|
|
437
478
|
sessionId = ctx.sessionManager.getSessionId();
|
|
438
479
|
currentDir = snap.telemetry.dir;
|
|
439
480
|
const replay = replayBranch(ctx.sessionManager.getBranch());
|
|
440
481
|
phases = replay.phases;
|
|
482
|
+
gauntletEntered = replay.gauntletEntered;
|
|
483
|
+
if (!canBind()) return;
|
|
441
484
|
const candidate = replay.planCheckSpec ?? replay.lastSpecWrite;
|
|
442
485
|
const loc = candidate ? await locate(ctx, candidate) : undefined;
|
|
443
486
|
if (loc && isSpecPath(loc.rel)) {
|
|
444
487
|
await bind(loc.rel, snap, loc.toplevel, loc.via);
|
|
445
|
-
|
|
488
|
+
flush();
|
|
446
489
|
}
|
|
447
|
-
}
|
|
490
|
+
};
|
|
491
|
+
for (const event of ["session_start", "session_switch", "session_fork", "session_tree"] as const) {
|
|
492
|
+
pi.on(event, async (_event, ctx) => reconstruct(ctx));
|
|
493
|
+
}
|
|
448
494
|
|
|
449
495
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
450
496
|
const snap = enabledSettings(ctx);
|
|
@@ -454,7 +500,7 @@
|
|
|
454
500
|
record.accumulators.total = foldAccumulators(record.accumulators.total ?? emptyAccumulators(), liveBlock);
|
|
455
501
|
delete record.accumulators[sessionId];
|
|
456
502
|
}
|
|
457
|
-
|
|
503
|
+
flush();
|
|
458
504
|
});
|
|
459
505
|
|
|
460
506
|
pi.on("tool_result", async (event, ctx) => {
|
|
@@ -466,6 +512,7 @@
|
|
|
466
512
|
await applyPhaseDetails(event.details as never, snap);
|
|
467
513
|
return undefined;
|
|
468
514
|
}
|
|
515
|
+
if (!record && !canBind()) return undefined;
|
|
469
516
|
if (event.toolName === "plan_check" && !event.isError) {
|
|
470
517
|
const d = event.details as { status?: string; specPath?: string; planPath?: string } | undefined;
|
|
471
518
|
const specLoc = d?.specPath ? await locate(ctx, d.specPath) : undefined;
|
|
@@ -473,13 +520,13 @@
|
|
|
473
520
|
const spec = specLoc?.rel;
|
|
474
521
|
const plan = planLoc?.rel;
|
|
475
522
|
const pass = d?.status === "pass";
|
|
476
|
-
if (pass && specLoc && (specLoc.rel !== boundSpec || specLoc.toplevel !== toplevel)) {
|
|
523
|
+
if (pass && canBind() && specLoc && (specLoc.rel !== boundSpec || specLoc.toplevel !== toplevel)) {
|
|
477
524
|
if (record) unbind();
|
|
478
525
|
await bind(specLoc.rel, snap, specLoc.toplevel, specLoc.via);
|
|
479
526
|
}
|
|
480
527
|
live().gates.plan_rounds += 1;
|
|
481
528
|
pushEvent({ kind: "plan_check", pass, spec, plan });
|
|
482
|
-
|
|
529
|
+
flush();
|
|
483
530
|
return undefined;
|
|
484
531
|
}
|
|
485
532
|
if ((event.toolName === "write" || event.toolName === "edit" || event.toolName === "read") && !event.isError) {
|
|
@@ -500,21 +547,11 @@
|
|
|
500
547
|
return undefined;
|
|
501
548
|
}
|
|
502
549
|
if (event.toolName === "bash") {
|
|
503
|
-
if (pendingShip && pendingShip.id === event.toolCallId) {
|
|
504
|
-
pendingShip = undefined;
|
|
505
|
-
if (event.isError) {
|
|
506
|
-
pushEvent({ kind: "ship_failed" });
|
|
507
|
-
clearShipState();
|
|
508
|
-
await flush(false);
|
|
509
|
-
} else {
|
|
510
|
-
frozen = true;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
550
|
const matched = pendingTest.get(event.toolCallId);
|
|
514
551
|
if (matched && record) {
|
|
515
552
|
pendingTest.delete(event.toolCallId);
|
|
516
553
|
record.derived.tests = { command: truncateCommand(matched), result: event.isError ? "fail" : "pass" };
|
|
517
|
-
|
|
554
|
+
flush();
|
|
518
555
|
}
|
|
519
556
|
return undefined;
|
|
520
557
|
}
|
|
@@ -528,26 +565,28 @@
|
|
|
528
565
|
if (!boundSpec) {
|
|
529
566
|
if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
|
|
530
567
|
await bind(rel, snap, loc.toplevel, loc.via);
|
|
568
|
+
if (!boundSpec) return undefined;
|
|
531
569
|
const patch = onBoundSpecWrite(tool, rel, event);
|
|
532
|
-
|
|
570
|
+
flush();
|
|
533
571
|
return patch;
|
|
534
572
|
} else if (isPlanPath(rel)) {
|
|
535
573
|
const spec = planSpecHeader(deps.fs.readFile(join(loc.toplevel, rel)) ?? "");
|
|
536
574
|
const specRel = spec ? repoRelativeToolPath(loc.toplevel, loc.toplevel, spec) : undefined;
|
|
537
575
|
if (specRel && deps.fs.exists(join(loc.toplevel, specRel))) {
|
|
538
576
|
await bind(specRel, snap, loc.toplevel, loc.via);
|
|
539
|
-
|
|
577
|
+
flush();
|
|
540
578
|
}
|
|
541
579
|
}
|
|
542
580
|
return undefined;
|
|
543
581
|
}
|
|
544
582
|
if (loc.toplevel !== toplevel) {
|
|
545
583
|
// Another checkout: a fresh run, never a rename of this record.
|
|
546
|
-
if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
|
|
584
|
+
if ((tool === "write" || tool === "edit") && isSpecPath(rel) && canBind() && !sealedOnDisk(loc.toplevel, rel, snap.telemetry.dir)) {
|
|
547
585
|
unbind();
|
|
548
586
|
await bind(rel, snap, loc.toplevel, loc.via);
|
|
587
|
+
if (!boundSpec) return undefined;
|
|
549
588
|
const patch = onBoundSpecWrite(tool, rel, event);
|
|
550
|
-
|
|
589
|
+
flush();
|
|
551
590
|
return patch;
|
|
552
591
|
}
|
|
553
592
|
return undefined;
|
|
@@ -555,17 +594,17 @@
|
|
|
555
594
|
if (tool === "read") return undefined;
|
|
556
595
|
if (rel === boundSpec) {
|
|
557
596
|
const patch = onBoundSpecWrite(tool, rel, event);
|
|
558
|
-
|
|
597
|
+
flush();
|
|
559
598
|
return patch;
|
|
560
599
|
}
|
|
561
600
|
if (tool === "write" && isSpecPath(rel) && phases.brainstorm.status === "in_progress" && isDraftOrMissing(boundSpec)) {
|
|
562
601
|
if (await rebind(rel)) onBoundSpecWrite(tool, rel, event);
|
|
563
|
-
|
|
602
|
+
flush();
|
|
564
603
|
return undefined;
|
|
565
604
|
}
|
|
566
605
|
if (tool === "edit" && isSpecPath(rel)) {
|
|
567
606
|
onOtherSpecEdit(rel, event);
|
|
568
|
-
|
|
607
|
+
flush();
|
|
569
608
|
}
|
|
570
609
|
return undefined;
|
|
571
610
|
};
|
|
@@ -622,7 +661,7 @@
|
|
|
622
661
|
}
|
|
623
662
|
}
|
|
624
663
|
}
|
|
625
|
-
|
|
664
|
+
flush();
|
|
626
665
|
};
|
|
627
666
|
|
|
628
667
|
const onPlanTracker = async (details: unknown, snap: SettingsSnapshot) => {
|
|
@@ -639,97 +678,20 @@
|
|
|
639
678
|
await applyPhaseDetails({ action: "complete", phases: next }, snap);
|
|
640
679
|
return;
|
|
641
680
|
}
|
|
642
|
-
if (record)
|
|
643
|
-
};
|
|
644
|
-
|
|
645
|
-
let pendingShip: { id: string; option: "squash" | "pr" | "discard" } | undefined;
|
|
646
|
-
|
|
647
|
-
const computeDiff = async (snap: SettingsSnapshot) => {
|
|
648
|
-
if (checkoutVia === "jj") await computeJjDiff(snap);
|
|
649
|
-
else await computeGitDiff(snap);
|
|
650
|
-
};
|
|
651
|
-
|
|
652
|
-
const JJ_MAINLINE = "coalesce(trunk() ~ root(), present(main), present(master))";
|
|
653
|
-
|
|
654
|
-
const computeJjDiff = async (snap: SettingsSnapshot) => {
|
|
655
|
-
if (!record || !toplevel) return;
|
|
656
|
-
const jj = deps.jj ?? realJj;
|
|
657
|
-
const failed = (sub: string, r: GitResult) => warn(`diff omitted: jj ${sub} failed: ${r.stderr.trim().split("\n")[0] || "unknown error"}`);
|
|
658
|
-
// Without the ::M guard an empty mainline resolves to @ and counts the full history.
|
|
659
|
-
const baseR = await jj(["--color=never", "log", "-r", `fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`, "--no-graph", "-T", 'commit_id ++ "\\n"'], toplevel);
|
|
660
|
-
if (baseR.code !== 0) return failed("log", baseR);
|
|
661
|
-
const base = baseR.stdout.trim().split("\n")[0].trim();
|
|
662
|
-
if (!base) return warn("diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)");
|
|
663
|
-
const patch = await jj(["--color=never", "--config", "diff.git.show-path-prefix=true", "diff", "--from", base, "--to", "@", "--git"], toplevel);
|
|
664
|
-
if (patch.code !== 0) return failed("diff", patch);
|
|
665
|
-
const rows = parsePatchNumstat(patch.stdout);
|
|
666
|
-
if (!rows) return warn("diff omitted: jj diff unparseable");
|
|
667
|
-
const dir = currentDir.replace(/\/+$/, "");
|
|
668
|
-
// Exclude record-only @ and other telemetry-only revisions from the count.
|
|
669
|
-
const count = await jj(["--color=never", "log", "-r", `(${base}::@ ~ ${base}) & files(~glob:"${dir}/**")`, "--count"], toplevel);
|
|
670
|
-
if (count.code !== 0) return failed("log", count);
|
|
671
|
-
const n = count.stdout.trim();
|
|
672
|
-
if (!/^\d+$/.test(n)) return warn(`diff omitted: jj log --count unparseable: ${n}`);
|
|
673
|
-
const files = modifiedFilesFrom(rows.map((r) => r.path).join("\n"), record.spec, currentDir);
|
|
674
|
-
const numstat = rows.map((r) => `${r.added}\t${r.removed}\t${r.path}`).join("\n");
|
|
675
|
-
record.derived.modified_files = files;
|
|
676
|
-
record.derived.diff = { base, commits: Number(n), buckets: aggregateNumstat(numstat, new Set(files), snap.telemetry.buckets) };
|
|
677
|
-
};
|
|
678
|
-
|
|
679
|
-
const computeGitDiff = async (snap: SettingsSnapshot) => {
|
|
680
|
-
if (!record || !toplevel) return;
|
|
681
|
-
let baseRef: string | undefined;
|
|
682
|
-
for (const ref of BASE_REFS) {
|
|
683
|
-
if ((await deps.git(["rev-parse", "--verify", "--quiet", ref], toplevel!)).code === 0) {
|
|
684
|
-
baseRef = ref;
|
|
685
|
-
break;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
if (!baseRef) {
|
|
689
|
-
warn("diff omitted: no base ref among origin/HEAD, main, master");
|
|
690
|
-
return;
|
|
691
|
-
}
|
|
692
|
-
const mb = await deps.git(["merge-base", "HEAD", baseRef], toplevel!);
|
|
693
|
-
if (mb.code !== 0 || !mb.stdout.trim()) {
|
|
694
|
-
warn(`diff omitted: merge-base failed: ${mb.stderr.trim().split("\n")[0]}`);
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
const base = mb.stdout.trim();
|
|
698
|
-
const names = await deps.git(["diff", "--name-only", `${base}...HEAD`], toplevel!);
|
|
699
|
-
const files = modifiedFilesFrom(names.stdout, record.spec, currentDir);
|
|
700
|
-
const numstat = await deps.git(["diff", "--numstat", `${base}...HEAD`], toplevel!);
|
|
701
|
-
const count = await deps.git(["rev-list", "--count", "--invert-grep", "--grep=^telemetry: ", `${base}..HEAD`], toplevel!);
|
|
702
|
-
record.derived.modified_files = files;
|
|
703
|
-
record.derived.diff = { base, commits: Number(count.stdout.trim()) || 0, buckets: aggregateNumstat(numstat.stdout, new Set(files), snap.telemetry.buckets) };
|
|
704
|
-
};
|
|
705
|
-
|
|
706
|
-
const clearShipState = () => {
|
|
707
|
-
if (!record) return;
|
|
708
|
-
record.shipped_at = undefined;
|
|
709
|
-
record.abandoned_at = undefined;
|
|
710
|
-
record.status = "in_progress";
|
|
711
|
-
record.derived.diff = undefined;
|
|
712
|
-
record.derived.modified_files = undefined;
|
|
713
|
-
};
|
|
714
|
-
|
|
715
|
-
const shipAttempt = async (option: "squash" | "pr" | "keep" | "discard", command: string | undefined, snap: SettingsSnapshot) => {
|
|
716
|
-
if (!record) return;
|
|
717
|
-
const ts = deps.now();
|
|
718
|
-
if (option === "discard") {
|
|
719
|
-
record.status = "abandoned";
|
|
720
|
-
record.abandoned_at = ts;
|
|
721
|
-
} else {
|
|
722
|
-
record.status = "shipped";
|
|
723
|
-
record.shipped_at = ts;
|
|
724
|
-
await computeDiff(snap);
|
|
725
|
-
}
|
|
726
|
-
pushEvent({ kind: "ship", option, command: command ? truncateCommand(command) : undefined });
|
|
727
|
-
await flush(true);
|
|
681
|
+
if (record) flush();
|
|
728
682
|
};
|
|
729
683
|
|
|
684
|
+
// jj only: finishing cannot run in a plain jj workspace, so `phase complete ship` seals
|
|
685
|
+
// the record in place (jj snapshots the working copy; there is no commit step).
|
|
730
686
|
onShipKeep = async (snap) => {
|
|
731
|
-
if (!record || liveShipEvent(record.events)) return;
|
|
732
|
-
|
|
687
|
+
if (!record || !toplevel || liveShipEvent(record.events)) return;
|
|
688
|
+
record.status = "shipped";
|
|
689
|
+
record.shipped_at = deps.now();
|
|
690
|
+
const out = await computeJjDiff({ jj: deps.jj ?? realJj, cwd: toplevel, spec: record.spec, dir: currentDir, buckets: snap.telemetry.buckets });
|
|
691
|
+
if (out.warning) warn(out.warning);
|
|
692
|
+
record.derived.modified_files = out.modified_files;
|
|
693
|
+
record.derived.diff = out.diff;
|
|
694
|
+
pushEvent({ kind: "ship", option: "keep" });
|
|
733
695
|
};
|
|
734
696
|
|
|
735
697
|
pi.on("tool_call", async (event, ctx) => {
|
|
@@ -751,20 +713,6 @@
|
|
|
751
713
|
}
|
|
752
714
|
if (event.toolName === "bash") {
|
|
753
715
|
const command = String((event.input as { command?: unknown }).command ?? "");
|
|
754
|
-
if (record && !frozen && phases.ship.status === "in_progress" && !liveShipEvent(record.events)) {
|
|
755
|
-
const ship = matchShipStatement(command);
|
|
756
|
-
if (ship) {
|
|
757
|
-
pendingShip = { id: event.toolCallId, option: ship.option };
|
|
758
|
-
await shipAttempt(ship.option, ship.statement, snap);
|
|
759
|
-
return undefined;
|
|
760
|
-
}
|
|
761
|
-
const discard = matchDiscardStatement(command);
|
|
762
|
-
if (discard) {
|
|
763
|
-
pendingShip = { id: event.toolCallId, option: "discard" };
|
|
764
|
-
await shipAttempt("discard", discard, snap);
|
|
765
|
-
return undefined;
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
716
|
if (record && (phases.verify.status === "in_progress" || phases.ship.status === "in_progress")) {
|
|
769
717
|
const statement = matchTestStatement(command, snap.testCommands ?? DEFAULT_TEST_COMMANDS);
|
|
770
718
|
if (statement) pendingTest.set(event.toolCallId, statement);
|
|
@@ -804,21 +752,21 @@
|
|
|
804
752
|
lastCtx = ctx;
|
|
805
753
|
if (!enabledSettings(ctx) || phaseNow() === "unphased") return;
|
|
806
754
|
pushEvent({ kind: "config_change", model: `${event.model.provider}/${event.model.id}` });
|
|
807
|
-
|
|
755
|
+
flush();
|
|
808
756
|
});
|
|
809
757
|
|
|
810
758
|
pi.on("thinking_level_select", async (event, ctx) => {
|
|
811
759
|
lastCtx = ctx;
|
|
812
760
|
if (!enabledSettings(ctx) || phaseNow() === "unphased") return;
|
|
813
761
|
pushEvent({ kind: "config_change", thinking: String(event.level) });
|
|
814
|
-
|
|
762
|
+
flush();
|
|
815
763
|
});
|
|
816
764
|
|
|
817
765
|
pi.on("session_compact", async (_event, ctx) => {
|
|
818
766
|
if (!enabledSettings(ctx)) return;
|
|
819
767
|
const acc = phaseAcc();
|
|
820
768
|
acc.compactions = (acc.compactions ?? 0) + 1;
|
|
821
|
-
|
|
769
|
+
flush();
|
|
822
770
|
});
|
|
823
771
|
|
|
824
772
|
onBoundSpecWrite = (tool, rel, event) => {
|
|
@@ -832,7 +780,6 @@
|
|
|
832
780
|
acc.spec_writes[k] = { count: cur.count + 1, last_sha256: sha256(new TextEncoder().encode(body)) };
|
|
833
781
|
refreshLinks();
|
|
834
782
|
if (record.shipped_at) {
|
|
835
|
-
acc.spec_edits_after_ship += 1;
|
|
836
783
|
const warning = `⚠️ spec shipped at ${record.shipped_at}; write a follow-up spec that supersedes it`;
|
|
837
784
|
return { content: [{ type: "text" as const, text: warning }, ...(event.content as { type: string }[])] };
|
|
838
785
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-gauntlet",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.20.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",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
],
|
|
34
34
|
"bin": {
|
|
35
35
|
"gauntlet-spec-index": "bin/gauntlet-spec-index.mjs",
|
|
36
|
-
"gauntlet-telemetry-
|
|
36
|
+
"gauntlet-telemetry-seal": "bin/gauntlet-telemetry-seal.mjs",
|
|
37
37
|
"gauntlet-performance": "bin/gauntlet-performance.mjs"
|
|
38
38
|
},
|
|
39
39
|
"engines": {
|
|
@@ -66,7 +66,7 @@ The spec is the first commit in a dedicated worktree, never a separate commit on
|
|
|
66
66
|
2. Carry its `Worktree ready at <full-path>` value: spec path `<full-path>/doc/specs/<filename>.md`; every dispatch uses that `cwd`; git uses `git -C <full-path>`. Keep the process cwd unchanged.
|
|
67
67
|
3. Write, review, and commit there.
|
|
68
68
|
|
|
69
|
-
Spec, plan, and implementation share this worktree. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. `/skill:finishing-a-development-branch` strips the plan
|
|
69
|
+
Spec, plan, and implementation share this worktree. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. `/skill:finishing-a-development-branch` strips the plan, then seals and commits the record before landing. Explicit trivial one-off edits outside this flow need no worktree.
|
|
70
70
|
|
|
71
71
|
## The Process
|
|
72
72
|
|