pi-gauntlet 8.4.1 → 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 CHANGED
@@ -1,5 +1,15 @@
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
+
7
+ ## v8.4.2 - 2026-11-07
8
+
9
+ ### Fixed
10
+
11
+ - `gatekeep-pr`: a viewer GitHub reports as a ruleset bypass actor can merge a `BLOCKED` PR on the ordinary `merge` row, with CI evidence and every other merge precondition still binding. False or unreadable eligibility fails closed; live non-admin bypass behavior remains unverified.
12
+
3
13
  ## v8.4.1 - 2026-11-07
4
14
 
5
15
  - Make `phase_tracker`, `plan_check`, and `plan_tracker` model-only so codemode scripts cannot make updates that disappear on session reconstruction; requires pi >= 1.5.0.
package/README.md CHANGED
@@ -97,9 +97,9 @@ Non-final `stop` removes an eligible tracked-clean, non-primary worktree, create
97
97
 
98
98
  pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
99
99
 
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, an unknown merge state, and every other merge precondition still withhold; 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>]`.
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 orphan `gauntlet/telemetry` branch, 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.
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.6 - 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.
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 configured `gauntlet/telemetry` branch 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.
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 gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them");
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/heads/gauntlet/telemetry";
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/remotes/${remote === "origin" ? "origin" : "telemetry"}/gauntlet/telemetry`;
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", "gauntlet/telemetry:team/product/brainstorming/doc/specs/seal.yaml"]));
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 gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them");
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/heads/gauntlet/telemetry";
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/remotes/${remote === "origin" ? "origin" : "telemetry"}/gauntlet/telemetry`;
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", "gauntlet/telemetry"], { env });
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", "gauntlet/telemetry", "-m", "telemetry fixture"]);
29
- git(root, ["update-ref", "refs/heads/gauntlet/telemetry", commit]);
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/heads/gauntlet/telemetry", tip]);
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 gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them");
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/heads/gauntlet/telemetry";
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/remotes/${remote === "origin" ? "origin" : "telemetry"}/gauntlet/telemetry`;
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
- if (opts.migrate !== void 0) {
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 = `refs/remotes/${!remote || remote === "origin" ? "origin" : "telemetry"}/gauntlet/telemetry`;
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/heads/gauntlet/telemetry";
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, ["rev-parse", REF]), git(root, ["rev-parse", "refs/remotes/origin/gauntlet/telemetry"]));
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 gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them");
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 gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them; telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
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 gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them");
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/heads/gauntlet/telemetry"), "");
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/heads/gauntlet/telemetry";
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/remotes/${remote === "origin" ? "origin" : "telemetry"}/gauntlet/telemetry`;
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/heads/gauntlet/telemetry";
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, "gauntlet/telemetry") });
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!, "gauntlet/telemetry") });
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);
@@ -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, "gauntlet/telemetry") };
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.1",
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.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 orphan `gauntlet/telemetry` branch 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.
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 orphan `gauntlet/telemetry` branch 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.
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 gauntlet/telemetry.
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 `gauntlet/telemetry`.
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 `gauntlet/telemetry`; retain the plan only in feature history.
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 branch publication.
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
@@ -72,7 +72,7 @@ These bind every rendered report, menu, and external payload (review bodies, rep
72
72
  - The orchestrator resolving a merge conflict itself, or editing or committing a tracked file during a fix wave - owner: `reference/fix-wave.md` `## Sync` and `## Wave`.
73
73
  - A helper's duty run in the orchestrator's own context - owner: `## Harness notes`.
74
74
  - Approving your own PR - owner: `reference/decision-menu.md` `## Availability`.
75
- - Merging around an undispositioned failing check or a binding pending check, or a `BLOCKED`/`UNKNOWN`/`unreadable` merge state - owner: `reference/findings.md` `## Dispositions`.
75
+ - Merging around an undispositioned failing check or a binding pending check, or a merge-state withhold under `reference/post-selection-loop.md` `### Merge preconditions` - owner: `reference/findings.md` `## Dispositions`.
76
76
  - A menu rendered without the pre-menu refresh - owner: `reference/post-selection-loop.md` `### Pre-menu refresh`.
77
77
  - Reading `mergeStateStatus` from anything but a `gh pr view` invocation, attributing a `BLOCKED` to a named rule, or treating a non-`BLOCKED` value as a merge verdict - owner: `verification-brief.md` Section A.
