pi-gauntlet 5.18.6 → 5.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +4 -1
- package/extensions/lib/telemetry-paths.test.ts +43 -0
- package/extensions/lib/telemetry-paths.ts +54 -0
- package/extensions/telemetry.test.ts +132 -3
- package/extensions/telemetry.ts +45 -3
- package/package.json +1 -1
- package/skills/gauntlet-resume/SKILL.md +46 -32
- package/skills/gauntlet-resume/reference/brief-contract.md +2 -2
- package/skills/gauntlet-resume/reference/reconstruction.md +10 -3
- package/skills/gauntlet-resume/reference/seed.md +47 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v5.19.0 - 2026-09-26
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `gauntlet-resume` accepts a spec seed: a one-token `.md` file tracked on main under a configured spec dir. The route (`skills/gauntlet-resume/reference/seed.md`) checks primary session, tracked, and not shipped, derives the branch name from the spec basename minus its date, reuses the worktree registered for that branch or creates it through `/skill:using-git-worktrees`, pins the spec, and continues in reconstruction. Reconstruction pairs a candidate plan with a spec tracked from base; a plan already at base reports `plan predates base; no post-base task evidence`.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- `gauntlet-resume` arguments classify by token count then content: a file whose line 1 starts `# Handoff:` is a brief regardless of extension, a `.md` under a spec dir is a seed, an existing git worktree root or bare name is a worktree. "Never creates a worktree" narrows to "never `git worktree add` directly". Supersedes the matching clauses of `doc/specs/2026-09-17-gh-31-gauntlet-resume.md` and `doc/specs/2026-09-20-gh-40-gauntlet-handoff-shared-brief-contract.md`.
|
|
12
|
+
|
|
13
|
+
## v5.18.7 - 2026-09-24
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- The telemetry recorder derives `modified_files` and diff buckets in plain jj workspaces through `jj` (remote trunk or local `main`/`master` as the mainline), with jj-named warnings and an intact record on any jj failure. (#51)
|
|
18
|
+
|
|
3
19
|
## v5.18.6 - 2026-09-24
|
|
4
20
|
|
|
5
21
|
### Changed
|
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ Everything between gate 1 and gate 2 - task breakdown, implementation, both revi
|
|
|
69
69
|
|
|
70
70
|
pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
|
|
71
71
|
|
|
72
|
-
- **20 skills** - the workflow logic. Thirteen activate automatically when pi sees the matching kind of task, and each one gates the next: `brainstorming`, `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `dispatching-parallel-agents`, `verification-before-completion`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `writing-skills`, `linear` (reads/searches/comments on/manages Linear tickets via the `linearis` CLI; owns all linearis mechanics and the `## Issue tracker` overrides schema; tracker-facing skills route to it). Seven more are explicit-invocation-only (`disable-model-invocation: true`): `shape-ticket` creates or repairs one tracker issue per run against a Context/Problem/Idea/Acceptance-Criteria template, gated by an AC integrity check, a cheap council roast, and a single human-confirmed write - run it with `/skill:shape-ticket`. `gatekeep-pr` is consent-gated pre-merge verification of a PR against its issue - read-only gathering, verification evidence resolved CI-first (green checks on the exact assessed head count as evidence; the project's verification command runs only as fallback), a rubric-based review, then a deterministic authorship-aware menu with stable finding IDs (P#/L#/C#/F#) and numbered pre-composed courses (fixes execute as a single parallel-safe wave: one gate run, one re-review, one push); nothing mutates (fixes, pushes, reviews, merges) until you pick a row - run it with `/skill:gatekeep-pr <pr>`. `check-delivery` is a post-merge detective control: proves an issue actually shipped (default-branch landing, delivery target, per-AC evidence) before its tracker status advances; it never writes a terminal status - run it with `/skill:check-delivery <ref>`. `chase-bug` is human-only bug triage: read-only root-cause discovery to an evidenced verdict menu (real bug -> ticket/brainstorm/hotfix/respond; five negative verdicts), then a gated response to the reporter for addressable origins (GitHub issue / tracker ticket) and a rendered verdict summary otherwise - it never fixes during triage; the hotfix row hands off to `skills/chase-bug/hotfix.md` after the menu - run it with `/skill:chase-bug`. `gauntlet-performance` reads the committed run telemetry (current repo by default, `--dir <path>` adds others) through the parse-only `gauntlet-performance` CLI and answers with one example-led recommendation, 3-5 cornerstone numbers, and a menu of at most three actions (render a report file, open the recommendation as a ticket via `shape-ticket`, drill into one run); it writes nothing unless you pick render - run it with `/skill:gauntlet-performance [--dir <path>]... [--since <version>]`. `gauntlet-handoff` ends a session whose flow a fresh session will continue: it invokes pi-cohort's `handoff` skill (`--out <path>` or `--key <name>`, default key = the run worktree's branch with `/` flattened to `-`, default file `<tmpdir>/pi-handoff/<key>.md`), then appends the gauntlet process-state section (phase/plan tracker status) per the shared contract `skills/gauntlet-resume/reference/brief-contract.md` - run it with `/skill:gauntlet-handoff [--out <path> | --key <name>]`. `gauntlet-resume` is the only way back into an interrupted flow from a fresh session: it takes a `gauntlet-handoff` brief (a file path, pasted text, or - with no arguments - a pick from the briefs under `<tmpdir>/pi-handoff/`)
|
|
72
|
+
- **20 skills** - the workflow logic. Thirteen activate automatically when pi sees the matching kind of task, and each one gates the next: `brainstorming`, `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `dispatching-parallel-agents`, `verification-before-completion`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `writing-skills`, `linear` (reads/searches/comments on/manages Linear tickets via the `linearis` CLI; owns all linearis mechanics and the `## Issue tracker` overrides schema; tracker-facing skills route to it). Seven more are explicit-invocation-only (`disable-model-invocation: true`): `shape-ticket` creates or repairs one tracker issue per run against a Context/Problem/Idea/Acceptance-Criteria template, gated by an AC integrity check, a cheap council roast, and a single human-confirmed write - run it with `/skill:shape-ticket`. `gatekeep-pr` is consent-gated pre-merge verification of a PR against its issue - read-only gathering, verification evidence resolved CI-first (green checks on the exact assessed head count as evidence; the project's verification command runs only as fallback), a rubric-based review, then a deterministic authorship-aware menu with stable finding IDs (P#/L#/C#/F#) and numbered pre-composed courses (fixes execute as a single parallel-safe wave: one gate run, one re-review, one push); nothing mutates (fixes, pushes, reviews, merges) until you pick a row - run it with `/skill:gatekeep-pr <pr>`. `check-delivery` is a post-merge detective control: proves an issue actually shipped (default-branch landing, delivery target, per-AC evidence) before its tracker status advances; it never writes a terminal status - run it with `/skill:check-delivery <ref>`. `chase-bug` is human-only bug triage: read-only root-cause discovery to an evidenced verdict menu (real bug -> ticket/brainstorm/hotfix/respond; five negative verdicts), then a gated response to the reporter for addressable origins (GitHub issue / tracker ticket) and a rendered verdict summary otherwise - it never fixes during triage; the hotfix row hands off to `skills/chase-bug/hotfix.md` after the menu - run it with `/skill:chase-bug`. `gauntlet-performance` reads the committed run telemetry (current repo by default, `--dir <path>` adds others) through the parse-only `gauntlet-performance` CLI and answers with one example-led recommendation, 3-5 cornerstone numbers, and a menu of at most three actions (render a report file, open the recommendation as a ticket via `shape-ticket`, drill into one run); it writes nothing unless you pick render - run it with `/skill:gauntlet-performance [--dir <path>]... [--since <version>]`. `gauntlet-handoff` ends a session whose flow a fresh session will continue: it invokes pi-cohort's `handoff` skill (`--out <path>` or `--key <name>`, default key = the run worktree's branch with `/` flattened to `-`, default file `<tmpdir>/pi-handoff/<key>.md`), then appends the gauntlet process-state section (phase/plan tracker status) per the shared contract `skills/gauntlet-resume/reference/brief-contract.md` - run it with `/skill:gauntlet-handoff [--out <path> | --key <name>]`. `gauntlet-resume` is the only way back into an interrupted flow from a fresh session: it takes a `gauntlet-handoff` brief (a file path, pasted text, or - with no arguments - a pick from the briefs under `<tmpdir>/pi-handoff/`), a bare worktree that already holds a spec, or a spec tracked on main under a configured spec dir (a spec seed: it creates or reuses the worktree through `using-git-worktrees` and pins the spec), restores phase/plan tracker state through the legal arming sequence (`start brainstorm`, `skip` with `resume:` reasons, `plan_check` before implement-or-later), and never infers approval from artifacts - run it with `/skill:gauntlet-resume [<brief-file> | <spec>.md | <worktree-name-or-path>]`.
|
|
73
73
|
- **7 subagent personas** - the specialized child agents the skills dispatch via pi-cohort: `implementer`, `code-reviewer`, `spec-reviewer`, `conformance-reviewer`, `spec-summarizer`, `spec-council-member`, `spec-council-synthesizer`. See [doc/personas.md](./doc/personas.md) for what each one does and why its permissions are scoped the way they are.
|
|
74
74
|
- **4 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you push or open a PR without a passing test run since your last edit; a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. In a brainstorming-entered flow, phase-tracker rejects `implement` or `verify` completion while tracker tasks remain pending or in progress; see [its configuration reference](./doc/configuration.md#phase-tracker). phase-tracker also registers `plan_check`, which verifies a plan against its spec and against the grammar in [skills/writing-plans/reference/plan-contract.md](./skills/writing-plans/reference/plan-contract.md), including that each task's `Tests:` commands are selective and never the full suite; a pass stamps the plan for implementation. `telemetry` records one committed YAML record per gauntlet run (phase timing, models, personas, gate/fix rounds, diff at ship) that ships in the squash beside the spec - finishing and the PR gate restore a stripped record, and stamp a record left `in_progress` with no ship phase as `shipped`, with `gauntlet-telemetry-salvage` - and blocks a brainstorm `write` into an already-shipped spec; see [its configuration reference](./doc/configuration.md#telemetry). See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
|
|
75
75
|
|
|
@@ -90,6 +90,9 @@ Run by a human against pi-cohort >= 7.1.0, before a pi-gauntlet release claims t
|
|
|
90
90
|
3. Hotfix context with non-pending trackers: the brief ends at `## Skills loaded`; resume takes the `chase-bug` route.
|
|
91
91
|
4. Fresh session, zero arguments: the listing shows the briefs from 1-3 and a pick restores; `/skill:gauntlet-resume /nonexistent.md` stops naming that path without listing anything.
|
|
92
92
|
5. Branch `hotfix/x`: the default file is `pi-handoff/hotfix-x.md` and appears in the zero-argument listing.
|
|
93
|
+
6. Primary session, idle trackers, spec tracked on main: `/skill:gauntlet-resume doc/specs/<x>.md` creates the worktree and asks the approval question; approval leaves `phase_tracker` at plan in_progress with brainstorm `⊘ (resume: ...)`.
|
|
94
|
+
7. Fresh idle session, same seed: the registered worktree is reused (no creation) and the question repeats. Run once on a consumer whose `## using-git-worktrees` override lays worktrees out as sibling dirs, once on `.worktrees/`.
|
|
95
|
+
8. After writing-plans commits the plan beside the seeded spec: `/skill:gauntlet-handoff` then a fresh `/skill:gauntlet-resume <brief>` restores plan phase; a fresh `/skill:gauntlet-resume <worktree>` reaches "Spec with plan" with task evidence from the plan commit.
|
|
93
96
|
|
|
94
97
|
## Key concepts
|
|
95
98
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
matchDiscardStatement,
|
|
10
10
|
matchShipStatement,
|
|
11
11
|
matchTestStatement,
|
|
12
|
+
parsePatchNumstat,
|
|
12
13
|
parseSpecLinks,
|
|
13
14
|
planSpecHeader,
|
|
14
15
|
recordPathFor,
|
|
@@ -95,6 +96,48 @@ test("aggregateNumstat sums per bucket over the given file set; binary rows coun
|
|
|
95
96
|
});
|
|
96
97
|
});
|
|
97
98
|
|
|
99
|
+
const patch = (...blocks: string[][]) => blocks.map((b) => b.join("\n")).join("\n") + "\n";
|
|
100
|
+
|
|
101
|
+
test("parsePatchNumstat counts hunk lines and paths with spaces", () => {
|
|
102
|
+
const p = patch(
|
|
103
|
+
["diff --git a/src/a.ts b/src/a.ts", "--- a/src/a.ts", "+++ b/src/a.ts", "@@ -1 +1 @@", "+a", "+b", "-c", "\"],
|
|
104
|
+
["diff --git a/sp ace.txt b/sp ace.txt", "--- a/sp ace.txt", "+++ b/sp ace.txt", "@@ -1 +1 @@", "-x", "+y"],
|
|
105
|
+
);
|
|
106
|
+
assert.deepEqual(parsePatchNumstat(p), [{ added: 2, removed: 1, path: "src/a.ts" }, { added: 1, removed: 1, path: "sp ace.txt" }]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("parsePatchNumstat handles renames and delete/add pairs", () => {
|
|
110
|
+
assert.deepEqual(parsePatchNumstat(patch(
|
|
111
|
+
["diff --git a/old.ts b/new.ts", "rename from old.ts", "rename to new.ts"],
|
|
112
|
+
["diff --git a/o2.ts b/n2.ts", "rename from o2.ts", "rename to n2.ts", "--- a/o2.ts", "+++ b/n2.ts", "@@ -1 +1,2 @@", " k", "+extra"],
|
|
113
|
+
["diff --git a/gone.ts b/gone.ts", "deleted file mode 100644", "--- a/gone.ts", "+++ /dev/null", "@@ -1 +0,0 @@", "-old"],
|
|
114
|
+
["diff --git a/newer.ts b/newer.ts", "new file mode 100644", "--- /dev/null", "+++ b/newer.ts", "@@ -0,0 +1 @@", "+new"],
|
|
115
|
+
)), [
|
|
116
|
+
{ added: 0, removed: 0, path: "new.ts" }, { added: 1, removed: 0, path: "n2.ts" },
|
|
117
|
+
{ added: 0, removed: 1, path: "gone.ts" }, { added: 1, removed: 0, path: "newer.ts" },
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("parsePatchNumstat handles binary, mode-only and positional hunk headers", () => {
|
|
122
|
+
assert.deepEqual(parsePatchNumstat(patch(
|
|
123
|
+
["diff --git a/dir b/icon.png b/dir b/icon.png", "Binary files a/dir b/icon.png and b/dir b/icon.png differ"],
|
|
124
|
+
["diff --git a/run.sh b/run.sh", "old mode 100644", "new mode 100755"],
|
|
125
|
+
["diff --git a/image.png b/image.png", "GIT binary patch", "literal 4"],
|
|
126
|
+
["diff --git a/test/fm.md b/test/fm.md", "--- a/test/fm.md", "+++ b/test/fm.md", "@@ -1 +1 @@", "----", "++text"],
|
|
127
|
+
)), [
|
|
128
|
+
{ added: 0, removed: 0, path: "dir b/icon.png" }, { added: 0, removed: 0, path: "run.sh" },
|
|
129
|
+
{ added: 0, removed: 0, path: "image.png" }, { added: 1, removed: 1, path: "test/fm.md" },
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("parsePatchNumstat rejects malformed input and accepts empty patches", () => {
|
|
134
|
+
assert.deepEqual(parsePatchNumstat(""), []);
|
|
135
|
+
assert.deepEqual(parsePatchNumstat(" \n\n"), []);
|
|
136
|
+
assert.equal(parsePatchNumstat("garbage\n"), null);
|
|
137
|
+
assert.equal(parsePatchNumstat("diff --git a/x b/y\nindex 1..2\n"), null);
|
|
138
|
+
assert.equal(parsePatchNumstat("diff --git c/x b/x\n"), null);
|
|
139
|
+
});
|
|
140
|
+
|
|
98
141
|
test("parseSpecLinks reads Supersedes/Fixes banners as paths or markdown links", () => {
|
|
99
142
|
const body = "# T\n\n> **Supersedes:** [doc/specs/a.md](./a.md), doc/specs/b.md\n> **Fixes:** [doc/specs/c.md](./c.md)\n";
|
|
100
143
|
assert.deepEqual(parseSpecLinks(body), { supersedes: ["doc/specs/a.md", "doc/specs/b.md"], fixes: ["doc/specs/c.md"] });
|
|
@@ -116,6 +116,60 @@ export function aggregateNumstat(numstat: string, files: Set<string>, buckets: [
|
|
|
116
116
|
return out;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
export interface PatchRow {
|
|
120
|
+
added: number;
|
|
121
|
+
removed: number;
|
|
122
|
+
path: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function patchBlockPath(headers: string[]): string | undefined {
|
|
126
|
+
const plus = headers.find((l) => l.startsWith("+++ b/"));
|
|
127
|
+
if (plus) return plus.slice(6);
|
|
128
|
+
const renameTo = headers.find((l) => l.startsWith("rename to "));
|
|
129
|
+
if (renameTo) return renameTo.slice(10);
|
|
130
|
+
const minus = headers.find((l) => l.startsWith("--- a/"));
|
|
131
|
+
if (minus && headers.includes("+++ /dev/null")) return minus.slice(6);
|
|
132
|
+
if (!headers[0].startsWith("diff --git a/")) return undefined;
|
|
133
|
+
const rest = headers[0].slice("diff --git a/".length);
|
|
134
|
+
const mid = (rest.length - 3) / 2;
|
|
135
|
+
if (Number.isInteger(mid) && mid > 0 && rest.slice(mid, mid + 3) === " b/" && rest.slice(0, mid) === rest.slice(mid + 3)) return rest.slice(0, mid);
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parsePatchBlock(block: string[]): PatchRow | undefined {
|
|
140
|
+
const hunk = block.findIndex((l) => l.startsWith("@@"));
|
|
141
|
+
const headers = hunk < 0 ? block : block.slice(0, hunk);
|
|
142
|
+
const path = patchBlockPath(headers);
|
|
143
|
+
if (path === undefined) return undefined;
|
|
144
|
+
let added = 0;
|
|
145
|
+
let removed = 0;
|
|
146
|
+
for (const line of hunk < 0 ? [] : block.slice(hunk)) {
|
|
147
|
+
if (line.startsWith("+")) added++;
|
|
148
|
+
else if (line.startsWith("-")) removed++;
|
|
149
|
+
}
|
|
150
|
+
return { added, removed, path };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// jj has no numstat: --stat truncates paths and -T exposes no line counts.
|
|
154
|
+
// Headers end at the first @@; subsequent ---- and ++text lines count as content.
|
|
155
|
+
export function parsePatchNumstat(patch: string): PatchRow[] | null {
|
|
156
|
+
if (!patch.trim()) return [];
|
|
157
|
+
const lines = patch.split("\n");
|
|
158
|
+
if (!lines.find((l) => l.trim())!.startsWith("diff --git ")) return null;
|
|
159
|
+
const blocks: string[][] = [];
|
|
160
|
+
for (const line of lines) {
|
|
161
|
+
if (line.startsWith("diff --git ")) blocks.push([line]);
|
|
162
|
+
else if (blocks.length) blocks[blocks.length - 1].push(line);
|
|
163
|
+
}
|
|
164
|
+
const rows: PatchRow[] = [];
|
|
165
|
+
for (const block of blocks) {
|
|
166
|
+
const row = parsePatchBlock(block);
|
|
167
|
+
if (!row) return null;
|
|
168
|
+
rows.push(row);
|
|
169
|
+
}
|
|
170
|
+
return rows;
|
|
171
|
+
}
|
|
172
|
+
|
|
119
173
|
// ---- spec banners --------------------------------------------------------------
|
|
120
174
|
|
|
121
175
|
const LINK_BANNER_RE = /^> \*\*(Supersedes|Fixes):\*\*\s*(.+)$/gm;
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
type Handler = (event: any, ctx: any) => unknown;
|
|
22
22
|
|
|
23
|
-
function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[], cwd: string) => GitResult | undefined; jjWorkspace?: boolean; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
|
|
23
|
+
function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[], cwd: string) => GitResult | undefined; jjWorkspace?: boolean; jjFail?: (args: string[], cwd: string) => GitResult | undefined; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
|
|
24
24
|
const root = mkdtempSync(join(tmpdir(), "telemetry-test-"));
|
|
25
25
|
tempDirs.push(root);
|
|
26
26
|
mkdirSync(join(root, "doc/specs"), { recursive: true });
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
const handlers = new Map<string, Handler[]>();
|
|
30
30
|
const gitCalls: string[][] = [];
|
|
31
31
|
const gitCwds: string[] = [];
|
|
32
|
+
const jjCalls: string[][] = [];
|
|
32
33
|
const readFileCalls: string[] = [];
|
|
33
34
|
let clock = Date.parse("2026-09-17T10:00:00Z");
|
|
34
35
|
const deps: Deps = {
|
|
@@ -53,7 +54,13 @@
|
|
|
53
54
|
},
|
|
54
55
|
settings: () => ({ telemetry: { enabled: o.enabled ?? true, dir: o.telemetryDir ?? ".pi/gauntlet/telemetry", buckets: DEFAULT_TELEMETRY_BUCKETS, warning: o.telemetryWarning }, errors: [], agentOverrides: { implementer: { model: "p/x" } }, versions: { pi: "0.85.1" } }),
|
|
55
56
|
// A plain jj workspace: `jj root` answers the temp root while git rev-parse fails.
|
|
56
|
-
|
|
57
|
+
jj: async (args, cwd) => {
|
|
58
|
+
jjCalls.push(args);
|
|
59
|
+
const forced = o.jjFail?.(args, cwd);
|
|
60
|
+
if (forced) return forced;
|
|
61
|
+
if (!o.jjWorkspace) return { code: 1, stdout: "", stderr: "jj stub: not a jj workspace" };
|
|
62
|
+
return shipJj(args, root);
|
|
63
|
+
},
|
|
57
64
|
};
|
|
58
65
|
let branch = o.branch ?? [];
|
|
59
66
|
const ctx = {
|
|
@@ -79,11 +86,39 @@
|
|
|
79
86
|
await emit("tool_call", { toolName: "write", toolCallId: "w", input: { path: rel, content: body } });
|
|
80
87
|
return emit("tool_result", { toolName: "write", toolCallId: "w", input: { path: rel, content: body }, content: [], isError: false, details: undefined });
|
|
81
88
|
};
|
|
82
|
-
return { root, handlers, emit, gitCalls, gitCwds, readFileCalls, commits, recordPath, readRecord, phaseResult, writeSpec, setBranch: (b: unknown[]) => (branch = b), ctx };
|
|
89
|
+
return { root, handlers, emit, gitCalls, gitCwds, jjCalls, readFileCalls, commits, recordPath, readRecord, phaseResult, writeSpec, setBranch: (b: unknown[]) => (branch = b), ctx };
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
const P = (over: Record<string, string> = {}) => Object.fromEntries(["brainstorm", "plan", "implement", "verify", "ship"].map((p) => [p, { status: over[p] ?? "pending" }]));
|
|
86
93
|
|
|
94
|
+
const JJ_PATCH = [
|
|
95
|
+
"diff --git a/src/a.ts b/src/a.ts",
|
|
96
|
+
"index 1111111..2222222 100644",
|
|
97
|
+
"--- a/src/a.ts",
|
|
98
|
+
"+++ b/src/a.ts",
|
|
99
|
+
"@@ -1,4 +1,12 @@",
|
|
100
|
+
...Array.from({ length: 10 }, (_, i) => `+added ${i}`),
|
|
101
|
+
"-removed 1",
|
|
102
|
+
"-removed 2",
|
|
103
|
+
" context",
|
|
104
|
+
"diff --git a/test/a.test.ts b/test/a.test.ts",
|
|
105
|
+
"new file mode 100644",
|
|
106
|
+
"index 0000000..3333333",
|
|
107
|
+
"--- /dev/null",
|
|
108
|
+
"+++ b/test/a.test.ts",
|
|
109
|
+
"@@ -0,0 +1,5 @@",
|
|
110
|
+
...Array.from({ length: 5 }, (_, i) => `+t ${i}`),
|
|
111
|
+
"",
|
|
112
|
+
].join("\n");
|
|
113
|
+
|
|
114
|
+
function shipJj(args: string[], root: string): GitResult {
|
|
115
|
+
if (args[0] === "root") return { code: 0, stdout: root + "\n", stderr: "" };
|
|
116
|
+
if (args.includes("--count")) return { code: 0, stdout: args.some((a) => a.includes('files(~glob:".pi/gauntlet/telemetry/**")')) ? "2\n" : "3\n", stderr: "" };
|
|
117
|
+
if (args.includes("log")) return { code: 0, stdout: "jjbase0001\n", stderr: "" };
|
|
118
|
+
if (args.includes("diff")) return { code: 0, stdout: JJ_PATCH, stderr: "" };
|
|
119
|
+
return { code: 1, stdout: "", stderr: `jj stub: unexpected call ${args.join(" ")}` };
|
|
120
|
+
}
|
|
121
|
+
|
|
87
122
|
test("enabled: false registers handlers that no-op", async () => {
|
|
88
123
|
const h = harness({ enabled: false });
|
|
89
124
|
await h.emit("session_start", { type: "session_start", reason: "startup" });
|
|
@@ -726,6 +761,100 @@
|
|
|
726
761
|
assert.ok(rec.events.some((e) => e.kind === "warning" && /base/.test((e as any).message)));
|
|
727
762
|
});
|
|
728
763
|
|
|
764
|
+
async function boundInJjShip(jjFail?: (args: string[]) => GitResult | undefined) {
|
|
765
|
+
const h = harness({
|
|
766
|
+
jjWorkspace: true,
|
|
767
|
+
jjFail: jjFail ? (args) => jjFail(args) : undefined,
|
|
768
|
+
gitFail: (args) => (args[0] === "rev-parse" && args.includes("--path-format=absolute") ? { code: 128, stdout: "", stderr: "fatal: not a git repository" } : undefined),
|
|
769
|
+
});
|
|
770
|
+
await h.phaseResult("start", P({ brainstorm: "in_progress" }));
|
|
771
|
+
await h.writeSpec("doc/specs/a.md");
|
|
772
|
+
await h.phaseResult("complete", P({ brainstorm: "complete" }));
|
|
773
|
+
await h.phaseResult("start", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete", ship: "in_progress" }));
|
|
774
|
+
await h.phaseResult("complete", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete", ship: "complete" }));
|
|
775
|
+
return h;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const diffWarnings = (h: { readRecord: () => ReturnType<typeof parseRecord> }) =>
|
|
779
|
+
h.readRecord()!.events.filter((e) => e.kind === "warning").map((e: any) => e.message as string).filter((m) => m.startsWith("diff omitted"));
|
|
780
|
+
|
|
781
|
+
const isBaseLog = (args: string[]) => args.includes("log") && !args.includes("--count");
|
|
782
|
+
|
|
783
|
+
test("jj ship (AC 1): keep path derives modified_files and diff through jj with pinned output flags", async () => {
|
|
784
|
+
const h = await boundInJjShip();
|
|
785
|
+
const rec = h.readRecord();
|
|
786
|
+
assert.equal(rec.status, "shipped");
|
|
787
|
+
assert.equal(rec.derived.gates.ship_option, "keep");
|
|
788
|
+
assert.deepEqual(rec.derived.modified_files, ["src/a.ts", "test/a.test.ts"]);
|
|
789
|
+
assert.deepEqual(rec.derived.diff, { base: "jjbase0001", commits: 2, buckets: { code: { files: 1, insertions: 10, deletions: 2 }, test: { files: 1, insertions: 5, deletions: 0 } } });
|
|
790
|
+
assert.deepEqual(diffWarnings(h), []);
|
|
791
|
+
const diffSteps = h.jjCalls.filter((a) => a[0] !== "root");
|
|
792
|
+
assert.equal(diffSteps.length, 3);
|
|
793
|
+
assert.ok(diffSteps.every((a) => a.includes("--color=never")));
|
|
794
|
+
assert.deepEqual(diffSteps[1].slice(0, 4), ["--color=never", "--config", "diff.git.show-path-prefix=true", "diff"]);
|
|
795
|
+
assert.ok(diffSteps[0].some((a) => a.includes("fork_point(") && a.includes("~ root() & ::")));
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("jj ship (AC 2): empty mainline -> jj-named warning, both derived fields absent, record written", async () => {
|
|
799
|
+
const h = await boundInJjShip((args) => (isBaseLog(args) ? { code: 0, stdout: "", stderr: "" } : undefined));
|
|
800
|
+
const rec = h.readRecord();
|
|
801
|
+
assert.equal(rec.status, "shipped");
|
|
802
|
+
assert.equal(rec.derived.diff, undefined);
|
|
803
|
+
assert.equal(rec.derived.modified_files, undefined);
|
|
804
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)"]);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("jj ship: jj diff nonzero exit -> first stderr line in the warning", async () => {
|
|
808
|
+
const h = await boundInJjShip((args) => (args.includes("diff") ? { code: 1, stdout: "", stderr: "Error: Revision `jjbase0001` doesn't exist\nHint: try jj log\n" } : undefined));
|
|
809
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj diff failed: Error: Revision `jjbase0001` doesn't exist"]);
|
|
810
|
+
assert.equal(h.readRecord().derived.diff, undefined);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
test("jj ship: ENOENT-shaped runner result (empty stderr) -> 'unknown error'", async () => {
|
|
814
|
+
const h = await boundInJjShip((args) => (isBaseLog(args) ? { code: 127, stdout: "", stderr: "" } : undefined));
|
|
815
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj log failed: unknown error"]);
|
|
816
|
+
assert.equal(h.readRecord().status, "shipped");
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("jj ship: unparseable patch -> warning, neither field set", async () => {
|
|
820
|
+
const h = await boundInJjShip((args) => (args.includes("diff") ? { code: 0, stdout: "not a patch\n", stderr: "" } : undefined));
|
|
821
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj diff unparseable"]);
|
|
822
|
+
const rec = h.readRecord();
|
|
823
|
+
assert.equal(rec.derived.diff, undefined);
|
|
824
|
+
assert.equal(rec.derived.modified_files, undefined);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("jj ship: malformed patch header -> warning, neither field set, record shipped", async () => {
|
|
828
|
+
const h = await boundInJjShip((args) => (args.includes("diff") ? { code: 0, stdout: "diff --git c/x b/x\n", stderr: "" } : undefined));
|
|
829
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj diff unparseable"]);
|
|
830
|
+
const rec = h.readRecord();
|
|
831
|
+
assert.equal(rec.status, "shipped");
|
|
832
|
+
assert.equal(rec.derived.diff, undefined);
|
|
833
|
+
assert.equal(rec.derived.modified_files, undefined);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("jj ship: --count failure -> jj log warning, no derived diff", async () => {
|
|
837
|
+
const h = await boundInJjShip((args) => (args.includes("--count") ? { code: 1, stdout: "", stderr: "Error: invalid revset\nHint: check the mainline\n" } : undefined));
|
|
838
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj log failed: Error: invalid revset"]);
|
|
839
|
+
const rec = h.readRecord();
|
|
840
|
+
assert.equal(rec.status, "shipped");
|
|
841
|
+
assert.equal(rec.derived.diff, undefined);
|
|
842
|
+
assert.equal(rec.derived.modified_files, undefined);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test("jj ship: unparseable --count -> warning with the raw output", async () => {
|
|
846
|
+
const h = await boundInJjShip((args) => (args.includes("--count") ? { code: 0, stdout: "abc\n", stderr: "" } : undefined));
|
|
847
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj log --count unparseable: abc"]);
|
|
848
|
+
assert.equal(h.readRecord().derived.diff, undefined);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test("git ship never calls jj", async () => {
|
|
852
|
+
const h = await boundInShip();
|
|
853
|
+
await h.bash("s1", "git push");
|
|
854
|
+
assert.deepEqual(h.readRecord().derived.modified_files, ["README.md", "extensions/telemetry.test.ts", "extensions/telemetry.ts"]);
|
|
855
|
+
assert.deepEqual(h.jjCalls, []);
|
|
856
|
+
});
|
|
857
|
+
|
|
729
858
|
test("guard: write to a shipped spec during brainstorm is blocked before any state change; edit passes; other phases pass", async () => {
|
|
730
859
|
const h = await boundInShip();
|
|
731
860
|
await h.bash("s1", "git push");
|
package/extensions/telemetry.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { checkoutOf } from "./lib/checkout.ts";
|
|
19
19
|
import { CONTEXT_DRAFT_MARKER } from "./lib/phase-tracker-helpers.ts";
|
|
20
20
|
import { sha256 } from "./lib/plan-check.ts";
|
|
21
|
-
import { SUPERSEDED_BY_RE, aggregateNumstat, isPlanPath, isSpecPath, isSupersededByBanner, matchDiscardStatement, matchShipStatement, matchTestStatement, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
|
|
21
|
+
import { SUPERSEDED_BY_RE, aggregateNumstat, isPlanPath, isSpecPath, isSupersededByBanner, matchDiscardStatement, matchShipStatement, matchTestStatement, parsePatchNumstat, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
|
|
22
22
|
import { COUNTED_USER_PHASES, REVIEWER_AGENTS, countFindings, countOpenGaps, hasReopen, insertedText, planTotals, textOf, usageToTokens } from "./lib/telemetry-collect.ts";
|
|
23
23
|
import { addTokens, capEvents, compact, currentPhase, derive, diffPhases, emptyAccumulators, emptyPhases, foldAccumulators, implementAutoCompletes, liveShipEvent, newRecord, parseRecord, serializeRecord, type Accumulators, type BaseEvent, type PhaseAcc, type PhaseKey, type PhaseMap, type TelemetryEvent, type TelemetryRecord, type Tokens } from "./lib/telemetry-record.ts";
|
|
24
24
|
import { BASE_REFS, guardReason, modifiedFilesFrom } from "./lib/telemetry-ship.ts";
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
export interface Deps {
|
|
49
49
|
fs: FsPort;
|
|
50
50
|
git: (args: string[], cwd: string) => Promise<GitResult>;
|
|
51
|
-
//
|
|
51
|
+
// jj runner; production uses realJj for both checkout detection and the ship-time diff.
|
|
52
52
|
jj?: (args: string[], cwd: string) => GitResult | Promise<GitResult>;
|
|
53
53
|
now: () => string;
|
|
54
54
|
settings: (cwd: string) => SettingsSnapshot;
|
|
@@ -78,6 +78,16 @@
|
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
// jj diff --git returns full patches; execFile's default buffer is too small.
|
|
82
|
+
// On ENOENT stderr is empty, so preserve the error message.
|
|
83
|
+
export const realJj = (args: string[], cwd: string): Promise<GitResult> =>
|
|
84
|
+
new Promise((res) => {
|
|
85
|
+
execFile("jj", args, { cwd, timeout: 10_000, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
86
|
+
const code = err ? (typeof (err as { code?: unknown }).code === "number" ? ((err as { code: number }).code) : 1) : 0;
|
|
87
|
+
res({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "").trim() || String(err?.message ?? "") });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
81
91
|
const isoNow = (): string => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
82
92
|
|
|
83
93
|
const readPkgVersion = (p: string): string | undefined => {
|
|
@@ -115,7 +125,7 @@
|
|
|
115
125
|
return { telemetry, errors, agentOverrides, versions: realVersions(), testCommands };
|
|
116
126
|
}
|
|
117
127
|
|
|
118
|
-
export const realDeps: Deps = { fs: realFs, git: realGit, now: isoNow, settings: realSettings };
|
|
128
|
+
export const realDeps: Deps = { fs: realFs, git: realGit, jj: realJj, now: isoNow, settings: realSettings };
|
|
119
129
|
|
|
120
130
|
// ---- replay ------------------------------------------------------------------------
|
|
121
131
|
|
|
@@ -635,6 +645,38 @@
|
|
|
635
645
|
let pendingShip: { id: string; option: "squash" | "pr" | "discard" } | undefined;
|
|
636
646
|
|
|
637
647
|
const computeDiff = async (snap: SettingsSnapshot) => {
|
|
648
|
+
if (checkoutVia === "jj") await computeJjDiff(snap);
|
|
649
|
+
else await computeGitDiff(snap);
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
const JJ_MAINLINE = "coalesce(trunk() ~ root(), present(main), present(master))";
|
|
653
|
+
|
|
654
|
+
const computeJjDiff = async (snap: SettingsSnapshot) => {
|
|
655
|
+
if (!record || !toplevel) return;
|
|
656
|
+
const jj = deps.jj ?? realJj;
|
|
657
|
+
const failed = (sub: string, r: GitResult) => warn(`diff omitted: jj ${sub} failed: ${r.stderr.trim().split("\n")[0] || "unknown error"}`);
|
|
658
|
+
// Without the ::M guard an empty mainline resolves to @ and counts the full history.
|
|
659
|
+
const baseR = await jj(["--color=never", "log", "-r", `fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`, "--no-graph", "-T", 'commit_id ++ "\\n"'], toplevel);
|
|
660
|
+
if (baseR.code !== 0) return failed("log", baseR);
|
|
661
|
+
const base = baseR.stdout.trim().split("\n")[0].trim();
|
|
662
|
+
if (!base) return warn("diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)");
|
|
663
|
+
const patch = await jj(["--color=never", "--config", "diff.git.show-path-prefix=true", "diff", "--from", base, "--to", "@", "--git"], toplevel);
|
|
664
|
+
if (patch.code !== 0) return failed("diff", patch);
|
|
665
|
+
const rows = parsePatchNumstat(patch.stdout);
|
|
666
|
+
if (!rows) return warn("diff omitted: jj diff unparseable");
|
|
667
|
+
const dir = currentDir.replace(/\/+$/, "");
|
|
668
|
+
// Exclude record-only @ and other telemetry-only revisions from the count.
|
|
669
|
+
const count = await jj(["--color=never", "log", "-r", `(${base}::@ ~ ${base}) & files(~glob:"${dir}/**")`, "--count"], toplevel);
|
|
670
|
+
if (count.code !== 0) return failed("log", count);
|
|
671
|
+
const n = count.stdout.trim();
|
|
672
|
+
if (!/^\d+$/.test(n)) return warn(`diff omitted: jj log --count unparseable: ${n}`);
|
|
673
|
+
const files = modifiedFilesFrom(rows.map((r) => r.path).join("\n"), record.spec, currentDir);
|
|
674
|
+
const numstat = rows.map((r) => `${r.added}\t${r.removed}\t${r.path}`).join("\n");
|
|
675
|
+
record.derived.modified_files = files;
|
|
676
|
+
record.derived.diff = { base, commits: Number(n), buckets: aggregateNumstat(numstat, new Set(files), snap.telemetry.buckets) };
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const computeGitDiff = async (snap: SettingsSnapshot) => {
|
|
638
680
|
if (!record || !toplevel) return;
|
|
639
681
|
let baseRef: string | undefined;
|
|
640
682
|
for (const ref of BASE_REFS) {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gauntlet-resume
|
|
3
|
-
description: Use when a human wants to continue an interrupted gauntlet flow in a fresh session - from a gauntlet-handoff brief (file, pasted, or picked from the default handoff directory)
|
|
3
|
+
description: Use when a human wants to continue an interrupted gauntlet flow in a fresh session - from a gauntlet-handoff brief (file, pasted, or picked from the default handoff directory), from a bare worktree that already holds a spec, or from a spec tracked on main (creates or reuses its worktree via `/skill:using-git-worktrees`). Human-only; the sole resume entry point. Restores phase/plan tracker state through the legal arming sequence, never infers approval.
|
|
4
4
|
disable-model-invocation: true
|
|
5
|
-
argument-hint: "[<brief-file>
|
|
5
|
+
argument-hint: "[<brief-file> | <spec>.md | <worktree-name-or-path>]"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
> **Related skills:** Continues into `/skill:writing-plans`, `/skill:subagent-driven-development`, or `/skill:verification-before-completion` depending on the restored stage. `/skill:brainstorming` is where new ideas go - a free-form prompt is not a resume input.
|
|
@@ -11,48 +11,61 @@ argument-hint: "[<brief-file>] [<worktree-name-or-path>]"
|
|
|
11
11
|
|
|
12
12
|
## Overview
|
|
13
13
|
|
|
14
|
-
Re-enter an interrupted gauntlet flow in a fresh session.
|
|
15
|
-
produced by `/skill:gauntlet-handoff` (grammar in `reference/brief-contract.md`),
|
|
14
|
+
Re-enter an interrupted gauntlet flow in a fresh session. Three inputs: a handoff brief
|
|
15
|
+
produced by `/skill:gauntlet-handoff` (grammar in `reference/brief-contract.md`), a bare
|
|
16
16
|
worktree whose spec/plan artifacts are reconstructed into tracker state
|
|
17
|
-
(`reference/reconstruction.md`). Every check runs
|
|
18
|
-
stop names the offending path, field, or line.
|
|
17
|
+
(`reference/reconstruction.md`), a spec seed (`reference/seed.md`). Every check runs
|
|
18
|
+
before any tracker mutation; every stop names the offending path, field, or line.
|
|
19
19
|
|
|
20
20
|
**Announce at start:** "I'm using the gauntlet-resume skill to continue an interrupted flow."
|
|
21
21
|
|
|
22
22
|
## Boundaries
|
|
23
23
|
|
|
24
24
|
- Reads: anything.
|
|
25
|
-
- Writes: `phase_tracker` / `plan_tracker` state
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
the
|
|
25
|
+
- Writes: `phase_tracker` / `plan_tracker` state after every check passes and every
|
|
26
|
+
human question is answered; on the seed route, what `/skill:using-git-worktrees`
|
|
27
|
+
writes. Never a spec copy, rename, or marker commit.
|
|
28
|
+
- Never: `git worktree add` directly; worktree creation by resume itself only on
|
|
29
|
+
the seed route, only through `/skill:using-git-worktrees`; infer approval from
|
|
30
|
+
artifact presence; restore
|
|
31
|
+
gate history, closure-review evidence, or fix rounds; start a later phase directly
|
|
32
|
+
(that does not arm the flow).
|
|
30
33
|
|
|
31
34
|
## Arguments
|
|
32
35
|
|
|
33
|
-
`/skill:gauntlet-resume [<
|
|
34
|
-
optional pasted text after the command line.
|
|
36
|
+
`/skill:gauntlet-resume [<token>] [<worktree>]` - zero to two positional tokens, plus
|
|
37
|
+
optional pasted text after the command line. Count tokens first; then first match wins.
|
|
38
|
+
Spec dirs resolve per `reference/reconstruction.md` "Candidates" (`flowGuards.specDirs`
|
|
39
|
+
precedence).
|
|
35
40
|
|
|
36
|
-
|
|
|
41
|
+
| Input | Route |
|
|
37
42
|
|---|---|
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
43
|
+
| no tokens, no pasted text | discovery: `<tmpdir>` via `node -p "require('os').tmpdir()"`; candidates are the regular files directly under `<tmpdir>/pi-handoff/` ending `.md` (non-recursive) whose line 1 starts `# Handoff:`; sorted by mtime, newest first; displayed one per line as `<n>. <title line> - <worktree: field value> - <mtime ISO-8601>` with `worktree: unavailable` when the field is absent; the human picks `<n>` (a single candidate is still confirmed); none -> STOP "no handoff briefs found under <tmpdir>/pi-handoff" |
|
|
44
|
+
| pasted text, line 1 starts `# Handoff:` | brief |
|
|
45
|
+
| two tokens, first an existing file with line 1 `# Handoff:` | brief with worktree override: required when the brief's worktree field is `no`/`unavailable`/not a git repo; must equal the brief's worktree after `realpath` otherwise |
|
|
46
|
+
| two tokens, first a spec seed | stop: `a spec seed takes no worktree: <second token>` |
|
|
47
|
+
| one token, existing regular file, line 1 starts `# Handoff:` | brief |
|
|
48
|
+
| one token, existing regular file ending `.md`, `realpath` under `<primary>/<specDir>` | spec seed - `reference/seed.md` |
|
|
49
|
+
| one token, existing regular file, anything else | stop: `not a handoff brief and not under <specDirs>: <path>` |
|
|
50
|
+
| one token, existing directory that is a git worktree root (`git -C <dir> rev-parse --show-toplevel` equals its `realpath`) other than `<primary>` | worktree |
|
|
51
|
+
| one token, missing path (ends `.md` or contains a separator) | stop: `not found: <path>` |
|
|
52
|
+
| one token, bare name | worktree at `<primary>/.worktrees/<name>`; missing or not a git worktree -> entry check 2 stop |
|
|
53
|
+
| anything else | stop: "this is a new idea - run /skill:brainstorming" |
|
|
54
|
+
|
|
55
|
+
A relative token resolves against the session cwd; a bare `name.md` absent there is
|
|
56
|
+
tried at `<primary>/<specDir>/name.md` per resolved spec dir in order, first hit wins.
|
|
57
|
+
`<primary>` is the checkout owning `.worktrees/`:
|
|
58
|
+
`dirname "$(git rev-parse --path-format=absolute --git-common-dir)"` run in the session
|
|
59
|
+
cwd - absolute from any primary subdirectory and from inside a linked worktree
|
|
60
|
+
(`--show-toplevel` would return the linked worktree there). A pasted brief that needs
|
|
61
|
+
an override uses the file form. A brief then runs entry checks 1-5, so a stale or
|
|
62
|
+
foreign-repo brief stops there.
|
|
52
63
|
|
|
53
64
|
## Entry checks
|
|
54
65
|
|
|
55
|
-
In order. All before any tracker mutation; entry check 1 is read-only.
|
|
66
|
+
In order. All before any tracker mutation; entry check 1 is read-only. A spec
|
|
67
|
+
seed runs check 1 here, then `reference/seed.md`, which runs check 3 itself;
|
|
68
|
+
checks 2, 4, 5 do not apply.
|
|
56
69
|
|
|
57
70
|
1. **Idle session.** `phase_tracker({ action: "status" })`. Any phase not pending ->
|
|
58
71
|
stop: "session already carries flow state - reset is your call".
|
|
@@ -91,6 +104,7 @@ In order. All before any tracker mutation; entry check 1 is read-only.
|
|
|
91
104
|
| brief without process state, `worktree: no` | invoke `/skill:brainstorming` with `## Intent` as the idea, in the current directory; resume creates no worktree - brainstorming's own Worktree First applies (a plain handoff is a new flow) |
|
|
92
105
|
| brief without process state, worktree present | `reference/reconstruction.md`, with `## Intent` and `## Decisions` carried into every confirmation prompt |
|
|
93
106
|
| bare worktree | `reference/reconstruction.md`; prompts state that no brief context is available (never invent Intent/Decisions) |
|
|
107
|
+
| spec seed | `reference/seed.md` |
|
|
94
108
|
|
|
95
109
|
**Hotfix route.** `skills/chase-bug/hotfix.md` consumes only a hotfix record at
|
|
96
110
|
`$TMPDIR/hotfix-<slug>.md`. If `## Decisions` or `## Intent` names an existing record
|
|
@@ -113,11 +127,11 @@ owning skill **without** its reset-bearing entry:
|
|
|
113
127
|
|
|
114
128
|
## Red flags — STOP
|
|
115
129
|
|
|
116
|
-
- Any `phase_tracker` or `plan_tracker` mutation before entry checks
|
|
117
|
-
human question is answered.
|
|
130
|
+
- Any `phase_tracker` or `plan_tracker` mutation before the route's entry checks pass and
|
|
131
|
+
every human question is answered.
|
|
118
132
|
- `start <phase>` for anything other than brainstorm as the first arming call.
|
|
119
133
|
- `complete` on a prior phase during restore (priors are `skip`ped with a `resume:` reason).
|
|
120
|
-
- Running `git worktree add
|
|
134
|
+
- Running `git worktree add` directly, or `cd`-ing to "fix" a cwd mismatch.
|
|
121
135
|
- Accepting a free-form prompt as a brief.
|
|
122
136
|
|
|
123
137
|
## Project overrides
|
|
@@ -130,8 +130,8 @@ Per-stage call table, keyed by the brief's active phase (`→`). `R` is the reas
|
|
|
130
130
|
|
|
131
131
|
`planPath`: the brief carries none. Resolve as reconstruction does
|
|
132
132
|
(`reconstruction.md`, "Candidates", including its `flowGuards.specDirs` resolution): the
|
|
133
|
-
single
|
|
134
|
-
|
|
133
|
+
single plan added after base, paired with `<specDir>/<same basename>` whether that
|
|
134
|
+
spec was added after base or tracked from base (`reconstruction.md` "Candidates");
|
|
135
135
|
resolve the selected plan to an absolute path under the worktree before `plan_check`.
|
|
136
136
|
Zero pairs -> stop; more than one -> human picks.
|
|
137
137
|
|
|
@@ -23,6 +23,8 @@ for a base ref before reading any artifact.
|
|
|
23
23
|
|
|
24
24
|
## Candidates
|
|
25
25
|
|
|
26
|
+
A pinned spec (seed route, `seed.md`) skips this section.
|
|
27
|
+
|
|
26
28
|
Spec/plan directories: `piGauntlet.flowGuards.specDirs` plus each one's sibling `plans` directory. Resolve with the precedence `doc/configuration.md` documents for every `piGauntlet.*` key: the session cwd's `.pi/settings.json` if it defines `flowGuards`,
|
|
27
29
|
else the active pi profile's `settings.json`, else the default `["doc/specs"]` (sibling
|
|
28
30
|
`doc/plans`). An empty array is the default. `<dirs>` below is that resolved list,
|
|
@@ -36,15 +38,17 @@ git -C <worktree> ls-files --others --exclude-standard -- <dirs>
|
|
|
36
38
|
Candidates are files under those directories added after base, plus untracked files
|
|
37
39
|
there. Paths returned by these commands are relative to `<worktree>`; resolve them to
|
|
38
40
|
absolute paths under `<worktree>` before reading artifacts, showing paths in prompts,
|
|
39
|
-
or calling `plan_check`. A
|
|
40
|
-
|
|
41
|
+
or calling `plan_check`. A candidate plan pairs with
|
|
42
|
+
`<worktree>/<specDir>/<same basename>` whether that spec is a candidate or tracked
|
|
43
|
+
from base; count a base-tracked spec so paired as a spec in the route table below.
|
|
44
|
+
The plan commit is the first
|
|
41
45
|
post-base commit that added the plan file: `git -C <worktree> log --diff-filter=A
|
|
42
46
|
--format=%H --reverse <base>..HEAD -- <plan>`, first line. An uncommitted plan has no
|
|
43
47
|
plan commit; treat every task as `pending`.
|
|
44
48
|
|
|
45
49
|
| Candidates | Route |
|
|
46
50
|
|---|---|
|
|
47
|
-
| no spec | stop; offer `/skill:brainstorming`; no tracker call |
|
|
51
|
+
| no spec | stop; offer `/skill:brainstorming` or pass the spec: `/skill:gauntlet-resume <specDir>/<name>.md`; no tracker call |
|
|
48
52
|
| more than one spec | the human picks one, then continue below with that spec |
|
|
49
53
|
| one spec, no plan | "Spec without plan" |
|
|
50
54
|
| one spec with plan | "Spec with plan" |
|
|
@@ -71,6 +75,9 @@ Show, and ask the human to confirm or edit both in one reply:
|
|
|
71
75
|
Uncommitted plan (no
|
|
72
76
|
plan commit): skip this query entirely - there is no range to search - and show
|
|
73
77
|
"plan uncommitted; no task evidence" in its place; every task is proposed `pending`.
|
|
78
|
+
Plan commit lookup empty while `git -C <worktree> cat-file -e <base>:<plan>` exits 0
|
|
79
|
+
(plan at or before base): skip the log query, show
|
|
80
|
+
`plan predates base; no post-base task evidence`, every task proposed `pending`.
|
|
74
81
|
2. Uncommitted files: `git -C <worktree> status --porcelain`.
|
|
75
82
|
3. Proposed task statuses: `complete` iff at least one matching commit, else `pending`.
|
|
76
83
|
4. Proposed stage: `implement` if any task is `pending`, else `verify`.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Spec seed (gauntlet-resume supplementary)
|
|
2
|
+
|
|
3
|
+
Consumed only by `SKILL.md` in this directory, for a one-token `.md` argument classified
|
|
4
|
+
as a spec seed. `<seed>` is its `realpath`; `<rel>` is `<seed>` relative to `<primary>`.
|
|
5
|
+
Entry check 1 (idle session) runs first. Then, in order; every stop names the path.
|
|
6
|
+
|
|
7
|
+
1. **Primary session.** `git rev-parse --git-dir --git-common-dir` in the session cwd
|
|
8
|
+
prints two paths; their `realpath`s must agree. Otherwise stop:
|
|
9
|
+
`seed from the primary checkout`.
|
|
10
|
+
2. **Tracked.** `git -C <primary> ls-files --error-unmatch <rel>` exits 0; otherwise
|
|
11
|
+
stop: `not tracked: <seed> - commit it first`. Line 1 of `<seed>` equal to
|
|
12
|
+
`# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing` -> stop:
|
|
13
|
+
`context draft, not a spec: <seed> - finish brainstorming`.
|
|
14
|
+
3. **Not shipped.** Read `<primary>/<telemetry.dir>/<rel with .md -> .yaml>`
|
|
15
|
+
(`piGauntlet.telemetry.dir`, default `.pi/gauntlet/telemetry`). A `shipped_at:` or
|
|
16
|
+
`abandoned_at:` line carrying a value -> stop:
|
|
17
|
+
`spec already shipped at <ts> - write a superseding spec`. No record, or
|
|
18
|
+
`in_progress`: continue; the extension folds this run into the record on
|
|
19
|
+
`plan_check` pass.
|
|
20
|
+
4. **Name.** `<name>` = basename of `<rel>` minus `.md`, minus a leading `YYYY-MM-DD-`:
|
|
21
|
+
`2026-09-17-gh-31-gauntlet-resume.md` -> `gh-31-gauntlet-resume`. Same shape as
|
|
22
|
+
brainstorming's Filename Convention slug; wrappers receive it as `<name>`.
|
|
23
|
+
5. **Reuse or create.** `git -C <primary> worktree list --porcelain`. A block whose
|
|
24
|
+
`branch` line is `refs/heads/<name>` or ends in `/<name>` -> `<full-path>` is that
|
|
25
|
+
block's `worktree` line; skip creation (layout-agnostic: wrapper sibling dirs,
|
|
26
|
+
wrapper branch prefixes such as `<user>/<name>`, and `.worktrees/` alike); more than
|
|
27
|
+
one match -> the human picks. No block -> invoke `/skill:using-git-worktrees` with branch `<name>` and carry its
|
|
28
|
+
`Worktree ready at <full-path>`; its Step 1a applies project `## using-git-worktrees`
|
|
29
|
+
overrides, its Step 3 runs the clean-base check and dirty ask. Wrapper arguments
|
|
30
|
+
beyond `<name>` are that skill's ask, never inferred here. Any stop it raises is this
|
|
31
|
+
route's stop, verbatim. Never invent a second name.
|
|
32
|
+
6. **Same repository.** Entry check 3 against `<full-path>`.
|
|
33
|
+
7. **Pinned copy.** `<full-path>/<rel>` exists, is readable, and its line 1 is not the
|
|
34
|
+
context-draft marker; otherwise stop naming that path. Never copy `<seed>` in.
|
|
35
|
+
8. **Continue** in `reconstruction.md` with the spec pinned at `<full-path>/<rel>`,
|
|
36
|
+
skipping Candidates: `<full-path>/<sibling plans dir>/<basename of rel>` exists ->
|
|
37
|
+
"Spec with plan"; otherwise "Spec without plan". Prompts say "no brief context
|
|
38
|
+
available".
|
|
39
|
+
|
|
40
|
+
The seed route writes no spec copy, rename, or marker commit: the spec stays where main
|
|
41
|
+
tracks it, amends ride the squash as ordinary diffs, and `finishing-a-development-branch`
|
|
42
|
+
strips only the plan. `gauntlet-telemetry-salvage` prints `no spec on branch` for an
|
|
43
|
+
unamended seed throughout the flow (it selects specs from `git diff --name-only
|
|
44
|
+
<base>...HEAD`, which never lists a file tracked at base), so the extension's live ship
|
|
45
|
+
stamp is the only writer of `shipped_at`; the record still binds on `plan_check` pass.
|
|
46
|
+
Before a plan exists beside the spec, re-pass the seed to resume; after, plain `<worktree>` resume and
|
|
47
|
+
`gauntlet-handoff` -> `gauntlet-resume <brief>` restore the flow.
|