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 +4 -0
- package/README.md +2 -0
- package/extensions/lib/checkout.test.ts +59 -0
- package/extensions/lib/checkout.ts +57 -0
- package/extensions/lib/gauntlet-settings-loader.test.ts +32 -0
- package/extensions/lib/gauntlet-settings-loader.ts +11 -7
- package/extensions/lib/phase-tracker-helpers.test.ts +17 -0
- package/extensions/lib/phase-tracker-helpers.ts +16 -17
- package/extensions/lib/telemetry-paths.test.ts +10 -0
- package/extensions/lib/telemetry-paths.ts +9 -5
- package/extensions/phase-tracker.test.ts +75 -23
- package/extensions/phase-tracker.ts +20 -29
- package/extensions/telemetry.test.ts +51 -10
- package/extensions/telemetry.ts +57 -41
- package/extensions/verify-before-ship.test.ts +16 -0
- package/extensions/verify-before-ship.ts +4 -4
- package/package.json +1 -1
- package/skills/brainstorming/SKILL.md +7 -5
- package/skills/finishing-a-development-branch/SKILL.md +51 -88
- package/skills/gauntlet-resume/SKILL.md +1 -3
- package/skills/gauntlet-resume/reference/brief-contract.md +1 -1
- package/skills/roasting-the-spec/SKILL.md +1 -1
- package/skills/subagent-driven-development/SKILL.md +18 -15
- package/skills/using-git-worktrees/SKILL.md +46 -39
- package/skills/verification-before-completion/reference/conformance-check.md +6 -6
- package/skills/verification-before-completion/reference/settings-precedence.md +8 -6
- package/skills/writing-plans/SKILL.md +5 -3
- package/skills/writing-plans/reference/plan-contract.md +1 -1
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 (
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// substitutes {} for that layer and records the error,
|
|
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
|
|
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
|
|
41
|
-
// `-c user.email=x
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
//
|
|
66
|
-
//
|
|
67
|
-
export function
|
|
68
|
-
const m =
|
|
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 + (
|
|
6
|
+
// STMT_START + git <global flags> (merge --squash | push) | gh pr create
|
|
7
7
|
// and discard against
|
|
8
|
-
// STMT_START + git
|
|
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
|
-
|
|
44
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1207
|
+
const dir = gitPrimary().primary;
|
|
1156
1208
|
const { planPath, specPath } = writePlanFixture(dir);
|
|
1157
1209
|
const passDetails = {
|
|
1158
1210
|
status: "pass",
|