78
78
  - A claim verdict recorded behind a pending tracker stage - owner: `## Progress tracking`.
@@ -14,7 +14,7 @@ Read and apply [menu-layout](../../brainstorming/reference/menu-layout.md) befor
14
14
  - `preserve contract` - keep the baseline and independent blockers; resume scope-preserving course work without the rejected payload on that re-run.
15
15
  - `approve` - approve someone else's non-draft PR with zero blockers; persist approved_head only after success.
16
16
  - `approve workflow run` - execute `gh api --method POST repos/<base-owner>/<base-repo>/actions/runs/<id>/approve`, then refresh and watch under the course.
17
- - `merge` - execute `gh pr merge --match-head-commit <assessed-sha>` with the resolved policy flag and all merge preconditions; never infer approval from a setting.
17
+ - `merge` - execute `gh pr merge --match-head-commit <assessed-sha>` with the resolved policy flag and all merge preconditions; append `--admin` only on the bypass path defined in `post-selection-loop.md` `### Merge course`; never infer approval from a setting.
18
18
  - `reply` - post drafted replyable C# replies; exact `reply <n>` selects one.
19
19
  - `post coverage to ticket` - post the rendered AC coverage block through the resolved tracker verb; renders only under the `own merge` template with issue and rows.
20
20
  - `post coverage to PR` - post the same block with `gh pr comment <N> --body-file`; renders only under the `own merge` template.
@@ -45,7 +45,7 @@ Resolve every actual unmet `### Merge preconditions` reason through `post-select
45
45
 
46
46
  | Observation | Render |
47
47
  |---|---|
48
- | BLOCKED with no local repair | Use findings.md `## Dispositions` for the actor-framed PR-comments line and obstacle verdict; render obstacle even with course unset, plus `Not offered: merge (blocked by GitHub)`; never attribute a named rule |
48
+ | BLOCKED with no eligible bypass and no local repair | Use findings.md `## Dispositions` for the actor-framed PR-comments line and obstacle verdict; render obstacle even with course unset, plus `Not offered: merge (blocked by GitHub)`; never attribute a named rule |
49
49
  | UNKNOWN/unreadable after one re-poll | `stuck - merge state unknown`; `Not offered: merge (merge state unknown)` |
50
50
  | binding pending check | name the check under PR comments, `Not offered: merge (<check name> pending)`; watch only at delivery step 4 |
51
51
  | draft | `Not offered: merge (draft)` |
