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 +4 -0
- package/README.md +1 -1
- package/extensions/lib/checkout.test.ts +65 -2
- package/extensions/lib/checkout.ts +54 -14
- package/extensions/lib/gauntlet-settings-loader.test.ts +13 -0
- package/extensions/lib/gauntlet-settings-loader.ts +6 -5
- package/extensions/telemetry.test.ts +22 -1
- package/extensions/telemetry.ts +25 -10
- package/package.json +1 -1
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
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
-
//
|
|
49
|
-
export
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 {
|
|
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
|
|
16
|
-
//
|
|
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 =
|
|
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" });
|
package/extensions/telemetry.ts
CHANGED
|
@@ -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