pi-gauntlet 8.4.2 → 8.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +3 -3
- package/bin/gauntlet-performance.mjs +3 -3
- package/bin/gauntlet-performance.test.mjs +1 -1
- package/bin/gauntlet-spec-index.mjs +3 -3
- package/bin/gauntlet-spec-index.test.mjs +4 -4
- package/bin/gauntlet-telemetry-seal.mjs +118 -8
- package/bin/gauntlet-telemetry-seal.test.mjs +2 -2
- package/extensions/lib/gauntlet-settings.test.ts +2 -2
- package/extensions/lib/gauntlet-settings.ts +1 -1
- package/extensions/lib/telemetry-branch.test.ts +28 -1
- package/extensions/lib/telemetry-branch.ts +38 -2
- package/extensions/telemetry-leg.test.ts +5 -1
- package/extensions/telemetry.test.ts +2 -2
- package/extensions/telemetry.ts +1 -1
- package/package.json +2 -2
- package/skills/brainstorming/SKILL.md +1 -1
- package/skills/finishing-a-development-branch/SKILL.md +5 -5
- package/skills/gauntlet-resume/reference/seed.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v8.4.3 - 2026-11-08
|
|
4
|
+
|
|
5
|
+
- Telemetry publishes and reads `refs/telemetry/records` outside branch namespaces. The migration guide orders machine upgrades, repeatable record carry with `--migrate-branch`, verified remote retirement with `--retire-branch`, local cleanup and removal of the old CI exclusion. (#130)
|
|
6
|
+
|
|
3
7
|
## v8.4.2 - 2026-11-07
|
|
4
8
|
|
|
5
9
|
### Fixed
|
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
|
|
|
99
99
|
|
|
100
100
|
- **21 skills** - the workflow logic. Sixteen activate automatically when pi sees the matching kind of task, and the workflow skills chain through their gates: `shape-ticket` (creates one tracker issue or repairs a comma/whitespace list of refs against a Context/Problem/Idea/Acceptance-Criteria template, with a grounded repo audit, an AC integrity check, a cheap council roast, and sequential per-ticket exact-diff write confirmations; model-invocable so other skills and commands can route "file a ticket" to it, the gate still needs your yes), `gatekeep-pr` (model-invocable on an explicit ask that names the PR and the verb; the gates still need your selection - is consent-gated pre-merge verification of a PR against the scope contract at its head - the approved spec's acceptance-criteria dispositions and Design when the PR carries one, else the PR's stated intent; the ticket is cross-checked, never judged here (`check-delivery` owns it) - read-only gathering, verification evidence resolved CI-first (green checks on the exact assessed head count as evidence; a pending commit status that GitHub does not report as blocking the gating actor (`mergeStateStatus` not `BLOCKED`) is not waited for - external-CI commit statuses included - while a running Actions check and an unknown merge state still withhold; a viewer GitHub reports as a ruleset bypass actor merges a `BLOCKED` PR on the ordinary `merge` row with CI evidence still binding and all other merge preconditions unchanged; false or unreadable bypass eligibility keeps `BLOCKED` withheld, and live non-admin bypass behavior remains unverified; the project's verification command runs only as fallback), a rubric-based review, a compact bottom-up report (what the PR delivers, including rows deferred or deviating per spec, PR comments, nits, blockers, then a `mergeable` / `fixable` verdict) and course and terminal menus whose rows derive from the actor's permissions on the PR; nothing mutates (repairs, pushes, reviews, merges, tracker comments) until you pick a course or terminal row, except the opt-in `--rebase [base]` sync that rebases, reviews, and lease-pushes the head before verification, or holds the rebased head locally when a conflict stop's scoped tests fail, so a selected delivery course resolves it before the lease push; after your own merge two opt-in rows post the `AC coverage` block to the ticket or the PR; own-merge, approved, and closed-PR menus offer a recommended `cleanup` row that tears down the gate's worktree and branch and runs the overrides file's `## cleanup` steps - run it with `/skill:gatekeep-pr [<pr>] [--rebase [base]]` (no argument: your open PRs)), `check-delivery` (model-invocable on an explicit ask that names the ticket and the verb; the gates still need your selection - 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>` or, in pi, `/skill:check-delivery #12 #15` for fresh parallel verification and one gate and write per ticket in turn), `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `loop-implementation` (the plan-free implement driver that iterates a fresh `loop-implementer` over the spec's acceptance-criteria ledger when approval picks the loop engine), `dispatching-parallel-agents`, `verification-before-completion`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `forge-skill`, `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 require explicit entry (`disable-model-invocation: true`): `brainstorming` starts the gated workflow on explicit consent - use `/skill:brainstorming` for reliable discovery, or opt in through reachable explicit prose or a human-selected handoff; after spec approval, downstream planning and implementation still chain automatically. `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, a council assessment when available, 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>]`.
|
|
101
101
|
- **11 subagent personas** - the specialized child agents the skills dispatch via pi-cohort: `implementer`, `loop-implementer`, `wave-reviewer`, `conformance-reviewer`, `spec-summarizer`, `plan-writer`, `spec-council-member`, `spec-council-synthesizer`, `spec-council-applier`, `pr-gatekeeper`, `delivery-checker`. See [doc/personas.md](./doc/personas.md) for what each one does and why its permissions are scoped the way they are.
|
|
102
|
-
- **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 (expected when a loop gate set does not match its patterns; the scoped `Verification:` line answers it, not a full local run); a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. Both engines use the tracker: waves manages plan tasks, while loop calls `plan_tracker refresh` to project ledger rows and preserve conformance tasks. Loop implement completes when every row is closed, then verify step 1 runs the ledger's gate set in the owning checkouts. 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, and that every test command carries a passing base-commit result; a pass stamps the plan and spec content hashes for implementation and returns a bounded task manifest; see the [manifest contract and size limit](./doc/configuration.md#phase-tracker). `telemetry` publishes YAML records on the
|
|
102
|
+
- **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 (expected when a loop gate set does not match its patterns; the scoped `Verification:` line answers it, not a full local run); a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. Both engines use the tracker: waves manages plan tasks, while loop calls `plan_tracker refresh` to project ledger rows and preserve conformance tasks. Loop implement completes when every row is closed, then verify step 1 runs the ledger's gate set in the owning checkouts. 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, and that every test command carries a passing base-commit result; a pass stamps the plan and spec content hashes for implementation and returns a bounded task manifest; see the [manifest contract and size limit](./doc/configuration.md#phase-tracker). `telemetry` publishes YAML records on the `refs/telemetry/records` ref, outside the product diff: one sealed brainstorming run plus one session leg per settled `shape-ticket`, `gatekeep-pr` or `check-delivery` invocation. In-progress runs live in Git common-dir metadata; `gauntlet-telemetry-seal` publishes before landing without changing checkout HEAD or index. Publication failure warns without blocking ship; plain jj keeps local metadata and reports no Git remote. A fetched shipped record blocks a brainstorm `write` into that spec. See [the record layout, settings and migration guide](./doc/configuration.md#telemetry). See [doc/configuration.md](./doc/configuration.md) for the settings each one reads. `phase_tracker`, `plan_check`, and `plan_tracker` are model-only (not callable from codemode scripts) because their state is rebuilt from transcript tool results, which nested calls do not persist.
|
|
103
103
|
|
|
104
104
|
pi-gauntlet is **opinionated**: it recommends this pipeline for non-trivial changes, but starts `brainstorming` only after explicit user consent. 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.
|
|
105
105
|
|
|
@@ -142,7 +142,7 @@ Run by a human against pi-cohort >= 7.1.0, before a pi-gauntlet release claims t
|
|
|
142
142
|
## Requirements
|
|
143
143
|
|
|
144
144
|
- [pi-coding-agent](https://github.com/earendil-works/pi) >= 1.5.0 - supports model-only tracker exposure and sequential tracker execution, so updates persist as transcript tool results rather than nested calls.
|
|
145
|
-
- [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.6.
|
|
145
|
+
- [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.6.7 - required peer package, including binary-safe worktree patches; it also lets a persona with `subagent` in `tools:` start with inherited extensions ([pi-cohort #26](https://github.com/jjuraszek/pi-cohort/issues/26)), which multi-PR `gatekeep-pr` needs. Skills that dispatch agents (`requesting-code-review`, `subagent-driven-development`, `dispatching-parallel-agents`, `writing-plans`, `forge-skill`, `shape-ticket`, `roasting-the-spec`) call `subagent({})`, which pi-cohort provides. pi-gauntlet does not vendor the dispatch tool; without pi-cohort those skills have nothing to call. Gauntlet execution dispatches explicitly use `async: false`; do not enable pi-cohort's incompatible `forceTopLevelAsync` setting. See pi-cohort's [dispatch configuration](https://github.com/jjuraszek/pi-cohort/blob/main/doc/configuration.md) for that setting's owner and semantics. A wave of N >= 1 tasks dispatches its implementers and first spec reviews as one chain call. `subagent-driven-development` stops before dispatch on unsupported installs; there is no older-runtime fallback.
|
|
146
146
|
|
|
147
147
|
Both packages must be listed in your `.pi/settings.json#packages` array (pi adds them automatically when you `pi install`). pi-gauntlet and pi-cohort are versioned independently but release together whenever dispatch semantics change - pin compatible versions of both. At each pi-gauntlet release, set the pi-cohort peer minimum and README floor together to the newest released pi-cohort version; `/skill:release` checks the registry before proceeding.
|
|
148
148
|
|
|
@@ -170,7 +170,7 @@ Pin an exact release with `npm:pi-gauntlet@X.Y.Z`. See [doc/install-internals.md
|
|
|
170
170
|
|
|
171
171
|
## Performance digest
|
|
172
172
|
|
|
173
|
-
`gauntlet-performance` digests telemetry records without judging them: `node <pi-gauntlet-package>/bin/gauntlet-performance.mjs [--dir <telemetry dir>]... [--since <version>] [--json]` prints a `corpus:` line, one row per run (`run_id`, repo, spec, version, status - `shipped*` when the record has no ship phase - wall, per-phase minutes, tokens, cost, models, dispatches, `disp/task`, `coord`, `fix_round_grants`, reopens, conformance loops, reviewer findings, council dispatches, and `big` - the count of parent calls whose cache write exceeded 50k tokens with their share of the run's parent cache-write tokens (`-` for records without `parent_calls`)), a `by version` block of p50/max over shipped, non-truncated runs, a `council` block grouped by member roster, and `skipped:` lines for files it could not use. Each council table shows member `total`, `unique`, `applied`, `uniq_appl`, `deferred`, `rejected` blocker/major/minor counts and `uniq_appl_nonminor/run`; `chair:` shows runs, average clusters and members reported, and retried runs. Roster flags (JSON `flags[].rule`: `low_unique_applied` - fewer than 0.2 unique-and-applied blocker/major findings per run; `high_rejection` - over 50% rejected while another member is below 25%) require five runs and print as `flag: <member> - <detail>`. Smaller groups print `not enough runs to assess (N of 5)`; absent council data prints `no council data in selected records`. JSON includes `council: [{ roster, runs, members, chairs, flags }]`. By default it refreshes the
|
|
173
|
+
`gauntlet-performance` digests telemetry records without judging them: `node <pi-gauntlet-package>/bin/gauntlet-performance.mjs [--dir <telemetry dir>]... [--since <version>] [--json]` prints a `corpus:` line, one row per run (`run_id`, repo, spec, version, status - `shipped*` when the record has no ship phase - wall, per-phase minutes, tokens, cost, models, dispatches, `disp/task`, `coord`, `fix_round_grants`, reopens, conformance loops, reviewer findings, council dispatches, and `big` - the count of parent calls whose cache write exceeded 50k tokens with their share of the run's parent cache-write tokens (`-` for records without `parent_calls`)), a `by version` block of p50/max over shipped, non-truncated runs, a `council` block grouped by member roster, and `skipped:` lines for files it could not use. Each council table shows member `total`, `unique`, `applied`, `uniq_appl`, `deferred`, `rejected` blocker/major/minor counts and `uniq_appl_nonminor/run`; `chair:` shows runs, average clusters and members reported, and retried runs. Roster flags (JSON `flags[].rule`: `low_unique_applied` - fewer than 0.2 unique-and-applied blocker/major findings per run; `high_rejection` - over 50% rejected while another member is below 25%) require five runs and print as `flag: <member> - <detail>`. Smaller groups print `not enough runs to assess (N of 5)`; absent council data prints `no council data in selected records`. JSON includes `council: [{ roster, runs, members, chairs, flags }]`. By default it refreshes the `refs/telemetry/records` ref from the configured telemetry remote and reads under the current repository's own `<owner>/<name>/` prefix; `--dir <path>` reads a bare directory of YAML records without fetching. Network failure warns and serves the last fetched ref, or an empty corpus when none exists. The `tickets`, `prs` and `unattributed` tables include session-leg costs and split shared batch costs evenly; see [allocation and record fields](doc/configuration.md#telemetry). `--since` compares versions numerically. Exit 0 always except usage errors. `/skill:gauntlet-performance` is the reasoning layer on top.
|
|
174
174
|
|
|
175
175
|
`disp/task` counts recorded implement-phase dispatch-result events divided by `plan.tasks - plan.skipped`; missing plan data, zero denominator or dropped events print `n/a`. `coord` is `yes` only for a non-empty sealed `legs:` list. Telemetry also records `verification_runs`: parent bash matcher hits from implement start through seal, not child checks or an assertion that verification passed. See [the telemetry reference](doc/configuration.md#telemetry) for the counter window and name-based reopen rules.
|
|
176
176
|
|
|
@@ -29,7 +29,7 @@ function resolveTelemetry(g) {
|
|
|
29
29
|
const warnings = [];
|
|
30
30
|
const enabled = t?.enabled !== false;
|
|
31
31
|
if (t && Object.hasOwn(t, "dir")) {
|
|
32
|
-
warnings.push("piGauntlet.telemetry.dir is retired; records live on the
|
|
32
|
+
warnings.push("piGauntlet.telemetry.dir is retired; records live on the refs/telemetry/records ref - set piGauntlet.telemetry.remote to redirect them");
|
|
33
33
|
}
|
|
34
34
|
let buckets = DEFAULT_TELEMETRY_BUCKETS;
|
|
35
35
|
if (t?.buckets !== void 0) {
|
|
@@ -128,7 +128,7 @@ var runBinary = (bin) => (args, cwd, timeoutMs = 1e4, options = {}) => {
|
|
|
128
128
|
var gitRun = runBinary("git");
|
|
129
129
|
|
|
130
130
|
// extensions/lib/telemetry-branch.ts
|
|
131
|
-
var TELEMETRY_REF = "refs/
|
|
131
|
+
var TELEMETRY_REF = "refs/telemetry/records";
|
|
132
132
|
function telemetryRepo(root) {
|
|
133
133
|
const result = gitRun(["config", "--get", "remote.origin.url"], root);
|
|
134
134
|
const match = result.stdout.match(/^(?:https?:\/\/[^/]+\/|ssh:\/\/(?:[^@/]+@)?[^/]+\/|(?:[^@\s]+@)?[^:\s/]+:)(.+)$/);
|
|
@@ -147,7 +147,7 @@ function telemetryRepo(root) {
|
|
|
147
147
|
const primary = worktrees.ok ? /^worktree ([^\0]+)/.exec(worktrees.stdout)?.[1] : void 0;
|
|
148
148
|
return `local/${basename(primary ?? top)}`;
|
|
149
149
|
}
|
|
150
|
-
var trackingRef = (remote) => `refs/
|
|
150
|
+
var trackingRef = (remote) => `refs/telemetry/fetched/${remote === "origin" ? "origin" : "telemetry"}`;
|
|
151
151
|
var fetchBranch = (root, remote) => gitRun(["fetch", "--no-tags", remote, `+${TELEMETRY_REF}:${trackingRef(remote)}`], root);
|
|
152
152
|
function syncTelemetryBranch(root, records, remote = "origin") {
|
|
153
153
|
const scratch = mkdtempSync(join2(tmpdir(), "gauntlet-telemetry-index-"));
|
|
@@ -585,7 +585,7 @@ test("shipped seal preserves verification runs and plan totals for performance",
|
|
|
585
585
|
GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t",
|
|
586
586
|
}, join(dirname(CLI), "gauntlet-telemetry-seal.mjs"));
|
|
587
587
|
assert.equal(result.status, 0, result.stderr);
|
|
588
|
-
const sealed = parseYaml(git(["show", "
|
|
588
|
+
const sealed = parseYaml(git(["show", "refs/telemetry/records:team/product/brainstorming/doc/specs/seal.yaml"]));
|
|
589
589
|
assert.equal(sealed.status, "shipped");
|
|
590
590
|
assert.equal(sealed.derived.verification_runs, 2);
|
|
591
591
|
assert.deepEqual(sealed.derived.plan, { tasks: 2, complete: 1, skipped: 1, failed: 0 });
|
|
@@ -42,7 +42,7 @@ function resolveTelemetry(g) {
|
|
|
42
42
|
const warnings = [];
|
|
43
43
|
const enabled = t?.enabled !== false;
|
|
44
44
|
if (t && Object.hasOwn(t, "dir")) {
|
|
45
|
-
warnings.push("piGauntlet.telemetry.dir is retired; records live on the
|
|
45
|
+
warnings.push("piGauntlet.telemetry.dir is retired; records live on the refs/telemetry/records ref - set piGauntlet.telemetry.remote to redirect them");
|
|
46
46
|
}
|
|
47
47
|
let buckets = DEFAULT_TELEMETRY_BUCKETS;
|
|
48
48
|
if (t?.buckets !== void 0) {
|
|
@@ -141,7 +141,7 @@ var runBinary = (bin) => (args, cwd, timeoutMs = 1e4, options = {}) => {
|
|
|
141
141
|
var gitRun = runBinary("git");
|
|
142
142
|
|
|
143
143
|
// extensions/lib/telemetry-branch.ts
|
|
144
|
-
var TELEMETRY_REF = "refs/
|
|
144
|
+
var TELEMETRY_REF = "refs/telemetry/records";
|
|
145
145
|
function telemetryRepo(root) {
|
|
146
146
|
const result = gitRun(["config", "--get", "remote.origin.url"], root);
|
|
147
147
|
const match = result.stdout.match(/^(?:https?:\/\/[^/]+\/|ssh:\/\/(?:[^@/]+@)?[^/]+\/|(?:[^@\s]+@)?[^:\s/]+:)(.+)$/);
|
|
@@ -160,7 +160,7 @@ function telemetryRepo(root) {
|
|
|
160
160
|
const primary = worktrees.ok ? /^worktree ([^\0]+)/.exec(worktrees.stdout)?.[1] : void 0;
|
|
161
161
|
return `local/${basename(primary ?? top)}`;
|
|
162
162
|
}
|
|
163
|
-
var trackingRef = (remote) => `refs/
|
|
163
|
+
var trackingRef = (remote) => `refs/telemetry/fetched/${remote === "origin" ? "origin" : "telemetry"}`;
|
|
164
164
|
var fetchBranch = (root, remote) => gitRun(["fetch", "--no-tags", remote, `+${TELEMETRY_REF}:${trackingRef(remote)}`], root);
|
|
165
165
|
function syncTelemetryBranch(root, records, remote = "origin") {
|
|
166
166
|
const scratch = mkdtempSync(join2(tmpdir(), "gauntlet-telemetry-index-"));
|
|
@@ -21,12 +21,12 @@ const git = (root, args, options = {}) => {
|
|
|
21
21
|
};
|
|
22
22
|
const telemetry = (root, path, text) => {
|
|
23
23
|
const env = { GIT_INDEX_FILE: join(root, ".git/telemetry-index") };
|
|
24
|
-
git(root, ["read-tree", "
|
|
24
|
+
git(root, ["read-tree", "refs/telemetry/records"], { env });
|
|
25
25
|
const blob = git(root, ["hash-object", "-w", "--stdin"], { input: text });
|
|
26
26
|
git(root, ["update-index", "--add", "--cacheinfo", "100644", blob, `team/product/brainstorming/${path}`], { env });
|
|
27
27
|
const tree = git(root, ["write-tree"], { env });
|
|
28
|
-
const commit = git(root, ["commit-tree", tree, "-p", "
|
|
29
|
-
git(root, ["update-ref", "refs/
|
|
28
|
+
const commit = git(root, ["commit-tree", tree, "-p", "refs/telemetry/records", "-m", "telemetry fixture"]);
|
|
29
|
+
git(root, ["update-ref", "refs/telemetry/records", commit]);
|
|
30
30
|
};
|
|
31
31
|
const DRAFT = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
|
|
32
32
|
const HEADER = ["score", "path", "service", "title", "status", "shipped_at", "state", "files", "snippet"];
|
|
@@ -59,7 +59,7 @@ const gitRepo = () => {
|
|
|
59
59
|
git(root, ["config", `url.${root}.insteadOf`, "https://example.test/team/product.git"]);
|
|
60
60
|
const tree = git(root, ["mktree"], { input: "" });
|
|
61
61
|
const tip = git(root, ["commit-tree", tree, "-m", "empty telemetry fixture"]);
|
|
62
|
-
git(root, ["update-ref", "refs/
|
|
62
|
+
git(root, ["update-ref", "refs/telemetry/records", tip]);
|
|
63
63
|
return root;
|
|
64
64
|
};
|
|
65
65
|
const commit = (root) => {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/bins/gauntlet-telemetry-seal.mjs
|
|
4
|
-
import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2, realpathSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { homedir } from "node:os";
|
|
4
|
+
import { existsSync as existsSync3, mkdtempSync as mkdtempSync2, readdirSync, readFileSync as readFileSync2, realpathSync, rmSync as rmSync2, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir, tmpdir as tmpdir2 } from "node:os";
|
|
6
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
6
7
|
import { isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as resolve2 } from "node:path";
|
|
7
8
|
import process2 from "node:process";
|
|
8
9
|
|
|
@@ -41,7 +42,7 @@ function resolveTelemetry(g) {
|
|
|
41
42
|
const warnings = [];
|
|
42
43
|
const enabled = t?.enabled !== false;
|
|
43
44
|
if (t && Object.hasOwn(t, "dir")) {
|
|
44
|
-
warnings.push("piGauntlet.telemetry.dir is retired; records live on the
|
|
45
|
+
warnings.push("piGauntlet.telemetry.dir is retired; records live on the refs/telemetry/records ref - set piGauntlet.telemetry.remote to redirect them");
|
|
45
46
|
}
|
|
46
47
|
let buckets = DEFAULT_TELEMETRY_BUCKETS;
|
|
47
48
|
if (t?.buckets !== void 0) {
|
|
@@ -275,7 +276,7 @@ function changedNames(vcs, base) {
|
|
|
275
276
|
import { existsSync as existsSync2, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
276
277
|
import { tmpdir } from "node:os";
|
|
277
278
|
import { basename, join as join2 } from "node:path";
|
|
278
|
-
var TELEMETRY_REF = "refs/
|
|
279
|
+
var TELEMETRY_REF = "refs/telemetry/records";
|
|
279
280
|
function trackerRefs(text) {
|
|
280
281
|
const tokens = text.match(/https?:\/\/[^\s<>\])]+|[\w.-]+\/[\w.-]+#\d+|#\d+|\b[A-Z][A-Z0-9]*-\d+\b|\bgh-\d+\b/g) ?? [];
|
|
281
282
|
return [...new Set(tokens.map((ref) => ref.replace(/[.,;]+$/, "")).filter((ref) => !ref.startsWith("http") || /\/(?:issues\/\d+|pull\/\d+|issue\/[A-Z][A-Z0-9]*-\d+)(?:\/[^\s]*)?$/.test(ref)).map((ref) => ref.startsWith("gh-") ? `#${ref.slice(3)}` : ref))];
|
|
@@ -302,8 +303,38 @@ function telemetryRepo(root) {
|
|
|
302
303
|
const primary = worktrees.ok ? /^worktree ([^\0]+)/.exec(worktrees.stdout)?.[1] : void 0;
|
|
303
304
|
return `local/${basename(primary ?? top)}`;
|
|
304
305
|
}
|
|
305
|
-
var trackingRef = (remote) => `refs/
|
|
306
|
+
var trackingRef = (remote) => `refs/telemetry/fetched/${remote === "origin" ? "origin" : "telemetry"}`;
|
|
306
307
|
var fetchBranch = (root, remote) => gitRun(["fetch", "--no-tags", remote, `+${TELEMETRY_REF}:${trackingRef(remote)}`], root);
|
|
308
|
+
function compareRecords(root, sources, destination) {
|
|
309
|
+
const snapshots = [...sources, destination].map((source) => {
|
|
310
|
+
const blobs = /* @__PURE__ */ new Map();
|
|
311
|
+
if (source.oid) {
|
|
312
|
+
const tree = gitRun(["ls-tree", "-r", "-z", source.oid], root, void 0, { raw: true });
|
|
313
|
+
if (!tree.ok) throw new Error(tree.stderr);
|
|
314
|
+
for (const entry of tree.stdout.split("\0").filter(Boolean)) {
|
|
315
|
+
const tab = entry.indexOf(" ");
|
|
316
|
+
const [, type, oid] = entry.slice(0, tab).split(" ");
|
|
317
|
+
if (type === "blob") blobs.set(entry.slice(tab + 1), oid);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { source, blobs };
|
|
321
|
+
});
|
|
322
|
+
const conflicts = [];
|
|
323
|
+
const missing = /* @__PURE__ */ new Map();
|
|
324
|
+
const dest = snapshots.at(-1);
|
|
325
|
+
for (let i = 0; i < sources.length; i++) {
|
|
326
|
+
const { source, blobs } = snapshots[i];
|
|
327
|
+
for (const [path, oid] of blobs) {
|
|
328
|
+
if (!dest.blobs.has(path) && !missing.has(path)) missing.set(path, source);
|
|
329
|
+
for (const other of snapshots.slice(i + 1)) {
|
|
330
|
+
if (other.blobs.has(path) && other.blobs.get(path) !== oid) {
|
|
331
|
+
conflicts.push({ path, a: source.label, b: other.source.label });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return { conflicts, missing: [...missing].map(([path, source]) => ({ path, source })) };
|
|
337
|
+
}
|
|
307
338
|
function syncTelemetryBranch(root, records, remote = "origin") {
|
|
308
339
|
const scratch = mkdtempSync(join2(tmpdir(), "gauntlet-telemetry-index-"));
|
|
309
340
|
const env = { GIT_INDEX_FILE: join2(scratch, "index") };
|
|
@@ -509,7 +540,7 @@ var fail = (code, message) => {
|
|
|
509
540
|
process2.stderr.write(message + "\n");
|
|
510
541
|
process2.exit(code);
|
|
511
542
|
};
|
|
512
|
-
var usage = () => fail(1, "usage: gauntlet-telemetry-seal --worktree <abs path> --option <pr|squash> --base <ref> [--spec <spec path inside the checkout>]\n gauntlet-telemetry-seal [--worktree <abs path>] --migrate [<telemetry dir>]");
|
|
543
|
+
var usage = () => fail(1, "usage: gauntlet-telemetry-seal --worktree <abs path> --option <pr|squash> --base <ref> [--spec <spec path inside the checkout>]\n gauntlet-telemetry-seal [--worktree <abs path>] --migrate [<telemetry dir>]\n gauntlet-telemetry-seal [--worktree <abs path>] <--migrate-branch|--retire-branch>");
|
|
513
544
|
function parseArgs(argv) {
|
|
514
545
|
const opts = { worktree: void 0, option: void 0, base: void 0, spec: void 0, legs: [] };
|
|
515
546
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -520,6 +551,8 @@ function parseArgs(argv) {
|
|
|
520
551
|
opts[key] = argv[++i];
|
|
521
552
|
};
|
|
522
553
|
if (a === "--migrate") opts.migrate = value !== void 0 && !value.startsWith("--") ? argv[++i] : ".pi/gauntlet/telemetry";
|
|
554
|
+
else if (a === "--migrate-branch") opts.migrateBranch = true;
|
|
555
|
+
else if (a === "--retire-branch") opts.retireBranch = true;
|
|
523
556
|
else if (a === "--worktree") takes("worktree");
|
|
524
557
|
else if (a === "--option") takes("option");
|
|
525
558
|
else if (a === "--base") takes("base");
|
|
@@ -534,7 +567,9 @@ function parseArgs(argv) {
|
|
|
534
567
|
opts.legs.push(leg);
|
|
535
568
|
} else usage();
|
|
536
569
|
}
|
|
537
|
-
|
|
570
|
+
const modes = [opts.migrate !== void 0, opts.migrateBranch, opts.retireBranch].filter(Boolean).length;
|
|
571
|
+
if (modes > 1) usage();
|
|
572
|
+
if (modes) {
|
|
538
573
|
opts.worktree ??= process2.cwd();
|
|
539
574
|
if (opts.option || opts.base || opts.spec || opts.legs.length) usage();
|
|
540
575
|
} else if (!opts.base || !OPTIONS.has(opts.option)) usage();
|
|
@@ -585,6 +620,74 @@ function migrate(root, dir, repo, remote) {
|
|
|
585
620
|
if (!result.ok) return console.log(`telemetry: not pushed (${result.reason})`);
|
|
586
621
|
console.log(`migrated ${writes.size} records from ${dir}`);
|
|
587
622
|
}
|
|
623
|
+
var LEGACY_REF = "refs/heads/gauntlet/telemetry";
|
|
624
|
+
function branchMigration(root, remote = "origin", retire) {
|
|
625
|
+
const required = (args, options) => {
|
|
626
|
+
const result = gitRun(args, root, void 0, options);
|
|
627
|
+
if (!result.ok) throw new Error(result.stderr || `git ${args[0]} failed`);
|
|
628
|
+
return result.stdout;
|
|
629
|
+
};
|
|
630
|
+
const discover = (ref, tracking) => {
|
|
631
|
+
const found = gitRun(["ls-remote", "--exit-code", remote, ref], root);
|
|
632
|
+
if (found.code === 2) return void 0;
|
|
633
|
+
if (!found.ok) throw new Error(found.stderr || "git ls-remote failed");
|
|
634
|
+
required(["fetch", "--no-tags", remote, tracking ? `+${ref}:${tracking}` : ref]);
|
|
635
|
+
return required(["rev-parse", "--verify", tracking ?? "FETCH_HEAD"]);
|
|
636
|
+
};
|
|
637
|
+
const oldRemote = discover(LEGACY_REF);
|
|
638
|
+
if (retire && !oldRemote) return console.log("nothing to retire");
|
|
639
|
+
const sources = oldRemote ? [{ label: `${remote}/${LEGACY_REF}`, oid: oldRemote }] : [];
|
|
640
|
+
const local = gitRun(["show-ref", "--verify", "--quiet", LEGACY_REF], root);
|
|
641
|
+
if (local.ok) sources.push({ label: LEGACY_REF, oid: required(["rev-parse", "--verify", LEGACY_REF]) });
|
|
642
|
+
else if (local.code !== 1) throw new Error(local.stderr || "git show-ref failed");
|
|
643
|
+
if (!sources.length) return console.log("nothing to migrate");
|
|
644
|
+
let destOid = discover(TELEMETRY_REF, trackingRef(remote));
|
|
645
|
+
let compared = compareRecords(root, sources, { label: `${remote}/${TELEMETRY_REF}`, oid: destOid ?? null });
|
|
646
|
+
if (compared.conflicts.length) throw new Error(compared.conflicts.map(({ path, a, b }) => `conflict: ${path} differs between ${a} and ${b}`).join("\n"));
|
|
647
|
+
if (retire && compared.missing.length) throw new Error(compared.missing.map(({ path }) => `not carried: ${path}`).join("\n"));
|
|
648
|
+
const push = (args, config = []) => {
|
|
649
|
+
const result = spawnSync2("git", [...config, "push", ...args], { cwd: root, encoding: "utf8", timeout: 3e4 });
|
|
650
|
+
if (result.status !== 0) {
|
|
651
|
+
const stderr = result.stderr?.trim() || result.error?.message || "git push failed";
|
|
652
|
+
const retry = retire ? "gauntlet/telemetry moved since verification - re-run --migrate-branch then --retire-branch" : "destination changed since comparison - re-run --migrate-branch";
|
|
653
|
+
throw new Error(`${stderr}
|
|
654
|
+
${retry}`);
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
if (retire) {
|
|
658
|
+
const remotes = required(["remote"]).split("\n");
|
|
659
|
+
const pushRemote = remotes.includes(remote) ? required(["remote", "get-url", "--push", remote]) : remote;
|
|
660
|
+
let alias = `gauntlet-retire-${process2.pid}`;
|
|
661
|
+
while (remotes.includes(alias)) alias += "-";
|
|
662
|
+
push([`--force-with-lease=${LEGACY_REF}:${oldRemote}`, alias, `:${LEGACY_REF}`], ["-c", `remote.${alias}.url=${pushRemote}`]);
|
|
663
|
+
console.log(`retired: gauntlet/telemetry on ${remote}`);
|
|
664
|
+
console.log(`if git show-ref --verify --quiet ${LEGACY_REF}; then git branch -D gauntlet/telemetry; fi`);
|
|
665
|
+
return console.log(`git update-ref -d refs/remotes/${trackingRef(remote).split("/").at(-1)}/gauntlet/telemetry`);
|
|
666
|
+
}
|
|
667
|
+
if (!destOid && oldRemote) {
|
|
668
|
+
push([`--force-with-lease=${TELEMETRY_REF}:`, remote, `${oldRemote}:${TELEMETRY_REF}`]);
|
|
669
|
+
destOid = oldRemote;
|
|
670
|
+
compared = compareRecords(root, sources, { label: `${remote}/${TELEMETRY_REF}`, oid: destOid });
|
|
671
|
+
}
|
|
672
|
+
if (!compared.missing.length) return console.log("carried: 0 records");
|
|
673
|
+
const scratch = mkdtempSync2(join3(tmpdir2(), "gauntlet-ref-migration-"));
|
|
674
|
+
try {
|
|
675
|
+
const env = { GIT_INDEX_FILE: join3(scratch, "index") };
|
|
676
|
+
required(destOid ? ["read-tree", destOid] : ["read-tree", "--empty"], { env });
|
|
677
|
+
for (const { path, source } of compared.missing) {
|
|
678
|
+
const entry = required(["ls-tree", "-z", source.oid, "--", path], { raw: true });
|
|
679
|
+
const [mode, , blob] = entry.slice(0, entry.indexOf(" ")).split(" ");
|
|
680
|
+
required(["update-index", "--add", "--cacheinfo", `${mode},${blob},${path}`], { env });
|
|
681
|
+
}
|
|
682
|
+
const tree = required(["write-tree"], { env });
|
|
683
|
+
const author = { ...env, GIT_AUTHOR_NAME: "gauntlet", GIT_AUTHOR_EMAIL: "gauntlet@local", GIT_COMMITTER_NAME: "gauntlet", GIT_COMMITTER_EMAIL: "gauntlet@local" };
|
|
684
|
+
const commit = required(["-c", "commit.gpgsign=false", "commit-tree", tree, ...destOid ? ["-p", destOid] : [], "-m", "Carry legacy gauntlet telemetry"], { env: author });
|
|
685
|
+
push([`--force-with-lease=${TELEMETRY_REF}:${destOid ?? ""}`, remote, `${commit}:${TELEMETRY_REF}`]);
|
|
686
|
+
console.log(`carried: ${compared.missing.length} records from ${[...new Set(compared.missing.map(({ source }) => source.label))].join(", ")}`);
|
|
687
|
+
} finally {
|
|
688
|
+
rmSync2(scratch, { recursive: true, force: true });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
588
691
|
var isoNow = () => (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
589
692
|
function runTickets(vcs, record, base) {
|
|
590
693
|
const log = gitRun(["log", "--format=%B", `${base}..HEAD`], vcs.root);
|
|
@@ -598,7 +701,7 @@ var normalized = (legs) => [...new Set(legs.map((leg) => JSON.stringify([leg.rep
|
|
|
598
701
|
var sameLegs = (record, legs) => JSON.stringify(normalized(record.legs ?? [])) === JSON.stringify(normalized(legs));
|
|
599
702
|
function branchBlob(root, path, remote) {
|
|
600
703
|
const local = gitRun(["rev-parse", "--verify", TELEMETRY_REF], root);
|
|
601
|
-
const tracking =
|
|
704
|
+
const tracking = trackingRef(remote ?? "origin");
|
|
602
705
|
const fetched = local.ok && gitRun(["merge-base", "--is-ancestor", TELEMETRY_REF, tracking], root).ok;
|
|
603
706
|
const ref = !local.ok || fetched ? tracking : TELEMETRY_REF;
|
|
604
707
|
const blob = gitRun(["show", `${ref}:${path}`], root, void 0, { raw: true });
|
|
@@ -660,6 +763,13 @@ async function main() {
|
|
|
660
763
|
`);
|
|
661
764
|
if (!telemetry.enabled) return console.log("telemetry disabled");
|
|
662
765
|
if (vcs.kind === "jj") return console.log("telemetry: not pushed (jj workspace: no git remote)");
|
|
766
|
+
if (opts.migrateBranch || opts.retireBranch) {
|
|
767
|
+
try {
|
|
768
|
+
return branchMigration(root, telemetry.remote, opts.retireBranch);
|
|
769
|
+
} catch (error) {
|
|
770
|
+
fail(1, error.message);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
663
773
|
const dir = DEFAULT_TELEMETRY_DIR;
|
|
664
774
|
if (opts.migrate === void 0 && !validateBase(vcs, opts.base)) fail(1, `no base ref ${opts.base}`);
|
|
665
775
|
const store = telemetryDir(root);
|
|
@@ -11,7 +11,7 @@ import { isolatedGitEnv } from "../extensions/test-support/git-env.mjs";
|
|
|
11
11
|
const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-telemetry-seal.mjs");
|
|
12
12
|
const SPEC = "doc/specs/x.md";
|
|
13
13
|
const REC = ".git/gauntlet/telemetry/doc/specs/x.yaml";
|
|
14
|
-
const REF = "refs/
|
|
14
|
+
const REF = "refs/telemetry/records";
|
|
15
15
|
const BLOB = "team/product/brainstorming/doc/specs/x.yaml";
|
|
16
16
|
const RECORD = `schema: 1
|
|
17
17
|
spec: doc/specs/x.md
|
|
@@ -168,7 +168,7 @@ fi
|
|
|
168
168
|
assert.equal(result.status, 0, result.stderr);
|
|
169
169
|
assert.match(result.stderr, /telemetry: not pushed.*(?:EISDIR|EPERM)/);
|
|
170
170
|
assert.equal(record(root).status, "shipped", "the published record survives failed cleanup");
|
|
171
|
-
assert.equal(git(root, ["
|
|
171
|
+
assert.equal(git(root, ["ls-remote", "origin", REF]), `${git(root, ["rev-parse", REF])}\t${REF}`);
|
|
172
172
|
});
|
|
173
173
|
|
|
174
174
|
test("shipped CLI rejects malformed leg values before sealing", () => {
|
|
@@ -284,7 +284,7 @@ test("telemetry: retired dir setting is ignored with one migration warning", ()
|
|
|
284
284
|
]) {
|
|
285
285
|
const r = resolveTelemetry(mergeGauntlet(undefined, { telemetry: { dir } }));
|
|
286
286
|
assert.equal("dir" in r, false);
|
|
287
|
-
assert.equal(r.warning, "piGauntlet.telemetry.dir is retired; records live on the
|
|
287
|
+
assert.equal(r.warning, "piGauntlet.telemetry.dir is retired; records live on the refs/telemetry/records ref - set piGauntlet.telemetry.remote to redirect them");
|
|
288
288
|
}
|
|
289
289
|
});
|
|
290
290
|
|
|
@@ -307,7 +307,7 @@ test("telemetry: malformed buckets -> default + warning", () => {
|
|
|
307
307
|
|
|
308
308
|
test("telemetry: retired dir and invalid buckets each warn once", () => {
|
|
309
309
|
const r = resolveTelemetry(mergeGauntlet(undefined, { telemetry: { dir: 1, buckets: 2 } }));
|
|
310
|
-
assert.equal(r.warning, "piGauntlet.telemetry.dir is retired; records live on the
|
|
310
|
+
assert.equal(r.warning, "piGauntlet.telemetry.dir is retired; records live on the refs/telemetry/records ref - set piGauntlet.telemetry.remote to redirect them; telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
|
|
311
311
|
});
|
|
312
312
|
|
|
313
313
|
test("DEFAULT_TEST_COMMANDS + buildTestCmdRegex match the documented entrypoints", () => {
|
|
@@ -240,7 +240,7 @@ export function resolveTelemetry(g: PiGauntlet): TelemetryResolved {
|
|
|
240
240
|
const warnings: string[] = [];
|
|
241
241
|
const enabled = t?.enabled !== false;
|
|
242
242
|
if (t && Object.hasOwn(t, "dir")) {
|
|
243
|
-
warnings.push("piGauntlet.telemetry.dir is retired; records live on the
|
|
243
|
+
warnings.push("piGauntlet.telemetry.dir is retired; records live on the refs/telemetry/records ref - set piGauntlet.telemetry.remote to redirect them");
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
let buckets = DEFAULT_TELEMETRY_BUCKETS;
|
|
@@ -68,7 +68,34 @@ test("failed telemetry ref commit reports committed: false and preserves the fea
|
|
|
68
68
|
assert.match(result.reason, /rejected|reference transaction/);
|
|
69
69
|
assert.equal(f.git("rev-parse", "HEAD"), before);
|
|
70
70
|
assert.deepEqual(readFileSync(join(f.root, ".git/index")), index);
|
|
71
|
-
assert.equal(f.git("for-each-ref", "refs/
|
|
71
|
+
assert.equal(f.git("for-each-ref", "refs/telemetry/records"), "");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("record comparison reports missing paths and every conflicting snapshot pair", () => {
|
|
75
|
+
const f = fixture();
|
|
76
|
+
const snapshots = ["remote", "local", "destination"].map(label => {
|
|
77
|
+
writeFileSync(join(f.root, "record.yaml"), label + "\n");
|
|
78
|
+
f.git("add", "."); f.git("commit", "-m", label);
|
|
79
|
+
return { label, oid: f.git("rev-parse", "HEAD") };
|
|
80
|
+
});
|
|
81
|
+
const [remote, local, destination] = snapshots;
|
|
82
|
+
const empty = { label: "empty", oid: null };
|
|
83
|
+
for (const [sources, dest, conflicts, missing] of [
|
|
84
|
+
[[remote], remote, [], []],
|
|
85
|
+
[[remote], empty, [], [{ path: "record.yaml", source: remote }]],
|
|
86
|
+
[[remote], destination, [{ path: "record.yaml", a: "remote", b: "destination" }], []],
|
|
87
|
+
[[remote, local], empty, [{ path: "record.yaml", a: "remote", b: "local" }], [{ path: "record.yaml", source: remote }]],
|
|
88
|
+
[[remote, local], destination, [
|
|
89
|
+
{ path: "record.yaml", a: "remote", b: "local" },
|
|
90
|
+
{ path: "record.yaml", a: "remote", b: "destination" },
|
|
91
|
+
{ path: "record.yaml", a: "local", b: "destination" },
|
|
92
|
+
], []],
|
|
93
|
+
]) {
|
|
94
|
+
const result = JSON.parse(f.run(process.execPath, ["--input-type=module", "--eval",
|
|
95
|
+
`import { compareRecords } from ${JSON.stringify(library)}; console.log(JSON.stringify(compareRecords(process.cwd(), ${JSON.stringify(sources)}, ${JSON.stringify(dest)})));`,
|
|
96
|
+
]));
|
|
97
|
+
assert.deepEqual(result, { conflicts, missing });
|
|
98
|
+
}
|
|
72
99
|
});
|
|
73
100
|
|
|
74
101
|
test("origin-less identity uses repo from the first-found overrides Issue tracker section", () => {
|
|
@@ -4,7 +4,7 @@ import { basename, join } from "node:path";
|
|
|
4
4
|
import { checkoutOfSync } from "./checkout.ts";
|
|
5
5
|
import { gitRun } from "./vcs.ts";
|
|
6
6
|
|
|
7
|
-
export const TELEMETRY_REF = "refs/
|
|
7
|
+
export const TELEMETRY_REF = "refs/telemetry/records";
|
|
8
8
|
|
|
9
9
|
export function trackerRefs(text: string): string[] {
|
|
10
10
|
const tokens = text.match(/https?:\/\/[^\s<>\])]+|[\w.-]+\/[\w.-]+#\d+|#\d+|\b[A-Z][A-Z0-9]*-\d+\b|\bgh-\d+\b/g) ?? [];
|
|
@@ -39,9 +39,45 @@ export function telemetryRepo(root: string): string {
|
|
|
39
39
|
return `local/${basename(primary ?? top)}`;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
const trackingRef = (remote: string) => `refs/
|
|
42
|
+
export const trackingRef = (remote: string) => `refs/telemetry/fetched/${remote === "origin" ? "origin" : "telemetry"}`;
|
|
43
43
|
const fetchBranch = (root: string, remote: string) => gitRun(["fetch", "--no-tags", remote, `+${TELEMETRY_REF}:${trackingRef(remote)}`], root);
|
|
44
44
|
|
|
45
|
+
type RecordSnapshot = { label: string; oid: string | null };
|
|
46
|
+
|
|
47
|
+
export function compareRecords(root: string, sources: RecordSnapshot[], destination: RecordSnapshot): {
|
|
48
|
+
conflicts: { path: string; a: string; b: string }[];
|
|
49
|
+
missing: { path: string; source: RecordSnapshot }[];
|
|
50
|
+
} {
|
|
51
|
+
const snapshots = [...sources, destination].map(source => {
|
|
52
|
+
const blobs = new Map<string, string>();
|
|
53
|
+
if (source.oid) {
|
|
54
|
+
const tree = gitRun(["ls-tree", "-r", "-z", source.oid], root, undefined, { raw: true });
|
|
55
|
+
if (!tree.ok) throw new Error(tree.stderr);
|
|
56
|
+
for (const entry of tree.stdout.split("\0").filter(Boolean)) {
|
|
57
|
+
const tab = entry.indexOf("\t");
|
|
58
|
+
const [, type, oid] = entry.slice(0, tab).split(" ");
|
|
59
|
+
if (type === "blob") blobs.set(entry.slice(tab + 1), oid);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { source, blobs };
|
|
63
|
+
});
|
|
64
|
+
const conflicts: { path: string; a: string; b: string }[] = [];
|
|
65
|
+
const missing = new Map<string, RecordSnapshot>();
|
|
66
|
+
const dest = snapshots.at(-1)!;
|
|
67
|
+
for (let i = 0; i < sources.length; i++) {
|
|
68
|
+
const { source, blobs } = snapshots[i];
|
|
69
|
+
for (const [path, oid] of blobs) {
|
|
70
|
+
if (!dest.blobs.has(path) && !missing.has(path)) missing.set(path, source);
|
|
71
|
+
for (const other of snapshots.slice(i + 1)) {
|
|
72
|
+
if (other.blobs.has(path) && other.blobs.get(path) !== oid) {
|
|
73
|
+
conflicts.push({ path, a: source.label, b: other.source.label });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { conflicts, missing: [...missing].map(([path, source]) => ({ path, source })) };
|
|
79
|
+
}
|
|
80
|
+
|
|
45
81
|
export function syncTelemetryBranch(root: string, records: ReadonlyMap<string, string>, remote = "origin"): { ok: boolean; committed: boolean; pushed: boolean; reason?: string; warning?: string } {
|
|
46
82
|
const scratch = mkdtempSync(join(tmpdir(), "gauntlet-telemetry-index-"));
|
|
47
83
|
const env = { GIT_INDEX_FILE: join(scratch, "index") };
|
|
@@ -16,7 +16,7 @@ Object.assign(process.env, gitEnv);
|
|
|
16
16
|
const { default: registerTelemetry, realDeps } = await import("./telemetry.ts");
|
|
17
17
|
const { registerLegTelemetry } = await import("./lib/telemetry-leg.ts");
|
|
18
18
|
|
|
19
|
-
const ref = "refs/
|
|
19
|
+
const ref = "refs/telemetry/records";
|
|
20
20
|
const originalDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
21
21
|
process.env.PI_SUBAGENT_DEPTH = "0";
|
|
22
22
|
after(() => {
|
|
@@ -99,6 +99,7 @@ for (const [skill, outcome, activation, empty] of [
|
|
|
99
99
|
test(`${skill} ${outcome}${empty ? " with no items" : ""} seals one session leg only after the final settlement and follow-on work`, async () => {
|
|
100
100
|
const marker = outcome === "stopped" ? "stuck" : "done";
|
|
101
101
|
const h = harness(skill);
|
|
102
|
+
const localHeads = h.git(h.root, "show-ref", "--heads");
|
|
102
103
|
await h.emit("session_start");
|
|
103
104
|
// Terminal-looking text outside a selected skill is not a leg.
|
|
104
105
|
await h.say(`gauntlet: done ${outcome}\n#42: ${outcome}`);
|
|
@@ -147,5 +148,8 @@ for (const [skill, outcome, activation, empty] of [
|
|
|
147
148
|
assert.deepEqual(readFileSync(join(h.root, ".git/index")), h.index);
|
|
148
149
|
assert.equal(readFileSync(join(h.root, "file.txt"), "utf8"), "unstaged user change\n");
|
|
149
150
|
assert.equal(h.git(h.root, "rev-parse", ref), tip);
|
|
151
|
+
assert.equal(h.git(h.root, "show-ref", "--heads"), localHeads, "settlement must not create a local branch");
|
|
152
|
+
assert.equal(h.git(h.root, "ls-remote", "--heads", "origin"), "", "settlement must not create a remote branch");
|
|
153
|
+
assert.equal(h.git(h.root, "ls-remote", "origin", "refs/telemetry/*"), `${tip}\t${ref}`);
|
|
150
154
|
});
|
|
151
155
|
}
|
|
@@ -981,7 +981,7 @@
|
|
|
981
981
|
h2.fetchedRecords.set("test/repo/brainstorming/doc/specs/a.yaml", before);
|
|
982
982
|
await h2.phaseResult("start", P({ brainstorm: "in_progress" }));
|
|
983
983
|
const [blocked] = await h2.emit("tool_call", { toolName: "write", toolCallId: "w", input: { path: "doc/specs/a.md" } });
|
|
984
|
-
assert.deepEqual(blocked, { block: true, reason: guardReason("doc/specs/a.md", rec.shipped_at, "
|
|
984
|
+
assert.deepEqual(blocked, { block: true, reason: guardReason("doc/specs/a.md", rec.shipped_at, "refs/telemetry/records") });
|
|
985
985
|
assert.equal(readFileSync(h.recordPath(), "utf8"), before);
|
|
986
986
|
assert.equal(h.readRecord().derived.spec_edits_after_ship, 0);
|
|
987
987
|
});
|
|
@@ -1313,7 +1313,7 @@
|
|
|
1313
1313
|
h2.fetchedRecords.set("test/repo/brainstorming/doc/specs/a.yaml", before);
|
|
1314
1314
|
await h2.phaseResult("start", P({ brainstorm: "in_progress" }));
|
|
1315
1315
|
const [blocked] = await h2.emit("tool_call", { toolName: "write", toolCallId: "w1", input: { path: "doc/specs/a.md", content: "# again" } });
|
|
1316
|
-
assert.deepEqual(blocked, { block: true, reason: guardReason("doc/specs/a.md", h.readRecord().shipped_at!, "
|
|
1316
|
+
assert.deepEqual(blocked, { block: true, reason: guardReason("doc/specs/a.md", h.readRecord().shipped_at!, "refs/telemetry/records") });
|
|
1317
1317
|
assert.equal(readFileSync(h.recordPath(), "utf8"), before, "shipped record byte-identical");
|
|
1318
1318
|
const [editPass] = await h2.emit("tool_call", { toolName: "edit", toolCallId: "e1", input: { path: "doc/specs/a.md", edits: [] } });
|
|
1319
1319
|
assert.equal(editPass, undefined);
|
package/extensions/telemetry.ts
CHANGED
|
@@ -789,7 +789,7 @@
|
|
|
789
789
|
const loc = await locate(ctx, rawPath);
|
|
790
790
|
if (loc?.via === "git" && isSpecPath(loc.rel, snap.specDirs)) {
|
|
791
791
|
const shippedAt = shippedRecords(loc.toplevel, snap).get(loc.rel);
|
|
792
|
-
if (shippedAt) return { block: true, reason: guardReason(loc.rel, shippedAt, "
|
|
792
|
+
if (shippedAt) return { block: true, reason: guardReason(loc.rel, shippedAt, "refs/telemetry/records") };
|
|
793
793
|
}
|
|
794
794
|
}
|
|
795
795
|
return undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-gauntlet",
|
|
3
|
-
"version": "8.4.
|
|
3
|
+
"version": "8.4.3",
|
|
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",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"test": "node scripts/ci.mjs"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
|
-
"pi-cohort": ">=7.6.
|
|
65
|
+
"pi-cohort": ">=7.6.7"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"yaml": "^2.9.0"
|
|
@@ -69,7 +69,7 @@ The spec is the first commit in a dedicated worktree, never a separate commit on
|
|
|
69
69
|
|
|
70
70
|
Read [reference/legs.md](reference/legs.md) and settle membership before creation. Invoke `/skill:using-git-worktrees` for each member with explicit `$ROOT` = its primary and `$BRANCH` = the shared branch; carry every `Worktree ready at <full-path>` value, home first. Root the home spec at `<home worktree>/<home spec dir>/<filename>.md`; root each dispatch and `git -C` in its owning leg's worktree. Keep the process cwd unchanged. Gather each leg's rules and record portable contracts and companion artifacts using that reference; a one-member run adds only its one-block `## Legs` section.
|
|
71
71
|
|
|
72
|
-
Keep spec, plan, and implementation in this worktree. Ship the spec and implementation; strip only the plan. Let `/skill:finishing-a-development-branch` seal and publish the telemetry record to the
|
|
72
|
+
Keep spec, plan, and implementation in this worktree. Ship the spec and implementation; strip only the plan. Let `/skill:finishing-a-development-branch` seal and publish the telemetry record to the `refs/telemetry/records` ref before landing; keep recorder state in Git common-dir metadata, outside the working tree. In a plain jj workspace, retain local metadata and report that publication is unavailable. Use no worktree for explicit trivial one-off edits outside this flow.
|
|
73
73
|
|
|
74
74
|
## The Process
|
|
75
75
|
|
|
@@ -226,7 +226,7 @@ Rows 1-2 run the Option 1/2 blocks; row 3 runs the Keep block and row 4 the Disc
|
|
|
226
226
|
|
|
227
227
|
#### Strip the plan, seal the record (Options 1-3)
|
|
228
228
|
|
|
229
|
-
Run this on the feature branch before any landing path. Ship the spec and implementation; strip only the plan. Let the seal publish the telemetry record to the
|
|
229
|
+
Run this on the feature branch before any landing path. Ship the spec and implementation; strip only the plan. Let the seal publish the telemetry record to the `refs/telemetry/records` ref without changing the product checkout, index or HEAD. Keep in-progress records in Git common-dir metadata, outside the working tree. Resolve `<bin>` as `<directory of this skill's SKILL.md>/../../bin` from the skill's `<location>` in the system prompt.
|
|
230
230
|
|
|
231
231
|
Read the existing artifact's path-only `**Spec:**` header (repo-relative, backticks stripped) before removal; pass that value as `--spec`, including for a line-1 ledger. Resolve the spec dirs with `gauntlet_setting({ key: "flowGuards" })` and use their sibling `plans` dirs for plan paths. Enumerate both PLAN_PATH candidates: the task plan paired by basename and `<sibling plans dir>/<spec stem>-ledger.md`. Preserve the spec and telemetry.
|
|
232
232
|
|
|
@@ -242,7 +242,7 @@ elif [ -f "$WORKTREE/$PLAN_PATH" ] && jj -R "$WORKTREE" root >/dev/null 2>&1; th
|
|
|
242
242
|
fi
|
|
243
243
|
done
|
|
244
244
|
|
|
245
|
-
# Seal the telemetry record: stamp shipped, compute the diff, publish to
|
|
245
|
+
# Seal the telemetry record: stamp shipped, compute the diff, publish to refs/telemetry/records.
|
|
246
246
|
# The bin reports publication unavailable in a plain jj workspace and keeps local metadata.
|
|
247
247
|
# --option is pr for Options 1-2 and squash for Option 3; --spec is the artifact header's **Spec:** value (omit when no artifact is known).
|
|
248
248
|
node <bin>/gauntlet-telemetry-seal.mjs --worktree "$WORKTREE" --option <pr|squash> --base <base-branch> --spec <spec path>
|
|
@@ -346,7 +346,7 @@ git -C "$PRIMARY" commit -m "<imperative summary> (ref <ticket-id>)"
|
|
|
346
346
|
(cd "$PRIMARY" && <the Step 1 set>)
|
|
347
347
|
```
|
|
348
348
|
|
|
349
|
-
Keep only the spec and implementation in the staged squash tree; the stripped plan stays in feature history and the sealed telemetry record belongs to `
|
|
349
|
+
Keep only the spec and implementation in the staged squash tree; the stripped plan stays in feature history and the sealed telemetry record belongs to `refs/telemetry/records`.
|
|
350
350
|
|
|
351
351
|
The post-squash re-verify is not optional - `git merge --squash` can surface conflict-resolution mistakes the worktree-side run couldn't catch. For a ledger, run the per-pair post-squash recipe in [verification-set.md](reference/verification-set.md) instead of the non-ledger subshell above.
|
|
352
352
|
|
|
@@ -450,10 +450,10 @@ A host-owned worktree (Step 6: no `home:` match, or the legacy "Otherwise") keep
|
|
|
450
450
|
|
|
451
451
|
**Skipping the strip-and-seal block in Options 1-3 (any path that lands on base)**
|
|
452
452
|
- **Problem:** Skipping the block ships an ephemeral plan or skips publication of the record the spec index reads.
|
|
453
|
-
- **Fix:** Run the block on `$WORKTREE` before the squash or push. Preserve the spec on `<base-branch>` and the telemetry record on `
|
|
453
|
+
- **Fix:** Run the block on `$WORKTREE` before the squash or push. Preserve the spec on `<base-branch>` and the telemetry record on `refs/telemetry/records`; retain the plan only in feature history.
|
|
454
454
|
|
|
455
455
|
**Treating telemetry as a checkout deliverable**
|
|
456
|
-
- **Problem:** Staging a recorder file pollutes the product diff and bypasses
|
|
456
|
+
- **Problem:** Staging a recorder file pollutes the product diff and bypasses ref publication.
|
|
457
457
|
- **Fix:** Let the seal own publication from common-dir metadata; stage no telemetry record in the product checkout.
|
|
458
458
|
|
|
459
459
|
## Completion
|
|
@@ -13,7 +13,7 @@ Before starting the seed sequence, read the seed's header. A `**Home:**` header
|
|
|
13
13
|
stop: `not tracked: <seed> - commit it first`. Line 1 of `<seed>` equal to
|
|
14
14
|
`# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing` -> stop:
|
|
15
15
|
`context draft, not a spec: <seed> - finish brainstorming`.
|
|
16
|
-
3. **Not shipped.** Resolve `piGauntlet.telemetry` from preset and primary project settings; `enabled: false` skips this check. Run `git -C <primary> rev-parse --git-common-dir`; resolve a relative result against `<primary>` and read `<git common dir>/gauntlet/telemetry/<rel with .md -> .yaml>`. When git resolution fails and `jj root` in `<primary>` succeeds, read `<jj root>/.jj/gauntlet/telemetry/<rel with .md -> .yaml>` instead. When the local record is absent, fetch `
|
|
16
|
+
3. **Not shipped.** Resolve `piGauntlet.telemetry` from preset and primary project settings; `enabled: false` skips this check. Run `git -C <primary> rev-parse --git-common-dir`; resolve a relative result against `<primary>` and read `<git common dir>/gauntlet/telemetry/<rel with .md -> .yaml>`. When git resolution fails and `jj root` in `<primary>` succeeds, read `<jj root>/.jj/gauntlet/telemetry/<rel with .md -> .yaml>` instead. When the local record is absent, fetch `refs/telemetry/records` from `telemetry.remote` (default `origin`) and read `<owner>/<repo>/brainstorming/<rel with .md -> .yaml>` from the fetched ref, using the last fetched ref on fetch failure. Degrade to the local-only check when no fetched ref is available, including jj-only workspaces. A valued `shipped_at:` or `abandoned_at:` stops: `spec already shipped at <ts> - write a superseding spec`. No record or `in_progress`: continue; let the extension bind on `plan_check` pass.
|
|
17
17
|
4. **Name.** `<name>` = basename of `<rel>` minus `.md`, minus a leading `YYYY-MM-DD-`:
|
|
18
18
|
`2026-09-17-gh-31-gauntlet-resume.md` -> `gh-31-gauntlet-resume`. Same shape as
|
|
19
19
|
brainstorming's Filename Convention slug; wrappers receive it as `<name>`.
|