pi-gauntlet 5.10.2 → 5.11.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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## v5.11.0 - 2026-09-18
4
+
5
+ - `checkoutOf` falls back to `jj root` when `git rev-parse` fails, so `plan_check`, Guard 2, and telemetry resolve the checkout inside a plain (non-colocated) jj workspace; colocated jj repos still resolve through git first. The settings loader goes through the same resolution via a new sync `checkoutOfSync`, and telemetry records bound to a jj workspace carry a one-time `record written, not committed: not a git checkout` warning instead of a failed `git commit` per checkpoint. (#38)
6
+
3
7
  ## v5.10.2 - 2026-09-18
4
8
 
5
9
  - The telemetry record is a deliverable: brainstorming, `finishing-a-development-branch`, and `gatekeep-pr` name it beside the spec, and a new shipped `gauntlet-telemetry-salvage` bin restores a record that a plan strip or a fix commit deleted (from the deleting commit's parent, as one `telemetry: ` commit, exit 0 for valid invocations, never pushes). Finishing strips the plan on the feature branch before the Option 1 squash; gatekeep-pr checks at assessment, restores after each fix wave, and restores as the first step of a merge course. CI pins the bin, its test, its tarball imports, and the rule's presence in the three skills.
package/README.md CHANGED
@@ -75,7 +75,7 @@ pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
75
75
 
76
76
  pi-gauntlet is **opinionated**: every non-trivial change is *meant* to ride this one pipeline, entered through `brainstorming`. Enforcement is opt-in by entry, not ambient: once brainstorming starts a flow, the phase-tracker extension mechanically blocks a phase from closing before its gate runs, and warns once if the main loop writes code during implement (subagents own implement-phase edits). A change made *without* entering the flow (a typo, a formatting run, a dependency bump - see "When to use / when NOT to use") is not gated; the discipline of routing real work through the pipeline is a convention the tooling supports, not a trap it springs on every edit.
77
77
 
78
- **Worktree contract.** pi runs in the primary checkout; the work happens in `.worktrees/<branch>`. Skills never `cd` there - the worktree path is a value from the `using-git-worktrees` report (or the handoff brief on `gauntlet-resume`), carried as dispatch `cwd: "<path>"`, as `git -C <path>`, or as `(cd "<path>" && <cmd>)` for other cwd-bound commands; the runtime extensions derive the checkout from the artifact path they act on. Requires git >= 2.31.
78
+ **Worktree contract.** pi runs in the primary checkout; the work happens in `.worktrees/<branch>`. Skills never `cd` there - the worktree path is a value from the `using-git-worktrees` report (or the handoff brief on `gauntlet-resume`), carried as dispatch `cwd: "<path>"`, as `git -C <path>`, or as `(cd "<path>" && <cmd>)` for other cwd-bound commands; the runtime extensions derive the checkout from the artifact path they act on. Requires git >= 2.31; a plain (non-colocated) jj workspace has no `.git`, so the resolver falls back to `jj root` there, reading primary-vs-workspace from `.jj/repo` (a directory in the primary checkout, a pointer file in added workspaces).
79
79
 
80
80
  ## Key concepts
81
81
 
@@ -3,7 +3,7 @@ import { after, test } from "node:test";
3
3
  import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { checkoutOf, parseCheckout, gitSync, type GitResult } from "./checkout.ts";
6
+ import { checkoutOf, checkoutOfSync, parseCheckout, gitSync, jjSync, type GitResult } from "./checkout.ts";
7
7
 
8
8
  const tempDirs: string[] = [];
9
9
  after(() => { for (const d of tempDirs) rmSync(d, { recursive: true, force: true }); });
@@ -32,7 +32,7 @@ test("checkoutOf: a file path runs git in its dirname", async () => {
32
32
  writeFileSync(join(dir, "spec.md"), "# s\n");
33
33
  const cwds: string[] = [];
34
34
  const git = (_args: string[], cwd: string): GitResult => { cwds.push(cwd); return out([dir, dir + "/.git", dir + "/.git"]); };
35
- assert.deepEqual(await checkoutOf(join(dir, "spec.md"), git), { toplevel: dir, isPrimary: true });
35
+ assert.deepEqual(await checkoutOf(join(dir, "spec.md"), git), { toplevel: dir, isPrimary: true, via: "git" });
36
36
  assert.deepEqual(cwds, [dir]);
37
37
  });
38
38
 
@@ -57,3 +57,66 @@ test("gitSync: never throws; nonzero code outside a repo", () => {
57
57
  assert.notEqual(r.code, 0);
58
58
  assert.equal(typeof r.stdout, "string");
59
59
  });
60
+
61
+ test("checkoutOf: falls back to `jj root` when git fails, added workspace is not primary", async () => {
62
+ const dir = tmp();
63
+ mkdirSync(join(dir, ".jj"));
64
+ writeFileSync(join(dir, ".jj", "repo"), "/main/repo/.jj/repo\n"); // pointer file in added workspaces
65
+ const git = (): GitResult => ({ code: 128, stdout: "" });
66
+ const jj = (args: string[], cwd: string): GitResult => {
67
+ assert.deepEqual(args, ["root"]);
68
+ assert.equal(cwd, dir);
69
+ return { code: 0, stdout: dir + "\n" };
70
+ };
71
+ assert.deepEqual(await checkoutOf(join(dir, "doc", "plan.md"), git, jj), { toplevel: dir, isPrimary: false, via: "jj" });
72
+ });
73
+
74
+ test("checkoutOf: jj primary checkout keeps .jj/repo as a directory", async () => {
75
+ const dir = tmp();
76
+ mkdirSync(join(dir, ".jj", "repo"), { recursive: true });
77
+ const git = (): GitResult => ({ code: 128, stdout: "" });
78
+ const jj = (): GitResult => ({ code: 0, stdout: dir + "\n" });
79
+ assert.deepEqual(await checkoutOf(dir, git, jj), { toplevel: dir, isPrimary: true, via: "jj" });
80
+ });
81
+
82
+ test("checkoutOf: git result wins when it resolves, jj is not consulted", async () => {
83
+ const dir = tmp();
84
+ let jjCalled = false;
85
+ const git = (): GitResult => out([dir, dir + "/.git", dir + "/.git"]);
86
+ const jj = (): GitResult => { jjCalled = true; return { code: 0, stdout: "/elsewhere\n" }; };
87
+ assert.deepEqual(await checkoutOf(dir, git, jj), { toplevel: dir, isPrimary: true, via: "git" });
88
+ assert.equal(jjCalled, false);
89
+ });
90
+
91
+ test("checkoutOf: undefined when neither git nor jj resolves", async () => {
92
+ const fail = (): GitResult => ({ code: 1, stdout: "" });
93
+ assert.equal(await checkoutOf(join(tmp(), "x.md"), fail, fail), undefined);
94
+ });
95
+
96
+ test("jjSync: never throws; nonzero code outside a jj repo", () => {
97
+ const r = jjSync(["root"], tmp());
98
+ assert.notEqual(r.code, 0);
99
+ assert.equal(typeof r.stdout, "string");
100
+ });
101
+
102
+ test("checkoutOfSync: git wins without consulting jj", () => {
103
+ const dir = tmp();
104
+ let jjCalled = false;
105
+ const git = (): GitResult => out([dir, dir + "/.git", dir + "/.git"]);
106
+ const jj = (): GitResult => { jjCalled = true; return { code: 0, stdout: "/elsewhere\n" }; };
107
+ assert.deepEqual(checkoutOfSync(dir, git, jj), { toplevel: dir, isPrimary: true, via: "git" });
108
+ assert.equal(jjCalled, false);
109
+ });
110
+
111
+ test("checkoutOfSync: falls back to `jj root` when git fails", () => {
112
+ const dir = tmp();
113
+ mkdirSync(join(dir, ".jj", "repo"), { recursive: true });
114
+ const git = (): GitResult => ({ code: 128, stdout: "" });
115
+ const jj = (): GitResult => ({ code: 0, stdout: dir + "\n" });
116
+ assert.deepEqual(checkoutOfSync(join(dir, "doc", "specs"), git, jj), { toplevel: dir, isPrimary: true, via: "jj" });
117
+ });
118
+
119
+ test("checkoutOfSync: undefined when neither git nor jj resolves", () => {
120
+ const fail = (): GitResult => ({ code: 1, stdout: "" });
121
+ assert.equal(checkoutOfSync(join(tmp(), "x.md"), fail, fail), undefined);
122
+ });
@@ -1,17 +1,23 @@
1
1
  // One checkout resolver for every extension that needs "which git checkout owns this
2
2
  // path" (#37): plan_check, the settings loader, Guard 2, telemetry. `--path-format=absolute`
3
3
  // (git >= 2.31) makes git-dir and common-dir comparable as strings from any subdirectory.
4
+ // A plain (non-colocated) jj checkout has no .git for rev-parse, so when git fails the
5
+ // resolver falls back to `jj root`; colocated jj repos still resolve through git first.
6
+ // checkoutOfSync is the same resolution for sync callers (the settings loader).
4
7
  import { execFileSync } from "node:child_process";
5
8
  import { existsSync, statSync } from "node:fs";
6
- import { dirname } from "node:path";
9
+ import { dirname, join } from "node:path";
7
10
 
8
11
  export type GitResult = { code: number; stdout: string };
9
- export type Checkout = { toplevel: string; isPrimary: boolean };
12
+ // `via` records which binary resolved the checkout, so callers with git-only follow-ups
13
+ // (telemetry's checkpoint commit) can skip them in a plain jj workspace.
14
+ export type Checkout = { toplevel: string; isPrimary: boolean; via: "git" | "jj" };
15
+ type ResolvedCheckout = { toplevel: string; isPrimary: boolean };
10
16
 
11
17
  export const CHECKOUT_ARGS = ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"];
12
18
 
13
19
  // isPrimary = gitDir === commonDir (both absolute thanks to --path-format).
14
- export function parseCheckout(r: GitResult): Checkout | undefined {
20
+ export function parseCheckout(r: GitResult): ResolvedCheckout | undefined {
15
21
  if (r.code !== 0) return undefined;
16
22
  const lines = r.stdout.trim().split("\n").map((l) => l.trim());
17
23
  if (lines.length < 3 || !lines[0]) return undefined;
@@ -38,20 +44,54 @@ export function nearestExistingDir(absPath: string): string {
38
44
  return p;
39
45
  }
40
46
 
47
+ export const JJ_ROOT_ARGS = ["root"];
48
+
49
+ // Added jj workspaces keep .jj/repo as a pointer file into the main repo; the primary
50
+ // checkout keeps it as the real directory.
51
+ function parseJjRoot(r: GitResult): ResolvedCheckout | undefined {
52
+ if (r.code !== 0) return undefined;
53
+ const toplevel = r.stdout.trim();
54
+ if (!toplevel) return undefined;
55
+ return { toplevel, isPrimary: isDir(join(toplevel, ".jj", "repo")) };
56
+ }
57
+
41
58
  export async function checkoutOf(
42
59
  absPath: string,
43
60
  git: (args: string[], cwd: string) => GitResult | Promise<GitResult>,
61
+ jj: (args: string[], cwd: string) => GitResult | Promise<GitResult> = jjSync,
44
62
  ): Promise<Checkout | undefined> {
45
- return parseCheckout(await git(CHECKOUT_ARGS, nearestExistingDir(absPath)));
63
+ const cwd = nearestExistingDir(absPath);
64
+ const g = parseCheckout(await git(CHECKOUT_ARGS, cwd));
65
+ if (g) return { ...g, via: "git" };
66
+ const j = parseJjRoot(await jj(JJ_ROOT_ARGS, cwd));
67
+ return j ? { ...j, via: "jj" } : undefined;
46
68
  }
47
69
 
48
- // Never throws: any failure (not a repo, missing dir, old git) is a nonzero code.
49
- export const gitSync = (args: string[], cwd: string): GitResult => {
50
- try {
51
- const stdout = execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
52
- return { code: 0, stdout };
53
- } catch (e) {
54
- const status = (e as { status?: unknown }).status;
55
- return { code: typeof status === "number" ? status : 1, stdout: "" };
56
- }
57
- };
70
+ // Same git-then-jj resolution for sync callers; either side injectable for tests.
71
+ export function checkoutOfSync(
72
+ absPath: string,
73
+ git: (args: string[], cwd: string) => GitResult = gitSync,
74
+ jj: (args: string[], cwd: string) => GitResult = jjSync,
75
+ ): Checkout | undefined {
76
+ const cwd = nearestExistingDir(absPath);
77
+ const g = parseCheckout(git(CHECKOUT_ARGS, cwd));
78
+ if (g) return { ...g, via: "git" };
79
+ const j = parseJjRoot(jj(JJ_ROOT_ARGS, cwd));
80
+ return j ? { ...j, via: "jj" } : undefined;
81
+ }
82
+
83
+ // Never throws: any failure (not a repo, missing dir, missing binary) is a nonzero code.
84
+ const commandSync =
85
+ (bin: string) =>
86
+ (args: string[], cwd: string): GitResult => {
87
+ try {
88
+ const stdout = execFileSync(bin, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
89
+ return { code: 0, stdout };
90
+ } catch (e) {
91
+ const status = (e as { status?: unknown }).status;
92
+ return { code: typeof status === "number" ? status : 1, stdout: "" };
93
+ }
94
+ };
95
+
96
+ export const gitSync = commandSync("git");
97
+ export const jjSync = commandSync("jj");
@@ -30,3 +30,16 @@ test("outside any checkout the loader falls back to cwd", () => {
30
30
  assert.equal(loaded.root, dir);
31
31
  assert.deepEqual(loaded.gauntlet.flowGuards, { enforce: false });
32
32
  });
33
+
34
+ test("settings resolve through the jj fallback in a plain jj workspace (no .git)", () => {
35
+ const dir = tmp();
36
+ mkdirSync(join(dir, ".jj", "repo"), { recursive: true }); // primary jj checkout marker
37
+ mkdirSync(join(dir, ".pi"));
38
+ mkdirSync(join(dir, "doc"));
39
+ writeFileSync(join(dir, ".pi", "settings.json"), JSON.stringify({ piGauntlet: { specCouncil: { members: ["p/jj"] } } }));
40
+ const jjCheckout = () => ({ toplevel: dir, isPrimary: true, via: "jj" as const });
41
+ const loaded = loadGauntletSettings(join(dir, "doc"), "/tmp/pi-gauntlet-test-agent", jjCheckout);
42
+ assert.equal(loaded.root, dir);
43
+ assert.deepEqual(loaded.gauntlet.specCouncil, { members: ["p/jj"] });
44
+ assert.deepEqual(loaded.errors, []);
45
+ });
@@ -2,7 +2,7 @@
2
2
  // ci.mjs unit tests. Only pi-loaded extensions import this file.
3
3
  import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import { mergeGauntlet, type PiGauntlet } from "./gauntlet-settings.ts";
5
- import { gitSync, parseCheckout } from "./checkout.ts";
5
+ import { checkoutOfSync, type Checkout } from "./checkout.ts";
6
6
 
7
7
  export interface LoadedGauntlet {
8
8
  gauntlet: PiGauntlet;
@@ -12,12 +12,13 @@ export interface LoadedGauntlet {
12
12
  }
13
13
 
14
14
  // Reads the preset (agentDir/settings.json) and repo (<root>/.pi/settings.json) layers
15
- // via pi's own SettingsManager, where <root> is the git toplevel of cwd - pi launched in a
16
- // subdirectory or a linked worktree still finds that checkout's file (#37). SettingsManager
15
+ // via pi's own SettingsManager, where <root> is the checkout toplevel of cwd (git, falling
16
+ // back to `jj root` in a plain jj workspace) - pi launched in a subdirectory or a linked
17
+ // worktree still finds that checkout's file (#37). SettingsManager
17
18
  // never throws on a bad file - it substitutes {} for that layer and records the error,
18
19
  // surfaced here via errors[] so callers can report a degraded read instead of failing silent.
19
- export function loadGauntletSettings(cwd: string, agentDir: string = getAgentDir()): LoadedGauntlet {
20
- const root = parseCheckout(gitSync(["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"], cwd))?.toplevel ?? cwd;
20
+ export function loadGauntletSettings(cwd: string, agentDir: string = getAgentDir(), checkout: (path: string) => Checkout | undefined = checkoutOfSync): LoadedGauntlet {
21
+ const root = checkout(cwd)?.toplevel ?? cwd;
21
22
  const sm = SettingsManager.create(root, agentDir);
22
23
  const preset = sm.getGlobalSettings() as { piGauntlet?: Record<string, unknown> };
23
24
  const repo = sm.getProjectSettings() as { piGauntlet?: Record<string, unknown> };
@@ -20,7 +20,7 @@
20
20
 
21
21
  type Handler = (event: any, ctx: any) => unknown;
22
22
 
23
- function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[], cwd: string) => GitResult | undefined; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
23
+ function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[], cwd: string) => GitResult | undefined; jjWorkspace?: boolean; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
24
24
  const root = mkdtempSync(join(tmpdir(), "telemetry-test-"));
25
25
  tempDirs.push(root);
26
26
  mkdirSync(join(root, "doc/specs"), { recursive: true });
@@ -52,6 +52,8 @@
52
52
  return { code: 0, stdout: "", stderr: "" };
53
53
  },
54
54
  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" } }),
55
+ // A plain jj workspace: `jj root` answers the temp root while git rev-parse fails.
56
+ ...(o.jjWorkspace ? { jj: async () => ({ code: 0, stdout: root + "\n", stderr: "" }) } : {}),
55
57
  };
56
58
  let branch = o.branch ?? [];
57
59
  const ctx = {
@@ -281,6 +283,25 @@
281
283
  assert.deepEqual(nogit.readRecord().events.filter((e) => e.kind === "warning").map((e: any) => e.message), [`spec ${outsideSpec} is outside a git checkout; telemetry not recorded`]);
282
284
  });
283
285
 
286
+ test("jj-bound checkout: record is written, never committed, and the warning fires once", async () => {
287
+ const h = harness({
288
+ jjWorkspace: true,
289
+ gitFail: (args) =>
290
+ args[0] === "rev-parse" && args.includes("--path-format=absolute")
291
+ ? { code: 128, stdout: "", stderr: "fatal: not a git repository" }
292
+ : undefined,
293
+ });
294
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
295
+ await h.writeSpec("doc/specs/a.md");
296
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
297
+ const rec = h.readRecord();
298
+ assert.deepEqual(
299
+ rec.events.filter((e) => e.kind === "warning").map((e: any) => e.message),
300
+ ["record written, not committed: not a git checkout"],
301
+ );
302
+ assert.equal(h.commits().length, 0);
303
+ });
304
+
284
305
  test("worktree spec from a primary cwd: record keyed under the worktree, committed there (AC 8)", async () => {
285
306
  const h = harness();
286
307
  await h.emit("session_start", { type: "session_start", reason: "startup" });
@@ -48,6 +48,8 @@
48
48
  export interface Deps {
49
49
  fs: FsPort;
50
50
  git: (args: string[], cwd: string) => Promise<GitResult>;
51
+ // Optional jj override for tests; production resolves plain jj workspaces via jjSync.
52
+ jj?: (args: string[], cwd: string) => GitResult | Promise<GitResult>;
51
53
  now: () => string;
52
54
  settings: (cwd: string) => SettingsSnapshot;
53
55
  }
@@ -160,6 +162,7 @@
160
162
  export default function (pi: ExtensionAPI, deps: Deps = realDeps) {
161
163
  let phases: PhaseMap = emptyPhases();
162
164
  let toplevel: string | undefined; // toplevel of the bound record's checkout; undefined until bind
165
+ let checkoutVia: "git" | "jj" | undefined; // which binary resolved toplevel; undefined until bind
163
166
  let boundSpec: string | undefined; // repo-relative
164
167
  let record: TelemetryRecord | undefined;
165
168
  const predecessorLinks = new Set<string>();
@@ -170,6 +173,7 @@
170
173
  let lastCommitted = "";
171
174
  let frozen = false;
172
175
  let settingsWarned = false;
176
+ let notGitWarned = false;
173
177
  let sessionId = "";
174
178
 
175
179
  // Per-event settings read; undefined => this event is a no-op.
@@ -182,11 +186,11 @@
182
186
 
183
187
  // Resolves a tool path (relative to ctx.cwd or absolute) to its owning checkout and
184
188
  // checkout-relative key. undefined outside any checkout.
185
- const locate = async (ctx: ExtensionContext, p: string): Promise<{ toplevel: string; rel: string } | undefined> => {
189
+ const locate = async (ctx: ExtensionContext, p: string): Promise<{ toplevel: string; rel: string; via: "git" | "jj" } | undefined> => {
186
190
  const candidateAbs = isAbsolute(p) ? p : resolve(ctx.cwd, p);
187
- const co = await checkoutOf(candidateAbs, deps.git);
191
+ const co = await checkoutOf(candidateAbs, deps.git, deps.jj);
188
192
  const rel = co ? repoRelativeToolPath(co.toplevel, ctx.cwd, p) : undefined;
189
- return co && rel ? { toplevel: co.toplevel, rel } : undefined;
193
+ return co && rel ? { toplevel: co.toplevel, rel, via: co.via } : undefined;
190
194
  };
191
195
 
192
196
  const live = (): Accumulators => record
@@ -230,6 +234,15 @@
230
234
 
231
235
  const commitRecord = async () => {
232
236
  if (!record || !recordRel || frozen || !toplevel) return;
237
+ if (checkoutVia === "jj") {
238
+ // A jj-bound checkout has no .git to commit into; say so once per session instead
239
+ // of failing `git commit` at every checkpoint.
240
+ if (!notGitWarned) {
241
+ notGitWarned = true;
242
+ warn("record written, not committed: not a git checkout");
243
+ }
244
+ return;
245
+ }
233
246
  const text = deps.fs.readFile(abs(recordRel)) ?? "";
234
247
  if (text === lastCommitted) return;
235
248
  const paths = [recordRel, ...(oldRecordRel ? [oldRecordRel] : [])];
@@ -274,8 +287,9 @@
274
287
  return newRecord({ spec: specRel, session: sessionId, now: buffer[0]?.ts ?? deps.now(), runId: randomUUID() });
275
288
  };
276
289
 
277
- const bind = async (specRel: string, snap: SettingsSnapshot, specToplevel: string) => {
290
+ const bind = async (specRel: string, snap: SettingsSnapshot, specToplevel: string, via: "git" | "jj") => {
278
291
  toplevel = specToplevel;
292
+ checkoutVia = via;
279
293
  currentDir = snap.telemetry.dir;
280
294
  boundSpec = specRel;
281
295
  recordRel = recordPathFor(currentDir, specRel);
@@ -314,6 +328,7 @@
314
328
 
315
329
  const unbind = () => {
316
330
  toplevel = undefined;
331
+ checkoutVia = undefined;
317
332
  boundSpec = undefined;
318
333
  record = undefined;
319
334
  recordRel = undefined;
@@ -416,7 +431,7 @@
416
431
  const candidate = replay.planCheckSpec ?? replay.lastSpecWrite;
417
432
  const loc = candidate ? await locate(ctx, candidate) : undefined;
418
433
  if (loc && isSpecPath(loc.rel)) {
419
- await bind(loc.rel, snap, loc.toplevel);
434
+ await bind(loc.rel, snap, loc.toplevel, loc.via);
420
435
  await flush(false);
421
436
  }
422
437
  });
@@ -450,7 +465,7 @@
450
465
  const pass = d?.status === "pass";
451
466
  if (pass && specLoc && (specLoc.rel !== boundSpec || specLoc.toplevel !== toplevel)) {
452
467
  if (record) unbind();
453
- await bind(specLoc.rel, snap, specLoc.toplevel);
468
+ await bind(specLoc.rel, snap, specLoc.toplevel, specLoc.via);
454
469
  }
455
470
  live().gates.plan_rounds += 1;
456
471
  pushEvent({ kind: "plan_check", pass, spec, plan });
@@ -498,11 +513,11 @@
498
513
  });
499
514
 
500
515
  // Binding + spec-write bookkeeping; the bound-spec hooks are defined in block 2.
501
- const onSpecInteraction = async (tool: "write" | "edit" | "read", loc: { toplevel: string; rel: string }, event: { input: unknown; content: unknown[] }, snap: SettingsSnapshot): Promise<unknown> => {
516
+ const onSpecInteraction = async (tool: "write" | "edit" | "read", loc: { toplevel: string; rel: string; via: "git" | "jj" }, event: { input: unknown; content: unknown[] }, snap: SettingsSnapshot): Promise<unknown> => {
502
517
  const rel = loc.rel;
503
518
  if (!boundSpec) {
504
519
  if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
505
- await bind(rel, snap, loc.toplevel);
520
+ await bind(rel, snap, loc.toplevel, loc.via);
506
521
  const patch = onBoundSpecWrite(tool, rel, event);
507
522
  await flush(true);
508
523
  return patch;
@@ -510,7 +525,7 @@
510
525
  const spec = planSpecHeader(deps.fs.readFile(join(loc.toplevel, rel)) ?? "");
511
526
  const specRel = spec ? repoRelativeToolPath(loc.toplevel, loc.toplevel, spec) : undefined;
512
527
  if (specRel && deps.fs.exists(join(loc.toplevel, specRel))) {
513
- await bind(specRel, snap, loc.toplevel);
528
+ await bind(specRel, snap, loc.toplevel, loc.via);
514
529
  await flush(true);
515
530
  }
516
531
  }
@@ -520,7 +535,7 @@
520
535
  // Another checkout: a fresh run, never a rename of this record.
521
536
  if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
522
537
  unbind();
523
- await bind(rel, snap, loc.toplevel);
538
+ await bind(rel, snap, loc.toplevel, loc.via);
524
539
  const patch = onBoundSpecWrite(tool, rel, event);
525
540
  await flush(true);
526
541
  return patch;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "5.10.2",
3
+ "version": "5.11.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",