pi-gauntlet 6.0.0 → 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 +6 -0
- package/README.md +1 -1
- 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/gauntlet-resume/reference/reconstruction.md +1 -4
- package/skills/writing-plans/SKILL.md +4 -3
|
@@ -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
|
|
@@ -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)
|