pi-gauntlet 5.23.1 → 6.0.1
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 +17 -5
- package/README.md +25 -18
- package/bin/gauntlet-spec-index.mjs +142 -100
- package/bin/gauntlet-spec-index.test.mjs +41 -1
- package/bin/gauntlet-telemetry-seal.mjs +49 -14
- package/bin/gauntlet-telemetry-seal.test.mjs +76 -5
- package/extensions/lib/gauntlet-settings.test.ts +13 -3
- package/extensions/lib/gauntlet-settings.ts +18 -2
- package/extensions/lib/phase-tracker-helpers.test.ts +14 -0
- package/extensions/lib/phase-tracker-helpers.ts +5 -4
- package/extensions/lib/telemetry-diff.test.ts +52 -4
- package/extensions/lib/telemetry-diff.ts +4 -4
- package/extensions/lib/telemetry-paths.test.ts +17 -6
- package/extensions/lib/telemetry-paths.ts +7 -2
- package/extensions/lib/telemetry-ship.test.ts +9 -6
- package/extensions/lib/telemetry-ship.ts +4 -3
- package/extensions/phase-tracker.test.ts +24 -0
- package/extensions/phase-tracker.ts +10 -6
- package/extensions/telemetry.test.ts +32 -7
- package/extensions/telemetry.ts +30 -26
- package/package.json +1 -1
- package/skills/brainstorming/SKILL.md +5 -5
- package/skills/finishing-a-development-branch/SKILL.md +2 -2
- package/skills/forge-skill/SKILL.md +68 -0
- package/skills/gauntlet-resume/reference/reconstruction.md +1 -4
- package/skills/writing-plans/SKILL.md +4 -3
- package/skills/writing-skills/SKILL.md +0 -441
- package/skills/writing-skills/reference/anthropic-best-practices.md +0 -1130
- package/skills/writing-skills/reference/persuasion.md +0 -187
- package/skills/writing-skills/reference/testing-skills-with-subagents.md +0 -384
|
@@ -17,6 +17,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
17
17
|
import { type Static, Type } from "@sinclair/typebox";
|
|
18
18
|
import {
|
|
19
19
|
mainLoopModel,
|
|
20
|
+
planDirsFor,
|
|
20
21
|
resolveClosureReview,
|
|
21
22
|
resolveEscalationLoop,
|
|
22
23
|
resolveFlowGuards,
|
|
@@ -734,7 +735,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
734
735
|
});
|
|
735
736
|
|
|
736
737
|
const GauntletSettingParams = Type.Object({
|
|
737
|
-
key: StringEnum(["specCouncil", "closureReview", "escalationLoop"] as const, {
|
|
738
|
+
key: StringEnum(["specCouncil", "closureReview", "escalationLoop", "flowGuards"] as const, {
|
|
738
739
|
description: "Which gauntlet setting to resolve (merged repo-over-preset).",
|
|
739
740
|
}),
|
|
740
741
|
});
|
|
@@ -747,16 +748,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
747
748
|
executionMode: "sequential",
|
|
748
749
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
749
750
|
const { gauntlet, errors } = loadGauntletSettings(ctx.cwd);
|
|
751
|
+
const fg = resolveFlowGuards(gauntlet);
|
|
750
752
|
const payload =
|
|
751
753
|
params.key === "specCouncil"
|
|
752
754
|
? { key: "specCouncil" as const, ...resolveSpecCouncil(gauntlet), errors }
|
|
753
755
|
: params.key === "closureReview"
|
|
754
756
|
? { key: "closureReview" as const, ...resolveClosureReview(gauntlet), errors }
|
|
755
|
-
:
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
757
|
+
: params.key === "flowGuards"
|
|
758
|
+
? { key: "flowGuards" as const, ...fg, planDirs: planDirsFor(fg.specDirs), errors }
|
|
759
|
+
: {
|
|
760
|
+
key: "escalationLoop" as const,
|
|
761
|
+
...resolveEscalationLoop(gauntlet, mainLoopModel(ctx.model, ctx.thinkingLevel)),
|
|
762
|
+
errors,
|
|
763
|
+
};
|
|
760
764
|
return {
|
|
761
765
|
content: [{ type: "text", text: "```json\n" + JSON.stringify(payload, null, 2) + "\n```" }],
|
|
762
766
|
details: payload,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { after, test } from "node:test";
|
|
6
6
|
import registerTelemetry, { realFs, replayBranch, type Deps, type GitResult } from "./telemetry.ts";
|
|
7
|
-
import { DEFAULT_TELEMETRY_BUCKETS } from "./lib/gauntlet-settings.ts";
|
|
7
|
+
import { DEFAULT_TELEMETRY_BUCKETS, planDirsFor, resolveFlowGuards } from "./lib/gauntlet-settings.ts";
|
|
8
8
|
import { emptyAccumulators, parseRecord, serializeRecord } from "./lib/telemetry-record.ts";
|
|
9
9
|
import { guardReason } from "./lib/telemetry-ship.ts";
|
|
10
10
|
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
const root = mkdtempSync(join(tmpdir(), "telemetry-test-"));
|
|
25
25
|
tempDirs.push(root);
|
|
26
26
|
mkdirSync(join(root, "doc/specs"), { recursive: true });
|
|
27
|
+
mkdirSync(join(root, "docs/specs"), { recursive: true });
|
|
27
28
|
mkdirSync(join(root, "doc/plans"), { recursive: true });
|
|
28
29
|
mkdirSync(join(root, ".worktrees/x/doc/specs"), { recursive: true });
|
|
29
30
|
const handlers = new Map<string, Handler[]>();
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
const gitCwds: string[] = [];
|
|
32
33
|
const jjCalls: string[][] = [];
|
|
33
34
|
const readFileCalls: string[] = [];
|
|
35
|
+
const specDirs = resolveFlowGuards({}).specDirs;
|
|
34
36
|
let clock = Date.parse("2026-09-17T10:00:00Z");
|
|
35
37
|
const deps: Deps = {
|
|
36
38
|
fs: { ...realFs, readFile: (p) => { readFileCalls.push(p); return realFs.readFile(p); } },
|
|
@@ -56,7 +58,7 @@
|
|
|
56
58
|
if (args[0] === "ls-files") return { code: 0, stdout: "", stderr: "" };
|
|
57
59
|
return { code: 0, stdout: "", stderr: "" };
|
|
58
60
|
},
|
|
59
|
-
settings: () => ({ telemetry: { enabled: o.enabled ?? true, dir: o.telemetryDir ?? ".pi/gauntlet/telemetry", buckets: DEFAULT_TELEMETRY_BUCKETS, warning: o.telemetryWarning }, errors: [], agentOverrides: { implementer: { model: "p/x" } }, versions: { pi: "0.85.1" } }),
|
|
61
|
+
settings: () => ({ specDirs, planDirs: planDirsFor(specDirs), telemetry: { enabled: o.enabled ?? true, dir: o.telemetryDir ?? ".pi/gauntlet/telemetry", buckets: DEFAULT_TELEMETRY_BUCKETS, warning: o.telemetryWarning }, errors: [], agentOverrides: { implementer: { model: "p/x" } }, versions: { pi: "0.85.1" } }),
|
|
60
62
|
// A plain jj workspace: `jj root` answers the temp root while git rev-parse fails.
|
|
61
63
|
jj: async (args, cwd) => {
|
|
62
64
|
jjCalls.push(args);
|
|
@@ -180,6 +182,19 @@
|
|
|
180
182
|
assert.equal(readFileSync(h.excludeFile(), "utf8"), ".pi/gauntlet/telemetry/\n", "exclude line is written once after rebind");
|
|
181
183
|
});
|
|
182
184
|
|
|
185
|
+
test("a docs/specs write binds under the default dirs and the record lands under telemetry.dir/docs/specs", async () => {
|
|
186
|
+
const h = harness();
|
|
187
|
+
await h.phaseResult("start", P({ brainstorm: "in_progress" }));
|
|
188
|
+
assert.equal(existsSync(h.recordPath("docs/specs/x.md")), false);
|
|
189
|
+
await h.writeSpec("docs/specs/x.md");
|
|
190
|
+
assert.equal(h.recordPath("docs/specs/x.md"), join(h.root, ".pi/gauntlet/telemetry/docs/specs/x.yaml"));
|
|
191
|
+
assert.equal(h.readRecord("docs/specs/x.md").spec, "docs/specs/x.md");
|
|
192
|
+
await h.phaseResult("complete", P({ brainstorm: "complete" }));
|
|
193
|
+
const rec = h.readRecord("docs/specs/x.md");
|
|
194
|
+
assert.equal(rec.approved_at, rec.events.at(-1)!.ts);
|
|
195
|
+
assert.equal(h.gitWrites().length, 0);
|
|
196
|
+
});
|
|
197
|
+
|
|
183
198
|
test("bind derives approved_at from a buffered brainstorm completion", async () => {
|
|
184
199
|
const h = harness();
|
|
185
200
|
await h.phaseResult("start", P({ brainstorm: "in_progress" }));
|
|
@@ -231,7 +246,7 @@
|
|
|
231
246
|
{ type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w1", isError: false } },
|
|
232
247
|
{ type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w2", isError: false } },
|
|
233
248
|
{ type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w3", isError: true } },
|
|
234
|
-
]);
|
|
249
|
+
], ["doc/specs", "docs/specs"]);
|
|
235
250
|
assert.deepEqual(replay.phases, successfulPhases);
|
|
236
251
|
assert.equal(replay.gauntletEntered, true);
|
|
237
252
|
assert.equal(replay.planCheckSpec, "/repo/doc/specs/a.md");
|
|
@@ -246,7 +261,7 @@
|
|
|
246
261
|
const unarmed = replayBranch([
|
|
247
262
|
...write("w1", "doc/specs/a.md"),
|
|
248
263
|
{ type: "message", message: { role: "toolResult", toolName: "plan_check", details: { status: "pass", specPath: "/repo/doc/specs/a.md" } } },
|
|
249
|
-
]);
|
|
264
|
+
], ["doc/specs", "docs/specs"]);
|
|
250
265
|
assert.equal(unarmed.gauntletEntered, false);
|
|
251
266
|
assert.equal(unarmed.lastSpecWrite, undefined);
|
|
252
267
|
assert.equal(unarmed.planCheckSpec, undefined);
|
|
@@ -254,21 +269,31 @@
|
|
|
254
269
|
...write("w0", "doc/specs/before.md"),
|
|
255
270
|
{ type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
|
|
256
271
|
...write("w1", "doc/specs/a.md", "edit"),
|
|
257
|
-
]);
|
|
272
|
+
], ["doc/specs", "docs/specs"]);
|
|
258
273
|
assert.deepEqual([armed.gauntletEntered, armed.lastSpecWrite], [true, "doc/specs/a.md"]);
|
|
259
274
|
const reset = replayBranch([
|
|
260
275
|
{ type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
|
|
261
276
|
...write("w1", "doc/specs/a.md"),
|
|
262
277
|
{ type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "reset", phases: P() } } },
|
|
263
|
-
]);
|
|
278
|
+
], ["doc/specs", "docs/specs"]);
|
|
264
279
|
assert.deepEqual([reset.gauntletEntered, reset.lastSpecWrite], [false, undefined]);
|
|
265
280
|
const resumed = replayBranch([
|
|
266
281
|
{ type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
|
|
267
282
|
{ type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "skip", phases: { ...P({ brainstorm: "skipped" }), brainstorm: { status: "skipped", reason: "resume: /repo/doc/specs/a.md" } } } } },
|
|
268
|
-
]);
|
|
283
|
+
], ["doc/specs", "docs/specs"]);
|
|
269
284
|
assert.deepEqual([resumed.gauntletEntered, resumed.lastSpecWrite], [true, "/repo/doc/specs/a.md"]);
|
|
270
285
|
});
|
|
271
286
|
|
|
287
|
+
test("replayBranch honors a custom spec dir list", () => {
|
|
288
|
+
const branch = [
|
|
289
|
+
{ type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { action: "start", phases: P({ brainstorm: "in_progress" }) } } },
|
|
290
|
+
{ type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "w1", name: "write", arguments: { path: "design/specs/a.md" } }] } },
|
|
291
|
+
{ type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w1", isError: false } },
|
|
292
|
+
];
|
|
293
|
+
assert.equal(replayBranch(branch, ["design/specs"]).lastSpecWrite, "design/specs/a.md");
|
|
294
|
+
assert.equal(replayBranch(branch, ["doc/specs"]).lastSpecWrite, undefined);
|
|
295
|
+
});
|
|
296
|
+
|
|
272
297
|
test("session_start replay binds from plan_check details with no spec write, extends sessions, preserves run_id", async () => {
|
|
273
298
|
const first = harness();
|
|
274
299
|
await first.phaseResult("start", P({ brainstorm: "in_progress" }));
|
package/extensions/telemetry.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import * as piRuntime from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import { DEFAULT_TEST_COMMANDS, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
|
|
17
|
+
import { DEFAULT_TEST_COMMANDS, planDirsFor, resolveFlowGuards, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
|
|
18
18
|
import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
|
|
19
19
|
import { checkoutOf } from "./lib/checkout.ts";
|
|
20
20
|
import { CONTEXT_DRAFT_MARKER, nextGauntletEntered } from "./lib/phase-tracker-helpers.ts";
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
}
|
|
44
44
|
export interface SettingsSnapshot {
|
|
45
45
|
telemetry: TelemetryResolved;
|
|
46
|
+
specDirs: string[];
|
|
47
|
+
planDirs: string[];
|
|
46
48
|
errors: string[];
|
|
47
49
|
agentOverrides?: Record<string, unknown>;
|
|
48
50
|
versions: Record<string, string>;
|
|
@@ -118,6 +120,8 @@
|
|
|
118
120
|
function realSettings(cwd: string): SettingsSnapshot {
|
|
119
121
|
const { gauntlet, errors, root } = loadGauntletSettings(cwd);
|
|
120
122
|
const telemetry = resolveTelemetry(gauntlet);
|
|
123
|
+
const { specDirs } = resolveFlowGuards(gauntlet);
|
|
124
|
+
const planDirs = planDirsFor(specDirs);
|
|
121
125
|
const sm = SettingsManager.create(root, getAgentDir());
|
|
122
126
|
const layer = (s: unknown) => ((s as { subagents?: { agentOverrides?: unknown } })?.subagents?.agentOverrides ?? undefined) as Record<string, unknown> | undefined;
|
|
123
127
|
const preset = layer(sm.getGlobalSettings());
|
|
@@ -125,7 +129,7 @@
|
|
|
125
129
|
const agentOverrides = preset || repo ? { ...(preset ?? {}), ...(repo ?? {}) } : undefined;
|
|
126
130
|
const rawCmds = gauntlet.verifyBeforeShip?.testCommands;
|
|
127
131
|
const testCommands = Array.isArray(rawCmds) && rawCmds.length > 0 && rawCmds.every((c) => typeof c === "string" && c.trim()) ? (rawCmds as string[]) : undefined;
|
|
128
|
-
return { telemetry, errors, agentOverrides, versions: realVersions(), testCommands };
|
|
132
|
+
return { telemetry, specDirs, planDirs, errors, agentOverrides, versions: realVersions(), testCommands };
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
export const realDeps: Deps = { fs: realFs, git: realGit, jj: realJj, now: isoNow, settings: realSettings };
|
|
@@ -142,16 +146,16 @@
|
|
|
142
146
|
// gauntlet-resume's reconstruction skips brainstorm with `resume: <spec path>`; the path
|
|
143
147
|
// is the bind candidate on that route because no spec write follows.
|
|
144
148
|
const RESUME_REASON_RE = /^resume: (\S+)$/;
|
|
145
|
-
export function resumeSpecOf(phases: PhaseMap | undefined): string | undefined {
|
|
149
|
+
export function resumeSpecOf(phases: PhaseMap | undefined, specDirs: readonly string[]): string | undefined {
|
|
146
150
|
const b = phases?.brainstorm;
|
|
147
151
|
const m = b?.status === "skipped" && typeof b.reason === "string" ? RESUME_REASON_RE.exec(b.reason) : null;
|
|
148
|
-
return m && isSpecPath(toPosix(m[1])) ? m[1] : undefined;
|
|
152
|
+
return m && isSpecPath(toPosix(m[1]), specDirs) ? m[1] : undefined;
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
// Walks the session branch like phase-tracker.ts: successful phase_tracker results set
|
|
152
156
|
// the phase map and the flow-entry marker; bind candidates are taken only while armed
|
|
153
157
|
// and dropped on reset. Silent: no events are emitted.
|
|
154
|
-
export function replayBranch(entries: Iterable<unknown
|
|
158
|
+
export function replayBranch(entries: Iterable<unknown>, specDirs: readonly string[]): ReplayResult {
|
|
155
159
|
const out: ReplayResult = { phases: emptyPhases(), gauntletEntered: false };
|
|
156
160
|
const calls = new Map<string, { name: string; args: unknown }>();
|
|
157
161
|
for (const raw of entries) {
|
|
@@ -173,14 +177,14 @@
|
|
|
173
177
|
out.planCheckSpec = undefined;
|
|
174
178
|
out.lastSpecWrite = undefined;
|
|
175
179
|
}
|
|
176
|
-
const resumed = d.action === "skip" ? resumeSpecOf(d.phases) : undefined;
|
|
180
|
+
const resumed = d.action === "skip" ? resumeSpecOf(d.phases, specDirs) : undefined;
|
|
177
181
|
if (out.gauntletEntered && resumed) out.lastSpecWrite = resumed;
|
|
178
182
|
} else if (msg.toolName === "plan_check") {
|
|
179
183
|
const d = msg.details as { status?: string; specPath?: string } | undefined;
|
|
180
184
|
if (out.gauntletEntered && d?.status === "pass" && typeof d.specPath === "string") out.planCheckSpec = d.specPath;
|
|
181
185
|
} else if ((msg.toolName === "write" || msg.toolName === "edit") && !msg.isError && msg.toolCallId) {
|
|
182
186
|
const p = (calls.get(msg.toolCallId)?.args as { path?: unknown } | undefined)?.path;
|
|
183
|
-
if (out.gauntletEntered && typeof p === "string" && isSpecPath(toPosix(p))) out.lastSpecWrite = p;
|
|
187
|
+
if (out.gauntletEntered && typeof p === "string" && isSpecPath(toPosix(p), specDirs)) out.lastSpecWrite = p;
|
|
184
188
|
}
|
|
185
189
|
}
|
|
186
190
|
return out;
|
|
@@ -328,7 +332,7 @@
|
|
|
328
332
|
predecessorLinks.clear();
|
|
329
333
|
record = loadOrCreate(specRel);
|
|
330
334
|
for (const link of record.supersedes ?? []) {
|
|
331
|
-
if (!isSafeSpecLink(link)) continue;
|
|
335
|
+
if (!isSafeSpecLink(link, snap.specDirs)) continue;
|
|
332
336
|
const predecessor = deps.fs.readFile(abs(link));
|
|
333
337
|
if (predecessor !== undefined && isSupersededByBanner(predecessor, record.spec)) predecessorLinks.add(link);
|
|
334
338
|
}
|
|
@@ -369,9 +373,9 @@
|
|
|
369
373
|
councilPending = undefined;
|
|
370
374
|
};
|
|
371
375
|
|
|
372
|
-
const isSafeSpecLink = (link: string): boolean => {
|
|
376
|
+
const isSafeSpecLink = (link: string, specDirs: readonly string[]): boolean => {
|
|
373
377
|
const normalized = posix.normalize(link);
|
|
374
|
-
return isSpecPath(link) && !posix.isAbsolute(link) && normalized !== ".." && !normalized.startsWith("../");
|
|
378
|
+
return isSpecPath(link, specDirs) && !posix.isAbsolute(link) && normalized !== ".." && !normalized.startsWith("../");
|
|
375
379
|
};
|
|
376
380
|
|
|
377
381
|
const refreshLinks = () => {
|
|
@@ -449,9 +453,9 @@
|
|
|
449
453
|
}
|
|
450
454
|
phases = details.phases;
|
|
451
455
|
if (action === "skip" && !boundSpec && canBind() && lastCtx) {
|
|
452
|
-
const resumed = resumeSpecOf(details.phases);
|
|
456
|
+
const resumed = resumeSpecOf(details.phases, snap.specDirs);
|
|
453
457
|
const loc = resumed ? await locate(lastCtx, resumed) : undefined;
|
|
454
|
-
if (loc && isSpecPath(loc.rel)) {
|
|
458
|
+
if (loc && isSpecPath(loc.rel, snap.specDirs)) {
|
|
455
459
|
await bind(loc.rel, snap, loc.toplevel, loc.via);
|
|
456
460
|
flush();
|
|
457
461
|
}
|
|
@@ -481,13 +485,13 @@
|
|
|
481
485
|
pendingTest.clear();
|
|
482
486
|
sessionId = ctx.sessionManager.getSessionId();
|
|
483
487
|
currentDir = snap.telemetry.dir;
|
|
484
|
-
const replay = replayBranch(ctx.sessionManager.getBranch());
|
|
488
|
+
const replay = replayBranch(ctx.sessionManager.getBranch(), snap.specDirs);
|
|
485
489
|
phases = replay.phases;
|
|
486
490
|
gauntletEntered = replay.gauntletEntered;
|
|
487
491
|
if (!canBind()) return;
|
|
488
492
|
const candidate = replay.planCheckSpec ?? replay.lastSpecWrite;
|
|
489
493
|
const loc = candidate ? await locate(ctx, candidate) : undefined;
|
|
490
|
-
if (loc && isSpecPath(loc.rel)) {
|
|
494
|
+
if (loc && isSpecPath(loc.rel, snap.specDirs)) {
|
|
491
495
|
await bind(loc.rel, snap, loc.toplevel, loc.via);
|
|
492
496
|
flush();
|
|
493
497
|
}
|
|
@@ -537,7 +541,7 @@
|
|
|
537
541
|
const rawPath = (event.input as { path?: unknown }).path;
|
|
538
542
|
const loc = typeof rawPath === "string" ? await locate(ctx, rawPath) : undefined;
|
|
539
543
|
if (!loc) {
|
|
540
|
-
if (typeof rawPath === "string" && isSpecPath(toPosix(rawPath))) warn(`spec ${rawPath} is outside a git checkout; telemetry not recorded`);
|
|
544
|
+
if (typeof rawPath === "string" && isSpecPath(toPosix(rawPath), snap.specDirs)) warn(`spec ${rawPath} is outside a git checkout; telemetry not recorded`);
|
|
541
545
|
return undefined;
|
|
542
546
|
}
|
|
543
547
|
return onSpecInteraction(event.toolName, loc, event, snap);
|
|
@@ -568,13 +572,13 @@
|
|
|
568
572
|
const onSpecInteraction = async (tool: "write" | "edit" | "read", loc: { toplevel: string; rel: string; via: "git" | "jj" }, event: { input: unknown; content: unknown[] }, snap: SettingsSnapshot): Promise<unknown> => {
|
|
569
573
|
const rel = loc.rel;
|
|
570
574
|
if (!boundSpec) {
|
|
571
|
-
if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
|
|
575
|
+
if ((tool === "write" || tool === "edit") && isSpecPath(rel, snap.specDirs)) {
|
|
572
576
|
await bind(rel, snap, loc.toplevel, loc.via);
|
|
573
577
|
if (!boundSpec) return undefined;
|
|
574
578
|
const patch = onBoundSpecWrite(tool, rel, event);
|
|
575
579
|
flush();
|
|
576
580
|
return patch;
|
|
577
|
-
} else if (isPlanPath(rel)) {
|
|
581
|
+
} else if (isPlanPath(rel, snap.planDirs)) {
|
|
578
582
|
const spec = planSpecHeader(deps.fs.readFile(join(loc.toplevel, rel)) ?? "");
|
|
579
583
|
const specRel = spec ? repoRelativeToolPath(loc.toplevel, loc.toplevel, spec) : undefined;
|
|
580
584
|
if (specRel && deps.fs.exists(join(loc.toplevel, specRel))) {
|
|
@@ -586,7 +590,7 @@
|
|
|
586
590
|
}
|
|
587
591
|
if (loc.toplevel !== toplevel) {
|
|
588
592
|
// Another checkout: a fresh run, never a rename of this record.
|
|
589
|
-
if ((tool === "write" || tool === "edit") && isSpecPath(rel) && canBind() && !sealedOnDisk(loc.toplevel, rel, snap.telemetry.dir)) {
|
|
593
|
+
if ((tool === "write" || tool === "edit") && isSpecPath(rel, snap.specDirs) && canBind() && !sealedOnDisk(loc.toplevel, rel, snap.telemetry.dir)) {
|
|
590
594
|
unbind();
|
|
591
595
|
await bind(rel, snap, loc.toplevel, loc.via);
|
|
592
596
|
if (!boundSpec) return undefined;
|
|
@@ -602,13 +606,13 @@
|
|
|
602
606
|
flush();
|
|
603
607
|
return patch;
|
|
604
608
|
}
|
|
605
|
-
if (tool === "write" && isSpecPath(rel) && phases.brainstorm.status === "in_progress" && isDraftOrMissing(boundSpec)) {
|
|
609
|
+
if (tool === "write" && isSpecPath(rel, snap.specDirs) && phases.brainstorm.status === "in_progress" && isDraftOrMissing(boundSpec)) {
|
|
606
610
|
if (await rebind(rel)) onBoundSpecWrite(tool, rel, event);
|
|
607
611
|
flush();
|
|
608
612
|
return undefined;
|
|
609
613
|
}
|
|
610
|
-
if (tool === "edit" && isSpecPath(rel)) {
|
|
611
|
-
onOtherSpecEdit(rel, event);
|
|
614
|
+
if (tool === "edit" && isSpecPath(rel, snap.specDirs)) {
|
|
615
|
+
onOtherSpecEdit(rel, event, snap.specDirs);
|
|
612
616
|
flush();
|
|
613
617
|
}
|
|
614
618
|
return undefined;
|
|
@@ -616,7 +620,7 @@
|
|
|
616
620
|
|
|
617
621
|
// Defined in block 2.
|
|
618
622
|
let onBoundSpecWrite: (tool: "write" | "edit", rel: string, event: { input: unknown; content: unknown[] }) => unknown;
|
|
619
|
-
let onOtherSpecEdit: (rel: string, event: { input: unknown }) => void;
|
|
623
|
+
let onOtherSpecEdit: (rel: string, event: { input: unknown }, specDirs: string[]) => void;
|
|
620
624
|
|
|
621
625
|
let amendmentOpen = false;
|
|
622
626
|
let lastPlanTasks: { status: string }[] | undefined;
|
|
@@ -728,7 +732,7 @@
|
|
|
728
732
|
if (!record || !toplevel || liveShipEvent(record.events)) return;
|
|
729
733
|
record.status = "shipped";
|
|
730
734
|
record.shipped_at = deps.now();
|
|
731
|
-
const out = await computeJjDiff({ jj: deps.jj ?? realJj, cwd: toplevel, spec: record.spec, dir: currentDir, buckets: snap.telemetry.buckets });
|
|
735
|
+
const out = await computeJjDiff({ jj: deps.jj ?? realJj, cwd: toplevel, spec: record.spec, dir: currentDir, buckets: snap.telemetry.buckets, planDirs: snap.planDirs });
|
|
732
736
|
if (out.warning) warn(out.warning);
|
|
733
737
|
record.derived.modified_files = out.modified_files;
|
|
734
738
|
record.derived.diff = out.diff;
|
|
@@ -743,7 +747,7 @@
|
|
|
743
747
|
const rawPath = (event.input as { path?: unknown }).path;
|
|
744
748
|
if (typeof rawPath === "string" && phases.brainstorm.status === "in_progress") {
|
|
745
749
|
const loc = await locate(ctx, rawPath);
|
|
746
|
-
if (loc && isSpecPath(loc.rel)) {
|
|
750
|
+
if (loc && isSpecPath(loc.rel, snap.specDirs)) {
|
|
747
751
|
const recRel = recordPathFor(snap.telemetry.dir, loc.rel);
|
|
748
752
|
const existing = deps.fs.readFile(join(loc.toplevel, recRel));
|
|
749
753
|
const parsed = existing ? parseRecord(existing) : undefined;
|
|
@@ -832,8 +836,8 @@
|
|
|
832
836
|
return undefined;
|
|
833
837
|
};
|
|
834
838
|
|
|
835
|
-
onOtherSpecEdit = (rel, event) => {
|
|
836
|
-
if (!record || !boundSpec || !isSafeSpecLink(rel)) return;
|
|
839
|
+
onOtherSpecEdit = (rel, event, specDirs) => {
|
|
840
|
+
if (!record || !boundSpec || !isSafeSpecLink(rel, specDirs)) return;
|
|
837
841
|
if (!isSupersededByBanner(insertedText(event.input), boundSpec)) return;
|
|
838
842
|
predecessorLinks.add(rel);
|
|
839
843
|
refreshLinks();
|
package/package.json
CHANGED
|
@@ -17,9 +17,9 @@ Identify the target project → set up an isolated worktree → understand curre
|
|
|
17
17
|
|
|
18
18
|
Do **not** implement until the design is presented and approved, regardless of simplicity. Implementation-heavy requests define spec scope; they do not lift this gate.
|
|
19
19
|
|
|
20
|
-
You **may** read code/docs, run the existing system to observe current behavior, write under `
|
|
20
|
+
You **may** read code/docs, run the existing system to observe current behavior, write under a resolved spec dir (`gauntlet_setting({ key: "flowGuards" })` -> `specDirs`), and `edit` a predecessor spec there to add a [supersession banner](reference/superseding.md).
|
|
21
21
|
|
|
22
|
-
You may **not** write outside
|
|
22
|
+
You may **not** write outside the resolved spec dirs; build, deploy, validate, or exercise the proposed change; run implementation skills; commit the spec on `main`; or start `/skill:writing-plans`.
|
|
23
23
|
|
|
24
24
|
The line: current-system observation is research; exercising the proposed change waits for approval.
|
|
25
25
|
|
|
@@ -63,7 +63,7 @@ Detection order: (1) infer from ticket labels, description, or paths; (2) use th
|
|
|
63
63
|
The spec is the first commit in a dedicated worktree, never a separate commit on `main`.
|
|
64
64
|
|
|
65
65
|
1. Invoke `/skill:using-git-worktrees`; use `.worktrees/` or the project-native location.
|
|
66
|
-
2. Carry its `Worktree ready at <full-path>` value: spec path `<full-path
|
|
66
|
+
2. Carry its `Worktree ready at <full-path>` value: spec path `<full-path>/<spec dir>/<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
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.
|
|
@@ -146,7 +146,7 @@ Shared schemas, contracts, and invariants require a spec. If work is mechanical
|
|
|
146
146
|
|
|
147
147
|
## Filename Convention
|
|
148
148
|
|
|
149
|
-
Write under the routed `doc/specs
|
|
149
|
+
Write under the routed spec dir: pick the routing prose's dir (overrides file, `AGENTS.md`) when it is one of the resolved dirs, else the one resolved dir from `gauntlet_setting({ key: "flowGuards" })` that already exists in the repo, else the first resolved dir (`doc/specs` under the default). Name ticketed files `YYYY-MM-DD-<ticket-id>-<topic>.md`, otherwise `YYYY-MM-DD-<topic>.md`. Use a filename-safe tracker slug (`E-12345`, `gh-123`), but native references in plan headers and commits. `<topic>` is 3-6 kebab-case words without `-design` or another suffix.
|
|
150
150
|
|
|
151
151
|
Mint the slug once during gather and reuse it. If questionary invalidates it, write to the new path and delete the uncommitted draft.
|
|
152
152
|
|
|
@@ -293,7 +293,7 @@ One question at a time, YAGNI, 2-3 approaches, two design rounds, clarify freely
|
|
|
293
293
|
|
|
294
294
|
## Red Flags — STOP
|
|
295
295
|
|
|
296
|
-
- Writes outside
|
|
296
|
+
- Writes outside the resolved spec dirs ([owner](#hard-constraint)).
|
|
297
297
|
- Draft overwrite without the same-turn full read, or overwrite via `edit` ([owner](#spec-self-review-before-user-review-gate)).
|
|
298
298
|
- Dispatch while line 1 is the context-draft marker ([owner](#spec-self-review-before-user-review-gate)).
|
|
299
299
|
- Inline scope or ambiguity checks ([owner](#spec-council-optional)).
|
|
@@ -185,11 +185,11 @@ Rows 1-2 run the Option 1/2 blocks; row 3 runs the Keep block and row 4 the Disc
|
|
|
185
185
|
|
|
186
186
|
Run this on the feature branch before any landing path. 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. The record is an untracked, git-excluded file until this step commits it. `<bin>` is `<directory of this skill's SKILL.md>/../../bin`, resolved from the skill's `<location>` in the system prompt.
|
|
187
187
|
|
|
188
|
-
Read the plan's `**Spec:**` header (repo-relative, backticks stripped) before the strip; that value
|
|
188
|
+
Read the plan's `**Spec:**` header (repo-relative, backticks stripped) before the strip; pass that value as `--spec`. Resolve the spec dirs with `gauntlet_setting({ key: "flowGuards" })` and use their sibling `plans` dirs for plan paths.
|
|
189
189
|
|
|
190
190
|
```bash
|
|
191
191
|
# Plans are ephemeral - if one was committed on this branch, remove it before landing.
|
|
192
|
-
PLAN_PATH
|
|
192
|
+
PLAN_PATH=<sibling-plans-dir>/<plan-file>.md # the sibling plans dir of the spec's dir
|
|
193
193
|
if git -C "$WORKTREE" ls-files --error-unmatch "$PLAN_PATH" >/dev/null 2>&1; then
|
|
194
194
|
git -C "$WORKTREE" rm "$PLAN_PATH" && git -C "$WORKTREE" commit -m "Remove ephemeral plan doc"
|
|
195
195
|
fi
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: forge-skill
|
|
3
|
+
description: Use when creating or editing a SKILL.md, a skill's reference file, an agent persona, a prompt template, or a slash command in pi or Claude Code - including one-line edits - or when such a file exceeds 500 lines, gains if/else branching, or drifts from imperative voice; the authoring skill for repos that follow gauntlet's conventions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Forge Skill
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
This skill governs every file a model reads as instructions: `SKILL.md` and its reference files, agent personas, prompt templates, and slash commands, in pi and in Claude Code. Every sentence below is a rule to apply or a fact to check.
|
|
11
|
+
|
|
12
|
+
## Conventions
|
|
13
|
+
|
|
14
|
+
Sources: pi - `docs/skills` in `@earendil-works/pi-coding-agent`; Claude Code - `code.claude.com/docs/en/skills`.
|
|
15
|
+
|
|
16
|
+
| Item | pi | Claude Code |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| Frontmatter | `---` on line 1, `name` and `description`, closing `---` | Same; an unknown field is ignored silently; unparseable YAML loads the skill with no fields |
|
|
19
|
+
| `name` | Lowercase letters, digits, hyphens; no leading, trailing, or doubled hyphen; at most 64 chars; match the directory name (Agent Skills standard; pi does not enforce it) | Display label for a personal or project skill (the command is the directory name); the last command segment for a plugin skill |
|
|
20
|
+
| `description` | At most 1024 chars; a skill without one is not loaded | Recommended; `description` plus `when_to_use` is cut at 1536 chars in the listing |
|
|
21
|
+
| Harness-only fields | `license`, `compatibility`, `metadata`, `allowed-tools` (spec-listed, not enforced) | `when_to_use`, `argument-hint`, `arguments`, `user-invocable`, `allowed-tools` (enforced) |
|
|
22
|
+
| `disable-model-invocation: true` | Command-only skill | Same; also excluded from subagent preload |
|
|
23
|
+
| Invocation | `/skill:name [args]` | `/name [args]`, or `/plugin:name` for a plugin skill |
|
|
24
|
+
| Supporting files | `reference/<topic>.md` (gauntlet convention; pi docs show `references/`), read at the step that names it; dispatch payloads sit beside `SKILL.md` | Any file linked by relative path; `references/`, `scripts/`, `assets/` by convention; `scripts/` files are executed, not loaded |
|
|
25
|
+
| Substitutions | None; the body is read as written | Argument tokens and `CLAUDE_*` variables in the dollar form expand before the model reads the body. In prose, prefix an argument token with one backslash (backslash, dollar, ARGUMENTS) and name a variable bare (`CLAUDE_SKILL_DIR`) |
|
|
26
|
+
| Locations | `.pi/skills/`, `~/.pi/agent/skills/`, `.agents/skills/`, `~/.agents/skills/`, a package's `skills/` dir | `.claude/skills/`, `~/.claude/skills/`, a plugin's `skills/` dir; `.agents/skills/` is not read |
|
|
27
|
+
| Reload after edit | `/reload` | Watched for `.claude/skills/` and `~/.claude/skills/`; a plugin skill needs a new session |
|
|
28
|
+
|
|
29
|
+
## Authoring rules
|
|
30
|
+
|
|
31
|
+
These rules bind the lines an edit adds or changes. Leave other lines alone.
|
|
32
|
+
|
|
33
|
+
- Imperative voice. Write instructions as commands. Never "should", "consider", "you may want to", "it is recommended".
|
|
34
|
+
- Low conditionality. One path per step: branch only on a runtime fact the model can observe - a tool result, a file's presence, a settings value - never on the reader's judgment. Two branches is the ceiling; a third goes into a table or a `reference/` file.
|
|
35
|
+
- Minimal diff. A rule change touches the sentence that owns the rule, not the section. Never restate a rule in a second place; link the owner.
|
|
36
|
+
- Oversized file. A change touching a file over 500 lines extracts the concern it touches - or the largest self-contained `##` section - into `reference/<topic>.md` in the same change, and leaves a one-line "read X now" pointer at the step that needs it.
|
|
37
|
+
- Recipe over prohibition. A shape or quality problem gets a statement of what the output is; a discipline problem gets one explicit rule. Never a list of things to avoid.
|
|
38
|
+
- No nuance clauses. "Unless it matters", "when appropriate", "if needed" reopen the decision; delete them or name the observable condition.
|
|
39
|
+
- Description is a trigger. State when to load the skill (symptoms, file types, situations); never summarize its steps.
|
|
40
|
+
- One example. One concrete example per rule, never three.
|
|
41
|
+
- Skip what the model knows. No explanations of git, shell, or the harness.
|
|
42
|
+
- Never force-load. Cross-reference a skill by its invocation name (pi `/skill:name`, Claude Code `/name` or `/plugin:name`); never with `@`.
|
|
43
|
+
- ASCII punctuation, compact `|---|` table separators, present tense.
|
|
44
|
+
|
|
45
|
+
## Edit procedure
|
|
46
|
+
|
|
47
|
+
1. Read the whole file.
|
|
48
|
+
2. Locate and edit the sentence that owns the rule you are changing (see Minimal diff).
|
|
49
|
+
3. Read the changed lines back against `## Authoring rules`.
|
|
50
|
+
4. Run `wc -l` on the file; over 500 lines, extract per the oversized-file rule.
|
|
51
|
+
5. pi: run `/reload`. Claude Code: start a new session.
|
|
52
|
+
|
|
53
|
+
## Test (optional)
|
|
54
|
+
|
|
55
|
+
Run after adding a rule or rewording a discipline rule.
|
|
56
|
+
|
|
57
|
+
1. Write one scenario prompt the rule governs.
|
|
58
|
+
2. In one turn dispatch two fresh workers: the loaded worker's task is the scenario plus the full `SKILL.md` body; the baseline worker's task is the scenario alone.
|
|
59
|
+
3. Compare the two outputs. Fold every excuse the baseline gives into the rule sentence it evades.
|
|
60
|
+
|
|
61
|
+
| Harness | Dispatch |
|
|
62
|
+
|---|---|
|
|
63
|
+
| pi | `subagent({ agent: "worker", context: "fresh", task })` twice, one task with the body appended |
|
|
64
|
+
| Claude Code | The harness's subagent dispatch tool, twice, same split |
|
|
65
|
+
|
|
66
|
+
## Project overrides
|
|
67
|
+
|
|
68
|
+
If a gauntlet overrides file exists - checked in order: `.pi/gauntlet-overrides.md`, `<repo root>/gauntlet-overrides.md`, `<repo root>/doc/gauntlet-overrides.md`; first found wins - read it. Read and apply `## conventions` whenever present, without a relevance judgment. Give this skill's named section precedence over conflicting `## conventions` rules. Use other relevant sections - by name match, by topic (routing, verification, worktrees, etc.), or by workflow convention - to override or extend the instructions above. Project-local `AGENTS.md` is already in context - check it for project-specific routing tables, service paths, and verification commands.
|
|
@@ -25,10 +25,7 @@ for a base ref before reading any artifact.
|
|
|
25
25
|
|
|
26
26
|
A pinned spec (seed route, `seed.md`) skips this section.
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
else the active pi profile's `settings.json`, else the default `["doc/specs"]` (sibling
|
|
30
|
-
`doc/plans`). An empty array is the default. `<dirs>` below is that resolved list,
|
|
31
|
-
space-separated - never the literal defaults when a setting is present.
|
|
28
|
+
Resolve with `gauntlet_setting({ key: "flowGuards" })`; `<dirs>` below is its `specDirs` plus `planDirs` (each spec dir's sibling `plans` dir), space-separated.
|
|
32
29
|
|
|
33
30
|
```bash
|
|
34
31
|
git -C <worktree> diff --diff-filter=A --name-only <base>..HEAD -- <dirs>
|
|
@@ -17,13 +17,14 @@ DRY. YAGNI. TDD. Frequent commits.
|
|
|
17
17
|
|
|
18
18
|
Before drafting the plan, check `phase_tracker({ action: "status" })`. If `plan` is already `in_progress` (a flow restored by `/skill:gauntlet-resume` arrives this way), do **not** call `start` again - a repeat `start` on the in_progress phase resets its guard ledger. Otherwise call `phase_tracker({ action: "start", phase: "plan" })`.
|
|
19
19
|
|
|
20
|
-
**Input:** an approved spec in
|
|
20
|
+
**Input:** an approved spec in a resolved spec dir from `gauntlet_setting({ key: "flowGuards" })` - produced by `/skill:brainstorming` in this session, or restored from another session by `/skill:gauntlet-resume`. Those two are the only entry points.
|
|
21
21
|
|
|
22
|
-
**Save plans to:** the sibling `
|
|
22
|
+
**Save plans to:** the sibling `plans/` directory next to the spec's resolved dir (`doc/specs` -> `doc/plans`, `docs/specs` -> `docs/plans`). The plan filename matches the spec filename exactly - same date, same ticket ID (if any), same topic slug, no `-design` suffix. Use the absolute plan path under the worktree (`<abs worktree path>/<sibling plans dir>/<filename>.md`, using the path reported by `using-git-worktrees`), and `plan_check({ planPath })` receives that absolute path - the tool roots its checks at the plan's own checkout, not at the session cwd.
|
|
23
23
|
|
|
24
24
|
| Spec path | Plan path |
|
|
25
25
|
|---|---|
|
|
26
26
|
| `doc/specs/2025-05-26-foo.md` | `doc/plans/2025-05-26-foo.md` |
|
|
27
|
+
| `docs/specs/2025-05-26-foo.md` | `docs/plans/2025-05-26-foo.md` |
|
|
27
28
|
| `doc/specs/2025-05-26-PROJ-1234-foo.md` | `doc/plans/2025-05-26-PROJ-1234-foo.md` |
|
|
28
29
|
| `<service>/doc/specs/<name>.md` | `<service>/doc/plans/<name>.md` |
|
|
29
30
|
|
|
@@ -32,7 +33,7 @@ If no spec exists, send the work back to `/skill:brainstorming`. Do not invent a
|
|
|
32
33
|
## Boundaries
|
|
33
34
|
|
|
34
35
|
- Read code and docs: yes
|
|
35
|
-
- Write the plan to the sibling `
|
|
36
|
+
- Write the plan to the sibling `plans/` dir of the spec: yes
|
|
36
37
|
- Edit or create any other files: no
|
|
37
38
|
- Write implementation code: never inside this skill. After Self-Review + `phase_tracker` complete, auto-invoke `/skill:subagent-driven-development` to execute.
|
|
38
39
|
- Land the plan on `main`: no — the plan commit goes on the worktree branch (same branch as the spec)
|