pi-gauntlet 5.9.0 → 5.10.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.
Files changed (30) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +3 -1
  3. package/agents/spec-council-member.md +4 -2
  4. package/agents/spec-council-synthesizer.md +3 -1
  5. package/extensions/lib/checkout.test.ts +59 -0
  6. package/extensions/lib/checkout.ts +57 -0
  7. package/extensions/lib/gauntlet-settings-loader.test.ts +32 -0
  8. package/extensions/lib/gauntlet-settings-loader.ts +11 -7
  9. package/extensions/lib/phase-tracker-helpers.test.ts +17 -0
  10. package/extensions/lib/phase-tracker-helpers.ts +16 -17
  11. package/extensions/lib/telemetry-paths.test.ts +10 -0
  12. package/extensions/lib/telemetry-paths.ts +9 -5
  13. package/extensions/phase-tracker.test.ts +75 -23
  14. package/extensions/phase-tracker.ts +20 -29
  15. package/extensions/telemetry.test.ts +51 -10
  16. package/extensions/telemetry.ts +57 -41
  17. package/extensions/verify-before-ship.test.ts +16 -0
  18. package/extensions/verify-before-ship.ts +4 -4
  19. package/package.json +1 -1
  20. package/skills/brainstorming/SKILL.md +80 -192
  21. package/skills/finishing-a-development-branch/SKILL.md +51 -88
  22. package/skills/gauntlet-resume/SKILL.md +1 -3
  23. package/skills/gauntlet-resume/reference/brief-contract.md +1 -1
  24. package/skills/roasting-the-spec/SKILL.md +4 -2
  25. package/skills/subagent-driven-development/SKILL.md +18 -15
  26. package/skills/using-git-worktrees/SKILL.md +46 -39
  27. package/skills/verification-before-completion/reference/conformance-check.md +6 -6
  28. package/skills/verification-before-completion/reference/settings-precedence.md +8 -6
  29. package/skills/writing-plans/SKILL.md +5 -3
  30. package/skills/writing-plans/reference/plan-contract.md +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## v5.10.0 - 2026-09-18
4
+
5
+ - Council provenance: member findings (except `over-spec`) end with `probed: <source or check> - <result> | none`; the chair tags each suggested edit `grounded:` or `hypothesis:` (`external-ref:` and `over-spec:` clusters stay untagged); `roasting-the-spec` probes `hypothesis` data-shape claims once (bounded, read-only, artifact at hand) before applying, else lands them as one grouped Open Question per artifact; `Applied:` audit lines carry the probe.
6
+ - Standing amend approval: a chat instruction that waives per-diff review ("auto-apply amends, stop only for redraws") lets brainstorming's amendment step render the diff and continue; redraws and the spec gate still stop; the grant is quoted in each amendment commit body. Limitation: back-to-back amendments with no user input between them count as one in telemetry `derived.amendments` (coalesced until the next input event).
7
+ - `skills/brainstorming/SKILL.md` states each rule once; Checklist and all 12 Red Flags link to owning sections, with no gate, step, red flag, or dispatch field removed.
8
+ - CI pins the new persona and skill tokens.
9
+
10
+ ## v5.9.1 - 2026-09-17
11
+
12
+ - 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))
13
+
3
14
  ## v5.9.0 - 2026-09-17
4
15
 
5
16
  - 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
@@ -63,7 +63,7 @@ flowchart LR
63
63
 
64
64
  <!-- TODO GIF: a real gauntlet run end to end -->
65
65
 
