pi-gauntlet 5.9.0 → 5.9.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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## v5.9.1 - 2026-09-17
4
+
5
+ - Process in primary, work by path: `plan_check` roots at the plan's checkout, settings load from the session cwd's checkout toplevel, the branch-switch guard evaluates the command's `-C`/`cd` target, telemetry commits on the spec's checkout, and ship detection accepts `git -C <worktree> push` / `merge --squash`. Stage skills carry the worktree path as a value (dispatch `cwd`, `git -C`, subshell) - a new CI lint keeps them that way; `finishing-a-development-branch` takes `<worktree-path>` as a mandatory argument. Requires git >= 2.31. ([#37](https://github.com/jjuraszek/pi-gauntlet/issues/37))
6
+
3
7
  ## v5.9.0 - 2026-09-17
4
8
 
5
9
  - Added `gauntlet-spec-index`, a build-on-query FTS5 search over root and one-level service spec corpora with a per-worktree cache and telemetry-enriched tabular results; brainstorming uses it to find predecessor specs. Requires Node >=24.15.0. (#34)
package/README.md CHANGED
@@ -75,6 +75,8 @@ 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.
79
+
78
80
  ## Key concepts
79
81
 
80
82
  | Term | Meaning |
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, test } from "node:test";
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { checkoutOf, parseCheckout, gitSync, type GitResult } from "./checkout.ts";
7
+
8
+ const tempDirs: string[] = [];
9
+ after(() => { for (const d of tempDirs) rmSync(d, { recursive: true, force: true }); });
10
+ const tmp = () => { const d = realpathSync(mkdtempSync(join(tmpdir(), "checkout-test-"))); tempDirs.push(d); return d; };
11
+
12
+ const out = (lines: string[]): GitResult => ({ code: 0, stdout: lines.join("\n") + "\n" });
13
+
14
+ test("parseCheckout: primary checkout when git-dir equals common-dir", () => {
15
+ assert.deepEqual(parseCheckout(out(["/repo", "/repo/.git", "/repo/.git"])), { toplevel: "/repo", isPrimary: true });
16
+ });
17
+
18
+ test("parseCheckout: linked worktree when the dirs differ", () => {
19
+ assert.deepEqual(
20
+ parseCheckout(out(["/repo/.worktrees/x", "/repo/.git/worktrees/x", "/repo/.git"])),
21
+ { toplevel: "/repo/.worktrees/x", isPrimary: false },
22
+ );
23
+ });
24
+
25
+ test("parseCheckout: nonzero code or fewer than three lines -> undefined", () => {
26
+ assert.equal(parseCheckout({ code: 128, stdout: "" }), undefined);
27
+ assert.equal(parseCheckout(out(["/repo", "/repo/.git"])), undefined);
28
+ });
29
+
30
+ test("checkoutOf: a file path runs git in its dirname", async () => {
31
+ const dir = tmp();
32
+ writeFileSync(join(dir, "spec.md"), "# s\n");
33
+ const cwds: string[] = [];
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 });
36
+ assert.deepEqual(cwds, [dir]);
37
+ });
38
+
39
+ test("checkoutOf: a nonexistent leaf walks up to the nearest existing ancestor", async () => {
40
+ const dir = tmp();
41
+ mkdirSync(join(dir, "doc"));
42
+ const cwds: string[] = [];
43
+ const git = async (_args: string[], cwd: string): Promise<GitResult> => { cwds.push(cwd); return out([dir, dir + "/.git", dir + "/.git"]); };
44
+ await checkoutOf(join(dir, "doc", "specs", "new.md"), git);
45
+ assert.deepEqual(cwds, [join(dir, "doc")]);
46
+ });
47
+
48
+ test("checkoutOf: an existing directory is used as-is", async () => {
49
+ const dir = tmp();
50
+ const cwds: string[] = [];
51
+ await checkoutOf(dir, (_a, cwd) => { cwds.push(cwd); return out([dir, dir + "/.git", dir + "/.git"]); });
52
+ assert.deepEqual(cwds, [dir]);
53
+ });
54
+
55
+ test("gitSync: never throws; nonzero code outside a repo", () => {
56
+ const r = gitSync(["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"], tmp());
57
+ assert.notEqual(r.code, 0);
58
+ assert.equal(typeof r.stdout, "string");
59
+ });
@@ -0,0 +1,57 @@
1
+ // One checkout resolver for every extension that needs "which git checkout owns this
2
+ // path" (#37): plan_check, the settings loader, Guard 2, telemetry. `--path-format=absolute`
3
+ // (git >= 2.31) makes git-dir and common-dir comparable as strings from any subdirectory.
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, statSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+
8
+ export type GitResult = { code: number; stdout: string };
9
+ export type Checkout = { toplevel: string; isPrimary: boolean };
10
+
11
+ export const CHECKOUT_ARGS = ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"];
12
+
13
+ // isPrimary = gitDir === commonDir (both absolute thanks to --path-format).
14
+ export function parseCheckout(r: GitResult): Checkout | undefined {
15
+ if (r.code !== 0) return undefined;
16
+ const lines = r.stdout.trim().split("\n").map((l) => l.trim());
17
+ if (lines.length < 3 || !lines[0]) return undefined;
18
+ return { toplevel: lines[0], isPrimary: lines[1] === lines[2] };
19
+ }
20
+
21
+ const isDir = (p: string): boolean => {
22
+ try {
23
+ return statSync(p).isDirectory();
24
+ } catch {
25
+ return false;
26
+ }
27
+ };
28
+
29
+ // Nearest existing directory at or above absPath: the path itself when it is a directory,
30
+ // else its dirname, walking up. Covers the first write into a not-yet-existing doc/specs/.
31
+ export function nearestExistingDir(absPath: string): string {
32
+ let p = absPath;
33
+ while (!(existsSync(p) && isDir(p))) {
34
+ const parent = dirname(p);
35
+ if (parent === p) return p;
36
+ p = parent;
37
+ }
38
+ return p;
39
+ }
40
+
41
+ export async function checkoutOf(
42
+ absPath: string,
43
+ git: (args: string[], cwd: string) => GitResult | Promise<GitResult>,
44
+ ): Promise<Checkout | undefined> {
45
+ return parseCheckout(await git(CHECKOUT_ARGS, nearestExistingDir(absPath)));
46
+ }
47
+
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
+ };
@@ -0,0 +1,32 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, test } from "node:test";
3
+ import { execFileSync } from "node:child_process";
4
+ import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { loadGauntletSettings } from "./gauntlet-settings-loader.ts";
8
+
9
+ const tempDirs: string[] = [];
10
+ after(() => { for (const d of tempDirs) rmSync(d, { recursive: true, force: true }); });
11
+ const tmp = () => { const d = realpathSync(mkdtempSync(join(tmpdir(), "settings-loader-test-"))); tempDirs.push(d); return d; };
12
+ const gitRepo = () => { const d = tmp(); execFileSync("git", ["init", "-q", d]); return d; };
13
+
14
+ test("settings resolve from the checkout root when cwd is a subdirectory (AC 2)", () => {
15
+ const repo = gitRepo();
16
+ mkdirSync(join(repo, ".pi"));
17
+ mkdirSync(join(repo, "doc"));
18
+ writeFileSync(join(repo, ".pi", "settings.json"), JSON.stringify({ piGauntlet: { specCouncil: { members: ["p/m"] } } }));
19
+ const loaded = loadGauntletSettings(join(repo, "doc"), "/tmp/pi-gauntlet-test-agent");
20
+ assert.equal(loaded.root, repo);
21
+ assert.deepEqual(loaded.gauntlet.specCouncil, { members: ["p/m"] });
22
+ assert.deepEqual(loaded.errors, []);
23
+ });
24
+
25
+ test("outside any checkout the loader falls back to cwd", () => {
26
+ const dir = tmp();
27
+ mkdirSync(join(dir, ".pi"));
28
+ writeFileSync(join(dir, ".pi", "settings.json"), JSON.stringify({ piGauntlet: { flowGuards: { enforce: false } } }));
29
+ const loaded = loadGauntletSettings(dir, "/tmp/pi-gauntlet-test-agent");
30
+ assert.equal(loaded.root, dir);
31
+ assert.deepEqual(loaded.gauntlet.flowGuards, { enforce: false });
32
+ });
@@ -2,22 +2,26 @@
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
6
 
