pi-gauntlet 5.9.0 → 5.10.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 +11 -0
- package/README.md +3 -1
- package/agents/spec-council-member.md +4 -2
- package/agents/spec-council-synthesizer.md +3 -1
- package/extensions/lib/checkout.test.ts +59 -0
- package/extensions/lib/checkout.ts +57 -0
- package/extensions/lib/gauntlet-settings-loader.test.ts +32 -0
- package/extensions/lib/gauntlet-settings-loader.ts +11 -7
- package/extensions/lib/phase-tracker-helpers.test.ts +17 -0
- package/extensions/lib/phase-tracker-helpers.ts +16 -17
- package/extensions/lib/telemetry-paths.test.ts +10 -0
- package/extensions/lib/telemetry-paths.ts +9 -5
- package/extensions/phase-tracker.test.ts +75 -23
- package/extensions/phase-tracker.ts +20 -29
- package/extensions/telemetry.test.ts +51 -10
- package/extensions/telemetry.ts +57 -41
- package/extensions/verify-before-ship.test.ts +16 -0
- package/extensions/verify-before-ship.ts +4 -4
- package/package.json +1 -1
- package/skills/brainstorming/SKILL.md +80 -192
- package/skills/finishing-a-development-branch/SKILL.md +51 -88
- package/skills/gauntlet-resume/SKILL.md +1 -3
- package/skills/gauntlet-resume/reference/brief-contract.md +1 -1
- package/skills/roasting-the-spec/SKILL.md +4 -2
- package/skills/subagent-driven-development/SKILL.md +18 -15
- package/skills/using-git-worktrees/SKILL.md +46 -39
- package/skills/verification-before-completion/reference/conformance-check.md +6 -6
- package/skills/verification-before-completion/reference/settings-precedence.md +8 -6
- package/skills/writing-plans/SKILL.md +5 -3
- package/skills/writing-plans/reference/plan-contract.md +1 -1
package/extensions/telemetry.ts
CHANGED
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
import { execFile } from "node:child_process";
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
11
|
import * as nodeFs from "node:fs";
|
|
12
|
-
import { dirname, join, posix } from "node:path";
|
|
12
|
+
import { dirname, isAbsolute, join, posix, resolve } from "node:path";
|
|
13
13
|
import * as piRuntime from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { DEFAULT_TEST_COMMANDS, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
|
|
17
17
|
import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
|
|
18
|
+
import { checkoutOf } from "./lib/checkout.ts";
|
|
18
19
|
import { CONTEXT_DRAFT_MARKER } from "./lib/phase-tracker-helpers.ts";
|
|
19
20
|
import { sha256 } from "./lib/plan-check.ts";
|
|
20
21
|
import { SUPERSEDED_BY_RE, aggregateNumstat, isPlanPath, isSpecPath, isSupersededByBanner, matchDiscardStatement, matchShipStatement, matchTestStatement, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
|
|
@@ -100,9 +101,9 @@
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
function realSettings(cwd: string): SettingsSnapshot {
|
|
103
|
-
const { gauntlet, errors } = loadGauntletSettings(cwd);
|
|
104
|
+
const { gauntlet, errors, root } = loadGauntletSettings(cwd);
|
|
104
105
|
const telemetry = resolveTelemetry(gauntlet);
|
|
105
|
-
const sm = SettingsManager.create(
|
|
106
|
+
const sm = SettingsManager.create(root, getAgentDir());
|
|
106
107
|
const layer = (s: unknown) => ((s as { subagents?: { agentOverrides?: unknown } })?.subagents?.agentOverrides ?? undefined) as Record<string, unknown> | undefined;
|
|
107
108
|
const preset = layer(sm.getGlobalSettings());
|
|
108
109
|
const repo = layer(sm.getProjectSettings());
|
|
@@ -158,8 +159,7 @@
|
|
|
158
159
|
|
|
159
160
|
export default function (pi: ExtensionAPI, deps: Deps = realDeps) {
|
|
160
161
|
let phases: PhaseMap = emptyPhases();
|
|
161
|
-
let toplevel: string | undefined;
|
|
162
|
-
let inGit = false;
|
|
162
|
+
let toplevel: string | undefined; // toplevel of the bound record's checkout; undefined until bind
|
|
163
163
|
let boundSpec: string | undefined; // repo-relative
|
|
164
164
|
let record: TelemetryRecord | undefined;
|
|
165
165
|
const predecessorLinks = new Set<string>();
|
|
@@ -180,12 +180,13 @@
|
|
|
180
180
|
return s;
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
// Resolves a tool path (relative to ctx.cwd or absolute) to its owning checkout and
|
|
184
|
+
// checkout-relative key. undefined outside any checkout.
|
|
185
|
+
const locate = async (ctx: ExtensionContext, p: string): Promise<{ toplevel: string; rel: string } | undefined> => {
|
|
186
|
+
const candidateAbs = isAbsolute(p) ? p : resolve(ctx.cwd, p);
|
|
187
|
+
const co = await checkoutOf(candidateAbs, deps.git);
|
|
188
|
+
const rel = co ? repoRelativeToolPath(co.toplevel, ctx.cwd, p) : undefined;
|
|
189
|
+
return co && rel ? { toplevel: co.toplevel, rel } : undefined;
|
|
189
190
|
};
|
|
190
191
|
|
|
191
192
|
const live = (): Accumulators => record
|
|
@@ -228,7 +229,7 @@
|
|
|
228
229
|
};
|
|
229
230
|
|
|
230
231
|
const commitRecord = async () => {
|
|
231
|
-
if (!record || !recordRel || frozen || !
|
|
232
|
+
if (!record || !recordRel || frozen || !toplevel) return;
|
|
232
233
|
const text = deps.fs.readFile(abs(recordRel)) ?? "";
|
|
233
234
|
if (text === lastCommitted) return;
|
|
234
235
|
const paths = [recordRel, ...(oldRecordRel ? [oldRecordRel] : [])];
|
|
@@ -273,7 +274,8 @@
|
|
|
273
274
|
return newRecord({ spec: specRel, session: sessionId, now: buffer[0]?.ts ?? deps.now(), runId: randomUUID() });
|
|
274
275
|
};
|
|
275
276
|
|
|
276
|
-
const bind = async (specRel: string, snap: SettingsSnapshot) => {
|
|
277
|
+
const bind = async (specRel: string, snap: SettingsSnapshot, specToplevel: string) => {
|
|
278
|
+
toplevel = specToplevel;
|
|
277
279
|
currentDir = snap.telemetry.dir;
|
|
278
280
|
boundSpec = specRel;
|
|
279
281
|
recordRel = recordPathFor(currentDir, specRel);
|
|
@@ -302,7 +304,6 @@
|
|
|
302
304
|
const approval = record.events.find((e) => e.kind === "phase" && e.name === "brainstorm" && (e.action === "complete" || e.action === "skip"));
|
|
303
305
|
if (approval) record.approved_at ??= approval.ts;
|
|
304
306
|
buffer = [];
|
|
305
|
-
if (!inGit) warn("not a git repository: record is written but never committed");
|
|
306
307
|
if (!settingsWarned && (snap.errors.length || snap.telemetry.warning)) {
|
|
307
308
|
settingsWarned = true;
|
|
308
309
|
if (snap.errors.length) warn(settingsErrorWarning(snap.errors));
|
|
@@ -312,6 +313,7 @@
|
|
|
312
313
|
};
|
|
313
314
|
|
|
314
315
|
const unbind = () => {
|
|
316
|
+
toplevel = undefined;
|
|
315
317
|
boundSpec = undefined;
|
|
316
318
|
record = undefined;
|
|
317
319
|
recordRel = undefined;
|
|
@@ -347,8 +349,8 @@
|
|
|
347
349
|
warn(`record move skipped: ${to} already exists`);
|
|
348
350
|
return false;
|
|
349
351
|
}
|
|
350
|
-
const trackedResult =
|
|
351
|
-
const tracked = trackedResult
|
|
352
|
+
const trackedResult = await deps.git(["ls-files", "--error-unmatch", "--", from], toplevel!);
|
|
353
|
+
const tracked = trackedResult.code === 0 && trackedResult.stdout.trim() !== "";
|
|
352
354
|
try {
|
|
353
355
|
deps.fs.mkdirp(dirname(abs(to)));
|
|
354
356
|
if (tracked) {
|
|
@@ -408,14 +410,13 @@
|
|
|
408
410
|
const snap = enabledSettings(ctx);
|
|
409
411
|
if (!snap) return;
|
|
410
412
|
sessionId = ctx.sessionManager.getSessionId();
|
|
411
|
-
await ensureToplevel(ctx);
|
|
412
413
|
currentDir = snap.telemetry.dir;
|
|
413
414
|
const replay = replayBranch(ctx.sessionManager.getBranch());
|
|
414
415
|
phases = replay.phases;
|
|
415
416
|
const candidate = replay.planCheckSpec ?? replay.lastSpecWrite;
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
418
|
-
await bind(rel, snap);
|
|
417
|
+
const loc = candidate ? await locate(ctx, candidate) : undefined;
|
|
418
|
+
if (loc && isSpecPath(loc.rel)) {
|
|
419
|
+
await bind(loc.rel, snap, loc.toplevel);
|
|
419
420
|
await flush(false);
|
|
420
421
|
}
|
|
421
422
|
});
|
|
@@ -436,19 +437,20 @@
|
|
|
436
437
|
const snap = enabledSettings(ctx);
|
|
437
438
|
if (!snap) return undefined;
|
|
438
439
|
if (!sessionId) sessionId = ctx.sessionManager.getSessionId();
|
|
439
|
-
await ensureToplevel(ctx);
|
|
440
440
|
if (event.toolName === "phase_tracker" && !event.isError) {
|
|
441
441
|
await applyPhaseDetails(event.details as never, snap);
|
|
442
442
|
return undefined;
|
|
443
443
|
}
|
|
444
444
|
if (event.toolName === "plan_check" && !event.isError) {
|
|
445
445
|
const d = event.details as { status?: string; specPath?: string; planPath?: string } | undefined;
|
|
446
|
-
const
|
|
447
|
-
const
|
|
446
|
+
const specLoc = d?.specPath ? await locate(ctx, d.specPath) : undefined;
|
|
447
|
+
const planLoc = d?.planPath ? await locate(ctx, d.planPath) : undefined;
|
|
448
|
+
const spec = specLoc?.rel;
|
|
449
|
+
const plan = planLoc?.rel;
|
|
448
450
|
const pass = d?.status === "pass";
|
|
449
|
-
if (pass &&
|
|
451
|
+
if (pass && specLoc && (specLoc.rel !== boundSpec || specLoc.toplevel !== toplevel)) {
|
|
450
452
|
if (record) unbind();
|
|
451
|
-
await bind(
|
|
453
|
+
await bind(specLoc.rel, snap, specLoc.toplevel);
|
|
452
454
|
}
|
|
453
455
|
live().gates.plan_rounds += 1;
|
|
454
456
|
pushEvent({ kind: "plan_check", pass, spec, plan });
|
|
@@ -457,9 +459,12 @@
|
|
|
457
459
|
}
|
|
458
460
|
if ((event.toolName === "write" || event.toolName === "edit" || event.toolName === "read") && !event.isError) {
|
|
459
461
|
const rawPath = (event.input as { path?: unknown }).path;
|
|
460
|
-
const
|
|
461
|
-
if (!
|
|
462
|
-
|
|
462
|
+
const loc = typeof rawPath === "string" ? await locate(ctx, rawPath) : undefined;
|
|
463
|
+
if (!loc) {
|
|
464
|
+
if (typeof rawPath === "string" && isSpecPath(toPosix(rawPath))) warn(`spec ${rawPath} is outside a git checkout; telemetry not recorded`);
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
return onSpecInteraction(event.toolName, loc, event, snap);
|
|
463
468
|
}
|
|
464
469
|
if (event.toolName === "subagent" && !event.isError) {
|
|
465
470
|
await onSubagentResult(event.details, event.content);
|
|
@@ -493,23 +498,35 @@
|
|
|
493
498
|
});
|
|
494
499
|
|
|
495
500
|
// 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> => {
|
|
501
|
+
const onSpecInteraction = async (tool: "write" | "edit" | "read", loc: { toplevel: string; rel: string }, event: { input: unknown; content: unknown[] }, snap: SettingsSnapshot): Promise<unknown> => {
|
|
502
|
+
const rel = loc.rel;
|
|
497
503
|
if (!boundSpec) {
|
|
498
504
|
if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
|
|
499
|
-
await bind(rel, snap);
|
|
505
|
+
await bind(rel, snap, loc.toplevel);
|
|
500
506
|
const patch = onBoundSpecWrite(tool, rel, event);
|
|
501
507
|
await flush(true);
|
|
502
508
|
return patch;
|
|
503
509
|
} else if (isPlanPath(rel)) {
|
|
504
|
-
const spec = planSpecHeader(deps.fs.readFile(
|
|
505
|
-
const specRel = spec ? repoRelativeToolPath(toplevel
|
|
506
|
-
if (specRel && deps.fs.exists(
|
|
507
|
-
await bind(specRel, snap);
|
|
510
|
+
const spec = planSpecHeader(deps.fs.readFile(join(loc.toplevel, rel)) ?? "");
|
|
511
|
+
const specRel = spec ? repoRelativeToolPath(loc.toplevel, loc.toplevel, spec) : undefined;
|
|
512
|
+
if (specRel && deps.fs.exists(join(loc.toplevel, specRel))) {
|
|
513
|
+
await bind(specRel, snap, loc.toplevel);
|
|
508
514
|
await flush(true);
|
|
509
515
|
}
|
|
510
516
|
}
|
|
511
517
|
return undefined;
|
|
512
518
|
}
|
|
519
|
+
if (loc.toplevel !== toplevel) {
|
|
520
|
+
// Another checkout: a fresh run, never a rename of this record.
|
|
521
|
+
if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
|
|
522
|
+
unbind();
|
|
523
|
+
await bind(rel, snap, loc.toplevel);
|
|
524
|
+
const patch = onBoundSpecWrite(tool, rel, event);
|
|
525
|
+
await flush(true);
|
|
526
|
+
return patch;
|
|
527
|
+
}
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
513
530
|
if (tool === "read") return undefined;
|
|
514
531
|
if (rel === boundSpec) {
|
|
515
532
|
const patch = onBoundSpecWrite(tool, rel, event);
|
|
@@ -603,7 +620,7 @@
|
|
|
603
620
|
let pendingShip: { id: string; option: "squash" | "pr" | "discard" } | undefined;
|
|
604
621
|
|
|
605
622
|
const computeDiff = async (snap: SettingsSnapshot) => {
|
|
606
|
-
if (!record || !
|
|
623
|
+
if (!record || !toplevel) return;
|
|
607
624
|
let baseRef: string | undefined;
|
|
608
625
|
for (const ref of BASE_REFS) {
|
|
609
626
|
if ((await deps.git(["rev-parse", "--verify", "--quiet", ref], toplevel!)).code === 0) {
|
|
@@ -665,13 +682,12 @@
|
|
|
665
682
|
if (event.toolName === "write") {
|
|
666
683
|
const rawPath = (event.input as { path?: unknown }).path;
|
|
667
684
|
if (typeof rawPath === "string" && phases.brainstorm.status === "in_progress") {
|
|
668
|
-
await
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
const
|
|
672
|
-
const existing = deps.fs.readFile(join(toplevel!, recRel));
|
|
685
|
+
const loc = await locate(ctx, rawPath);
|
|
686
|
+
if (loc && isSpecPath(loc.rel)) {
|
|
687
|
+
const recRel = recordPathFor(snap.telemetry.dir, loc.rel);
|
|
688
|
+
const existing = deps.fs.readFile(join(loc.toplevel, recRel));
|
|
673
689
|
const parsed = existing ? parseRecord(existing) : undefined;
|
|
674
|
-
if (parsed?.shipped_at) return { block: true, reason: guardReason(rel, parsed.shipped_at, recRel) };
|
|
690
|
+
if (parsed?.shipped_at) return { block: true, reason: guardReason(loc.rel, parsed.shipped_at, recRel) };
|
|
675
691
|
}
|
|
676
692
|
}
|
|
677
693
|
return undefined;
|
|
@@ -72,6 +72,22 @@ test("gh pr create warns when unverified", async () => {
|
|
|
72
72
|
assert.match(warning ?? "", /ran without verification/);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
test("git -C <worktree> push and merge --squash are ship events (AC 5)", async () => {
|
|
76
|
+
for (const cmd of ["git -C /p push -u origin HEAD", "git -C /p merge --squash f"]) {
|
|
77
|
+
const h = harness();
|
|
78
|
+
await editSource(h);
|
|
79
|
+
await h.emitEvent("tool_call", bashCall("s1", cmd));
|
|
80
|
+
assert.match(warningOf(await h.emitEvent("tool_result", bashResult("s1"))) ?? "", /ran without verification/, cmd);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a quoted mention of git push is not a ship event", async () => {
|
|
85
|
+
const h = harness();
|
|
86
|
+
await editSource(h);
|
|
87
|
+
await h.emitEvent("tool_call", bashCall("q1", 'rg "git push" skills/'));
|
|
88
|
+
assert.equal((await h.emitEvent("tool_result", bashResult("q1")))[0], undefined);
|
|
89
|
+
});
|
|
90
|
+
|
|
75
91
|
test("a passing recognised run clears the warning for push", async () => {
|
|
76
92
|
const h = harness();
|
|
77
93
|
await editSource(h);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Verify-before-ship extension
|
|
3
3
|
*
|
|
4
|
-
* Single-session verification gate for shipping commands
|
|
4
|
+
* Single-session verification gate for statement-start shipping commands: git merge
|
|
5
|
+
* --squash, git push, and gh pr create (with any git global flags).
|
|
5
6
|
* Local commits are not ship events; commit-time review-cadence advisories live in
|
|
6
7
|
* phase-tracker. Tracks whether a recognised verification command has succeeded
|
|
7
8
|
* since the last source-file write; injects an advisory warning into the tool
|
|
@@ -32,8 +33,7 @@ import {
|
|
|
32
33
|
settingsErrorWarning,
|
|
33
34
|
} from "./lib/gauntlet-settings.ts";
|
|
34
35
|
import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
|
|
35
|
-
|
|
36
|
-
const SHIP_CMD = /\b(git\s+push|gh\s+pr\s+create)\b/;
|
|
36
|
+
import { matchShipStatement } from "./lib/telemetry-paths.ts";
|
|
37
37
|
|
|
38
38
|
const SOURCE_EXT = /\.(ts|tsx|js|jsx|py|rb|go|rs|java|swift|kt)$/;
|
|
39
39
|
const TEST_PATH = /(^|\/)(tests?|__tests__)\/|\.(test|spec)\.|_test\.(py|go|rb)$/;
|
|
@@ -101,7 +101,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
101
101
|
return undefined;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
if (
|
|
104
|
+
if (matchShipStatement(command) && !verified) {
|
|
105
105
|
if (settingsWarned && !settingsErrShown) {
|
|
106
106
|
addShipWarning(event.toolCallId, settingsWarned);
|
|
107
107
|
settingsErrShown = true;
|
package/package.json
CHANGED