66
- Everything between gate 1 and gate 2 - task breakdown, implementation, both review passes - runs without you in the loop. Changing an approved spec later is a conditional diff-approval stop (brainstorming's `Amending an approved spec`), not a third numbered gate. That's the mechanism. What follows is the machinery behind it.
66
+ Everything between gate 1 and gate 2 - task breakdown, implementation, both review passes - runs without you in the loop. Changing an approved spec later is a conditional diff-approval stop (brainstorming's `Amending an approved spec`) - or, under a standing grant, a diff render that continues - not a third numbered gate. That's the mechanism. What follows is the machinery behind it.
67
67
 
68
68
  ## Architecture
69
69
 
@@ -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 |
@@ -16,7 +16,7 @@ You receive a problem statement and the artifact under review, as defined by you
16
16
 
17
17
  You are read-only: you never modify the repository or any input artifact; your only write is your findings file at the dispatched output path.
18
18
 
19
- When your dispatching task asks for codebase verification, verify - do not trust assertions about existing files, APIs, or conventions - but bounded: prefer `rg` (it respects `.gitignore`) over recursive `grep`, use `rg`-native bounds (`--max-count`, explicit paths); scope every scan to explicit paths, never a repository root; bound each scan with `timeout` (or `gtimeout`) when available, and do not run it unbounded when neither exists. A scan that times out or cannot be bounded is reported as unverified - never retried broader.
19
+ When your dispatching task asks for codebase verification, verify - do not trust assertions about existing files, APIs, or conventions - but bounded: prefer `rg` (it respects `.gitignore`) over recursive `grep`, use `rg`-native bounds (`--max-count`, explicit paths); scope every scan to explicit paths, never a repository root; bound each scan with `timeout` (or `gtimeout`) when available, and do not run it unbounded when neither exists. A scan that times out or cannot be bounded is reported as unverified - never retried broader. End every finding with `probed:`; `none` is a normal answer.
20
20
 
21
21
  Assess the spec on five axes:
22
22
 
@@ -43,12 +43,14 @@ Emit exactly this markdown and nothing else:
43
43
  verdict: sound | needs-work | unsound
44
44
  addresses-problem: yes | partial | no — <why>
45
45
  findings:
46
- - [blocker|major|minor] <kind> @ <section or quote> — <problem> → <suggested edit>
46
+ - [blocker|major|minor] <kind> @ <section or quote> — <problem> → <suggested edit> — probed: <source or check> - <observed result> | none
47
47
  lean: nothing to cut | <N> over-spec findings above
48
48
  ```
49
49
 
50
50
  `<kind>` is one of: gap, oversimplification, ambiguity, scope, not-actionable, external-ref, other, over-spec. `scope` = too little or the wrong problem. `over-spec` = too much. No findings -> keep the `findings:` header, no bullets. `lean:` is always the last line; `<N>` = number of `over-spec` bullets. `lean: nothing to cut` is a normal answer.
51
51
 
52
+ `probed:` names what you read or ran and what it showed (`probed: rg 'source IN' migrations/ - CHECK lists 3 values`); `probed: none` when the edit rests on the spec text alone. The `over-spec` bullet form below carries no `probed:`.
53
+
52
54
  **`over-spec`.** Flag a clause only when all three are true:
53
55
 
54
56
  1. It is clearly outside the stated problem.
@@ -30,13 +30,15 @@ Emit exactly this markdown and nothing else:
30
30
  consensus: <one-line overall verdict, e.g. needs-work — 2 of 3 members flagged blockers>
31
31
  lean: <k> of <n> members found nothing to cut
32
32
  clusters:
33
- - [blocker|major|minor] <theme> — raised-by: [<model>, <model>] — <consolidated finding> → <suggested edit>
33
+ - [blocker|major|minor] <theme> — raised-by: [<model>, <model>] — <consolidated finding> → grounded|hypothesis: <suggested edit>
34
34
  resolved:
35
35
  - <contested point> → sided with <position> (<one-clause why>)
36
36
  ```
37
37
 
38
38
  Every cluster must be pre-resolved — never emit a raw "members disagree" item. Leave `resolved` as a header with no bullets if no members conflicted.
39
39
 
40
+ Tag every suggested edit: `grounded:` when the raising members' `probed:` results support every factual assertion the edit makes; otherwise `hypothesis:`. A missing `probed:` reads as `none`. Never probe to upgrade a label - the tag records what members checked, not what you could check. `external-ref:` and `over-spec:` clusters carry no tag.
41
+
40
42
  When any member raises an `external-ref` finding (load-bearing external context the spec does not inline), surface it as its own cluster with the theme prefixed `external-ref:`, e.g. `- [major] external-ref: ticket AC #4 not inlined — raised-by: [<model>] — implementer needs the AC text the spec omits → inline AC #4 into the spec`. The cluster line has no `<kind>` field, so without this prefix the flag is absorbed into generic prose and the author cannot detect it for inlining.
41
43
 
42
44
  `over-spec` findings get their own cluster, theme prefixed `over-spec:` (the author branches on that prefix, as with `external-ref:`). Rules:
@@ -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);