pi-gauntlet 5.10.0 → 5.10.2
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 +8 -0
- package/README.md +1 -1
- package/bin/gauntlet-telemetry-salvage.mjs +141 -0
- package/bin/gauntlet-telemetry-salvage.test.mjs +364 -0
- package/package.json +3 -2
- package/skills/brainstorming/SKILL.md +7 -7
- package/skills/finishing-a-development-branch/SKILL.md +39 -15
- package/skills/gatekeep-pr/SKILL.md +29 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v5.10.2 - 2026-09-18
|
|
4
|
+
|
|
5
|
+
- The telemetry record is a deliverable: brainstorming, `finishing-a-development-branch`, and `gatekeep-pr` name it beside the spec, and a new shipped `gauntlet-telemetry-salvage` bin restores a record that a plan strip or a fix commit deleted (from the deleting commit's parent, as one `telemetry: ` commit, exit 0 for valid invocations, never pushes). Finishing strips the plan on the feature branch before the Option 1 squash; gatekeep-pr checks at assessment, restores after each fix wave, and restores as the first step of a merge course. CI pins the bin, its test, its tarball imports, and the rule's presence in the three skills.
|
|
6
|
+
|
|
7
|
+
## v5.10.1 - 2026-09-18
|
|
8
|
+
|
|
9
|
+
- `skills/brainstorming/SKILL.md`: restore precision the 5.10.0 dedup dropped - first-feature round 1 lists the concrete decisions (public types/files/routes/identifiers; abstraction location/responsibility/boundary; schema entity names/field types/indexing), doc updates ship in the same commit as the code, the premise note is full sentences and an unverified claim is not a stop, a multi-concern request is decomposed rather than designed as one spec, 300-500 words is a target per round, and the gather-stop red flag links to `gatherer.md`.
|
|
10
|
+
|
|
3
11
|
## v5.10.0 - 2026-09-18
|
|
4
12
|
|
|
5
13
|
- 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.
|
package/README.md
CHANGED
|
@@ -71,7 +71,7 @@ pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
|
|
|
71
71
|
|
|
72
72
|
- **18 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). Five 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-resume` is the only way back into an interrupted flow from a fresh session: it takes a pi-cohort `/handoff` brief (file or pasted) or a bare worktree that already holds a spec, restores phase/plan tracker state through the legal arming sequence (`start brainstorm`, `skip` with `resume:` reasons, `plan_check` before implement-or-later), never creates a worktree, and never infers approval from artifacts - run it with `/skill:gauntlet-resume [<brief>] [<worktree>]`.
|
|
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
|
-
- **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) 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.
|
|
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 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
|
|
|
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
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Restore a gauntlet telemetry record that a plan strip or a gate fix commit removed
|
|
3
|
+
// from the branch. The recorder pathspec-commits the record at every checkpoint, so the
|
|
4
|
+
// newest copy is always in the deleting commit's parent. Exit 0 on every outcome: the
|
|
5
|
+
// callers are ship paths and this script never blocks one.
|
|
6
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { isAbsolute, join } from "node:path";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
import { mergeGauntlet, resolveTelemetry } from "../extensions/lib/gauntlet-settings.ts";
|
|
12
|
+
import { isSpecPath, recordPathFor } from "../extensions/lib/telemetry-paths.ts";
|
|
13
|
+
import { BASE_REFS } from "../extensions/lib/telemetry-ship.ts";
|
|
14
|
+
|
|
15
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
16
|
+
const COMMIT_TIMEOUT_MS = Number(process.env.GAUNTLET_SALVAGE_COMMIT_TIMEOUT_MS) || 30_000;
|
|
17
|
+
const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
|
|
18
|
+
|
|
19
|
+
const usage = () => {
|
|
20
|
+
process.stderr.write("usage: gauntlet-telemetry-salvage --worktree <abs path> [--base <ref>] [--dir <telemetry dir>] [--check]\n");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const opts = { worktree: undefined, base: undefined, dir: undefined, check: false };
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i];
|
|
28
|
+
if (a === "--worktree" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.worktree = argv[++i];
|
|
29
|
+
else if (a === "--base" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.base = argv[++i];
|
|
30
|
+
else if (a === "--dir" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.dir = argv[++i];
|
|
31
|
+
else if (a === "--check") opts.check = true;
|
|
32
|
+
else usage();
|
|
33
|
+
}
|
|
34
|
+
if (!opts.worktree || !isAbsolute(opts.worktree)) usage();
|
|
35
|
+
return opts;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function git(cwd, args, timeout = GIT_TIMEOUT_MS) {
|
|
39
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: GIT_ENV, timeout });
|
|
40
|
+
const timedOut = r.error?.code === "ETIMEDOUT";
|
|
41
|
+
const stderr = timedOut ? "timed out" : (r.stderr ?? "").trim().split("\n")[0] || r.error?.message || `git exited ${r.status}`;
|
|
42
|
+
return { ok: !timedOut && r.status === 0, stdout: (r.stdout ?? "").trim(), stderr, timedOut };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readLayer(file) {
|
|
46
|
+
if (!existsSync(file)) return {};
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
49
|
+
} catch (e) {
|
|
50
|
+
process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer\n`);
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Same two layers the recorder reads (gauntlet-settings-loader.ts), without the pi import.
|
|
56
|
+
function telemetrySettings(root) {
|
|
57
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
58
|
+
const preset = readLayer(join(agentDir, "settings.json"));
|
|
59
|
+
const repo = readLayer(join(root, ".pi", "settings.json"));
|
|
60
|
+
return resolveTelemetry(mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveBase(root, explicit) {
|
|
64
|
+
for (const ref of explicit ? [explicit] : BASE_REFS) {
|
|
65
|
+
if (git(root, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).ok) return ref;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function salvage(root, rec, base, check) {
|
|
71
|
+
const presence = git(root, ["cat-file", "-e", `HEAD:${rec}`]);
|
|
72
|
+
if (presence.timedOut) return `restore failed ${rec}: timed out`;
|
|
73
|
+
if (presence.ok) return `present ${rec}`;
|
|
74
|
+
const history = git(root, ["log", `${base}..HEAD`, "--grep=^telemetry: ", "-1", "--format=%H"]);
|
|
75
|
+
if (history.timedOut) return `restore failed ${rec}: timed out`;
|
|
76
|
+
if (!history.stdout) return `no telemetry run ${rec}`;
|
|
77
|
+
const deletion = git(root, ["log", "-1", "--diff-filter=D", "--format=%H", `${base}..HEAD`, "--", rec]);
|
|
78
|
+
if (deletion.timedOut) return `restore failed ${rec}: timed out`;
|
|
79
|
+
const del = deletion.stdout;
|
|
80
|
+
if (!del) return `never written ${rec}`;
|
|
81
|
+
if (check) return `stripped ${rec} in ${del}`;
|
|
82
|
+
|
|
83
|
+
const abs = join(root, rec);
|
|
84
|
+
const onDisk = existsSync(abs);
|
|
85
|
+
const preStaged = git(root, ["ls-files", "--", rec]).stdout !== "";
|
|
86
|
+
// A pre-staged record is user-owned and can only be committed when the worktree matches it.
|
|
87
|
+
if (preStaged && (!onDisk || !git(root, ["diff", "--quiet", "--", rec]).ok)) {
|
|
88
|
+
return `restore failed ${rec}: staged copy differs from worktree`;
|
|
89
|
+
}
|
|
90
|
+
let created = false;
|
|
91
|
+
let stagedByScript = false;
|
|
92
|
+
const rollback = () => {
|
|
93
|
+
if (stagedByScript) git(root, ["reset", "-q", "--", rec]);
|
|
94
|
+
if (created) rmSync(abs, { force: true });
|
|
95
|
+
};
|
|
96
|
+
const failed = (reason) => {
|
|
97
|
+
rollback();
|
|
98
|
+
return `restore failed ${rec}: ${reason}`;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (!onDisk) {
|
|
102
|
+
const co = git(root, ["checkout", `${del}^`, "--", rec]);
|
|
103
|
+
if (!co.ok) return `restore failed ${rec}: ${co.stderr}`;
|
|
104
|
+
created = true;
|
|
105
|
+
stagedByScript = true;
|
|
106
|
+
} else if (!preStaged) {
|
|
107
|
+
const add = git(root, ["add", "-f", "--", rec]);
|
|
108
|
+
if (!add.ok) return failed(add.stderr);
|
|
109
|
+
stagedByScript = true;
|
|
110
|
+
}
|
|
111
|
+
const commit = git(root, ["commit", "-q", "-m", `telemetry: restore record stripped in ${del.slice(0, 10)}`, "--", rec], COMMIT_TIMEOUT_MS);
|
|
112
|
+
if (!commit.ok) return failed(commit.stderr);
|
|
113
|
+
return `restored ${rec} from ${del}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function main() {
|
|
117
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
118
|
+
const override = opts.dir === undefined
|
|
119
|
+
? undefined
|
|
120
|
+
: resolveTelemetry({ telemetry: { enabled: true, dir: opts.dir } });
|
|
121
|
+
if (override?.warning) usage();
|
|
122
|
+
const top = git(opts.worktree, ["rev-parse", "--show-toplevel"]);
|
|
123
|
+
if (!existsSync(opts.worktree) || !top.ok) {
|
|
124
|
+
process.stderr.write(`not a git worktree: ${opts.worktree}\n`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const root = top.stdout;
|
|
128
|
+
const telemetry = telemetrySettings(root);
|
|
129
|
+
if (telemetry.warning) process.stderr.write(`warning: ${telemetry.warning}\n`);
|
|
130
|
+
if (!telemetry.enabled) return console.log("telemetry disabled");
|
|
131
|
+
const dir = override?.dir ?? telemetry.dir;
|
|
132
|
+
const base = resolveBase(root, opts.base);
|
|
133
|
+
if (!base) return console.log("no base ref");
|
|
134
|
+
const diff = git(root, ["diff", "--name-only", `${base}...HEAD`]);
|
|
135
|
+
if (!diff.ok) return console.log("no base ref");
|
|
136
|
+
const specs = diff.stdout.split("\n").filter((f) => f && isSpecPath(f) && git(root, ["cat-file", "-e", `HEAD:${f}`]).ok);
|
|
137
|
+
if (specs.length === 0) return console.log("no spec on branch");
|
|
138
|
+
for (const spec of specs) console.log(salvage(root, recordPathFor(dir, spec), base, opts.check));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
main();
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, chmodSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-telemetry-salvage.mjs");
|
|
10
|
+
const SPEC = "doc/specs/x.md";
|
|
11
|
+
const REC = ".pi/gauntlet/telemetry/doc/specs/x.yaml";
|
|
12
|
+
const RECORD_V1 = "schema: 1\nspec: doc/specs/x.md\nstatus: in_progress\n";
|
|
13
|
+
const RECORD_V2 = "schema: 1\nspec: doc/specs/x.md\nstatus: shipped\n";
|
|
14
|
+
|
|
15
|
+
const git = (cwd, args, env = {}) =>
|
|
16
|
+
spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], {
|
|
17
|
+
cwd, encoding: "utf8", env: { ...process.env, ...env },
|
|
18
|
+
});
|
|
19
|
+
const out = (cwd, args) => git(cwd, args).stdout.trim();
|
|
20
|
+
const write = (root, rel, text) => {
|
|
21
|
+
mkdirSync(join(root, dirname(rel)), { recursive: true });
|
|
22
|
+
writeFileSync(join(root, rel), text);
|
|
23
|
+
};
|
|
24
|
+
const commit = (root, msg, ...paths) => {
|
|
25
|
+
git(root, ["add", "-f", "--", ...paths]);
|
|
26
|
+
git(root, ["commit", "-q", "-m", msg, "--", ...paths]);
|
|
27
|
+
return out(root, ["rev-parse", "HEAD"]);
|
|
28
|
+
};
|
|
29
|
+
const rmCommit = (root, msg, ...paths) => {
|
|
30
|
+
git(root, ["rm", "-q", "--", ...paths]);
|
|
31
|
+
git(root, ["commit", "-q", "-m", msg, "--", ...paths]);
|
|
32
|
+
return out(root, ["rev-parse", "HEAD"]);
|
|
33
|
+
};
|
|
34
|
+
// main: README only. feat: spec (+ record when withRecord).
|
|
35
|
+
const repo = ({ withRecord = true } = {}) => {
|
|
36
|
+
const root = mkdtempSync(join(tmpdir(), "gts-"));
|
|
37
|
+
git(root, ["init", "-q", "-b", "main"]);
|
|
38
|
+
write(root, "README.md", "# fixture\n");
|
|
39
|
+
commit(root, "init", "README.md");
|
|
40
|
+
git(root, ["checkout", "-q", "-b", "feat"]);
|
|
41
|
+
write(root, SPEC, "# Spec X\n\n**Goal:** g.\n");
|
|
42
|
+
commit(root, "Add spec", SPEC);
|
|
43
|
+
if (withRecord) {
|
|
44
|
+
write(root, REC, RECORD_V1);
|
|
45
|
+
commit(root, "telemetry: doc/specs/x.md", REC);
|
|
46
|
+
}
|
|
47
|
+
return root;
|
|
48
|
+
};
|
|
49
|
+
// An empty preset dir by default: the developer's real ~/.pi/agent/settings.json must never leak into fixtures.
|
|
50
|
+
const EMPTY_AGENT = mkdtempSync(join(tmpdir(), "gts-empty-agent-"));
|
|
51
|
+
process.on("exit", () => rmSync(EMPTY_AGENT, { recursive: true, force: true }));
|
|
52
|
+
// The CLI commits with whatever identity git resolves, like the recorder; CI runners have none.
|
|
53
|
+
const IDENTITY = { GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
|
|
54
|
+
const invoke = (args, env = {}, options = {}) => {
|
|
55
|
+
const r = spawnSync(process.execPath, [CLI, ...args], {
|
|
56
|
+
encoding: "utf8", env: { ...process.env, ...IDENTITY, PI_CODING_AGENT_DIR: EMPTY_AGENT, ...env }, timeout: options.timeout,
|
|
57
|
+
});
|
|
58
|
+
return { status: r.status, stdout: r.stdout.trim(), stderr: r.stderr.trim(), lines: r.stdout.split("\n").filter(Boolean) };
|
|
59
|
+
};
|
|
60
|
+
const run = (root, args = [], env = {}) => invoke(["--worktree", root, "--base", "main", ...args], env);
|
|
61
|
+
const head = (root) => out(root, ["rev-parse", "HEAD"]);
|
|
62
|
+
const commitCount = (root) => Number(out(root, ["rev-list", "--count", "HEAD"]));
|
|
63
|
+
const headSubject = (root) => out(root, ["log", "-1", "--format=%s"]);
|
|
64
|
+
const headFiles = (root) => out(root, ["show", "--name-only", "--format=", "HEAD"]).split("\n").filter(Boolean);
|
|
65
|
+
const porcelain = (root) => out(root, ["status", "--porcelain"]);
|
|
66
|
+
const cleanup = (t, ...roots) => t.after(() => roots.forEach((r) => rmSync(r, { recursive: true, force: true })));
|
|
67
|
+
|
|
68
|
+
test("present: record in HEAD tree -> present, no commit", (t) => {
|
|
69
|
+
const root = repo(); cleanup(t, root);
|
|
70
|
+
const before = head(root);
|
|
71
|
+
const r = run(root);
|
|
72
|
+
assert.equal(r.status, 0, r.stderr);
|
|
73
|
+
assert.deepEqual(r.lines, [`present ${REC}`]);
|
|
74
|
+
assert.equal(head(root), before);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("deleted once -> restored from <sha>, byte-identical, one telemetry: commit touching only the record", (t) => {
|
|
78
|
+
const root = repo(); cleanup(t, root);
|
|
79
|
+
const del = rmCommit(root, "Strip ephemeral plan and telemetry scaffolding", REC);
|
|
80
|
+
const n = commitCount(root);
|
|
81
|
+
const r = run(root);
|
|
82
|
+
assert.equal(r.status, 0, r.stderr);
|
|
83
|
+
assert.deepEqual(r.lines, [`restored ${REC} from ${del}`]);
|
|
84
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), RECORD_V1);
|
|
85
|
+
assert.equal(commitCount(root), n + 1);
|
|
86
|
+
assert.equal(headSubject(root), `telemetry: restore record stripped in ${del.slice(0, 10)}`);
|
|
87
|
+
assert.deepEqual(headFiles(root), [REC]);
|
|
88
|
+
assert.equal(porcelain(root), "");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("deleted twice -> restores the newest (second) content", (t) => {
|
|
92
|
+
const root = repo(); cleanup(t, root);
|
|
93
|
+
rmCommit(root, "strip 1", REC);
|
|
94
|
+
write(root, REC, RECORD_V2);
|
|
95
|
+
commit(root, "telemetry: doc/specs/x.md", REC);
|
|
96
|
+
const del2 = rmCommit(root, "strip 2", REC);
|
|
97
|
+
const r = run(root);
|
|
98
|
+
assert.deepEqual(r.lines, [`restored ${REC} from ${del2}`]);
|
|
99
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), RECORD_V2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("deleted, then rewritten on disk untracked -> on-disk copy committed, not DEL^", (t) => {
|
|
103
|
+
const root = repo(); cleanup(t, root);
|
|
104
|
+
const del = rmCommit(root, "strip", REC);
|
|
105
|
+
write(root, REC, RECORD_V2);
|
|
106
|
+
const r = run(root);
|
|
107
|
+
assert.deepEqual(r.lines, [`restored ${REC} from ${del}`]);
|
|
108
|
+
assert.equal(out(root, ["show", `HEAD:${REC}`]), RECORD_V2.trim());
|
|
109
|
+
assert.equal(porcelain(root), "");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("deleted and staged again uncommitted -> not present; staged copy committed", (t) => {
|
|
113
|
+
const root = repo(); cleanup(t, root);
|
|
114
|
+
const del = rmCommit(root, "strip", REC);
|
|
115
|
+
write(root, REC, RECORD_V2);
|
|
116
|
+
git(root, ["add", "-f", "--", REC]);
|
|
117
|
+
const r = run(root);
|
|
118
|
+
assert.deepEqual(r.lines, [`restored ${REC} from ${del}`]);
|
|
119
|
+
assert.equal(out(root, ["show", `HEAD:${REC}`]), RECORD_V2.trim());
|
|
120
|
+
assert.equal(porcelain(root), "");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("pre-staged record missing from worktree -> failure preserves index and HEAD", (t) => {
|
|
124
|
+
const root = repo(); cleanup(t, root);
|
|
125
|
+
rmCommit(root, "strip", REC);
|
|
126
|
+
write(root, REC, RECORD_V2);
|
|
127
|
+
git(root, ["add", "-f", "--", REC]);
|
|
128
|
+
rmSync(join(root, REC));
|
|
129
|
+
const before = head(root);
|
|
130
|
+
const r = run(root);
|
|
131
|
+
assert.equal(r.stdout, `restore failed ${REC}: staged copy differs from worktree`);
|
|
132
|
+
assert.equal(out(root, ["show", `:${REC}`]), RECORD_V2.trim());
|
|
133
|
+
assert.equal(head(root), before);
|
|
134
|
+
assert.equal(existsSync(join(root, REC)), false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("pre-staged record differing from worktree -> failure preserves both copies and HEAD", (t) => {
|
|
138
|
+
const root = repo(); cleanup(t, root);
|
|
139
|
+
rmCommit(root, "strip", REC);
|
|
140
|
+
write(root, REC, RECORD_V2);
|
|
141
|
+
git(root, ["add", "-f", "--", REC]);
|
|
142
|
+
write(root, REC, RECORD_V1);
|
|
143
|
+
const before = head(root);
|
|
144
|
+
const r = run(root);
|
|
145
|
+
assert.equal(r.stdout, `restore failed ${REC}: staged copy differs from worktree`);
|
|
146
|
+
assert.equal(out(root, ["show", `:${REC}`]), RECORD_V2.trim());
|
|
147
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), RECORD_V1);
|
|
148
|
+
assert.equal(head(root), before);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("deleted in its introducing commit (amended away) -> nothing to restore, no file, clean index", (t) => {
|
|
152
|
+
const root = repo(); cleanup(t, root);
|
|
153
|
+
// The record's only commit is rewritten without it: git records no D entry whose parent lacks the blob,
|
|
154
|
+
// so the spec's "introducing commit" limitation surfaces as `never written` and the script leaves nothing behind.
|
|
155
|
+
git(root, ["rm", "-q", "--", REC]);
|
|
156
|
+
git(root, ["commit", "-q", "--amend", "--allow-empty", "-m", "telemetry: doc/specs/x.md"]);
|
|
157
|
+
const before = head(root);
|
|
158
|
+
const r = run(root);
|
|
159
|
+
assert.equal(r.status, 0, r.stderr);
|
|
160
|
+
assert.deepEqual(r.lines, [`never written ${REC}`]);
|
|
161
|
+
assert.equal(head(root), before);
|
|
162
|
+
assert.equal(existsSync(join(root, REC)), false);
|
|
163
|
+
assert.equal(porcelain(root), "");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("no telemetry: commit on branch -> no telemetry run, no commit", (t) => {
|
|
167
|
+
const root = repo({ withRecord: false }); cleanup(t, root);
|
|
168
|
+
const n = commitCount(root);
|
|
169
|
+
const r = run(root);
|
|
170
|
+
assert.deepEqual(r.lines, [`no telemetry run ${REC}`]);
|
|
171
|
+
assert.equal(commitCount(root), n);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("telemetry: commit for another path, no deletion of this one -> never written", (t) => {
|
|
175
|
+
const root = repo({ withRecord: false }); cleanup(t, root);
|
|
176
|
+
write(root, ".pi/gauntlet/telemetry/doc/specs/other.yaml", "x\n");
|
|
177
|
+
commit(root, "telemetry: doc/specs/other.md", ".pi/gauntlet/telemetry/doc/specs/other.yaml");
|
|
178
|
+
const r = run(root);
|
|
179
|
+
assert.deepEqual(r.lines, [`never written ${REC}`]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("--check on a stripped record -> stripped ... in <sha>, no commit, no file", (t) => {
|
|
183
|
+
const root = repo(); cleanup(t, root);
|
|
184
|
+
const del = rmCommit(root, "strip", REC);
|
|
185
|
+
const before = head(root);
|
|
186
|
+
const r = run(root, ["--check"]);
|
|
187
|
+
assert.deepEqual(r.lines, [`stripped ${REC} in ${del}`]);
|
|
188
|
+
assert.equal(head(root), before);
|
|
189
|
+
assert.equal(existsSync(join(root, REC)), false);
|
|
190
|
+
assert.equal(porcelain(root), "");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("repo .pi/settings.json telemetry.enabled false -> telemetry disabled only", (t) => {
|
|
194
|
+
const root = repo(); cleanup(t, root);
|
|
195
|
+
write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { telemetry: { enabled: false } } }));
|
|
196
|
+
commit(root, "settings", ".pi/settings.json");
|
|
197
|
+
rmCommit(root, "strip", REC);
|
|
198
|
+
const r = run(root);
|
|
199
|
+
assert.equal(r.stdout, "telemetry disabled");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("preset-only telemetry.dir via PI_CODING_AGENT_DIR resolves; --dir overrides both layers", (t) => {
|
|
203
|
+
const root = repo({ withRecord: false });
|
|
204
|
+
const agent = mkdtempSync(join(tmpdir(), "gts-agent-"));
|
|
205
|
+
cleanup(t, root, agent);
|
|
206
|
+
writeFileSync(join(agent, "settings.json"), JSON.stringify({ piGauntlet: { telemetry: { dir: "custom/dir" } } }));
|
|
207
|
+
const custom = "custom/dir/doc/specs/x.yaml";
|
|
208
|
+
write(root, custom, RECORD_V1);
|
|
209
|
+
commit(root, "telemetry: doc/specs/x.md", custom);
|
|
210
|
+
const del = rmCommit(root, "strip", custom);
|
|
211
|
+
const r = run(root, [], { PI_CODING_AGENT_DIR: agent });
|
|
212
|
+
assert.deepEqual(r.lines, [`restored ${custom} from ${del}`]);
|
|
213
|
+
const r2 = run(root, ["--dir", "elsewhere"], { PI_CODING_AGENT_DIR: agent });
|
|
214
|
+
assert.deepEqual(r2.lines, [`never written elsewhere/doc/specs/x.yaml`]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("rejecting pre-commit hook -> restore failed, index and worktree identical to before", (t) => {
|
|
218
|
+
const root = repo(); cleanup(t, root);
|
|
219
|
+
const del = rmCommit(root, "strip", REC);
|
|
220
|
+
write(root, ".git/hooks/pre-commit", "#!/bin/sh\nexit 1\n");
|
|
221
|
+
chmodSync(join(root, ".git/hooks/pre-commit"), 0o755);
|
|
222
|
+
const before = head(root);
|
|
223
|
+
const r = run(root);
|
|
224
|
+
assert.equal(r.status, 0);
|
|
225
|
+
assert.match(r.stdout, new RegExp(`^restore failed ${REC.replace(/\./g, "\\.")}: `));
|
|
226
|
+
assert.equal(head(root), before);
|
|
227
|
+
assert.equal(existsSync(join(root, REC)), false);
|
|
228
|
+
assert.equal(porcelain(root), "");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("hanging pre-commit hook -> restore failed ... timed out within the bound, state rolled back", (t) => {
|
|
232
|
+
const root = repo(); cleanup(t, root);
|
|
233
|
+
rmCommit(root, "strip", REC);
|
|
234
|
+
write(root, ".git/hooks/pre-commit", "#!/bin/sh\nsleep 60\n");
|
|
235
|
+
chmodSync(join(root, ".git/hooks/pre-commit"), 0o755);
|
|
236
|
+
const before = head(root);
|
|
237
|
+
const r = invoke(["--worktree", root, "--base", "main"], { GAUNTLET_SALVAGE_COMMIT_TIMEOUT_MS: "2000" }, { timeout: 20_000 });
|
|
238
|
+
assert.equal(r.status, 0);
|
|
239
|
+
assert.equal(r.stdout, `restore failed ${REC}: timed out`);
|
|
240
|
+
assert.equal(head(root), before);
|
|
241
|
+
assert.equal(existsSync(join(root, REC)), false);
|
|
242
|
+
assert.equal(porcelain(root), "");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("timed-out record presence query -> restore failed, no mutation", (t) => {
|
|
246
|
+
const root = repo();
|
|
247
|
+
const bin = mkdtempSync(join(tmpdir(), "gts-bin-"));
|
|
248
|
+
cleanup(t, root, bin);
|
|
249
|
+
const wrapper = join(bin, "git");
|
|
250
|
+
writeFileSync(wrapper, `#!/bin/sh
|
|
251
|
+
case "$*" in
|
|
252
|
+
*"cat-file -e HEAD:${REC}"*) sleep 60 ;;
|
|
253
|
+
esac
|
|
254
|
+
PATH="${process.env.PATH.split(":").filter((part) => part !== bin).join(":")}" exec git "$@"
|
|
255
|
+
`);
|
|
256
|
+
chmodSync(wrapper, 0o755);
|
|
257
|
+
const before = head(root);
|
|
258
|
+
const r = invoke(["--worktree", root, "--base", "main"], { PATH: `${bin}:${process.env.PATH}` }, { timeout: 20_000 });
|
|
259
|
+
assert.equal(r.status, 0);
|
|
260
|
+
assert.equal(r.stdout, `restore failed ${REC}: timed out`);
|
|
261
|
+
assert.equal(head(root), before);
|
|
262
|
+
assert.equal(porcelain(root), "");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("two specs on the branch, one record stripped -> two lines, one restore", (t) => {
|
|
266
|
+
const root = repo(); cleanup(t, root);
|
|
267
|
+
const spec2 = "doc/specs/y.md"; const rec2 = ".pi/gauntlet/telemetry/doc/specs/y.yaml";
|
|
268
|
+
write(root, spec2, "# Y\n"); commit(root, "Add y", spec2);
|
|
269
|
+
write(root, rec2, RECORD_V1); commit(root, "telemetry: doc/specs/y.md", rec2);
|
|
270
|
+
const del = rmCommit(root, "strip", REC);
|
|
271
|
+
const n = commitCount(root);
|
|
272
|
+
const r = run(root);
|
|
273
|
+
assert.deepEqual(r.lines.sort(), [`present ${rec2}`, `restored ${REC} from ${del}`].sort());
|
|
274
|
+
assert.equal(commitCount(root), n + 1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("spec deleted on the branch -> no spec on branch", (t) => {
|
|
278
|
+
const root = repo(); cleanup(t, root);
|
|
279
|
+
rmCommit(root, "reap superseded spec", SPEC, REC);
|
|
280
|
+
const r = run(root);
|
|
281
|
+
assert.equal(r.stdout, "no spec on branch");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("unresolvable base without --base -> no base ref; --base main works", (t) => {
|
|
285
|
+
const root = repo(); cleanup(t, root);
|
|
286
|
+
git(root, ["branch", "-m", "main", "trunk"]);
|
|
287
|
+
const bare = invoke(["--worktree", root]);
|
|
288
|
+
assert.equal(bare.status, 0);
|
|
289
|
+
assert.equal(bare.stdout, "no base ref");
|
|
290
|
+
const r = invoke(["--worktree", root, "--base", "trunk"]);
|
|
291
|
+
assert.equal(r.stdout, `present ${REC}`);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("--worktree at a non-git directory -> stderr not a git worktree, empty stdout, exit 0", (t) => {
|
|
295
|
+
const dir = mkdtempSync(join(tmpdir(), "gts-nogit-")); cleanup(t, dir);
|
|
296
|
+
const r = invoke(["--worktree", dir]);
|
|
297
|
+
assert.equal(r.status, 0);
|
|
298
|
+
assert.equal(r.stdout, "");
|
|
299
|
+
assert.match(r.stderr, /^not a git worktree: /);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("relative --worktree -> usage on stderr, exit 1, empty stdout", () => {
|
|
303
|
+
const r = invoke(["--worktree", "relative/path"]);
|
|
304
|
+
assert.equal(r.status, 1);
|
|
305
|
+
assert.equal(r.stdout, "");
|
|
306
|
+
assert.match(r.stderr, /^usage: gauntlet-telemetry-salvage/);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("escaping --dir -> usage on stderr, exit 1", (t) => {
|
|
310
|
+
const root = repo(); cleanup(t, root);
|
|
311
|
+
const r = invoke(["--worktree", root, "--dir", "../outside"]);
|
|
312
|
+
assert.equal(r.status, 1);
|
|
313
|
+
assert.equal(r.stdout, "");
|
|
314
|
+
assert.match(r.stderr, /^usage: gauntlet-telemetry-salvage/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("trailing slash in --dir is normalized", (t) => {
|
|
318
|
+
const root = repo({ withRecord: false }); cleanup(t, root);
|
|
319
|
+
const custom = "custom/dir/doc/specs/x.yaml";
|
|
320
|
+
write(root, custom, RECORD_V1);
|
|
321
|
+
commit(root, "telemetry: doc/specs/x.md", custom);
|
|
322
|
+
const del = rmCommit(root, "strip", custom);
|
|
323
|
+
const r = run(root, ["--dir", "custom/dir/"]);
|
|
324
|
+
assert.deepEqual(r.lines, [`restored ${custom} from ${del}`]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("value flag followed by another flag -> usage, exit 1, no git call", (t) => {
|
|
328
|
+
const root = repo(); cleanup(t, root);
|
|
329
|
+
const before = head(root);
|
|
330
|
+
const r = invoke(["--worktree", root, "--base", "--check"]);
|
|
331
|
+
assert.equal(r.status, 1);
|
|
332
|
+
assert.match(r.stderr, /^usage:/);
|
|
333
|
+
assert.equal(head(root), before);
|
|
334
|
+
assert.equal(invoke(["--worktree"]).status, 1);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("never pushes: remote refs unchanged after a successful restore", (t) => {
|
|
338
|
+
const root = repo();
|
|
339
|
+
const remote = mkdtempSync(join(tmpdir(), "gts-remote-"));
|
|
340
|
+
cleanup(t, root, remote);
|
|
341
|
+
git(remote, ["init", "-q", "--bare"]);
|
|
342
|
+
git(root, ["remote", "add", "origin", remote]);
|
|
343
|
+
git(root, ["push", "-q", "origin", "main", "feat"]);
|
|
344
|
+
const remoteBefore = out(remote, ["for-each-ref"]);
|
|
345
|
+
rmCommit(root, "strip", REC);
|
|
346
|
+
const r = run(root);
|
|
347
|
+
assert.match(r.stdout, /^restored /);
|
|
348
|
+
assert.equal(out(remote, ["for-each-ref"]), remoteBefore);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("finishing Option 1 fixture: strip-on-branch + salvage + merge --squash leaves the record, not the plan, staged", (t) => {
|
|
352
|
+
const root = repo(); cleanup(t, root);
|
|
353
|
+
const plan = "doc/plans/x.md";
|
|
354
|
+
write(root, plan, "# plan\n"); commit(root, "Add plan", plan);
|
|
355
|
+
rmCommit(root, "Strip ephemeral plan and telemetry scaffolding", plan, REC);
|
|
356
|
+
const r = run(root);
|
|
357
|
+
assert.match(r.stdout, /^restored /);
|
|
358
|
+
git(root, ["checkout", "-q", "main"]);
|
|
359
|
+
git(root, ["merge", "--squash", "-q", "feat"]);
|
|
360
|
+
const staged = out(root, ["diff", "--cached", "--name-only"]).split("\n");
|
|
361
|
+
assert.ok(staged.includes(REC), "record staged");
|
|
362
|
+
assert.ok(staged.includes(SPEC), "spec staged");
|
|
363
|
+
assert.ok(!staged.includes(plan), "plan not staged");
|
|
364
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-gauntlet",
|
|
3
|
-
"version": "5.10.
|
|
3
|
+
"version": "5.10.2",
|
|
4
4
|
"description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"type": "module",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"CHANGELOG.md"
|
|
33
33
|
],
|
|
34
34
|
"bin": {
|
|
35
|
-
"gauntlet-spec-index": "bin/gauntlet-spec-index.mjs"
|
|
35
|
+
"gauntlet-spec-index": "bin/gauntlet-spec-index.mjs",
|
|
36
|
+
"gauntlet-telemetry-salvage": "bin/gauntlet-telemetry-salvage.mjs"
|
|
36
37
|
},
|
|
37
38
|
"engines": {
|
|
38
39
|
"node": ">=24.15.0"
|
|
@@ -66,7 +66,7 @@ The spec is the first commit in a dedicated worktree, never a separate commit on
|
|
|
66
66
|
2. Carry its `Worktree ready at <full-path>` value: spec path `<full-path>/doc/specs/<filename>.md`; every dispatch uses that `cwd`; git uses `git -C <full-path>`. Keep the process cwd unchanged.
|
|
67
67
|
3. Write, review, and commit there.
|
|
68
68
|
|
|
69
|
-
Spec, plan, and implementation share this worktree; finishing strips the
|
|
69
|
+
Spec, plan, and implementation share this worktree. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. `/skill:finishing-a-development-branch` strips the plan before landing and restores a stripped record with `gauntlet-telemetry-salvage`. Explicit trivial one-off edits outside this flow need no worktree.
|
|
70
70
|
|
|
71
71
|
## The Process
|
|
72
72
|
|
|
@@ -82,7 +82,7 @@ One spec is default. Test seemingly independent concerns against `../shape-ticke
|
|
|
82
82
|
outcome: <what a user observes once it ships>
|
|
83
83
|
axis: <one item from the closed list>
|
|
84
84
|
|
|
85
|
-
If any test fails, offer no split. Never split by service, package, repo, layer, or team. If all pass, ask whether to brainstorm the independent specs separately or explain their coupling.
|
|
85
|
+
If any test fails, offer no split. Never split by service, package, repo, layer, or team. If all pass, ask whether to brainstorm the independent specs separately or explain their coupling. Decompose a genuinely multi-concern request; never design it as one spec.
|
|
86
86
|
|
|
87
87
|
### 3. Understand the idea
|
|
88
88
|
|
|
@@ -92,7 +92,7 @@ Ask one question per message, preferably multiple choice, about purpose, constra
|
|
|
92
92
|
|
|
93
93
|
Append only citable findings - schemas, hard constraints, contradictions, and scope-changing answers - to `## Appended during questionary` using `edit`.
|
|
94
94
|
|
|
95
|
-
Before approaches, state in chat the supported, disproved, corrected, and unverified premises with sources and attempted lookups.
|
|
95
|
+
Before approaches, state in chat the supported, disproved, corrected, and unverified premises with sources and attempted lookups, in full sentences - no status-keyword lists, no template; if the design depends on no claims, one sentence says so. An unverified claim is not a stop: put it in Open Questions or a stated assumption. If a load-bearing claim is contradicted, the premise note is your next message - even as question one - and asks the user to accept the corrected fact or override it; ask nothing else and propose nothing until answered. Record the outcome in the draft for `## Problem` or the relevant design decision.
|
|
96
96
|
|
|
97
97
|
### 4. Explore approaches
|
|
98
98
|
|
|
@@ -104,7 +104,7 @@ Prefer clear testable boundaries, YAGNI, existing conventions, the owning schema
|
|
|
104
104
|
|
|
105
105
|
### 6. Present the design in two rounds
|
|
106
106
|
|
|
107
|
-
Use two 300-500
|
|
107
|
+
Use two rounds, targeting 300-500 words each, with one approval each; revisions remain within that approval point. Ask once per round; round-1 approval without correction confirms the predecessor. Round 1 covers architecture, responsibilities, data flow, and `supersedes <path>, <scope>` when applicable. Round 2 covers errors, edges, tests, and `## Documentation impact`.
|
|
108
108
|
|
|
109
109
|
`## Documentation impact` is required. Cite `reference/documentation-impact.md` by relative path, do not restate its categories, and reproduce this template verbatim:
|
|
110
110
|
|
|
@@ -115,7 +115,7 @@ Use two 300-500-word rounds with one approval each; revisions remain within that
|
|
|
115
115
|
- Derived / memory docs invalidated: <routers / AGENTS.md sections / topic guides / indexes, or "none">
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
Use doc names, `none`, or `deferred: <trigger>`. Apply `reference/documentation-impact.md`; amend
|
|
118
|
+
Use doc names, `none`, or `deferred: <trigger>`. Apply `reference/documentation-impact.md`; amend the existing owner; create a standalone file only where no doc owns the topic. Put project taxonomy in the overrides file's `## documentation` block (guidance only; no settings key). Doc updates ship in the same commit as the code and the conformance gate verifies them against the spec. Clarify when needed.
|
|
119
119
|
|
|
120
120
|
## Ticket Handling
|
|
121
121
|
|
|
@@ -123,7 +123,7 @@ A ticket is guidance, not sole truth. Fetch it; propose scope, approach, or acce
|
|
|
123
123
|
|
|
124
124
|
## First-Feature Oversight (Early Project Stages)
|
|
125
125
|
|
|
126
|
-
For the first two features of a new module, long-lived component, schema area, or repeatable pattern, round 1 explicitly
|
|
126
|
+
For the first two features of a new module, long-lived component, schema area, or repeatable pattern, round 1 explicitly lists: directory and module structure; naming of public types, files, routes, and identifiers; each new shared abstraction's location, responsibility, and boundary; persistence/schema entity names, field types, and indexing; proposed AGENTS.md/docs additions. Use no separate confirmation; later features follow established patterns.
|
|
127
127
|
|
|
128
128
|
## Anti-Pattern: "Too simple to need a design"
|
|
129
129
|
|
|
@@ -267,7 +267,7 @@ One question at a time, YAGNI, 2-3 approaches, two design rounds, clarify freely
|
|
|
267
267
|
- Inline scope or ambiguity checks ([owner](#spec-council-optional)).
|
|
268
268
|
- Gate after failed/skipped critique or before placeholder re-scan ([owner](#spec-self-review-before-user-review-gate)).
|
|
269
269
|
- Gate without summary `Read` last, or with a paraphrased summary ([owner](#user-review-gate)).
|
|
270
|
-
- Human stop between gather and question one ([owner](
|
|
270
|
+
- Human stop between gather and question one ([owner](gatherer.md)).
|
|
271
271
|
- Proposed-change execution before approval ([owner](#hard-constraint)).
|
|
272
272
|
- Plan before approval; brainstorming invocation for an amend ([owner](#user-review-gate)).
|
|
273
273
|
- Missing predecessor banner; invalid multi-spec split ([owner](#spec-self-review-before-user-review-gate); [owner](#2-scope-check)).
|
|
@@ -162,18 +162,38 @@ Which option?
|
|
|
162
162
|
|
|
163
163
|
### Step 5: Execute Choice
|
|
164
164
|
|
|
165
|
+
#### Strip the plan, keep the record (Options 1 and 2)
|
|
166
|
+
|
|
167
|
+
Run this on the feature branch before either landing path. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. `<bin>` is `<directory of this skill's SKILL.md>/../../bin`, resolved from the skill's `<location>` in the system prompt.
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
# Plans are ephemeral - if one was committed on this branch, remove it before landing.
|
|
171
|
+
PLAN_PATH=doc/plans/<plan-file>.md # or <service>/doc/plans/<plan-file>.md
|
|
172
|
+
if git -C "$WORKTREE" ls-files --error-unmatch "$PLAN_PATH" >/dev/null 2>&1; then
|
|
173
|
+
git -C "$WORKTREE" rm "$PLAN_PATH" && git -C "$WORKTREE" commit -m "Remove ephemeral plan doc"
|
|
174
|
+
fi
|
|
175
|
+
|
|
176
|
+
# The telemetry record is a deliverable - restore it if a strip or a stray delete removed it.
|
|
177
|
+
node <bin>/gauntlet-telemetry-salvage.mjs --worktree "$WORKTREE" --base <base-branch>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The salvage prints one line per spec on the branch (`present`, `restored <path> from <sha>`, `no telemetry run`, `never written`, `restore failed <path>: <reason>`) and always exits 0. Print its stdout verbatim in the ship completion message. A `restore failed` line is reported, never retried, and never blocks the ship - the record stays recoverable from the branch ref.
|
|
181
|
+
|
|
165
182
|
#### Option 1: Squash-merge to base
|
|
166
183
|
|
|
184
|
+
Run the strip-and-salvage block above first (plan stripped, telemetry record kept), then:
|
|
185
|
+
|
|
167
186
|
```bash
|
|
168
187
|
git -C "$PRIMARY" checkout <base-branch>
|
|
169
188
|
git -C "$PRIMARY" pull
|
|
170
189
|
git -C "$PRIMARY" merge --squash "$FEATURE"
|
|
171
|
-
git -C "$PRIMARY" rm doc/plans/<plan-file>.md # or <service>/doc/plans/<plan-file>.md
|
|
172
190
|
git -C "$PRIMARY" commit -m "<imperative summary> (ref <ticket-id>)"
|
|
173
191
|
(cd "$PRIMARY" && <Step 1 command for the service(s) touched>)
|
|
174
192
|
```
|
|
175
193
|
|
|
176
|
-
The
|
|
194
|
+
The plan was already removed on the branch, so the staged squash tree carries the spec, the telemetry record, and the implementation - never the plan.
|
|
195
|
+
|
|
196
|
+
The post-squash re-verify is not optional - `git merge --squash` can surface conflict-resolution mistakes the worktree-side run couldn't catch.
|
|
177
197
|
|
|
178
198
|
Cleanup worktree (Step 6), then, if Step 6 removed the worktree, `git -C "$PRIMARY" branch -D "$FEATURE"`.
|
|
179
199
|
|
|
@@ -181,16 +201,14 @@ Cleanup worktree (Step 6), then, if Step 6 removed the worktree, `git -C "$PRIMA
|
|
|
181
201
|
|
|
182
202
|
#### Option 2: Push and Create PR
|
|
183
203
|
|
|
184
|
-
|
|
185
|
-
# Plans are ephemeral - if one was committed on this branch, remove it before the PR diff is opened.
|
|
186
|
-
PLAN_PATH=doc/plans/<plan-file>.md # or <service>/doc/plans/<plan-file>.md
|
|
187
|
-
if git -C "$WORKTREE" ls-files --error-unmatch "$PLAN_PATH" >/dev/null 2>&1; then
|
|
188
|
-
git -C "$WORKTREE" rm "$PLAN_PATH" && git -C "$WORKTREE" commit -m "Remove ephemeral plan doc"
|
|
189
|
-
fi
|
|
204
|
+
Run the strip-and-salvage block above first (plan stripped, telemetry record kept), then:
|
|
190
205
|
|
|
206
|
+
```bash
|
|
191
207
|
# Push branch
|
|
192
208
|
git -C "$WORKTREE" push -u origin "$FEATURE"
|
|
209
|
+
```
|
|
193
210
|
|
|
211
|
+
```bash
|
|
194
212
|
# Create PR
|
|
195
213
|
(cd "$WORKTREE" && gh pr create --title "<title>" --body "$(cat <<'EOF'
|
|
196
214
|
## Summary
|
|
@@ -245,10 +263,10 @@ Removal precedes branch deletion in both options; `git branch -d`/`-D` fails whi
|
|
|
245
263
|
|
|
246
264
|
## Quick Reference
|
|
247
265
|
|
|
248
|
-
| Option | Merge | Push | Keep Worktree | Cleanup Branch | Plan
|
|
266
|
+
| Option | Merge | Push | Keep Worktree | Cleanup Branch | Plan strip + record salvage |
|
|
249
267
|
|---|---|---|---|---|---|
|
|
250
|
-
| 1. Squash-merge locally | yes (squash) | - | - | yes (after Step 6 removal) | yes (
|
|
251
|
-
| 2. Create PR | - | yes | yes | - | yes (guarded, before push) |
|
|
268
|
+
| 1. Squash-merge locally | yes (squash) | - | - | yes (after Step 6 removal) | yes (guarded, on the branch before squash) |
|
|
269
|
+
| 2. Create PR | - | yes | yes | - | yes (guarded, on the branch before push) |
|
|
252
270
|
| 3. Keep as-is | - | - | yes | - | - |
|
|
253
271
|
| 4. Discard | - | - | - | yes (force, after Step 6 removal) | - |
|
|
254
272
|
|
|
@@ -284,9 +302,13 @@ A host-owned worktree (Step 6 "Otherwise") keeps both the worktree and the branc
|
|
|
284
302
|
- **Problem:** Accidentally delete work
|
|
285
303
|
- **Fix:** Require typed "discard" confirmation
|
|
286
304
|
|
|
287
|
-
**Skipping the
|
|
288
|
-
- **Problem:** Plan docs are ephemeral and shouldn't land on `<base-branch
|
|
289
|
-
- **Fix:** The plan stays in the
|
|
305
|
+
**Skipping the strip-and-salvage block in Options 1 and 2 (any path that lands on base)**
|
|
306
|
+
- **Problem:** Plan docs are ephemeral and shouldn't land on `<base-branch>`; the telemetry record is a deliverable and must. Skipping the block ships the plan, or drops the record the spec index reads.
|
|
307
|
+
- **Fix:** Run the block on `$WORKTREE` before the squash or the push. The plan stays in the branch's git history (`git -C "$PRIMARY" log --all -- doc/plans/...`). Spec and telemetry record stay on `<base-branch>`; plan does not.
|
|
308
|
+
|
|
309
|
+
**Widening the plan strip to the telemetry record**
|
|
310
|
+
- **Problem:** `.pi/gauntlet/telemetry/**` looks like scaffolding next to the plan and gets deleted in the same commit - main then has no record for the run.
|
|
311
|
+
- **Fix:** Never `git rm` under the telemetry dir. The salvage restores a stripped record, but the deletion should not happen in the first place.
|
|
290
312
|
|
|
291
313
|
## Completion
|
|
292
314
|
|
|
@@ -309,10 +331,12 @@ Once the merge (and any deploy) has landed, `/skill:check-delivery <ticket-ref>`
|
|
|
309
331
|
- Clean up worktrees you didn't create (provenance check)
|
|
310
332
|
- Run any step without the `<worktree-path>` argument
|
|
311
333
|
- Auto-proceed past an undispositioned carried-open gap
|
|
312
|
-
- Skip the
|
|
334
|
+
- Skip the strip-and-salvage block before the Option 1 squash or the Option 2 push
|
|
335
|
+
- Delete the telemetry record (`.pi/gauntlet/telemetry/**` or the configured `telemetry.dir`) on any path
|
|
313
336
|
|
|
314
337
|
**Always:**
|
|
315
338
|
- Verify tests before offering options
|
|
339
|
+
- Print the `gauntlet-telemetry-salvage.mjs` output verbatim in the ship completion message
|
|
316
340
|
- Derive `$PRIMARY` and `$FEATURE` from `<worktree-path>` before presenting the menu
|
|
317
341
|
- Present exactly 4 options (or 3 for detached HEAD)
|
|
318
342
|
- Get typed confirmation for Option 4
|
|
@@ -164,6 +164,16 @@ verification command may write to the tree while the Reviewer reads it):
|
|
|
164
164
|
`<sha>` is the assessed `headRefOid`, `<url>` degrades to `unavailable` when
|
|
165
165
|
absent. Provenance checks on `worktree_root`/`run_cwd` bind only to the local
|
|
166
166
|
path.
|
|
167
|
+
- **Telemetry record:** run `node <bin>/gauntlet-telemetry-salvage.mjs --worktree
|
|
168
|
+
<provisioned path> --base origin/<baseRefName> --check` (`<bin>` = `<directory of
|
|
169
|
+
this skill's SKILL.md>/../../bin`). Detect-only: it never mutates. `present` / `no
|
|
170
|
+
telemetry run` / `never written` land in `## Evidence` as one line each. `stripped
|
|
171
|
+
<path> in <sha>` mints a blocking `P#` (`source_ref`:
|
|
172
|
+
`gauntlet-telemetry-salvage`) whose drafted fix is "run the salvage without
|
|
173
|
+
`--check`" - the spec and its telemetry record are deliverables that ship in the
|
|
174
|
+
squash. On a cell with no push row (fork overlay, report-only states) the same
|
|
175
|
+
finding is a non-blocking follow-up instead: the record stays recoverable from the
|
|
176
|
+
PR head ref after merge, and blocking would stop a ship the gate cannot repair.
|
|
167
177
|
- **Evidence:** On the CI path, list each satisfying check's name, conclusion, assessed SHA, and run URL - there is no command or raw_tail to paste. On the local path, paste each run's `command` and `raw_tail` verbatim, fenced - never
|
|
168
178
|
paraphrased. Any authored summary is labeled as a summary and never substitutes for
|
|
169
179
|
`raw_tail`.
|
|
@@ -242,7 +252,13 @@ head (fixes pushed first); explicit selection with a head compare-and-swap that
|
|
|
242
252
|
passes. A merge selection while any precondition fails is refused, naming the failing
|
|
243
253
|
precondition, and the menu re-renders - never a dead end, never a silent merge. Merge
|
|
244
254
|
always executes as `gh pr merge --match-head-commit <assessed-sha>`; push and merge
|
|
245
|
-
are never bundled into one selection
|
|
255
|
+
are never bundled into one selection, with one scoped exception: the selected merge
|
|
256
|
+
course first runs `node <bin>/gauntlet-telemetry-salvage.mjs --worktree <provisioned
|
|
257
|
+
path> --base origin/<baseRefName>` (no `--check`). `present` -> merge as-is. `restored
|
|
258
|
+
<path> from <sha>` -> push that single `telemetry: restore` commit as part of this
|
|
259
|
+
course, re-fetch `headRefOid`, and pass the new SHA to `--match-head-commit`. `restore
|
|
260
|
+
failed` -> merge proceeds, the reason is printed, and the follow-up names recovery
|
|
261
|
+
from the PR head ref.
|
|
246
262
|
|
|
247
263
|
**Consent menu** (deterministic - this table is the golden-scenario oracle):
|
|
248
264
|
|
|
@@ -364,7 +380,8 @@ Actions (compose freely in the custom row):
|
|
|
364
380
|
reviewed doc edits exist
|
|
365
381
|
in the worktree)
|
|
366
382
|
merge-squash | merge-commit (preconditions per Verdict;
|
|
367
|
-
never bundled with a push
|
|
383
|
+
never bundled with a push,
|
|
384
|
+
except the telemetry: restore commit)
|
|
368
385
|
request-changes | review-comment | approve (approve: never own PR)
|
|
369
386
|
reply <C#s> post drafted thread replies
|
|
370
387
|
tracker <act> tracker action (only when a tracker tool resolved)
|
|
@@ -474,8 +491,11 @@ The menu is a state machine, not a one-shot report:
|
|
|
474
491
|
orchestrator owns commit, gate, and push - never a child. Every dispatched
|
|
475
492
|
child gets `cwd` = the PR worktree, **`worktree: true` forbidden** (a separate
|
|
476
493
|
isolated worktree breaks the shared-tree contract - see Inline-first
|
|
477
|
-
execution); is **edit-only, no git commands, no verification runs
|
|
478
|
-
|
|
494
|
+
execution); is **edit-only, no git commands, no verification runs**, and must
|
|
495
|
+
never delete `.pi/gauntlet/telemetry/**` or anything under the configured
|
|
496
|
+
telemetry dir (a fix that "cleans up" the run's telemetry record is a defect in
|
|
497
|
+
the fix - the record is a deliverable); and its task is that batch's `P#` lines
|
|
498
|
+
**plus the drafted edit already keyed to each
|
|
479
499
|
ID** in `## Drafted fixes / review` - the child applies the consented payload,
|
|
480
500
|
it does not re-solve the finding. Below the cutoff (<= 2 worktree-fixable findings), the orchestrator
|
|
481
501
|
applies inline instead of dispatching - the no-cohort path stays available at
|
|
@@ -486,7 +506,11 @@ The menu is a state machine, not a one-shot report:
|
|
|
486
506
|
Once every dispatched/inline batch returns, the orchestrator commits the golden
|
|
487
507
|
course as one local commit set - the code fixes plus any already-applied
|
|
488
508
|
reviewed doc edits selected alongside them (one commit, or one per batch
|
|
489
|
-
sequentially; subjects name the fixes) - then re-resolves the evidence for the new head **once** (the brief's stale-head row: prior evidence is stale; the local command executes only on a fallback/opt-out resolution).
|
|
509
|
+
sequentially; subjects name the fixes) - then re-resolves the evidence for the new head **once** (the brief's stale-head row: prior evidence is stale; the local command executes only on a fallback/opt-out resolution). Before the push, run
|
|
510
|
+
`node <bin>/gauntlet-telemetry-salvage.mjs --worktree <provisioned path> --base
|
|
511
|
+
origin/<baseRefName>` (no `--check`); a `restored` commit rides the wave's single
|
|
512
|
+
push and the pushed SHA becomes the assessed head under the course's-own-push rule
|
|
513
|
+
in step 1. Print its stdout in the re-rendered report's `## Evidence`. **On
|
|
490
514
|
green**, push **once**; gate and push are per-wave invariants, never per-fix or
|
|
491
515
|
per-batch. **On red**, do not push: leave the commit(s) local, re-render with
|
|
492
516
|
the unresolved `P#`s still open, and warn that unpushed fix commits sit in the
|