@@ -87,7 +87,7 @@ Any blocking conclusion in the resolved check set (required or not - `../verific
87
87
 
88
88
  Only `flaky` lifts the blocker; an undispositioned check, `real`, and `ci-infrastructure-broken` keep it until the check - or, for `ci-infrastructure-broken`, the fallback local run (brief Section B, Failed CI row) - is green.
89
89
 
90
- Record dispositions only from direct human check commands. A flaky disposition followed by numeric deliver can resume; no helper can disposition a check. With BLOCKED and no dispositionable local repair, render the obstacle template from `menus.md`, even with course unset, and use verdict `GitHub reports BLOCKED, no local repair applies`. Render its PR-comments line as `GitHub reports this actor's merge as blocked, so merge waits. (merge_state_status BLOCKED; reviewDecision <value>)`; keep the locator and never attribute a named rule.
90
+ Record dispositions only from direct human check commands. A flaky disposition followed by numeric deliver can resume; no helper can disposition a check. With BLOCKED, no eligible bypass under `post-selection-loop.md` `### Merge preconditions`, and no dispositionable local repair, render the obstacle template from `menus.md`, even with course unset, and use verdict `GitHub reports BLOCKED, no local repair applies`. Render its PR-comments line as `GitHub reports this actor's merge as blocked, so merge waits. (merge_state_status BLOCKED; reviewDecision <value>)`; keep the locator and never attribute a named rule.
91
91
 
92
92
  ## Drafted payloads
93
93
 
@@ -15,6 +15,8 @@ Read with `decision-menu.md` at step 6. Select the template from this state map;
15
15
  | first report not ready, course unset | initial |
16
16
  | multi-PR parent round | batch summary |
17
17
 
18
+ Resolve BLOCKED through `post-selection-loop.md` `### Merge preconditions` before selecting an obstacle; treat only BLOCKED without eligible bypass as undispositionable.
19
+
18
20
  Render every menu in full from its template: the verdict line, every available row, and the hint line. Never narrate or summarize a menu; do not substitute "the initial template applied; the human picked 2" for its rendering. Quote a numeric pick only after the rendered menu.
19
21
 
20
22
  Append ` recommended=<n>` to each gate line using the actual rendered number of its tagged recommended row. Emit one first line per turn; when combining batch summary and child menus, emit only the parent `gatekeep-pr/batch-summary` line. Keep runtime guard text, including any quoted escalation instruction, inside the obstacle body, never as a second standalone marker.
@@ -46,6 +48,8 @@ Course holds until the final menu; `show evidence` prints the full record.
46
48
 
47
49
  Start the message with `gauntlet: gate=gatekeep-pr/final`.
48
50
 
51
+ For eligible BLOCKED readiness, render `merge - <policy>-merge on ruleset bypass authority; CI evidence stands` with the resolved policy, and carry `merge_state_status BLOCKED (bypass eligible; reviewDecision <value>)` in the `Evidence:` line below. Keep the ordinary `merge` row and the same recommendation defaults; render no separate bypass row.
52
+
49
53
  Offer available `merge`, `approve`, `fix nits` when nits remain and the course excluded them, `reply` when drafted replies exist, then `stop`. Recommend merge for own PR; recommend approve for someone else's when available, else merge; with neither available recommend stop. Omit merge on a draft with `Not offered: merge (draft)` or below WRITE with `Not offered: merge (needs WRITE on <owner>/<repo>)`. Omit approve on drafts and own PRs. Authorize exactly one delivery iteration for fix nits, leaving course unchanged (`post-selection-loop.md` `### Next action`). Hint: `Type a number; show evidence prints the full record.` Print one compact `Evidence:` line above the `Delivers:` line naming the source (`CI (source: ci)` or the local run), the head SHA, each run and its checks with their conclusions, the binding-pending count, and `merge_state_status`; put the same record in the `show evidence` hint's parenthetical.
50
54
 
51
55
  ## assess terminal
@@ -60,7 +60,7 @@ Resolve every unmet merge prerequisite; retain failed prerequisites and append c
60
60
  | Helper open - needs <what>, or pi failed decision's second slot starts needs <what> | `stuck - needs <what>`. |
61
61
  | Helper open - contract exception, or pi failed decision's second slot starts contract exception: | Render contract exception through `### Contract decision`, not obstacle. |
62
62
  | HEAD equals pre_wave_head with all payloads open, or scoped test unchanged-red after restore | render per ### No-progress obstacle |
63
- | Undispositionable BLOCKED | `GitHub reports BLOCKED, no local repair applies`; invent no rule attribution. |
63
+ | Undispositionable BLOCKED without eligible bypass under Merge preconditions | `GitHub reports BLOCKED, no local repair applies`; invent no rule attribution. |
64
64
  | UNKNOWN/unreadable after step 2's single re-poll, regardless of CI color or a pending binding check | Render `stuck - merge state unknown` through `menus.md`'s obstacle template; let merge state own the withhold reason and the PR comments line. Authorize no sync, offer no ordinary wait row, keep stop last and `[recommended]`, report both usage counters unchanged, and print `Not offered: merge (merge state unknown)`. |
65
65
  | Nonzero gh or incomplete pagination during refresh/refetch | One retry, then `stuck - refresh failed: <actual reason>`. |
66
66
  | Incomplete comment source review | One fresh reviewer retry, then `stuck - comment source review incomplete`. |
@@ -112,7 +112,7 @@ All must hold before `merge` executes; a `merge` pick while one fails is refused
112
112
  - `mergeable == MERGEABLE` (`UNKNOWN` after step 2's single re-poll withholds like `CONFLICTING`)
113
113
  - `merge_state_status` is not `BEHIND`; while it is, run `### Loop sync` under delivery
114
114
  - no undispositioned failing check in the resolved set, no **binding** pending check (`../verification-brief.md` Section B, binding classification)
115
- - `merge_state_status` is not `BLOCKED`, `UNKNOWN`, or `unreadable`, and the pre-menu refresh succeeded (`### Pre-menu refresh`); a non-`BLOCKED` value is never a verdict - it only leaves this row satisfied, and a `gh pr merge` refusal is reported verbatim
115
+ - `merge_state_status` is not `UNKNOWN` or `unreadable`, and the pre-menu refresh succeeded (`### Pre-menu refresh`). For `BLOCKED`, require fresh `permissions.bypass_eligible == true`; lift only this merge-state withhold and keep every other precondition binding. Treat false or unreadable eligibility as no bypass. A non-`BLOCKED` value is never a verdict - it only leaves this row satisfied, and a `gh pr merge` refusal is reported verbatim
116
116
  - no retained, unreviewed comment delta (`### Re-render` step 4)
117
117
  - worktree tracked-clean and synced with the remote head (fixes pushed first)
118
118
  - the repo allows the resolved merge policy: read `gh api repos/<owner>/<repo> --jq '{allow_squash_merge,allow_merge_commit,allow_rebase_merge}'` and require the policy's flag (`squash` -> `allow_squash_merge`, `merge-commit` -> `allow_merge_commit`, `rebase` -> `allow_rebase_merge`) to be `true`; a `false` is a blocker on the `merge` pick, named as `merge policy <policy> is disabled on <owner>/<repo>`, never a `gh pr merge` failure
@@ -120,6 +120,8 @@ All must hold before `merge` executes; a `merge` pick while one fails is refused
120
120
 
121
121
  ### Compare-and-swap
122
122
 
123
+ Re-read `permissions.bypass_eligible` with Section A's fixed GraphQL query beside the freshness read below; apply its fail-closed decoding. On a head, body, or base mismatch across reads, discard eligibility and re-enter Pre-menu refresh. A `bypass_eligible` change from the rendered menu invalidates the selected merge even with `BLOCKED` unchanged: run no `gh pr merge` and re-enter Pre-menu refresh for a fresh selection.
124
+
123
125
  Before every external write, re-fetch `headRefOid`, `state`, `mergeable`, and `mergeStateStatus`, plus actual `body` and `baseRefName`; apply Next action's target check. A body change aborts selected merge pending narrow reassessment and fresh numeric selection. Under a loop rewrite compare remote head to `repair.wave.sync.expected_remote_head`, never replace it on mismatch, and recover to `repair.wave.sync.pre_head` through Sync. Without a loop checkpoint, an entry rewrite keeps its own pre_head for both comparison and recovery. Movement restores/asserts and exits `stuck - remote head moved`; closed/merged state renders closed PR. Other head movement re-syncs and re-enters step 3; merge-state change alone invalidates a selected merge and re-enters Pre-menu refresh. Preserve course and accounting, never open another initial menu mid-course. The gate's own successful push updates the assessed SHA before later writes.
124
126
 
125
127
  Before a merge executes, run the full comment refetch and reconciliation (`### Re-render` steps 1-5). A head move re-enters step 3. A new or changed-body delta aborts the selected merge even when source review finds no blocker: render the fresh final or obstacle template for a new pick. A pending reviewer row, queued/in-progress reviewer run, failed refetch or unreviewed delta withholds merge. Resolve failures through Exit check's bounded retries; no override bypasses a new blocker or unreviewed delta.
@@ -142,9 +144,11 @@ After successful `gh pr review --approve`, persist approved_head as the assessed
142
144
 
143
145
  ### Merge course
144
146
 
145
- `merge` executes as `gh pr merge --match-head-commit <assessed-sha>` with the flag of the resolved merge policy: `squash` -> `--squash`, `merge-commit` -> `--merge`, `rebase` -> `--rebase`. Push and merge are never one pick.
147
+ `merge` executes as `gh pr merge --match-head-commit <assessed-sha>` with the flag of the resolved merge policy: `squash` -> `--squash`, `merge-commit` -> `--merge`, `rebase` -> `--rebase`. Push and merge are never one pick. Append `--admin` only on the bypass path: `merge_state_status == BLOCKED` and `permissions.bypass_eligible == true`, with every merge precondition satisfied. Omit `--admin` on every other path. This flag skips the merge queue; the bypass path merges directly, not through the merge queued template. In batch mode the parent reads `permissions.bypass_eligible` from the retained child record and applies this same predicate and refusal branch, without a new handoff field or batch compare-and-swap.
148
+
149
+ On a nonzero exit, print gh's error verbatim, consume the selection without retry (no second attempt with or without `--admin`), and re-run `### Pre-menu refresh`. Render from the refreshed prerequisites: final while all merge preconditions hold; obstacle with `Not offered: merge (blocked by GitHub)` when `BLOCKED` remains and `bypass_eligible` is no longer `true`; otherwise use the refreshed withhold resolver. Do not treat refusal as a queued merge or `stuck` by itself.
146
150
 
147
- After `gh pr merge` returns, read `gh pr view <N> --json state,mergeCommit`. `state: MERGED` -> write the returned `state` into `pr.state` and `mergeCommit.oid` into `pr.merge_commit` (the same two writes when a queued merge's `wait` re-read returns `MERGED`), render the `AC coverage` block once and re-render the menu under the `own merge` overlay (`menus.md`); any other state (a merge queue) -> one `PR comments` line `The merge is queued, so coverage posts wait. (<state>)` and the merge queued template (wait re-runs the read; show evidence stays hint-only). The block, `<sha>` = `mergeCommit.oid`, one row per `scope.rows` entry in row order with its outcome (`covered`, `deferred per spec to <ref>`, `deviates per spec: <why>`, `elsewhere per spec: <repo>`, `venue: <env> - <observation>, checked before prod deploy`); with no rows (`source: pr`, or an empty `scope.rows`) the block has no row lines and `post coverage to ticket` is not offered. With `source: pr` the header line drops `, spec <path>`. An `in-scope` row whose observation half is `not judged here` renders `covered; observable half checked after merge`.
151
+ On a zero exit, read `gh pr view <N> --json state,mergeCommit`. `state: MERGED` -> write the returned `state` into `pr.state` and `mergeCommit.oid` into `pr.merge_commit` (the same two writes when a queued merge's `wait` re-read returns `MERGED`), render the `AC coverage` block once and re-render the menu under the `own merge` overlay (`menus.md`); any other state (a merge queue) -> one `PR comments` line `The merge is queued, so coverage posts wait. (<state>)` and the merge queued template (wait re-runs the read; show evidence stays hint-only). The block, `<sha>` = `mergeCommit.oid`, one row per `scope.rows` entry in row order with its outcome (`covered`, `deferred per spec to <ref>`, `deviates per spec: <why>`, `elsewhere per spec: <repo>`, `venue: <env> - <observation>, checked before prod deploy`); with no rows (`source: pr`, or an empty `scope.rows`) the block has no row lines and `post coverage to ticket` is not offered. With `source: pr` the header line drops `, spec <path>`. An `in-scope` row whose observation half is `not judged here` renders `covered; observable half checked after merge`.
148
152
 
149
153
  Read and apply [menu-layout](../../brainstorming/reference/menu-layout.md) before presenting these options and before acting on a numeric reply.
150
154
 
@@ -179,7 +183,9 @@ After every push or any mutation that can change readiness (fix wave pushed, PR
179
183
 
180
184
  ### Pre-menu refresh
181
185
 
182
- Refresh read-only before step 5, first report included, and at each Re-render. Run one `gh pr view <N> --json headRefOid,state,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,body,baseRefName,number,title,author,isDraft,headRefName,isCrossRepository,files,additions,deletions,commits,reviews,closingIssuesReferences,headRepository,maintainerCanModify,url,mergeCommit,reviewRequests` and one `gh run list -R <owner>/<repo> -c <headRefOid> --json databaseId,status,conclusion,workflowName,url`. Fold `gh pr view <N> --json headRefOid,body,baseRefName` into that read. Apply target/body freshness from Next action. While locally rewritten under a loop checkpoint compare remote to `repair.wave.sync.expected_remote_head`; preserve that expectation on mismatch and recover to `repair.wave.sync.pre_head` through Sync's restore/assert obstacle. An entry rewrite without a loop checkpoint keeps its own pre_head for comparison and recovery. An unmoved remote retains local-head source: pending until lease push; preserve the loop checkpoint through this read and any local-head assessment. On other head moves re-enter step 3. On an unchanged head replace merge state, reviewDecision, status_checks, actions_runs, and pr.review_requests together and re-derive `viewer.role` per Section A; reclassify via the brief's binding rule and re-resolve the Evidence resolution table. Pending to CI-sufficient dispatches a fresh helper in claim-check-only mode with a disposition for every material claim.
186
+ Refresh `permissions.bypass_eligible` through Section A's fixed GraphQL query on every pre-menu refresh, using the recorded base repository and PR number; apply Section A's fail-closed decoding instead of retaining a cached true. On a head, body, or base mismatch across the reads, discard eligibility and refresh the matching snapshot before offering merge.
187
+
188
+ Refresh read-only before step 5, first report included, and at each Re-render. Run one `gh pr view <N> --json headRefOid,state,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,body,baseRefName,number,title,author,isDraft,headRefName,isCrossRepository,files,additions,deletions,commits,reviews,closingIssuesReferences,headRepository,maintainerCanModify,url,mergeCommit,reviewRequests` and one `gh run list -R <owner>/<repo> -c <headRefOid> --json databaseId,status,conclusion,workflowName,url`. Fold `gh pr view <N> --json headRefOid,body,baseRefName` into that read. Apply target/body freshness from Next action. While locally rewritten under a loop checkpoint compare remote to `repair.wave.sync.expected_remote_head`; preserve that expectation on mismatch and recover to `repair.wave.sync.pre_head` through Sync's restore/assert obstacle. An entry rewrite without a loop checkpoint keeps its own pre_head for comparison and recovery. An unmoved remote retains local-head source: pending until lease push; preserve the loop checkpoint through this read and any local-head assessment. On other head moves re-enter step 3. On an unchanged head replace merge state, bypass_eligible, reviewDecision, status_checks, actions_runs, and pr.review_requests together and re-derive `viewer.role` per Section A; reclassify via the brief's binding rule and re-resolve the Evidence resolution table. Pending to CI-sufficient dispatches a fresh helper in claim-check-only mode with a disposition for every material claim.
183
189
 
184
190
  A failed refresh retains prior values, classifies every pending check as binding, records `merge state not refreshed (<reason>)`, retries once then exits through Exit check. This read never mints `C#` rows and never starts an independent watch. A failing resolved check mints one P# with its failing line from `gh run view <id> -R <repo> --log-failed` and drafted payload; apply the repeated-red/source-repair rule. An action_required run goes through Exit check.
185
191
 
@@ -6,6 +6,8 @@ Read from SKILL.md step 5, after `findings.md`. Input: the integration output. O
6
6
 
7
7
  Render bottom-up for a terminal: least important first, verdict and menu last. Empty sections are omitted - never "None". A failed comment refresh, an incomplete comment source review (`post-selection-loop.md` `### Re-render`), a blocked or unknown merge state, or a binding pending check renders its one line under `PR comments` as well.
8
8
 
9
+ For an eligible BLOCKED merge, omit the blocked PR-comments line; resolve eligibility through `post-selection-loop.md` `### Merge preconditions` and render the bypass consequence through `menus.md` `## final`. For false or unreadable eligibility, render the actor-framed BLOCKED line below and `Not offered: merge (blocked by GitHub)` unchanged. Keep `bypass_eligible` and its value only in the digest `permissions` block; never name the field in rendered output, including obstacle explanations and `show evidence`.
10
+
9
11
  Section order is fixed:
10
12
 
11
13
  Read and apply [menu-layout](../../brainstorming/reference/menu-layout.md) before presenting these options and before acting on a numeric reply.
@@ -54,7 +54,7 @@ Read-only. Fixed `gh` command set - do not substitute ad hoc queries:
54
54
  gh pr view <N> --json number,title,body,author,state,isDraft,headRefName,baseRefName,isCrossRepository,mergeable,headRefOid,statusCheckRollup,files,additions,deletions,commits,reviews,closingIssuesReferences,reviewDecision,headRepository,maintainerCanModify,mergeStateStatus,url,mergeCommit,reviewRequests
55
55
  gh run list -R <owner>/<repo> -c <headRefOid> --json databaseId,status,conclusion,workflowName,url # Actions runs on the assessed head: held-run detection (action_required with or without a rollup entry)
56
56
  gh api user --jq .login # viewer.login; a no-token run reuses discovery's value, unreadable included
57
- gh api graphql -f query='query($o:String!,$r:String!,$n:Int!){ repository(owner:$o,name:$r){ viewerPermission pullRequest(number:$n){ viewerCanUpdateBranch } } }' -f o=<owner> -f r=<repo> -F n=<N> # viewer_permission, can_update_branch; a null field is recorded as `unreadable`
57
+ gh api graphql -f query='query($o:String!,$r:String!,$n:Int!){ repository(owner:$o,name:$r){ viewerPermission pullRequest(number:$n){ viewerCanUpdateBranch viewerCanMergeAsAdmin headRefOid body baseRefName } } }' -f o=<owner> -f r=<repo> -F n=<N> # viewer_permission, can_update_branch, bypass_eligible; a null field is recorded as `unreadable`
58
58
  gh repo view <base-repository> --json nameWithOwner # resolved base repository context, never headRepository
59
59
  gh pr diff <N>
60
60
  gh api repos/{owner}/{repo}/pulls/<N>/reviews --paginate # REST review ids join inline comments; prior-review selection source
@@ -101,7 +101,7 @@ provisioning, so it never re-reads; step 2 re-reads once after provisioning (`re
101
101
  - pr.review_requests: [ { __typename: User, login } | { __typename: Team, name, slug } ] # raw reviewRequests entries
102
102
  - viewer: { login, is_author, role }
103
103
  - prior_review: null | { id, state, commit, submitted_at, body, inline[ { id, path, original_line, body } ], delta: ancestor | not ancestor (<reason>) | unset }
104
- - permissions: { viewer_permission, is_cross_repository, maintainer_can_modify, merge_state_status, can_update_branch, head_url, head_ref, head_pushable } # head_pushable is set in step 2 after provisioning (reference/assessment.md); merge_state_status is `BLOCKED`, `BEHIND`, `UNKNOWN`, `unreadable` (null), or any other GitHub value (`CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `DIRTY`) and is written only from a `gh pr view` invocation that names `mergeStateStatus` (this call, the step-2 re-read, the step-2b re-gather (`reference/sync.md`), the pre-menu refresh before and after the single watch, compare-and-swap), never from an ad hoc GraphQL query or `gh pr list`, and never from a reply carrying `errors`; any field that cannot be read is the literal `unreadable`; menu availability derives from this block (reference/decision-menu.md ## Availability)
104
+ - permissions: { viewer_permission, is_cross_repository, maintainer_can_modify, merge_state_status, can_update_branch, bypass_eligible, head_url, head_ref, head_pushable } # head_pushable is set in step 2 after provisioning (reference/assessment.md); merge_state_status is `BLOCKED`, `BEHIND`, `UNKNOWN`, `unreadable` (null), or any other GitHub value (`CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `DIRTY`) and is written only from a `gh pr view` invocation that names `mergeStateStatus` (this call, the step-2 re-read, the step-2b re-gather (`reference/sync.md`), the pre-menu refresh before and after the single watch, compare-and-swap), never from an ad hoc GraphQL query or `gh pr list`, and never from a reply carrying `errors`; any field that cannot be read is the literal `unreadable`; menu availability derives from this block (reference/decision-menu.md ## Availability)
105
105
  - status_checks: [ { name, kind, status, conclusion, required, url, workflowName } ] # kind: check_run | status_context | unreadable, from the rollup entry's __typename; evidence semantics: Section B Evidence resolution; workflowName from the CheckRun rollup entry (absent on StatusContext)
106
106
  - actions_runs: [ { databaseId, status, conclusion, workflowName, url } ] # from the fixed gh run list call on the assessed head
107
107
  - comments: { inline[ { id, updated_at, user_type, body, ... } ], top_level[ { id, updated_at, user_type, body, ... } ], review_threads[]? } # retain REST body for source-review deltas; C# identity diffs on id/updated_at, Section C gates placeholder detection on user_type
@@ -150,7 +150,7 @@ uses (`https://github.com/<nameWithOwner>.git` or `git@github.com:<nameWithOwner
150
150
  a null `headRepository` (deleted fork) gives `unreadable`;
151
151
  `head_ref` is `headRefName`. `viewer_permission` is the GraphQL
152
152
  `viewerPermission` (null when authenticated as an App -> `unreadable`);
153
- `can_update_branch` is `viewerCanUpdateBranch`.
153
+ `can_update_branch` is `viewerCanUpdateBranch`. Set `bypass_eligible` from the viewer-relative GraphQL `viewerCanMergeAsAdmin`: true alone establishes eligibility; false stays false; null, missing, non-boolean, or a failed/error-bearing response becomes `unreadable`. Never infer bypass eligibility from `viewer_permission` or a repository role. Keep this field in the fixed GraphQL query, not `gh pr view --json`; apply eligibility only through `reference/post-selection-loop.md` `### Merge preconditions`.
154
154
 
155
155
  That `mergeStateStatus` reflects the viewer's own standing is an observation (an exempt viewer reads `UNSTABLE` on a review-required PR), not a documented guarantee; GitHub documents `BLOCKED` only as "the merge is blocked" with no per-rule attribution, so the gate never names which rule blocks. A non-`BLOCKED` value never approves a merge, it only leaves the `merge` row available to the preconditions (`reference/post-selection-loop.md`).
156
156
 
@@ -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 `gauntlet/telemetry` 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.
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>`.