6
7
  export interface LoadedGauntlet {
7
8
  gauntlet: PiGauntlet;
8
9
  errors: string[];
10
+ // Checkout toplevel the repo layer was read from (cwd itself outside any checkout).
11
+ root: string;
9
12
  }
10
13
 
11
- // Reads the preset (agentDir/settings.json) and repo (cwd/.pi/settings.json)
12
- // layers via pi's own SettingsManager and returns the whole-object second-level
13
- // merge (repo over preset). SettingsManager never throws on a bad file - it
14
- // substitutes {} for that layer and records the error, surfaced here via errors[]
15
- // so callers can report a degraded read instead of failing silent.
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
17
+ // never throws on a bad file - it substitutes {} for that layer and records the error,
18
+ // surfaced here via errors[] so callers can report a degraded read instead of failing silent.
16
19
  export function loadGauntletSettings(cwd: string, agentDir: string = getAgentDir()): LoadedGauntlet {
17
- const sm = SettingsManager.create(cwd, agentDir);
20
+ const root = parseCheckout(gitSync(["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"], cwd))?.toplevel ?? cwd;
21
+ const sm = SettingsManager.create(root, agentDir);
18
22
  const preset = sm.getGlobalSettings() as { piGauntlet?: Record<string, unknown> };
19
23
  const repo = sm.getProjectSettings() as { piGauntlet?: Record<string, unknown> };
20
24
  const gauntlet = mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet);
21
25
  const errors = sm.drainErrors().map((e) => `${e.scope}: ${e.error.message}`);
22
- return { gauntlet, errors };
26
+ return { gauntlet, errors, root };
23
27
  }
@@ -10,6 +10,7 @@ import {
10
10
  implementExemptDirs,
11
11
  nextGauntletEntered,
12
12
  phaseLabel,
13
+ parseGitCommand,
13
14
  parseGitCommit,
14
15
  resolveRepoDir,
15
16
  findMarkerFile,
@@ -78,6 +79,22 @@ test("phaseLabel: with and without substep", () => {
78
79
  assert.equal(phaseLabel("brainstorm", undefined), "brainstorm");
79
80
  });
80
81
 
82
+ test("parseGitCommand: branch subcommands with and without global flags", () => {
83
+ const SWITCH = /switch(?=\s|$)/;
84
+ const CHECKOUT_B = /checkout\s+-[bB](?=\s|$)/;
85
+ assert.deepEqual(parseGitCommand("git switch -c y", SWITCH), { cPath: undefined, cdPath: undefined });
86
+ assert.deepEqual(parseGitCommand("git -C .worktrees/x switch -c y", SWITCH), { cPath: ".worktrees/x", cdPath: undefined });
87
+ assert.deepEqual(parseGitCommand("git --no-pager -C p checkout -b y", CHECKOUT_B), { cPath: "p", cdPath: undefined });
88
+ assert.deepEqual(parseGitCommand("cd .worktrees/x && git checkout -b y", CHECKOUT_B), { cPath: undefined, cdPath: ".worktrees/x" });
89
+ assert.equal(parseGitCommand("git checkout file.ts", CHECKOUT_B), undefined);
90
+ assert.equal(parseGitCommand("git switcheroo", SWITCH), undefined);
91
+ assert.equal(parseGitCommand("echo git switch", SWITCH), undefined);
92
+ });
93
+
94
+ test("parseGitCommit is parseGitCommand with the commit subcommand", () => {
95
+ assert.deepEqual(parseGitCommit("git -C /wt -c user.email=x commit"), parseGitCommand("git -C /wt -c user.email=x commit", /commit(?=\s|$)/));
96
+ });
97
+
81
98
  test("parseGitCommit: plain, -am, chained after &&", () => {
82
99
  assert.ok(parseGitCommit('git commit -m "x"'));
83
100
  assert.ok(parseGitCommit("git commit -am 'x'"));
@@ -37,18 +37,15 @@ export function phaseLabel(name: string, substep?: string): string {
37
37
  // Same statement-start anchor as the branch guards in phase-tracker.ts. Exported
38
38
  // so Wave 2 wiring in phase-tracker.ts can drop its duplicate copy.
39
39
  export const STMT_START = "(?:^|[\\n;&|(])\\s*";
40
- // `git commit`, tolerating any global flags between `git` and `commit` (e.g.
41
- // `-c user.email=x`, common in CI per-commit identity). Group 1 captures the
42
- // whole flags span as ONE opaque block rather than -C directly: nesting a
43
- // capture inside a repeated alternation resets it to undefined on iterations
44
- // that take the other branch (JS regex semantics), so -C after a later flag
45
- // (e.g. `-C /wt -c user.email=x commit`) would silently lose its capture.
46
- // DASH_C below re-extracts -C from that span once, outside any repetition.
47
- // `commit` must be followed by whitespace or end-of-string, not just a word
48
- // boundary, so `commit-graph` / `commit-tree` don't false-positive.
49
- const GIT_COMMIT = new RegExp(
50
- STMT_START + "git\\s+((?:-\\S+(?:\\s+\\S+)?\\s+)*)commit(?=\\s|$)",
51
- );
40
+ // `git <global flags> <subcommand>`, tolerating any global flags between `git` and the
41
+ // subcommand (e.g. `-C /wt`, `-c user.email=x`). Group 1 captures the whole flags span as
42
+ // ONE opaque block rather than -C directly: nesting a capture inside a repeated alternation
43
+ // resets it to undefined on iterations that take the other branch (JS regex semantics).
44
+ // DASH_C re-extracts -C from that span once, outside any repetition. Callers pass a
45
+ // subcommand regex that ends in `(?=\s|$)` so `commit-graph` / `switcheroo` don't match.
46
+ const GIT_FLAGS_SPAN = "git\\s+((?:-\\S+(?:\\s+\\S+)?\\s+)*)";
47
+ const gitCommandRe = (subcommand: RegExp): RegExp => new RegExp(STMT_START + GIT_FLAGS_SPAN + "(?:" + subcommand.source + ")");
48
+ const GIT_COMMIT_SUB = /commit(?=\s|$)/;
52
49
  const DASH_C = /(?:^|\s)-C\s+(\S+)/;
53
50
  // Global, lookaround-delimited (not consuming) so adjacent `cd a && cd b &&`
54
51
  // statements don't eat each other's anchor/`&&` and hide the second match.
@@ -61,11 +58,11 @@ export interface CommitForm {
61
58
  cdPath: string | undefined;
62
59
  }
63
60
 
64
- // Textual match anchored at statement starts (^ ; & | ( or newline). Quoted text
65
- // can still match when preceded by such a char (e.g. sh -c 'x; git commit') —
66
- // accepted heuristic, same tolerance as the existing Guard 3 mutation checks.
67
- export function parseGitCommit(command: string): CommitForm | undefined {
68
- const m = GIT_COMMIT.exec(command);
61
+ // Textual match anchored at statement starts (^ ; & | ( or newline). Quoted text can
62
+ // still match when preceded by such a char (e.g. sh -c 'x; git commit') - accepted
63
+ // heuristic, same tolerance as the existing Guard 3 mutation checks.
64
+ export function parseGitCommand(command: string, subcommand: RegExp): CommitForm | undefined {
65
+ const m = gitCommandRe(subcommand).exec(command);
69
66
  if (!m) return undefined;
70
67
  let cdPath: string | undefined;
71
68
  for (const cd of command.matchAll(LEADING_CD)) {
@@ -74,6 +71,8 @@ export function parseGitCommit(command: string): CommitForm | undefined {
74
71
  return { cPath: DASH_C.exec(m[1])?.[1], cdPath };
75
72
  }
76
73
 
74
+ export const parseGitCommit = (command: string): CommitForm | undefined => parseGitCommand(command, GIT_COMMIT_SUB);
75
+
77
76
  export function resolveRepoDir(form: CommitForm, sessionCwd: string): string {
78
77
  const base = form.cdPath ? resolve(sessionCwd, form.cdPath) : sessionCwd;
79
78
  return form.cPath ? resolve(base, form.cPath) : base;
@@ -57,6 +57,16 @@ test("matchDiscardStatement matches worktree remove and branch -D", () => {
57
57
  assert.equal(matchDiscardStatement("git branch -d gh-33"), undefined);
58
58
  });
59
59
 
60
+ test("matchShipStatement/matchDiscardStatement accept git -C <path> global flags", () => {
61
+ assert.deepEqual(matchShipStatement("git -C /p merge --squash f"), { option: "squash", statement: "git -C /p merge --squash f" });
62
+ assert.equal(matchShipStatement("git -C /p push -u origin HEAD")?.option, "pr");
63
+ assert.equal(matchShipStatement("git -c user.name=t -C /p merge --squash f")?.option, "squash");
64
+ assert.equal(matchShipStatement("git -C /p log"), undefined);
65
+ assert.equal(matchDiscardStatement("git -C /p worktree remove x"), "git -C /p worktree remove x");
66
+ assert.equal(matchDiscardStatement("git -C /p branch -D f"), "git -C /p branch -D f");
67
+ assert.equal(matchDiscardStatement("git -C /p branch -d f"), undefined);
68
+ });
69
+
60
70
  test("matchTestStatement returns the first statement matching a test fragment", () => {
61
71
  assert.equal(matchTestStatement("cd repo && npm test -- --grep x", DEFAULT_TEST_COMMANDS), "npm test -- --grep x");
62
72
  assert.equal(matchTestStatement("make test-smoke", DEFAULT_TEST_COMMANDS), undefined);
@@ -3,9 +3,9 @@
3
3
  //
4
4
  // Ship detection runs on a `bash` `tool_call` while `ship` is in progress and matches
5
5
  // the first statement against
6
- // STMT_START + (git\s+merge\s+--squash|git\s+push|gh\s+pr\s+create)
6
+ // STMT_START + git <global flags> (merge --squash | push) | gh pr create
7
7
  // and discard against
8
- // STMT_START + git\s+(worktree\s+remove|branch\s+-D)
8
+ // STMT_START + git <global flags> (worktree remove | branch -D)
9
9
  // with STMT_START from phase-tracker-helpers.ts:39.
10
10
  // Default bucket globs (DEFAULT_TELEMETRY_BUCKETS): test = **/test/**, **/tests/**,
11
11
  // **/__tests__/**, **/*.test.*, **/*.spec.*, **/*_test.*; docs = **/*.md;
@@ -40,8 +40,12 @@ export const planSpecHeader = (planBody: string): string | undefined => SPEC_HEA
40
40
 
41
41
  // ---- command matchers ----------------------------------------------------------
42
42
 
43
- const SHIP_RE = new RegExp(STMT_START + "(git\\s+merge\\s+--squash|git\\s+push|gh\\s+pr\\s+create)");
44
- const DISCARD_RE = new RegExp(STMT_START + "(git\\s+(?:worktree\\s+remove|branch\\s+-D))");
43
+ // `git <global flags> <subcommand>`: same flags-span grammar as parseGitCommand in
44
+ // phase-tracker-helpers.ts, so `git -C <worktree> push` from the primary checkout counts.
45
+ const GIT_FLAGS = "git\\s+(?:-\\S+(?:\\s+\\S+)?\\s+)*";
46
+ const SHIP_RE = new RegExp(STMT_START + "(" + GIT_FLAGS + "(?:merge\\s+--squash|push)(?=\\s|$)|gh\\s+pr\\s+create)");
47
+ const DISCARD_RE = new RegExp(STMT_START + "(" + GIT_FLAGS + "(?:worktree\\s+remove|branch\\s+-D)(?=\\s|$))");
48
+ const SQUASH_RE = new RegExp("^" + GIT_FLAGS + "merge\\s+--squash");
45
49
  const STATEMENT_END = /\n|;|&&|\|\||\|/;
46
50
 
47
51
  export const truncateCommand = (s: string): string => (s.length > 120 ? s.slice(0, 120) : s);
@@ -59,7 +63,7 @@ export type ShipOption = "squash" | "pr";
59
63
  export function matchShipStatement(command: string): { option: ShipOption; statement: string } | undefined {
60
64
  const statement = statementAt(command, SHIP_RE);
61
65
  if (!statement) return undefined;
62
- return { option: /^git\s+merge\s+--squash/.test(statement) ? "squash" : "pr", statement };
66
+ return { option: SQUASH_RE.test(statement) ? "squash" : "pr", statement };
63
67
  }
64
68
 
65
69
  export const matchDiscardStatement = (command: string): string | undefined => statementAt(command, DISCARD_RE);
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, test } from "node:test";
3
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
3
+ import { execFileSync } from "node:child_process";
4
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, realpathSync } from "node:fs";
4
5
  import { createHash } from "node:crypto";
5
6
  import { tmpdir } from "node:os";
6
7
  import { isAbsolute, join } from "node:path";
@@ -26,6 +27,18 @@ const tempCwd = (settings?: unknown) => {
26
27
  return dir;
27
28
  };
28
29
 
30
+ // Primary checkout with one linked worktree at .worktrees/x. Real git: plan_check and Guard 2
31
+ // resolve checkouts with gitSync, and macOS temp paths need realpath to compare toplevels.
32
+ const gitPrimary = () => {
33
+ const primary = realpathSync(tempCwd());
34
+ const git = (...args: string[]) => execFileSync("git", ["-C", primary, ...args], { stdio: "pipe" });
35
+ git("init", "-q");
36
+ git("-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "-q", "--allow-empty", "-m", "init");
37
+ mkdirSync(join(primary, "doc"));
38
+ git("worktree", "add", "-q", join(primary, ".worktrees", "x"), "-b", "x");
39
+ return { primary, worktree: join(primary, ".worktrees", "x") };
40
+ };
41
+
29
42
  const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
30
43
  type Phase = (typeof PHASES)[number];
31
44
  type Status = "pending" | "in_progress" | "complete" | "skipped";
@@ -720,7 +733,7 @@ const FIXTURE_SPEC = [
720
733
 
721
734
  const FIXTURE_PLAN = `# Fixture Plan
722
735
 
723
- **Spec:** \`spec.md\`
736
+ **Spec:** \`doc/specs/spec.md\`
724
737
 
725
738
  **Verification:** npm test
726
739
 
@@ -772,8 +785,10 @@ function writePlanFixture(
772
785
  ) {
773
786
  const planText = opts.mutatePlan ? opts.mutatePlan(FIXTURE_PLAN) : FIXTURE_PLAN;
774
787
  const specText = opts.mutateSpec ? opts.mutateSpec(FIXTURE_SPEC) : FIXTURE_SPEC;
775
- const planPath = join(dir, "plan.md");
776
- const specPath = join(dir, "spec.md");
788
+ const planPath = join(dir, "doc/plans/plan.md");
789
+ const specPath = join(dir, "doc/specs/spec.md");
790
+ mkdirSync(join(dir, "doc/plans"), { recursive: true });
791
+ mkdirSync(join(dir, "doc/specs"), { recursive: true });
777
792
  writeFileSync(planPath, planText);
778
793
  writeFileSync(specPath, specText);
779
794
  writeFileSync(join(dir, "file-a.ts"), "// a\n");
@@ -793,8 +808,45 @@ test("plan_check is registered", () => {
793
808
  assert.ok(h.tools.some((t) => t.name === "plan_check"));
794
809
  });
795
810
 
796
- test("plan_check pass: text, details, and stamp arming when a flow is entered", async () => {
811
+ test("plan_check roots at the plan's checkout: worktree plan passes from <primary>/doc (AC 1)", async () => {
812
+ const { primary, worktree } = gitPrimary();
813
+ const { planPath } = writePlanFixture(worktree);
814
+ const h = harness({ cwd: join(primary, "doc") });
815
+ await h.emit("session_start");
816
+ const tool = h.tools.find((t) => t.name === "plan_check")!;
817
+ const res = (await tool.execute("t1", { planPath }, undefined, undefined, h.ctx)) as { content: { text: string }[]; details: { status: string; specPath: string } };
818
+ assert.match(res.content[0].text, /^PASS/);
819
+ assert.equal(res.details.specPath, join(worktree, "doc/specs/spec.md"));
820
+ });
821
+
822
+ test("plan_check outside any checkout: input finding", async () => {
797
823
  const dir = tempCwd();
824
+ const { planPath } = writePlanFixture(dir);
825
+ const h = harness({ cwd: dir });
826
+ await h.emit("session_start");
827
+ const tool = h.tools.find((t) => t.name === "plan_check")!;
828
+ const res = (await tool.execute("t1", { planPath }, undefined, undefined, h.ctx)) as { content: { text: string }[]; details: { status: string } };
829
+ assert.equal(res.details.status, "fail");
830
+ assert.match(res.content[0].text, /input @ line 0: plan is not inside a git checkout/);
831
+ });
832
+
833
+ const bashCall = (id: string, command: string) => ({ toolName: "bash", toolCallId: id, input: { command } });
834
+ const blocked = (results: unknown[]) => (results[0] as { block?: boolean } | undefined)?.block === true;
835
+
836
+ test("Guard 2 evaluates the command's target checkout (AC 3)", async () => {
837
+ const { primary, worktree } = gitPrimary();
838
+ const h = harness({ cwd: join(primary, "doc"), branch: [phaseResult("start", phases({ brainstorm: "in_progress" }))] });
839
+ await h.emit("session_start");
840
+ assert.ok(blocked(await h.emitEvent("tool_call", bashCall("b1", "git switch -c y"))), "bare switch from <primary>/doc blocks");
841
+ assert.ok(blocked(await h.emitEvent("tool_call", bashCall("b2", `git -C ${primary} switch -c y`))), "-C <primary> blocks");
842
+ assert.ok(!blocked(await h.emitEvent("tool_call", bashCall("b3", `git -C ${worktree} switch -c y`))), "-C <worktree> passes");
843
+ assert.ok(!blocked(await h.emitEvent("tool_call", bashCall("b4", `cd ${worktree} && git checkout -b y`))), "cd <worktree> passes");
844
+ assert.ok(!blocked(await h.emitEvent("tool_call", bashCall("b5", "git -C /nonexistent/dir switch -c y"))), "unresolvable target passes");
845
+ assert.ok(!blocked(await h.emitEvent("tool_call", bashCall("b6", "git worktree add .worktrees/y -b y"))), "git worktree is exempt");
846
+ });
847
+
848
+ test("plan_check pass: text, details, and stamp arming when a flow is entered", async () => {
849
+ const dir = gitPrimary().primary;
798
850
  const { planPath, specPath, planText, specText } = writePlanFixture(dir);
799
851
  const h = harness({ cwd: dir, branch: readyForImplementBranch });
800
852
  await h.emit("session_start");
@@ -824,7 +876,7 @@ test("plan_check pass: text, details, and stamp arming when a flow is entered",
824
876
  });
825
877
 
826
878
  test("plan_check pass outside a flow: no stamp, plain-linter text", async () => {
827
- const dir = tempCwd();
879
+ const dir = gitPrimary().primary;
828
880
  const { planPath } = writePlanFixture(dir);
829
881
  const h = harness({ cwd: dir });
830
882
  await h.emit("session_start");
@@ -834,7 +886,7 @@ test("plan_check pass outside a flow: no stamp, plain-linter text", async () =>
834
886
  });
835
887
 
836
888
  test("plan_check fail: mutated plan body, no error key, clears stamp", async () => {
837
- const dir = tempCwd();
889
+ const dir = gitPrimary().primary;
838
890
  const { planPath } = writePlanFixture(dir, {
839
891
  mutatePlan: (t) => t.replace("implements helperFn() for parsing.", "implements the helper for parsing."),
840
892
  });
@@ -857,9 +909,9 @@ test("plan_check fail: mutated plan body, no error key, clears stamp", async ()
857
909
  });
858
910
 
859
911
  test("plan_check fail: unresolvable spec path", async () => {
860
- const dir = tempCwd();
912
+ const dir = gitPrimary().primary;
861
913
  const { planPath } = writePlanFixture(dir, {
862
- mutatePlan: (t) => t.replace("**Spec:** `spec.md`", "**Spec:** `missing-spec.md`"),
914
+ mutatePlan: (t) => t.replace("**Spec:** `doc/specs/spec.md`", "**Spec:** `missing-spec.md`"),
863
915
  });
864
916
  const h = harness({ cwd: dir, branch: readyForImplementBranch });
865
917
  await h.emit("session_start");
@@ -881,11 +933,11 @@ test("plan_check fail: unresolvable spec path", async () => {
881
933
  });
882
934
 
883
935
  test("plan_check fail: header **Spec:** extraction is confined to the header (a path-only-looking line below the separator does not count)", async () => {
884
- const dir = tempCwd();
936
+ const dir = gitPrimary().primary;
885
937
  const { planPath } = writePlanFixture(dir, {
886
938
  mutatePlan: (t) =>
887
939
  t
888
- .replace("**Spec:** `spec.md`\n\n", "")
940
+ .replace("**Spec:** `doc/specs/spec.md`\n\n", "")
889
941
  .replace(
890
942
  "## Wave 1 - Two parallel tasks",
891
943
  "## Wave 1 - Two parallel tasks\n\n**Spec:** `not-the-header-spec.md`\n",
@@ -907,9 +959,9 @@ test("plan_check fail: header **Spec:** extraction is confined to the header (a
907
959
  });
908
960
 
909
961
  test("plan_check fail: plan header has no path-only **Spec:** line at all", async () => {
910
- const dir = tempCwd();
962
+ const dir = gitPrimary().primary;
911
963
  const { planPath } = writePlanFixture(dir, {
912
- mutatePlan: (t) => t.replace("**Spec:** `spec.md`\n\n", ""),
964
+ mutatePlan: (t) => t.replace("**Spec:** `doc/specs/spec.md`\n\n", ""),
913
965
  });
914
966
  const h = harness({ cwd: dir, branch: readyForImplementBranch });
915
967
  await h.emit("session_start");
@@ -942,7 +994,7 @@ test("implement-start gate: rejects with no stamp, names plan_check remedy", asy
942
994
  });
943
995
 
944
996
  test("implement-start gate: stale plan bytes rejected, names the stale file", async () => {
945
- const dir = tempCwd();
997
+ const dir = gitPrimary().primary;
946
998
  const { planPath } = writePlanFixture(dir);
947
999
  const h = harness({ cwd: dir, branch: readyForImplementBranch });
948
1000
  await h.emit("session_start");
@@ -958,7 +1010,7 @@ test("implement-start gate: stale plan bytes rejected, names the stale file", as
958
1010
  });
959
1011
 
960
1012
  test("implement-start gate: stale spec bytes rejected, names the stale file", async () => {
961
- const dir = tempCwd();
1013
+ const dir = gitPrimary().primary;
962
1014
  const { planPath, specPath } = writePlanFixture(dir);
963
1015
  const h = harness({ cwd: dir, branch: readyForImplementBranch });
964
1016
  await h.emit("session_start");
@@ -974,7 +1026,7 @@ test("implement-start gate: stale spec bytes rejected, names the stale file", as
974
1026
  });
975
1027
 
976
1028
  test("implement-start gate: missing stamped file rejected, names the missing path", async () => {
977
- const dir = tempCwd();
1029
+ const dir = gitPrimary().primary;
978
1030
  const { planPath } = writePlanFixture(dir);
979
1031
  const h = harness({ cwd: dir, branch: readyForImplementBranch });
980
1032
  await h.emit("session_start");
@@ -1002,7 +1054,7 @@ test("implement-start gate: enforce false skips the gate entirely", async () =>
1002
1054
  });
1003
1055
 
1004
1056
  test("replay: a passing plan_check result restores the stamp without re-running the tool", async () => {
1005
- const dir = tempCwd();
1057
+ const dir = gitPrimary().primary;
1006
1058
  const { planPath, specPath } = writePlanFixture(dir);
1007
1059
  const passDetails = {
1008
1060
  status: "pass",
@@ -1022,7 +1074,7 @@ test("replay: a passing plan_check result restores the stamp without re-running
1022
1074
  });
1023
1075
 
1024
1076
  test("replay: pass then fail clears the stamp", async () => {
1025
- const dir = tempCwd();
1077
+ const dir = gitPrimary().primary;
1026
1078
  const { planPath, specPath } = writePlanFixture(dir);
1027
1079
  const passDetails = {
1028
1080
  status: "pass",
@@ -1045,7 +1097,7 @@ test("replay: pass then fail clears the stamp", async () => {
1045
1097
  });
1046
1098
 
1047
1099
  test("replay: a plan_check pass observed before any flow entry does not arm the stamp, and a live walk with no live plan_check is rejected", async () => {
1048
- const dir = tempCwd();
1100
+ const dir = gitPrimary().primary;
1049
1101
  const { planPath, specPath } = writePlanFixture(dir);
1050
1102
  const passDetails = {
1051
1103
  status: "pass",
@@ -1070,7 +1122,7 @@ test("replay: a plan_check pass observed before any flow entry does not arm the
1070
1122
  });
1071
1123
 
1072
1124
  test("replay: a phase_tracker reset clears the stamp", async () => {
1073
- const dir = tempCwd();
1125
+ const dir = gitPrimary().primary;
1074
1126
  const { planPath, specPath } = writePlanFixture(dir);
1075
1127
  const passDetails = {
1076
1128
  status: "pass",
@@ -1097,7 +1149,7 @@ test("replay: a phase_tracker reset clears the stamp", async () => {
1097
1149
  });
1098
1150
 
1099
1151
  test("replay via session_fork: a passing plan_check result restores the stamp without re-running the tool", async () => {
1100
- const dir = tempCwd();
1152
+ const dir = gitPrimary().primary;
1101
1153
  const { planPath, specPath } = writePlanFixture(dir);
1102
1154
  const passDetails = {
1103
1155
  status: "pass",
@@ -1124,7 +1176,7 @@ test("replay via session_fork: a passing plan_check result restores the stamp wi
1124
1176
  });
1125
1177
 
1126
1178
  test("replay via session_tree: a passing plan_check result restores the stamp without re-running the tool", async () => {
1127
- const dir = tempCwd();
1179
+ const dir = gitPrimary().primary;
1128
1180
  const { planPath, specPath } = writePlanFixture(dir);
1129
1181
  const passDetails = {
1130
1182
  status: "pass",
@@ -1152,7 +1204,7 @@ test("replay via session_tree: a passing plan_check result restores the stamp wi
1152
1204
  });
1153
1205
 
1154
1206
  test("replay via session_switch: pass then fail clears the stamp, rebuilt from replay rather than a live run", async () => {
1155
- const dir = tempCwd();
1207
+ const dir = gitPrimary().primary;
1156
1208
  const { planPath, specPath } = writePlanFixture(dir);
1157
1209
  const passDetails = {
1158
1210
  status: "pass",