# Changelog

All notable changes to this project will be documented in this file.

## 1.0.3

### Added

- **A compact, honesty-gated per-execution-unit telemetry record, a live watch-cycle producer, and cross-harness regression coverage close the remaining ACs of the dev-loop execution-cap epic (issue [2157](https://github.com/mfittko/dev-loops/issues/2157), epic [2153](https://github.com/mfittko/dev-loops/issues/2153) slice b2 of 4/4).** `buildExecutionUnitRecord` (`packages/core/src/loop/execution-record.mjs`, newly exported at `@dev-loops/core/loop/execution-record`) builds one frozen record per execution unit across the 5 unit kinds this epic bounded (`coordinator_phase`, `reviewer_unit`, `judge_round`, `fixer_pass`, `watch_cycle`). LOCAL/harness-observable metrics (prompt/context bytes, turns, tool calls, local tool time) are always measured — a genuine local zero (e.g. a watch cycle's 0 model turns) is a real zero, never coerced. PROVIDER-owned metrics (input/output/cache-read tokens) are honesty-gated per `TELEMETRY_HARNESS_PROFILES` (`claude` observes provider tokens; `pi`/`codex` do not, since neither has proven per-unit telemetry — the #2157 non-goal): a `null` value records `{available:false, reason}`, NEVER zero/estimated, and — the core fidelity guard the slice-b1 retro flagged as missing — a numeric value for a dimension the harness profile marks unavailable FAILS CLOSED at build time instead of silently being reported. Child wall time (`childWallTimeMs`) is a wall-clock measurement, not provider telemetry: any harness may report it regardless of its provider-token profile — measured when a finite non-negative value is supplied, else `{available:false, reason}`, never a coerced zero. `validateExecutionUnitRecord` re-derives availability from the record's own harness profile rather than trusting a stored `available:true` flag, so a hand-edited record that flips availability still fails closed; `enforceExecutionUnitRecord` throws `GATE-EXEC-EXECUTION-RECORD` naming every failing check. `scripts/loop/run-watch-cycle.mjs` gains a real producer, `buildWatchCycleExecutionRecord`, wired to attach one genuine `watch_cycle` record (sourced from the cycle's own owner/verdict/disposition data, never a hand-built bag) to `result.executionRecord` at every cycle exit point, best-effort (a malformed/absent head never breaks the cycle, only the optional record). Proven by `packages/core/test/execution-record.test.mjs` (the honesty gate, fail-closed fidelity guard, re-derivation on a forged record, deterministic path + writer), `packages/core/test/execution-record-replay.test.mjs` (a before/after replay driving the SAME 5-role pipeline through its real production primitive twice — `enforceRoleBudget`/`enforceReviewerUnitBound`/`buildWatchCycleExecutionRecord` — and asserting the delta report never states a fixed percentage-savings claim), `packages/core/test/execution-record-cross-harness.test.mjs` (claude measures real provider tokens while pi/codex fail closed on a non-null value, and the watch-cycle record is harness-agnostic), and new coverage in `test/loop/run-watch-cycle.test.mjs` (the live wiring attaches a real record and skips it, never throws, when no usable head exists).

- **GitHub write helpers now fail closed when invoked in test mode without an injected stub, so a test can never mutate the live repo (issue [2216](https://github.com/mfittko/dev-loops/issues/2216)).** On the PR-2215 cycle an unstubbed `bun run verify` drove a dedup test's followUpDraft through applyFollowUpIssues -> ensureFollowUpIssue -> createIssue and FILED A REAL ISSUE against this repo; the DI stub seam existed but the test did not thread it, and nothing failed closed. A new TEST-MODE-ONLY guard `assertGithubWriteStubbedInTestMode(run, op, { env })` (`packages/core/src/github/test-mode-write-guard.mjs`, exported at `@dev-loops/core/github/test-mode-write-guard`) throws `GH_WRITE_UNSTUBBED_IN_TEST` at the call site — before any network call — when `NODE_ENV === "test"` and the write would reach the live path with neither sanctioned stub seam present: an in-process DI stub (`run`/`runChild` replaced, e.g. `makeGhMock`, so it is no longer the live `runChild`) or a process-boundary gh stub (`writeGhStub`, which now sets the `DEV_LOOPS_GH_STUB` attestation). Wired into the issue/PR create/edit/close/comment/merge write helpers — `createIssue`/`editIssue` (incl. close via `--state`)/`commentIssue` (`packages/core/src/github/issue-ops.mjs`), `editPr` (`scripts/github/edit-pr.mjs`), `mergePr` (`scripts/github/merge-pr.mjs`, before its first read), and the spawn-based `spawnCreatePr` (`scripts/github/create-pr.mjs`) — never a read helper. Production behavior is unchanged: outside test mode the guard is a no-op (no general network sandbox). Proven by `packages/core/test/test-mode-write-guard.test.mjs` (pure guard: no-op outside test mode, fail-closed on live+unstubbed, pass on either seam; plus createIssue/editIssue/commentIssue AC1/AC2/AC3 cases) and the AC4 regression `test/github/gate-finding-surface-write-guard.test.mjs` (the applyFollowUpIssues->ensureFollowUpIssue->createIssue path is blocked when the write is unstubbed, the commentIssue append sub-path too, and a properly-stubbed follow-up create runs unaffected). Test mode is now derived from the EXECUTING PROCESS's own env (`processEnv`, defaulting to `process.env`), never from the caller-supplied write-target `env` — a fail-open where a sparse caller `env` (e.g. carrying `GH_TOKEN` but omitting `NODE_ENV`, as `createGithubTrackerAdapter({ env })` can pass) could otherwise disable the guard from under a real test.
- **A deterministic guard for the shell-injection-via-untrusted-repo-metadata class — a remote-derived or repo-slug value interpolated into a shell-command string without a charset validator (issue [2206](https://github.com/mfittko/dev-loops/issues/2206)).** `scripts/loop/check-shell-slug-injection.mjs` statically scans runtime source (`extension/`, `scripts/`, `packages/*/src`) for a template literal that both interpolates a slug/remote-derived value (any identifier segment in the interpolation matching `slug`/`remoteUrl`/`originUrl`, so an inline transform like `${slug.trim()}` or `${remoteUrl.split(':')[1]}` still trips it) AND is shell-command-shaped (a `bash -lc` marker, a `*command`/`*cmd` binding, a direct `exec`/`execSync` argument, an object-form `runCommand({command})`, or a `spawn`/`execFile`-family call with `shell: true` — a plain argv spawn is not a sink), and flags it unless that value's full dotted path is proven clean in the same file — guarded by `isCleanRepoSlug(...)` or bound from `normalizeGitHubRepoSlug(...)` (full-path keying, so a guarded `safe.repoSlug` does not exempt an unguarded `evil.repoSlug`). An argument-vector value (`spawn(cmd, ['--repo', slug])`) is never interpolated into a shell string, so it never trips the guard — the safe alternative. Wired into the deterministic gate set via a repo-wide contract test (`test/contracts/shell-slug-injection-clean.test.mjs`) that fails closed on a match, naming `file:line` + the safe alternative; the current tree passes (the sink guarded at `extension/post-merge-update.ts`). Proven by `test/loop/check-shell-slug-injection.test.mjs`: the unsanitized-remote-slug-into-`bash -lc` regression fixture is caught, the guarded `gateCommand` shape and the arg-vector / `normalizeGitHubRepoSlug`-bound / non-shell-literal negatives are not. This is the mechanical complement to the fail-closed-guard reviewer lens (issue [2178](https://github.com/mfittko/dev-loops/issues/2178)), closing a class soft review judgment missed repeatedly.
- **Reviewer dispatch prompts now carry the bounded-reviewer contract, and budget exhaustion produces a durable `blocked` result (issue [2155](https://github.com/mfittko/dev-loops/issues/2155), epic [2153](https://github.com/mfittko/dev-loops/issues/2153) slice 2/4).** `buildAngleNamingSuffix` (`scripts/github/emit-fanout-dispatch.mjs`) appends a "Bounded reviewer contract" block sourced live from the `reviewer-unit-bound` primitive — the per-unit budget (45 model turns / 50 tool calls), the assigned-angles-only scope rule, and every prohibited reviewer operation (poll PR/CI/Copilot state, network probes, validation reruns, orchestration-runtime inspection, unassigned-angle review), rendered off `PROHIBITED_REVIEWER_OPERATIONS` so the prompt can never drift from the enumeration. A new sanctioned wrapper `scripts/github/emit-reviewer-blocked.mjs` makes a live `enforceReviewerUnitBound` call and, on a blocked result, writes one durable per-angle `verdict:"blocked"` findings artifact naming the exact unreviewed angles — the shape `consolidate-fanin` already refuses to consolidate clean, so a budget-exhausted reviewer is never reported as passing. Copilot follow-up: `--run` is now optional and defaults to `--head-sha`; and the suffix's escape hatch now names a directly invokable `emit-reviewer-blocked.mjs` command and covers both ways a unit can fail its bound (budget exhaustion or incomplete angle coverage), not only budget exhaustion. Second Copilot round: the escape-hatch command now names every flag value as a PLACEHOLDER (e.g. `--angles <your assigned angles, comma-separated>`) instead of interpolating the unit's concrete angle names into the shell-command text, closing a command-injection surface (an angle name is only required to be a non-empty string and could otherwise carry shell metacharacters into a copy-pasted command); and the blocked artifact's filename reverted to the plain canonical `<angle>.json` path the scoped reviewer's own normal artifact uses (dropping the `sha256(angle)` suffix introduced in the first round) so a same-head retry's normal artifact overwrites the stale blocked one instead of coexisting beside a second file that left fan-in permanently blocked.
- **A bounded role-budget primitive capping the judge-round and fixer-pass roles at fixed per-role execution budgets (issue [2157](https://github.com/mfittko/dev-loops/issues/2157), epic [2153](https://github.com/mfittko/dev-loops/issues/2153) slice 4/4).** `enforceRoleBudget({ unit, consumed })` (`packages/core/src/loop/role-budget-bound.mjs`) validates a `judge_round` or `fixer_pass` role unit at the trust boundary (deep-clone-then-freeze of the caller's gate context, non-negative-integer consumed counters) and always returns a durable `blocked` record naming every exceeded dimension — never a silent pass — when consumption crosses the fixed budget (judge-round 12 turns / 15 tool calls / 100k input tokens / 10k output tokens; fixer-pass 45 turns / 50 tool calls / at most 1 push per gate round). Pure and offline, mirroring the `reviewer-unit-bound` primitive. Bounded cut of the slice: watcher exclusivity, coordinator phase budget, compact telemetry records, before/after replay, and wiring into the live judge/fixer flow are deferred as non-closing follow-ups.
- **`coordinator_phase` is now a live-enforced role-budget-bound role (40 model turns / 50 tool calls / 20,000 output tokens), with a sanctioned `emit-coordinator-phase-blocked.mjs` wrapper writing the same durable `blocked` findings artifact `consolidate-fanin.mjs` already refuses to consolidate clean (issue [2157](https://github.com/mfittko/dev-loops/issues/2157), epic [2153](https://github.com/mfittko/dev-loops/issues/2153) slice a of 4/4).** Copilot follow-up: `coordinator_phase` is a SYNTHETIC identity with no legitimate same-angle reviewer (unlike the reviewer role's own `<angle>.json`), so its blocked artifact writes to a RESERVED filename (`coordinator-phase--blocked.json`) provably outside `sanitizeScopeSegment`'s output image for any reviewer angle name (that function can never emit two adjacent hyphens) — closing a fail-open where a reviewer angle literally named (or sanitizing to) `coordinator-phase` could otherwise overwrite the blocker or be clobbered by it — and fails closed (exit 2, no write) if a pre-existing file at that reserved path is not recognizably this producer's own prior blocker.
- **Watcher exclusivity resolver primitive: each external wait boundary (target, head, wait-kind) resolves to exactly one sanctioned owner, and no verdict ever authorizes a second observer (issue [2157](https://github.com/mfittko/dev-loops/issues/2157), epic [2153](https://github.com/mfittko/dev-loops/issues/2153) slice a2 of 4/4).** `resolveWatchOwnership({ boundary, evidence, now, staleAfterMs })` (`packages/core/src/loop/watcher-exclusivity.mjs`, exported at `@dev-loops/core/loop/watcher-exclusivity`) is a pure, offline, fail-closed resolver that genuinely keys on the full (target, head, wait-kind) triple: the owner and transition evidence each carry a `target` field, compared against `boundary.target` alongside `head` and `waitKind`, so evidence for a different target can never yield a verdict for this boundary. `secondObserverAuthorized` is `false` in every returned verdict: missing, stale, target-mismatched, head-mismatched, wait-kind-mismatched, or malformed-transition evidence produces a `blocked` verdict that routes escalation through `EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY` and refuses to authorize the advance; a fresh matching owner with a `changed`/`completed` transition is `transition_ready` (advance authorized), while no transition or a `timeout`/`idle`/`pending` one stays `owned_waiting` (a timeout never advances by itself). The resolver is caller-agnostic — it reports the verdict over supplied evidence and does not authenticate the calling runner; caller-identity enforcement is the consumer's responsibility, deferred to the slice-b wiring below. The companion `assertNoOverlappingObserver(operation)` is a default-deny guard with an empty allow-list — every probe/watch/sleep-retry operation throws. Proven by `packages/core/test/watcher-exclusivity.test.mjs` (fail-closed validation including missing/empty `target`, every verdict branch, target-mismatch blocking on both owner and transition evidence, the "secondObserverAuthorized is false in every branch" invariant, stale/mismatch/malformed-transition blocking, and the default-deny guard's non-object coverage). This slice ships the pure primitive only — it is not yet wired into `scripts/loop/run-watch-cycle.mjs`. Copilot review found that an earlier advisory wiring attempt on this branch attached the verdict to the result without consuming it anywhere, mismapped CI's success/failure statuses, discarded the CI watcher's returned head, used a pre-watch lease timestamp (a false `owner_lease_stale` after a long watch), and omitted `workflow_run` boundary coverage; that wiring has been removed. The correct, ENFORCING wiring — recording the claimed head/wait-kind on the runner-coordination lease, gating the wait on validated ownership before it starts via `assertNoOverlappingObserver`, mapping CI's settled statuses and carrying the watcher-reported head into the transition, and covering the `workflow_run` boundary — needs lease-schema changes plus a pre-watch ownership gate and is deferred to slice b (non-closing).
- **Watcher exclusivity is now LIVE-ENFORCED on the real external-wait path: each `(target, head, wait-kind)` boundary is gated on validated single-owner ownership before its watcher starts (issue [2157](https://github.com/mfittko/dev-loops/issues/2157), epic [2153](https://github.com/mfittko/dev-loops/issues/2153) slice b1 of 4/4).** This is the correct, enforcing wiring the slice-a2 entry above deferred. The runner-coordination lease (`scripts/loop/_pr-runner-coordination.mjs`) gains an additive-optional `activeRun.watch = { head, waitKind, updatedAt }` and a new `recordWatchClaim({ repo, pr, runId, head, waitKind })` writer that records the claimed boundary ONLY when this run is the current active owner (mirrors `assertRunnerOwnership`'s owner-confirmed locked write; a foreign/absent owner returns an `ownership_*` conflict and writes nothing, so a run that does not own the PR lease can never register itself as the boundary's watch owner). `scripts/loop/run-watch-cycle.mjs` now gates every external wait — `copilot_review`, `ci`, and `workflow_run` — via `recordWatchClaim` + `resolveWatchOwnership` BEFORE the watcher starts: a claim failure (another run owns the boundary) makes starting a watcher a prohibited second-observer op surfaced through `assertNoOverlappingObserver({ kind: "start_watcher" })`, and a blocked verdict never authorizes a second observer — the coordinator stays in a healthy wait on the owner's evidence under the existing `EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY`, never starting a competing watcher/sleep loop. This closes the resolver's documented caller-identity gap (the a2 primitive was caller-agnostic). For the `ci` boundary, a settled success/failure now maps to the resolver's `completed` transition and the watcher-reported head is carried through, so a fresh push observed during the wait (a head that differs from the owned boundary head) blocks phase advance and re-baselines instead of advancing on a stale head; the resolver also requires the watcher result's `ok === true` before mapping any status, so a malformed/failure result never authorizes a transition. The `copilot_review` boundary is gated pre-watch only — `watchCopilotReview` reports no head, so a post-watch current-head-validated transition there would fall back to the pre-watch owned head and fabricate a validated advance; that post-watch step is intentionally not computed for this boundary (deferred, non-blocking, to slice b2 along with continuous mid-wait exclusivity). With no async run id the harness is single-runner by construction, so the gate is a no-op and every non-async watch path is byte-identical to before. Proven by new fail-closed tests: `recordWatchClaim` ownership guards (foreign owner, no record, missing head, invalid wait-kind) in `test/loop/pr-runner-coordination.test.mjs`, and the gate block/permit paths (foreign-owner block with the named prohibition and the healthy-wait timeout policy, unresolved-head block, owned-boundary copilot permit with no false-advance transition marker, CI settled→`completed` with the watcher-reported head carried through, a CI `ok:false` result never authorizing a transition, and the single-runner no-op) in `test/loop/run-watch-cycle.test.mjs`. The compact telemetry record, before/after fixture replay, continuous mid-wait exclusivity, and cross-harness regression coverage remain for slice b2 (non-closing).
- **A bounded, deterministic fail-fast primitive for unresolvable or unsupported concrete child-model launch requests, capping dev-loop execution amplification at the child-launch seam (issue [2154](https://github.com/mfittko/dev-loops/issues/2154), epic [2153](https://github.com/mfittko/dev-loops/issues/2153) slice 1/4).** `enforceChildLaunchBound(options)` (`packages/core/src/loop/child-launch-bound.mjs`) makes at most one launch attempt and, only on failure, at most one supported-model inventory query for the failed `(run, role or angle, model)` request; when the request is still invalid it returns a durable `blocked` result within the deadline (default 60s) carrying the request identity and a mapped reason (`child_model_unresolvable` / `child_model_unsupported` / `child_launch_failed_model_supported`) plus the failed launch result's own detail verbatim in `launchFailureDetail`; `withinDeadline` is reported on both the success and blocked branches. It never retries the same request, substitutes a model, removes the override, dispatches without the requested override, or reads runtime source — it is a pure function over injected `attemptLaunch` / `querySupportedModels` / `now` dependencies, so it is harness-agnostic and fail-closed by construction (any launch failure blocks, never falls back). Proven by a deterministic adapter fixture in `packages/core/test/child-launch-bound.test.mjs` that records the launch/query event sequence, elapsed bound, and the exact request argument each dependency received; call-log spies assert the primitive's complete invoked-dependency surface is exactly the two sanctioned functions (`attemptLaunch`, then `querySupportedModels` only on failure), with the four forbidden operations (retry/fallback/substituteModel/inspectSource) asserted uncalled and the failed `(run, roleOrAngle, model)` request identity preserved across both calls. Coverage spans the one-attempt/one-query budget, blocker identity and reason, preserved launch-failure detail, cross-harness parity across all three dev-loop harnesses (`pi`, `claude`, `codex`) with unknown-harness rejection, malformed-request rejection, the elapsed bound in both directions (including a slow successful launch), the supported-set inventory shapes (array/Set/malformed fail-closed), and the fail-closed revoke path (a previously-supported model now unsupported blocks with no substitution).
- **Deterministic fan-in finding clustering closes the duplicate-finding/silent-clean gap in the gate-review sub-loop (issue [2156](https://github.com/mfittko/dev-loops/issues/2156), bounded cut 3a).** `packages/core/src/loop/finding-cluster.mjs`, newly exported at `@dev-loops/core/loop/finding-cluster`, is a pure, offline primitive: `computeRootCauseKey(finding, { headSha })` derives a canonical root-cause key from the reviewed head, the finding's `file:line`, and its normalized (trim/lowercase/whitespace-collapsed) `recommendation` — null (UNKEYABLE) when any component is absent. `clusterFindings(findings, { headSha })` groups findings by EXACT key match only (a plausibly-related-but-non-identical finding stays separate); every unkeyable finding is its own singleton, never grouped even with another identical-looking unkeyable finding; and it FAILS OPEN (bad input, missing head, or any internal error) to one singleton cluster per original finding so every finding still reaches the judge unchanged. `projectClusterDisposition` projects one cluster's representative judge decision (`judgeDisposition`/`judgeRationale`/`judgeCriterion`/`followUpDraft`) onto every member reporting the same root cause; `dedupeActListByCluster` reduces the fixer's act list to one remediation per acted cluster; `assertCleanImpliesNoAct(overallVerdict, actCount)` throws when a `clean` verdict is paired with a nonzero act count — any acted finding prevents clean. Wired additively into `consolidate-fanin.mjs` (stamps each ledger finding with a `clusterId`) and `judge-pass.mjs` (clusters + projects the enriched findings, dedupes the `--out` fixer act list, and enforces the clean-implies-no-act invariant before the ledger's `overallVerdict` is written — a clean verdict with a nonzero act count now fails closed instead of writing a lossy or contradictory ledger). Proven by `packages/core/test/finding-cluster.test.mjs` (exact-key clustering, unkeyable-singleton isolation, fail-open behavior, disposition projection, and a duplicate-remediation fixture showing the deduped act list is strictly shorter) plus extended coverage in `packages/core/test/gate-fanin.test.mjs` and `test/loop/judge-pass.test.mjs` and `test/loop/consolidate-fanin.test.mjs`. Defers the judge-round/fixer-pass role-budget primitive to a follow-up (Refs #2156, non-closing).
- **A bounded, fail-closed scoped-reviewer-unit primitive caps a dev-loop reviewer's dispatch to a small assigned-angle set with a default-deny operation surface (issue [2155](https://github.com/mfittko/dev-loops/issues/2155)).** `packages/core/src/loop/reviewer-unit-bound.mjs`, newly exported at `@dev-loops/core/loop/reviewer-unit-bound`, caps a reviewer unit at `REVIEWER_UNIT_MAX_ANGLES` (3) angles and a fixed `REVIEWER_UNIT_BUDGET` (45 model turns / 50 tool calls). `validateReviewerUnit` fails closed and deep-freezes the normalized unit (including nested `gateContext` values) so a reviewer can never mutate the current-head identity it was handed. `assertReviewerOperationAllowed` is a pure default-deny guard: only `inspect_diff`, `inspect_adjacent_code`, and `review_angle` (for an assigned angle) pass, every other kind is denied, and the 7 prohibited probe kinds (e.g. `poll_pr_state`, `rerun_validation`, `inspect_orchestration_runtime`) are named explicitly via the frozen `PROHIBITED_REVIEWER_OPERATIONS` array. `enforceReviewerUnitBound` always returns a durable blocker — never a silent pass — the moment the unit runs over budget (`reviewer_budget_exhausted`, naming the genuinely-remaining unreviewed angles) or leaves an assigned angle unreviewed (`reviewer_coverage_incomplete`); budget exhaustion always wins over a nominally "complete" run. Proven by `packages/core/test/reviewer-unit-bound.test.mjs`: malformed-unit rejection (run/gateContext/headSha/angles), duplicate/over-budget angle rejection, the deep-frozen gate context (including no caller-side-effect on the original nested value), the default-deny operation surface with every prohibited kind and an unassigned/unknown-kind rejection, cross-harness parity (`pi`/`claude`/`codex`) asserting a byte-identical normalized result against a single baseline, budget-exhaustion and coverage-incomplete blocking (naming exactly the genuinely-remaining angles), malformed-`consumed`/`completedAngles` rejection (including a bare-string `completedAngles`), and headSha attribution on the blocked result.

### Fixed

- **Pre-publish `npm ci` smoke no longer blocks the release that creates the version it checks (release hen-and-egg).** `test/contracts/claude-plugin-npm-ci-smoke.test.mjs` runs `npm ci` against the committed `.claude` lockfile, which a release bump pins to the new first-party version (`dev-loops`, `@dev-loops/core`) before it is published. Run as a pre-publish `verify` gate, it 404'd on the not-yet-published tarball and made the release unpublishable — a hen-and-egg where the gate needs the version the gate is about to create. It now skips only when the pinned first-party version is genuinely absent from the registry (an exact-version E404, distinct from a network failure — precisely the pre-publish window), mirroring the existing registry-outage skip; once published the probe resolves and full coverage is restored, and real install breakage on an already-published version still fails closed. First triggered on the v1.0.3 cut because the smoke landed (#2132) one day after v1.0.2.

- **Copilot advisory-body vs gate-coordination deadlock: `request-copilot-review` now sees the same current-head Copilot body signal gate-coordination does, so a stale "changes recommended" body no longer suppresses the very re-request that would clear it (issue [2228](https://github.com/mfittko/dev-loops/issues/2228)).** When Copilot left a `COMMENTED` review whose body carries the changes-recommended marker and its inline threads were then all resolved, the drain deadlocked: `detect-pr-gate-coordination-state` refused `pre_approval_gate` entry with `copilotBodyFeedbackUnresolved` (reading the stale body), while `request-copilot-review` returned `suppressed_same_head_clean` and would not produce the fresh Copilot review that clears it — the two helpers disagreed on convergence with no head-bump-free escape. Root cause: both feed the shared `interpretLoopState` interpreter (where `sameHeadCleanConverged` is already gated on `!copilotBodyFeedbackUnresolved`), but `request-copilot-review`'s `detectSameHeadCleanConvergence`/`detectRoundCapAutoRerequestEligibility` never populated `copilotBodyFeedbackUnresolved` in the snapshot they built, so it defaulted to `false` and wrongly reported clean-converged. The already-computed `summarizeCopilotReviews().hasBodyFindingOnCurrentHead` is now threaded through `parseReviewsPayload` → `fetchCopilotReviewState` into both convergence snapshots, so a current-head body finding correctly makes `sameHeadCleanConverged` false, suppression lifts, and the normal re-request proceeds within the round budget. Fail-closed is preserved: a genuinely unresolved review still blocks, and this only lifts the same-head suppression when Copilot's own current-head body still signals changes. Proven by a new regression test in `test/github/request-copilot-review.test.mjs` (a `COMMENTED` current-head review with the changes-recommended body and resolved threads is no longer suppressed and re-requests Copilot).

- **The gate fan-out emitter no longer splits a sanctioned auto-chunk leftover bundle back into per-angle singleton reviewers, closing an over-dispatch gap on a no-config-table repo (issue [2180](https://github.com/mfittko/dev-loops/issues/2180), Path A; ADR 0048; new ADR 0072).** `resolveFanoutGroups` (`@dev-loops/core/config`) auto-chunks ungrouped angles into ≤`maxAnglesPerGroup` leftover bundles named `group:a+b+c` per ADR 0048's grouped-dispatch-default, but `expandDispatchUnits` (`scripts/github/emit-fanout-dispatch.mjs`) split every one of those bundles back into per-angle singletons — a repo with no configured `gates.fanout.groups` table dispatched one reviewer PER ANGLE regardless of the cap, observed at 25+ reviewers for a ~100-LOC change in a consumer repo, and diverged from the reviewer-budget preflight's own unsplit-bundle count. `expandDispatchUnits` now dispatches EVERY multi-angle resolved unit — a configured group OR an auto-chunk bundle — as ONE shared reviewer recording the resolved unit's own name as provenance `group`, capped/split at `REVIEWER_UNIT_MAX_ANGLES` via the same ordered cap-split an over-cap configured group already used; only a genuinely single-angle unit stays a singleton. The merge guard (`fanoutReviewerPairingError`, re-deriving via `resolveFanoutGroups` at both `detect-checkpoint-evidence.mjs` re-derivation sites) remains the fail-closed authority — it already honored a shared identity within an auto-chunk bundle on equal footing with a configured group, so the emitter no longer needs to be more conservative than the guard by splitting a sanctioned bundle to singletons; a fabricated group spanning angles the guard's re-derivation places in different units still fails closed (adversarial coverage in `packages/core/test/gate-fanin.test.mjs`). `skills/docs/gate-review-sub-loop-contract.md`'s `GATE-EXEC-FANOUT-DISPATCH-EMIT` section is reconciled to match. Emitted `maxConcurrent` still counts EMITTED dispatch units, so wave/concurrency accounting is unchanged. Path A (this fix) is the count-side reconciliation only; primer-owned plan grouping and provenance-authority reload from a persisted plan are deferred to issue 2180's re-scoped quality slice.

### Changed

- **The live gate fan-out now enforces the reviewer-unit angle cap: a configured `gates.fanout.groups` group larger than `REVIEWER_UNIT_MAX_ANGLES` (3) is deterministically split into ordered ≤3-angle dispatch sub-units (issue [2155](https://github.com/mfittko/dev-loops/issues/2155), epic [2153](https://github.com/mfittko/dev-loops/issues/2153) slice 2/4, wiring slice a).** `expandDispatchUnits` (`scripts/github/emit-fanout-dispatch.mjs`) previously shipped a configured group as ONE reviewer carrying ALL its angles, with no cap — a group of N>3 angles became a single over-budget dispatch unit that violated the merged `reviewer-unit-bound` primitive's `REVIEWER_UNIT_MAX_ANGLES` contract. It now imports `REVIEWER_UNIT_MAX_ANGLES` from `@dev-loops/core/loop/reviewer-unit-bound` and, for an over-cap configured group, splits its angles in order into `ceil(N/3)` sub-units of at most 3 angles each (`<name>-part1`, `<name>-part2`, ...), disambiguated (`-x1`, `-x2`, ...) whenever a candidate sub-unit name's SANITIZED form (the same `sanitizeScopeSegment` `dispatchUnitScope` applies) collides with the sanitized form of ANOTHER configured group's own name — including a same-scope collision from two differently-punctuated raw names (e.g. a generated `backend-part1` vs a configured `backend_part1`) — no angle dropped, duplicated, or merged, and angle order preserved. Each multi-angle sub-unit derives a distinct reviewer scope (so `dispatchUnitScope`'s sentinel/prompt-layout scope never collides with a sibling's, with the emitter's existing `seenScopes` guard as the final backstop) and records the CONFIGURED group's name — not its own split sub-unit name — as its provenance `group`, matching `skills/docs/gate-review-sub-loop-contract.md`'s "a shared unit's provenance is the configured group name" rule; a 1-angle split remainder stays a singleton (`group: null`), same as any other singleton. Every sub-unit's angles stay members of the SAME configured group, so the merge guard's `resolveFanoutGroups` re-derivation and `fanoutReviewerPairingError` pairing check (`detect-checkpoint-evidence.mjs`) still pair them honestly. A configured group AT or under the cap keeps its exact name and single-unit shape (unchanged behaviour), and `gates.fanout.maxConcurrent` continues to count EMITTED dispatch units per wave (never angles), so a split yields more units per wave, never more angles per unit. Proven by unit tests in `test/github/emit-fanout-dispatch.test.mjs` (at-cap group stays shared; over-cap 5/4/7-angle groups split with a trailing multi-angle or singleton remainder; no drop/dupe/reorder and every emitted unit within the cap; split sub-units derive distinct scopes; a split sub-unit name disambiguates against a raw- AND sanitized-only colliding configured group name; a real `main()` end-to-end run over a 5-angle configured group resolved through a genuine `.devloops` config via `resolveFanoutGroups`, not a hand-authored plan). Remaining `2155` wiring — the prohibited-probe/reviewer-budget prompt injection and the live `enforceReviewerUnitBound` blocked-artifact emission on budget exhaustion — is deferred to a follow-up slice (non-closing).
- **The `gate-evidence` workflow's `issue_comment` trigger now also re-fires on the head-pinned `approve merge <sha>` marker, unblocking an approved escalated/T1 PR stuck behind a stale FAILURE status (issue [2189](https://github.com/mfittko/dev-loops/issues/2189)).** `gate-evidence-runner`'s job `if` only started an `issue_comment` run when the comment body `startsWith('### Gate review:')`, so a human's `approve merge <sha>` comment never triggered a re-evaluation of the current head — the pre-approval-era FAILURE status stood forever with no event left to clear it. The `if` now starts a run on EITHER marker, under the same OWNER/MEMBER/COLLABORATOR author-association guard. Fail-closed is unchanged: the strict head-pinned approval validity check still lives in `detect-checkpoint-evidence`/`merge-approval`, so a valid approval re-evaluates to SUCCESS and a missing/invalid one still re-evaluates to FAILURE. RETRACTING an approve-merge approval now also re-fires, closing a fail-open gap: an edit that removes the marker re-fires via `github.event.changes.body.from` (present only on `edited`, so it never affects `created`/`deleted`) still matching `'approve merge '`, and a comment deletion re-fires via the new `deleted` `issue_comment` action (where `comment.body` still carries the deleted body); both re-evaluate the head so a retracted approval flips a stale SUCCESS back to FAILURE. Pinned by the exact-composition `job.if` assertion in `test/github/gate-evidence-workflow.test.mjs`.
- **`review` gate's `provablyNoSpecOfRecord` suppression now also drops the process angles that depend on a spec of record, not just `acceptance-criteria` (issue [2201](https://github.com/mfittko/dev-loops/issues/2201)).** `resolveReviewGateAngles` (`scripts/github/write-gate-context.mjs`) — review gate only, no draft/pre-approval change — extends the existing drop to `pr-checklist`, `pr-description`, and `gate-evidence` whenever the PR closes no issue AND its own body carries no AC checklist; each dropped angle present in the union is recorded in `skippedAngles`/`reasons` with the same `"no spec-of-record"` rationale as the existing `acceptance-criteria` drop. Either a closing issue or a PR-body AC checklist still keeps every angle, and an unknown `hasClosingIssue` (`undefined`, e.g. `--prefix-file` mode) still fails closed and keeps everything. Draft and pre-approval angle resolution is untouched. Proven by new tests in `test/github/write-gate-context.test.mjs`.
- **The primer owns a deterministic review-proportionality dispatch plan that scales reviewer cost to diff size/risk within non-overridable quality floors (issue [1984](https://github.com/mfittko/dev-loops/issues/1984)).** `resolveGateDispatchMode` (`@dev-loops/core/config`) gains two new floors that force `full_fanout` regardless of size — a hard-coded, union-of-layers risk-path denylist (`RISK_PATH_DENYLIST_DEFAULT`/`touchesRiskPath`, covering the gate/review, security/auth, contract, hook, and release trees; a repo may only ADD extra globs via `localImplementation.lightMode.riskPaths`, never remove the shipped floor) and the diff's `check-size-budget.mjs` outcome (reused as-is: `escalate`/`block`/a nonzero T1 slice all force full fan-out). Missing `changedFiles`/size-outcome evidence fails the same way — full fan-out, never inline; absence of evidence is never triviality. A new pure composer, `resolveReviewProportionality`, exposes the plan (mode + angle set, mandatory angles always unioned in) as one testable object over `resolveGateDispatchMode` + `resolveGateTier`, without duplicating either. `resolve-gate-dispatch.mjs` wires the two new facts in lazily (only once the cheap file/line cap already passed, so an already-over-cap diff pays no extra git/size-budget I/O). `detect-checkpoint-evidence.mjs`'s merge-gate `scopeUnderThreshold` re-derivation mirrors both floors from the actual merge-base diff and accepts a light-mode verdict only when every floor passes — the recorded `--inline-reason` marker is audit-only, never trusted for accept/reject, so a mislabelled-trivial diff (risk-path touch, over-cap, or a non-pass size outcome) is rejected exactly like today's over-cap case. `check-adr-tripwire.mjs` gains a new `devloops-proportionality` trigger: a base-vs-head change to `.devloops`'s `localImplementation.lightMode.maxFiles`/`maxLines`/`riskPaths` (add, modify, or remove) requires an ADR or a `adr-tripwire:allow` waiver, same as the existing `extension-defaults.yaml` gate-config trigger already requires for the shipped defaults. Documented in `skills/docs/gate-review-sub-loop-contract.md` (new "Review-proportionality dispatch plan" section), `skills/docs/merge-preconditions.md`, and `docs/decisions/0071-review-proportionality-non-overridable-floors.md` (the risk-path denylist and size-outcome-as-floor decisions). No mandatory-angle SET or size-threshold VALUE changes; fan-out concurrency/pacing (issue [1971](https://github.com/mfittko/dev-loops/issues/1971)) untouched. Generated `.claude/` mirror regenerated in lockstep.

- **Head-bump re-gate now seeds a re-running reviewer with the prior round's rejected/deferred findings, so a reviewer never re-litigates settled ground (issue [2175](https://github.com/mfittko/dev-loops/issues/2175)).** The incremental re-gate machinery (touched-file→touched-angle carry-forward via `resolve-angle-carry-forward.mjs`, `write-gate-context.mjs --carried-angles`'s `fanout.pendingGroups` exclusion, and `emit-fanout-dispatch.mjs --pending`'s reduced keyed emit-plan) already existed and is now proven end-to-end by new tests; this phase's one net-new piece is disposition memory (AC3). `write-gate-context.mjs` gains `--prev-head <sha>` (mirroring `resolve-angle-carry-forward.mjs`'s own vocabulary): it reads the prior head's durable findings-log, extracts `reject`/`defer`-disposed findings attributed to an angle re-running this round (an angle in `--angles` not named in `--carried-angles`), and seeds them into the rendered volatile tail via a new `renderBriefingVolatile(..., priorDispositions)` block — "Prior-round dispositions (do not re-raise a rejected finding at a shifted severity)" — carrying each finding's fingerprint, angle, severity, summary, and `judgeRationale`. An `act` (still-open) disposition is deliberately excluded. This is purely additive and fails open: an absent (first round), unreadable, or malformed prior log renders a byte-identical volatile tail to omitting the flag; it never blocks the write, suppresses a finding, or converts a `reject` into an approval. No new module — reuses `buildLogPath` (`write-gate-findings-log.mjs`), `fingerprintFinding` (`_gate-finding-surface.mjs`), and `baseAngleName` (`@dev-loops/core/loop/gate-fanin`). Documented in `skills/docs/gate-review-sub-loop-contract.md`. Proven by new tests in `test/github/write-gate-context.test.mjs` (`resolvePriorDispositions` unit coverage, `renderBriefingVolatile`'s new block and its newline guard, and `writeGateContext`/`--prev-head` integration: seeded/absent/malformed/act-excluded cases), an end-to-end pipeline test in `test/github/emit-fanout-dispatch.test.mjs` (a narrow bump's `--carried-angles` narrows `pendingGroups` and `--pending` emits only the changed-input angle's unit), and a first-round fail-closed pin in `test/github/resolve-angle-carry-forward.test.mjs` (no prior findings-log at all → exit 1, full fan-out). Generated `.claude/` mirror regenerated in lockstep.
- **`dev-loops pr create` now runs the `LOCAL-COMMENT-DISCIPLINE` guard as a fail-closed preflight before `gh`, so a comment-discipline violation is caught before the draft PR opens instead of only at ready-for-review (issue [2171](https://github.com/mfittko/dev-loops/issues/2171)).** Previously the guard ran only at the ready boundary, after the `draft_gate` reviewer fan-out; a violation there was fixed with a comment-reword commit that bumped the head, invalidated the `draft_gate` marker, and forced a second full fan-out. `scripts/github/create-pr.mjs` now reuses the shared `evaluateCommentDiscipline` (from `scripts/loop/check-comment-discipline.mjs`) as a preflight in `main(...)`, on the same `origin/<base>...HEAD` diff surface the ready boundary uses: a `block` outcome refuses PR creation before `gh` is invoked, naming `LOCAL-COMMENT-DISCIPLINE` and the offending path, and the inline `comment-discipline:allow` escape marker is honored identically. An unresolvable base is swallowed to a skip, with `ready-for-review.mjs` / `pre-pr-ready-gate.mjs` unchanged as the fail-closed backstop. No second implementation of the comment-discipline logic. Proven by two new subprocess tests in `test/github/create-pr.test.mjs` over the real-git `initSizeBudgetFixtureRepo` fixture (refusal-before-`gh` on an issue-citing runtime comment; admission with the escape marker).
- **Gate fan-out reviewer concurrency is now Claude-harness-scoped, bounding the DEFAULT single-driver session burst without cutting review coverage (issue [1971](https://github.com/mfittko/dev-loops/issues/1971)).** Gate fan-out review under the Claude Code harness tripped the single-driver session's aggregate model-call rate limit (HTTP 429), killing the whole drive: a wave's burst is the driver's own call plus up to `gates.fanout.maxConcurrent` concurrently dispatched reviewer units, and this repo's `.devloops` override (3) meant a full wave was 4 concurrent high-tier streams. `resolveFanoutEffectiveConcurrency(config, env = process.env)` (`@dev-loops/core/config`) is now harness-aware: under `isClaudeHarness(env)` it additionally clamps its result to the new `CLAUDE_MAX_EFFECTIVE_CONCURRENT` (2), bounding a Claude session's DEFAULT per-wave burst to driver + 2 with no operator-imposed throttle (no lowered `maxConcurrent`, no `sequential: true`) required; every other harness (pi, unknown, no env) still resolves the configured value unchanged, and the shipped cross-harness `gates.fanout.maxConcurrent` zod default (4) and this repo's `.devloops` override (3) are byte-unchanged. Both dispatch callers (`scripts/github/write-gate-context.mjs`, `scripts/github/emit-fanout-dispatch.mjs`) now pass `process.env` through the one shared choke point. `GATE-EXEC-DISPATCH-RETRY-BACKOFF`'s 429/5xx same-unit retry schedule (30s/60s/120s, halve-after-~3-attempts, hard-4xx escalates) is now a pure, tested policy function, `planDispatchRetry(attempt, errorClass)` (`@dev-loops/core/loop/gate-fanin`, next to `backoffMaxConcurrent`), so the retry-before-abort ordering is code the conductor consults rather than prose it re-derives; the pre-existing idempotent single-write findings-artifact guarantee (`GATE-EXEC-COLLECTABLE-DISPATCH`) that makes a same-unit retry safe is unchanged and gains a confirming test. No angle/reviewer coverage is dropped; this is pacing/concurrency only. Documented in `skills/docs/gate-review-sub-loop-contract.md` (ADR [0069](docs/decisions/0069-claude-harness-fanout-concurrency-clamp.md), amending ADR 0056). Proven by `packages/core/test/config.test.mjs` (Claude-harness clamp across default/`.devloops`-style/`maxConcurrent: 1`/`sequential`/non-Claude/no-env cases), `packages/core/test/gate-fanin.test.mjs` (`planDispatchRetry` schedule, 5xx classification, reduce-after-3-without-abort, hard-4xx escalation; a wave-plan bound test under the clamped value), and `test/loop/consolidate-fanin.test.mjs` (retry-overwrite yields exactly one fan-in entry). Generated `.claude/` mirror regenerated in lockstep.
- **Fan-out gate emitters now persist their round plan to a keyed emit-plan artifact, and the fan-in gains a fail-closed `--emit-plan` key guard (issue [2138](https://github.com/mfittko/dev-loops/issues/2138)).** Concurrent gate emitters previously shared one fixed-path emit.json scratchpad, so two rounds in flight clobbered each other's plan. `scripts/github/emit-fanout-dispatch.mjs` now persists its emitted round plan (body = the emitter's own result object) to the keyed `<gate>-<headSha>.emit-plan.json` sibling of the gate-context bundle via the new `buildGateEmitPlanPath` (`scripts/github/write-gate-context.mjs`, reusing `buildGateArtifactPath` so two gates at one head write distinct files by path construction); the persist is success-only — after the full per-unit loop, so a failure anywhere earlier leaves no plan file — and a failed persist takes the module's exit-2 IO-failure tier. `scripts/loop/consolidate-fanin.mjs` gains the optional fail-closed `--emit-plan <path>` key guard: the plan's embedded gate/headSha must match the round being consolidated, and a mismatch, missing, malformed, or unreadable key fails closed (exit 1, `cannot verify emit-plan key` / `is stamped for ...`) before any `--out`/`--ledger-out` write, with programmatic callers failing identically through the same in-function check. `GATE-EXEC-FANOUT-DISPATCH-EMIT` names the keyed artifact and Phase 3 names the `GATE-EXEC-EMIT-PLAN-KEY` check in `skills/docs/gate-review-sub-loop-contract.md`. Proven by `test/github/emit-fanout-dispatch.test.mjs`, `test/github/write-gate-context.test.mjs`, and `test/loop/consolidate-fanin.test.mjs` (persist body and refusal-no-persist, two-gate distinctness, parser pairing, mismatch/malformed rejects, normalization and CLI twins, and an end-to-end emit→consolidate round trip). Generated `.claude/` mirror regenerated in lockstep.
  The sanctioned fan-out procedures now also pass that keyed plan to the shared
  `write-gate-findings-log.mjs --emit-plan` provenance-write seam. Omission
  remains backward-compatible, while a supplied plan fails closed unless the
  caller's fresh provenance matches the full round key, emitted angles/groups,
  one reviewer identity per unit, and reviewer-count floor. The plan remains a
  guard only and never supplies findings or provenance; coverage lives in
  `test/github/write-gate-findings-log.test.mjs`.
- Narrow `wait_watch` startup reading to the public routing contract and a short watch procedure. Re-entry loads the freshly selected route's contracts before acting; watch budgets, gates, authorization, and Pi/Claude continuation behavior remain unchanged.
- **`dev-loops queue list` / `dev-loops project list` auto-detect the repo from the git origin remote when `--repo` is omitted (issue [1952](https://github.com/mfittko/dev-loops/issues/1952)).** Explicit `--repo` still wins. A non-GitHub or absent origin remote fails closed with `INVALID_REPO`, naming both the git-remote fallback and `--repo`. The shared `detectRepoSlug` helper is hardened to only resolve `github.com` hosts (SSH scp, `ssh://`, and HTTPS forms), returning `null` for any other host.

### Added

- **Sanctioned dev-loops merge wrapper with a mandatory `--human-approved-by <login>`; raw `gh pr merge` forbidden (issue [1939](https://github.com/mfittko/dev-loops/issues/1939)).** Merge was the one GitHub mutation with no wrapper — the agent typed a raw `gh pr merge` guarded only by a PreToolUse interception hook, contradicting the doc that said the agent never runs it. `scripts/github/merge-pr.mjs` is now the positive, sanctioned merge path: it runs the FULL merge-precondition set fail-closed and refuses with a non-zero, machine-readable reason naming the specific failing precondition (`human_approver`, `mergeable`, `ci_green`, `title_markers`, `gate_evidence`, `size_budget_human_approval`, `merge_approval`), reusing `detect-checkpoint-evidence` for the draft_gate / current-head pre_approval_gate / unresolved-threads / runner-lock / fan-out-provenance set rather than re-deriving it. A mandatory `--human-approved-by <login>` is validated as a real GitHub login (not a boolean or free text) and stamped on the machine-readable result (`{ ok, merged, mergeCommit, approvedBy, mergeClass, approvalVia, ... }`) under the same `--jq`/`--silent` base-CLI contract as the sibling wrappers. A normal **drain** merge is satisfied by a recorded standing authorization (asserted via `--standing-authorization`; config alone authorizes nothing) OR a fresh operator approval; a **stable-release** (`--stable-release`), **size-escalated**, or **T1-touching** merge is escalated and a standing authorization does not satisfy it — it requires a fresh per-merge approval, verified against an agent-unforgeable, head-pinned record (a genuine `APPROVED` review by `<login>` on the current head, else an operator comment marker `approve merge <headSha>` authored by `<login>`), failing closed on a stale, agent/bot-authored, or wrong-login approval and re-gating on every head bump. The wrapper only merges the PR to its base — it NEVER tags or publishes and does NOT satisfy the operator-owned stable-release approval gate. Raw `gh pr merge` is now a recorded raw-`gh` violation (`scripts/loop/check-retro-tooling.mjs` de-allowlists it now that a wrapper exists) and the PreToolUse Bash gate (`decideBashGate`) denies a raw merge outright as defense-in-depth, naming the wrapper. The wrapper head-pins the merge (`--match-head-commit`), refuses under `autonomy.humanMergeOnly`, requires an explicit `--standing-authorization` for a standing-authorized drain (config alone authorizes nothing), reuses the loop-safe CI-rollup normalization (excluding the separately-validated `gate-evidence` check), takes each reviewer's latest submitted state per login, and fails closed on a non-`MERGED` postcondition or a missing title. It also fails closed on a config load/validation error, excludes `user.type: "Bot"` authors (not just `[bot]` logins), preserves a missing T1 signal so the size gate fails closed, honors an explicit `--standing-authorization=false`/`--stable-release=false`, and fails closed on any non-`{green:true}` CI aggregate. New pure decision core `packages/core/src/loop/merge-approval.mjs` (`isValidGithubLogin`, `resolveMergeClass`, `verifyFreshHumanApproval`, `resolveMergeApprovalDecision`, `resolveCiGreenFromRollup`, `evaluateMergePreconditions`). Documented as `RAW-GH-PR-MERGE-BYPASS` in `skills/docs/anti-patterns.md` and the sanctioned-wrapper section of `skills/docs/merge-preconditions.md`, with the copilot-pr-followup and dev-loop skills updated. Proven by `packages/core/test/merge-approval.test.mjs`, `test/github/merge-pr.test.mjs`, and updated `test/loop/check-retro-tooling.test.mjs` / `packages/core/test/claude-hook-decisions.test.mjs`. Generated `.claude/` mirror regenerated in lockstep.
- **The Claude Code plugin is now self-contained via native dependency auto-install (issue [2123](https://github.com/mfittko/dev-loops/issues/2123)).** The plugin previously shipped `agents/`/`commands/`/`hooks/`/`skills/` only, with generated bodies invoking 46 wrappers as bare `node scripts/…mjs`/`dev-loops <ns> <sub>`; on a plugin-only install (no source checkout, no `scripts/` on disk) those never resolved, and the silent failure could fall through to an unsanctioned raw `gh` call. `.claude/package.json` + a committed `.claude/package-lock.json` declare a dependency on the published `dev-loops` package (Claude Code's native plugin dependency auto-install runs `npm ci --ignore-scripts` at cache time — the npm path, not `bun.lock`, so no lifecycle scripts run and no extra runtime is required on the consumer). A new hand-authored resolver launcher, `.claude/bin/dev-loops-run` (node: builtins only), resolves the toolchain root in order — a live source checkout (detected by a `process.cwd()` walk-up for a `package.json` named `dev-loops` with a sibling `scripts/`) wins UNCONDITIONALLY over any auto-installed copy, even a newer one, so a dogfooder's live edits run with no reinstall/release; else the plugin's auto-installed `dev-loops` package; else a loud hard-stop (exit 3, stderr names the wrapper and the unresolved script) that never falls back to raw `gh`. `rewriteWrapperInvocation` (`packages/core/src/claude/asset-generation.mjs`), composed last in `transformSkill`/`transformAgent`/`transformCommand`, mechanically routes every generated `node scripts/…mjs` and `dev-loops <ns> <sub>` invocation through `dev-loops-run`, byte-identical on args; bundled docs and hooks stay a documented out-of-scope allowlist. `scripts/release/bump-version.mjs` gains `writeClaudePluginPin`, keeping the plugin's `dev-loops` pin and the lock's two first-party entries in lockstep with every release (integrity intentionally omitted pre-publish; `npm ci` recomputes it from the download). Proven by `test/unit/claude-route-invocations.test.mjs`, `test/contracts/claude-plugin-launcher.test.mjs` (hermetic: installed-only, checkout-wins-over-newer, hard-stop with no `gh` branch), `test/contracts/claude-no-bare-invocation.test.mjs`, `test/contracts/claude-plugin-manifest-lockfile.test.mjs`, and `test/unit/release-bump-claude-pin.test.mjs`. Generated `.claude/` mirror regenerated in lockstep.
- **Fail closed when a fixer push leaves tackled review threads unresolved (issue [1988](https://github.com/mfittko/dev-loops/issues/1988)).** A new per-fixer fail-closed boundary, `GATE-EXEC-FIXER-DISPOSITION-BOUNDARY`, sits between one fixer push and the NEXT review or gate round: PR [1975](https://github.com/mfittko/dev-loops/pull/1975) showed repeated fixer pushes and follow-on gate reviews accumulating unresolved threads because a push claimed to address findings without the PR conversation ever carrying a commit-evidenced reply and resolution. A new pure evaluator, `evaluateFixerDisposition` (`packages/core/src/loop/fixer-disposition.mjs`), decides — with no GitHub/git I/O of its own, so the decision is identical across every harness — whether every thread a fixer's handoff marks `disposition: "tackled"` is commit-contained by the observed PR head (`isCommitContainedByHead`, `scripts/github/_commit-containment.mjs`; a SHA alone, an uncontained SHA, or a wrong-branch/superseded SHA never authorizes resolution), replied with that commit's evidence, and resolved on a live re-read. Untackled, deferred, rejected, foreign-authored, and newly arrived threads are never touched by this boundary. `packages/core/src/loop/pr-gate-coordination.mjs` accepts a `fixerDisposition: { complete, incomplete }` input and forces a blocked `feedback_resolution` result — forbidding gate dispatch AND requesting/re-requesting Copilot review, broader than the existing `postDraftForbidden` set — ahead of every lifecycle-state branch, even when `unresolvedThreadCount` reads 0. `scripts/github/verify-fixer-disposition.mjs` is the enforcement CLI: it holds a durable checkpoint under `tmp/gate-findings/<repo-slug>/pr-<N>/fixer-disposition-<headSha>.json` and is idempotent by construction (it checks live state before posting any reply, so a restart or a partial reply-succeeded/resolve-failed run never posts a duplicate evidence reply); `scripts/loop/detect-pr-gate-coordination-state.mjs` surfaces the same evaluation into the shared coordination seam. Documented as `GATE-EXEC-FIXER-DISPOSITION-BOUNDARY` in `skills/docs/gate-review-sub-loop-contract.md` (ADR [0068](docs/decisions/0068-fixer-disposition-fail-closed-boundary.md)) and cross-referenced from the copilot-pr-followup Step 7 follow-up loop. Proven by `packages/core/test/fixer-disposition.test.mjs`, `test/github/_commit-containment.test.mjs`, `test/github/verify-fixer-disposition.test.mjs`, and coverage in `packages/core/test/pr-gate-coordination.test.mjs` / `test/loop/detect-pr-gate-coordination-state.test.mjs`. Generated `.claude/` mirror regenerated in lockstep.

### Fixed

- **A `fanout_fanin` findings-log write for a gate with a mandatory angle now fails closed with no provenance, closing a local/CI evidence divergence at its actual write-time seam (issue [2202](https://github.com/mfittko/dev-loops/issues/2202)).** The clean-round finalization posted a verdict comment carrying provenance but wrote the durable ledger without one, so local `detect-checkpoint-evidence` (which reads the ledger) reported a `requireFanoutProvenance` violation the posted comment never showed — blocking a green-CI merge or forcing a manual ledger backfill (PR [2200](https://github.com/mfittko/dev-loops/pull/2200)'s `draft_gate` round 2), and defeating the next round's carry-forward (PR [2203](https://github.com/mfittko/dev-loops/pull/2203)). A first attempt derived provenance only on a fully-carried round (zero fresh `--findings-dir` artifacts), but that trigger is production-unreachable: every merge gate configures at least one always-rerun mandatory angle, so a real clean re-gate is always MIXED (fresh mandatory + carried), never fully-carried — the derivation never fired. `writeGateFindingsLog` (`scripts/github/write-gate-findings-log.mjs`) now carries a write-time fail-closed guard instead: a new `--execution-mode <fanout_fanin|inline_single_agent>` flag (mirroring `upsert-checkpoint-verdict.mjs`'s own, default `inline_single_agent`) makes a `fanout_fanin` write for a gate that configures a mandatory angle THROW (writing no ledger) unless an explicit `--provenance` or a wrapper-supplied one is present — the omitting-conductor bug becomes impossible to write, rather than a silent divergence. `inline_single_agent` writes and gates with no mandatory angles configured stay exempt and byte-identical to before. The production-unreachable fully-carried derivation in `consolidateGateFanin` (`scripts/loop/consolidate-fanin.mjs`) is removed; the additive wrapper-provenance-threading infra it left behind (`resolveFindingsInput`/`_findings-input.mjs` threading a wrapper `provenance` field through, and `writeGateFindingsLog` accepting it as a fallback source) stays, since any caller may still hand-supply that field. Proven by a rewritten end-to-end regression in `test/github/detect-checkpoint-evidence.test.mjs` that drives a REAL mixed (one fresh mandatory-angle artifact + carried angles) clean re-gate through the real `consolidateGateFanin` → `writeGateFindingsLog` chain: the fanout write with no provenance now throws, and the same write with explicit provenance covering both the fresh and carried angles succeeds and reads `evidenceState: "satisfied"` (no `--skip-fanout-ledger-check`, no manual backfill) — parity with the posted-comment surface. New targeted cases in `test/github/write-gate-findings-log.test.mjs` cover the guard's own exemptions (inline mode, and a gate with no mandatory angle, both stay unaffected).
- **The Pi harness's `gh pr ready`/`gh pr merge` guards no longer go dead in a consumer repo, and now fail closed instead of open (issue [2194](https://github.com/mfittko/dev-loops/issues/2194)).** `extension/post-merge-update.ts`'s `onUserBash` gated its `gh pr ready` draft-gate check and intercepted `gh pr merge` only when the cwd repo's resolved slug equaled the hardcoded `TARGET_REPO_SLUG` ("mfittko/dev-loops") — the same fail-open class the Claude Bash-hook guard suite closed in #2187/#2192, but left open on this harness. Both guards now resolve the managed repo dynamically via the shared `deriveInManagedRepo`/`explicitRepoProvenForeign` predicates (`packages/core/src/loop/bash-command-classify.mjs`, ported from `decideBashGate`'s `inManagedRepo` resolution): a repo counts as managed when a `.devloops` config exists at its root (`RepoContext.inManagedContext`, newly resolved in `defaultResolveRepoContext` via the shared `DEVLOOPS_CONFIG_VARIANTS` list) AND, when the managed repo's identity resolves, the cwd repo IS that managed repo — fail closed: inside a managed context whose identity can't be resolved, both guards still apply rather than passing through. An explicit `--repo`/`-R` target passes a guard through only when PROVEN foreign (the managed slug resolves and differs). `TARGET_REPO_SLUG` now scopes only the Pi extension's own dev-loops-repo self-update flow (`markPendingUpdate`/`queueIfEligible`), which is unchanged. `decideBashGate` (`packages/core/src/claude/hook-decisions.mjs`) is refactored onto the same shared `deriveInManagedRepo` predicate with byte-identical behavior (proven unchanged by the existing `claude-hook-decisions.test.mjs` suite). Proven by new cases in `test/extension-post-merge-update.test.mjs` (guard active and gate/intercept enforced in a managed consumer repo of a non-dev-loops slug; guard stays active when the managed repo's identity is unresolvable; an explicit proven-foreign `--repo` and a non-managed cwd both pass through for both verbs) and a `deriveInManagedRepo`/`explicitRepoProvenForeign` truth table in `packages/core/test/bash-command-classify.test.mjs`. A follow-up Copilot review found the resolved slug itself, derived from `git config --get remote.origin.url`, reached the gate-command shell-string interpolation in `extension/post-merge-update.ts` unvalidated: a hostile remote (e.g. `git@github.com:acme/widgets;id`) could inject a second shell command. `normalizeGitHubRepoSlug` (`packages/core/src/loop/bash-command-classify.mjs`) now returns `null` for any candidate outside the strict `owner/name` GitHub-identity charset (`/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/`) instead of passing shell metacharacters through, and the gate-command sink fails closed (refuses to run the gate) if a non-null resolved slug is ever not clean, as belt-and-suspenders. Proven by new cases in `packages/core/test/bash-command-classify.test.mjs` and `test/extension-post-merge-update.test.mjs` (a hostile slug is rejected at the source and, independently, at the sink; the unresolvable-identity fail-closed case now also covers the `gh pr merge` path). A round-2 Copilot review found two more fail-open cracks: `defaultResolveRepoContext`'s remote-url lookup let a thrown/rejected `exec` (timeout, spawn failure) bubble out of the whole function, so `resolveRepoContextSafe` discarded the already-known `repoRoot`/`inManagedContext` and returned `null` for the WHOLE context — fail open — instead of the same `{ repoRoot, repoSlug: null, inManagedContext }` shape the handled non-zero-exit branch already returns; and `explicitRepoProvenForeign` proved a `--repo` foreign off of any two differing non-null slugs, so a managed slug that bypassed the normalizer (e.g. an injected `acme/widgets;id`) could still wave an explicit target through. Both are fixed: the remote-url lookup is now wrapped in try/catch, and `explicitRepoProvenForeign` requires both the managed and explicit slugs to be clean `owner/name` identities (`isCleanRepoSlug`) before it can prove foreign-ness, falling back to fail-closed otherwise. Proven by a new `test/extension-post-merge-update.test.mjs` case (a rejected remote-url lookup in a managed context still runs the draft-gate guard) and an extended truth-table case in `packages/core/test/bash-command-classify.test.mjs` (a non-clean managed slug can never prove foreign).
- **`upsert-checkpoint-verdict.mjs` no longer posts a `pre_approval_gate` verdict with null size evidence when `--size-budget-json` is omitted, closing a fail-open-to-hard-block hole in the size-budget merge gate (issue [2185](https://github.com/mfittko/dev-loops/issues/2185)).** `--size-budget-json` was always optional, so a `pre_approval_gate` verdict emitted without it posted `sizeOutcome`/`sizeTouchesT1`/waiver fields as `null`; post-[1960](https://github.com/mfittko/dev-loops/issues/1960) the size gate reads that null evidence as a hard block at merge time, turning an omitted flag into an unmergeable PR instead of the intended human-approval fallback. `upsertCheckpointVerdict` (`scripts/github/upsert-checkpoint-verdict.mjs`) now auto-derives the size budget in-process for `--gate pre_approval_gate` whenever `--size-budget-json` is absent, calling the SAME `evaluatePrSizeBudget` (`@dev-loops/core`'s `scripts/loop/check-size-budget.mjs`, injected with a real default exactly like `ready-for-review.mjs`/`pre-pr-ready-gate.mjs` already do) against the PR's base ref — no second size-computation implementation. The prior explicit-file validate+derive logic is factored into one shared `applySizeBudgetFields` helper so the explicit `--size-budget-json` override (still read verbatim, still wins when supplied — AC3 back-compat) and the new auto-derive path run through the identical fail-closed checks (outcome enum, finite non-negative `t1SliceLoc`, well-typed `.waiver`). A `pre_approval_gate` call whose base ref cannot be resolved (`gh pr view --json baseRefOid,labels` fails or returns empty) now fails closed with an actionable error naming the unresolved base ref and the `--size-budget-json` escape hatch, rather than silently posting null evidence. `draft_gate`/`review` verdicts are unchanged — no auto-derive, no size fields when the flag is omitted — since the size merge gate only reads `pre_approval_gate` evidence. Proven by new cases in `test/github/upsert-checkpoint-verdict.test.mjs` (auto-derive populates non-null `sizeOutcome`/`sizeTouchesT1` via an injected fake `evaluatePrSizeBudget`; unresolved-base-ref fails closed; the explicit `--size-budget-json` override still wins and skips auto-derive entirely; `draft_gate` without the flag still posts no size fields).
- **A compound-command bypass in the `gh pr ready`/`gh pr merge` Bash-hook guard is closed (issue [2193](https://github.com/mfittko/dev-loops/issues/2193)).** `decideBashGate` (`packages/core/src/claude/hook-decisions.mjs`) read only the FIRST matching `gh pr ready`/`gh pr merge` shell segment's `--repo` value to decide whether an explicit repo was proven foreign, and passed the WHOLE command through once that first segment resolved foreign — so a proven-foreign leading segment shielded a later, unguarded managed-repo segment in the same compound command (`gh pr merge --repo other/x 1 && gh pr merge 2`), reachable whenever `humanMergeOnly` is false. The create and external-write paths already closed this class with per-segment `.some()` scoping; the ready/merge path now mirrors it. Two new helpers, `extractRepoFlagsFromGhPrMergeSegments`/`extractRepoFlagsFromGhPrReadySegments` (`packages/core/src/loop/bash-command-classify.mjs`), return `{ segment, explicitRepo }` for every matching segment (ignoring `--help`/`-h`), mirroring `extractRepoFlagsFromGhPrCreateSegments`. `decideBashGate` now passes a command through only when EVERY gated-verb segment is PROVEN foreign (explicit repo present, managed slug resolves, and demonstrably differs); a segment with no explicit repo, or an unresolvable managed slug, stays gated (fail closed). The subsequent `inManagedRepo` cwd guard is unchanged. Proven by new cases in `packages/core/test/bash-command-classify.test.mjs` (per-segment extraction) and `packages/core/test/claude-hook-decisions.test.mjs` (a later managed segment behind a proven-foreign leading one is denied for both verbs, an all-foreign compound command still passes, and an unresolvable managed slug stays fail-closed). A follow-up Copilot review found the per-segment scoping was still bypassable through a standalone `&` (the async/background shell operator): the shared `SHELL_SEGMENT_SEPARATOR` splitter (`packages/core/src/loop/bash-command-classify.mjs`) did not treat a lone `&` as a segment boundary, so `gh pr merge --repo other/x 1 & gh pr merge 2` was read as one un-split segment and its explicit foreign `--repo` shielded the second, managed-repo `gh pr merge`. `&` is now added to the separator alternation AFTER `&&`, so `&&` still consumes as one two-character boundary (never mis-tokenized into two empty `&` splits) while a standalone `&` now also terminates a segment — closing the same shielding gap for the `gh pr ready`/`gh pr merge` gate and, since the splitter is shared, for the `gh pr create`/external-write paths too. Proven by new `&`-joined cases in both test files (per-segment extraction, gate denial on a later managed segment, an all-foreign `&`-joined command still passing, and an `&&`-vs-`&` boundary-count pin).
- **`dev-loops-run` now resolves the dev-loops toolchain from its own binary location, so a source-checkout launcher linked onto `PATH` works from any directory (issue [2188](https://github.com/mfittko/dev-loops/issues/2188)).** The launcher (`.claude/bin/dev-loops-run`) resolved the toolchain root with only a CWD walk-up and the plugin's installed-package path; a source-checkout copy symlinked onto `PATH` and invoked from an unrelated working directory matched neither (that tree has no dev-loops checkout, and a source checkout has no `.claude/node_modules/dev-loops`), so it hard-exited even though the checkout it lived in was complete. A third resolution path is added between the two existing ones — a walk-up from the launcher's own real binary path (`findCheckout(path.dirname(realpathSync(process.argv[1])))`), guarded so a broken symlink or unresolvable path fails closed to "not found" rather than crashing. Precedence is now CWD checkout > binary-location checkout > installed package, so a local checkout at the CWD still wins and normal plugin users (installed launcher, no checkout ancestor) still fall through to the installed package unchanged. Proven by new hermetic cases in `test/contracts/claude-plugin-launcher.test.mjs` (a source-checkout binary invoked via a `PATH` symlink from an unrelated cwd resolves the checkout it lives in; CWD-checkout precedence still wins over the binary-location checkout).
- **The size-budget merge gate is no longer dark, and its absent-evidence path no longer deadlocks a fresh human approval (issue [1960](https://github.com/mfittko/dev-loops/issues/1960)).** The `pre_approval_gate` gate-verdict procedure ([Copilot PR Followup](skills/copilot-pr-followup/SKILL.md)) now computes the size budget via `check-size-budget.mjs` and always threads it into `upsert-checkpoint-verdict.mjs` via `--size-budget-json` — reused verbatim, never recomputed — so `sizeOutcome`/`sizeTouchesT1`/waiver fields are populated on every posted verdict instead of reading back `null`. `buildPreMergeGateCheck` (`scripts/github/detect-checkpoint-evidence.mjs`) — the authoritative pre-merge path the `gate-evidence` CI check and `merge-pr.mjs`'s own gate-evidence probe both run — now consults `resolveSizeBudgetHumanApprovalRequired` (`@dev-loops/core/loop/size-budget-merge-gate`) once the base `pre_approval_gate` verdict is itself established, remapping the persisted `sizeTouchesT1` to `touchesT1` and deriving the review decision via `verifyFreshHumanApproval`/`countUnresolvedHumanChangesRequested`; an escalated/T1 PR now fails closed on this path without a human `APPROVED` review, while a `pass`/non-T1 PR still merges with none. Because this stateless surface has no single named approver, it derives its approval signal by trying the shared `verifyFreshHumanApproval` resolver (`@dev-loops/core/loop/merge-approval`) for every distinct human login the fetched reviews/comments surface, so EITHER a head-pinned `APPROVED` review OR a head-pinned `approve merge <headSha>` operator comment from any one of them satisfies it, honoring the solo-owner comment path `merge-pr.mjs` already accepts. Separately, `resolveSizeBudgetHumanApprovalRequired`'s own early-return guards previously returned "approval required" for absent/unreadable size evidence BEFORE reaching the approval check, so a fresh human approval could never satisfy the gate once size evidence was missing (the exact deadlock class PR [2170](https://github.com/mfittko/dev-loops/pull/2170) hit); absent/ill-typed size evidence now folds into the same escalated-review path and still reaches the approval + unresolved-CHANGES_REQUESTED checks, so a fresh approval clears it while no-approval still blocks. Documented in `skills/docs/merge-preconditions.md` and `skills/docs/gate-review-comment-contract.md`. Proven by new cases in `packages/core/test/size-budget-merge-gate.test.mjs` (absent-size + approval clears, absent-size + no approval blocks, absent-size + unresolved CHANGES_REQUESTED still blocks) and `test/github/detect-checkpoint-evidence.test.mjs` (escalate/T1 blocks without a human `APPROVED` review or approve-merge comment, a `pass`/non-T1 PR passes with no review, null/ill-typed size fields fail closed, an unresolved `CHANGES_REQUESTED` alongside an `APPROVED` still blocks). Generated `.claude/` mirror regenerated in lockstep.
- **The gate provenance validator now resolves the angle-pool / fanout-groups / mandatory-angle layer from the PR HEAD commit's config, not the invoking checkout's, unblocking rename-class config PRs (issue [1972](https://github.com/mfittko/dev-loops/issues/1972)).** `buildFanoutEnforcement` (`scripts/github/detect-checkpoint-evidence.mjs`) previously loaded ONE `.devloops` config from the invoking checkout (`loadDevLoopConfig({ repoRoot: resolveRepoRoot(cwd) })`) and threaded it into `resolveGateConfig`, `resolveGateAngleContract`, and `resolveFanoutGroups` (including the per-candidate resolution inside `readLedgerProvenanceInAny` and the `resolvedGroups` field). The fan-out ledger's BYTES are already read from any checkout (`resolveLedgerCheckouts`), but a fan-out that ran conformantly under a PR's own angle rename, regroup, or pool edit was checked against the OLD invoking-checkout config, so a pre-merge check run from a checkout that predates the change reported a false out-of-pool, missing-mandatory, or non-configured-group violation — making the config-changing PR unmergeable from a stale `main` with no worktree-only workaround. `loadDevLoopConfig` (`packages/core/src/config/config.mjs`) gains a `devloopsOverride` option that sources the devloops (primary override) layer's content directly instead of reading it off disk, reusing the exact same parsing/schema-validation/merge path (`parseConfigContent`/`applyParsedLayer`, factored out of the disk-only `readConfigFile`/`applyLayer`); `buildFanoutEnforcement` now reads the PR HEAD commit's committed `.devloops` via `git show <headSha>:.devloops` (bare, then `.yaml`/`.yml`/`.json`, mirroring the disk loader's own precedence — git worktrees of one repo share a single object store, so this resolves from any checkout as long as the head commit was fetched into any of them) and resolves ONE `angleConfig` from it, reused across every angle-layer resolver so they can never drift apart. Falls back to the invoking checkout's own config — never looser, never a silent enforcement skip — when the head commit isn't resolvable locally or its `.devloops` fails to parse/validate; extension defaults and `.pi/dev-loop/defaults` still come from the invoking checkout's disk, only the `.devloops` layer is re-sourced from the head. Documented in `skills/docs/merge-preconditions.md`. Proven by `packages/core/test/config.test.mjs` (`devloopsOverride` layering, `raw: null` skip, malformed-override fallback error) and four new `test/github/detect-checkpoint-evidence.test.mjs` cases (a rename-class PR validating from a stale invoking checkout; a head-config-violating ledger still failing closed; deterministic fallback on an unresolvable head commit and on a malformed head `.devloops`). Generated `.claude/` mirror regenerated in lockstep.
- **The sanctioned merge wrapper's `size_budget_human_approval` gate now honors the same head-pinned `approve merge <headSha>` operator comment the `merge_approval` gate accepts, unblocking solo-owner escalated/T1 PRs (issue [2161](https://github.com/mfittko/dev-loops/issues/2161)).** `resolveSizeBudgetHumanApprovalRequired` (`packages/core/src/loop/size-budget-merge-gate.mjs`) previously read ONLY a human-scoped `reviewDecision` derived from review objects — but GitHub forbids self-approval, so a solo-owner repo can never produce an `APPROVED` review, and the size gate stayed fail-closed-required even when the operator had recorded approval via the comment `merge-pr.mjs` itself advertises. `evaluateMergePreconditions` (`packages/core/src/loop/merge-approval.mjs`) now computes `verifyFreshHumanApproval` once and feeds its `.satisfied` boolean into the size gate as a new optional `humanApprovalSatisfied` input (`humanApprovalSatisfied === true || reviewDecision === "APPROVED"` satisfies it), so both `merge_approval` and `size_budget_human_approval` draw "valid human approval" from the one shared, head-pinned, bot/agent-excluding resolver instead of two divergent checks. The size gate keeps every existing fail-closed guard, including zero unresolved human `CHANGES_REQUESTED`; a stale-head or unresolved-`CHANGES_REQUESTED` comment approval still refuses. `scripts/github/merge-pr.mjs` drops the now-unused `resolveHumanReviewDecision` wiring. T1/escalated human-approval policy is unchanged; no size-classification thresholds changed. Proven by new tests in `packages/core/test/size-budget-merge-gate.test.mjs`, `packages/core/test/merge-approval.test.mjs`, and `test/github/merge-pr.test.mjs`.
- **Local implementation no longer runs a pre-pull-request review fan-out that duplicated the PR lifecycle gates (issue [2031](https://github.com/mfittko/dev-loops/issues/2031)).** The `local-implementation` skill's developer implementation loop now performs exactly one developer self-check against the merged plan (rule `LOCAL-DEV-SELF-CHECK-NO-FANOUT`); the pre-approval angle fan-out step that ran before the PR existed is removed. Multi-reviewer angle fan-out and fan-in occur only at the conductor-owned pull-request lifecycle gates, the `draft_gate` and `pre_approval_gate`. A local review artifact is never lifecycle-gate evidence, and any review with a reviewer that did not complete must not be summarized as clean. Cross-harness regression coverage in `test/contracts/local-implementation-delegation-contract.test.mjs` and `test/contracts/gate-angle-carry-forward-routing-contract.test.mjs` guards the boundary. Generated `.claude/` mirror regenerated in lockstep.
- **The durable fan-out findings-log ledger write is now unbypassable at verdict-post time, closing the `--skip-fanout-ledger-check` asymmetry (issue [1970](https://github.com/mfittko/dev-loops/issues/1970)).** A `requireFanoutEvidence` `fanout_fanin` verdict could be posted via `upsert-checkpoint-verdict --findings-json` while the canonical durable ledger at `tmp/gate-findings/<slug>/pr-<n>/<gate>-<head>.json` was never written (the consolidator's `--ledger-out` wrote only a scratch path): the local pre-merge hook (`detect-checkpoint-evidence`) fail-closed correctly on the missing ledger, but the CI `gate-evidence` check passed anyway because it runs with `--skip-fanout-ledger-check`, so CI reported green on a merge-blocking evidence gap. The former advisory `findingsLedgerWarning` branch in `scripts/github/upsert-checkpoint-verdict.mjs` is upgraded to a hard, fail-closed refusal scoped to `requireFanoutEvidence` `fanout_fanin` gates: the verdict-post now refuses (naming the missing ledger path and the `write-gate-findings-log` write step) unless that canonical durable ledger for the reviewed head already exists on disk. This fires at post time — earlier than the local pre-merge hook — reusing `enforcePostTimeFanoutMode`'s `buildFanoutEnforcement` descriptor so the post-time and merge-time boundaries share the one `ledgerExists` predicate and can never drift. CI genuinely cannot read the gitignored, worktree-local ledger, so `--skip-fanout-ledger-check` stays as a deliberate, justified exception now documented in `skills/docs/merge-preconditions.md`, which names this verdict-post refusal as the layer that closes the local write-skip the CI check cannot cover; no code change makes CI read the machine-local ledger. Proven by a two-arm test in `test/github/upsert-checkpoint-verdict.test.mjs` (ledger-absent → refused with the path/write-step message; ledger-present → posts) plus updated fan-out fixtures that stage the canonical durable ledger. Generated `.claude/` mirror regenerated in lockstep.
- **Gate dispatch-prompt provenance now binds to the sanctioned emitted unit, not a coordinator-authored record (issue [2131](https://github.com/mfittko/dev-loops/issues/2131)).** PR [2129](https://github.com/mfittko/dev-loops/pull/2129) exposed that the dispatch-prompt record bound to a caller-supplied prompt and the fan-in (`verify-dispatch-prompt-layout.mjs`) only checked LEADING-prefix alignment — a matching invariant prefix never proved an unchanged suffix — so a hand-composed pointer-seeding prompt, a paraphrased/altered suffix, and any mismatched delivered prompt all passed a clean gate. `record-dispatch-prompt-layout.mjs` now records `promptContentHash`, the sha256 of the FULL recorded prompt (never truncated). At fan-in, `evaluateDispatchPromptLayout`/`verifyDispatchPromptLayoutForHead` re-discover the sanctioned emitter's canonical `<gate>-<headSha>.dispatch-prompt-<scope>.txt` emitted file by name under `tmp/gate-context/**` (never trusted from the record's own stored path) and fail closed unless the record's `promptContentHash` equals that file's hash AND the emitted file leads with the invariant prefix INLINE — rejecting pointer-seeding, altered suffixes, and mismatched deliveries before clean publication/ready, while a compliant re-emit (`emit-fanout-dispatch.mjs`) recovers without erasing audit history. A present record with no hash or no emitted unit fails closed (never grandfathered); a round with no dispatch-prompt records at all stays progressive/unblocked. This binds recorded-layout identity to generated-file identity only; delivered-task identity (the Claude Agent-tool relay hop) stays a documented best-effort boundary rather than a faked file-hash-as-delivery-proof claim. `GATE-EXEC-FANOUT-DISPATCH-EMIT` / `GATE-EXEC-BRIEFING-PREFIX` in `skills/docs/gate-review-sub-loop-contract.md` reconciled with the enforcement they now describe. Proven by `test/github/dispatch-prompt-layout.test.mjs` (binding, altered suffix, pointer seeding, missing emitted unit, recovery) and `test/loop/consolidate-fanin.test.mjs` (fan-in fails closed on pointer-seeded/drifted emitted units; sanctioned round consolidates). Generated `.claude/` mirror regenerated in lockstep.
- **The Copilot follow-up loop no longer converges past a body-only "Changes recommended" review (issue [2023](https://github.com/mfittko/dev-loops/issues/2023)).** Convergence was decided from inline review THREADS only, but Copilot now posts its summary in the review BODY as a `COMMENTED` review headed `### 🟡 Changes recommended` (finding) or `### 🟢 Approval recommended` (clean). With zero inline threads `unresolvedThreadCount` was `0`, so a body-only finding read as Copilot-clean and could converge and merge a flagged defect. The current-head Copilot review body/state is now read as an ADDITIONAL unresolved-feedback surface, unioned with (never replacing) inline-thread detection: `copilotReviewBodySignalsChanges` (`packages/core/src/github/copilot-helpers.mjs`) classifies a review's disposition (`CHANGES_REQUESTED` always blocks; a `COMMENTED` body signals a finding via the 🟡 / "changes recommended" marker; 🟢 / "approval recommended" / empty / footer-only / "no changes recommended" is clean), and `summarizeCopilotReviews` surfaces `hasBodyFindingOnCurrentHead` from the LATEST timestamped current-head review (a later approval supersedes an earlier finding on the same head; the degenerate all-null-timestamp case fails toward surfacing). The state machine (`packages/core/src/loop/copilot-loop-state.mjs`) carries it as a new `copilotBodyFeedbackUnresolved` snapshot field that `interpretLoopState` unions with `unresolvedThreadCount` for state routing (→ `UNRESOLVED_FEEDBACK_PRESENT`), folds into the round-cap `cleanThreads` check (a body finding forces `ROUND_CAP_REACHED` instead of `ROUND_CAP_CLEAN_FALLBACK`), and guards `sameHeadCleanConverged`. Both detectors (`scripts/loop/detect-copilot-loop-state.mjs`, `scripts/loop/detect-pr-gate-coordination-state.mjs`) thread the signal through their `buildSnapshotFromPrFacts` calls. Inline-thread counters are untouched. Proven by new tests in `packages/core/test/copilot-helpers.test.mjs` and `packages/core/test/copilot-loop-state.test.mjs`.
- **The standalone `review` gate no longer creates a duplicate submitted review on a same-head correction (issue [2030](https://github.com/mfittko/dev-loops/issues/2030)).** `scripts/github/upsert-checkpoint-verdict.mjs` hard-coded `existing = null` for `gate: review`, so a same-head rerun of a submitted COMMENT review POSTed a second review instead of updating or suppressing the first (the core body parser deliberately returns `null` for a `review` header, so no path could recognize the already-submitted review as the round's existing surface). A new `findOwnSubmittedReview` helper (`scripts/github/_gate-finding-surface.mjs`) resolves the caller's OWN same-head submitted review-gate review — matched by own author, head `commit_id`, and the `` ### Gate review: `review` `` header, with the `api user` login read deferred until a header/head candidate exists. The review gate now routes same-head corrections by body equality: a byte-identical body is a `noop` (no second review), a body-only correction updates the submitted review body in place via `updateGateReview` (`action: "updated"`), and a correction that needs a NEW inline comment on an already-submitted review fails closed (GitHub has no endpoint to attach it) with an actionable error naming the new review-scoped `--new-round` escape hatch that forces a fresh review round. The pending-review flow (`findOwnPendingReview`/`submitPendingReview`) and `draft_gate`/`pre_approval_gate` verdict posting are unchanged; the new scan is reached only when `isReviewGate` is true. The usage text and review-gate inline comment no longer claim the review gate "always creates a fresh review". Proven by new tests in `test/github/upsert-checkpoint-verdict.test.mjs` (same-head noop, body-only update, new-inline fail-closed, `--new-round` fresh review with a distinct id, `findOwnSubmittedReview` author/head/header/pending matching, and `--new-round` review-gate scoping).
- **A dev-loop runner no longer leaves an active tracker issue in `Next Up` after creating a draft PR (issue [2029](https://github.com/mfittko/dev-loops/issues/2029)).** The pure board-column derivation in `packages/core/src/loop/queue-board-sync.mjs` treated an open DRAFT linked PR as not-yet-in-flight, so the board advertised actively owned work as pickable: a second queue consumer could select it, and an operator could not tell implementation was underway. `deriveReconcileColumn` moved an open linked PR to In Progress only when `prIsDraft === false` (a draft returned `null`, leaving the item untouched in `Next Up`), and `DEFAULT_STATE_LOGICAL_MAP.pr_draft` mapped the inner `pr_draft` loop state to `NEXT_UP` — both contradicting the outer lifecycle, which already resolves a draft PR to `implementation` (In Progress). Both signals are fixed at the single shared source every board mover reads: `deriveReconcileColumn` now derives `IN_PROGRESS` for any OPEN linked PR regardless of draft state (merged still maps to Done, closed-unmerged and no-linked-PR still leave the item untouched), and `pr_draft` now maps to `IN_PROGRESS`. This converges the startup self-heal reconcile (`scripts/loop/resolve-dev-loop-startup.mjs`), the `dev-loops queue reconcile` command (`scripts/projects/reconcile-queue.mjs`), and any `boardColumnForLoopState` sync on the same invariant: an issue whose linked PR is OPEN (draft or ready) is never advertised in the pickup queue. Pre-PR states (`no_pr`, `issue_opened`, `issue_intake`, `refinement`) stay `NEXT_UP` so genuine pickable work is unchanged. Proven by `packages/core/test/queue-board-sync.test.mjs` (draft linked PR and draft PR item both derive In Progress; `pr_draft` maps to In Progress across the default map, column-name override, revert path, and invalid-override fallback) and `test/projects/reconcile-queue.test.mjs` (a `Next Up` item with an open draft linked PR is planned out of the pickup column).
- **`managedGhApiPathRegex` fully escapes the managed slug and matches case-insensitively (issue [2187](https://github.com/mfittko/dev-loops/issues/2187)).** The absolute-path arm previously escaped only `/` in the interpolated slug, so a `.` in a legitimate repo name (e.g. `acme/my.repo`) acted as a regex wildcard and over-matched a foreign repo's path; the slug is now fully regex-escaped before interpolation. Both the null-slug and resolved-slug branches now match with the `i` flag, since GitHub repo identity is case-insensitive and a mixed-case path previously evaded the `gh api` classifiers. Proven by new cases in `packages/core/test/bash-command-classify.test.mjs`.
- **Two residual fail-open gaps in the #2187 Bash-hook guard suite are closed (issue [2187](https://github.com/mfittko/dev-loops/issues/2187)).** First, `.claude/hooks/pre-tool-use-bash-gate.mjs` resolved `inManagedContext` from a bare `.devloops` filename only, while the config loader (`packages/core/src/config/config.mjs`) also accepts `.devloops.yaml`/`.devloops.yml`/`.devloops.json` — a consumer configured via any of those three variants (no bare `.devloops`) was silently treated as unmanaged and every guard fell through to allow. The hook now checks the same small variant list the loader uses. Second, `managedGhApiPathRegex`'s null-managed-slug branch (`packages/core/src/loop/bash-command-classify.mjs`) matched only the bare relative `gh api` endpoint form, so an absolute `repos/<owner>/<repo>/...` write passed through unguarded exactly when the managed repo's identity was unresolvable (AC4's fail-closed case); the null-slug branch now also matches an absolute `repos/<any>/<any>/` path, denying regardless of which repo it names. The resolved-slug branch (a proven managed repo) is unchanged, so cross-repo pass-through for a resolved identity is unaffected. Proven by new cases in `test/contracts/claude-hooks-settings.test.mjs` (every `.devloops` config variant is recognized as managed; an unmanaged repo is unaffected) and `packages/core/test/bash-command-classify.test.mjs` (null-slug absolute-path match).
- **The Claude Bash-hook guard suite is no longer dead in every consumer repo, and now fails closed instead of open (issue [2187](https://github.com/mfittko/dev-loops/issues/2187)).** `decideBashGate` (`packages/core/src/claude/hook-decisions.mjs`) scoped every guard (`RAW-GH-PR-MERGE-BYPASS`, `STOP-HUMAN-MERGE-001`, `OPS-NO-INLINE-INTERPRETER`, `SUBISSUE-NO-ADHOC-BYPASS`, the Copilot reply/request-helper guards, `git stash`, the detached-wait guard, and the `gh pr create`/`ready` gates) by comparing the cwd repo slug against a hardcoded `TARGET_REPO_SLUG = "mfittko/dev-loops"`; in any repo whose slug differed, every guard evaluated false and the entire suite was inert, and an unresolved/blank slug deliberately fell through to allow (fail OPEN). The predicate is now "is this the repo dev-loops manages", resolved dynamically and fail-closed: `decideBashGate` takes two new params, `inManagedContext` (a `.devloops` config exists at the repo root — the dev-loops-driven context) and `managedRepoSlug` (that repo's resolved git-remote slug, possibly null); `inManagedRepo = inManagedContext && (managedRepoSlug === null || cwdSlug === managedRepoSlug)` — inside a managed context whose identity can't be resolved, every guard still applies rather than silently allowing everything. The three `gh api`-path classifiers (`commandContainsSubIssueAdHocBypass`/`ReplyResolveBypass`/`CopilotRequestBypass`, `bash-command-classify.mjs`) take the resolved `managedSlug` and match its absolute `repos/<slug>/...` form (only the bare relative form when unresolvable), via the renamed `managedGhApiPathRegex`. An explicit `--repo <X>` passes a guard through ONLY when it can be PROVEN foreign (the managed slug resolves and differs); an unresolvable managed slug can't prove foreignness, so it stays in scope. `.claude/hooks/pre-tool-use-bash-gate.mjs` resolves `inManagedContext` via a filesystem check for `.devloops` at the repo root and threads it and the cwd slug (as `managedRepoSlug`) into the decider; the gate-evidence scripts run only when the resolved repo slug is non-null. `TARGET_REPO_SLUG` is retained for the Pi extension's (`extension/post-merge-update.ts`) own dev-loops-repo self-update scoping AND its own `gh pr ready`/`gh pr merge` guard gating (both still anchored to this one repo; porting `inManagedRepo` to the Pi harness is a separate follow-up) — it is no longer read by any Claude Bash-hook guard. Proven by new consumer-repo (non-mfittko managed slug) coverage in `packages/core/test/claude-hook-decisions.test.mjs` (every guard denies; cross-repo pass-through and fail-closed-on-ambiguity preserved; a non-managed context allows everything) and `packages/core/test/bash-command-classify.test.mjs` (`managedGhApiPathRegex` absolute/relative/foreign/null-slug matching). Generated `.claude/` mirror regenerated in lockstep.

## 1.0.2

### Added

- **Refuse a premature pre-approval gate up front, before spending reviewers (issue [2090](https://github.com/mfittko/dev-loops/issues/2090)).** The gate context-build seam (`scripts/github/write-gate-context.mjs`, Phase 1 of the gate-review sub-loop) now performs a gate-ORDERING tripwire as its FIRST action: for a `pre_approval_gate`, it reads the raw coordination facts (`scripts/loop/detect-pr-gate-coordination-state.mjs`'s `prData.isDraft` + `gateEvidence.draftGateSatisfied`) and fails closed — exits non-zero naming `run_draft_gate` — when the draft gate is not satisfied (the PR is still draft, or no clean `draft_gate` verdict exists), before any reviewer fork, diff capture, or gate-context tmp artifact is spent. Previously a `pre_approval_gate` fan-out on a still-draft PR was only refused at verdict post, after the reviewer effort was already spent. The check is DELIBERATELY scoped to the one gate-ordering precondition where the coordination detector and the verdict-post refusal can never disagree — it consults only raw draft-state + draft-gate evidence, never the run-context-specific Copilot-cycle / internal-only / lightweight guards — so it can never false-block a legal gate. All of that run-context legality stays at the verdict post (`upsert-checkpoint-verdict.mjs`, the authoritative fail-closed backstop, left byte-unchanged). `draft_gate` has no predecessor gate and `review` carries no obligation, so neither triggers a lookup; `--prefix-file` mode (which never touches GitHub) skips the tripwire; a coordination-load failure proceeds silently (an unread state is not an ordering violation; a genuine read problem surfaces at the following spec-of-record read, which fails closed). No new state machine, gate, or legality source. Proven by focused tests in `test/github/write-gate-context.test.mjs` (non-zero exit naming `run_draft_gate` with no artifact/`.diff` for a premature pre_approval; a ready PR with a satisfied draft gate builds+schedules unchanged; the default real-resolver path reads the raw draft facts; `draft_gate`/`review` never consult coordination; fail-open and `--prefix-file` skip).
- **Sanctioned one-shot gate fan-out dispatch (issue [2092](https://github.com/mfittko/dev-loops/issues/2092)).** `scripts/github/emit-fanout-dispatch.mjs` turns a `write-gate-context.mjs` bundle into per-unit reviewer prompts in one step: it reads the artifact's fan-out dispatch plan (`fanout.groups`, or `fanout.pendingGroups` under `--pending`) and, for each resolved dispatch unit, writes a minimal angle-naming suffix and drives the composer under the unit's EXACT `resolveFanoutGroups` name, emitting one `{ scope, angles, group, promptPath }` per unit. A coordinator no longer re-derives persona/prompt composition (the emitted suffix only NAMES the unit's angle(s); the `review` agent self-resolves each persona via `resolveReviewerRole`) and no longer spelunks `print-gates.mjs`. Only a CONFIGURED `gates.fanout.groups` group shares one reviewer (recording that group's name as its provenance `group`, matching the merge guard's `resolveFanoutGroups` re-derivation in `detect-checkpoint-evidence.mjs`); every angle NOT in a configured group gets its OWN distinct singleton reviewer — including angles `resolveFanoutGroups` auto-chunked into a leftover `group:...` unit, which the emitter SPLITS back into per-angle singletons. A coordinator can therefore never seed a shared reviewer for an ad-hoc auto-chunk unit the configured table never named — the `requireFanoutProvenance` breach seen on #2100/#2101. This changes only how the emitter dispatches resolved units, not which angles/units `resolveFanoutGroups` resolves. Because splitting makes the emitted-unit set diverge from `write-gate-context`'s `fanout.wavePlan` (computed over the unsplit units), the emitter also reports `maxConcurrent` (`resolveFanoutEffectiveConcurrency`, 1 when `gates.fanout.sequential`) and the coordinator waves the EMITTED units by that bound rather than the stale wave plan. Fail-closed on a missing artifact, no fan-out plan, zero units, an angle-less unit, a unit name that sanitizes to an invalid scope, two units deriving a colliding sanitized scope, or a missing invariant-prefix record; each unit's angle list is normalized once and threaded through the scope, suffix, and provenance-`group` derivation so a malformed unit cannot split those views. Extracts `composeAndRecordReviewerPrompt` from `compose-reviewer-prompt.mjs` so the CLI and emitter share one atomic compose-and-record core. Documented as `GATE-EXEC-FANOUT-DISPATCH-EMIT` in `skills/docs/gate-review-sub-loop-contract.md` and the copilot-pr-followup Phase 2 dispatch step; the `review` agent's scoped-mode self-resolution is documented in `agents/review.agent.md`. Proven by `test/github/emit-fanout-dispatch.test.mjs` (one prompt per unit with exact-name provenance groups, `--pending` subset, prefix-first composition, and every fail-closed path). Generated `.claude/` mirror regenerated in lockstep.

### Fixed

- **`bump-version.mjs --silent` is now truly silent — subprocess output no longer leaks to stderr on a clean bump (issue [2095](https://github.com/mfittko/dev-loops/issues/2095)).** The shared `run()` helper spawned every child with `stdio: ["ignore", 2, 2]`, unconditionally routing each subprocess's stdout+stderr to the parent's stderr, and `--silent` reached only `emitResult` (the trailing JSON on stdout) — `main()` never threaded `silent` into `bumpVersion`/`run`, so the spawn path never learned the caller asked for quiet. `--silent` is now threaded from `main` into `bumpVersion` into every `run(...)` call at the one shared spawn point: under `--silent` each child is captured (`stdio: ["ignore", "pipe", "pipe"]`, with `maxBuffer: Infinity` so capture matches fd-inherit's unbounded behavior and never reclassifies a large-output success as a failure) and its output discarded on success, while on a non-zero child exit or thrown error the captured stdout/stderr is replayed to stderr before the throw so genuine failure diagnostics survive. The non-silent default (`["ignore", 2, 2]` to stderr, JSON summary to stdout) and the `--jq` clean-stdout guarantee are unchanged, as are the release surfaces, staging set, drift guards, idempotency, and exit codes. Proven by `test/release/bump-version.test.mjs` (silent-success emits no bytes on either stream, silent-failure replays diagnostics to stderr, a &gt;1MB successful child still succeeds under `--silent`, `silent` threads into every spawn call, and the non-silent default routing is preserved).
- **`review` skill docs point at the real `agents/review.agent.md` source, not the non-existent `agents/review.md`.** `skills/review/SKILL.md` (and its generated `.claude/` mirror) referenced `agents/review.md` for the `dev-loops:review` persona; that path does not exist in the repo. Both now cite `agents/review.agent.md`, generated to `.claude/agents/review.md`, matching the source/generated split used elsewhere.
- **Pre-flight worktree gate admits a verified core-isolated checkout outside `tmp/worktrees/` (issue [2063](https://github.com/mfittko/dev-loops/issues/2063)).** The `local_implementation` worktree-isolation guard rejected a checkout on path prefix alone: both enforcement sites (`checkWorktreeIsolation` in `scripts/loop/pre-flight-gate.mjs` and the `local_implementation` block in `scripts/loop/resolve-dev-loop-startup.mjs`) returned `not_in_worktree` when the working directory was not under `tmp/worktrees/`, before the real core-isolation invariant ran — and `isWorktreeCoreIsolated` itself short-circuited to a vacuous `true` for any outside checkout because `resolveContainingWorktreeRoot` filtered to `tmp/worktrees/`-scoped paths. A sibling/linked checkout that already satisfied the real invariant (its `node_modules/@dev-loops/core` realpath equals its own `packages/core` realpath) had no sanctioned way past the gate, since the `DEVLOOPS_WORKTREE_BYPASS` / `DEVLOOPS_PREFLIGHT_BYPASS` env escapes are blocked by the auto-mode classifier. `resolveContainingWorktreeRoot` now resolves ANY listed worktree root (longest/innermost match wins), so the core-isolation invariant is actually computed for an outside checkout instead of short-circuited. A new shared decision `classifyWorktreeIsolation` (in `packages/core/src/loop/worktree-guard.mjs`) is the single source both enforcement sites route through so they cannot diverge: it admits an outside checkout that is not the main checkout and satisfies core isolation, and fails closed (`not_in_worktree`) for an outside checkout whose core link escapes its own `packages/core` OR whose containing worktree root cannot be resolved (an unlisted checkout, or an empty/unparseable `git worktree list` that also nulls the main-checkout guard) — so the core-isolation check can never vacuously admit. `tmp/worktrees/` stays the default and recommended location; the main-checkout rejection still points to `ensure-worktree.mjs`; the env bypasses keep their prior semantics. Proven by `packages/core/test/worktree-guard.test.mjs` (classifier admit/reject + invariant evaluable outside `tmp/worktrees/`), `test/loop/pre-flight-gate.test.mjs`, and `test/loop/resolve-dev-loop-startup.test.mjs` (both sites admit the isolated outside checkout and fail closed on the escaping one).
- **`bun run verify` is deterministically green under parallel-leg CPU contention: the per-test timeout now scales with parallelism instead of inheriting Bun's fixed 5000ms default (issue [2099](https://github.com/mfittko/dev-loops/issues/2099)).** `buildBunTestArgs` (`scripts/run-bun-test.mjs`) set no `--timeout`, so every test inherited Bun's fixed 5000ms per-test wall. `verify` runs its three legs concurrently (`verify.mjs`) while `test:all` forks `--parallel` Bun workers, so subprocess-spawning suites (the `runNode`/`makeGhMock` helpers in `test/_helpers.mjs`) saw CPU oversubscription that drifted real subprocess round-trips past 5000ms and timed them out non-deterministically (`# Unhandled error between tests`, `killed 1 dangling process`) — most visibly in `test/github/close-gate-findings.test.mjs` and `test/loop/copilot-pr-handoff.test.mjs`. A new `resolveBunTestTimeoutMs` scales the ceiling linearly with worker count (`PER_TEST_TIMEOUT_BASE_MS` × parallelism) and `buildBunTestArgs` now emits `--timeout=<scaled>` at the one shared point every test routes through: at parallelism 1 it stays the 5000ms default (isolated runs still catch a genuine hang fast) and at the default parallelism 8 it grants 40000ms of headroom for the contention that many workers create. `--timeout` is centrally managed like the reporting/discovery flags — a caller-provided `--timeout` (either spelling) is dropped so the scaled value always wins and no duplicate/overriding flag can silently reinstate the unscaled wall. Test coverage is unchanged — no test skipped, disabled, or removed, and `discoverRepositoryTests` inventory is untouched. A guard test pins the scaling so an accidental revert to the unscaled 5000ms uniform default fails a check rather than silently reintroducing the flake. Proven by `test/loop/run-bun-test.test.mjs` (scaled `--timeout` in the emitted args at parallelism 2/8; `resolveBunTestTimeoutMs` scales and never returns the unscaled default above parallelism 1).
- **Guard `Closes #N` against the branch's resolved issue in the sanctioned PR wrappers (issue [2110](https://github.com/mfittko/dev-loops/issues/2110)).** A body swap could silently re-point a PR at the wrong issue — `edit-pr.mjs` accepted any `--body`/`--body-file` with no check that its closing reference matched the PR's own linkage, and `create-pr.mjs` enforced the reference only under an explicit `--issue`. Both wrappers now fail closed (new rule `CLOSING-REF-BRANCH-MISMATCH`) when a body closing reference disagrees with the issue the branch resolves to: `create-pr` derives the expected issue from the `--head` branch slug (`issue-<N>` / `dl/issue-<N>-*`, else the current branch) when `--issue` is omitted (an explicit `--issue` still wins), and `edit-pr` resolves it from the PR's head branch slug, else its `closingIssuesReferences`. The guard recognizes GitHub's full closing-keyword vocabulary (`close`/`closes`/`closed`, `fix`/`fixes`/`fixed`, `resolve`/`resolves`/`resolved`, any case) — not only `Closes`/`Fixes` — and inspects every reference in the body (a wrong second reference is refused even when the first matches, since GitHub auto-closes every one). A correct-match reference, a body with no closing reference, and a PR/branch with no resolvable issue (issue-less lightweight) all pass unobstructed; a new `--allow-cross-issue` waiver records a deliberate cross-issue reference. The shared primitives live in `packages/core/src/github/closing-ref-guard.mjs` (both wrappers route through one implementation). This never changes GitHub auto-close semantics and never rewrites the body — it only compares the referenced issue number. Documented as `CLOSING-REF-BRANCH-MISMATCH` in `skills/docs/copilot-loop-operations.md`. Proven by `packages/core/test/closing-ref-guard.test.mjs`, `test/github/create-pr.test.mjs`, and `test/github/edit-pr.test.mjs` (mismatch rejection, correct-match acceptance, branch-slug derivation, issue-less exemption, waiver, and edit-pr fail-closed on unresolvable context). Generated `.claude/` mirror regenerated in lockstep.
- **`detectAcDodMatrix` now resolves the criterion/evidence columns by header name, so a 3-column matrix with a leading index column parses (issue [2109](https://github.com/mfittko/dev-loops/issues/2109)).** `detectAcDodMatrix` (`packages/core/src/loop/issue-refinement-artifact.mjs`) assumed the criterion column was `headerCells[0]` and the evidence column `headerCells[1]`, and read row cells from `cells[0]`/`cells[1]`. A common `| # | Acceptance criterion | Definition of Done |` matrix with a leading index column (`#`, `No`, `No.`, `Idx`, or an empty first header) therefore shifted both mapped columns one position right and matched no candidate table, returning `{found:false}` — which blocked enqueue (`decideEnqueueRefinementGate`) and dropped the spec-authority stamp (`extractSpecFromBody` returned zero criteria, so `spec-context.mjs` failed closed). A new `resolveMatrixColumns` finds the criterion and evidence columns by header name across all columns and reads each row from the resolved named columns, never the index cell; a matrix-heading table whose columns are not explicitly named still falls back to positions 0/1, and the 2-column form is unchanged. `rowIsSemantic` is not relaxed, so identifier-only and empty matrices still fail closed for both the indexed and non-indexed shapes. `extractSpecFromBody` consumes the same detector rows, so the spec-authority stamp derives a non-empty authoritative spec for indexed bodies with no separate change there. Proven by tests in `packages/core/test/issue-refinement-artifact.test.mjs` (indexed 3-column found+valid with rows read from named columns; empty-header index column; identifier-only and empty indexed matrices fail closed) and `packages/core/test/spec-authority.test.mjs` (indexed body yields the row criteria and a non-empty spec digest).
- **Strict `QueueConfig` now accepts the documented `queue.statusColumns` / `queue.stateColumnMap` keys (issue [2077](https://github.com/mfittko/dev-loops/issues/2077)).** The permissive runtime reader `loadStateColumnMap` (`packages/core/src/loop/queue-board-sync.mjs`) already consumes both keys, but the strict file-config schema (`QueueConfig` in `packages/core/src/config/config.mjs`) omitted them, so a consumer `.devloops` that renamed a board column (e.g. `queue.statusColumns.next_up: Ready`) failed strict validation — which made `upsert-checkpoint-verdict.mjs` refuse to post gate verdicts and degrade to the fallback poster (summary-only, no inline comments). `QueueConfig` gains `statusColumns` (a strict logical-column allow-list `next_up`/`in_progress`/`ready_for_review`/`done` -> non-empty trimmed display-name strings) and `stateColumnMap` (loop-state -> known logical column), mirroring exactly what `loadStateColumnMap` allow-lists. The rest of `QueueConfig` stays strict: every other unknown `queue` key, non-object shapes, non-string/empty display names, and non-known logical-column values still fail closed. No change to board-sync behavior, the allow-list, the column defaults, or `DEFAULT_STATE_LOGICAL_MAP`. Proven by `packages/core/test/config.test.mjs` (validates + round-trips into `loadStateColumnMap`; invalid shapes fail closed).

### Changed

- **Forbid any issue/pr number in runtime-source code comments (issue [2071](https://github.com/mfittko/dev-loops/issues/2071)).** Tightens the canonical `LOCAL-COMMENT-DISCIPLINE` rule (owner `skills/local-implementation/SKILL.md`): a runtime-source code comment MUST NOT carry any issue/pr number, superseding the prior allowance of one `(#NNN)` authoritative reference. A load-bearing reference now cites the governing contract/rule by name/path/rule-id (e.g. "per gate-review-sub-loop-contract GATE-EXEC-...") instead of a number. `scripts/loop/check-comment-discipline.mjs` now flags ANY added `#NNN` token in a runtime-source comment span (diff-scoped, added-lines-only, runtime-source-only) rather than only a two-or-more-ref chronology chain; the inline `comment-discipline:allow` escape and the pre-existing-backlog exemption are unchanged. No product behavior changes beyond the rule and check. Proven by `test/loop/check-comment-discipline.test.mjs` (single-ref rejection, contract-name/rule-id acceptance, escape acceptance, pre-existing non-flagging). Generated `.claude/` mirror regenerated in lockstep.
- **`create-pr.mjs` defaults `--base` to `resolveBaseBranch(config)` (issue [2062](https://github.com/mfittko/dev-loops/issues/2062)).** When the caller gives no explicit `--base`/`-B`, the wrapper now injects `--base <resolveBaseBranch(config, { cwd })>` so a repo that configures `workflow.baseBranch` in `.devloops` opens its PR against that branch instead of `gh`'s repository-default fallback — closing the silent wrong-base gap where a worktree cut from a non-default base still opened its PR against `main`. An unset `workflow.baseBranch` resolves to the auto-detected default branch (unchanged targeting), a prefixed configured value normalizes to a bare branch name, and resolution never throws (a missing/malformed config auto-detects). An explicit `--base`/`-B` always wins and is forwarded unchanged (exactly one `--base`). `edit-pr.mjs` gains a `--base <branch>` option forwarded to `gh pr edit --base` (reported in the `edited` set), giving base retarget a sanctioned wrapper; an empty or whitespace-only value is refused. `resolveBaseBranch` and the gate/merge base-resolution are unchanged. Proven by `test/github/create-pr.test.mjs` and `test/github/edit-pr.test.mjs`.
- **Scope retrospective recency to PRs merged into the configured base branch (#2027).** Direct and release commits after a valid checkpoint no longer make it stale, while squash-merged PRs still do; uncertain commit-to-PR association remains fail-closed. Canonical startup can now turn a legitimate `needs_reconcile` result with a null strategy into an actionable reconciliation envelope instead of failing schema validation. Tracker-backed PR guidance now keeps volatile run totals in head-stamped gate artifacts and records stable command-level outcomes in PR descriptions.
- **Group the pre-approval gate's design-quality + finalization angles for fan-out provenance (issue [2089](https://github.com/mfittko/dev-loops/issues/2089)).** The global `gates.fanout.groups` reviewer-identity table named only draft-gate angles, so the pre-approval gate's design-quality (`dry`/`kiss`/`yagni`/`deep`, `srp`/`soc`/`ocp`/`lsp`/`isp`/`dip`) and finalization (`correctness-final`/`ui-validation`) angles had no configured reviewer-identity unit and scattered across arbitrary auto-chunked leftover units — so a compliant grouped pre-approval reviewer covering a semantic group was rejected by `fanoutReviewerPairingError` (`the configured gates.fanout.groups table does not place all of them in one group`). Adds `design-simplicity`, `design-solid`, and `finalization` groups to the shipped table so a grouped pre-approval round collapses to one reviewer per resolved dispatch unit, while the distinct-reviewer floor (`countFreshDispatchUnits`) and `requireFanoutProvenance` stay intact for full-scope rounds. The table is global; the new groups are inert for the draft/spike gates (they never resolve those angles), and the draft `docs`/`pr-checklist` angles the pre-approval set also resolves stay covered by the existing `docs-surface`/`process` groups. No resolver code changes — `resolveFanoutGroups` is already global and `fanoutReviewerPairingError` already validates against its output. `skills/docs/gate-review-sub-loop-contract.md` now documents the pre-approval grouping symmetrically with the draft grouping and states that provenance is validated against `resolveFanoutGroups` (reviewer identity), never `buildAngleRequestGroups` (model/cache request batching). Proven under the shipped grouped default by tests in `packages/core/test/config.test.mjs` (fewer dispatch units than angles for the design set) and `packages/core/test/gate-fanin.test.mjs` (one-reviewer-per-unit ledger passes; a cross-unit reviewer fails closed). Generated `.claude/` mirror regenerated in lockstep.
- **`bump-version.mjs` stamps the CHANGELOG as a sixth release surface.** The
  sanctioned bump now rewrites the `## Unreleased` heading to `## <version>`
  (leaving its entries intact) so `extract-changelog-section.mjs` finds the
  release section, closing the gap that broke the manual `v1.0.2-slim.0` release
  at the extract-changelog guard. It fails closed when there is no Unreleased
  content to stamp (an undocumented release cannot proceed), stays bump-only and
  idempotent (a re-run over an already-stamped CHANGELOG is a no-op), and stages
  `CHANGELOG.md` among the enumerated release files.

### Fixed

- **`loop startup` resolves the bundled `detect-linked-issue-pr.mjs` module-relative, not via the consumer repoRoot (issue [2079](https://github.com/mfittko/dev-loops/issues/2079)).** `scripts/loop/resolve-dev-loop-startup.mjs` located the bundled linkage helper with `path.join(repoRoot, "scripts/github/detect-linked-issue-pr.mjs")`. In a consumer repo that installs dev-loops as a package (npx/global/plugin) and does NOT vendor dev-loops' `scripts/` tree, that repoRoot has no `scripts/`, so `loop startup` threw `Cannot find module` and failed closed — silently disabling the deterministic entrypoint (route selection, gate ordering, real gate-verdict posting) and pushing the operator off the loop. The helper is a bundled package artifact (shipped via `package.json` `files: ["scripts/"]`), so it now resolves from this module's own installed location via `import.meta.url` (`path.dirname(fileURLToPath(import.meta.url))` joined to `../github/detect-linked-issue-pr.mjs`), matching the module-relative sibling pattern already used by `ui-review-diagnose.mjs` / `ui-review-report.mjs` / `info.mjs`. The `execFileSync` keeps `cwd: repoRoot` and its unchanged `--repo`/`--issue` arguments — only the script-path resolution changed, so the git/gh execution target stays the consumer repo and vendored source-checkout runs are byte-for-byte unchanged. An audit confirmed this was the lone target-repoRoot-bound bundled-helper lookup in the resolver and its siblings. Proven by `test/loop/resolve-dev-loop-startup.test.mjs` (a rehearsal that runs `loop startup` from a repoRoot with no `scripts/` tree and asserts linkage resolves with no `Cannot find module`; it fails on the pre-fix repoRoot-relative resolver) with linkage detection across the resolver test suite now driven through the shared `gh` stub instead of a planted-helper shadow.
- **PR pickup integrates the base branch FIRST and never CI-waits on a
  `CONFLICTING`/`DIRTY` branch (issue [2096](https://github.com/mfittko/dev-loops/issues/2096)).**
  Closes a hard deadlock: GitHub does not dispatch `pull_request` CI on a
  conflicted PR, so a loop that picked up a behind/diverged PR and proceeded to
  wait for CI waited forever (observed picking up a PR left behind post-epic
  `main`). The deterministic pickup preflight (`runBasePickupPreflight` in
  `scripts/loop/copilot-pr-handoff.mjs`) now reads the PR's
  `mergeable`/`mergeStateStatus` as the FIRST action — before any gate or
  CI-wait — and, when the branch is behind base or `CONFLICTING`/`DIRTY`,
  integrates `origin/<base>` via the sanctioned `resolve-pr-conflicts.mjs`
  (merge, additive-CHANGELOG auto-resolve, or fail closed) and pushes, then
  re-baselines at the new head so the gate/CI re-runs there. It NEVER enters a
  CI/review wait while `mergeStateStatus` is `DIRTY` / `mergeable` is
  `CONFLICTING`: it either integrates the base first or stops with a clear
  actionable message, and a fail-closed backstop at the watch decision refuses
  to route a conflicted head to a wait. A watch-refresh re-entry never
  auto-merges on every poll but still refuses to re-enter a wait while
  conflicting. `detect-copilot-loop-state.mjs` carries `mergeable`,
  `mergeStateStatus`, and `baseRefName` on the snapshot to drive it. The rule is
  documented in `skills/docs/public-dev-loop-contract.md`
  (`FACADE-PICKUP-INTEGRATE-BASE-FIRST`, `FACADE-NEVER-CI-WAIT-WHILE-DIRTY`).
  Proven by `test/loop/copilot-pr-handoff.test.mjs` (dirty-state rehearsal
  asserting no CI-wait is entered while `DIRTY`, base-integrated-first,
  behind-integration, fail-closed-on-unresolvable, and watch-refresh
  no-auto-merge paths). Generated `.claude/` mirror regenerated in lockstep.

## 1.0.2-slim.0

### Added

- **Sanctioned atomic version-bump script (issue [2088](https://github.com/mfittko/dev-loops/issues/2088)).** `scripts/release/bump-version.mjs` sets all five release surfaces to one target version in lockstep from a single invocation — root `package.json` `version`, `packages/core/package.json` `version`, the root `@dev-loops/core` range (`^<version>`), `bun.lock` (`bun install --lockfile-only`, proven with `--frozen-lockfile`), and the generated `.claude` tree (plugin manifest `version` plus every pinned `npx dev-loops@<version>` call-site, via `generate-claude-assets.mjs`). Any prerelease token is supported (`1.0.2-slim.0`, `1.0.0-rc.7`), compared as the full version token. After regenerating the surfaces it runs the fail-closed drift guards (`assert-core-dependency-version.mjs` and `generate-claude-assets.mjs --check`) and exits non-zero on any residual drift; it stages exactly the enumerated release paths (never `git add -A`) and is idempotent. It is bump-only — it never commits, tags, pushes, or publishes; commit + tag + push and the stable-release approval gate stay operator/runbook-owned. It is a repo-internal maintainer script (run as `node scripts/release/bump-version.mjs <version>`), deliberately not exposed on the public `dev-loops` consumer CLI. `skills/docs/release-runbook.md` names it as the only supported bump path. Proven by `test/release/bump-version.test.mjs` (manifest edits, per-surface drift detection, non-bare-token rejection, and the real lockstep guard accepting a prerelease-token bump / failing closed on a stale lockfile).
- **Admit only code-documenting comments before cleanup (issue [2054](https://github.com/mfittko/dev-loops/issues/2054)).** Comment-side recurrence guard, the analogue of the coverage-admission rule. Adds one canonical rule `LOCAL-COMMENT-DISCIPLINE` to the `skills/local-implementation/SKILL.md` owner: a code comment states a current invariant, constraint, fail-closed/security rationale, calibration knob, or external-contract note; it does not narrate agent moves, cite issue-number chronology, or restate the acceptance criteria (a single `(#NNN)` reference is allowed, a chain is not). `agents/review.agent.md` references the owner in one line without restating it. A deterministic check (`scripts/loop/check-comment-discipline.mjs`) enforces it fail-closed: diff-scoped and added-lines-only (a three-dot `base...head` diff over `+` lines only, so it never flags the pre-existing backlog owned by #2042), high-precision (only runtime-source `.mjs/.cjs/.js/.ts/.mts/.cts/.sh` files, excluding tests, docs, and generated `.claude/` mirrors), flagging an added comment span that cites two or more distinct `#NNN` references or exceeds the design-essay comment-block threshold (a calibration knob). An inline `comment-discipline:allow` escape marker admits a genuinely load-bearing exception. Wired into both lifecycle-gate seams beside the ADR tripwire (`scripts/loop/pre-pr-ready-gate.mjs` and `scripts/github/ready-for-review.mjs`). Changes no product behavior; it only constrains future comment expansion. Proven by `test/loop/check-comment-discipline.test.mjs` (blocks an added chronology comment, passes a clean invariant comment, never flags a pre-existing context comment, honors the inline escape, blocks an over-threshold block, ignores non-runtime files). Generated `.claude/` mirrors regenerated in lockstep.
- **Reject unjustified coverage expansion before fixer dispatch (issue [2032](https://github.com/mfittko/dev-loops/issues/2032)).** Adds `VALIDATE-COVERAGE-ADMISSION` to the canonical `skills/docs/validation-policy.md` owner: a coverage request is actionable only when it names the protected observable behavior or risk, why existing evidence misses it, and the cheapest authoritative seam. One authoritative test layer is the default; another unit/integration/CLI/package/harness layer is justified only by a distinct boundary failure (argument parsing, transport, serialization, packaging, or actual harness routing). Percentage-only, missing-test-only, equivalent-permutation, generated-mirror-only, and harness-label-only demands are non-actionable, and the ≥90% target stays diagnostic only. `agents/judge.agent.md` and `agents/review.agent.md` reference the owner without restating it; a coverage finding that fails admission is dispositioned `reject`, so the existing judge act-list filter keeps it out of fixer dispatch (no new dispatch code, reviewer, gate, service, or rejection registry — a repeated demand is compared against the prior-round judge ledgers via the existing finding fingerprint). Deterministic judge rehearsals over `runJudgePass` in `test/loop/judge-pass.test.mjs` prove reject-an-already-covered-permutation, accept-a-missing-public-boundary, retain-a-fail-open-defect, and cross-round persistence; generated `.claude/` mirrors regenerated in lockstep.

### Fixed

- **Keep the release-script import closure `@dev-loops/core`-free again.** The release workflows run the release-script family with bare `node` before any install, so their transitive import closure must be node:-pure; a bare `@dev-loops/core` import anywhere in that graph `ERR_MODULE_NOT_FOUND`s at module load and blocks the release. A prior change added `formatCliError` from `scripts/_core-helpers.mjs` (which re-exports `@dev-loops/core`) into `scripts/lib/jq-output.mjs`, which every release script pulls in via `verify-release-approval`, so `node scripts/release/*.mjs` crashed at load. `formatCliError` now lives in a node:-pure copy `scripts/lib/format-cli-error.mjs` (mirroring `direct-run.mjs`); `jq-output.mjs` imports it from there and `_core-helpers.mjs` re-exports it so its other importers are unaffected. A new transitive import-closure contract test (`test/docs/release-scripts-import-closure.test.mjs`) fails closed if any release script reaches `@dev-loops/core` or a 3rd-party import at any depth, and a parity test pins the pure copy to the canonical formatter; the `pre-commit-hook-e2e` fixture carries the new module.
- **Decouple the Copilot-round-cap tests from the ambient `.devloops` config (#2055).** Seven suites (`request-copilot-review`, `upsert-checkpoint-verdict`, `detect-copilot-loop-state-auto-detect`, `detect-copilot-loop-state-input-modes`, `copilot-pr-handoff`, `detect-pr-gate-coordination-state`, `run-watch-cycle`) read `refinement.maxCopilotRounds` from the ambient `.devloops` and asserted cap=2 behavior, so the `1.0.2-slim` line's `maxCopilotRounds: 1` (config-only chore `f1059cf8`) reddened them in isolation. Each suite now pins the round cap in its own fixture `repoRoot` (mirroring the existing `fanoutDisabledRepoRoot` pattern), so the tests own their config; every assertion is unchanged and each passes at both `maxCopilotRounds: 1` and `2`. The hardcoded `/tmp/findings.md` fixture path is replaced by a per-test `mkdtemp`. Six suites are fixed test-side; the seventh (`run-watch-cycle` integration) needed `runHandoff` to thread its already-resolved `repoRoot` into the `performCopilotReviewRequest` sub-call (`scripts/loop/copilot-pr-handoff.mjs`) — a single behavior-preserving line (a no-op from the repo root, more correct from a subdir). The originally-hypothesized git-stub `PATH` shard race is inert; the retained in-process git-stub and a `detectMergeBaseScope` injectable runner remain out-of-scope follow-ups.

### Changed

- **Verify the lean baseline across harnesses and reconcile paused work (issue [2047](https://github.com/mfittko/dev-loops/issues/2047)).** Closeout record for the streamline epic; docs-only, no product-behavior change. Adds `docs/slim-line-verification.md`: maps each representative surface (tracker, local implementation, gate, review follow-up, reconciliation, queue, UI-review) to its existing authoritative test/contract on the combined `1.0.2-slim` line; records `bun run verify` green (8264 pass, 0 fail across 358 files; 664 links, 231 rules) and a standalone packed-consumer proof (`test/packaged-install-smoke.test.mjs`, exports + queue CLIs resolve outside the checkout); aggregates the epic's before/after change by authority category (187 files, net -2692 lines; runtime source net -2191, tests net -563, generated mirrors net ~0); confirms no child silently changed public behavior (only the coverage-admission, comment-discipline, and comment-aware size-budget guards, all future-constraining); and reconciles the paused retrospective-recency work (its behavioral fix is still required and absent from the combined line; its prose/comment/test edits are stale-on-arrival duplication to re-derive from post-slim owners on resume) plus the operator's base/promotion decision inputs. No new golden-workflow harness is added.
- **Collapse public dev-loop routing test permutations to distinct outcomes (issue [2045](https://github.com/mfittko/dev-loops/issues/2045)).** Tests-only; no source or product-behavior change. Removes seven redundant cases from `packages/core/test/public-dev-loop-routing-variation.test.mjs` (45 → 38), each a cross-product of two boundaries already witnessed independently elsewhere, so no distinct input-class → routed-outcome mapping is lost. Four were metadata-preservation permutations asserting only that a fail-closed reconcile keeps the requested/derived `durable_auto` execution mode; one representative of that class is retained (`auto_continue_current invalid parameter preserves the derived durable_auto execution mode`) because the `auto_continue_current` branch of the `requestedExecutionMode` ternary is reached only through an early parameter-validation reconcile and has no other witness (the missing-canonical-state reconciles use a separate `effectiveMode` derivation). The remaining metadata-preservation removals are owned by the routing suite's `missing intent preserves requested durable_auto execution-mode metadata` (explicit-`mode` branch) crossed with the already-covered invalid-`watch` / invalid-`targetPreference` / `prefer_local`-conflict reasons. One removal was the `watchRequested`-trace-preservation permutation (owned by `watch=true on inspect_state fails closed`). Two were intent-harness permutations replaying the same watch-eligibility outcome through `continue_on_pr` that `continue_current` already covers (`watch=true on a non-wait route fails closed`, and the representative `continue_on_pr + watch` success translation). The retrospective-gating and wait-state contract-trace families stay intact as the authoritative fail-closed owners; the four public-composition translation cases and every distinct refusal remain. Focused routing suites and `bun run verify` pass with coverage unchanged.
- **Collapse duplicated CLI wrapper and test-helper evidence (issue [2044](https://github.com/mfittko/dev-loops/issues/2044)).** Tests-only; no source or product-behavior change. Consolidates duplicated test evidence onto the authoritative owners that #2037/#2038 established, removing only equivalent duplication and no-op cases while each affected wrapper keeps one positive option-to-output wiring case plus its distinct mutation-preflight/error/exit behavior. (1) Generic `emitResult` matrix cases (`--silent` success mapping, invalid-filter refusal on read commands) are dropped from `comment-issue`, `fetch-ci-logs`, `edit-comment`, `close-gate-findings`, `wait-pr-checks`, and `pr-runner-coordination`; the shared jq/silent/exit contract stays owned by `test/loop/jq-output.test.mjs` and `test/contracts/jq-output-base-guarantee-contract.test.mjs`, so each wrapper retains only its positive `--jq` wiring case and its distinct guards (`comment-issue` keeps its invalid-filter-before-post no-mutation guard). (2) The byte-identical JSON gh-stub helper duplicated in `view-issue`/`view-pr`/`list-issues` collapses to one `makeJsonGhStub` factory in `test/_helpers.mjs`, tested once in `test/helpers.test.mjs`; wrappers with distinct setup (`edit-issue`/`edit-pr` fixed URLs, `create-issue` stdout override) keep explicit local stubs. (3) Byte-identical Projects GraphQL response literals shared across six suites (`add`/`move`/`list`/`reorder-queue-item`/`reorder-subcommands`/`ensure-queue-board`) move to plain-data helpers plus two id-parameterized builders in a new `test/projects/_fixtures.mjs` (no fixture DSL); `ensure-queue-board` passes realistic node ids explicitly and keeps its own no-`pageInfo` projection, and per-caller option/name/id/number/url variations stay explicit. (4) The spawned-CLI `update`-command case in `test/dev-loops-cli.test.mjs` is removed as a pure duplicate — its parse result is owned by `test/dev-loops-core.test.mjs` and its exit-1/stderr renderer mapping by the sibling `install moon` renderer case. Core parser parity and one representative spawned-CLI smoke are unchanged. `bun run verify` stays green with no coverage regression.
- **Collapse prose and generated-mirror tests onto canonical source checks (issue [2046](https://github.com/mfittko/dev-loops/issues/2046)).** Tests + doc-drift only; no product-behavior change. Removes contract-test assertions that re-scanned generated `.claude/` mirrors or copied an owner's prose across non-owner surfaces, where an authoritative source-of-truth check already protects the normative meaning. (1) `gate-fanout-code-defect-surfacing-contract.test.mjs`: drops the generated `.claude/agents/review.md` directive re-scan — the source-agent and source-skill assertions stay, and `claude-assets-reproducible.test.mjs` byte-reproducibility proves the mirror cannot carry different directives. (2) `gate-angle-carry-forward-routing-contract.test.mjs`: drops the generated-mirror routing re-scan and the mirror entries in `GATE_DRIVING_SKILLS`, keeping every source-surface assertion including the load-bearing command/rule-ID tokens (`resolve-angle-carry-forward.mjs`, `--prev-head`, `carriedFromHead`, `GATE-EXEC-ANGLE-CARRY-FORWARD`, `GATE-EXEC-BRIEFING-PREFIX`, the 40-character SHA form). (3) `review-doc-contracts.test.mjs`: drops the four-surface copied boundary-sentence loop; its meaning is owned by `GATE-EXEC-BUILD-ONCE-SEED` / `GATE-EXEC-FANOUT-SEQUENTIAL-FALLBACK` / `REVIEWER-STATE-GATE-ANGLE-MAPPING` (asserted via `assertRuleOwned`) and guarded against copied restatement by `validate-rule-ownership`. (4) `public-facade-doc-contracts.test.mjs`: drops the repeated status-shape and reconcile prose across non-owner skills, keeping the owner assertions plus the non-owner rule-ID references (`FACADE-STATUS-AUTHORITATIVE-FAIL-CLOSED`, `FACADE-LINKED-PR-SINGLE-ARTIFACT`). Retained exact-text assertions remain for command/option tokens, stable rule IDs, exported sentinels, and runtime-reachability trigger recognition (the `auto dev loop on issue` trigger loop is left intact). No forbidden-phrase or prose-snapshot replacement suite is added. Also corrects pre-existing removed-CLI-flag doc drift for the same `--reviewer-login` flag: it is dropped from `scripts/README.md` (the `detect-reviewer-loop-state`, `outer-loop`, `inspect-run`, and `inspect-run-viewer` sections) and from the canonical `skills/docs/reviewer-loop-state-graph.md` detector CLI contract plus the `skills/docs/copilot-loop-operations.md` invocation instruction (reviewer scope is auto-resolved from the PR's requested reviewers), keeping the still-valid `--reviewer-input` flag and the `reviewerScope`/`reviewerLogin` output fields. Generated `.claude/` mirrors regenerated in lockstep. `bun run verify` stays green with zero skipped cases and no coverage regression.
- **Slim inline documentation in `scripts/loop` (issue [2051](https://github.com/mfittko/dev-loops/issues/2051)).** Comments-only cleanup across the `scripts/loop` runtime-source files — no behavior, logic, or public-contract change (the diff touches only comment/JSDoc lines). Collapses incident-chronology comment chains to a single current-invariant statement, trims signature-only and design-essay JSDoc to the added contract, and deletes dead or verbatim-restating comments. Every file keeps its load-bearing comments: current invariants, fail-closed/security rationale, calibration knobs, and external-contract notes (path schemes, JSON shapes, ledger/evidence contracts). Per operator policy, historic issue/PR-number references are removed from the touched comments entirely; a load-bearing reference now cites the governing contract/rule by name or rule-id instead of an issue number. `bun run verify` and the `LOCAL-COMMENT-DISCIPLINE` guard stay green; no test file or generated mirror was edited.
- **Remove or restore dormant skipped test coverage (issue [2043](https://github.com/mfittko/dev-loops/issues/2043)).** Tests-only; no source or product-behavior change. Resolves all 35 dormant `test.skip` blocks across eight suites (`inspect-run-unit`, `inspect-run-cli`, `reconcile-draft-gate`, `detect-pr-gate-coordination-state`, `detect-copilot-loop-state-input-modes`, `detect-copilot-loop-state-auto-detect`, `detect-reviewer-loop-state`, `steer-loop`): 27 deleted, 8 restored. Deletions each name their active owner — removed CLI surfaces (`--reviewer-login`, `--skip-checks`, `--review-mode`, and the removed `--steering-state-file`/`--local-validation-head-sha`/`--review-request-status` flags whose `reviewRequestStatusOverride`/`localValidationHeadSha` params are unreachable from any production caller of `autoDetectSnapshot`) and `crediblyGreen` permutations already owned by `packages/core` state tests. Restorations each name an observable risk with no other coverage and are rewritten against the current interface (no revived flags/fixtures): the auto-resolve-single-reviewer path and four still-current CLI error paths in `detect-reviewer-loop-state`, and the success-path steering acknowledgement envelope (`applied_now`/`queued_for_safe_point` dispositions) in `steer-loop`. No `test.skip` remains in `test/`; `bun run verify` stays green with zero skipped replacement cases.
- **Slim inline documentation across the long-tail runtime modules (issue [2052](https://github.com/mfittko/dev-loops/issues/2052)).** Comments-only cleanup across 25 runtime-source files in `scripts/docs`, `scripts/projects`, `scripts/release`, `scripts/refine`, `scripts/pages`, `scripts/claude`, `scripts/lib`, `cli/`, and `lib/` — no behavior, logic, or public-contract change (the diff touches only comment/JSDoc lines). Collapses `#NNN` incident-chronology at ~40 comment sites to a plain current-invariant statement, trims a signature-only JSDoc block, and deletes dead or verbatim-restating comments. Every file keeps its load-bearing comments: current invariants, fail-closed/security rationale (natural-language approval parsing, CSP/path-safety notes, race/tiebreak reasoning), calibration knobs, and external-contract notes (the `validate-state-machine-conformance` doc↔code binding architecture, GraphQL/board shapes). Per operator policy, historic issue/PR-number references are removed from the touched comments; a load-bearing reference now cites the governing mechanism by name/rule/path instead of an issue number. Net −23 comment lines (+101 / −124). `bun run verify` and the `LOCAL-COMMENT-DISCIPLINE` guard stay green; no test file or generated mirror was edited (none of these dirs feed a `.claude` mirror).
- **Slim inline documentation in `scripts/github` (issue [2050](https://github.com/mfittko/dev-loops/issues/2050)).** Comments-only cleanup across 12 runtime-source files in `scripts/github` — no behavior, logic, or public-contract change (the diff touches only comment/JSDoc lines). Collapses `#NNN` incident-chronology chains to a single current-invariant statement, trims signature-only and design-essay JSDoc to the added contract, and deletes dead or verbatim-restating comments. Every file keeps its load-bearing comments: current invariants, fail-closed/security rationale (git-diff isolation-flag reproducibility, sanitizer trust boundaries, marker-forgery guards), calibration knobs, and external-contract notes (path schemes, JSON shapes, ledger/evidence contracts). Per operator policy, historic issue/PR-number references are removed from the touched comments entirely; a load-bearing reference now cites the governing contract/rule by name or rule-id (e.g. `GATE-EXEC-THREAD-DISPOSITION`, `GATE-EXEC-DEFERRAL-RECORD`, `GATE-COMMENT-VERDICT-VALUES`) instead of an issue number. `bun run verify` and the `LOCAL-COMMENT-DISCIPLINE` guard stay green; no test file or generated mirror was edited.
- **Slim inline documentation in `packages/core` (issue [2049](https://github.com/mfittko/dev-loops/issues/2049)).** Comments-only cleanup, no behavior change. Collapses incident-chronology comment chains to current-invariant statements, trims design-essay and signature-only JSDoc down to the added contract (constraints, throws, side effects, non-obvious invariants), and deletes dead / verbatim-restating comments across 20 `packages/core/src` files. Net −1083 comment lines (+1380 / −2463). Every at-risk file (`config/config.mjs` −399, `loop/gate-fanin.mjs` −190, `loop/issue-refinement-artifact.mjs` −93, `github/copilot-helpers.mjs` −68, `analysis/diff-analyzer.mjs` −52) was carved as its own reviewed commit with a keep-list of load-bearing comments (current invariants, fail-closed/security rationale, ponytail ceilings, calibration knobs, external-contract notes) signed off by a fresh-context reviewer before its cut. Issue/PR number references are removed from comments; load-bearing references now cite the contract/rule-id/path (or `ADR <n>`). No executable line, string literal, export, or identifier changed; the added-lines-only comment-discipline guard (`LOCAL-COMMENT-DISCIPLINE`) passes; generated `.claude/` mirrors (`_bash-command-classify.mjs`, `_hook-decisions.mjs`) regenerated in lockstep.
- **Make the PR size budget comment-aware (issue [2073](https://github.com/mfittko/dev-loops/issues/2073)).** `check-size-budget`'s logic-LOC computation now excludes comment-only changed lines for a code-classified file, so a comments-only diff scores ~0 logic and passes while a change with real code lines is counted exactly as before. It reuses the comment-discipline guard's own lexical detector: `isCommentLine` is now exported from `scripts/loop/check-comment-discipline.mjs` and consumed by `scripts/loop/check-size-budget.mjs`, which walks the already-captured unified diff body and counts a code file's changed lines (added `+` and removed `-`) that classify as comments, subtracting that count (clamped to `[0, changedLines]`) from the file's logic contribution. This is the comment analogue of the existing `testDiscount`. Fail-closed: `isCommentLine` rejects anything not confidently a comment (blank, code, ambiguous/unparseable), so an undetected line stays counted as logic and is never discounted; the clamp prevents any diff-body-vs-`numstat` skew from producing negative logic or over-discounting. Thresholds, `absoluteHardLoc`, tiers, and the waiver flow are unchanged. Proven by focused cases in `test/loop/check-size-budget.test.mjs` (a comments-only multi-thousand-line diff yields `wholeLogicLoc: 0`/`pass`; a mixed comment+code diff discounts only comments; a pure-code diff is unchanged; a block-comment inner line without a sigil is counted as logic).
- **Compress runtime-loaded dev-loop contracts and reviewer prompts (issue [2035](https://github.com/mfittko/dev-loops/issues/2035)).** Equivalence-preserving compression onto the single owners #2036 established; no runtime, schema, gate-semantics, or enforcement change, and no rule, ownership, link, or generation invariant weakened. (1) The scoped gate reviewer's whole-owner read requirement in `agents/review.agent.md` is removed: the composed reviewer prompt (`compose-reviewer-prompt.mjs`) already delivers the isolation and briefing rules at point-of-action — the byte-identical invariant prefix (repo/PR/head/worktree, gate-context artifact path, the mandatory `verify-fresh-review-context.mjs` isolation check, the worktree-absolute findings write-path, the source-read invariant, and the build-once diff + adjacent-code seed) followed by the angle's own adversarial prompt — so the scoped reviewer no longer must read `gate-review-sub-loop-contract.md` (2443 lines) and `copilot-pr-followup/SKILL.md` (561 lines) in full as a precondition. The rule references (`GATE-EXEC-BUILD-ONCE-SEED`, `GATE-EXEC-BRIEFING-PREFIX`, `COPILOT-FOLLOWUP-ADVERSARIAL-BRIEFING`) stay as single-owner provenance and on-demand anchors. The load-bearing point-of-action directives (full-diff read with `git diff` fallback, the adversarial defect classes, `contextWidened`) are preserved verbatim — they remain load-bearing for the full-PR review mode, which receives no composed angle suffix. (2) In `skills/copilot-pr-followup/SKILL.md` the conflict-resolution gate's restated merge-ready triplet (zero unresolved threads / clean current-head `pre_approval_gate` / green CI) now references the [Required before merge](skills/docs/merge-preconditions.md#required-before-merge) owner instead of re-enumerating it; the merge-ready-preconditions subsection keeps its point-of-action checklist (`AC3`: minimum executable instruction stays local where a role must act). Generated `.claude/` mirrors regenerated in lockstep via the sanctioned generator; existing link/anchor, rule-ownership, defect-surfacing, review-doc, reproducibility, and `bun run verify` checks stay green.
- **Consolidate operational contracts around single rule owners (issue [2036](https://github.com/mfittko/dev-loops/issues/2036)).** Equivalence-preserving documentation reconciliation, no runtime change. Resolves four audited contradictions in the named operational contracts so each affected normative decision has one canonical owner and the contradicting or duplicated occurrence references it. (1) The `queue.statusColumns` / `queue.stateColumnMap` YAML examples in `skills/docs/projects-queue-contract.md` nested `board:` under `queue:` — the obsolete alias removed at the v1.0.0 cut; they now nest `board:` under `tracker:`, the sole board key. (2) The "How queue helpers use the board" fall-back bullet claimed local fallback on a board that is "absent or unreachable"; it now falls back only when NO board is configured, while a configured-but-unreachable board fails closed (surface and stop) per the retained `QUEUE-BOARD-QUERY-FAIL-CLOSED` owner, which the bullet now references. (3) The upper "Error reporting" section claimed the structured stderr payload is `{ ok, error }` and that `code` keys never appear in structured output; the shipped domain-error emitter is `{ ok, error, code }` (top-level `code` mapped to an exit status by each helper's `classifyExitCode` in `scripts/projects/*.mjs` and `packages/core/src/projects/*` — `INVALID_*` → 1, not-found → 3, the enqueue refinement gate → 4, else → 2), and only usage/argument-parse errors use `formatCliError`'s `{ ok, error, hint? }`. The section now states both shapes accurately and references the canonical "Error format" owner. (4) In `skills/docs/retrospective-checkpoint-contract.md` the findings-envelope shape `{ internalToolingOnly, rawCallViolations, allowedWriteOps }` was re-listed in "How findings travel" step 2 after step 1 already named it; step 2 now references the same findings and reaffirms that non-persisted findings stay distinct from the persisted checkpoint state record (the "Deterministic verifier" and "After retrospective is done" sections remain the canonical schema owner). No rule weakened, no ownership moved, no generated mirror hand-edited. Existing link/anchor, rule-ownership, changelog, and `bun run verify` checks stay green.
- **Consolidate head-CI observation and watch heartbeat waits (issue [2039](https://github.com/mfittko/dev-loops/issues/2039)).** Equivalence-preserving consolidation, no observable behavior change. (1) The lossless head-scoped CI acquisition/parsing duplicated in `scripts/github/probe-ci-status.mjs` (`fetchHeadCiState`) and `scripts/loop/detect-copilot-loop-state.mjs` (`fetchCurrentHeadCiEvidence`) — the parallel `check-runs` + commit-`status` reads, the `gate-evidence` loop-derived exclusion, PR-visibility filtering, and the raw/visible/full/hidden signal assembly — now lives in one seam `scripts/github/observe-head-ci.mjs` (`observeHeadCiSignals`) that reuses the existing core CI normalizers and returns only lossless raw signals (per-provider validity, raw and non-loop-derived counts, visible/full/hidden signals, commit status, exclusions). It does NOT decide pending/success/failure, so each caller keeps its deliberately-different projection: the CI watcher forces `pending` on any unavailable/malformed read and computes `allQueued`/`statusFailures`/no-check grace, while the Copilot detector may classify from one surviving provider, uses raw counts, and surfaces hidden diagnostics — both unchanged. (2) The 45-second chunked heartbeat/lease-refresh delay (private `waitWithHeartbeat` in the CI probe, inlined a third time in `scripts/github/probe-copilot-review.mjs`) is now the single exported `scripts/github/_watch-heartbeat.mjs` (`waitWithHeartbeat` + `WATCH_HEARTBEAT_MS`), reused by the PR-CI, commit-CI, and Copilot-activity waits with identical chunk size, stderr `watch_heartbeat` fields, no-trailing-heartbeat rule, and best-effort lease refresh. Each caller keeps its own baseline, attempt budget, and poll-delay formula (`buildAttemptBudget`/`buildPollDelayMs` stay deliberately divergent between the CI and Copilot probes). Proven by the unchanged public-contract suites (`probe-ci-status`, `probe-copilot-review`, their lease-heartbeat suites, and `detect-copilot-loop-state-auto-detect`) plus a focused `test/github/observe-head-ci.test.mjs` (valid, unavailable, malformed, explicit-empty, loop-derived, hidden, failed signals) and a deterministic `test/github/watch-heartbeat.test.mjs` (multi-chunk waiting, no trailing heartbeat, swallowed refresh failure).
- **Consolidate duplicated Projects discovery and cursor traversal (issue [2038](https://github.com/mfittko/dev-loops/issues/2038)).** Equivalence-preserving consolidation, no observable behavior change. The byte-for-byte duplicated GitHub Projects V2 read mechanics — strict `owner/name` validation, the `projectsV2` discovery query + cursor traversal, single-select `Status` field listing, and `Status` extraction — are extracted into one small package-owned module, `packages/core/src/projects/projects-access.mjs` (`validateProjectsRepo`, `discoverProjects`, `paginateNodes`, `listProjectFields`, `extractStatus`), composing the already-canonical `ghGraphql` transport with no new client, transport, query DSL, registry, or pagination framework. Core `list-queue-items`/`move-queue-item`/`queue-board-sync` and the root `add`/`ensure`/`reorder`/`archive` scripts migrate onto it (roughly -765 caller lines for one ~200-line owner). Each caller keeps its own query projection, page size, repository scope, position ordering, selection precedence, mutation policy, and error presentation: queue add stays a single-page ten-item presence probe (pre-existing defect, named not fixed), board-sync keeps its null filter and title cache, and ensure keeps its wider option projection and caller-specific `INVALID_REPO` usage. One authoritative traversal/discovery/validation/status suite (`test/projects/projects-access.test.mjs`) covers ordered and malformed continuation; existing caller suites keep their distinct behavior.
- **Consolidate three duplicate platform primitives (issue [2048](https://github.com/mfittko/dev-loops/issues/2048)).** Equivalence-preserving consolidation, no observable behavior change. (1) The symlink-safe direct-run predicate `isDirectCliRun` was hand-inlined in four release scripts (`extract-changelog-section`, `verify-release-approval`, `assert-core-dependency-version`, `resolve-npm-dist-tag`) and spelled as a non-symlink-safe string compare in the Claude asset generator; all five now import one `node:`-builtins-only owner, `scripts/lib/direct-run.mjs`, so the release job stays dependency-free (the `extract-changelog-section` #1016 deps-free contract test now verifies the import closure is `node:`-pure transitively instead of rejecting every relative import). (2) The byte-identical ten-flag git-diff isolation array duplicated in gate-context capture and size-budget capture is now the single exported `DIFF_ISOLATION_FLAGS` in `scripts/github/write-gate-context.mjs`, consumed by `scripts/loop/check-size-budget.mjs`; each caller keeps its own `runGit` stdio and captured views, and the distinct two-dot `scripts/lib/git-delta.mjs` flag set is untouched. (3) The duplicated base-ref resolution and git client in the changelog and decision-record validators moved to one shared `scripts/docs/_doc-git-client.mjs` (`createGitClient` + `resolveBaseRef`); the callers' delimiter (`-z`/NUL vs newline), directory-scoping, and log-subject differences stay explicit at the call site via a parameterized `diffNameOnly`. Candidate-order coverage moves to a shared-owner test; the validator caller suites keep only their distinct `diffNameOnly` and policy behavior.

## 1.0.2-pre.0 - 2026-09-07

### Added

- **Engage immutable spec authority in the live dev-loop conductor by default (issue [2008](https://github.com/mfittko/dev-loops/issues/2008), [ADR 0061](docs/decisions/0061-engage-spec-authority-in-live-conductor.md)).** The opt-in mechanism #2000 shipped is now the default on every gate round: a new CLI seam (`scripts/loop/spec-context.mjs`) resolves the canonical tracker spec and computes both revision-identity digests (`specDigest`, `contentDigest`) plus a `changed-paths` mode for fixer-push re-entry, so `skills/dev-loop/SKILL.md` Phase 3.5 and `gate-review-sub-loop-contract.md` always run it and always invoke `judge-pass` with `--spec-file`/`--content-digest`/`--spec-authority-verdict` (plus `--prior-approvals`/`--approvals-out` across re-entry). AC1: a single shared stamp helper (`stampSpecAuthorityIdentity`) pins `specDigest`/`headSha`/`contentDigest`/`checkedCriteria` onto every durable record writer's output — the findings-log ledger, the consolidated fan-in and judge-pass enriched ledgers (`--ledger-out`), the gate verdict, and the carry-forward plan (the durable `--approvals-out` record already carries them) — threaded from one computed identity, never recomputed per writer. `spec-context.mjs` produces that identity once per round via `--identity-out <identity-path>`, and the gate flow docs prescribe passing `--spec-authority <identity-path>` to all four writers on every round by default, so this is live in the running loop, not only a capability the writers accept. The fixer's durable record is the enriched `--ledger-out` ledger; the transient `--out` act-list hand-off stays a bare array for consumer compatibility. AC7 (#2000 numbering; issue 2008 AC2): a pure, fail-closed affected-criteria producer (`resolveAffectedCriteria`, wired through `judge-pass --changed-paths`/`--coverage-map`) intersects a fixer push's changed paths against a declared per-criterion coverage map; an unmapped changed path, or no coverage map at all, falls back to the full prior-approved set (all-stale), so the producer only narrows the fallback, never loosens it. AC6: the durable `--approvals-out` record now also carries `humanDecision` provenance (whether a human-spec-decision was required, and why), the chosen `authorizedRemediations` per finding, and the `criterionCoverage` used, so a fresh process reconstructs full re-entry context from the record alone. AC11 (#2000 numbering; issue 2008 AC3): explicit per-harness Pi/Claude Code/Codex regression fixtures pin identical authority/rejection/routing/escalation/invalidation/re-entry behavior across harnesses. `agents/judge.agent.md` reflects the default-on engagement (the judge always emits a spec-authority verdict, written to a `spec-authority-verdict.json` sibling of its relevance verdict) and `skills/docs/spec-authority-contract.md`'s adoption note is updated accordingly. Generated `.claude/` mirrors regenerated in lockstep.
- **Enforce immutable spec authority across judge, fixer, and review revalidation (#2000).** The canonical tracker AC/DoD/Non-goals are now immutable spec authority for a run, enforced in shared deterministic core (`packages/core/src/loop/spec-authority.mjs`) rather than harness prose. It pins two independent revision identities — `specDigest` (a `sha256:` digest of the normalized AC/DoD/Non-goals, never derived from `headSha`) and the reviewed implementation revision (`headSha` + `contentDigest`) — and requires the judge to evaluate every finding AND each proposed remediation against the COMPLETE criterion set (a supportive-only/partial citation fails closed: `SPEC-AUTHORITY-WHOLE-SPEC-EVAL`). Each finding gets exactly one named outcome: `valid_compliant`, `finding_conflicts` (autonomous reject), `remediation_conflicts` (keep finding, reject remedy, route to a compliant alternative), or `spec_cannot_decide` (the only outcome that escalates to a human-spec-decision state: `SPEC-AUTHORITY-HUMAN-DECISION-LAST-RESORT`). Conflict outcomes require explicit `conflictingCriteria` (`SPEC-AUTHORITY-CONFLICT-EVIDENCE`); a stale/mismatched revision identity fails closed (`SPEC-AUTHORITY-STALE-REVISION-FAIL-CLOSED`). `resolveCriterionInvalidation` stales every prior-derived approval on a human-approved spec change (new `specDigest`) and, on a fixer push at the same digest, stales only affected criteria while carrying an unaffected criterion forward only with positive proof that both its governing spec text and covered surface are unchanged (unknown impact → fresh review). The judge-pass bridge (`scripts/loop/judge-pass.mjs`) enforces the gate opt-in via `--spec-file`/`--content-digest`/`--spec-authority-verdict`: it derives the revision identities through `buildRevisionIdentity` on the live path, DROPS every `finding_conflicts` finding from the fixer act list (the outcome is enforced, not just recorded), fails closed on `spec_cannot_decide`, and — with `--prior-approvals`/`--approvals-out`/`--carry-forward-proof` — invokes `resolveCriterionInvalidation` and persists a durable, re-entry-safe approval record. The existing `act`/`defer`/`reject` relevance axis is intact and backward compatible. New canonical contract `skills/docs/spec-authority-contract.md` and decision record `docs/decisions/0060-immutable-spec-authority.md`; composes with (does not weaken) the `COPILOT-FOLLOWUP-VERIFY-BEFORE-RESOLVE`/`-RESOLVE-AFTER-REPLY` ordering and fresh-review-context machinery. Regression-pinned in `packages/core/test/spec-authority.test.mjs` (revision identities, whole-spec coverage, the four outcomes, fail-closed conflict/stale paths, spec-change and criterion-scoped invalidation, and a deduplication-vs-voice regression fixture) and `test/loop/judge-pass.test.mjs` (valid pass, human-decision fail-closed, supportive-only fail-closed). Generated `.claude/` mirrors regenerated in lockstep.
- **Publishable state-graph presentation with reproducible browser evidence.** Adds the self-contained, CSP-safe “The State Graph Is the Surface” deck, publishes it through the GitHub Pages build and navigation, and registers it with the shared desktop/mobile Playwright fit, accessibility, console, and screenshot harness so rendered-artifact gate evidence is produced automatically.

### Changed

- **Gate carry-forward now carries a findings-present angle's OPEN findings forward, not just a clean verdict (issue [2017](https://github.com/mfittko/dev-loops/issues/2017)).** `resolveAngleCarryForward` (`packages/core/src/loop/gate-carry-forward.mjs`) previously hard-gated carry-forward on `prevVerdict === "clean"`, forcing every gate angle to re-run on a head-advancing commit whenever the prior round had any findings, even for angles whose declared review surface the commit's diff never touched; it now treats both `clean` and `findings_present` as carry-forward-eligible, deciding purely on whether the angle's surface was proven untouched. The wiring that makes this real end to end: `resolve-angle-carry-forward.mjs`'s `buildCarryForwardPlan` derives each angle's prior verdict from the findings-log's own findings (never from the round's overall verdict), attributes a finding to its angle only when the attribution is UNAMBIGUOUS (a finding matching more than one `provenance.perAngle` row — e.g. a base angle and its `-delta-at-...` re-review sibling — keeps today's always-rerun fail-closed behavior unchanged), and stamps a findings-present carried entry with `prevVerdict: "findings_present"` plus its exact prior `findings`. `consolidate-fanin.mjs`'s `--carried-angles`/`--carry-forward-plan` upsert now honors that `prevVerdict`/`findings` pair instead of hardcoding `clean`/`[]` (fail-closed both directions: a `findings_present` entry with no non-empty findings, or a `clean` entry smuggling findings, is refused at parse time), so the carried angle's findings flow through `consolidateFanin`'s own blocking computation and the round still blocks exactly as if the angle had been freshly reviewed. `write-gate-findings-log.mjs`'s `--provenance.perAngle[].carriedVerdict` (`"clean"`|`"findings_present"`, requires `carriedFromHead`) records which verdict was carried, distinct from an ordinary clean carry — the findings themselves stay recorded only in `--findings`, never duplicated into provenance. Every existing fail-closed guard (unclassifiable file, empty/unavailable delta, dev-loop-config-source edit, rename/copy, mandatory/`ALWAYS_INCLUDE` angle) is unchanged and applies identically to a findings-present angle; a carried finding is never dropped and never converted into a pass.
- **Bun 1.4.1 becomes the pinned contributor and CI toolchain while Node 24 and npm retain their public boundaries (#1966).** Source checkouts now use one authoritative `bun.lock`, frozen Bun installs, Bun script execution, and `bun:test` unit suites. Both published packages continue to require only Node `>=24`; packaged-consumer, public-CLI, inspect-run, and Playwright coverage keep exercising Node. npm remains intentional for package packing, registry queries, dist-tags, provenance, and publication. ADR 0062 records the boundary; frozen historical npm evidence and a fresh Bun candidate verify the migration.
- **Release runbook documents the post-date approval requirement and the gate's refusal conditions (#1956, follow-up to #1941).** The "Operator release approval gate" section in `skills/docs/release-runbook.md` now states that an `approve release v<version>` comment must post-date the release commit to be honored — a stale/older approval carried over from a prior or reverted cut is rejected — and names every refusal condition `verify-release-approval.mjs` enforces so the durable operator-facing doc matches the runtime gate: quoted/code-span (or fenced/indented/block-quote) text (`stripNonAssertionMarkdown`), instructional/handoff phrasing refused regardless of author (`instructsApproval`), same-clause negation, unverifiable (unparseable-timestamp) approvals as distinct from stale ones (`resolveApprovalState`), and the exact-version/operator-authored requirement. Doc alignment only — no change to `verify-release-approval.mjs` behavior (shipped in #1941). Generated `.claude/` mirror regenerated in lockstep.
- **Tier the gate vocabulary so "run through the gates" no longer mis-routes to the non-gating `review` gate (#1913).** `review` was a flat member of `GATE_NAMES` alongside the two lifecycle gates, but it gates nothing (never blocks a draft→ready or ready→merge transition, never satisfies gate evidence); the disambiguator "does it block a lifecycle transition?" lived only in `skills/review/SKILL.md` prose and scattered `=== "review"` guards, so a "run this PR through the gates" request could reasonably keyword-match the one gate that gates nothing and its prominent standalone `/loop-review` command. `scripts/github/_gate-names.mjs` now encodes the tier in the canonical vocabulary: it exports `LIFECYCLE_GATES` (`draft_gate`, `pre_approval_gate`) and `REVIEW_GATE` (`review`) and DERIVES `GATE_NAMES = [...LIFECYCLE_GATES, REVIEW_GATE]`, so the value/order stays byte-identical (`["draft_gate","pre_approval_gate","review"]`) and every existing consumer is unaffected. The `dev-loop` skill's review-intent short-circuit gains a symmetric carve-out: a request phrased around *gating* ("run through the gates", "gate this PR", "gate PR #N") with no explicit "review" word routes through the ordinary startup flow to the lifecycle gates, NOT the `review` short-circuit. `gate-review-comment-contract.md` gains a one-glance gate-tier table. Non-goals: `review`'s behavior is unchanged (still non-gating, ownership-exempt, no evidence), no `=== "review"` call sites were refactored, and nothing was renamed. Regression-pinned by `test/github/gate-names.test.mjs`; generated `.claude/` mirrors regenerated.
- **Honor "matrix on the issue, checklist on the PR" without duplicate issue checklists (#1951).** The #1877 refinement floor required a refined tracker-backed issue body to carry an Acceptance criteria checklist AND a Definition of done checklist AND explicit Non-goals — three overlapping artifacts that made the issue visually repetitive and blurred the authority boundary. The floor is now the authoritative semantic **AC→DoD mapping matrix** (a two-column table mapping each acceptance-criterion outcome to its required completion evidence) plus an explicit Non-goals section; interactive issue-side AC/DoD checklists are no longer required merely to satisfy detection. `detectIssueRefinementArtifact` (via the new `detectAcDodMatrix`) validates the mapping table's presence and shape fail-closed with `missing_ac_dod_matrix` (a checklist-only or matrix-missing issue) and `malformed_ac_dod_matrix` (an empty or identifier-only/tautological table such as `AC1 → D1`), replacing the obsolete `missing_ac_checklist` / `missing_dod_checklist` findings; the epic/refinement verifier (`refinement-completeness-checker.mjs`) shares the same shape validation. Validation is structural/completeness only — the mapping's semantic truthfulness stays a reviewer duty. The new `derivePrChecklistsFromIssueMatrix` deterministically projects the issue matrix into self-contained list-form PR AC/DoD checkboxes (never a matrix/table on the PR, never checkboxes in table cells). The review angle `pr-checklist-matrix` is renamed to `pr-checklist` across config, `gate-fanin` synthetic-angle constant, the `consolidate-fanin --pr-checklist clean` CLI flag, prompts, docs, and generated Pi/Claude assets with no compatibility alias; its prompt now also verifies the PR checklists are faithfully derived from the issue matrix. The pre-approval unchecked-box block and `validateTrackerBackedPrBodySpec` continue to read the PR's list-form checklists unchanged, so acceptance verification no longer depends on ticking duplicate issue-side boxes. Migration: existing checklist-only issues stay readable but fail closed with `missing_ac_dod_matrix` and are re-grilled (loop-grill now synthesizes the matrix) rather than grandfathered. Canonical contracts updated: `ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR` (artifact-authority-contract), projects-queue-contract, acceptance-criteria-verification, gate-review-sub-loop-contract, and the loop-grill skill.

### Fixed

- **`specDigest` no longer changes on a semantics-preserving checklist-alias edit that projects an unchanged AC→DoD matrix (#2016).** `computeSpecDigest` hashed `extractSpecFromBody`'s output, which scraped the list-form Acceptance criteria / Definition of done CHECKLISTS — a redundant presentation projection of the authoritative AC→DoD mapping matrix (#1951) — rather than the matrix itself. Adding, aliasing, or re-heading a checklist line therefore changed the scraped text and the digest even though the matrix (the actual semantic spec) was unchanged, and `resolveCriterionInvalidation` then staled every prior clean review approval, forcing the full reviewer fan-out/fan-in to run again for no behavioral reason. `extractSpecFromBody` now sources acceptance-criteria/definition-of-done text from the authoritative matrix (reusing `detectAcDodMatrix` from `issue-refinement-artifact.mjs`, unmodified — no second matrix parser) whenever the body carries one that parses as valid: the redundant checklist is no longer part of the hashed input, so adding/removing/reformatting a checklist alias that projects the same matrix leaves `specDigest` unchanged, while any change to a matrix criterion's text, its completion-evidence cell, or the row set (add/remove) still changes the digest and correctly re-invalidates through the existing `resolveCriterionInvalidation` path — no genuine acceptance-criterion change is silently exempted from review. Non-goals text is untouched by this change (it was never part of the redundant-checklist problem) and continues to be read from the `## Non-goals` section. Fail-closed: a body with no matrix at all, or a matrix that is empty/malformed/identifier-only (`detectAcDodMatrix` reports `found: false` or `valid: false`), falls back unchanged to the pre-#2016 checklist-based read rather than digesting a narrowed/empty AC/DoD surface. Regression-pinned in `packages/core/test/spec-authority.test.mjs`: checklist-alias add/remove against an unchanged matrix leaves the digest unchanged; changing a criterion, a completion-evidence cell, adding/removing a matrix row, and changing a Non-goal each change the digest; heading/whitespace/checklist-marker normalization with an unchanged matrix leaves the digest unchanged; a malformed or entirely absent matrix falls back to the checklist projection.
- **`request-copilot-review.mjs` no longer reports a false-negative failure when Copilot's review request/review is genuinely already in progress (#1980).** Verification relied exclusively on two REST surfaces that go blind once a request transitions into an in-progress review: `GET .../pulls/<pr>/requested_reviewers` empties out, and `gh pr view --json reviews` never returns another actor's PENDING review — so a genuinely successful `[bot]`-login `requested_reviewers` POST (exit 0) could still throw "did not appear..." while the GitHub UI showed Copilot actively reviewing (observed on the #1922 → PR #1976 and #2013 → PR #2014 cycles). A new fail-soft GraphQL cross-check (`isCopilotReviewObservableViaGraphql`, via `gh api graphql`) observes an active `reviewRequests` entry for the Copilot bot login and/or a Copilot review node in ANY state (including PENDING) at the current head — the same state the UI renders — and is queried once, right after the initial post-request verification read, as an ADDITIONAL signal unioned with the existing REST checks (never replacing them); any GraphQL error is treated as "not observed here," never a throw. Separately, when the POST already returned exit 0 but nothing is observable via REST or GraphQL within the existing bounded retry window (~30s), the helper now returns `{ status: "requested", detail: "...eventually consistent..." }` (exit 0) instead of throwing — a genuine 422 ("Reviews may only be requested...") is still classified `unavailable`, unchanged. Every existing branch (draft suppression, blocked-by-copilot-comment, round-cap, draft-gate round reset, same-head clean-convergence, convergence-carry-forward, and the `--silent` exit-code contract of `0` only for `requested`) is untouched. Regression-pinned in `test/github/request-copilot-review.test.mjs`: GraphQL observing an active request or a current-head PENDING review (zero retries, immediate success), a stale-head GraphQL review correctly ignored, a GraphQL surface error treated as not-observed, and the POST-succeeded-but-unobservable-everywhere fallback returning `requested` instead of throwing.
- **Canonical Bun verification excludes repository `tmp/` snapshots and keeps passing test runs quiet while preserving failure diagnostics (#2013).** Passing suites now emit only a compact summary; failed, signaled, or unparseable runs emit a focused diagnostic digest and retain the complete raw log for inspection.
- **`isLocatableFinding` no longer crashes on a finding carrying a singular `file` string instead of a `files` array (#1900).** The shared shape floor `hasLocatableShape` (`@dev-loops/core/loop/gate-fanin`) accepts a finding whose path is named by EITHER `file` (singular string) OR `files[0]`, but two consumers read `finding.files[0]` directly after the floor passed: `isLocatableFinding` (`scripts/github/_gate-finding-surface.mjs`) in its commentable-set lookup, and the inline-comment post site in `scripts/github/upsert-checkpoint-verdict.mjs` (`path: finding.files[0]`). A hand-authored or non-`consolidate-fanin`-produced findings ledger using `file` singular (e.g. a main agent reconstructing a ledger to re-post a round) passed the floor via its `file` branch and then threw `TypeError: Cannot read properties of undefined (reading '0')`. The production producer always emits a `files` array, so the crash was latent for ledger-backed rounds and only bit hand-authored ledgers and any future producer emitting `file` singular. The `file`-or-`files[0]` resolution is now a single exported resolver `resolveFindingFile` co-located with the floor in `gate-fanin.mjs`; `hasLocatableShape` and both consumer sites call it, so a consumer can never drift from the shape the floor accepts. The resolver trims the resolved path and treats a whitespace-only `file` as absent (falling back to `files[0]` instead of shadowing it), so a hand-authored singular `file` — which `readGateFindingsLedger` does not trim, unlike `files[]` — still matches the trimmed commentable-line-set keys and yields a valid GitHub review-comment `path`. The production `files`-array shape resolves byte-identically (no behavior change). Non-goals: the shared floor still accepts BOTH shapes (not narrowed to array-only); `file` is not normalized to `files: [file]` at ledger read time; `fingerprintFinding`'s existing `files`-array guard and dedup semantics are untouched. Regression-pinned in `packages/core/test/gate-fanin.test.mjs` (the shared resolver across both shapes, precedence, and `undefined` fallbacks; `hasLocatableShape` accepting a singular-`file` finding) and `test/github/gate-finding-surface.test.mjs` (`isLocatableFinding` does not crash on the singular-`file` shape and classifies identically to its `files[0]` twin).
- **Catch a file-mutation `Edit`/`Write` that lands on the MAIN checkout while a worktree cycle is active, before it reaches a commit (#1994).** After `ensure-worktree.mjs` creates an isolated worktree for a cycle, nothing caught a subsequent absolute-path `Edit`/`Write` whose target resolved to the main checkout instead of the active worktree — the change landed on the wrong checkout and was silently lost from the branch (observed on #1973 → PR #1992: six edits hit main, self-caught only by a manual `git status` before commit, costing a redo pass). A new pure decider `decideWorktreeCheckoutGuard` (in `packages/core/src/claude/hook-decisions.mjs`), wired into the always-on PreToolUse `Edit`/`Write` hook (`.claude/hooks/pre-tool-use-write-guard.mjs`), refuses the mutation when the call context's `cwd` sits inside a listed worktree and the target resolves to a non-gitignored file in the main checkout (via `git check-ignore`, so a not-yet-tracked new source file is caught too), naming the worktree-local path to use instead. The "active worktree" is anchored to the worktree CONTAINING cwd — not to the mere existence of a worktree — so the guard is immune to the many stale `tmp/worktrees/` worktrees a long-lived checkout accumulates. A legitimate in-worktree edit and a gitignored/scratch/other-worktree/outside-repo path pass untouched (no false positives); an unresolvable/ambiguous active-worktree context fails SAFE (the hook treats an undeterminable tracked status — e.g. `git check-ignore` errored — as tracked and refuses rather than silently allowing). A deliberate main-checkout edit during an active cycle is authorized with `DEVLOOPS_ALLOW_MAIN=1` — the same override the default-branch guard uses, keeping "operate on the primary checkout on purpose" one operator flag. This is the tool-call-time counterpart to the commit-time `pre-commit-branch-guard.mjs --block-main-checkout` check. Non-goals: per-worktree retrospective-checkpoint scoping (#1979) and worktree-local gate-evidence locality (#1970/#1978) are the related "worktree vs main checkout" family, tracked separately; no durable active-worktree marker or lifecycle state is introduced (the cwd anchor needs none). `WORKTREE-WRONG-CHECKOUT-GUARD` documents the behavior in `skills/docs/worktree-guidance.md`; the `skills/docs/anti-patterns.md` main-checkout-mutation entry cross-links it; generated `.claude/` mirror regenerated in lockstep. Regression-pinned in `packages/core/test/claude-hook-decisions.test.mjs` (in-worktree allow, main-while-worktree-active deny with the worktree-local fix path, ambiguous fail-safe deny, no-active-worktree allow, deliberate-override allow, and gitignored/scratch allow).
- **Wire `--allowed-refs <governing-issue>` into the sanctioned `close-gate-findings` invocation so #1992 stops being inert (#1993).** PR #1992 added `--allowed-refs` to `close-gate-findings.mjs` end-to-end but never updated the invocation contract that calls it: `skills/copilot-pr-followup/SKILL.md` step 7 and the two `skills/docs/gate-review-sub-loop-contract.md` gate-close invocations still ran `close-gate-findings.mjs --ledger <path>` bare, so the capability existed but nothing passed it — the next PR whose deferred finding cites its own governing issue re-hit the fail-closed bare-`#N` comment-id guard until a driver threaded the flag by hand. All three sanctioned invocations now pass `--allowed-refs <governing-issue>`, where `<governing-issue>` is the PR's governing (closing) issue resolved deterministically from the PR's `closingIssuesReferences` (the same `Closes #N` the Phase 1 gate-context bundle already resolves) — never a hardcoded literal, and passed as the CSV of all closing issue numbers for an umbrella PR. This opens the comment-id guard ONLY for the PR's own governing issue (scoped allowlist, not a blanket bypass), so a deferred finding citing that issue dispositions cleanly with no manual flag-threading while a bare reference to any UNrelated issue stays fail-closed. Non-goals: `close-gate-findings.mjs`'s flag semantics (shipped in #1992) and the comment-id guard's default behavior are unchanged — this only wires the call site. Generated `.claude/` mirrors regenerated in lockstep; pinned by two `#1993` contract tests in `test/contracts/copilot-review-doc-contracts.test.mjs` (every sanctioned invocation across source + mirror carries the flag; the governing issue is resolved deterministically, not a hardcoded numeric id).
- **Reviewer briefings pin a worktree-absolute findings dir so a fan-out reviewer cannot pollute the primary checkout's `tmp/` (#1978).** A fan-out reviewer's shell cwd is not trustworthy across its commands (each may start in the primary checkout, not the driving worktree), so a cwd-relative `tmp/...` findings write could land in the primary checkout's `tmp/` where fan-in never looks — surfacing only as a confusing late "missing evidence" failure (observed on PR #1976, where the docs-surface reviewer's two artifacts landed in the main checkout and were recovered by a manual `cp`). The invariant briefing prefix (`renderBriefingPrefix` in `scripts/github/write-gate-context.mjs`) now carries a **findings write-path invariant** pinning the WORKTREE-ABSOLUTE per-angle findings directory (`<worktree>/tmp/gate-reviews/<repo-slug>/pr-<N>/<gate>-<headSha>/<angle>.json`) and the matching `--tmp-root "<worktree>/tmp"` for any findings-writer CLI; the path is deterministic from the round's invariant inputs, so it stays byte-identical across every reviewer of the gate pass and carries no angle identity. As a fail-closed backstop, `consolidate-fanin.mjs` (`detectMisplacedFindingsDiagnostic`) now detects a findings artifact stranded in the primary checkout — resolved via git-common-dir when run from a linked worktree — and names it in the "no findings artifacts" and `GATE-EXEC-RESOLVED-ANGLE-EVIDENCE` diagnostics instead of failing opaque. Non-goals: single-checkout runs are unaffected (the detection returns empty when the primary checkout IS the run's checkout); the per-worktree retrospective-checkpoint fragmentation is a separate follow-up. `GATE-EXEC-BRIEFING-PREFIX` in `gate-review-sub-loop-contract.md` documents the invariant; generated `.claude/` mirror regenerated. Regression-pinned in `test/github/write-gate-context.test.mjs` (the prefix pins the worktree-absolute findings path and `--tmp-root`) and `test/loop/consolidate-fanin.test.mjs` (misplaced-in-primary detection, single-checkout no-op, and the enriched empty-dir diagnostic).
- **`close-gate-findings` accepts `--allowed-refs`, matching `reply-resolve-review-thread` so a deferred/dispositioned finding may cite the PR's own governing issue (#1973).** The disposition reply body embeds the finding's own summary (`buildMeritRationale`), which is posted through the shared `guardCommentBodyNoIssuePrIds` comment-id guard inside `replyAndMaybeResolve`. `close-gate-findings.mjs` never threaded an allowlist, so a legitimate bare `#<digits>` reference to the PR's governing issue (observed on PR #1965 → issue #1951) fail-closed the reply, forced the target into `dispositionFailures`, and left the thread unresolved — the operator had to fall back to a manual script. It now accepts `--allowed-refs <csv>` with the same shape/semantics as `reply-resolve-review-thread` (parsed via `parseAllowedRefsCsv`), threading the ids through `runDispositionPass` into both the fileable and unfiled `replyAndMaybeResolve` calls. Without the flag the guard behavior is unchanged: un-whitelisted bare refs stay fail-closed. Non-goals: the comment-id guard's default behavior and its bare-`#N` detection are unchanged. Regression-pinned in `test/github/close-gate-findings.test.mjs`: a whitelisted governing-issue ref posts and resolves, a non-whitelisted bare ref stays refused (`dispositionFailures` + non-zero `unresolvedGateThreadCount`), and `--allowed-refs` parses to a deduped positive-int id list (rejecting non-integers).
- **commit-msg guard echoes a concrete `Claude-Session:` trailer example in its rejection message (#1959).** When the `WORKTREE-COMMIT-MSG-GUARD` (`renderCommitMsgGuardHook` in `packages/core/src/loop/commit-msg-guard.mjs`) rejected an agent-authored commit missing the required `Claude-Session:` trailer, the message showed only the placeholder `Claude-Session: <url>` — so the author had to grep `skills/docs/worktree-guidance.md` to learn the trailer's actual shape, a discovery tax hit repeatedly across sessions (observed in the #1946 retro). The rejection now appends a concrete example line (`e.g. Claude-Session: https://claude.ai/code/session_abc123`), kept accurate to the trailer the guard actually enforces (`/^Claude-Session:\s*\S+/`). No change to which trailers are required or the accept/reject logic. Regression-pinned in `packages/core/test/commit-msg-guard.test.mjs` by asserting the concrete `Claude-Session: https://claude.ai/code/session_` example is present in the rejection output.
- **`resolve-pr-conflicts` merges duplicate `### <Type>` CHANGELOG subsections instead of concatenating them (#1958).** The additive-CHANGELOG conflict auto-resolver's keep-both step (`resolveAdditiveChangelog` in `scripts/loop/resolve-pr-conflicts.mjs`) concatenated the two conflict blocks verbatim, so when both merge sides added a same-named subsection under `## Unreleased` (e.g. both add a `### Fixed` block) it emitted two consecutive `### Fixed` headings — a structurally wrong changelog observed on PR #1946 that needed an extra Copilot round to fix. The keep-both step now routes through a new `mergeKeepBothSubsections` helper that dedupes same-named `### <Type>` subsections: items are merged under a single heading (ours items then theirs items, seam blank lines trimmed), distinct subsections stay separate, and blocks with no `### ` heading still collapse to plain ours-then-theirs concatenation (unchanged behavior). The empty-merge-base additivity guard and every fail-closed path are unchanged. Regression-pinned in `test/loop/resolve-pr-conflicts.test.mjs` by a unit case, a distinct-subsection case, `mergeKeepBothSubsections` unit tests, and a real-git both-sides-add-`### Fixed` fixture asserting exactly one heading.
- **Gate-group scope names composed from an underscore gate id no longer fail the hyphen-only scope validator (#1957).** Gate ids canonically carry underscores (`draft_gate`, `pre_approval_gate`), but the reviewer-scope convention is hyphen-only (`VALID_SCOPE_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/`). During gate fan-out a group scope hand-composed from the gate id (e.g. `draft_gate-group-docs-surface`) inherited the underscore and was rejected by the `--scope` validator, so sub-dispatches failed until the driver self-corrected to the hyphenated form — a per-cycle retry observed in the #1947 and #1953 retros. The canonical convention stays hyphen: `scripts/github/_gate-names.mjs` now exports `canonicalizeScope` (the single source of the underscore→hyphen scope normalization; `gateScopePrefix` reuses it), and every `--scope` entry point (`verify-fresh-review-context.mjs`, `record-dispatch-prompt-layout.mjs`, `compose-reviewer-prompt.mjs`) canonicalizes the raw value before validating and keying, so an underscore gate-id-derived scope validates on the first attempt and keys the same sentinel/record as its already-hyphenated twin. No currently-valid (hyphen-only) scope changes behavior; the fix only canonicalizes inputs that would previously have been rejected. Regression-pinned in `test/github/gate-names.test.mjs` (the `canonicalizeScope` helper and `gateScopePrefix` reuse), `test/github/verify-fresh-review-context.test.mjs` (a `draft_gate`/`pre_approval_gate`-derived group scope validates without retry and keys one sentinel with its hyphen twin), `test/github/dispatch-prompt-layout.test.mjs`, and `test/github/compose-reviewer-prompt.test.mjs`.
- **`gate request-copilot` no longer silently no-ops for the Copilot bot; it requests via REST `requested_reviewers` with the `[bot]` login (#1918).** On repos whose Copilot code-reviewer is the app-style bot (`copilot-pull-request-reviewer[bot]`), `requestCopilotReview()` in `scripts/github/request-copilot-review.mjs` issued `gh pr edit --add-reviewer @copilot`, which returns success but registers no reviewer — a silent no-op that left `reviewRequests` empty and stalled the `copilot-pr-followup` loop at `waiting_for_copilot`, forcing a manual "Re-request review" UI click every round. (The GraphQL `requestReviews` `botIds` mutation has the same failure once Copilot has already reviewed.) The request now goes through the REST endpoint `gh api repos/{o}/{r}/pulls/{n}/requested_reviewers -X POST -f 'reviewers[]=copilot-pull-request-reviewer[bot]'` — the plain `copilot-pull-request-reviewer` login 422s ("Reviews may only be requested from collaborators…"), only the `[bot]`-suffixed login actually registers the request. The existing post-request verification (poll `requested_reviewers` / fresh review), the exactly-once request contract, and the `unavailable` classification of a genuine 422 (Copilot reviewer truly not enabled) are unchanged. `scripts/README.md` and the helper's in-file comments/USAGE are updated; a `#1918`-named regression test in `test/github/request-copilot-review.test.mjs` pins the REST arg vector, the `[bot]` login, and that `pr edit --add-reviewer` is never issued. This also incidentally reduces the false-negative stall symptom tracked separately in #1980 (a landing request now registers instead of appearing absent), without changing #1980's already-reviewed reporting logic.
- **Interactive `/loop-review` Submit no longer leaves a dangling pending draft (#1848).** The interactive Phase 4 **Submit as Comment/Request-changes/Approve** choice re-runs `upsert-checkpoint-verdict.mjs --gate review --submit <mode>` against the same head as the Phase 3 `--submit pending` draft. That re-run routes through the shared same-head pending-review resolution added in #1912 (`findOwnPendingReview`/`submitPendingReview`), which SUBMITS the existing author-only pending draft in place via `POST /pulls/<pr>/reviews/<id>/events` (same review id, inline comments preserved) instead of creating a second review — so an interactive Submit now leaves exactly one review and no orphaned pending draft (previously it created a new submitted review and left the original pending draft dangling, invisible in the filtered review stream but accumulating as author-side clutter). No code change to the #1912 resolution path or its login+head+header-anchored data-loss guard: this closes the residual documentation and traceability gap. `commands/loop-review.command.md` and `skills/review/SKILL.md` Phase 4 now state the no-dangling guarantee explicitly for every Submit-as option (Discard still deletes the draft, Leave pending still keeps exactly one); generated `.claude/` mirrors regenerated. A `#1848`-named test in `test/github/upsert-checkpoint-verdict.test.mjs` pins the no-dangling Submit behavior with a stubbed reviews API (submit-in-place reuses the pending id, no create POST, no DELETE) across all three submit events.
- **Gate fan-in auto-sanitizes bare `#<digits>` refs in reviewer finding text before the comment-id guard (#1922).** A scoped reviewer's finding summary/recommendation naturally contains bare issue/PR references (e.g. `#1807`, `#1584`); `upsert-checkpoint-verdict.mjs`'s comment-id guard fail-closes on any bare `#<digits>` auto-link token in a generated body, so the gate pipeline refused to post its own verdict and the operator had to hand-sanitize the consolidated findings/ledger JSON (`sed 's,#N,issue N,'`) on every round. The guard is correct; the gap was that the pipeline never neutralized its own generated finding text before the guard ran. `consolidateFanin` (`scripts/loop/consolidate-fanin.mjs`) now neutralizes bare refs (`#123` -> `123`, auto-link syntax requires the leading `#`) at the ONE fan-in seam — the in-place bounding loop both output shapes source their finding text from — so the flat `--ledger-out` findings AND the nested `--out` findingsJson are guard-safe and a rendered verdict body no longer refuses to post. The transform runs on RAW text before any markdown sanitizer emits its own numeric character references, and fingerprints are unchanged (`fingerprintFinding` already strips `#` in its normalization), so no finding re-surfaces. The neutralizer is hoisted to the shared `@dev-loops/core/github/comment-id-guard` module (`neutralizeBareIssuePrIds`) so the fan-in seam and the existing deferred-follow-up-issue path (`_gate-finding-surface.mjs`) share one implementation; the comment-id guard itself is unchanged and stays fail-closed for human-authored bodies. Deliberate cross-references are unaffected — they live on the verdict body's structured fields and round-trip through the guard's `allowedRefs` at post time, not through reviewer finding prose.
- **`review` gate `pending -> submit` re-run no longer 422s on an invisible own pending review (#1912).** The `review` skill's Phase 4 submit choice re-runs `upsert-checkpoint-verdict.mjs --gate review --submit comment|request-changes|approve` for the same round, but the same-head idempotency scan (`summarizeExistingComment`) keys off a `visible` marker and a pending review is author-only, so its marker is never visible — the scan missed it and took the CREATE path. GitHub allows only one pending review per user per PR, so the second create returned HTTP 422 for every submit transition out of `pending`. The review submit path now detects the caller's own PENDING review directly off the raw reviews list (which returns a pending review only to its own author, at most one per PR). GitHub's one-pending-per-PR-per-user limit is PR-scoped, not head-scoped, so it 422s any create regardless of head: a SAME-HEAD pending is SUBMITTED via `POST /pulls/<pr>/reviews/<id>/events` (mapped event `COMMENT|REQUEST_CHANGES|APPROVE`, preserving inline comments) instead of creating a second one, while a STALE different-head pending is DELETEd before the round falls through to create a fresh review at the current head. A new `--submit discard` mode DELETES the caller's own pending review (`DELETE /pulls/<pr>/reviews/<id>`) regardless of head — distinct from `--submit pending` (leave-pending), which is a noop; like `approve`/`request-changes` it is refused headless and fails closed without `--interactive-confirm`. The create path is unchanged when no own pending review exists. Separately, a `fanout_fanin` round posted with `--findings-json` but no `--findings-ledger` now emits a one-line advisory warning naming `--findings-ledger` as the missing inline-comment source (that combination silently files zero inline comments). New helpers `findOwnPendingReview`/`submitPendingReview`/`discardPendingReview` in `scripts/github/_gate-finding-surface.mjs`; `skills/review/SKILL.md` Phase 4 and `skills/docs/gate-review-comment-contract.md` (`GATE-REVIEW-SUBMIT-MODES`) document the behavior; generated `.claude/` assets regenerated.
- **Gate verdict comment splits the per-angle breakdown into two tracks by locatability instead of a wall of clean rows, and never degrades a finding to an invisible "N omitted — in ledger" pointer (#1942).** `renderStructuredFindings`/`renderAngleVerdictDigest` (`scripts/github/upsert-checkpoint-verdict.mjs`) now render, at TOP LEVEL (never through the blockquoted `--findings-summary`/`--findings-file` continuation-line path): (1) a locatable finding — carried entirely by its own inline PR review comment — is never enumerated per-finding in the body; the body states only one aggregate `**Inline findings:**` line (count, severity breakdown with a leading emoji per finding, 🔴 high · 🟠 medium · 🟡 low · ⚪ nit · 🔵 question, and the touched angle names); (2) a non-locatable (body-only) finding has no inline carrier, so it renders in full as its own plain bulleted list item — NEVER a markdown table — findings-first and severity-ordered (`SEVERITY_ORDER`: high, question, medium, low, nit), with its `file:line` linked to the blob at the reviewed head SHA when known and its angle in trailing brackets; (3) every clean angle collapses into one trailing `**Clean (N):**` line. Every finding's full text now lives in exactly ONE reader-reachable carrier (its inline comment or its body-list bullet), never both, never neither, and never only the on-disk disposition ledger. The now-redundant `**Body-filed findings**` block is removed; a body-filed finding's cross-round fingerprint/`disposition=deferred` marker still lands on the body, just invisibly. `consolidate-fanin.mjs`'s budget-degradation ladder no longer emits a synthetic omitted-count marker — a round over budget now hard-truncates every finding's own summary (down to a `MIN_FINDING_SUMMARY_CAP` of 16 chars) instead, and only withholds the whole round (`findingsJson: []`, `--out` removed) as the absolute structural floor when even that hard-truncated shape still cannot render.
- **SubagentStop guard no longer hard-deadlocks an editing subagent under a task-scoped no-commit instruction (#1936).** The `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT` env-var exemption (#1786) sanctioned a "LOCAL EDITS ONLY: no commit" delegation split: an editing sub-delegate (`developer`/`quality`/`docs`) made edits and left the commit to the orchestrator, exempting its own `SubagentStop`. On the Claude harness the orchestrator cannot set a per-dispatch env var, so a delegated editing subagent told not to commit hit a hard deadlock — the `subagent-stop-uncommitted-guard` hook demanded a commit, the session permission classifier denied it, the hook re-blocked the exit, and only a human interrupt broke the loop. This also contradicted `LOCAL-COMMIT-BEFORE-EXIT` (implementation-loop step 12), which already mandates a dispatched editing subagent commit before exit. The split and its env-var exemption are removed: editing sub-delegates (`developer`/`quality`/`docs`/`fixer`) commit their own work (and push, for tracker-backed sessions); an orchestrator that wants one consolidated commit performs the edits itself. The guard stays fully enforced for every editing role, so data-loss protection is preserved and the deadlock is structurally impossible. The read-only-role exemption (#1925, `judge`/`review`) and the interactive `DEVLOOPS_COMMIT_AUTH_PENDING` exemption (#1619) are unchanged. See ADR 0058.
- **Release-approval gate no longer fails open on quoted, instructional, or stale approve-release text (#1941).** `scripts/release/verify-release-approval.mjs` previously accepted any operator-authored comment that merely CONTAINED `approve release v<version>` — including the phrase quoted in a code span/fence/block quote, an instructional handoff occurrence (`post \`approve release v…\``, "requires a comment stating…"), or a comment that predated the release commit (e.g. an approval carried over from a prior/reverted cut). The stable-release gate could therefore pass with no genuine approval. The matcher now strips code spans, fenced blocks (backtick and tilde, including an unterminated fence), block quotes, and indented code blocks, and rejects instructional/handoff occurrences (`stripNonAssertionMarkdown` + `isGenuineApprovalAssertion`), so only a top-level assertion counts, and it enforces post-date freshness: an approval is accepted only when its `created_at` is strictly after the release commit being tagged (`resolveReleaseCommitDate`, default git HEAD committer date, overridable with `--release-commit-date`). A comment with a missing/unparseable timestamp, or an unresolvable release-commit date, fails closed. Genuine-absence and same-sentence-retraction refusals are preserved; both `release.yml` and `npm-publish.yml` call the hardened check. Refusal and acceptance cases (quoted / instructional / stale rejected, fresh genuine accepted) pinned by `test/docs/verify-release-approval.test.mjs`. Reconciles the agent-unforgeable-approval floor with the #1939 merge-approval sibling.
- **Owner resolution now falls back to the org namespace for org-owned Projects V2 boards (#1949).** `resolveOwner` probed the GitHub GraphQL `user(login:)` namespace first; for an org login, `gh api graphql` exits non-zero (`Could not resolve to a User`), and the thrown `GH_API_ERROR` short-circuited before the caller could fall through to the `organization(login:)` probe. Every queue/board command that resolved its owner from an org-owned board failed. The function is now a single shared, exported `resolveOwner` in `packages/core/src/github/gh.mjs`; it catches a failed user probe and falls through to the org probe, and only fails closed with `NO_USER_ID` when both probes fail. The 7 verbatim duplicate copies (`packages/core/src/projects/list-queue-items.mjs`, `move-queue-item.mjs`, `packages/core/src/loop/queue-board-sync.mjs`, `scripts/projects/ensure-queue-board.mjs`, `reorder-queue-item.mjs`, `archive-done-items.mjs`, `add-queue-item.mjs`) are consolidated to import the shared helper.
- **docs-reference validator: remove latent false positives from the disposition audit (#1920).** The `referenced-docs-commands-shipped` contract now resolves anchor links into setext headings (`===`/`---` underlines, matching GitHub) so a valid link is not false-failed as dangling, and skips `#fragment` links into non-markdown targets (e.g. `foo.mjs#L10`) instead of reading them and false-failing for having no headings (this narrows filesystem reads). The brittle scan-SIZE proxy is replaced by direct required-root assertions (`README.md`, `PLAN.md`, `AGENTS.md`, and at least one `docs/` file). Leading YAML frontmatter is stripped before setext detection so a frontmatter-closing `---` cannot forge a phantom anchor.
- **SubagentStop data-loss guard is now role-aware (#1925).** The `LOCAL-COMMIT-BEFORE-EXIT` uncommitted-worktree guard (`decideSubagentStopGuard` / `.claude/hooks/subagent-stop-uncommitted-guard.mjs`) was role-blind: a read-only `judge`/`review` subagent that stopped with a foreign uncommitted edit in the shared worktree was forced to commit it, violating the verdict-only contract. The guard now exempts read-only roles (`READONLY_SUBAGENT_ROLES = judge, review`) by `agent_type`, allowing the stop with an advisory that names the orchestrator as the actor responsible for the pending edit; the data-loss protection stays enforced against the orchestrator and every editing role (`developer`, `fixer`, `docs`, `quality`).
- **A stuck `gate-evidence` required status now self-heals at merge-readiness instead of needing a manual `gh run rerun` (#1935).** The server-side check re-fires when a gate verdict is posted (ADR 0043), but that native re-fire is racy: the verdict-post run can be cancelled by the job's `cancel-in-progress` concurrency, or evaluate before the just-posted verdict is API-visible, leaving the required status `failure` on the current head with nothing to re-fire it — the merge stays `UNSTABLE`. New `scripts/github/reconcile-gate-evidence-status.mjs` (and the pure `resolveGateEvidenceStatusReconcile` decision in `@dev-loops/core/loop/gate-evidence-reconcile`) reads the authoritative evidence exactly as the CI check does and the current-head `gate-evidence` status; when the evidence is satisfied but the status is stuck non-green it re-fires the run that posted the stale status so it flips to `success`. Fail-closed and test-pinned: a genuinely missing verdict re-fires nothing and keeps failing closed. The dev-loop runs it at merge-readiness after the post-drive audit. See ADR 0057.

## 1.0.1 - 2026-09-04

First npm-published stable release. Identical in content to 1.0.0 (see below) — no code changes; a version bump only. `dev-loops@1.0.0` was published then unpublished during an earlier reverted release cut, and npm permanently tombstones an unpublished version number, so the first stable that can ship to the npm `latest` dist-tag is `1.0.1`. `@dev-loops/core` is bumped to `1.0.1` in lockstep.

## 1.0.0 - 2026-09-04

First stable release. Freezes the rc.7 runtime surface and lands the deterministic contract-enforcement hardening (PR-description, CHANGELOG, docs-reference, Non-goals, ADR-tripwire, records-floor, commit-message, fresh-context retro, and stable-release approval gates) plus the release lockfile-lockstep guard. No new runtime features beyond rc.7; the hardening adds gate/tooling checks only.

### Changed (breaking — legacy config fallback and `queue.board` alias removed, #1701)

Removed at the v1.0.0 cut per ADR 0017 (`docs/decisions/0017-devloops-root-config-layered-precedence.md`):

- **The legacy `.pi/dev-loop/settings.*` / `.pi/dev-loop/overrides.*` config fallback no longer loads.** `.devloops` at the repo root is the only consumer override layer; those legacy files are now ignored silently (no deprecation warning, no values applied). The `.pi/dev-loop/defaults.*` layer is unchanged and still merges between the shipped extension defaults and `.devloops`.
- **The `queue.board` → `tracker.board` alias is removed.** `queue.board` is no longer a valid `.devloops` key (it fails schema validation), `resolveTrackerBoard` reads only `tracker.board`, and every queue/project command, doc, and error message teaches `tracker.board` only. `scripts/loop/detect-internal-only-pr.mjs` no longer auto-detects the legacy config paths (explicit `--config` still works).

Upgrading your `.devloops`:

| Old key / path | New shape |
|---|---|
| `.pi/dev-loop/settings.yaml` / `.settings.yml` / `.settings.json` contents | move into `.devloops` (or `.devloops.yaml` / `.yml` / `.json`) at the repo root |
| `.pi/dev-loop/overrides.yaml` / `.overrides.yml` / `.overrides.json` contents | move into `.devloops` (`.pi/dev-loop/defaults.*` still loads as the middle layer) |
| `queue.board.number` / `queue.board.title` | `tracker.board.number` / `tracker.board.title` |

### Added
- **`dev-loops --version` / `-v` prints a stable version line (#1897).** The CLI now answers `--version` (and its `-v` alias) with a single parseable `dev-loops <semver>` line instead of `Unrecognized command: --version.`; extra arguments fail with the same malformed-argument contract as other top-level commands, the flag needs no `@dev-loops/core` import (works in deps-less marketplace checkouts), and the version source of truth stays `package.json` (no timestamp plumbing per the issue's own acceptance criterion).
- **Gate stable releases behind explicit per-release operator approval (#1901).** Cutting a stable tag (`vX.Y.Z`) and publishing to npm is no longer an agent judgment call: `scripts/release/verify-release-approval.mjs` (wired into both `release.yml` and `npm-publish.yml`) fails closed — no GitHub Release, no npm publish — unless an issue comment by the repo owner states `approve release v<version>` (or the operator runs the publish commands themselves). Blanket merge authorizations and generic continue instructions never satisfy the gate; prereleases pass through unchanged. The release runbook (source + `.claude` mirror) documents the sanctioned division: agents stage and verify up to the release commit; the tag-push + publish decision is operator-owned per release. Refusal path pinned by `test/docs/verify-release-approval.test.mjs`.
- **Enforce fresh-context provenance on the post-run retrospective (#1870).** A `complete` retrospective checkpoint is now provenance-gated: `resolveCheckpointStateFromArtifact` treats a record whose `provenance` does not pin a fresh-context pass over the cycle's full agent tool-call record (`context: "fresh"`, `seededFrom: "agent_tool_call_record"`, non-blank `recordSource`) as `MISSING`, so every legacy inline self-authored retro fails closed. `checkpoint-contract.mjs --state complete` requires `--retro-context fresh --record-source <path>` and rejects `--retro-context inline` outright; the retro procedure prose and the behavioral-review extension now mandate the independent fresh-context dispatch, matching how gate reviewers already run.
- **Enforce explicit Non-goals on tracker-backed issue refinement (#1866).** `detectIssueRefinementArtifact` now requires an explicit, non-empty `## Non-goals` section on tracker-backed issues (fail-closed, distinct `missing_explicit_non_goals` finding), reconciling the predicate with the loop-grill synthesis contract; a linked refinement doc satisfies the artifact check only when it actually resolves. See `ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR` in the artifact-authority contract.
- **Advisory `review`-gate marker-presence check closes the raw-`gh`-post asymmetry (#1899).** `skills/review/SKILL.md` gains a `REVIEW-GATE-VERDICT-CANONICAL` inline-imperative — mirroring the draft/pre-approval "never raw gh" guard — naming `upsert-checkpoint-verdict.mjs --gate review --findings-ledger` as the only sanctioned verdict-post path; the dev-loop skill's `@dev-loops/core`-missing fallback poster is not a substitute. `scripts/github/audit-review-marker-presence.mjs` is a new standalone, advisory-only check that scans a PR's review stream for the `dev-loops:gate-findings-review review` marker on a given head and, when a `--findings-ledger` carries locatable findings, that inline comments were attached, emitting a WARNING (never a block, never gate evidence) when either is missing. `review` stays absent from `GATE_CONFIG_KEY`; the #1850/#1840 non-evidence exemption is unchanged.
- **Enforce CHANGELOG completeness at the PR seam (#1864).** `scripts/docs/validate-changelog-completeness.mjs`, wired into `test:docs`, fails closed when a notable PR (conventional `feat`/`fix` commit subject, or a diff touching code per the shared `classifyFile()` change classifier) adds no list item under `## Unreleased` in `CHANGELOG.md`. Base resolution follows the decision-record validator's merge-base pattern; the CI `test:docs` leg already fetches the base branch, so the check blocks the PR. The release-time empty-section extractor remains the second line of defense.

### Changed
- **This repo's gate fan-out flips from sequential to bounded-parallel dispatch, with the dispatch discipline pinned as contract prose (#1907).** `.devloops` sets `gates.fanout.maxConcurrent: 3` (aligned with `queue.maxParallel`) instead of `gates.fanout.sequential: true`; the shipped `maxConcurrent` mechanism (#1601, ADR 0048) is unchanged and no new knob ships — `sequential` remains a documented load fallback for a SIGTERM-prone environment, and the cross-harness default (`maxConcurrent: 4`, `sequential: false`) is unaffected for other repos (#1086). Three new anti-patterns are pinned in `skills/docs/anti-patterns.md` (`END-TURN-AND-AWAIT-WAKE`, `SILENT-STDERR-PROBE`, `STICKY-PROVIDER-PIN`), the fan-out dispatch sections of `skills/docs/gate-review-sub-loop-contract.md` (`GATE-EXEC-DISPATCH-RETRY-BACKOFF`, `GATE-EXEC-END-OF-RUN-CONTRACT`) and the dev-loop SKILL/agent contracts now pin retry-on-transient-with-backoff, escalate-on-hard-4xx, per-dispatch provider choice, the end-of-run contract, and blocking-join guidance for nested single-child steps (judge/fixer/single reviewer) instead of sleep-polling — closing the gap behind observed 180/240/300s sleep loops. See `docs/decisions/0056-bounded-parallel-gate-dispatch.md`.
- **AC/DoD/Non-goals matrix now lives on the issue; the PR carries the derived checklist, and pre-approval fails closed on unchecked boxes (#1877).** `detectIssueRefinementArtifact` requires the full matrix (AC checklist + DoD checklist + explicit Non-goals) on all tracker-backed issues, not only epics — AC-only bodies now fail with `missing_dod_checklist`, DoD-only with `missing_ac_checklist`. The PR body keeps the self-contained checklist (real checkboxes, not a pointer to the issue), and `upsert-checkpoint-verdict.mjs` refuses a clean `pre_approval_gate` verdict while any AC/DoD checkbox in the PR body remains unchecked (`extractPrBodyUncheckedChecklistItems`, surfaced as `prBodyUncheckedAcItems`/`prBodyUncheckedDodItems`); the reviewer/judge truthfulness check of each `[x]` remains, with the deterministic check owning completeness only. The old `pr-checklist-matrix` angle prompt was rewritten to the completeness-machine-backed/truthfulness-reviewer split. Closes #1877.

### Fixed
- **"Don't go ready dirty" now holds regardless of how draft→ready was triggered (#1915).** A regression test pins that the loop's own PR-state detection (`detect-copilot-loop-state.mjs`/`detect-pr-gate-coordination-state.mjs`) already routes a non-draft PR carrying unresolved gate-authored finding threads to the fixer/disposition path (`unresolved_feedback_present` / `feedback_resolution`), never forward to `pre_approval_gate` — `unresolvedThreadCount` counts every unresolved review thread regardless of author, so a caller-independent bypass (a raw `gh pr ready` or the GitHub UI's "Ready for review" button, skipping the guard in `scripts/github/ready-for-review.mjs`) self-corrects on the loop's next iteration. A new `RAW-GH-PR-READY-BYPASS` anti-pattern entry names the bypass and the mandatory `ready-for-review.mjs` path, referenced from the copilot-pr-followup draft-gate procedure.
- **Gate finding deferrals now require merit rationale (#1882).** `close-gate-findings.mjs` extracts each finding summary and adds an `Examined on merits:` basis naming the applicable disposition rule, preventing severity-only closure notes while preserving net-reduction behavior. The merit rationale is built (and thereby validated) before any GitHub mutation, so a target whose summary cannot be parsed is recorded in `dispositionFailures` and left unresolved — one malformed thread body can no longer leave a stamped-but-unresolved thread that deadlocks retries, nor abort disposition for the other well-formed threads in the same batch. Embedded double quotes in a summary are collapsed so the wrapped rationale stays legible.
- **`upsert-checkpoint-verdict` now fails closed on `--submit approve`/`request-changes` unless provably interactive (#1888).** The `#1840` headless guard keyed on the caller honestly passing `--auto`, but the default posture (no `--auto`) also permitted those modes — a headless/agent caller could simply omit the flag and POST a GitHub-native `APPROVE` review, a branch-protection signal satisfying required-approvals independent of any dev-loops gate. `approve`/`request-changes` now require the explicit `--interactive-confirm` token, enforced both at CLI parse time and structurally in the `upsertCheckpointVerdict()` runtime entry (direct callers cannot bypass the parser); the token parses fail-safe (an explicit `=false`/`=0`/`=no` — case-insensitive — or an empty/whitespace-only inline value does NOT confirm, the `--flag=$VAR` unset-expansion shape); `--auto` still refuses those modes even with the token, and `pending`/`comment` are unchanged. The review skill's interactive multiple-choice submit step passes the token after a human choice.
- **Release guard now fails on `package-lock.json` version drift and prerelease-token mismatch (#1886).** `scripts/release/assert-core-dependency-version.mjs` compares the `@dev-loops/core` dependency's full version (prerelease token included) against the release version instead of only major.minor, and asserts all four `package-lock.json` version fields (root version, root package entry version, `packages/core` workspace entry version, and the `@dev-loops/core` dependency spec) are in full lockstep before a tag creates a release — the gap that shipped rc.7 with a lockfile still pinned to rc.6 through every green gate. The guard also fails closed on non-semver dependency specs that merely contain a version substring (`workspace:`/`file:`/`npm:` protocols) and escapes its CI log output, so crafted manifest/lockfile values cannot forge `::error::`/`::warning::` annotation lines. The release-cut runbook now regenerates the lockfile (`npm install --package-lock-only`) as part of the version bump.
- fix(test): the docs-reference contract test now also resolves unbackticked (fenced) CLI subcommand citations, sanitizes doc-derived tokens in failure output, and drops dead/speculative seams (gate round-1 fix).

## 1.0.0-rc.7 - 2026-08-27

### Security
- **Fail-closed secret scan in the fixer, a git pre-commit hook in every worktree, and a non-negotiable security floor in the agent contracts (#1816).** `packages/core/src/security/secret-scan.mjs` + `scripts/security/scan-staged-diff.mjs` implement three detector classes — literal-credential (known token shapes plus base64-decoded runs), high-entropy (Shannon-entropy threshold with a mixed-content gate), and the secret-var-into-output-sink pattern. A hit blocks the commit, reports `file`/`line`/`detectorClass` with a canned value-free reason, and NEVER echoes the matched value; an internal scanner error fails closed (blocks); the only opt-out is a per-line `secret-scan:allow <reason>` marker (no global disable, no baseline file). The pre-commit hook is installed into every worktree via the existing `installDefaultBranchGuard` path and runs the scan before any `DEVLOOPS_ALLOW_MAIN` override. `agents/fixer.agent.md`, `agents/judge.agent.md`, and the threat-model review angle carry the non-negotiable floor: a secret-introducing or security-regressing suggestion is refused and escalated regardless of reviewer signal.

### Added
- **Standalone `review` deliverable gains submit modes and an interactive end-of-run choice (#1840).** `upsert-checkpoint-verdict.mjs --gate review --submit <pending|comment|request-changes|approve>`: `pending` creates an author-only draft review (invisible until submitted), the others submit with that event; default is `comment`. A headless run (`--auto`) allows only `pending`/`comment` and refuses `approve`/`request-changes`, so automation can never auto-approve or auto-block a PR. `--submit` on `draft_gate`/`pre_approval_gate` is rejected. A `review` verdict remains non-evidence for dev-loops gates in every mode, including `approve`.
- **Deterministic reviewer-prompt composer — cache-aligned dispatch by construction (#1852).** `scripts/github/compose-reviewer-prompt.mjs` assembles each reviewer prompt with the round's byte-identical invariant prefix inlined as the leading bytes, followed by the volatile tail and the per-group suffix, and records the dispatch-prompt layout atomically in the same call. Cross-reviewer prompt-prefix reuse is realizable in a code-driven harness; the by-construction alignment and the mechanical layout record hold everywhere.
- **Enforce cache-aligned reviewer dispatch (#1841).** A machine check (`record-dispatch-prompt-layout.mjs` + `verify-dispatch-prompt-layout.mjs`, wired fail-closed into `consolidate-fanin`) fails a fan-out whose reviewer prompt does not lead with the round's byte-identical invariant prefix. This is the enforcement half of the cache-aware-dispatch lineage (#1468).
- **Inline a filtered diff into the reviewer prompt's shared per-head block (#1853).** The reviewed-head diff, with lockfiles and generated paths filtered out (`filterDiffForInline`), is inlined after the static contract span and before the per-group suffix — byte-identical across a round's reviewers, while the static leading span stays byte-identical across rounds. The full unfiltered diff remains at `scope.diffPath` for on-demand reads.

### Fixed
- **Standalone review is ownership-exempt (#1850).** A plain review of a PR you do not own now routes to the review skill BEFORE the single-contributor ownership gate, so a requested reviewer is no longer blocked from reviewing someone else's PR. Write-capable routes (fix/merge automation) stay gated exactly as before; the review path performs no branch/commit/merge/board write.
- **Pre-validate `--jq` filter syntax before mutation (#1817).** The shared `jq-output` helper rejects a syntactically-invalid `--jq` at argument-parse time (exit 2, `ok:false`) BEFORE a mutation wrapper runs, so a formatting-only filter error can no longer report `ok:false` on a call that already succeeded — the failure mode that caused retry-driven double-applied mutations (e.g. duplicate comments from an unsupported `{ok,url}` construction). Inherited by `comment-issue.mjs`, `create-issue.mjs`, `edit-issue.mjs`, and `reply-resolve-review-thread.mjs`; data-dependent errors still surface at emit time.
- **Tighten the secret-scan `sink-pattern` detector to cut prose false-positives without a false-negative (#1857, #1816 follow-up).** The sink-pattern's secret-name match required only a substring, so ordinary words containing "token"/"secret"/"key" ("token-economical", "tokenize") counted as secret variables, and its `>` redirect branch matched the `>` closing a `<owner/name>` placeholder — combining into false positives that blocked legitimate commits (including the rc.7 release commit). `hasSecretNamedIdentifier` now requires the keyword to sit in a real identifier-shaped token (underscore, camelCase/PascalCase, or bare ALL-CAPS), and `stripAnglePlaceholders` removes `<…>` spans before the redirect test which now requires a real target. Both are strict narrowings — no identifier-shaped secret-var-into-sink is lost (verified by executing the module against adversarial cases plus every existing true-positive fixture); the literal-credential and high-entropy detectors are untouched. The one deliberately-dropped class — an all-lowercase single-word credential name, inherently indistinguishable from prose by shape — is out of the deterministic detector's scope by design and is the domain of the follow-up agent-judgment layer. <!-- secret-scan:allow release-notes prose describing the scanner; reviewed, no real secret -->
- **`close-gate-findings.mjs` gates the auto-filed follow-up issue on severity + operator-visibility, closing the over-filing gap (#1846).** The disposition pass minted a tracked "Deferred gate findings for …" GitHub issue for EVERY resolve-without-fix finding regardless of severity, so a single resolved `nit` or a `low` no operator would ever act on became standing backlog — conflicting with the adopted net-reduction disposition policy. New `isFileableDeferral` (`scripts/github/_gate-finding-surface.mjs`) separates "resolve this thread" (unchanged: `isDeferredAtRound` still governs `unresolvedGateThreadCount` reaching 0) from "FILE it to the tracked follow-up issue and stamp `disposition=deferred`": a `nit` is NEVER fileable, any round; a `low` is fileable only when its own marker carries the explicit `operatorVisible` signal (`ov=1`, rendered from the finding's own `operatorVisible: true` — set via `write-gate-findings-log.mjs`'s `--findings`/`--findings-file`); the conservative default (absent/`false`) resolves in-thread with rationale but is never filed; `medium`/`high`/`question` are unchanged. `runDispositionPass` now creates NO follow-up issue when the round's fileable batch is empty (a nit-only or non-visible-low-only round). `detectContractViolatingDeferredStamps` and `stampDeferredDisposition`'s defense-in-depth guard both switch from `isDeferredAtRound` to `isFileableDeferral`, so a subagent bypass that manually stamps `disposition=deferred` on a nit or a non-operator-visible low is rejected the same way an in-window medium or a question already was. `skills/docs/gate-review-sub-loop-contract.md` (`GATE-EXEC-THREAD-DISPOSITION`, `GATE-EXEC-DEFERRAL-RECORD`) and `skills/copilot-pr-followup/SKILL.md` reconciled: the nit contract is now stated once and matches the code (a nit is never tracked). Tests in `test/github/close-gate-findings.test.mjs`, `test/github/gate-finding-surface.test.mjs`, and `test/github/write-gate-findings-log.test.mjs`.
- **SubagentStop guard's `maxBuffer` raised to 10MB; block reason's dirty-path enumeration capped at 50 (#1686).** `.claude/hooks/subagent-stop-uncommitted-guard.mjs`'s `git status --porcelain` capture used Node's default 1MB `maxBuffer`, so a heavily dirty worktree could overflow it, throw, and fail-safe allow the stop — defeating the guard exactly when the most work is at risk. `execFileSync` now allows up to 10MB. The block reason from `decideSubagentStopGuard` (`@dev-loops/core/claude/hook-decisions`) now lists at most 50 dirty paths plus a summary line naming the remaining count (the reported total is always the full count), so a very dirty worktree still produces a bounded, consumable reason instead of an unbounded one. Tests in `packages/core/test/claude-hook-decisions.test.mjs` (cap boundary at 50/51, order preservation) and `test/contracts/claude-hooks-settings.test.mjs` (e2e >1MB fixture).

## 1.0.0-rc.6 - 2026-08-16

### Added
- **create-pr refuses duplicate linked PRs; explicit replacement override + fail-closed ambiguity posture (#1629).** `scripts/github/create-pr.mjs` is the single chokepoint every sanctioned PR creation routes through, and it validated no linkage: nothing stopped opening a second PR against an issue that already had an open linked PR. The wrapper now runs a same-repo linked-PR probe (`detectLinkedIssuePr`) before `gh pr create` for any closing keyword/`--issue` and refuses a duplicate naming the prior PR — `FACADE-LINKED-PR-SINGLE-ARTIFACT`. A deliberate replacement records its intent via `--allow-replacement-pr <prior>` (consumed, never forwarded to `gh`), which must match the detected open linked-PR number; a mismatch is still refused. Issue-less `--lightweight` PRs carry no closing keyword and are exempt. This adds the first network call to an otherwise-offline wrapper; the defined posture when it cannot run (no `--repo`, or the GitHub API unavailable) is FAIL CLOSED on ambiguity rather than silently risking a duplicate. Each refusal names the rule it upholds. Tests in `test/github/create-pr.test.mjs` and `test/github/create-pr-board.test.mjs`; each behavior fails when reverted (refuse-duplicate, allow-match, mismatch-refuse, invalid-override, no-`--repo` fail-closed, API-unavailable fail-closed).

### Fixed
- **Config keys and body validators with no runtime reader now resolve (#1628).** Five dormant config keys / body-validator paths gained a runtime reader. `RETRO-ENFORCEMENT-CONFIG-GATED`: `resolve-dev-loop-startup.mjs` gates the retrospective checkpoint read+inject on `workflow.requireRetrospective`, so a repo that never opts in is no longer over-blocked by a stale/missing/pending checkpoint (the routing passes through unchanged). `SPIKE-RELAXED-GATE-PROFILE`: a spike-mode spin now resolves the relaxed `spike` sub-gate and its acceptance template in the handoff envelope instead of the default local-implementation gate. `ARTIFACT-TRACKER-FIRST-NO-DUP`: `init-phase.mjs` refuses to mint the durable `docs/phases/phase-<n>.md` for an issue-keyed (tracker) worktree while keeping the ephemeral `tmp/` scaffold. `ARTIFACT-LIGHTWEIGHT-BODY-INVARIANTS`: `loadRefinementArtifact` accepts an `expectedIssue` and validates the draft body in expectedIssue mode when provided, so a declared tracker-backed PR that drops its `Closes #N` surfaces `missing_closing_issue_reference` instead of validating clean as an issue-less body. `GRILL-SUBLOOP`: pure predicates `detectGrillMarker` / `detectGrillEmbedHeading` in `issue-refinement-artifact.mjs`, consumed by `edit-issue`/`edit-pr` behind an opt-in `--enforce-grill` flag that refuses a body embedding grill transcript/synthesis/Q&A headings (`GRILL-SUBLOOP-NO-EMBED-SYNTHESIS`).
- **Worktree isolation now enforced end-to-end (#1627).** Three worktree rules were unenforced, and provisioning could create the forbidden state. New pure predicate `isWorktreeCoreIsolated` (`@dev-loops/core/loop/worktree-guard`, beside `isListedWorktree`) asserts a worktree's `node_modules/@dev-loops/core` realpaths into its OWN `packages/core`; it is consumed by the pre-flight gate (`pre-flight-gate.mjs`, refuses with the `core_link_escapes` error) and the `local_implementation` block of `resolve-dev-loop-startup.mjs` (fails closed to `needs_reconcile`). It tolerates consumer repos with no `packages/core` (vacuously satisfied) and worktrees whose core link is absent (nothing escapes). `provision-worktree.mjs` now rejects any entry whose resolved source is or sits under `node_modules` (same `{mode:"reject"}` shape, `reason:"node_modules"`), so the sanctioned provisioning path can no longer mirror the main checkout's installed deps into a worktree. `run-gate-validation.mjs`'s `buildValidationArtifact` stamps a `depState` (`synced`/`stale`/`n-a`) comparing `package-lock.json` against `node_modules/.package-lock.json`, so a gate run on stale deps is recorded, not blessed. Post-merge worktree removal now actually runs: a shared `buildWorktreeCleanupCommand` (`@dev-loops/core/loop/main-checkout-ff`) invokes `scripts/loop/cleanup-worktree.mjs --pr <n>` from the resolved MAIN checkout (the hook's cwd can be inside the worktree being removed), non-fatal (`|| true`, absence-guarded for consumers), wired into both post-merge hooks (`.claude/hooks/post-tool-use-merge.mjs` and `extension/post-merge-update.ts`, which captures the merged PR number from `gh pr merge`). The existing `issue-1087` forbidden symlink was cleaned up. Each behavior has a mutation-anchored test (worktree-guard, pre-flight-gate, resolve-dev-loop-startup, provision-worktree, run-gate-validation, main-checkout-ff, extension-post-merge-update). Non-goals respected: no worktree path-convention change, no CI freshness enforcement.
- **Bash-gate six-rule guards hardened after draft-gate review (#1622).** The `gh api` URL-path matcher now skips quoted value-taking flags whose value spans whitespace (`-H "Accept: application/vnd.github+json"` before the endpoint — the `-H` header flag is now case-folded like the rest), and detects attached-form write methods (`--method=POST`, `-XPOST`) in addition to whitespace-separated ones. `commandContainsCopilotSummonComment` matches only a bare `/copilot`/`/copilot re-review` summon, not prose mentions (`see /copilot for more`). `commandContainsDetachedWaitTool` no longer false-triggers on bare mentions of `nohup`/`disown` (`cat nohup.out`), and its polling-loop detection fires even with a leading compound expression; `commandContainsInlineInterpreter`'s `python3` branch now stops at a script path like the `node` branch (a later `-c` is a script argument). The `COPILOT-FOLLOWUP-WAIT-TOOLS` refusal names the correct `scripts/loop/detect-copilot-loop-state.mjs` path, and the `STOP-HUMAN-MERGE-001` wiring (`resolve-human-merge-only.mjs` output contract) gained a test in `test/loop/resolve-human-merge-only.test.mjs`. A second review-hardening round added: `--repo`/`-R` to the value-taking flag sets (so `gh api --repo mfittko/dev-loops pulls/5/... -X POST` parses the endpoint correctly), bare-relative-endpoint (`gh api issues/5/sub_issues`) and quoted-endpoint matching for the write-path predicates (the relative form gated on the target repo in `decideBashGate`), attached-form inline-interpreter args (`node -e"..."`, `python3 -c"..."`) and `node <<EOF` heredoc detection, a `gh`-standalone-token boundary in the polling-loop probe (no `grep gh-notes` false-deny), fixed a corrupted `+ +` concatenation in the `COPILOT-FOLLOWUP-WAIT-TOOLS` refusal body (now regression-tested against the full message), and added `until`/`seq` polling-loop coverage. Vendored mirrors regenerated (`assets:check` green).
- **Bash-gate six-rule guards hardened again after draft-gate round-2 review (#1622).** `commandContainsInlineInterpreter` now consumes node value-taking flags (`--require`/`-r`, `--import`, `--loader`, `-C`/`--cwd`, …) so `node --require ./setup.js -e "..."` can no longer hide an inline interpreter behind a pre-flag value. `commandContainsCopilotSummonComment` anchors a summon on the body's opening quote, so a trailing/embedded prose mention (`--body "see /copilot"`, `"see /copilot re-review in docs"`) is not misread as a bare summon while body-start `/copilot` (`/copilot re-review now`) still denies. `commandContainsDetachedWaitTool` treats only `while`/`until`/`for` as loop heads (a bare `seq` sequence generator is not a loop), catches `for`-based polls, and requires `loop-state` at a command-head position (no `grep loop-state` false-deny). The `gh api` write-path predicates normalize trailing-slash endpoints (`.../sub_issues/`, `.../requested_reviewers/`, `.../replies/`) and apply the previously-unused `normalizeGhApiEndpoint`; a mid-command boolean `-h` (help) no longer swallows the endpoint; `ghApiSegmentHasWriteMethod` is token-scoped so a method-looking string inside a field value (`-F 'body=--method DELETE'`) is not read as an explicit write method; and `decideBashGate` trims+case-folds `repoSlug` so a divergent slug cannot fail the target-repo scope test OPEN. New e2e hook tests exercise the real deny path for an inline interpreter and a sub_issues write (reverting them from the hook's early-return short-circuit now fails a test). Vendored mirrors regenerated (`assets:check` green).

### Added
- **Bash gate now classifies `gh api` shapes — six guard rules enforceable at one seam (#1622).** The dev-loop Bash gate (`decideBashGate`, PreToolUse hook) previously classified no raw `gh api` shape, so anything expressed as a raw API call was invisible to it. New pure predicate/classifier surface in `packages/core/src/loop/bash-command-classify.mjs` makes all six rules enforceable at that one seam, each with a refusal that names its rule:
  - a `gh api` URL-path matcher (`extractGhApiEndpointSegments`) tolerating env/wrapper/path prefixes and skipping value-taking flags (`-X/-m/-f/-F/-H`, `--method/--field/...`), plus a helper that requires an explicit write method (`POST/PUT/PATCH/DELETE`) so reads never false-deny.
  - `OPS-NO-INLINE-INTERPRETER` — `commandContainsInlineInterpreter` (`node -e/--eval/-p`, `python3 -c`, heredocs fed to node/python), ported from the previously-orphaned classifier, **actor-independent** (the rule bars Coordinator and agent flows).
  - `SUBISSUE-NO-ADHOC-BYPASS` — `commandContainsSubIssueAdHocBypass` (`gh api` writes to `.../issues/<n>/sub_issues[/priority]`), **actor-independent** (no reserved direct path; route through the sanctioned manage-sub-issues wrapper).
  - `COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER` — `commandContainsReplyResolveBypass` (POST `.../pulls/<n>/comments/<m>/replies`) and `commandContainsGraphqlResolveReviewThread` (graphql `resolveReviewThread`), **actor-independent** (route through reply-resolve-review-thread(s) helpers).
  - `COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY` — `commandContainsCopilotRequestBypass` (POST `.../pulls/<n>/requested_reviewers`) and `commandContainsCopilotSummonComment` (bare `/copilot`/`/copilot re-review` in `gh pr comment` bodies), **actor-independent** (route through request-copilot-review).
  - `COPILOT-FOLLOWUP-WAIT-TOOLS` — `commandContainsDetachedWaitTool` (`nohup`/`disown`/`tmux new-session`/`screen -dm`/`while|until|seq` + `sleep` + `gh`), **subagent-only** (rule is classified `agent` — behavioral guidance for the dev-loop driving agent; the main agent retains manual wait tooling).
  - `STOP-HUMAN-MERGE-001` — `gh pr merge` refused when the repo resolves `autonomy.humanMergeOnly`, **actor-independent** (the main agent is the merge actor, so only an actor-independent deny enforces the human-merge invariant). The hook resolves humanMergeOnly via the new `scripts/loop/resolve-human-merge-only.mjs` (self-contained hook cannot import `@dev-loops/core`; fails open to false).

  The hook script (`.claude/hooks/pre-tool-use-bash-gate.mjs`) feeds all new predicates through `decideBashGate` (the vendored `_hook-decisions.mjs`/`_bash-command-classify.mjs` are regenerated; `assets:check` mirror parity holds). Sanctioned wrapper invocations (`node scripts/...`) never match raw-`gh`/interpreter shapes, so existing sanctioned flows are unaffected (no false denies). Tests: `packages/core/test/bash-command-classify.test.mjs` (each predicate's positive/negative/scope cases, incl. write-method and repo-scope semantics) and `packages/core/test/claude-hook-decisions.test.mjs` (each refusal names its rule, per-predicate actor policy, and that reads/wrappers/off-target pass through). Mutation-anchored: reverting a predicate or its deny branch fails its test.
- **Orphan-verifier ratchet for `scripts/` entry points (#1620).** New contract test `test/contracts/orphan-entrypoint-ratchet.test.mjs` asserts that every exported CLI entry-point script under `scripts/**/*.mjs` (self-declared via the canonical `isDirectCliRun` gate) has at least one non-test caller — a non-test `.mjs` import/spawn, a `package.json` script, or a `.github/workflows` step — or is carried on the explicit `ORPHAN_ALLOWLIST` with a one-line disposition (`wire-up` / `delete` / `standalone`). The allowlist is a **non-increasing ratchet**: the detected orphan set must exactly equal the allowlist, so a new unwired CLI entry point fails the build (mutation-proven) and a seeded orphan that later gains a caller must have its entry removed. `standalone` marks intentionally-standalone operator/agent tools so the test does not force false wiring. The issue's named predicate orphans are ratcheted individually via `PREDICATE_ORPHANS` (seeded with `check-retro-tooling.mjs::analyzeTranscript`, whose consumer was deleted by ADR 0024): each must still exist and still have no non-test code importer. Non-goals respected: no current orphan is wired or deleted in this change, and the check does not extend to `packages/core/src/`.
- **Rule enforcement classification + traceability (#1617).** `skills/docs/required-rules.json` entries now carry an `enforcement` classification (`doc` | `runtime` | `agent`, default `runtime`); `agent` rules require a one-line `enforcementNote` so the classification cannot become a quiet escape hatch from the enforcement ratchet. `validate-rule-ownership.mjs` now cross-checks runtime source (`scripts/`, `packages/`, non-test) for enforcement citations: it fails on a phantom citation (a registry-ID-shaped token in source that is neither a real registry ID nor an allowlisted non-rule token), and reports the enforced / unenforced `runtime`-rule count as a visible number. The unenforced count is a **non-increasing ratchet** pinned by a test (currently 157; a new `runtime` rule must ship with a source citation or the test fails). Enforcement credit requires the rule ID in an **enforcement error/refusal string**, not mere presence: code comments and `.json` string values are excluded (a comment or data value is not an enforcement site), and a bare identifier or data/log string an ID appears in does not grant credit — an ID is only credited as enforced when it sits inside a refusal/error-string citation (e.g. a hook refusal, `stderr.write`/`errors.push`/`throw` message) that names the rule it upholds (`isRefusalPathCitation`, with template-literal `${}` nesting handled). The pinned count therefore reflects honest, refusal-path enforcement only. Existing enforcement is credited rather than reported missing: `WORKTREE-DEFAULT-BRANCH-GUARD` is now named in the default-branch-guard hook refusal, and the previously-unregistered jq base guarantee is registered as `BASE-JQ-OUTPUT-GUARANTEE` (defined in `skills/dev-loop/SKILL.md`, cited in `scripts/lib/jq-output.mjs`); `GATE-EXEC-VALIDATION-ARTIFACT` and `GATE-EXEC-ROUND-RETIREMENT` are named in the refusal strings that enforce them (`write-gate-context.mjs` unreadable-validation refusal, `retire-gate-round.mjs` explicit-retirement refusal). The code-comment convention in `skills/docs/structural-quality.md` now distinguishes stable rule IDs (a different kind of thing from ephemeral PR/issue references) and directs enforcement errors to name the rule they uphold. Each behavior has a mutation test in `test/docs/validate-rule-ownership.test.mjs`.
- **Decision-record validator (#1624).** New `scripts/docs/validate-decision-records.mjs`, chained into `test:docs`, enforces `ADR-PATH-NUMBERING` (filename shape `NNNN-<slug>.md` plus unique four-digit prefixes, `0000-template.md` exempt) and, in base-ref mode, `ADR-SUPERSEDE-NOT-REWRITE` (a record whose base Status is Accepted or Superseded must not change any line outside its Status section; a sanctioned Status flip passes). Rule 3 compares against `git merge-base origin/<default> HEAD` and degrades gracefully when the base ref is unavailable (shallow checkout, no origin) — it skips only rule 3 rather than failing every local run. `verify-suite`'s checkout now uses `fetch-depth: 0` so the base-ref comparison has the history it needs in CI, and the `test:docs` leg additionally fetches the base branch (actions/checkout does not fetch `origin/<default>` remote-tracking refs on a `pull_request`, which previously made rule 3 silently degrade instead of enforcing in PR CI). Rule 3 now fails closed on the real fail-open cases it previously swallowed: only base-resolution failure degrades it, while deleting an Accepted/Superseded record (and, via `--no-renames`, renaming one) is refused as an `ADR-SUPERSEDE-NOT-REWRITE` violation. The existing `0047` violation — a "Partially amended by [0048]" line appended to an Accepted record's Status after acceptance, which is neither the sanctioned Superseded-by flip nor a legal `ADR-STATUS-VALUES` value — is reverted to the clean `Accepted` status, consistent with how `0021`'s partial supersession by `0047` is recorded (the older record stays `Accepted`; the newer record documents the partial amendment). Each check has a mutation test in `test/docs/validate-decision-records.test.mjs`; refusals name the rule they uphold.
- **SubagentStop uncommitted-work guard (#1619).** A new `.claude/hooks/subagent-stop-uncommitted-guard.mjs` hook (registered under a `SubagentStop` matcher in both `.claude/hooks/hooks.json` and `.claude/settings.json`) refuses a subagent stop when its worktree under `tmp/worktrees/` has uncommitted changes, naming `LOCAL-COMMIT-BEFORE-EXIT` and the dirty paths. `scripts/loop/cleanup-worktree.mjs` runs `git worktree remove --force` after a merge, so uncommitted changes were destroyed with no warning — the only data-loss gap in the enforcement audit; `LOCAL-COMMIT-BEFORE-EXIT` existed only as prose. The pure decider `decideSubagentStopGuard` (`@dev-loops/core/claude/hook-decisions`, vendored into `.claude/hooks/_hook-decisions.mjs`) reuses `isUnderWorktreePath` (`@dev-loops/core/loop/worktree-guard`); the block surfaces via the SubagentStop contract (exit code 2 + stderr JSON `{"decision":"block","reason":...}`, fed back to the subagent so it commits before exiting). A clean worktree stops normally; a cwd outside `tmp/worktrees/` is unaffected; a non-git cwd (git unavailable) allows the stop. Interactive sessions awaiting commit authorization set `DEVLOOPS_COMMIT_AUTH_PENDING=1` (an opt-in operator/coordination-path signal, like `DEVLOOPS_MAIN_AGENT_READONLY`) and are exempt — they legitimately hold uncommitted work while waiting for the operator, while a non-interactive (dispatched) subagent leaves it unset, so its commit-before-exit obligation stays enforced. Tests in `packages/core/test/claude-hook-decisions.test.mjs` (mutation-anchor: the dirty case fails when the decider is reverted) and `test/contracts/claude-hooks-settings.test.mjs` (registration + e2e hook behavior: dirty blocks, clean allows, outside unaffected, exempt allows) cover the change.
- **`refine verify` no longer returns a vacuous PASS when ownership prose is absent (#1623).** `runRefinementCompletenessChecker` (`scripts/refine/refinement-completeness-checker.mjs`) now refuses a body with no explicit scope-boundary sentence ("This issue owns X. It does NOT own Y (#NNN)."), emitting `missing_scope_boundary` — closing the vacuous-PASS hole at its root: a tree with all four required sections and zero ownership text previously returned PASS, exit 0, because `runScopeBoundaryCrossChecker` derived an empty claim set and could never emit `duplicate_ownership` or `unowned_scope_gap`. The check enforces `EPIC-REFINEMENT-REQUIRED-CONTRACTS` (each issue MUST carry an explicit scope boundary in the format `"This issue owns X. It does NOT own Y (#NNN) or Z (#MMM)."`). Separately, `runProseLinkageDetector` (`scripts/refine/prose-linkage-detector.mjs`) now emits `duplicate_child_checklist` when a parent body duplicates its children's checklists — list items referencing two or more of its own sub-issue numbers, or a checked `- [x] #<child>` item — enforcing `SUBISSUE-LEAN-BODY-NO-DUPLICATE`. Each refusal names the rule it upholds. Tests in `test/loop/refine-verify.test.mjs`; each check has a test that fails when reverted, proven by mutation, and a regression test pins that PASS is no longer reachable with an empty claim set.
- **Gate fan-out dispatch bounds: always grouped (N angles per group) + at most M concurrent reviewers, both configurable (#1601).** The gate fan-out sub-loop now bounds concurrency instead of firing every resolved angle as a concurrent reviewer with no cap — the shape that 429-stormed multi-angle gate rounds (issue #1588 drive: 5–6 reviewers 429'd per round). Two orthogonal, configurable knobs ship on `gates.fanout` (zod schema + `extension-defaults.yaml`, defaults inherited): `maxAnglesPerGroup` (N, default 3, min 1) — `resolveFanoutGroups` (`@dev-loops/core/config`) auto-chunks the leftover ungrouped angles (after configured-groups matching, unchanged) into dispatch units of ≤N instead of singletons; `mode: per-angle` matches N=1 in dispatch unit size ONLY on the ungrouped-leftover path — configured groups are matched first in both modes, and per-angle bypasses the configured-groups table while N=1 honors it, so they diverge when a configured multi-angle group matches. `maxConcurrent` (M, default 4, min 1) — the conductor dispatches at most M dispatch units per wave, reusing the existing wave scheduler `scheduleParallelWaves` via the new `scheduleFanoutWaves` (`@dev-loops/core/loop/gate-fanin`); `write-gate-context.mjs` emits the deterministic wave plan (`artifact.fanout.wavePlan` + groups + both knobs) alongside the per-unit briefings, and the conductor dispatches wave-by-wave. `gate:full` no longer restores per-angle dispatch (ADR 0048 supersedes 0047): it keeps forcing the full angle set upstream (`resolveGateTier` → `gate_full_label`) and dispatches GROUPED. Adaptive 429 backoff: on a 429 the conductor halves the active batch (`backoffMaxConcurrent`), recomputes the wave plan, and retries before escalating to foreground one-at-a-time fallback; the backoff is recorded in the round's provenance. Both bounds count dispatch units (groups), not angles — a group of N angles is one concurrent unit — so `countFreshDispatchUnits` derives the `requireFanoutProvenance` `distinctReviewers` floor from fresh dispatch units automatically (no provenance-mechanism change). Decision record: `docs/decisions/0048-gate-full-dispatches-grouped-two-knob-dispatch-bounds.md`; `skills/docs/gate-review-sub-loop-contract.md` Phase 2 updated to the two-knob dispatch model and the wave-plan conductor contract.

- **Async-dispatch-no-block codified for the dev-loop dispatch pattern (#1586).** The main-agent contract (`skills/docs/main-agent-contract.md`, Pi section) and the dev-loop skill (`skills/dev-loop/SKILL.md`, Main agent dispatch section) now carry an explicit "Async dispatch posture (Pi)" clause: when the main agent dispatches the `dev-loop` async subagent in an interactive session, it MUST return control to the user after dispatch and MUST NOT call `subagent_wait` to block on completion; Pi wakes the session on completion or needs-attention. The only exception is run-to-completion (the user explicitly asked for results reported back before continuing, or a skill must finish in one turn). This closes the gap where "drive end-to-end, report results" read as run-to-completion and the agent blocked the interactive session for 30+ minutes per dev-loop drive (PRs #1580, #1582, #1577, #1584). The Pi platform default already said return control; this is a dev-loops contract codification so the dev-loop dispatch pattern stops triggering the run-to-completion instinct — no platform or `@dev-loops/core` code change.
- **Post-merge main-checkout fast-forward (#1596).** After each successful merge, the Pi post-merge hook (`extension/post-merge-update.ts`) and the Claude post-merge hook (`.claude/hooks/post-tool-use-merge.mjs`, no longer a no-op) best-effort fast-forward the main checkout's local `main` to `origin/main` via `git -C <main-checkout> fetch origin main && git -C <main-checkout> merge --ff-only origin/main`. The dev-loop merges remotely (`gh pr merge` → origin/main) but never fast-forwarded the main checkout, so read-only gate scripts (`probe-ci-status.mjs`, `detect-copilot-loop-state.mjs`, …) ran stale code — re-introducing the CI-wait stall every PR (#1531's fix was invisible until the main checkout caught up). The shared command shape lives in `buildMainCheckoutFastForwardCommand` (`@dev-loops/core/loop/main-checkout-ff`, dependency-free so it vendors into the hook bundle). The Claude hook resolves the main checkout via `parseMainWorktreePath` (`@dev-loops/core/loop/worktree-guard`); both are vendored as `.claude/hooks/_worktree-guard.mjs` and `.claude/hooks/_main-checkout-ff.mjs`. Best-effort and non-blocking: `--ff-only` refuses a diverged main without rewriting history — a diverged checkout warns and continues, never blocking the merge. The command is guarded by an on-`main` check (`[ "$(... rev-parse --abbrev-ref HEAD)" = main ]`), so a non-main checkout warns and continues instead of fast-forwarding the wrong branch. The Pi `pi update` step is unchanged.

- **Pi agent tool-name mapping (#1583).** The Pi extension now remaps the harness-neutral `tools:` frontmatter to Pi builtins at session-start sync time, mirroring the existing Claude `TOOL_NAME_MAP` (`packages/core/src/claude/asset-generation.mjs`). `syncPackagedAgents()` (`extension/sync-packaged-agents.ts`) rewrites `~/.agents/*.agent.md` via the new `TOOL_NAME_MAP_PI` (`read→read`, `search→bash`, `execute→bash`, `bash→bash`, `edit→edit`, `write→write`, `agent→subagent`, `subagent→subagent`, `todo→drop`, `review_loop→review_loop`) so Pi no longer rejects `search`/`execute`/`agent`/`todo` as unavailable child tools — fixing the process-level `failed` status on every Pi-dispatched `dev-loop` subagent run. The canonical `agents/*.agent.md` source stays harness-neutral and unchanged; the Claude path (`.claude/agents/*.md` tree, `assets:check` doc-guard) is untouched. `todo` has no Pi builtin — the dev-loop acceptance checklist falls back to prose/bash under Pi (documented in `agents/dev-loop.agent.md`). A contract test asserts every rendered `~/.agents/*.agent.md` lists only valid Pi builtin tool names.

- `dev-loops queue sync-status` gains `--logical-column <name>` (`next_up`, `in_progress`, `ready_for_review`, `done`), which resolves the target Status column through `.devloops` `queue.statusColumns` so a renamed column still converges, and `--pr <number>` as the move target when no `--item` is supplied. `--logical-column` and `--to-column` are mutually exclusive; exactly one is required.
- `ensure-worktree` now best-effort installs `pre-commit`/`pre-merge-commit`/`pre-push` hooks into the primary checkout's shared common git directory, refusing a commit, merge, or push (including via an explicit refspec) that would land on a guarded branch — the repo's own default (always re-derived from `origin`, independent of any given invocation's `--base`), and, when it differs, an explicit `--base`. Override for a sanctioned release or reconcile with `DEVLOOPS_ALLOW_MAIN=1`. The install is fail-soft and has documented no-op paths (an existing `core.hooksPath`, a foreign pre-existing hook, or an unresolvable default) — see [Default-branch guard](skills/docs/worktree-guidance.md#default-branch-guard).

### Changed

- **One door of N: guards moved to where every caller routes (#1625).** Four rules were unenforced because a guard lived at one entry point but not its siblings. (1) `write-gate-context.mjs` (`GATE-EXEC-VALIDATION-ARTIFACT`, pointer half) now derives `buildValidationResultsPath({repo, pr, gate, headSha, tmpRoot})` when `--validation-results` is omitted and uses it if the artifact exists — the export existed precisely so producer and consumer agree, and the consumer just never called it; omitting the flag with no derived artifact present stays byte-identical to before. (2) `queue move` into the pickup column now runs the same refinement gate `queue add` applies (`QUEUE-ENQUEUE-REFINEMENT-GATE`): `move-queue-item.mjs` (`@dev-loops/core/projects/move-queue-item`, main) refuses an un-refined issue with `MISSING_REFINEMENT_ARTIFACT` (exit 4) between the already-at-target no-op and the `UPDATE_ITEM_FIELD` mutation. It lives in core (not the script wrapper) so `reconcile-queue.mjs` routes through it — but reconcile is unaffected because it only derives Done/In Progress and never the pickup column; the column is only derived when `cwd`/config is supplied (the interactive `queue move` path), so headless no-cwd callers behave unchanged. (3) `create-issue.mjs` now idempotently and fail-open adds a newly created issue to the board in Backlog (`QUEUE-BOARD-LINKED`), generalizing `enqueueIssuelessLightweightPr` (`create-pr.mjs`) into `enqueueBoardItem({repo, itemNumber, column})` (an ADD, not a status transition — transitions stay orchestrator-owned per `sanctioned-commands.mjs`); the legacy `enqueueIssuelessLightweightPr` is a back-compat alias keeping its historical return contract. `commands/loop-enqueue.command.md` step 3.2 is repointed through the wrapper instead of raw `gh issue create`. (4) `ensure-queue-board.mjs` defaults `linkRepo` to the given `--repo` (`QUEUE-BOARD-SYNC-CONTINUOUS`). Each behavior has a test that fails when reverted, proven by mutation.

- **Warnings replaced with refusals for four degraded MUSTs (#1626).** Four MUSTs were enforced as a JSON `warning` field, which is invisible the moment a caller narrows the result with `--jq` (which the repo's own token-discipline contract mandates) — equivalent to unenforced. Each is now a refusal or an explicitly-decided advisory: (1) `retire-gate-round.mjs` now REFUSES when the canonical per-angle findings directory (`tmp/gate-reviews/<slug>/pr-<N>/<gate>-<headSha>/`, the path `write-gate-context.mjs` / `consolidate-fanin.mjs` use) exists and `--findings-dir` does not name it — its artifacts would stay LIVE and pass the head-stamp guard into the next round's fan-in. New `--repo`/`--pr` args name the canonical path; `--no-findings-artifacts` is the explicit opt-out. The old `warning` field is removed. (2) `create-pr.mjs` now REFUSES a missing or mismatched closing reference (`Closes #N`/`Fixes #N`) when `--issue <n>` declares the tracker link — fatal before `gh` is invoked; the advisory `warnMissingClosingKeyword` is removed. Without `--issue` the closing keyword is not enforced (issue-less `--lightweight` PRs intentionally carry none). (3) `resolve-dev-loop-startup.mjs` now FAILS CLOSED when linked-PR detection fails instead of fabricating `resolved_no_open_pr` (a transient `gh` failure would route an issue that HAS an open linked PR to `issue_intake`, which the router cannot catch). (4) `write-gate-context.mjs`'s `rebuildWarning` is DECIDED advisory-by-design and documented in the contract: the rebuild is the sanctioned first step of `GATE-EXEC-ROUND-RETIREMENT` (rebuild context → retire round → re-fan), the warning names the explicit recovery command, and the MUST (round retirement before re-fan) is enforced separately by `verify-fresh-review-context.mjs` failing closed on a sentinel whose recorded prefix hash no longer matches. Tests that pinned the lenient behavior are updated (not deleted) and each refusal has a test that fails when reverted.

- **Gate finding severity vocabulary aligned to Copilot (high/medium/low) + question/nit non-defect categories (#1592).** The gate finding severity field is renamed: `must-fix`→`high`, `worth-fixing-now`→`medium`, `nice-to-have`/`defer`→`low`. Two new non-defect categories join the vocabulary: `question` (answered, never deferred — the fixer replies with an answer, promoting the finding to a defect severity when the answer reveals one or escalating to the author when unanswerable; an unanswered question blocks gate-close exactly like an open defect, and never consumes a fix-round window) and `nit` (cosmetic; deferred immediately at round 1, with no fixer cycle at all). `normalizeSeverity`/`VALID_SEVERITIES`/`SEVERITY_ORDER`/`LEGACY_SEVERITY_ALIASES` (`@dev-loops/core/loop/gate-fanin`) own the canonical vocabulary and the read-time normalization of every pre-rename spelling — a marker, ledger entry, or config value carrying an old spelling parses and behaves identically to its canonical replacement. `isDeferredAtRound` (`scripts/github/_gate-finding-surface.mjs`) is extended for the two non-defect categories: `question` never defers (mirrors `high`'s never-defer posture, but for a non-blocking category); `nit` always defers immediately (mirrors `low`). `isDeferredAtRound` also now fails CLOSED (never deferred) on an unrecognized severity — a behavior change from the prior always-defer fallback — so a malformed/forged marker surfaces as a dangling gate-authored thread that blocks gate-close instead of being silently auto-resolved. The disposition ledger's `VALID_DISPOSITIONS` (`scripts/github/write-gate-findings-log.mjs`) widens from four to five values, adding `needs-answer`: a LOCATABLE `question` finding (one with a resolvable review thread) defaults to `needs-answer` instead of `deferred` — it is answered through its own thread, never silently deferred — while a non-locatable `question` still defaults to `deferred` (no thread to answer it through); the ledger CLI's disposition validation error message lists all five values. `gates.<gate>.blockCleanOnFindingSeverities` defaults to `["high"]` (was `["must-fix"]`); the per-gate fix-window config key is renamed `worthFixingNowFixWindow`→`mediumFixWindow` (built-in constant `WORTH_FIXING_NOW_FIX_WINDOW`→`MEDIUM_FIX_WINDOW`), with the old key still accepted as a deprecated alias (`mediumFixWindow` wins when both are set). `schemas/dev-loop-config.schema.json` regenerated. `skills/docs/gate-review-sub-loop-contract.md` (`GATE-EXEC-THREAD-DISPOSITION`, `GATE-EXEC-BLOCKING-ONLY-FIX`, `GATE-EXEC-DEFERRAL-RECORD`) and `skills/copilot-pr-followup/SKILL.md` updated to the new vocabulary and the question/nit dispositions.

- **Fixer-triage for all gate findings + require resolution of every review comment before gate close (#1585).** A clean gate verdict no longer satisfies the gate on its own: `draftGateSatisfied` / `ready-for-review` (`ready-for-review.mjs`) / `pre-pr-ready-gate.mjs` now assert 0 unresolved **gate-authored** review threads (must-fix, worth-fixing-now, AND nice-to-have) before the PR can leave draft — fixing the #1584 regression where a draft_gate posted a clean verdict with 2 `nice-to-have` inline threads, marked the PR ready, and left the threads dangling (the pre-merge evidence check only caught it at the merge boundary, stalling the `gate-evidence` commit status). `fetchDraftGateEvidence` (`scripts/github/_gate-finding-surface.mjs`) now fetches the authenticated login + review threads and exposes `unresolvedGateThreadCount` (fail-closed `-1` when unreadable); `countUnresolvedGateAuthoredThreads` / `fetchUnresolvedGateThreadCount` / `countUnresolvedGateAuthoredThreadsFromRawNodes` are the shared counter predicates (author-identity for the gate-close decision, marker-only fail-closed proxy for `detect-checkpoint-evidence.mjs`, which reuses its existing thread payload with no extra gh round-trip). The fixer now triages EVERY gate-authored finding (not just blocking severities): nice-to-haves are fix-if-cheap-in-the-same-commit, else defer — defer permitted from round 1 on (no forced fix window; the WFN window (#1581) is unaffected). The disposition pass (`close-gate-findings.mjs`) is reordered to run AFTER the Phase 5 (Retry) fixer triage as the closing sweep — it stamps `disposition=deferred` for threads the fixer chose to defer and reports `unresolvedGateThreadCount` after the defer pass — rather than silently auto-deferring nice-to-haves in the post-verdict, pre-fix slot before the fixer ever sees them. `skills/docs/gate-review-sub-loop-contract.md` updated: `GATE-EXEC-THREAD-DISPOSITION` (nice-to-have disposition is fixer-triage, not silent auto-defer; gate-close requires 0 unresolved gate-authored threads) and `GATE-EXEC-BLOCKING-ONLY-FIX` (drop "a nice-to-have finding is never fixed inside the gate"; the fixer may fix-if-cheap).

- **Per-gate worth-fixing-now fix window (#1581).** The worth-fixing-now (WFN) fix window is now a per-gate configurable value with default `3`, set via `gates.<gate>.worthFixingNowFixWindow` (zod schema + `extension-defaults.yaml`). `isDeferredAtRound` (`scripts/github/_gate-finding-surface.mjs`) and the disposition pass (`close-gate-findings.mjs`) read the resolved per-gate value instead of the hardcoded `WORTH_FIXING_NOW_FIX_WINDOW` constant; the constant is retained as the built-in fallback for the unconfigured case. Must-fix-if-present per-gate continuation is the documented default: an open `must-fix` finding forces another fix round for that gate and an unfixable must-fix escalates to the operator via the existing gate round cap — it never defers. The round budget is counted independently per gate (draft_gate rounds do not deplete pre_approval_gate's window). `skills/docs/gate-review-sub-loop-contract.md` updated to reflect the configurable window and the must-fix-if-present default.
- **Grouped dynamic angles are now the default (#1579).** `gates.<gate>.dynamic.subtractive` (diff-driven angle pruning) now defaults to `true`, so a fresh install narrows the gate's angle pool to the angles the diff-classifier recommends instead of running every configured angle as its own reviewer every gate. `mandatory: true` angles remain a hard always-run floor (exempt from pruning); `fallbackToAll` fires when classification is ambiguous, degrading gracefully to the full static pool. Grouped fan-out dispatch (`gates.fanout.mode: grouped`) was already the shipped default. To restore the previous full static fan-out (one reviewer per configured angle), set `gates.<gate>.dynamic.subtractive: false` (restores the full angle pool) and apply the `gate:full` label or `gates.fanout.mode: per-angle` (restores one-reviewer-per-angle dispatch); the `gate:full` label alone only forces per-angle dispatch of the still-pruned pool, not the full pool.

### Fixed

- **Review agent tool declaration made harness-agnostic so Pi does not mark review fan-out steps `failed` (#1659).** The `review` agent declared `search`/`execute` (harness-neutral tool names that Pi does not expose as builtins) in its `tools:` frontmatter. Pi strict-rejects unavailable declared tools, marking the review step `failed` — which aborts `runs.all` and the GATE-EXEC-PRIME primer-then-parallel pattern (the primer IS a review agent). Fix: drop `search`/`execute` from `agents/review.agent.md` tools frontmatter (now `read, bash, edit, write`). The review agent uses `bash` (rg/grep) for search on both harnesses; code-execution verification is delegated to CI. Other role agents (fixer, developer, docs, quality, refiner) keep `search`/`execute` per #1086 (Claude maps `search`→Grep+Glob). Cross-harness regression coverage: the `#1604` test is updated to exempt `review`, and a new `#1659` test asserts the review source is Pi-safe (no search/execute, all declared tools are Pi builtins).

- **`requireRetrospective` was a one-time gate: a past `complete` checkpoint satisfied it forever (#1613).** `.pi/dev-loop-retrospective-checkpoint.json`'s `state: "required"` — the only durable value that makes the startup resolver fail closed — was never written automatically; the sole in-tree writer was the manual `checkpoint-contract.mjs` CLI, so the first retrospective ever recorded left `state: "complete"` on disk permanently and every later qualifying completion went unchecked. Two earlier approaches each failed on their own terms: a write-time "arming" step relied on a seam the documented flow never reaches; a read-time GitHub query proxying "latest qualifying completion" as "the most recently merged PR assigned to Copilot" matched zero real PRs in this repo's own merge history, so it never fired either. The mechanism is a purely local git ancestry check instead: has anything merged to the base branch since the checkpoint's recorded discharge point? `resolveHasNewerMergeSinceCheckpoint` (`scripts/loop/resolve-dev-loop-startup.mjs`) runs a best-effort `git fetch origin <baseBranch>` then `git log <mergeCommit>..origin/<baseBranch>`, with no GitHub call and no Copilot-assignee proxy at all; an unresolvable recorded `mergeCommit` (unfetched, shallow clone, garbage value) fails closed exactly like a confirmed newer merge, since an unverifiable discharge claim must not be trusted. The pure `resolveCheckpointStateFromArtifact` (`packages/core/src/loop/retrospective-checkpoint.mjs`) takes this as a boolean (`hasNewerMergeSinceCheckpoint`) and maps a `complete` or `skipped` checkpoint to `missing` when set — `skipped` is cycle-scoped exactly like `complete`. A present-but-malformed checkpoint (not a JSON object — including the JSON literal `null`, previously indistinguishable from a genuinely absent file — or an unrecognized `state`) also fails closed to `missing`; a genuinely absent file still resolves to `none` (unchanged — the file is gitignored and per-working-copy, so failing closed on absence would block every fresh clone). The ancestry check is gated on `workflow.requireRetrospective`, so a repo that never opts in performs no extra git call. Separately, the checkpoint file's read path (the resolver) and write path (`checkpoint-contract.mjs`) previously resolved from different roots — cwd-relative on both sides — so the main checkout and a worktree of the same repo could disagree about the checkpoint state, and a worktree's write was destroyed the moment that worktree was removed; both paths now resolve through `resolveCheckpointRepoRoot` (`git worktree list`'s first line, reusing `parseMainWorktreePath`) to the one main-checkout file regardless of which worktree the command runs from. `checkpoint-contract.mjs` now requires a cycle identity (`--repo`/`--pr`/`--merge-commit`) for `complete`/`skipped` (previously optional, which could write a record that fails closed forever with no way to clear it by re-running); validates `--merge-commit` as a full 40-character commit oid and `--repo` as `owner/name` shape; and rejects whitespace-only `--notes`/`--reason` the same way the identity flags already do. `.pi/extensions/dev-loop-behavioral-review.ts`'s best-effort `required`-marker write is resolved through a vendored copy of the same repo-root logic. `skills/docs/retrospective-checkpoint-contract.md`, `skills/dev-loop/SKILL.md`, and `skills/copilot-pr-followup/SKILL.md` are updated to the ancestry-based contract.

- **`verify-briefing-prefixes.mjs` had zero callers — the `GATE-EXEC-BRIEFING-PREFIX` rule's own cited proof was never invoked at fan-in (#1618).** The fan-in consolidator (`scripts/loop/consolidate-fanin.mjs`, `consolidateGateFanin()`) now runs the verifier mechanically before consolidation (inside the existing `--head-sha` artifact-stamp branch), so a round whose reviewers were seeded with divergent briefings — the mid-flight-rebuild case the rule exists for — can no longer consolidate into a clean verdict with no consumer noticing. It fails closed (exit 1, naming `GATE-EXEC-BRIEFING-PREFIX`) when: two or more sentinels for the head record DISTINCT prefix hashes (AC1); any sentinel records NO prefix hash (AC2, never grandfathered); or, when the conductor declares `--expected-dispatch-units <n>`, the reviewer sentinel count is SHORT of the fresh dispatch units spawned (AC3 — a dispatched reviewer never ran the fresh-context guard). A head with NO sentinels at all still consolidates (AC4: offline/inline/test paths where the guard was never invoked stay byte-identical — `reviewerCount === 0` skips the check). `--expected-dispatch-units` is the dispatch-UNIT count sourced from `write-gate-context.mjs`'s `fanout.pendingGroups.length` (the dispatched dispatch units — groups for grouped dispatch; angle count for per-angle dispatch, where `resolveFanoutGroups` emits one singleton per angle; when Phase 1.2 carry-forward carried angles, the conductor passes the dispatch-unit count over the plan's FRESH angles, since `pendingGroups` includes carried angles and would overcount) — NOT `fanout.wavePlan.length` (the WAVE count, typically 1) and NOT the per-angle artifact count, which would false-fail every grouped round (#1579/#1601 shipped default); it is OPTIONAL and when omitted the count check is skipped (the hash checks AC1/AC2 still run), preserving backward compatibility. `verify-briefing-prefixes.mjs` exports a new programmatic `verifyBriefingPrefixesForHead(tmpRoot, headSha)` (the CLI `main` is refactored to call it, no behavior change). Complementarily, `verify-fresh-review-context.mjs` now requires `--prefix-hash`/`--prefix-file` on EVERY invocation, not only `--same-head-retry` (AC5) — a first-run sentinel can no longer be created without a recorded invariant-briefing prefix hash, so the fan-in's count/hash checks have a well-formed population to verify; tests that pinned the lenient first-run-no-hash behavior are updated (not deleted). `skills/copilot-pr-followup/SKILL.md` Phase 3 and `skills/docs/gate-review-sub-loop-contract.md` Phase 3 updated: the conductor passes `--expected-dispatch-units` from the Phase 1 context artifact's `fanout.pendingGroups.length` (the verifier no longer needs a separate pre-consolidation step — `consolidate-fanin` runs it). Each new fail-closed behavior has a test that fails when reverted, proven by mutation (AC1/AC2/AC3 for both per-angle and grouped dispatch framing).

- **`upsert-checkpoint-verdict.mjs` accepted a `--verdict` that contradicted the consolidator's computed `overallVerdict` (#1616).** The fan-in consolidator (`consolidate-fanin.mjs`) already computed `overallVerdict` (derived from the round's findings and the gate's `blockCleanOnFindingSeverities`, exactly `GATE-COMMENT-VERDICT-VALUES`'s definition), but nothing downstream read it: an operator could post `findings_present` for a round the consolidator computed as `clean` (or vice versa), and the tooling recorded it as the visible gate surface — the silent contradiction only surfaced rounds later as a stuck gate (PR #1612: a `findings_present` verdict on a clean round opened review threads that tripped `round_cap_reached` and deadlocked the gate for ~6 extra rounds). The consolidator now embeds `overallVerdict` in its `--ledger-out` wrapper (`{ overallVerdict, findings }`); `write-gate-findings-log.mjs` threads it into the durable ledger; and `upsert-checkpoint-verdict.mjs` reads the ledger's `overallVerdict` and refuses a contradicting `--verdict` (naming both values and the head, citing `GATE-COMMENT-VERDICT-VALUES`), derives the verdict from it by default (passing no `--verdict` is valid when the ledger carries `overallVerdict`), accepts a matching explicit value, and fails closed on a present-but-malformed `overallVerdict`. No override flag — a round whose verdict genuinely differs from the computed one is a consolidator bug to fix, not an operator decision. An absent `overallVerdict` (legacy ledger, inline/fallback paths) preserves today's behavior. `_findings-input.mjs` (shared `--findings`/`--findings-file` reader) unwraps the new object shape for both `write-gate-findings-log.mjs` (threads `overallVerdict`) and `post-gate-findings.mjs` (unwraps and ignores it); a bare-array input remains valid.

- **`upsert-checkpoint-verdict.mjs` left three verdict-write preconditions unenforced (#1621).** Builds on #1616's verdict-consistency enforcement. Four rules were unenforced at the verdict-write seam: (1) `GATE-COMMENT-DRAFT-REQUIREMENTS` / `GATE-COMMENT-PREAPPROVAL-REQUIREMENTS` — a non-clean verdict (`findings_present`/`blocked`) accepted any caller-supplied `--next-action`, so an advancing action like `merge` could be spliced into a round that found blocking findings; the mandated next action for a non-clean verdict is a closed set (`stay draft and fix` for `draft_gate`, `rerun gate` for `pre_approval_gate`), so the tool now DERIVES `effectiveNextAction` at the option seam (mutating `options.nextAction`) rather than accepting prose into a machine-read evidence surface — the derivation sits at the option seam, not the render site, so the same-head idempotency compare sees the derived value too (a render-only fix would break idempotency); (2) `GATE-EXEC-LIGHT-ESCALATION` — an inline round (`inline_single_agent`) that surfaced a blocking finding did not escalate to full fan-out; the `gate:full` PR label is now applied (via `gh label create` + `gh pr edit --add-label`, idempotent) when fan-out evidence is required and the round carries a blocking severity, applied rather than refusing the post so it never collides with `GATE-EXEC-POST-BEFORE-FIX` (both existing consumers, `detect-checkpoint-evidence.mjs` and `write-gate-context.mjs`, already honor the label); (3) `ACCEPT-CRITERIA-VERIFY-AND-REFLECT` — a `clean` `pre_approval_gate` verdict was accepted while the spec-of-record (linked tracker issue) still had unticked Acceptance criteria; `detectIssueRefinementArtifact` (`@dev-loops/core/loop/issue-refinement-artifact`) now surfaces `uncheckedAcItems` (only actual `- [ ]` checkboxes; a ticked box and a plain bullet are excluded) via a shared `parseChecklistItems` (so `extractChecklistItems` and the unticked read never drift on what counts as a checklist item), `loadRefinementArtifact` (`scripts/loop/detect-pr-gate-coordination-state.mjs`) threads it through and now fetches the linked issue body for a ready PR too (status stays `unknown`, finding null — the refinement ENFORCEMENT stays a draft-gate boundary) so the pre_approval_gate has the spec-of-record AC data to read, and `upsert-checkpoint-verdict.mjs` refuses a `clean` `pre_approval_gate` while `uncheckedAcItems` is non-empty (naming the rule, the linked issue, and the unticked items). Each refusal names the rule it upholds. An artifact that did not resolve AC data (no linked issue, or the fetch failed) carries no `uncheckedAcItems` and does not block. The enforcement complements the existing `GATE-EXEC-LIGHT-ESCALATION` (`skills/docs/gate-review-sub-loop-contract.md`) and `ACCEPT-CRITERIA-VERIFY-AND-REFLECT` (`skills/docs/acceptance-criteria-verification.md`) rules.

- **`gates.requireFanoutEvidence` was enforced only reactively at merge time, not preventively at post time (#1599).** `upsert-checkpoint-verdict.mjs` accepted an `inline_single_agent` verdict — of any value, `clean`/`findings_present`/`blocked` — for a PR that did not qualify for the light-mode carve-out, posting it as visible "clean gate" PR evidence that the pre-merge evidence check would only reject later, wasting a round and momentarily misrepresenting gate satisfaction (observed live on PR #1598: a 10-file/528-line PR posted an inline `draft_gate` verdict despite `requireFanoutEvidence: true`). The produce step now refuses to record an under-qualified inline verdict BEFORE it is ever posted, by reusing the exact merge-time acceptance predicate rather than mirroring it: the mode-qualification block inside `buildPreMergeGateCheck` is extracted into the newly exported `evaluateInlineFanoutMode` (`detect-checkpoint-evidence.mjs`), and `upsert-checkpoint-verdict.mjs` calls it against a candidate marker built from the to-be-posted verdict (via the also-exported `buildFanoutEnforcement`, fed the same fail-closed light-mode facts — `gate:full` label + merge-base scope re-derivation via `detectMergeBaseScope` — merge time already resolves) before the post proceeds. Refusal applies to every verdict value, since mode qualification never depends on the conclusion; the light-mode under-threshold carve-out and `fanout_fanin` posts are unaffected, including the #891 draft-transition re-post path; there is no override flag — `gates.requireFanoutEvidence: false` remains the only opt-out. `skills/docs/gate-review-sub-loop-contract.md` "Execution mode and fan-out evidence enforcement" updated: enforcement now runs at both the produce step and the pre-merge check, sharing one predicate so the two boundaries cannot drift.

- **Gate surface sweep: PR title marker false-positive on a component name, unbounded findings comment, and unsanitized findings-comment rendering (#1529, #1532, #1534).** Three fixes to the gate's PR-facing surfaces:
  - `findBlockingTitleMarkers` (`packages/core/src/loop/pr-title-markers.mjs`) no longer flags `WIP`/`DRAFT` when the word names a component (`draft-gate`, `draft gate`, `wip-branch`) rather than asserting a status — a hyphen or a space is a word boundary just like the bracket/paren/colon punctuation a genuine status marker uses (`[WIP]`, `DRAFT:`, `(draft)`), so `\bWORD\b` alone could not tell them apart, blocking a PR whose own title described the draft gate from ever being marked ready. A status marker now requires one of four explicit constructions (bracketed, parenthesized, colon-suffixed, or the bare word standing alone as the whole title); a dash-set-off trailing tag (`Fix login flow — WIP`) was tried and dropped again, because no dash-based construction closes the tag without also reopening the same component-name false positive for a different dash character. Both call sites (mark-ready, final-approval) already route through this one shared function.
  - `post-gate-findings.mjs` now bounds its rendered comment to GitHub's 65536-character limit: a round large enough to exceed it degrades by dropping individual findings one at a time (binary-searching a drop count close to the minimal one — never over the limit, though the search can settle a few findings past the true minimum — instead of a whole severity group at once), least-urgent first across every less-urgent severity group before touching a more-urgent one, naming what was omitted in the comment and pointing at the disposition ledger (always written in full) as the complete record; a round that still cannot fit even with every finding dropped, nor with only its single most-urgent finding kept, fails the post closed instead of reporting success. Every caller-supplied value the comment renders (severity, angle, summary, files, disposition, gate, headSha) now routes through one validate-and-sanitize seam, so a newly rendered field can't bypass it by omission; `gate` and `headSha` form the comment's own identity marker, so both are constrained rather than merely checked for non-emptiness: `gate` is normalized (trim + lowercase) and must be one of the two known gate names, and `headSha` is sanitized. `skills/docs/gate-review-sub-loop-contract.md` no longer asserts this comment unconditionally carries the full round.
  - `post-gate-findings.mjs`'s renderer and `upsert-checkpoint-verdict.mjs`'s renderer already shared one `sanitizeInline`/`sanitizeCodeSpan` implementation (defined once, imported by both); this sweep adds a regression test pinning that parity — exercising both renderers against the same crafted payload so a reviewer-supplied `summary`/`disposition`/`angle`/file value can neither complete a markdown link injection nor use a stray backtick to unbalance a later field's own code span — instead of changing the sharing itself.

- **`ensure-worktree.mjs --branch <name>` forked a new branch off base instead of reusing an existing `origin/<name>` (#1539).** When no LOCAL branch of the requested name existed but the branch was already published on `origin`, `ensure-worktree.mjs` created a fresh branch off the resolved base — the worktree then sat at base with none of the branch's commits, upstream set to the base branch, one `git push` away from replacing a PR's commits with a copy of base. It now checks for `<remote>/<branch>` before falling back to base: an existing local branch is still re-attached unchanged; an existing remote-only branch is checked out tracking the remote tip (`git worktree add -b <branch> --track <path> <remote>/<branch>`), never forked off base; neither existing still creates off base, unchanged. The result JSON gains `branchOrigin` (`created-from-base` | `tracked-remote` | `reused-local`, also reported on the already-existing-worktree reuse path) so callers can tell which happened, and a `diverged` field — on both the create and reuse paths — when the local branch has genuinely forked from `origin/<branch>` (neither is an ancestor of the other; a plain ahead/behind difference, the ordinary state of an in-progress branch, is never reported as diverged) — surfaced for the caller to resolve rather than silently picked one way. The remote a `--base` names is validated against `git remote` (a bare slashed `--base` like `release/1.0`, the shape `workflow.baseBranch` documents, falls back to `origin` instead of mis-resolving to a nonexistent `release` remote), and an explicit `--branch` is trimmed and normalized (any configured-remote prefix, not just `origin/`, and a `refs/heads/`-shaped value) so a remote-ref-shaped value (`origin/feature-x`) resolves to the bare name instead of building a nested local branch off base; a value that collapses to empty after normalizing (`origin/`) falls back to the default name instead of reaching git as an empty branch name. The branch lookup probes candidate remotes in priority order — the one `--base` names, then `origin` when it differs — so a fork workflow's `--base upstream/main` still finds an existing `origin/<branch>` instead of missing it and forking off base. A worktree already checked out DETACHED at the canonical path (e.g. `ui-review`'s pinned-PR-head worktrees) is now reused as `branchOrigin: "reused-detached"` rather than mislabeled `reused-local`. `fetchDegraded: true` is reported when a candidate remote's best-effort fetch fails. A prefix-only `--base` (`origin/`, or a non-`origin` configured-remote prefix like `upstream/`) is now treated as unset (falls back to the auto-detected default) rather than reaching git as an invalid ref, matching `resolveBaseBranch`'s own prefix-only-is-unset contract. `--pr <n>` resolving a branch goes through the same logic as `--branch`.

- **Reviewer fan-out reads stale INSTALLED skill/doc copies instead of the worktree source under review (#1603).** Gate fan-out reviewers resolved skill/doc path references to INSTALLED skill layouts (`.pi/skills/<name>/SKILL.md`, `~/.pi/agent/`) instead of the WORKTREE SOURCE files under review, so a PR that modifies skill/doc source produced false must-fix findings against already-fixed-in-PR text — the installed copy lagged the worktree source. On PR #1602 the draft_gate re-fan reported a false must-fix contract-surface finding quoting pre-PR text the PR had already rewritten. Three defenses ship: (1) `copilot-pr-followup` SKILL.md "Skill asset path resolution" section now distinguishes HELPER SCRIPT paths (invoked as tooling — still resolve from the installed layout per `ASSET-PATH-SOURCE-NO-REPO-LOCAL`) from SKILL/DOC SOURCE FILES reviewed as content — which MUST be read as relative paths from the worktree cwd and verified against `git show HEAD:<path>` before reporting; (2) `write-gate-context.mjs` stamps a new fixed `## Reviewer source-read invariant` section into the byte-identical briefing prefix (`renderBriefingPrefix` and `renderScopedBriefingVariant`) naming the worktree source as authoritative, the installed layouts to avoid (`.pi/skills/`, `~/.pi/agent/`), and the `git show HEAD:<path>` verification step — so every reviewer of a round is seeded with it; (3) the canonical `GATE-EXEC-SOURCE-READ-WORKTREE` rule lives in `skills/docs/gate-review-sub-loop-contract.md` alongside `GATE-EXEC-NO-CWD-DEPENDENCE` (same wrong-tree class as #1505, specifically skill-asset path resolution). Tests: pinned-prefix snapshot updated, a #1603 regression test pins that a briefing prefix for a PR rewriting a SKILL.md phrase carries the worktree-source invariant + the git-show verification that prevents a stale-installed-copy false finding, and a scoped-variant test pins the invariant is threaded when `worktreeRoot` is supplied.

- **Project-local `.pi/agents/` sync with symlink-safety (#1606, #1604 follow-up).** `syncPackagedAgents` (`extension/sync-packaged-agents.ts`) now ALSO syncs the project-local `.pi/agents/` directory at session start, not just the global `~/.agents/`. In this repo `.pi/agents` is a symlink to `../agents` (the package source — a dogfooding convention), so Pi's `subagent` tool resolved project role agents from the raw neutral templates with precedence over the Pi-valid global copies, and strict-rejected `search`/`execute` ("requested unavailable child tools: search, execute"), failing the gate fan-out reviewer/fixer dispatches under Pi (observed: 4/5 draft_gate reviewer dispatches failed; #1605 needed a manual unstaged symlink removal). The sync now detects a symlink at `.pi/agents`, removes it (the link only — never writing through to the neutral source target), and writes a REAL directory of Pi-valid rendered copies (same `tools:` rewrite as the global sync), so project-local resolution no longer reads the stale source. Pi also caches agent definitions at session start, so mid-session symlink removal did not invalidate the cache — the synced real directory takes precedence at the next session start with no manual intervention. When `.pi/agents` is already a real directory, its packaged-agent files are refreshed in place; when absent, the sync is a no-op so consumer repos (no project `.pi/agents` entry, relying on the global `~/.agents/`) stay unaffected. The neutral source `agents/*.agent.md` templates remain untouched (role agents keep `search`/`execute` per #1086; Claude assets unchanged). The session_start handler passes `projectRoot: ctx.cwd`. Tests: symlink replaced with real Pi-valid copies (source untouched); real-dir refresh in place; absent no-op (consumer repos unaffected); session_start integration.

- **Package-source dev-loop agent template stale tool names + dispatch-source finding (#1604, #1583 regression).** The `agents/dev-loop.agent.md` package source template still declared Pi-invalid/redundant tool names (`agent`, `todo`) after #1583 — `agent` is redundant with `subagent` (already listed), and `todo` has no Pi builtin. The dev-loop's subagent dispatch resolves role agents from the project `.pi/agents/` directory, which symlinks to `../agents` (the package source) in this repo, so the dispatch read the source templates directly — `syncPackagedAgents` only rewrites the global `~/.agents/`, which the project symlink bypasses. The dev-loop entrypoint source now declares `tools: read, search, execute, bash, edit, write, subagent` (drops `agent`/`todo`; adds `edit`/`write` so the conductor can mutate under Pi without the bash/sed workaround observed on PR #1602). Role-agent sources (`fixer`, `developer`, `docs`, `quality`, `refiner`, `review`) keep the neutral `search`/`execute` vocabulary unchanged — both harnesses map them (Claude `search`→Grep+Glob; Pi sync `search`→`bash`), and dropping them would regress Claude (#1086). `syncPackagedAgents` stays load-bearing for consumer repos with no project `.pi/agents` symlink. Claude assets regenerated: `.claude/agents/dev-loop.md` now renders `Read, Grep, Glob, Bash, Edit, Write, Agent` (gained Edit/Write, dropped TodoWrite); role-agent Claude assets unchanged. Cross-harness regression tests added: dev-loop source drops `agent`/`todo` + declares `edit`/`write`; role agents keep neutral `search`/`execute`.

- **Stale `tracker.board.title` config silently broke queue board-sync after the project rename (#1589).** `.devloops` identified the GitHub Projects queue board by `title: "dev-loops Queue"`, but the project (https://github.com/users/mfittko/projects/3) was renamed to "dev-loops", so every board-sync operation (`queue add`, `queue move`, `queue sync-status`, `add-queue-item`, the `marked_ready` sync) silently failed with `PROJECT_NOT_FOUND` — a title is fragile (renames break it silently), a project number is stable and survives renames. `.devloops` `tracker.board` now uses `number: 3`; the schema already supported `tracker.board.number` (`packages/core/src/config/config.mjs`), and `loadBoardConfig` already prefers `tracker.board` over the deprecated `queue.board`. Queue commands now resolve the board unaided, with no `--project` override. A contract test asserts `loadBoardConfig` resolves `tracker.board.number` without a stale `title` and that `tracker.board.number` takes precedence over a leftover `queue.board.title`.

- **Routing trap: clean current-head Copilot review + 0 threads + green CI dead-ended into stop instead of transitioning to pre_approval_gate (#1588).** Two detectors derived `copilotReviewRequestStatus` from the same GitHub facts and disagreed: `detect-copilot-loop-state.mjs` reconciled a lingering `requested_reviewers` entry against the latest same-head submitted review (a request older than the review is stale → status `none`), while `detect-pr-gate-coordination-state.mjs` mapped `requested → "requested"` unconditionally. The unreconciled status caused `applyUnsettledCopilotReviewEntryGuard` (#1190) to discard the evaluator's `RUN_PRE_APPROVAL_GATE` grant, dead-ending the loop into `stop` even though all pre-approval preconditions were met. The request-settled reconciliation is now extracted into a shared helper (`resolveCopilotReviewRequestStatus` in `scripts/loop/_copilot-review-request-status.mjs`) and every `copilotReviewRequestStatus` derivation routes through it: `detect-copilot-loop-state.mjs`, `detect-pr-gate-coordination-state.mjs`, and the re-derivation in `request-copilot-review.mjs`. A submitted clean review on the current head satisfies an outstanding formal request when the request is not newer than the latest submitted review (fail-closed to `requested` when the request timestamp is unknown). `applyUnsettledCopilotReviewEntryGuard` semantics are unchanged — a genuinely outstanding (unsettled) request still blocks pre_approval entry, and the same-head re-request suppression still suppresses. `skills/docs/copilot-loop-state-graph.md` and the state-machine conformance check updated to reflect the clean-current-head-review → pre_approval_gate transition.

### Removed

- `scripts/github/post-merge-board-sync.mjs`. It was a second CLI over the same `syncBoardStatus` core with the same best-effort exit contract; the post-merge step now runs `dev-loops queue sync-status --repo <owner/name> --pr <number> --item <linked-issue> --logical-column done || true`, which carries both behaviors the hook needed.

## 1.0.0-rc.5 - 2026-08-10

The rc.5 end-to-end drive: fifteen merged items hardening the gate review
surface (verdict integrity, fan-out enforcement, refusals over degraded
warnings) and the loop runtime (lock release at terminal stops, cheaper
outage status checks, faster stuck-CI bail), plus retrospective checkpoint cycle
scoping and namespaced alternatives for bare `/loop-*` slash commands.

### Added — gate review

- **Dedicated judge agent for relevance disposition (#1525, f99f40c2).** A
  dedicated judge agent now owns the relevance-disposition step of the gate
  review, separating the disposition decision from the reviewer/fixer roles.
- **Visible warning on a schema-rejected gate layer (#1578, 212b1865).** A gate
  layer that the schema rejects now surfaces a visible warning instead of
  failing silently, so a malformed layer is diagnosable rather than invisible.

### Changed — gate verdict integrity and fan-out enforcement

- **Verdict consistency enforced against the consolidator's `overallVerdict`
  (#1616, b3466c32).** `upsert-checkpoint-verdict.mjs` refuses a `--verdict`
  that contradicts the consolidator's computed `overallVerdict` and derives the
  verdict from it by default — no override flag. Closes the silent contradiction
  that stuck gates rounds later (PR #1612).
- **Verdict-write preconditions enforced (#1621, 64632d6d).** Builds on #1616:
  a non-clean verdict's `next-action` is derived (not caller-supplied prose),
  an inline round with a blocking finding escalates to full fan-out
  (`gate:full` label), and a `clean` `pre_approval_gate` is refused while the
  linked issue still has unticked Acceptance criteria.
- **Four degraded warnings replaced with refusals (#1626, 3195b49a).** Four
  MUSTs enforced as an invisible JSON `warning` field are now refusals or
  explicitly-decided advisories: stale findings-dir artifacts, missing/mismatched
  closing references, linked-PR detection failure (fails closed), and the
  context-rebuild advisory (decided, with the MUST enforced separately).
- **`verify-briefing-prefixes` wired into fan-in (#1618, 33efb9a2).** The
  fan-in consolidator now runs the briefing-prefix verifier mechanically before
  consolidation, failing closed (`GATE-EXEC-BRIEFING-PREFIX`) on divergent,
  missing, or short reviewer sentinels — the rule's cited proof is now actually
  invoked.
- **Unparseable findings tracked in the clean-verdict cross-check (#1526,
  5bfc44d7).** The clean-verdict cross-check accounts for unparseable findings
  rather than treating them as absent.
- **No-rebuild-mid-fan-out rule enforced (#1537, 7d032e44).** Rebuilding the
  gate context mid-fan-out is now refused, so a round's reviewers stay seeded
  from one invariant briefing.
- **Reviewer-budget preflight before fan-out dispatch (#1507, 805701b6).** The
  conductor runs a reviewer-budget preflight before fan-out dispatch, refusing
  dispatch that would exceed the configured budget rather than 429-ing mid-wave.

### Fixed — loop runtime

- **Auto-release the runner-coordination lock at terminal stops (#1632,
  f6efc55a).** The runner-coordination lock is released when the loop reaches a
  terminal stop, so a finished loop never leaves a stranded lock blocking the
  next run.
- **Faster stuck-CI bail — zero-allocation stall detector (#1631, 6db457a5).**
  Stuck-CI detection now bails faster via a zero-allocation stall detector.
- **Cheaper auto-resume status checks during outages (#1633, f19dcc8d).**
  Auto-resume status checks are cheaper during provider outages.

### Changed — process and UX

- **Retrospective checkpoint cycle scoping (#1613, 26234da3).**
  `requireRetrospective` is no longer a one-time gate satisfied forever by a past
  `complete` checkpoint: it is cycle-scoped via a local git ancestry check (has
  anything merged to the base branch since the checkpoint's discharge point?),
  with the checkpoint read/write paths resolving through the one main-checkout
  file regardless of worktree.
- **Bare `/loop-*` slash commands get a namespaced alternative (#1485,
  68c8cb5c).** Bare `/loop-*` slash commands now resolve to a namespaced
  alternative, so command surfaces stay unambiguous.

### Tests

- **Queue `sync-status` published-package regression coverage (#1555,
  2e9a5798).** A packaged-install regression test covers the queue
  `sync-status` published-package path.

## 1.0.0-rc.4 - 2026-07-30

Consumer-soak fixes for rc.3. The theme is deadlocks: several gate paths could
reach a state where the loop was correct, the PR was mergeable, and nothing
moved. Most of these were found by running the loop against its own queue.

### Fixed — gate deadlocks

- **The gate-evidence check never went green after a verdict comment (#1483).**
  A gate verdict posted as an issue comment did not re-fire the workflow, so the
  required status stayed stale and the PR sat merge-blocked behind a gate that
  had actually passed. It now re-fires on gate-verdict comments.
- **The loop counted the gate-evidence workflow's own check run as PR CI (#1498).**
  A PR could sit at `ciStatus: none` forever while its real checks were green,
  because the loop's derived check was watching itself. Its own check names are
  excluded from the CI status it derives.
- **A circular import between `upsert-checkpoint-verdict` and reconcile
  deadlocked draft-gate verdict posting (#1491).** The gate could not record the
  verdict it had just produced.
- **`round_cap_reached` refused the pre-approval fallback its own note offered
  (#1490).** The state told the operator a fallback was available and then
  rejected it. Adds a universal `nextAction`-consistency contract so a state
  cannot advertise an action it will not accept.
- **A stranded Copilot review request had no exit (#1501).** Withdrawing the
  request is now an explicit operator escape hatch, rather than loosening the
  gate to get past it.

### Fixed — packaging and consumer installs

- **`zod` and `yaml` were used by shipped code but never declared (#1500).** A
  consumer install could resolve them only by accident of hoisting. Both are
  declared, and a contract test now fails the build when shipped code imports a
  bare specifier the root manifest does not declare.
- **The ui-review browser-driving stages could not run from a consumer install
  (#1460).** Interstitial dismissal on the login page and the worktree guard's
  handling of the loop's own namespace are fixed too (#1456).
- **`axe` install is an explicit opt-in (#1489)** rather than an implicit
  dependency of a ui-review run.
- **A worktree's `@dev-loops/core` link is covered by a resolver-level
  regression test (#1432)** — the link silently resolving to the primary
  checkout is what made an earlier worktree bug invisible.

### Added — gate review

- **Cache primer for the review fan-out, `GATE-EXEC-PRIME` (#1462).** The
  handoff envelope's stable prefix is byte-identical across rounds for the same
  target and gate, with the volatile gate state isolated into a trailing block,
  so a fresh reviewer spawn stays cache-warm. The primer is mandatory — the
  opt-in flag is gone.
- **One scoped reviewer per fresh angle is enforced in fan-out provenance
  (#1431).** Two freshly-reviewed angles may no longer share a reviewer
  identity; a sanctioned single-reviewer run must declare
  `inline_single_agent` with a reason.
- **Consumer-run friction fixes (#1484):** a fan-in CLI, prefix-file record,
  `--findings-file` flags, angle parity, a tarball contract, and a stale-install
  doctor.
- **The single-contributor ownership gate is scoped to code-changing strategies
  (#1444)**, so a docs-only change no longer trips it.

### Changed

- **`.markdown` classifies as docs, and extensionless dotfile configs as config
  (#1488)** — subtractive angle pruning now works for config-only and docs-only
  PRs instead of falling back to a full fan-out.
- **The queue board resolves from `.devloops` (#1479)**, and a post-merge board
  sync runs best-effort after a merge (#1492).
- **`.devloops` carries per-layer angle deltas only (#1428)**, rather than
  restating the inherited angle list.
- **`edit-issue --state` opens and closes issues, and `create-issue` fails
  closed on an empty body (#1422).**

### Docs

- Evolution/history article and deck for dev-loops (#1440), a refreshed public
  intro after the pre-1.0 config changes (#1430), the ADR decision-record
  practice embedded into the workflow (#1436), and ADR 0041 recording the
  deslop + designer-review gate-loop decision (#1439).

## 1.0.0-rc.3 - 2026-07-19

### Changed (breaking — `.devloops` config shape, #1404)

Pre-1.0 config-schema redesign: gate-review angle identity, which used to be
split across five places, is now one array-of-objects per gate
(`gates.<gate>.angles`; a bare string is sugar for `{ name }`). Config layers
merge these arrays **by name** — a later layer can add a new angle, or
override/disable an existing one (`enabled: false`), without restating the
whole list.

No back-compat shim for the old flat keys (pre-1.0 hard break, by design).
Upgrading your `.devloops`:

| Old key | New shape |
|---|---|
| `gates.<gate>.mandatoryAngles: [name, ...]` | per-angle `{ name, mandatory: true }` in `gates.<gate>.angles` |
| `gates.<gate>.excludeAngles: [name, ...]` | per-angle `{ name, enabled: false }` in `gates.<gate>.angles` |
| `gates.<gate>.extraAngles: [name, ...]` | just add the angle to `gates.<gate>.angles` — arrays now merge by name across layers, so this no longer needs its own list |
| `gates.<gate>.dynamicAngles` / `additiveAngles` | `gates.<gate>.dynamic: { subtractive, additive }` |
| top-level `personas.<angle>: { persona, prompt, defaultModel }` | per-angle `persona` / `prompt` / `model` fields in `gates.<gate>.angles` |
| `models.roles.<angle>` / `models.roleTiers.<angle>` (angle-keyed) | per-angle `model` / `tier` fields in `gates.<gate>.angles` (role-keyed `models.roles`/`models.roleTiers` for subagent roles are unchanged) |
| `queue.projectNumber` / `queue.boardTitle` | `queue.board.number` / `queue.board.title` |
| `worktree.copyOnInit: [path, ...]` / `worktree.linkOnInit: [path, ...]` | `worktree.entries: [{ path, mode: "copy"\|"link" }, ...]` |
| `refinement.stopOnLowSignal` / `lowSignalRoundThreshold` / `lowSignalMaxComments` | `refinement.lowSignal: { enabled, roundThreshold, maxComments }` |
| `strategy.default` / `inputSource.default` | flattened: `strategy` / `inputSource` (bare enum value) |
| `approval.humanHandoff.*` | lifted: `approval.*` (its only child) |
| `localImplementation.issueless.enabled` | flattened: `localImplementation.issueless` (bare boolean) |
| `localPlanning` | removed (was already deprecated/unread since #1088) |

`gates.anglePool` stays global (not per-gate); the `spike` gate keeps the same
unified gate schema as `draft`/`preApproval` (some knobs, e.g.
`blockCleanOnFindingSeverities`, are inert for it). See
`schemas/dev-loop-config.schema.json` and
`packages/core/src/config/extension-defaults.yaml` for the full shape.

### Added (tracker-agnostic seam, #1408)

`@dev-loops/core/tracker` ships the generic `Tracker` provider
interface/registry (mirrors the existing harness-adapter idiom): Issues
(required — `parseRef`/`getIssue`/`createIssue`/`editIssue`/`commentIssue`/
`listIssues`/`detectLinkedPr`) and Board (optional capability). GitHub ships
as the v1 built-in default provider (`createGithubTrackerAdapter`); no
external tracker (Jira/Shortcut/…) is implemented — the seam makes one a
post-1.0 consumer plugin (`tracker.provider` + `resolveTrackerAdapter({ providers })`).

New `.devloops` block:

```yaml
tracker:
  provider: github   # registry key; default. See skills/docs/tracker-seam-contract.md
  board:              # supersedes the deprecated queue.board
    title: "My Queue"
```

`strategy: "tracker-first"` renames the former `"github-first"`
(provider-neutral); `"github-first"` and `queue.board` are both still
accepted as deprecated aliases (normalized with a load-time warning —
`tracker.board`/`queue.board` and `queue.projectNumber`/`queue.boardTitle`
are two separate migrations, see the #1404 table above for the latter).
Behavior with the shipped `github` default is unchanged. See
[Tracker Seam Contract](skills/docs/tracker-seam-contract.md).

### Changed (breaking — gate-tooling `--head-sha` contract, #1407)

`write-gate-findings-log.mjs`, `upsert-checkpoint-verdict.mjs`, and the
plugin-bundled `post-gate-verdict-fallback.mjs` now require the FULL head
commit SHA (40/64 hex) for the primary `--head-sha` and fail closed on a
short prefix — a prefix used to silently write a findings-log ledger and
gate marker the pre-merge reader (which resolves the full `headRefOid`)
could never find. Provenance-only fields (`carriedFromHead`, `resolvedIn`)
still accept a 7-64 hex prefix.

### Changed (self-contained plugin docs, #1381)

The dev-loops contract/step docs referenced from bundled skills moved from
repo-root `docs/` into `skills/docs/` (18 docs + the outer-loop-state-graph
pointer), so every link in the installed `.claude/` plugin resolves
in-plugin — no more dead cross-plugin-root `docs/…` links and no reliance
on the source repo being reachable. The installed-layout link guard now
scans `.claude/skills/**` and keeps a per-entry-verified consumer-artifact
exemption (`PLAN.md`, `AGENTS.md`, `IMPLEMENTATION_*`, `phase-x.md`).

### Fixed (gate evidence)

- The required `gate-evidence` commit status no longer deadlocks pre-approval:
  it is excluded from the loop's CI-status derivation, and an evidence state
  that is merely not-yet-established reports **pending** instead of failure
  (#1412).
- `gate-evidence` re-fires on review-thread state changes, closing the
  thread-axis stale-green window (#1385); the API-driven ready/merge bypass
  is closed server-side (#1383).
- Posted gate comments fail closed instead of truncating (#1394).

### Added

- Single-contributor ownership: runners claim an artifact at pickup and fail
  closed on foreign or unclaimed artifacts (#1378).
- ui-review route activated end-to-end (CLI subcommands + `--ui-review`
  selector, #1362); UI-review recipe contract bundled into the plugin (#1382).
- `workflow.baseBranch` — configurable integration branch (#1386);
  `extraAngles` additive gate-config operator (#1395).
- CLI: `pre-flight-gate`, `ensure-worktree`, and issue edit/create routed as
  `dev-loops` subcommands (#1391); `edit-comment` wrapper (#1393); zero-dep
  preflight for deps-less plugin checkouts (#1384).
- Sanctioned same-head PR-body-fix retry path for gate reviewers (#1380).
- `verify` lints GitHub Actions workflows with actionlint (#1409); `git stash`
  is refused in the shared-`.git` worktree layout (#1406).

## 1.0.0-rc.2 - 2026-07-17

Second release candidate: closes out the pre-1.0 backlog surfaced during the rc.1 window.

### Added

- **Issue-less PR-first at any change scope (#1350).** `localImplementation.issueless.enabled` (default off) lets consumers whose spec of record lives in an external tracker run `--lightweight` with no `--issue` regardless of change size. Review depth stays scope-driven: over-threshold PRs keep the full gate fan-out and the full-PR Copilot round cap; with the flag off, control flow and fail-closed reason codes are unchanged.
- **Config JSON schema generated from the zod validator (#1351, #1357),** with field descriptions flowing through and drift tests guarding the rendered schema.
- **Sanctioned `view-issue.mjs` wrapper (#1365)** for issue-body reads, unblocking AC checkbox ticking at merge.

### Changed

- **The plugin manifest version is now a generated asset (#1348).** `generate-claude-assets.mjs` stamps `.claude/.claude-plugin/plugin.json` from the root `package.json`, the committed-tree no-drift check catches a stale manifest in `verify`, and the release runbook names the regenerate step — the rc.1 publish-time abort can't recur.
- **Both articles now render from markdown (#1351, #1356).** The deep-dive article — the last hand-synced md/html twin — renders via `render-article.mjs` (fail-closed constructs: blockquote callouts, Part dividers, mermaid diagram figures, outro opt-out), fact-grounded and desloped with drift/anchor/nav/CSP contract tests and Playwright slices covering both articles.
- **Both decks fact-grounded and desloped (#1354, #1355).**
- **Gate-context CLI resolves angles dynamically when `--angles` is omitted (#1367).**

### Fixed

- **AC-hygiene follow-ups, groups A+B (#1340).** Skill-doc drift corrected (run-id provenance claims aligned with shipped behavior in both passages, raw script refs migrated to existing `dev-loops` subcommands, gate-inspection terminology stragglers fixed) and three regressed hand-rolled argv parsers re-migrated to `node:util` parseArgs with a contract-test guard against reappearance. Decision-shaped groups split to #1371/#1372/#1373/#1376.

## 1.0.0-rc.1 - 2026-07-14

**Release candidate for 1.0.** `1.0.0-rc.1` freezes the intended 1.0 public surface and publishes it for consumers to validate *before* the final `1.0.0` — install it opt-in with `npm install dev-loops@rc` (it is not the default `latest`). From 1.0, `dev-loops` follows [semantic versioning](https://semver.org/) for its public API — breaking changes to it bump the major version — and this RC window is the chance to surface anything that should change before that surface is frozen. See the [Stability section in the README](./README.md#stability) for the full statement.

### Stability

- **The `.devloops` configuration surface is now a semver-stable public API** (validated by the config loader in `packages/core/src/config/config.mjs`; `schemas/dev-loop-config.schema.json` documents the common keys): gate angles, personas, `gates.*` (including each gate's `requireCi` toggle and `gates.preApproval.requireCi`), `refinement.*`, the `uiReview.*` route config, `autonomy.*`, and `workflow.*`. New keys and gate angles are additive (minor); removing or repurposing an existing one is breaking (major).
- **The public `dev-loop` command/skill surface and the [Public Dev Loop Contract](./skills/docs/public-dev-loop-contract.md) routing contract** are likewise stable. Internal strategy names, script internals, and undocumented helpers are not part of the stable surface.

### Added

- **`/loop-review-ui` matured to a full running-app review route (ui_review Stages 1–6).** Per-state semantic snapshot (`snapshot.json`, #1297), computed a11y facts via axe-core (`axe.json`, #1298), per-state console + network capture (`console.json`, #1299), viewport + interaction-state encoded in the state slug (#1300, #1331), a lens fan-out with a pure converge/dedupe seam (#1322), and acceptance-criterion traceability with a coverage gate (#1323). Drive-session row tagging so teardown drops exactly the drive-created rows (#1333), plus a GitHub-native gist fallback for artifact hosting (#1332).
- **CI opt-out at the pre-approval gate (#1337).** `gates.preApproval.requireCi: false` is now honored (default stays `true`), so a repo with no CI can run the loop end-to-end instead of blocking forever at the pre-approval gate — mirroring the draft gate's `requireCi` knob. When false, the CI verdict is ignored entirely at that boundary (including a real failure).
- **Visual grilling in the loop-grill external-resources step (#1034).** A design gap can reference a screenshot (path or URL) or a Playwright navigation descriptor, captured via a bounded wrapper over the existing ui_review harness — surfaced before the Q&A, fail-closed to `unresolved` on an inaccessible resource.
- **Harness-aware model-tier policy on the existing models config (#1134)** and a sanctioned `create-issue.mjs` wrapper (#1309).

### Changed

- **Convergence carry-forward (#1326).** A doc/comment-only post-convergence head bump no longer forces a fresh Copilot round; a fail-closed angle carry-forward also skips gate re-review when a head delta doesn't touch the reviewed surface (#1308).
- **Change-classifier soundness (#1330).** A `docs/`-hosted code/config/test file is now classified by its extension before the `docs/` prefix fallback, so it is never mis-treated as documentation-only.

### Fixed

- **Model-tier role-name collision (#1314)** — gate review angles resolve at the high tier despite a routine-role name collision.
- **Gate-context build fails closed on a wrong-worktree/degenerate build (#1318);** queue `add` signals `moved: false` when it leaves an already-present item in a different column (#1306); generated `../docs` links shift correctly for `.claude/agents` and `validate-links` scans `.claude/**` (#1290).

## 0.9.0 - 2026-07-09

The v0.9 headline is the **`/loop-review-ui` epic (#1114)** — a UI-review dev-loop route that proves a change in the running app — plus a batch of loop-hardening and dev-loop self-convention fixes.

### Added

- **`/loop-review-ui` — running-app UI-review route (#1114, Stages 0–6).** A new dev-loop strategy that boots the app and reviews a PR against it: route + command scaffold with stop rules and acceptance self-validation (#1279); provision-and-boot module (#1280); auth + Playwright drive harness that captures page errors, error responses, and a server-log tail (#1281); diagnose that anchors reproduced exceptions to the exact diff lines (#1282); report that posts a head-pinned pending review with inline comments and a self-contained screenshot artifact, severity→event policy, harness-aware hosting (Claude Artifacts out of the box, fail-closed elsewhere) (#1286); teardown with an always-emit side-effect ledger (#1288); and command help + README + a per-project run/auth recipe contract (#1289). The full GitHub-native artifact-hosting fallback is tracked as follow-up #1285.
- **Headless auto-refine of parked un-refined items (#1258).** A deterministic `queue parked-unrefined` discovery helper surfaces issues the #1251 enqueue fail-safe parked without a refinement artifact; a headless/`--auto` dev-loop session auto-refines them at the orchestrator layer (`loop-grill --auto`) and promotes each into the pickup column via `queue move` — bounded (ascending order, at most once per session), with unrefinable items left parked with a recorded reason. No LLM in any coordinator script.

### Changed

- **PR-body convention + `pr-description` gate angle (#1283).** The `pr-description` draft-gate angle no longer requires (or flags the absence of) a "File-by-file changes" section — GitHub's Files-changed tab already covers it and the mandate churned gate findings. Linked-issue AC-checkbox syncing is now a first-class capability of `tick-verified-checkboxes.mjs` (`--issue`, or `--pr --issue` in one call), so a tracker-backed PR's approval gate keeps the issue's checkboxes honest instead of relying on a skippable manual step.
- **`gh issue edit` call-sites routed through the `edit-issue.mjs` wrapper (#1255)** so the loop's internal-tooling record stays complete (no agent-level raw `gh issue edit`).

### Fixed

- **CI verify-suite drift guard (#1273).** A contract test fails the build if `package.json`'s `test:*` scripts drift from the `ci.yml` verify-suite matrix in either direction.

### Performance

- **Faster `test:scripts` real-git files (#1277).** The heaviest real-git-repo test files were converted to a module-seam gh mock, in-process CLI, cheap shell git-stubs, and a copy-once git fixture (e.g. conductor-monitor 11.5s→2.5s), with genuine git-boundary/CLI-error smokes deliberately kept as real spawns. Suite wall time is throughput-bound and largely unchanged; throughput-aware wall reduction is tracked as follow-up #1294.

## 0.8.0 - 2026-07-08

<!-- Release date is set at tag time; see the release runbook. This section is prepared ahead of the v0.8.0 tag. -->

Everything merged since v0.7.1 — the v0.7.x contract-audit epic (#1104) plus follow-on release-gate fixes. **Versioning note:** most of these commits are ancestors of the `v0.7.2` tag and therefore already ship in the published `0.7.2` npm artifact, but `v0.7.2`'s release notes documented only the #1241 packaging hotfix (see the 0.7.2 section below). This section backfills that omitted changelog and adds the nine changes that genuinely postdate the `v0.7.2` tag (#1240, #1242, #1244, #1249, #1250, #1253, #1254, #1256, and #1236/#1252); it becomes the notes for the eventual `v0.8.0` tag.

### Added

- **Issue-less lightweight PR-first flow (#1210/#1215).** A PR with no backing issue can drive the lightweight path with a configurable composed Copilot round cap; such PRs auto-enqueue as board items so they are still tracked (#1218/#1226).
- **State-machine conformance and invariant harness (#1148/#1189).** Built on a rule-ownership foundation (#1183). The PR lifecycle state machine is exported from `@dev-loops/core` as the single source for docs, the state atlas, and the conformance harness (#1193/#1216), and `public-dev-loop-routing` is wired into the L2/L3 conformance harness so all five machines are covered (#1233/#1239).
- **Inline reviewer briefing prefix (#1220/#1229).** The reviewed content is carried inline, size-capped and hash-enforced, on top of invariant-prefix-first reviewer briefings with prefix-hash enforcement (#1207/#1214).
- **First-class loop primitives (#1198/#1223).** `list-review-threads` and `wait-pr-checks`, plus a sanctioned `edit-issue.mjs` wrapper for AC-reflection body edits (#1253) and a refinement-artifact-at-enqueue requirement so no un-refined item enters Next Up (#1251/#1254).
- **State-atlas fullscreen lightbox for diagrams (#1217).** Diagrams open in a fullscreen lightbox for readable inspection.

### Fixed

- **Package-escaping `@dev-loops/core` imports broke consumer installs (#1241).** Shipped as the v0.7.2 hotfix; full detail is in the 0.7.2 section below.
- **Converge-then-gate enforced at `pre_approval` entry (#1190/#1219).** Retires the known gap where a gate could run before Copilot convergence.
- **Fanout provenance fails closed on missing mandatory or foreign angles (#1196/#1225).** Briefing-prefix verification is also scoped by (gate, headSha) (#1249).
- **Reviewer-loop submission-failure edges join the transition table (#1200/#1221).** Copilot summon literals are sanitized on write with a code-span-aware guard and an honest blocked status (#1213/#1222).
- **Issue-less lightweight PR-first three-origin contracts reconciled (#1242).** Adds a gate-coordination `specSource` branch; a contradiction sweep covers gate-skip scoping, coverage-modality ownership, and advisory wording (#1244).
- **run-claim coordination file anchored at the git common dir, not CWD (#1250).** The per-PR runner lease now resolves its coordination file from the shared git common dir, so a runner started from a different worktree/CWD attaches to the same lease instead of forking a second one.
- **`queue --item` accepts the full node-ID alphabet across the projects scripts (#1227/#1230).** The `--item` node-ID parser now accepts the complete GitHub node-ID character set, so board items whose IDs use previously-rejected characters resolve consistently across the projects scripts.
- **release.yml dispatches npm-publish.yml explicitly after creating the release (#1187/#1188).** Pages deploy is bumped to deploy-pages v5 (#1211/#1212) and the gate-hub flowchart renders left-to-right (#1208/#1209).
- **docs-validator manifest gap and corpus→manifest completeness (#1238).** Epic AC1/AC2 zero-states restored — canonical-owner openers plus remaining phrase pins (#1240).

### Changed

- **Contract corpus condensation + single-owner rule IDs.** Rule ownership migrated to single-owner IDs across the gate, queue, loop, worktree, intake, and public-loop clusters, with residual normative phrase-pins retired to a zero-pin end state (#1149–#1159, #1191–#1206). Inline interpreters were banned from coordinator flows in favor of sanctioned paths by reference (#1224/#1228), a final corpus condensation sweep rewired rule IDs with zero semantic change (#1236/#1252), and RFC-2119 modality was harmonized on newly-tagged merge/confirmation clauses (#1256).

## 0.7.2 - 2026-07-06

### Fixed

- **Package-escaping imports in `@dev-loops/core` broke every consumer install (#1241).** `queue-board-sync.mjs` and `queue-board-ordering.mjs` imported `move-queue-item`/`list-queue-items` via `../../../../scripts/projects/*`, paths outside the package root that the published tarball (src/**+bin/** only) cannot resolve — so `resolve-active-board-item` (loop startup) and `ready-for-review` threw `ERR_MODULE_NOT_FOUND` on install for every release from 0.3.0 to 0.7.1. The `main` implementations now live in `@dev-loops/core` (`src/projects/`, exported via the package map); the two `scripts/projects/*` files are thin CLI wrappers importing core by package name. A packaged-install smoke test (`test:pack`) npm-packs both packages, installs them in a temp dir, imports every `@dev-loops/core` export-map entry, and runs the affected CLIs with `--help` — closing the artifact-layout gap that let five releases ship broken.

## 0.7.1 - 2026-07-05

### Changed

- **`captureDiffFromBase` pins `diff.renames`/`diff.algorithm`/`diff.context` and `core.abbrev`/`core.autocrlf` for cross-environment reproducibility (#1168).** The isolation `-c` overrides for the persisted `--base` neutral diff seed already made the diff byte-identical within a single run, but a machine with a contrary local gitconfig could still produce different bytes for the same base/HEAD pair — and, via `diff.renames`, a different `scope.changedFiles`/`adjacentCode` membership (rename vs delete+add). The five settings are now pinned alongside the existing overrides.

### Removed

- **Dead `isFenceLine` field dropped from `stepFence` (#1166).** `stepFence` (`packages/core/src/loop/issue-refinement-artifact.mjs`) computed and returned `isFenceLine` on every branch, but none of its three call sites (`parseMarkdownSections`, `extractChecklistItems`, `sectionHasBody`) read it. Removed from the return shape and its JSDoc; `fence`/`insideFence` and all fence-detection logic are unchanged.

### Fixed

- **Worktree provisioning creates the `@dev-loops/core` workspace link so `scripts/**` CLIs validate the branch's core (#1144).** Worktrees provisioned by `ensure-worktree.mjs`/`provision-worktree.mjs` have no `node_modules`, so any `scripts/**` CLI importing `@dev-loops/core` resolved the bare specifier by walking up to the MAIN checkout's `node_modules/@dev-loops/core` instead of the worktree's own `packages/core` — `npm run verify` in a worktree silently tested main's core, not the branch's. Provisioning now creates the relative symlink `node_modules/@dev-loops/core → ../../packages/core` unconditionally: idempotent on a correct existing link, replaces a stale/broken link, never clobbers a real file/dir already at the destination, and is reported in the provisioning summary.
- **Sibling logical columns resolved via `loadStateColumnMap` in pickup and archive (#1143).** `resolve-active-board-item.mjs` hardcoded the literal `"In Progress"` column for the live `/loop-continue` pickup query instead of resolving it through `loadStateColumnMap`/`LOGICAL_COLUMN.IN_PROGRESS` the way #1142 already did for Next Up, so a repo overriding `queue.statusColumns.in_progress` would have pickup silently miss the active item. An audit found the same gap in `archive-done-items.mjs` (`selectArchivable()` hardcoded `"Done"`). Both now resolve through the shared mapping, fail closed on a malformed `.devloops`, and honor configured overrides; `--help`/display literals in other scripts were left as-is (illustrative examples, not routing logic).
- **The post-convergence significance detector is content-aware, so comment/JSDoc-only changes don't reopen Copilot past the round cap (#1137).** Significance in `_post-convergence-change.mjs` (shared by `copilot-pr-handoff` and `detect-pr-gate-coordination-state`) was classified by path/size only, so a trivial comment fix could re-trip a re-request past the cap. A new `isCommentOnlyFileChange` classifier inspects the compare diff's patch hunks with a per-hunk block-comment state machine (reset at every `@@` header, conservative toward "significant") and filters comment-only JS/TS files out before the existing `>=20 lines`/`>=2 files` thresholds. A file with no `patch` (binary/oversized) is never treated as comment-only; the stale `+++`/`---` header-skip (never present in `gh compare` patches) was removed.
- **`copilot-pr-handoff` fails closed on a pending Copilot review at the round cap (#1165).** At the cap with a `--force-rerequest-review` in flight, handoff routed to `ROUND_CAP_CLEAN_FALLBACK` ("continue to pre_approval_gate") whenever the fail-silent secondary reopen-facts fetch (`gh pr view` + `gh compare`) failed — silently reading significance as false — while `detect-pr-gate-coordination-state`, reusing its already-validated PR data, correctly gated; the loop follows handoff as the pre-flight authority, so a significant change could skip Copilot's review (hit live on PR #1164). While a requested review is pending on the current head at the cap, handoff now surfaces `waiting_for_copilot_review` (action `watch`) before the fragile escape-hatch fetch can downgrade to "proceed"; a `--watch-status` refresh skips the guard so a request that never resolves re-settles to the clean fallback and the loop cannot dead-end.
- **Merge-block hook message explains that evidence writes and `gh pr merge` must be separate tool calls (#1172).** The "vanishing" gate-evidence ledgers on PRs #1167/#1169/#1171 were never swept: the PreToolUse bash gate evaluates `gh pr merge` before the tool call executes, so a compound command that both writes gate evidence (`write-gate-findings-log`/`upsert-checkpoint-verdict`) and merges in the same call is blocked at hook time — the write never runs. `decideBashGate` now appends a hint to the deny message in exactly that compound case (a bare `gh pr merge` keeps the standard message; no allow/deny boundary changes), and the write → verify → merge-alone contract is documented in `skills/docs/merge-preconditions.md`.
- **Light-mode-aware pre-merge gate accepts scoped inline verdicts (#1174).** `buildPreMergeGateCheck` rejected any `inline_single_agent` verdict whenever `gates.requireFanoutEvidence` was on (the repo default), so a genuinely under-threshold micro-PR's #1043 light-path verdict was unmergeable without a full fan-out (hit live on PR #1173, 1 file / 11 lines). An inline verdict is now accepted only when lightMode is enabled, the merge-base scope re-derived at merge time is under threshold (underivable scope → rejected, fail-closed), no `gate:full` label is present, and a non-empty inline reason is recorded; the findings-log ledger is still required, and the fan-out path (`fanout_fanin` verdicts, ledger, provenance enforcement) is unchanged.
- **`validate-pr-body-spec` enforces a `Closes #N` closing-keyword reference (#1181).** Five lightweight PRs merged without auto-closing their issues because the PR-body-as-spec validator never required the linkage. It now fails closed with `missing_closing_issue_reference` when the body carries no GitHub closing-keyword issue reference, supports `--expected-issue <n>` (`closes_wrong_issue` on a mismatch), and reports the extracted `closesIssues` — all keyword variants and the cross-repo `owner/repo#N` form accepted, while references inside fenced code blocks or inline code spans don't count.

## 0.7.0 - 2026-07-05

### Added

- **Lightweight local-first path — PR body as spec-of-record, no phase doc (#1025).** Local-first now supports a lightweight path for chores, bugfixes, and small mechanical changes where the PR description itself is the spec-of-record — no phase/plan doc is minted or committed. When active, `loop startup` no longer requires `--plan-file` and the pre-approval review agent reads the PR body (objective, in-scope/non-goals, testable acceptance criteria, definition of done, risks) instead of resolving a plan-file path; a new `validate-pr-body-spec` CLI enforces the required sections fail-closed. The gate sequence is unchanged (draft → pre-approval fanout → detect-evidence → human merge); only the backing artifact differs. Closes the phase-history pollution from minting a numbered phase doc for a six-file rename chore.
- **`lightMode` wired to gate dispatch — micro-PRs skip full fanout (#1043).** A PR under the `localImplementation.lightMode` threshold (`maxFiles`/`maxLines`, and no `gate:full` label) collapses the `draft_gate` + `pre_approval_gate` fanout to a single inline correctness + no-op check and skips Copilot polling; the draft→ready boundary is still recorded (`requireDraftFirst`). Two escalation paths: the inline check auto-escalates to full fanout on any finding at or above `worth-fixing-now`, and the maintainer-controlled `gate:full` label forces full fanout regardless of size. Scope detection fails closed — a diff whose size can't be determined runs the full fanout, not the light path.
- **Context-builder additive angle authority (#1048).** Gate angle resolution was subtractive-only (it could drop configured angles but never add one). With `gates.<gate>.additiveAngles` enabled (default off), the resolver may now pull in a review lens the diff's change categories imply even when it isn't in the gate's configured pool — drawn from `gates.anglePool` (or the union of known personas). Bounded and auditable: `excludeAngles` stays a hard ceiling, `mandatoryAngles` the floor, and each addition is recorded with an `"added"` rationale naming the triggering category alongside the existing kept/dropped entries.
- **Grouped board-summary mode for `queue list` (#1061).** `dev-loops queue list --summary` (alias `--group-by status`) returns a whole-board digest grouped by Status column in board order (`{ ok, groups: { <status>: { count, items } } }`), with `--done-limit` to cap the Done group, composing with `--jq`/`--silent`. One sanctioned call for the by-status overview that previously tempted an inline `| python3` grouping (retro rule 5); `--summary` is mutually exclusive with `--column`/`--limit`.
- **Cross-harness regression contract (#1086).** dev-loops is harness-agnostic (Pi + Claude Code); the rule "any harness-specific change MUST NOT regress other harnesses" is now a repo-wide working rule in `AGENTS.md`, with the detailed procedure in `skills/docs/cross-harness-regression-contract.md` (what counts as a harness-specific change, the required Claude-Code-path suites, and the additive-on-Pi / no-op-or-validated-on-Claude bar). Promotes the constraint from an ad-hoc section of #1084 to a universal, pre-0.7 enforced contract.

### Changed

- **`LOGIC_CHANGE` maps to a core review subset instead of fallback-to-all (#1049).** `dynamicAngles` was effectively decorative for code PRs: any non-trivial code change classified as `ambiguous`, which fell back to the full 15-angle draft pool. `LOGIC_CHANGE` now maps to a core-for-code angle subset in `CATEGORY_ANGLE_MAP` and no longer forces `ambiguous`/fallback-to-all (reserved for genuinely unclassifiable diffs); other categories still union in their mapped angles. A typical code PR now resolves to a handful of angles, with peripheral lenses (link-check, config-drift, etc.) appearing only when the diff implicates their category. Mandatory floor and exclude ceiling unchanged; each dropped angle keeps its recorded rationale.
- **`--jq`/`--silent` are now a base-CLI guarantee for every JSON-emitting command (#1071).** The output contract was per-script opt-in, so JSON-emitting commands like `sync-item-status.mjs` rejected `--jq` and forced grep/inline-parse workarounds. Every JSON-emitting command now routes through the shared emit path (`scripts/lib/jq-output.mjs`), so `--jq` filtering, invalid-filter fail-closed (exit 2), and `--silent` exit-code-only come for free; a contract test enforces it so new commands inherit the guarantee and can't regress.
- **Sanctioned operation→wrapper command map baked into the default handoff (#1081).** Which dev-loops wrapper to use for which operation (reads: `view-pr`/`probe-ci-status`/`fetch-ci-logs`/`list-issues`/`probe-copilot-review`; edits: `edit-pr`/`comment-issue`; lifecycle: `ready-for-review`/`create-pr`/`request-copilot-review`/`reply-resolve`/gate-verdict) is now a structural part of every dev-loop handoff, sourced once (`scripts/loop/sanctioned-commands.mjs`, carried via the handoff envelope + `agents/dev-loop.agent.md`) instead of an ad-hoc per-handoff decision — so a spawned subagent no longer re-derives the map or rediscovers a wrapper mid-run.
- **Playwright configs consolidated into one projects-based, registry-driven config (#1056).** The five byte-identical `playwright.*.config.mjs` files (one per UI slice) collapse to a single `playwright.config.mjs` with one Playwright project per registered slice; `test:playwright:<slice>` scripts run `--project=<slice>`. Adding a UI artifact now needs only a registry entry (already required by the ui-e2e-scoping gate) and no new config file; per-slice output dirs / report paths are preserved.
- **Deduplicated the three Playwright smoke jobs via a composite action (#1058).** `viewer-smoke` / `deck-smoke` / `article-smoke` shared copy-pasted setup (checkout, Node, `npm ci`, WebKit cache-restore, `playwright install`). The shared setup moves into `.github/actions/playwright-webkit/action.yml`, keeping the three separate jobs and their zero-cost `changes`-gated skips (a matrix would have paid setup on skipped legs). `UI_E2E_CHECK_NAMES` and the scoping contract are unaffected.
- **Faster `test:scripts` real-git tests (#1129).** Real-git fixtures paid `git` fsync on every object write; `test:scripts` now runs with `core.fsync=none` (via `GIT_CONFIG_*`), cutting the process-spawn-heavy suite's wall time with no behavior change. The originally-proposed concurrent single-pool `verify` runner was reverted (oversubscription risk); the fsync disable is the kept win.

### Removed

- **`mermaid` is no longer a runtime dependency (#1089).** The inspect-run viewer serves the same pinned `mermaid.min.js` (11.15.0) from a vendored copy at `scripts/loop/inspect-run-viewer/vendor/mermaid.min.js` instead of resolving it out of `node_modules`; the `/assets/mermaid.min.js` route, `loadMermaidBrowserScript` seam, and offline behavior are unchanged. The vendored bundle is sha256-pinned by test (`test/loop/inspect-run-viewer-client.test.mjs`) and `test/extension-package-contract.test.mjs` guards that mermaid stays out of `dependencies`. Net install footprint shrinks (the mermaid transitive tree no longer installs); the npm tarball grows ~3.3MB unpacked from the vendored asset.
- **Breaking: dead-code deletion — ponytail audit batch 1 (#1088).** Removed unused public surface with no in-repo callers: the `@dev-loops/core` export subpaths `./debt/signals`, `./debt/score`, `./debt/finding`, and `./refinement/ac-dod-matrix` along with the now-dead `refinementContract` handoff-envelope plumbing (builder pass-through and validation in `handoff-envelope.mjs`) that only that module fed (the underlying `debt/score.mjs` module remains and is still tested internally); the `dev-loops-capture-deep-persona-signals` bin and the whole deep-persona-signals feature (`packages/core/src/debt/deep-persona-signals.mjs`); `createClaudeExtensionAdapter` (removed from the `@dev-loops/core/harness` exports along with its `claude-extension-adapter.mjs` module); the dead OUTER-graph tables `OUTER_STATE_TO_ROUTING_OUTCOME`, `OUTER_STATE_TO_OUTER_ACTION`, and `OUTER_NONTERMINAL_STATES` from `@dev-loops/core/loop/conductor-routing`; the config surface `localPlanning.plansDir` with its resolver `resolvePlansDir`, plus the unrelated dead config resolvers `resolveInternalPathPatterns` and `resolveMaxFanoutReviewers`; and the script CLIs `scripts/loop/conductor.mjs`, `scripts/loop/detect-stale-runner.mjs`, and `scripts/loop/pre-push-main-guard.mjs` (with their tests). Deprecation shim: a `localPlanning` block in a consumer `.devloops` file is still **accepted and ignored** (tolerated deprecated key in both zod schemas and `schemas/dev-loop-config.schema.json`) so existing configs keep parsing; remove the block at your convenience.

### Fixed

- **Guard `draft_gate` self-heal against unbounded recursion on a lagged draft-state read (#1020).** `upsert-checkpoint-verdict`'s `postDraftGateViaDraftTransition` converts a `ready` PR to draft, re-posts the verdict, then restores ready — but when GitHub's draft-state read lagged the conversion mutation, the re-entry saw the PR still non-draft and recursed, hanging with a swallowed Node exit 13. A `_draftTransitionInProgress` reentrancy guard now breaks the loop and fails closed with the complete manual recovery commands instead of hanging.
- **Resolve repo root and ledgers worktree-relative, not from `process.cwd()` (#1052; folds #1050 and #1019).** Under the normal per-PR-worktree flow the session cwd and the PR worktree are different checkouts, so cwd-bound reads/writes silently diverged: the pre-merge fan-out ledger check bound to the session cwd and false-blocked clean gates (#1050, hit PR #1044 twice), and `detect-copilot-loop-state` read `maxCopilotRounds` from cwd instead of the worktree `.devloops` (#1019). A shared worktree resolver now derives the repo root from the PR worktree, so the ledger writer and the pre-merge check bind to the same checkout regardless of session cwd. Pure resolution-location correctness — no gate semantics or ledger-format change.
- **Block subagent-initiated external writes (#1051).** Gate/dev-loop subagents had twice minted unauthorized, user-attributed GitHub issues. The bash gate now blocks raw `gh issue create` / `gh issue comment` / `gh pr comment` on the target repo when initiated by a subagent and directs to the sanctioned path; contract-driven writes (gate-verdict comments, reply-resolve review threads, board sync) and the main-agent/operator path are unaffected. Unit + e2e coverage mirrors the `gh pr create` guard.
- **Wrapped `gh pr view` / `gh pr edit` so the retro internal-tooling gate is satisfiable (#1057).** The remaining PR-facing reads/edits a real gate run needs had no dev-loops wrapper, so an honest run could never record a clean retrospective (`internalToolingOnly: true`, `rawCallViolations: []`) and the gate blocked code-clean PRs by waiver. New `scripts/github/view-pr.mjs` and `edit-pr.mjs` (jq-output-backed, `--jq`/`--silent`) complete the coverage; `workflow.requireRetrospectiveInternalTooling` is re-armed to `true`.
- **Tick verified PR-body acceptance-criteria checkboxes after a clean `pre_approval_gate` (#1066).** The gate verified acceptance criteria but only updated the linked issue, so merged PRs displayed all `- [ ]` unchecked, reading as never-confirmed. A single `gh pr edit --body-file` now flips `- [ ]` → `- [x]` for the items the gate positively confirmed (CRLF preserved, duplicate flipped labels de-duped, only verified items ticked, unconfirmable items left unchecked — fail closed).
- **Key fresh-context review sentinels per head SHA / round (#1095).** On a `draft_gate` retry after a fix commit, the round-1 per-(cwd, scope) sentinel from `verify-fresh-review-context.mjs` was still present, so every retry reviewer failed closed and burned a launch to refuse. The sentinel key now includes the head-SHA round component, so a new head never collides with a prior round while same-round re-entry still fails closed; the orchestrator-owned sentinel lifecycle is documented in the gate-review sub-loop contract.
- **Honor `queue.statusColumns.next_up` across ordering, pickup, and the projects layer (#1098).** Board-sync resolved logical columns through the `statusColumns` display-name overrides, but the ordering (`resolveNextUpOrder`) and `add-queue-item --next-up` hardcoded the literal `"Next Up"`, so a repo that renamed its Next Up column got a driver querying a non-existent column (fail-closed board-query-error or empty). The logical→display resolution now flows through the same shared mapping everywhere; board-sync stays the single source of the mapping.
- **Auto-release the runner-coordination lock on run completion + document stale-lock takeover (#1109, Seam 1 of #1084).** A completed/stopped run left its `.pi/runner-coordination/<repo>/pr-<n>.json` claim in place, so a merge-authorized re-dispatch (fresh run id) was refused `ownership_lost` — hit live on a 9.5h-old stale lock. A run now releases its coordination claims on completion/stop, and the stale-lock takeover path is documented; a genuinely active (non-stale) claim still fails closed.
- **Record fan-out provenance + opt-in enforcement, route-to-conductor (#1110, Seam 2 dev-loops of #1084).** `requireFanoutEvidence` was artifact-only, so a single agent could self-produce every per-angle review plus a ledger and label the verdict `fanout_fanin`. The findings-log ledger now records fan-out provenance (distinct reviewer count and dispatch provenance), and an opt-in `requireFanoutProvenance` check in `buildPreMergeGateCheck` re-validates it (internally consistent across checkouts, `distinctReviewers >=` the floor) to distinguish real parallel review from single-agent artifacts; a child that cannot fan out fails closed with a route-to-conductor message.
- **Fix `tools:` frontmatter so pi provisions the subagent tool at child depth (#1111, Seam 2 core of #1084).** pi-subagents parses the `tools:` frontmatter line with a naive comma split (no real YAML), so the flow-sequence form `tools: [read, …, subagent]` read `subagent]` (bracket attached) and never matched — child fan-out was impossible on pi. The `tools:` lines in `agents/*.agent.md` and `.pi/agents/*.agent.md` drop the brackets for the comma-scalar form; the generated `.claude/` mirror regenerates byte-identical (`normalizeToolList` already splits on commas), so the fix is additive on pi and a no-op on Claude Code (governed by the #1086 cross-harness contract).
- **Make `copilot-pr-handoff` and gate-coordination agree past the Copilot round cap (#1126).** Round-cap enforcement lived only in `copilot-pr-handoff.mjs`, so after `maxCopilotRounds` was reached `detect-pr-gate-coordination-state.mjs` still surfaced `rerequest_copilot_review` — advertising a re-request the handoff would refuse. Both now defer to a shared round-cap + significant-change detector, so they agree at the cap boundary.
- **Treat body-mention cross-references as non-owning board links (#1130, #1124).** `detect-linked-issue-pr` accepted a bare `CrossReferencedEvent` as an issue's linked open ready PR, but that event fires on any body mention (`Seam 1 of #1084`, `part of #X`), so a sub-issue-tree PR dragged every mentioned sibling into In Progress and jammed `resolve-active-board-item` with "multiple in-progress". An owning link now requires `willCloseTarget: true` (or a `ConnectedEvent`); a direct `gatherLiveFacts` open-issue→linked-ready-PR unit test (#1124) covers the reconcile bridge.
- **Run gate fan-out reviewers in the PR's actual worktree/head (#1135).** Reviewers dispatched with worktree isolation got a fresh checkout of stale `main` and no access to the gitignored `tmp/gate-context` bundle, so a reviewer could silently review the wrong tree without the seeded context. The read-only per-angle review dispatch now runs in the PR worktree/head with the seeded gate-context bundle (a worktree-locality guard rejects an out-of-tree `--context-path`), honoring the build-once/seed-many contract.
- **`write-gate-context --base` builds the diff + adjacentCode bundle on the CLI path (#1140).** The library built the "build once, seed many" bundle only when handed a diff; the CLI exposed no diff flag, so every CLI-driven gate wrote a thin briefing (`scope.diffPath: null`, empty `changedFiles`, no `adjacentCode`) and each of N reviewers re-ran the same `git diff`. A new `--base <ref>` flag captures the full diff plus the per-file 1-hop adjacent-code bundle once (`scope.diffSource="base"`), so every reviewer is seeded with the identical neutral bundle; without it the CLI still emits an explicit thin briefing.
- **Fix `manage-sub-issues reorder` always failing (#1160).** Reorder seeded its cursor with `after_id=0`, but GitHub's priority endpoint rejects `0` with an empty-body HTTP 500, which `gh` surfaced as `unexpected end of JSON input` — the loop aborted on the first call so no reorder ever succeeded. The first item now uses `before_id=<current head>` (or skips when it is already first) and chains `after_id` after; `ghApi` also surfaces the HTTP status on empty-body non-2xx responses.
- **Eliminated the version-skew class: pinned consumer-facing CLI invocations + release-time core-version assertion (#1036, root cause of #1033).** Every consumer-facing CLI example now pins `npx dev-loops@<version>` (single source: the package version) instead of a bare `npx dev-loops` that resolves a possibly-stale global/cached copy: `README.md`, `docs/migrating-to-dev-loops.md`, and the `cli/index.mjs` help/setup strings. Global `npm install -g dev-loops` is reframed as an optional, drift-prone shell convenience — explicitly not the supported invocation path. A new fail-closed contract test (`test/contracts/cli-invocation-contract.test.mjs`) bans bare `npx dev-loops` across the README/docs/CLI surface. The Pi extension already resolves the CLI and `@dev-loops/core` from the installed pinned package (module imports, updated via `pi update git:...`), never a global install; `extension/README.md` now states that lockstep contract. Root-cause fix for the mis-published-bundle failure mode: `scripts/release/assert-core-dependency-version.mjs` (node-builtins-only) fails the release when root `dev-loops`'s `@dev-loops/core` dependency major.minor differs from the version being released, wired as a step in `.github/workflows/release.yml` before the GitHub Release is created; a unit test (`test/docs/assert-core-dependency-version.test.mjs`) rejects the #1033-shaped bad manifest (`0.6.2` / `^0.2.6`) and guards the real manifest. Non-goals (YAGNI): no per-startup runtime skew watcher, no auto `npm i -g` on update.

- **Bash gate now blocks raw `gh pr create`, closing the draft-first hole.** The PreToolUse bash gate already blocked `gh pr ready`/`gh pr merge` without evidence, but nothing guarded PR *creation* — so raw `gh pr create` (which defaults to ready-for-review) silently bypassed the `workflow.requireDraftFirst` contract. It now denies raw `gh pr create` on the target repo and directs callers to the canonical wrapper `scripts/github/create-pr.mjs` (`dev-loops pr create`), which always drafts and self-assigns. The wrapper runs `gh pr create` inside a node child process, so its command is unaffected; an explicit non-target `--repo` and non-target-repo cwd pass through. The create gate now denies an explicit `--repo` targeting the repo regardless of cwd (#1047) — raw `gh pr create --repo <target>` run from outside the repo previously slipped through. New `commandContainsGhPrCreate`/`extractRepoFlagFromGhPrCreateAnywhere` in `packages/core/src/loop/bash-command-classify.mjs`, a create branch in `decideBashGate` (`packages/core/src/claude/hook-decisions.mjs`), and detection wiring in `.claude/hooks/pre-tool-use-bash-gate.mjs`; unit + e2e coverage added. The `gh pr <verb>` matcher now also treats newlines/`\r` as segment separators and normalizes a leading env-assignment (`GH_TOKEN=x`), `command`/`env`/`exec` wrapper, or absolute/relative gh path before the `^gh` test — closing the same bypass gap identically for `gh pr ready`/`gh pr merge` (subshell/group forms remain out of scope). The create gate no longer short-circuits ready/merge gating in compound commands: an out-of-scope create (e.g. `gh pr create --repo other/repo && gh pr merge 5`) now falls through so the gated ready/merge segment is still evaluated instead of being allowed early. The create gate now evaluates each create segment's scope so a leading out-of-scope create can't shield a later in-scope raw create.

- **`strategy.default: local-first` now respected end to end as the built-in code default (#1033).** `BUILT_IN_DEFAULT_TARGET_PREFERENCE` and `BUILT_IN_DEFAULTS.strategy.default` changed from `github-first` to `local-first` in `packages/core/src/loop/public-dev-loop-routing.mjs` and `packages/core/src/config/config.mjs`. The `resolveTargetPreference` fallback in `scripts/loop/resolve-dev-loop-startup.mjs` (line 267) and the config-load-error fallback (line 675) were also changed from `prefer_github_first` to `prefer_local`, so configless repos now resolve to local-first through the CLI path too. The 32 tests that previously relied on the implicit github-first default were updated to pin `PREFER_GITHUB_FIRST` explicitly; one config test assertion and one routing-config test updated to match the new built-in default. Docs updated: `targetPreference` table in `skills/docs/public-dev-loop-contract.md` now shows `prefer_local (default)`; `BUILT_IN_DEFAULTS` description in `skills/docs/artifact-authority-contract.md` updated to `local-first`.

## 0.6.2 - 2026-06-30

### Changed

- **Terminology pass: 'named human/person' → 'contributor' across all doc surfaces (#1026).** Articles (`docs/articles/dev-loops-deep-dive.{md,html}`, `introducing-dev-loops.html`), decks (`docs/presentations/dev-loops-deep-dive.html`, `introducing-dev-loops.html`, `applied-dev-loops-presentation.md`), `skills/docs/merge-preconditions.md`, `schemas/dev-loop-config.schema.json`, and `packages/core/src/config/config.mjs` docstring. Deep-dive article subtitle tightened; attention-bottleneck section rewritten for clarity. `.md`/`.html` pair synced (`fast enough to be routine`).

### Fixed

- **`deriveUiE2ePassed` blocked the gate on SKIPPED UI e2e checks (#1026).** `viewer-smoke` is `SKIPPED` (not `FAILURE`) on PRs that don't touch viewer files — it means "not applicable to this run." The function now treats `SKIPPED` the same as `SUCCESS` so a skipped check no longer incorrectly blocks `run_pre_approval_gate`. Test coverage added.

- **`plugin.json` was missing from the v0.6.1 version bump (#1026).** `.claude/.claude-plugin/plugin.json` was still at `0.6.0`; bumped to `0.6.1` (now `0.6.2` in this release). Stale generated `.claude/` assets regenerated.

## 0.6.1 - 2026-06-30

### Changed

- **Docs: refresh intro article + deck to the 0.6.0 command surface + current velocity snapshot (#1015, #1018).** `/dev-loops:continue` now shows both forms — `/dev-loops:continue 112` (issue or PR) and bare `/dev-loops:continue` (resumes the single in-progress board item, fail-closed on 0/multiple). Adds `/dev-loops:start-spike "…"` to the command list in both the intro article (`docs/articles/introducing-dev-loops.{md,html}`) and the intro deck (`docs/presentations/introducing-dev-loops.html`). The Pi command set in the catch-all router note now lists `start-spike`. The velocity snapshot paragraph is updated from the previous "two-week, ~100 PR" estimate to a verified four-day snapshot (87 PRs, ~22/day, v0.4.0 + v0.5.0).

- **Deep-dive deck: desktop responsiveness, mandatory snap, keyboard nav (#1021, #1022).** Desktop layout adjusts for wider viewports. Section scroll-snap is enforced. Keyboard navigation is enabled.

- **Namespace Claude Code slash commands with `loop-` prefix to avoid collisions (#1023).** The 6 dev-loop commands (`.claude/commands/`) are now `/loop-start`, `/loop-auto`, `/loop-continue`, `/loop-start-spike`, `/loop-info`, `/loop-status` — renamed from bare `/start` etc. which collided with Claude Code built-ins (notably `/status`). Source files renamed from `commands/*.command.md` to `commands/loop-*.command.md`; generated assets updated; no-drift test updated.

### Fixed

- **`release.yml` auto-Release never ran — the CHANGELOG extractor pulled in `@dev-loops/core` (#1016, regression in #996's first run).** `scripts/release/extract-changelog-section.mjs` imported `isDirectCliRun` from `scripts/_core-helpers.mjs`, which re-exports from the `@dev-loops/core` workspace package. `release.yml` runs the extractor with **no `npm ci`** (a text parser needs no install), so on a `v*` tag push the import `ERR_MODULE_NOT_FOUND`ed before any notes were extracted and the GitHub Release was never created (the same hands-off-publish gap #996 set out to close). The extractor now imports **only `node:` builtins** — `isDirectCliRun` is inlined (`node:fs` `realpathSync` + `node:url` `fileURLToPath`), all behavior unchanged (`--version`/`--changelog`, leading-`v` strip, heading lookahead so `0.5.1` ≠ `0.5.10`, stop-at-next-`## `, no Unreleased bleed, exit 1 absent/empty, exit 2 arg/file error, the `extractChangelogSection()` export). A new guard test parses the script's `import` statements and asserts every specifier is `node:`-prefixed, so a future workspace import can't silently reintroduce the deps-free break. `release.yml` is unchanged — the standalone script is the fix.

- **Close `gh pr merge` bypass hole in the PreToolUse gate (#1024).** A hand-run `gh pr merge` could skip the pre-approval gate entirely because the PreToolUse Bash hook only blocked `gh pr ready`. The hook now also blocks `gh pr merge` unless `detect-checkpoint-evidence.mjs` confirms clean current-head `draft_gate` + `pre_approval_gate` evidence (fanout_fanin, no inline verdicts). Two compound-command bypass paths are also closed: (1) the all-segments scanner (`commandContainsGhPrReady`/`commandContainsGhPrMerge`) catches gated verbs in any shell segment, not just the first; (2) `gh pr ready N && gh pr merge N` no longer short-circuits to ready-only — both verbs are detected independently and the stricter merge gate is applied. Pi extension public API (`isGhPrReadyCommand`, extractors) retains first-segment-only semantics (correct: `false && gh pr ready 42` short-circuits so ready never ran).

## 0.6.0 - 2026-06-29

### Added

- **`/start-spike` — first-class command to start a spike from a question (#988, P2; closes #988).** `/dev-loops:start-spike <question>` (and the Pi `/dev-loops start-spike` subcommand) starts a time-boxed dev-loop spike, a thin wrapper over the already-shipped `--spike` intake (#964/#965/#966). Inline free-text scaffolds a startable findings artifact and `--file <path>` uses a pre-authored one; both then run `resolve-dev-loop-startup.mjs --spike <path>` and hand the bundle to the `dev-loop` skill (gates.spike, exit via `exit-spike.mjs` — discard/graduate per `skills/docs/spike-mode-contract.md`). Because the argument is free text/a path rather than a number, `start-spike` is parsed on a **separate path** from the numeric verbs (start/auto/continue/info), keeping their numeric-validation invariant intact. The one new piece is `scripts/refine/scaffold-spike-file.mjs --question <text> --out <path>`, a pure section builder + file writer that fills `## Question` from the arg and stubs `## Approach`/`## Findings` (leaving `## Recommendation` for the spike) so the result passes `validateSpikeExplorationSections` and is immediately startable (JSON `{ ok, path, question }` + `--jq`/`--silent` via the shared `scripts/lib/jq-output.mjs`). No new spike behavior, gate, or format. `node:test` coverage for the scaffold (startable/in-progress, empty-question fail-closed, CLI write + end-to-end startability), the separate free-text/`--file` parse path (numeric verbs unaffected), and the generated-command presence + no-strategy-leak guard. Also widened the Pi command-palette `description` string's `continue <pr>` to `continue [issue|pr]` for consistency with the P1 surface widening.

- **Path-triggered UI e2e auto-scoping gate criterion (#976, P2; replaces opt-in-by-annotation).** A PR that adds or modifies a *rendered* HTML artifact now MUST run the shared UI e2e assertions (mobile + desktop) AND register that artifact in the suite — inclusion is triggered by the changed-file set, never by a human annotating the PR/phase doc. The deterministic core is `packages/core/src/loop/ui-e2e-scoping.mjs` (`evaluateUiE2eScoping`): explicit, conservative path globs (`docs/articles/*.html`, `docs/presentations/*.html`, single-segment, plus the viewer source `scripts/loop/inspect-run-viewer.mjs`) classify changed paths into rendered-artifact descriptors and check each against the registry-membership list keyed by full repo-relative path (`REGISTERED_ARTIFACT_PATHS` mirrors `DECK_REGISTRY` at `docs/presentations/<deck>` + `ARTICLE_REGISTRY` at `docs/articles/<file>`; `VIEWER_ARTIFACT_ID` mirrors `VIEWER_REGISTRY`, kept in sync by hand — the deck/article sync test asserts only that `REGISTERED_ARTIFACT_PATHS` matches `DECK_REGISTRY` + `ARTICLE_REGISTRY`, since the viewer harness pulls `@playwright/test` into core). Full-path keying (not basename) means a deck and an article that share a basename are distinct artifacts. Each artifact family has a stable, path-conditioned CI job (`viewer-smoke`/`deck-smoke`/`article-smoke`) named to match `UI_E2E_CHECK_NAMES`, so a deck- or article-only PR has a satisfiable signal. It is wired as a gate precondition in `evaluatePrGateCoordination` (`packages/core/src/loop/pr-gate-coordination.mjs`), at a seam distinct from the mergeability (#980) and retrospective (#982) preconditions, and **fails closed**: a rendered-artifact change that is unregistered, or registered but whose UI e2e suite has not passed for this head, blocks with `nextAction: run_ui_e2e_suite` and a reason naming the artifact (and that it needs registration / the suite must pass). Non-UI changes pass through (`required: false`). The detect layer reads the changed-file set from `gh pr view --json files` (`extractChangedFiles`) and derives `uiE2ePassed` from the `statusCheckRollup` UI e2e check(s) (`deriveUiE2ePassed` / `UI_E2E_CHECK_NAMES`); a UI e2e check that is absent reads as unknown and fails closed. Standard-step doc: [ui-e2e-scoping-step](skills/docs/ui-e2e-scoping-step.md). `node:test` coverage: helper trigger/negative/fail-closed (unregistered + not-passed) cases + registry sync, gate-level integration (required-pass, non-UI, both fail-closed paths), and detect-helper unit tests.

- **`/continue` dual-routing — bare resumes the current in-progress board item (#988, P1).** `/dev-loops:continue` (and the Pi `/dev-loops continue` subcommand) is now dual-routed: with an argument it continues that artifact (the `continue` verb widened from PR-only to `either`, so `123`/`#123`/a GitHub issue-or-PR URL all normalize and the resolver picks the canonical artifact); bare (no argument) it picks up the single in-progress board item. The bare path adds one new thin helper, `scripts/projects/resolve-active-board-item.mjs --repo <owner/name> --project <number|id>`, a pure list→single-target collapse over `list-queue-items.mjs --column "In Progress"` (JSON `{ ok, target: { kind, number } }` + `--jq`/`--silent` via the shared `scripts/lib/jq-output.mjs`): exactly one in-progress item → that target (prefers the linked PR over the issue); **zero or more than one → fails closed** (exit 3) with a reason naming the items and instructing `/continue #N` — it never guesses. No new routing logic: both `/continue` branches hand the resolved target/intent to the existing `dev-loop` skill (`loop startup` → build-envelope → route), still stopping at the human-approval checkpoint. `/dev-loops:dev-loop` remains the catch-all router. `node:test` coverage for the resolver (1/0/multiple), the widened verb + bare/`#`/URL arg normalize, and the no-strategy-leak guard.

- Direct dev-loop slash commands as thin wrappers over the public contract (#972): `/dev-loops:start <issue>`, `/dev-loops:auto <issue>`, `/dev-loops:continue [issue|pr]`, `/dev-loops:info <issue|pr>`, and `/dev-loops:status` for the Claude Code plugin (generated under `.claude/commands/` from `commands/*.command.md`), with equivalent `/dev-loops start|auto|continue|info|status` subcommands in the Pi extension (`status` already existed; the start/auto/continue/info entrypoint subcommands are the new parity). `/dev-loops:dev-loop` remains the catch-all router; no new routing logic was added.

- **Wrapped the last three agent-level `gh` reads + re-armed the #982 internal-tooling discipline (#993).** Three reads the loop still shelled out for now have thin dev-loops wrappers, following the #981 convention (JSON result + `--jq`/`--silent` via the shared `scripts/lib/jq-output.mjs` helper — `JQ_OUTPUT_PARSE_OPTIONS`/`JQ_OUTPUT_USAGE`/`emitResult`, so the jq-subset filter, fail-closed-on-invalid-filter exit `2`, and exit-code-only `--silent` behavior are reused, not reimplemented): `scripts/github/fetch-ci-logs.mjs --repo --pr [--failed-only] [--tail <n>]` resolves the PR's current head SHA, lists the Actions runs for that commit, and returns each run's log tail (`--log-failed` for a failed run, `--log` otherwise; a per-run log fetch failure records an `<log unavailable: …>` note instead of aborting) — the LOG complement to `probe-ci-status.mjs`, which names the failed checks; `scripts/github/list-issues.mjs --repo [--state open|closed|all] [--label <l>…] [--limit <n>]` returns `{ ok, issues: [{ number, title, state, labels }] }` (state lowercased, labels flattened) for arbitrary issue queries the queue/board tool doesn't cover; `scripts/github/comment-issue.mjs --repo --issue (--body <text> | --body-file <path>)` posts a comment via `gh issue comment` and returns `{ ok, commentUrl }` (sibling to the PR comment/verdict posters). Each is a thin wrapper over `gh` (the script calling gh internally IS the allowed tooling). With these in place, every agent-level raw `gh` read now has a wrapper, so a fully clean internal-tooling record is achievable — the repo-root `.devloops` flips `workflow.requireRetrospectiveInternalTooling` back to `true` (it was set false in #995 pending this), re-arming the hard retro check (#982) on our own retros; the developer-mode rationale comment is restored to the enforce-on posture. The token-economical reading convention in `skills/dev-loop/SKILL.md` (+ generated `.claude/` mirror) names the three wrappers as THE path — never raw `gh run view`/`gh issue list`/`gh issue comment`. `node:test` coverage per wrapper (gh stubbed via the injected `run`): arg parsing/validation, the success path (failing-job log tail for a red PR, filtered issues, comment URL returned), and the shared output flags exercised through `runCli` — `--jq` extraction, invalid-filter fail-closed exit `2`, `--silent` exit-code-only, and gh-failure exit `1`.

- **Automated GitHub Release on tag push (#996).** Pushing a `v*` tag now creates the GitHub Release automatically, which fires `npm-publish.yml` — tag → Release → publish is hands-off (closing the gap where v0.5.0 was tagged but never published because no Release was created). New `.github/workflows/release.yml` triggers `on: push: tags: ['v*']`: it mirrors npm-publish's on-main guard (tag commit must be an ancestor of `origin/main`), extracts the release notes from the `## <version>` CHANGELOG.md section, is idempotent (no-op if a Release for the tag already exists), and fails closed if the version has no CHANGELOG section (no empty/auto-generated release for an undocumented version). Notes extraction is factored into `scripts/release/extract-changelog-section.mjs` (`--version <v> [--changelog <path>]`) with `node:test` coverage: present section, fail-closed absent version, the `## Unreleased`-above-latest layout, stop-at-next-`## ` boundary, and version-prefix disambiguation. `npm-publish.yml` is unchanged (still `on: release: published`). Tagging stays the only manual step — see [release-runbook](skills/docs/release-runbook.md).

### Changed

- **Reconciled the UI-validation docs to the auto-scoped model and added a worked example (#977, docs-only; UI-e2e epic UE3, follows #975/#976).** The four `docs/ui-*.md` docs no longer describe UI validation as an opt-in-by-annotation convention. `docs/ui-validation-contract.md` now leads with the path-triggered, registry-backed, fail-closed criterion, lists the shared harness assertions (section visibility, CSP-meta lock, 390px mobile fit, wide-element negative control), and carries a worked end-to-end example for the intro deck (`docs/presentations/introducing-dev-loops.html`): `DECK_REGISTRY["intro-deck"]` + thin `defineDeckSuite` spec → `REGISTERED_ARTIFACT_PATHS` membership → `ui_e2e_scoping` gate requirement (fails closed if unregistered) → the satisfiable `deck-smoke` CI signal (`UI_E2E_CHECK_NAMES`). `docs/ui-smoke-harness.md` reframes `webkit-smoke-harness.mjs` as the lower WebKit/config/capture seam the shared deck/article/viewer suites build on and drops the stale "not mandatory CI" claim; `docs/ui-artifact-contract.md` renames CI promotion to auto-scoped CI enforcement; `docs/ui-designer-review-loop.md` notes it is the optional design-review pass, distinct from the required gate. All four now cross-link the canonical owner `skills/docs/ui-e2e-scoping-step.md` and the shared harness. No behavior/harness/gate change. Closes #939 (its slide responsive-fit goal is delivered by the #975 mobile-fit harness).

- **Taught the direct subcommand structure across the how-to surfaces (#1001, follows #972).** The intro article (`docs/articles/introducing-dev-loops.{md,html}`) and the intro deck setup slide (`docs/presentations/introducing-dev-loops.html`) now show the direct named commands from #972 (`/dev-loops:start`, `:auto`, `:continue`, `:info`, `:status`), framing `/dev-loops:dev-loop` as the catch-all router for plain-language intent (with the Pi `/dev-loops start|auto|continue|info|status …` parity noted on both surfaces — the article spells out the full set, the deck setup slide flags that Pi drops the colon). The deck slide stays within the 390px mobile-fit harness (#975); section ids unchanged. Conceptual/routing-contract prose (deep-dive article + deck, public-dev-loop-contract, main-agent-contract) was left untouched.

- **Per-artifact Playwright deck assertions extracted into a shared harness + registry (#975, generalizes #939).** The two presentation-deck specs (`test/playwright/intro-deck.spec.mjs`, `test/playwright/deep-dive-deck.spec.mjs`) were ~190 lines of near-identical assertions each (same server, same three tests, only the deck path / section ids / mobile-capture id differed). The reusable assertions now live once in `test/playwright/harness/deck-fit-harness.mjs`: the single-file deck server, the `networkidle`+`innerWidth===390`+`document.fonts.ready` layout-settle barrier, the per-element `getBoundingClientRect().right <= innerWidth + 1` mobile-fit check (no overflow-x scroller exemption) with the defensive `scrollingElement.scrollWidth` guard and the per-section vertical-clip check (`clientHeight < scrollHeight` while `overflow-y` is hidden/clip), section-id presence + no-horizontal-scroll, a CSP-`<meta>` guard (`default-src 'none'`), and the guard-the-guard test (a deliberately-wide element MUST fail the fit check). A `DECK_REGISTRY` holds each deck as data (`{ sliceId, deck, sectionIds, mobileCapture }`) and `defineDeckSuite(entry)` generates the full per-deck suite; the two deck specs shrink to thin registrations that resolve the deck path and call `defineDeckSuite`. The inspect-run viewer (an interactive dashboard, not a fit-checked deck) registers via `test/playwright/harness/inspect-run-viewer-harness.mjs` (`startViewer`/`openTab`/`waitForMermaidGraph`/`captureViewerState`/`VIEWER_REGISTRY`), removing the duplicated server-setup + repeated capture blocks from its spec, and now runs the shared `assertSectionIdsAndNoHorizontalScroll` over its registered panel ids. The per-artifact `playwright.*.config.mjs` files and `test:playwright:*` scripts are unchanged (each config's `testMatch` still scopes which registration runs). All existing assertions still pass — `intro-deck` 3/3, `deep-dive-deck` 3/3, `inspect-run-viewer` 7/7 green.

### Fixed

- **Robust, bounded `<dev-loops-package-root>` resolution for user-level installs (#1009).** The `dev-loop` agent prompt (`agents/dev-loop.agent.md`) and the `dev-loop` skill (`skills/dev-loop/SKILL.md`) previously told the Pi runtime to resolve the package-local CLI by assuming a single fixed `../..` (agent) / `../../..` (skill) package-relative layout. Under a Pi user-level install the agent file is synced to `~/.agents/` and the package itself lives at `~/.pi/agent/npm/node_modules/dev-loops/` (skills are exposed via `package.json` `pi.skills`, loaded from that package location, not copied into `~/.agents/`), so the fixed relative guess missed the package root, the CLI could not be found, and the agent improvised an unbounded `find / -name dev-loop.agent.md` full-filesystem walk that stalled 60s+ and tripped the async needs-attention timeout. The resolution prose now lists an ordered set of **bounded** candidate roots — a best-effort `require.resolve('dev-loops/cli/index.mjs')` probe (cwd-dependent; reliable only when `dev-loops` is on Node's module search path; wrapped in try/catch so a miss exits non-zero silently instead of printing a stack trace that reads as a hard failure), the Pi user-agent npm root (`~/.pi/agent/npm/node_modules/dev-loops`, the reliable path for user-level installs), the legacy package-relative path, and the global npm root — taking the first whose `cli/index.mjs` exists. The prose now explicitly **forbids** `find /` / any unbounded filesystem walk and instructs the agent to stop and ask the orchestrator/operator for the path when every bounded candidate fails. Pi-only prose, stripped from the generated `.claude/` mirrors (which already pin `npx dev-loops@<version>` and rely on Node resolution); `.claude/` regenerated and in sync; cli-invocation contract test green.

- **Restored `PI_SUBAGENT_RUN_ID` as a recognized async-context run-id alias — the Pi async-start gate no longer fails closed (#1008, regression from #905).** The Pi runtime injects only `PI_SUBAGENT_RUN_ID` (never the neutral `DEVLOOPS_RUN_ID`) into each async-subagent child env, but #905 dropped that alias from `RUN_ID_MARKERS` as a "deliberate breaking change", leaving the sole marker absent under Pi. With `workflow.asyncStartMode: "required"` this made `validateAsyncStartContext` return `rejected` at startup step 1, so no dev-loop work could start under Pi. `RUN_ID_MARKERS` in `packages/core/src/loop/run-context.mjs` is now `["DEVLOOPS_RUN_ID", "PI_SUBAGENT_RUN_ID"]` — the neutral var stays the primary (and the only var dev-loops mints/propagates), with the Pi-injected name honored as a precedence-after alias. The alias threads through everywhere the primary does (`resolveRunId`/`ensureRunId` loop the markers; `ASYNC_CONTEXT_MARKERS` re-exports them, so the async-start gate recognizes it), and the rejection message names both again ("Set DEVLOOPS_RUN_ID (or the PI_SUBAGENT_RUN_ID alias) to proceed", matching #830). `PI_SUBAGENT_RUN_ID` is an externally-injected Pi-runtime contract var (not a dev-loops-owned `PI_*` name — #905's rename of owned vars to `DEVLOOPS_*` stands), so the harness-agnostic env guard (`test/contracts/cli-harness-agnostic.test.mjs`) allowlists it as a runtime-injected marker confined to the run-context/async-start modules + their tests (and the generated `.claude` mirror). `node:test`: Pi-only-marker recognition under `required` (the regression reproduction), neutral-primary precedence, and the both-markers rejection message.

## 0.5.0 - 2026-06-28

### Added

- **Conflict-free (mergeable) is a required gate precondition + deterministic auto-resolve (#980).** A PR that conflicts with its base gets no `pull_request` CI run (GitHub can't compute the merge ref), so the gate silently stalls green-less. Mergeability is now a required precondition checked at every gate (draft + pre-approval) and again before merge: the detect layer fetches `gh pr view --json mergeable,mergeStateStatus` and the evaluator (`evaluatePrGateCoordination`) blocks a `CONFLICTING`/`DIRTY`/`BEHIND` PR (`gateBoundary: conflict_resolution`, `nextAction: resolve_merge_conflicts`). Because GitHub computes `mergeable` asynchronously, a freshly-pushed head briefly reads `UNKNOWN`; `fetchPrFactsWithSettledMergeable` re-polls a bounded number of times and, if it never settles, the gate fails closed to a recheck (`nextAction: wait_for_ci`) — an unsettled merge state is never treated as clean. A new conservative helper `scripts/loop/resolve-pr-conflicts.mjs` merges `origin/<base>` into the PR branch and resolves ONLY the safe additive case — a `CHANGELOG.md` conflict where both sides only ADD list/section entries (keep BOTH sides, in order) — then runs `npm run test:docs` and (with `--push`) pushes; ANY other conflicted path, or a non-additive CHANGELOG edit, FAILS CLOSED naming the conflicted paths (no general conflict-resolution engine). `loop info` surfaces a `Mergeable:` line (mergeStateStatus included) so a conflict is diagnosed immediately rather than mistaken for missing CI. `node:test` coverage: CONFLICTING/UNKNOWN/MERGEABLE evaluator cases, the bounded UNKNOWN re-poll, and real-git fixtures for the additive-CHANGELOG resolve, the non-CHANGELOG fail-closed (path named), the non-additive-CHANGELOG fail-closed, and a clean merge. Docs: [merge-preconditions](skills/docs/merge-preconditions.md) documents the conflict-check-and-resolve step (before CI/Copilot and before merge).

### Changed

- **Pages decks auto-deploy on push to `main`** (#941, closes #940; supersedes the operator-gated deploy from #930). Pages is enabled (Source: GitHub Actions), so the deploy job no longer needs the `workflow_dispatch` gate `#930` shipped while Pages was off. `.github/workflows/pages.yml` now scopes the deploy job to the main ref (`if: github.ref == 'refs/heads/main'`) instead of `if: github.event_name == 'workflow_dispatch'`, so a merge to `main` publishes the assembled `site/` automatically. A manual `workflow_dispatch` still deploys, but only when run against `main`; dispatching any other branch/tag builds the artifact and skips deploy. The workflow header comment, the deploy-job inline comment, and `docs/presentations/README.md` were updated to describe auto-deploy on merge in place of the one-time operator "Run workflow" step.
- **Public materials reframed around the pull principle** (#991). The handoff-framing passages across the four public pieces now lead with the Kanban-style insight that the next step is always known: the authoritative resolver/state graph computes the next action for any change at any time, `loop info` surfaces it, and the board mirrors it, so whoever is free — agent or human — pulls the next bounded step from the visible state. The handoff becomes optional, at most additive: when one happens it adds a note and stays a recorded decision, and it is no longer a load-bearing blocking step, which is what dissolves the waiting-for-handoff. Edited in sync (`.md` + `.html`): the intro article's lede, "Model-agnostic by construction", and "Where to go deeper" pointer (`docs/articles/introducing-dev-loops.md`/`.html`); the deep-dive article's Part 1 — the two-part intro, "The one idea" (retitled "the next step is always known"), "What a known next step buys you", "Why a state graph beats a prompt" (recast as the thing that makes the next step always-known and pullable), and the close (`docs/articles/dev-loops-deep-dive.md`/`.html`); the intro deck's hero, the idea slide (retitled "The Next Step Is Always Known"), the model-agnostic slide, and the companion-pieces note (`docs/presentations/introducing-dev-loops.html`); and the deep-dive deck's hero, core-idea slide (retitled "The Next Step Is Always Known"), why-graphs slide, and close (`docs/presentations/dev-loops-deep-dive.html`). The "still a recorded decision when it happens" point is kept, now subordinate to the pull framing. Diagrams and the Diagram 1–8 caption numbering are unchanged; the deck section ids and 390px mobile-fit are intact (deep-dive deck Playwright spec green). The A/B-contrast deslop step (`docs/ab-contrast-deslop-step.md`) was applied to all rewritten prose — the pull idea is stated as plain declaratives with no "pull, not push" / "rather than" / "instead of" antithesis.
- **Public materials consolidated to ONE deep dive per format** (#978). The two deep-dive articles (`eliminating-coordination-delay` + `make-the-waiting-visible`) merged into one `docs/articles/dev-loops-deep-dive.md`/`.html`, and the two deep-dive decks (`applied-dev-loops.html` + `process-observability.html`) merged into one `docs/presentations/dev-loops-deep-dive.html`. Each merged piece runs in two parts — Part 1 eliminating coordination delay (explicit handoffs, fan-out/fan-in review, mid-flight steering, the state graph), then Part 2 make the waiting visible (interrupt cost, handoff discovery, the git blind spot, the four fields, the measurement loop, grounding in real mechanisms) — under one title/hero, one intro, and one close. The deep-dive article reuses the article design system (glass cards, inline-flow nodes, inline SVG diagrams) and the deck reuses the deck design system with stable per-slide section ids and mobile-fit layout. The old four published files are removed (and the orphaned `*-notes.md` review records for the two removed articles). `scripts/pages/build-site.mjs` `ARTICLES`/`DECKS`/`NAV_LINKS` collapse to the single deep-dive in each format (nav labels "Deep dive" / "Deep dive (deck)"); the article and deck share the source basename `dev-loops-deep-dive.html` under different `docs/` dirs, so the deck publishes as `dev-loops-deep-dive-deck.html` to avoid clobbering the article in `site/`. The Pages nav is now Intro article (landing) + Deep-dive article + Intro deck + Deep-dive deck. The two old deck Playwright specs/configs (`applied-deck`, `observability-deck`) and the `test:playwright:deck`/`:obs-deck` scripts are replaced by one `deep-dive-deck.spec.mjs` + `playwright.dev-loops-deep-dive.config.mjs` + `test:playwright:deep-dive` (section-ids, 390px mobile fit/no-clip, CSP, guard-the-guard). The intro article's "Where to go deeper", `docs/index.md`, and `docs/presentations/README.md` point at the consolidated pages. The A/B-contrast deslop step was applied to the merged prose (new part-divider headlines and bridge sentences kept clean; the source pieces were already deslopped).
- **Local-first low-noise posture made the coherent default (#953, builds on #949/#950/#951/#952).** The shipped extension-defaults layer (`packages/core/src/config/extension-defaults.yaml`) — the local-first opinion that sits above the built-in github-first code defaults and below repo config — now sets three existing knobs to their low-noise values, each with an inline intent comment: `autonomy.humanMergeOnly: true` (local-first never auto-merges; a human always merges), `queue.maxAutoFiledIssues: 1` (local-first is PR-first per #952, so auto-filing issues is near-zero; a low cap keeps tracker noise minimal), and an explicit `gates.postFindingsComments: true` (gate findings live ON the PR as review evidence, not tracker noise — keep them on). No new resolver and no new `strategy → knob` coupling: the values come purely from the existing config-merge layering (built-in < extension < repo `.pi/dev-loop/defaults.*` < repo `.devloops`), which already permits all three knobs at the file/extension layer (the runtime Zod schema needed no change). The published JSON-schema artifact `schemas/dev-loop-config.schema.json` was reconciled to match that runtime contract — its `gates` block (which used `additionalProperties: false`) now lists `postFindingsComments` plus the previously-missing `requireFanoutEvidence`/`maxFanoutReviewers`, so the schema no longer rejects the shipped config. The github-first/built-in posture is unchanged — `BUILT_IN_DEFAULTS` still yields `humanMergeOnly: false`, `maxAutoFiledIssues: 10`, and a resolved `postFindingsComments: true`. The post-promotion draft→pre-approval→human-merge flow is untouched. `node:test` coverage locks it: a merged-config assertion (`humanMergeOnly === true`, `maxAutoFiledIssues === 1`, `postFindingsComments === true` under the shipped defaults), built-in-default assertions for the unchanged github-first posture, and an explicit local-first phase-doc intake test (strategy from the shipped extension default, inputSource phase-docs from repo settings) asserting via a logging `gh` stub that NO `gh issue create` / `gh pr create` and NO Copilot request fire before promotion.
- **Both presentation decks deslopped — shorter headlines + A/B-contrast prose removed** (#936, applies the #944 standard step). Every slide headline on `docs/presentations/applied-dev-loops.html` and `docs/presentations/process-observability.html` was shortened to a short 3–7 word claim and stripped of the binary-contrast antipattern: "The Work Is One Loop Inside Another — and a Handoff Is Never Guessed" → "Loops Inside Loops"; "Prompt-Only Workflows Drift; a State Graph Can't" → "State Graphs Pin Behavior"; "One Interrupt Costs Five Transitions, Not Five Minutes" → "One Interrupt, Five Transitions"; "Those Four Fields Aren't a Wish — They're Where the Work Already Lives" → "The Fields Already Live in the Work"; "The Cheapest Speed-Up Is Making the Waiting Visible" → "Make the Waiting Visible" (and the rest). Slide body text in the publishable HTML renders lost the same "X, not Y" / negation-by-contrast construction in both orderings ("verified, never assumed" → "verified against the real result"; "don't add up. They multiply" → "multiply"; "Stop optimizing… Start measuring…" → "Measure how long the work waits"), with load-bearing distinctions kept plain. The HTML renders are the published source of truth; the Slidev `*-presentation.md` sources mirror the shortened headlines only (their body text already predates the HTML restructure — see `docs/presentations/README.md`). The dark visual identity, section ids, CSP guard, mobile-fit responsiveness, and all six Playwright deck tests are unchanged. Deploys to GitHub Pages on merge.

### Added
- **First-class `--jq` field-selection, `--silent`/`-s` exit-code checks, and concise output across the read/action scripts** (#981, subsumes #963). One shared helper `scripts/lib/jq-output.mjs` (`evaluateJqFilter`, `emitResult`, `JQ_OUTPUT_PARSE_OPTIONS`, `JQ_OUTPUT_USAGE`, `JqFilterError`) gives the loop a token-economical way to read tool JSON so it never falls back to `gh api | python3` or inline `node -e`. `--jq <filter>` applies a gh-style jq-subset filter (field access, `.a.b` chains, `.[]`/`.field[]` iteration, `.[N]` index, `|` pipes, `select(...)`, `==`/`!=`/`<`/`<=`/`>`/`>=`, `length`, `keys`) to the script's result and prints only the filtered value; an unsupported filter fails closed (stderr + exit `2`). `--silent`/`-s` suppresses stdout and maps the result to an exit code only (`0` pass/truthy, `1` fail/falsy) for zero-output yes/no checks — composing with `--jq` as a predicate (`… --jq '.ciStatus=="success"' -s; echo $?`), where an invalid filter still fails closed at exit `2`, distinct from a clean predicate-false silent exit `1`. Without these flags every script's JSON shape is unchanged. The full subset (not full jq — overkill for the loop's field/predicate needs) is applied uniformly to `scripts/github/capture-review-threads.mjs`, `scripts/loop/copilot-pr-handoff.mjs`, `scripts/loop/pr-runner-coordination.mjs`, `scripts/github/upsert-checkpoint-verdict.mjs`, and the queue scripts `scripts/projects/add-queue-item.mjs`/`move-queue-item.mjs`/`list-queue-items.mjs` (the #963 operator-action-scripts-JSON-only goal folded in here). In addition, the two scripts that emitted a large blob with no concise mode — `scripts/loop/run-watch-cycle.mjs` and `scripts/github/probe-copilot-review.mjs` — gain a `--concise`/`--summary` human-readable mode covering loop state, Copilot round count, unresolved/actionable thread counts, round-cap-clean eligibility, CI status, next action, and (the field `loop info --pr` omits) the current round's NEW Copilot comment bodies. The token-economical reading convention (subcommand/concise → `--silent` → `--jq` → `gh --jq` → never `| python3`/`node -e`) is documented in `skills/dev-loop/SKILL.md` and referenced from the round-cap gate-cadence rule. `node:test` coverage: a helper unit spec (subset extraction success, fail-closed on unsupported syntax, `emitResult` jq/silent/invalid-filter exit-code semantics), an end-to-end CLI spec (`--jq` extraction, invalid filter exit `2` + stderr, `--silent` pass exit `0` silent, predicate-false exit `1` silent, silent+invalid-filter exit `2`, unchanged JSON shape), and concise-mode specs for run-watch-cycle/probe-copilot-review.
- **Developer-mode internal-tooling-only retrospective check (#982).** A new opt-in workflow flag `workflow.requireRetrospectiveInternalTooling` (default **OFF**, defined in `packages/core/src/config/config.mjs`, shipped explicitly false in `extension-defaults.yaml`, declared in `schemas/dev-loop-config.schema.json`) gates an internal-tooling-only check in the retrospective merge gate. **It is scoped to developer mode — the dev-loops maintainers dogfooding the tooling on themselves — and never blocks consumers** of the extension, who may legitimately use raw `gh`/`python`/`node -e` in their own workflow. When the flag is ON, the merge-approval evaluator (`evaluateRetrospectiveMergeApproval(checkpoint, { developerMode })` in `packages/core/src/loop/pr-gate-coordination.mjs`, routed through at every merge-ready boundary in `evaluatePrGateCoordination`) requires `behavioralReview.internalToolingOnly: true` and `behavioralReview.rawCallViolations: []`, blocking (`retrospective_gate_pending`) when `internalToolingOnly` is not `true` **OR** `rawCallViolations` is missing/non-empty. When the flag is OFF (the consumer default) the check is inert: a complete, merge-approved checkpoint passes exactly as before — even if it omits the fields or records a violation — so a consumer's state changes are never blocked (this also resolves the draft-review back-compat concern: old checkpoints only fail closed in developer mode). The dev-loops repo opts in via its own repo-root `.devloops` (`workflow.requireRetrospectiveInternalTooling: true`), so the discipline still applies to our own retros. The rule treats agent-level top-level raw `gh` (incl. `gh api`/`--jq`), `python`/`python3`, and `node -e`/`node --eval` as the same breach; `node scripts/*.mjs` and dev-loops subcommands are allowed (the scripts call gh/GraphQL internally — that IS the tooling). The dependency-free, deterministic verifier `scripts/loop/check-retro-tooling.mjs` (pure `analyzeTranscript(transcript)` export) is unchanged: it reads a newline-delimited transcript of the agent's shell commands (`--transcript <path>` or stdin) and returns the `rawCallViolations` list, distinguishing script-internal gh from agent-level raw calls and keeping a small explicit write-op allowlist (`gh pr merge`/`gh pr ready`/`gh issue create`/`gh issue edit`, recorded as `allowedWriteOps`). `node:test` coverage: developer-mode-ON gate tests for a recorded violation / `internalToolingOnly:false` / missing-field-old-checkpoint (all block) and a clean record (allows `FINAL_APPROVAL_READY`), the key developer-mode-OFF test asserting a consumer is NOT blocked even with missing fields OR a recorded violation, plus the unchanged verifier spec (`test/loop/check-retro-tooling.test.mjs`). The retrospective-checkpoint contract doc (`skills/docs/retrospective-checkpoint-contract.md` + generated `.claude/` mirror) documents the developer-mode scoping, the opt-in flag, and that consumers are never blocked. Known verifier limitation: segment splitting does not parse shell quoting, so a separator inside a quoted argument can over-report (never under-report) — the fail-closed direction.
- **"Introducing dev-loops" presentation deck** (#973, companion to the #971 overview article). A self-contained, CSP-safe `docs/presentations/introducing-dev-loops.html` (9 slides: the coordination-delay problem, how manual handoffs compound, every handoff a recorded decision, one bounded decision per change, model-agnostic by construction (open-source models drive the work), the aggregate proof data, install/run, and the companion pieces) reusing the existing decks’ dark glass-card design system, CSP meta, and mobile-fit responsiveness. Added to `scripts/pages/build-site.mjs` `DECKS` (navLabel "Intro (deck)") so it publishes and joins the shared Pages nav; a mirror Playwright spec + config + `test:playwright:intro-deck` script match the existing per-deck tests. Aggregate data only (no internal PR/issue numbers or script paths in slide prose); the A/B-contrast deslop step was applied.
- **Model-agnostic section in the intro article + open-source model proof** (#979). A new "Model-agnostic by construction" section in `docs/articles/introducing-dev-loops.md`/`.html` (the Pages landing page) explains that the guardrails — every handoff a recorded decision, the same draft → review → green-CI gate, fail-closed on ambiguity, a person merging — bound how far any single step can stray, so a strong open-source model can drive most or all of the work (cheaper, self-hostable, no single-vendor dependence; the gate holds the quality bar). Names the concrete proof: the loop has driven real work end to end on DeepSeek V4, Kimi K2.6, MiniMax M3, Qwen 3.6, and GLM 5.2. Aggregate/conceptual only; A/B-contrast deslop applied.

- **Spike-mode contract doc + worked example** (#966, docs-only; phase 3 of 3 of the spike-mode track #955, closing it; builds on #964/#965). A new canonical operator-sequence doc [`skills/docs/spike-mode-contract.md`](skills/docs/spike-mode-contract.md) walks spike mode end to end against the merged surfaces: author the exploration scaffold and validate it (`scripts/refine/validate-spike-file.mjs`, base sections Question/Approach/Findings/Recommendation), start it (`scripts/loop/resolve-dev-loop-startup.mjs --spike`, `buildSpikeInput` + `evaluateSpikeIntakeState`, intake states `spike_in_progress`/`spike_ready_for_exit`/`ambiguous_fail_closed`), run it under the relaxed `gates.spike` profile, then exit via `scripts/refine/exit-spike.mjs` with disposition `discard` (zero tracker artifacts; the findings doc is the whole record) or `graduate` (`buildGraduatedPlanBody` emits a #947-consumable plan file that enters the existing plan→PR promotion path #952). The doc documents the relaxed gate profile and its rationale — `angles: [scope, docs]`, `required: false`, `requireCi: false` (`packages/core/src/config/extension-defaults.yaml`), resolved through the same `resolveGateConfig(config, "spike")` path and absent for non-spike work — and carries one worked example (`spike-cache.md`) from a question through findings to BOTH exits, showing the actual commands/states and, for graduate, the resulting plan-file body. Cross-referenced from the [Artifact Authority Contract](skills/docs/artifact-authority-contract.md) relationship table and `docs/index.md`; the generated `.claude/skills/docs/` mirror was regenerated. No behavior, schema, defaults, or scripts changed.
- **Spike-mode relaxed gate profile + discard/graduate exits** (#965, phase 2 of the spike-mode track #955; builds on #964 and reuses #951/#952/#953). A spike is now runnable to a conclusion: a lighter gate posture plus an explicit exit that either discards (zero tracker artifacts) or graduates (emits a #947-consumable plan file). **Relaxed gate profile (config):** a new `gates.spike` profile (same `GateConfig` shape as `gates.draft`/`gates.preApproval`) resolved through the SAME `resolveGateConfig(config, "spike")` path and the existing config-merge layering — no new `strategy → knob` resolver. The shipped extension default (`packages/core/src/config/extension-defaults.yaml`) is intentionally lighter than the production draft → pre-approval → Copilot set: a small docs-first angle set (`scope`, `docs`), `required: false`, and `requireCi: false`, because a spike's deliverable is a findings doc, not production code. Runtime zod (`GatesConfig`/`FileGatesConfig` in `packages/core/src/config/config.mjs`) and the published JSON-schema artifact (`schemas/dev-loop-config.schema.json`, `gates.spike` → `$defs/gateConfig`) were kept in sync (#953's lesson). The github-first/production posture is UNCHANGED: `draft`/`preApproval` stay `required: true`/`requireCi: true`, and `gates.spike` is absent for non-spike work. **Discard/graduate exit contract (pure):** a new `packages/core/src/loop/spike-exit-contract.mjs` (no `scripts/` import, no fs/network/gh; mirrors spike-intake-contract) exports frozen `SPIKE_EXIT_DISPOSITION`/`SPIKE_EXIT_ACTION` plus `evaluateSpikeExit({ spikeIntakeState, disposition }) -> { ok, action?, reason?, spikeIntakeState? }`, eligible ONLY from `spike_ready_for_exit` and failing closed with `not_ready_for_exit` (non-ready/ambiguous state) or `unknown_disposition` (anything but `discard`/`graduate`). `buildGraduatedPlanBody({ question, approach, findings, recommendation })` maps the spike's four sections onto a #947-consumable plan-file body with the four base authoring sections (Status/Objective/In scope/Explicit non-goals) so it passes `validatePlanFile` and enters the existing plan→PR promotion path (#952) unchanged; it is idempotent and fails closed (throws) on an empty required section. The export is added to `packages/core/package.json`. **Thin CLI:** `scripts/refine/exit-spike.mjs` (`--spike-file`/`--disposition`/`--plan-file`/`--json`, mirroring promote-plan.mjs) owns all I/O — it reads the spike, computes intake-state facts via the P1 surfaces (`validateSpikeExplorationSections` for the scaffold + the Recommendation exit-marker), classifies via the pure contract, and on graduate writes the local-first plan file; discard creates ZERO tracker artifacts and graduation opens nothing on the tracker (it only writes a local plan file, then promotes via the existing path). Structured success/error JSON matches the sibling refine/promote scripts. `node:test` coverage: the pure exit contract (discard/graduate eligible from ready; fail-closed on unknown disposition + non-ready/ambiguous/missing state) + the plan-body builder (base-valid output passes `validatePlanFile`, carries the spike content, idempotent, throws on empty sections); the gate-profile resolution (spike profile resolves; shipped default is relaxed and lighter than draft, with draft/preApproval unchanged); and the CLI (discard → empty gh-stub log + exit 0, graduate → base-valid plan file written + empty gh log + idempotent re-run, fail-closed not-ready/unknown-disposition → empty gh log + exit 1, arg validation). Narrative skill docs are #966 (NOT in this phase); no production-flow change for non-spike work.
- **Top-of-funnel article "Introducing dev-loops" + CSP-safe HTML render** (#971, sibling of the #942/#943 article PRs). `docs/articles/introducing-dev-loops.md` is the single what/why/how-do-I-start entry point the two concept articles lacked: a plain-language introduction to the idea (coordination delay is where AI-assisted lead time goes; the loop makes every handoff an explicit, recorded decision; a person merges by default; work starts from a durable plan or issue), a "what it does to the work" section that uses the repository’s own ~two-week history (~100 merged PRs, ~7/day, about seven in eight tied to a tracked item, every one drafted, reviewed, green-CI gated, and human-merged) to show the straightforward, repeatable process the loop produces, and concrete setup instructions (the Claude Code plugin slash commands, the Pi extension install, the plain-language `start/auto/continue dev loop` entrypoint, and a minimal `.devloops` covering start-local vs issue intake, Copilot rounds on/off, and human-merge). A self-contained, CSP-safe `docs/articles/introducing-dev-loops.html` ports the dark glass-card identity from the sibling renders (no font/CDN/remote assets). Cross-linked from a new Articles section in `docs/index.md`, and the two existing articles gain a one-line "start here" pointer so they read as deep dives beneath the intro. Aggregate figures are gh-verified; the A/B-contrast deslop step (`docs/ab-contrast-deslop-step.md`) was applied (zero flagged constructions). The intro article is also made the **GitHub Pages landing page**: `scripts/pages/build-site.mjs` now publishes `docs/articles/introducing-dev-loops.html` as `site/index.html` (superseding the prior generated "Presentation Decks" card index from #930), publishes the two deep-dive articles and two decks alongside it, and injects a shared navigation bar into the article pages linking the other resources (`injectNav`, which fails closed if a page lacks its `<style>`/`<body>` anchors); the decks are copied as-is. `test/pages/build-site.test.mjs` updated. On merge, `pages.yml` rebuilds and deploys the new landing page.
- **Spike-mode entry + findings-artifact format** (#964, phase 1 of the spike-mode track #955; reuses #949/#950). A spike is a time-boxed exploratory loop startable from a local question with no GitHub issue. A new spike artifact format mirrors the plan-file format with base sections **Question / Approach / Findings / Recommendation**; a focused sibling validator `scripts/refine/validate-spike-file.mjs` exports `validateSpikeFile(markdownText) -> { checker: "validate-spike-file", ok, errors }` with a distinct `missing_*` code per absent/empty section (`missing_question`/`missing_approach`/`missing_findings`/`missing_recommendation`) plus a thin `--input`/`--json`/`--help` CLI mirroring `validate-plan-file.mjs`. The base-section-checking loop is shared with `validatePlanFile` via a small parameterized helper `checkBaseSections(markdownText, checker, sectionCodes)` in `scripts/refine/_refine-helpers.mjs` (DRY without forcing a shared abstraction across the distinct heading sets). `scripts/loop/resolve-dev-loop-startup.mjs` gains `--spike <path>`, mutually exclusive with `--issue`/`--pr`/`--input`/`--plan-file`: it reuses the `local_implementation` strategy and the existing `local_phase` target as a work-origin addition, makes ZERO `gh` calls / no tracker artifacts at entry (no production-gate ceremony), is exempt from the worktree-isolation `needs_reconcile` guard (a spike has no issue to key a worktree on), and fails closed (exit 1, no bundle) on a missing/unreadable spike file or one missing the exploration scaffold (Question/Approach/Findings). A new pure, deterministic contract `packages/core/src/loop/spike-intake-contract.mjs` (no `scripts/` import, no fs/network/gh) exports a frozen `SPIKE_INTAKE_STATE` and `evaluateSpikeIntakeState({ baseSectionsValid, hasRecommendation }) -> { state }` classifying the spike as `spike_in_progress` (exploration ongoing, no Recommendation yet — phase 2's discard exit) or `spike_ready_for_exit` (Recommendation reached — phase 2's discard/graduate exit seam), failing closed (`ambiguous_fail_closed`) on a malformed artifact. These states are DISTINCT from `PLAN_FILE_INTAKE_STATE` (a spike is not a plan-needing-refinement). The resolver threads the resulting `spikeIntakeState` onto its JSON output and the new core export is added to `packages/core/package.json`. `node:test` coverage: the pure contract spec, the validator spec (all sections ok / each missing→its code / empty-body / CLI), and resolver specs (valid spike → `spike_ready_for_exit` with zero `gh` calls via the empty-log stub, in-progress spike → `spike_in_progress`, missing/malformed → fail closed, mutual exclusivity with `--issue`/`--plan-file`). The relaxed gate profile + discard/graduate exits (#965) and narrative docs (#966) are NOT in this phase; no production-flow change.
- **Local-first plan-file flow documented end to end + worked example** (#954, docs-only; builds on #949/#950/#951/#952/#953). The canonical [Artifact Authority Contract](skills/docs/artifact-authority-contract.md) (at `skills/docs/`; the `docs/` path in the original stub was stale) now documents the full local-first flow across P1–P5: the plan-file artifact + `localPlanning.plansDir` default (`docs/phases/`) and validator from P1; the `--plan-file` startup entry + `PLAN_FILE_INTAKE_STATE` states (`new_plan_needs_refinement` / `plan_refined_ready_for_promotion` / `ambiguous_fail_closed`) from P2; the in-place refine + `local_human_review` checkpoint (AC/DoD/`Coverage matrix`/`Docs-grill findings` sections) from P3; PR-first promotion + the bidirectional plan↔PR link (`prNumber` front-matter, `already_promoted` idempotency) from P4; and the low-noise extension defaults from P5. The contract was reconciled to the shipped posture: the effective default is `strategy.default: local-first` from `packages/core/src/config/extension-defaults.yaml` (decision #7 of #947), which sits above the `BUILT_IN_DEFAULTS` github-first fallback in `packages/core/src/config/config.mjs`; the stale `.pi/dev-loop/defaults.yaml` shipped-default claim and the "dev-loops is tracker-first" statement were corrected (the package ships defaults via the extension layer and the repo's own `.devloops` sets `local-first`). A new operator-sequence skill doc [`skills/docs/local-planning-flow.md`](skills/docs/local-planning-flow.md) walks validate-plan-file → resolve-dev-loop-startup `--plan-file` → refine-plan-file → promote-plan, and a worked example [`skills/docs/local-planning-worked-example.md`](skills/docs/local-planning-worked-example.md) shows one plan file (`docs/phases/phase-42.md`) evolving through every stage (base sections → refinement sections + coverage matrix + docs-grill findings → `prNumber` front-matter). All three docs are reachable from `docs/index.md` and cross-link the contract; the generated `.claude/skills/docs/` mirror was regenerated. No behavior, schema, defaults, or scripts changed.
- **Local-first PR-FIRST plan promotion — no issue ever minted** (#952, builds on #949/#950/#951). A pure contract `packages/core/src/loop/plan-file-promote-contract.mjs` exports `evaluatePromoteEligibility({ baseSectionsValid, hasAcceptanceCriteria, hasDefinitionOfDone, existingPrNumber }) -> { ok, action, reason, planFileIntakeState, existingPrNumber }` (`PLAN_FILE_PROMOTE_ACTION` `promote`/`already_promoted`), `buildPromotionPrBody({ planDocPath, acceptanceCriteria, definitionOfDone })`, and a minimal additive plan front-matter parser/serializer (`parsePlanFrontMatter`, `readLinkedPrNumber`, `writeLinkedPrNumber`, `PLAN_FILE_PR_FRONT_MATTER_KEY`). Eligibility is fail-closed: promotion acts ONLY on P3's `plan_refined_ready_for_promotion` state (composed via P2's `evaluatePlanFileIntakeState`); any other state returns `not_ready_for_promotion` with no action so the caller makes zero GitHub mutation. The PR body is the self-contained spec-of-record — it references the committed plan-doc path and carries the FULL `Acceptance criteria` + `Definition of done` extracted from the refined plan, and never an issue-close keyword (no issue is minted). The module is pure (no GitHub, no network, no filesystem I/O). A thin CLI `scripts/refine/promote-plan.mjs` (`--plan-file <path> [--base <branch>] [--branch <name>] [--json]`) owns all I/O: it reads the plan, computes the section-presence facts with the P1 `validatePlanFile`/`extractSection` surfaces, runs the eligibility gate, and on `promote` commits the plan doc to a branch and opens EXACTLY ONE draft PR via the canonical `scripts/github/create-pr.mjs` wrapper (always `--draft`, self-assigned; never raw `gh pr create`), then writes the returned PR number back into the plan's `prNumber:` front-matter and commits the link — the bidirectional plan↔PR link (doc path in PR body, PR number in plan front-matter). The opened draft PR is a normal-shaped PR that enters the existing loop unchanged via `loop startup --pr <n>`; no new lifecycle state and no separate authority artifact. Idempotent: a plan already carrying `prNumber` returns `already_promoted`, opens nothing, and reports the existing PR. The front-matter is a minimal additive extension to P1's plan-file format (documented in `skills/docs/plan-file-contract.md`): plans without a leading `---` block are unchanged and fully valid. A `node:test` contract spec plus a CLI spec cover the ready-gate (refuse non-ready/ambiguous/partially-refined with zero gh calls), the single draft-PR open (one `gh pr create --draft`, asserted via a logging `gh` stub), the plan-doc commit + bidirectional link, idempotency, the fail-closed reasons, zero-pre-promotion activity (empty gh-stub log on refusal and on the idempotent no-op), and the PR body carrying the full AC + DoD.
- **Local-first refine + human-review checkpoint (off-tracker)** (#951, builds on #949/#950, consumes #948). A pure contract `packages/core/src/loop/plan-file-refine-contract.mjs` exports `refinePlanFileInPlace({ markdownText, baseSectionsValid, hasAcceptanceCriteria, hasDefinitionOfDone, payload }) -> { ok, planFileIntakeState, refinedMarkdown, grillDispositions, stop }` plus `PLAN_FILE_REFINE_STOP`/`DOCS_GRILL_FINDINGS_HEADING`/`COVERAGE_MATRIX_HEADING`. It generalizes the proposal-first intake pattern (emit a local artifact, human-gate it, make zero tracker mutation, stop and ask) to plan files: starting from P2's `new_plan_needs_refinement`, it writes the refiner-produced `Acceptance criteria`, `Definition of done`, `Coverage matrix`, and recorded `Docs-grill findings` sections in place into the single canonical plan file, advances P2's intake state to `plan_refined_ready_for_promotion` via `evaluatePlanFileIntakeState`, and returns a `local_human_review` stop so the loop pauses for human review before any promotion. The docs-grill runs as a step of refinement: each finding is classified with #948's `classifyDocsGrillFinding` and recorded into the plan file, and a malformed finding fails the grill closed. The rewrite is idempotent — a re-run strips any prior copy of each refinement section before appending, so the section count stays at one each. It is pure (no GitHub, no network, no filesystem I/O): the caller supplies the section-presence facts and the refiner payload and writes the returned markdown back, keeping the zero-tracker-mutation guarantee structural. Fail-closed paths cover a non-`new_plan_needs_refinement` starting state, an ambiguous/base-invalid plan, missing AC/DoD/coverage-matrix payload pieces, and a failed grill — each surfaces a reason and advances/writes nothing. A thin CLI `scripts/refine/refine-plan-file.mjs` (`--plan-file <path> --payload <path> [--json]`) reads the plan and the refiner output, computes the facts with the P1 `validatePlanFile`/`extractSection` surfaces, calls the contract, writes the refined plan back in place on success (exit 0, `local_human_review` stop), and fails closed without writing on a fail-closed reason (exit 1). A `node:test` contract spec plus a CLI spec cover refine-from-needs-refinement, the docs-grill-as-a-step recording, in-place idempotency, state advance + local stop, the fail-closed cases, and a zero-tracker-mutation assertion (the logging `gh` stub's call log is empty across the whole path).
- **Autonomous in-loop docs-grill formalized as a standard step** (#948, sibling of #944 and #929). `docs/docs-grill-step.md` documents the repeatable step that interrogates a change against the repo's own contracts/docs while the loop runs — without a manual main-agent pass: what it checks (claims vs contracts, code-vs-doc drift, stale references, contract-surface accuracy), where it fires (the [refiner](agents/refiner.agent.md) cross-checks the active phase against the contracts it references during refinement, and the existing `docs` pre-approval angle resolves to the [docs persona](agents/docs.agent.md) review mode as one fan-out angle of the [gate review sub-loop](docs/gate-review-sub-loop-contract.md)), and the keep/fix rule. The rule is codified as a pure classifier `classifyDocsGrillFinding` in `scripts/loop/docs-grill-contract.mjs` (`DOCS_GRILL_FINDING_KINDS` `drift`/`stale_reference`/`cosmetic`; `DOCS_GRILL_DISPOSITIONS` `record_finding`/`fix_in_place`/`route_followup`/`ignore_cosmetic`): real drift between code/behavior and a contract claim is recorded as a finding, doc-only drift is fixed in place or routed as a follow-up, and a cosmetic nit never blocks a gate; an unknown kind fails closed (`invalid_finding`). The refiner agent gains an explicit docs cross-check line pointing at the step. The two firing surfaces already ran in-loop, so this captures the contract without adding a new entrypoint. The local-first epic (#947) tree refinement is recorded as the first run (each node was grilled against the contracts it reuses during the refiner+grill fan-out). Cross-linked from `docs/index.md`.
- **`--plan-file` startup entry + local-planning intake state machine** (#950, builds on #949). `scripts/loop/resolve-dev-loop-startup.mjs` gains `--plan-file <path>`, mutually exclusive with `--issue`/`--pr`/`--input`. It reuses the `local_implementation` strategy and the existing `local_phase` target as a work-origin addition: a valid plan resolves to a `local_phase` bundle with the plan path carried as the target `phase` and no issue/PR number. The path is read-only (zero tracker mutation, no `gh` calls) and exempt from the worktree-isolation `needs_reconcile` guard, because a pre-promotion plan has no issue to key a worktree on. A missing/unreadable plan, or one failing the `validatePlanFile` base-section contract, fails closed (exit 1, no readiness bundle). A new pure, deterministic intake evaluator `evaluatePlanFileIntakeState({ baseSectionsValid, hasAcceptanceCriteria, hasDefinitionOfDone }) -> { state }` (frozen `PLAN_FILE_INTAKE_STATE` enum, no I/O) in `packages/core/src/loop/plan-file-intake-contract.mjs` classifies the plan as `new_plan_needs_refinement` (base sections only), `plan_refined_ready_for_promotion` (base sections plus `Acceptance criteria` + `Definition of done`), or `ambiguous_fail_closed` (invalid base, or exactly one of the two refinement sections). The resolver detects refined-vs-needs-refinement via `extractSection` for each `PLAN_FILE_REFINEMENT_SECTIONS` heading and threads the resulting `planFileIntakeState` onto its JSON output.
- **Plan-file contract + validator for local-planning mode** (#949). The persisted markdown plan file reuses the existing phase-doc format under `docs/phases/`; a new `localPlanning` config family adds `plansDir` (default `docs/phases/`) across all four config locations (`DevLoopConfigSchema`, `FileConfigSchema`, `BUILT_IN_DEFAULTS`, and the packaged `extension-defaults.yaml`), with `resolvePlansDir(config)` returning the configured directory or the default. A pure validator `scripts/refine/validate-plan-file.mjs` exports `validatePlanFile(markdownText) -> { checker: "validate-plan-file", ok, errors }` that checks the base authoring sections a plan carries before refinement (`Status`, `Objective`, `In scope`, `Explicit non-goals`), reporting each absent or empty-body section under a distinct `missing_*` code; its thin CLI takes `--input`/`--json`/`--help`, reports the verdict in the JSON payload, and exits non-zero on argument or path errors. The format and required sections are documented in `skills/docs/plan-file-contract.md` (mirrored to `.claude/skills/docs/`) and linked from the Artifact Authority Contract.
- **A/B (binary-contrast) prose removal formalized as a standard deslop step + applied to the two articles** (#944). `docs/ab-contrast-deslop-step.md` documents a repeatable editorial step that removes the binary-contrast / negation-by-contrast antipattern in both orderings ("X, not Y" and "Y, not X") — the strongest single AI tell in generated prose: the detection spec, the parallel-analysis → final-check → human-likeness flow, and the keep-load-bearing-distinctions/cut-the-scaffolding rewrite rule (with a keep-vs-cut table). It is the first documented sub-step of the broader deslop step tracked in #936. Applied as the first run to both Medium articles (`eliminating-coordination-delay`, `make-the-waiting-visible`) and their HTML renders: an audit found ~120 constructions across the articles and decks; the article prose now states each point directly while keeping real technical distinctions plain (the merge signal stays read-from-CI, automation stays state-gated), followed by a human-likeness pass to keep the cadence natural. Each article's `-notes.md` records the pass; a residual check finds zero flagged constructions in the article `.md`/`.html`.
- **Presentation decks publishable to GitHub Pages** (#930). `.github/workflows/pages.yml` runs the standard Pages pipeline (`actions/configure-pages` + `actions/upload-pages-artifact` + `actions/deploy-pages`, with `permissions: { pages: write, id-token: write, contents: read }` and `concurrency: { group: pages }`). A committed, reusable build script (`scripts/pages/build-site.mjs`, exported `buildSite`/`DECKS`) deterministically assembles `site/` — copying the self-contained deck renders (`docs/presentations/applied-dev-loops.html`, `process-observability.html`) and generating a CSP-safe dark-aesthetic `site/index.html` linking both. The deck HTML stays the single source of truth; `site/` is assembled (gitignored), never hand-maintained. The **build** job (assemble + upload, which validates the assembly) runs on push to `main` + `workflow_dispatch`. At the time of this PR the **deploy** job was gated to `workflow_dispatch` (`if: github.event_name == 'workflow_dispatch'`) until an operator enabled Pages, with the build job validating the assembly on every push; **#941 (below) superseded that gate** once Pages was enabled, so the deploy job now auto-publishes on every push to `main`. `docs/presentations/README.md` documents the decks, local viewing, the deploy flow, and the private-repo plan caveat (a public Pages site from a private repo needs Pro/Team/Enterprise). Also adds the missing `@media (prefers-reduced-motion: reduce)` guard to `applied-dev-loops.html` for parity with the observability deck (disables `scroll-behavior: smooth` on `html`, `scroll-snap-type` on `body`, `scroll-snap-align` on `.slide`). A `node:test` spec (`test/pages/build-site.test.mjs`) asserts the build produces `index.html` + both decks and that the index links both.
- **Medium-ready long-form article: "Eliminating Coordination Delay in AI-Assisted Dev Workflows"** (#934). `docs/articles/eliminating-coordination-delay.md` is a public-audience, ~1500-word prose piece derived from the Applied dev-loops narrative — zoomed-out and durable (no version-pegging, no raw enum/identifier dumps): hook (cheap code, leaky guessed handoffs) → the one idea (never guess a handoff; make every handoff an explicit, observable decision) → what it buys (safe pauses, mid-flight steering, parallel review → one consolidated verdict, "done" that means merged and read from the real merge signal) → why a state graph beats prompt-only → a one-line close. It carries four captioned `mermaid` diagrams (nested-loops state diagram, PR gate lifecycle flowchart, fan-out/fan-in evidence→verdict flow, mid-flight steering flow) plus a "Rendering the diagrams on Medium" note. A self-contained, CSP-safe preview render `docs/articles/eliminating-coordination-delay.html` ports the dark glass-card identity (navy gradient, glass cards, violet accent, blue kicker, system fonts) in an article layout (~65ch measure) with the four diagrams as inline CSS-flow / inline SVG (no mermaid runtime, no remote resources, wide diagrams scroll in their own container). One prose-adapted storytelling/editorial review pass is recorded and applied in `docs/articles/eliminating-coordination-delay-notes.md`.
- **Long-form article "Make the Waiting Visible" + a self-contained HTML preview render** (#935). `docs/articles/make-the-waiting-visible.md` adapts the [Process Observability deck](docs/presentations/process-observability-presentation.md) into a Medium-ready public-audience essay (~1.6k words, optional title/dek/tags front-matter): AI writes code in seconds, then the work *sits* — the slow part is the invisible waiting between actions. The arc runs hook → interrupt cost (one interrupt = five transitions) → handoffs restart discovery → git history hides the waiting → make state observable (owner / blocker / latest decision / safe next step) → measure → change → verify → ground it in real mechanisms (board lifecycle, gate evidence trail, deterministic next-action resolver, provider-agnostic CI waits, post-merge reclaim/archive) → close (*"The next agent will write your code in seconds. The lever you control is how long it waits afterward, so measure that."*). Four captioned `mermaid` diagrams (interrupt-cost chain, handoff round trip, measurement loop, observable-state grounding) plus a "rendering on Medium" note. A standalone, CSP-safe `docs/articles/make-the-waiting-visible.html` renders the full article in the dark glass-card identity (navy gradient, glass cards, violet accent, blue kicker, system fonts) as an article layout (~65ch measure), drawing the four diagrams as inline CSS flow / inline SVG — no mermaid runtime, no font/CDN/remote assets. One editorial/storytelling review pass (lens from [docs/slides-story-review-loop.md](docs/slides-story-review-loop.md), adapted for prose) is recorded and applied in `docs/articles/make-the-waiting-visible-notes.md`.
- **Slides content & storytelling review loop formalized as a bounded reviewer mode** (#929). A sibling of the [UI Designer + Vision Review Loop](docs/ui-designer-review-loop.md) behind `dev-loop` that judges a deck's *narrative*, not its pixels — "does it land?" rather than "does it look right?". `docs/slides-story-review-loop.md` defines the contract: the public entrypoint/dependency boundary (no new public name), a fail-closed REQUIRED INPUT BUNDLE (deck source path + slice-level acceptance criteria + a short storytelling brief + optional captured slide screenshots from the UI smoke harness), the public-audience REVIEW LENS (arc/hook/close, one message per slide + claim titles, sequencing/no-forward-refs, jargon translation, cut/merge/reorder), and a REQUIRED OUTPUT BUNDLE (findings + corrective actions + a single structured outcome `story_review_satisfied` | `needs_iteration`). The pure module `scripts/loop/slides-story-review-contract.mjs` exports the outcome constants plus a fail-closed input-bundle validator and a result-shape validator (no I/O); the prompt template lives at `skills/dev-loop/templates/slides-story-review.md`. The two inline applications already run over the decks are recorded as the first two runs (`docs/presentations/applied-dev-loops-review-notes.md` #926, `docs/presentations/process-observability-review-notes.md` #927). Cross-linked from the UI loop doc, the README, and `docs/index.md`.
- **Process Observability deck refreshed + a self-contained shareable HTML render** (#927). `docs/presentations/process-observability-presentation.md` (Slidev) is restructured into one 9-slide public-audience story arc in the existing dark glass-card style: claim-style titles, one message per slide, jargon trimmed (`task state` / `pipeline latency` pills cut), the two overlapping handoff-cost slides merged, and a memorable close (*"Stop optimizing how fast you write code. Start measuring how long it waits."*) replacing the bare metric grid. A new grounding slide (`instrumented`) ties the abstract "observable state cuts delay" claim to what actually exists — the queue board lifecycle (owner / safe next step), the gate evidence trail (latest decision + findings), the deterministic next-action resolver, and "automate only where state supports safe continuation" via provider-agnostic CI waits and operator-induced post-merge worktree reclaim / long-done archival — in plain language, no version labels or raw identifiers. A standalone, CSP-safe `docs/presentations/process-observability.html` ships the full deck with all CSS inline (ported from `style.css`: navy gradient, glass cards, violet accent, blue kicker, mono pills) and the flowcharts rendered as inline CSS/HTML flow (no mermaid/CDN, no remote resources) — each slide is a stable-id `<section>` (`hero`, `interrupt-cost`, `handoff`, `blind-spot`, `observable-state`, `measurement-loop`, `instrumented`, `metrics`, `close`). A thin WebKit smoke spec (`test/playwright/observability-deck.spec.mjs` + `playwright.observability-deck.config.mjs`, `npm run test:playwright:obs-deck`) asserts every named section is present with no body horizontal overflow and captures the `hero`, `interrupt-cost`, `observable-state`, `measurement-loop`, `instrumented`, `metrics`, and `close` states under `test-results/ui-smoke/observability-deck/`; one designer/vision review pass (notes in `docs/presentations/process-observability-review-notes.md`, storytelling + visual sections) confirmed equal-height cards, legible flow diagrams, and a landing close with no corrective CSS required beyond the ported baseline.
- **Applied dev-loops deck refreshed for v0.4.0 + a self-contained shareable HTML render** (#926). `docs/presentations/applied-dev-loops-presentation.md` (Slidev) gains two slides in the existing dark glass-card style: the **gate fan-out/fan-in sub-loop** (build-once neutral bundle = full diff + 1-hop import adjacency, size-guarded; independent per-angle reviewers seeded with the identical bundle; `consolidateFanin` merges per-angle verdicts; no fork primitive / no Workflow dependency; fail-closed `fanout_fanin` verdict with enforced severity counts) and **the coordination runtime owning the full lifecycle** (enforced human merge via `autonomy.humanMergeOnly`, managed `tmp/worktrees/dev-loops/` worktrees, provider-agnostic `watch-ci`, queue board as a deterministic adapter). A standalone, CSP-safe `docs/presentations/applied-dev-loops.html` ships the full deck with all CSS inline and the mermaid diagrams rendered as inline CSS flow (no font/mermaid CDN, no remote resources) — each slide is a stable-id `<section>` for UI smoke targeting. A thin WebKit smoke spec (`test/playwright/applied-deck.spec.mjs` + `playwright.applied-deck.config.mjs`, `npm run test:playwright:deck`) captures the `hero`, `core-idea`, `parallel-review`, `trust`, and `impact` named states under `test-results/ui-smoke/applied-deck/`; one designer/vision review pass (notes in `docs/presentations/applied-dev-loops-review-notes.md`) fixed ragged card heights via equal-height grid rows. A follow-up **public-audience storytelling pass** restructured the deck to one ~8-slide narrative arc (claim-style titles, jargon translated to plain language, raw enum/pill walls cut to at most one identifier per slide as evidence, mechanism→outcome close) while keeping the dark glass-card visual identity unchanged.

### Fixed

- **`promote-plan` pushes the head branch before opening the PR, and the plan-commit step is re-runnable (#969).** Promotion committed the plan doc and then called `create-pr.mjs` (`gh pr create --head <branch>`) without first pushing the fresh local branch; when `gh pr create` did not auto-push, promotion failed `pr_create_failed` AFTER the plan commit had landed — an unrecoverable partial state (plan committed, no PR, no `prNumber`) where a re-run re-entered PROMOTE, found nothing to stage, and dead-ended on `git_commit_failed`. Fix in `scripts/refine/promote-plan.mjs`: (1) the head branch is now pushed with `git push -u origin <branch>` BEFORE invoking `create-pr.mjs`, so a fresh branch exists on the remote and `gh pr create --head` succeeds; a push failure fails closed with the new `git_push_failed` reason + detail and makes ZERO `gh` mutation. (2) The plan-commit step is idempotent: when the plan doc is already committed at HEAD (`git diff --cached --quiet` reports nothing staged), the commit is skipped instead of failing `git_commit_failed`, so a prior partial run recovers on a plain re-run — it pushes, opens the draft PR, and writes the plan↔PR link. The existing fail-closed ready-gate (zero `gh`), `already_promoted` idempotency, and the post-PR-open `failAfterPrOpen` link-commit recovery are unchanged. `test/loop/promote-plan.test.mjs` gains a real bare `origin` remote in its setup and three asserts: the fresh-branch run pushes the branch (asserted via `git ls-remote`) then opens exactly one draft PR, a partial state (plan committed at HEAD on the head branch, no `prNumber`) recovers on re-run with one `gh` call, and a missing-remote push failure surfaces `git_push_failed` with a detail and zero `gh` calls.
- **Decks fit the phone screen — no horizontal scroll, no clipped content (~390px)** (#937). On a phone the inline flow diagram (`.flow { min-width: max-content }`, `.node { white-space: nowrap }`) forced its grid track wider than the viewport, and `.slide { overflow: hidden }` then **clipped** anything taller than one screen (bullets cut mid-word, flow nodes sheared, slide bottoms cut off). The requirement is that content **fits** both dimensions, not that it scrolls inside a card. Fix (both `docs/presentations/applied-dev-loops.html` and `process-observability.html`): (1) **diagrams fit by stacking** — at `≤600px` `.flow` switches to `flex-direction: column` with arrows rotated to point down and `.node { white-space: normal }`, so the diagram lays out vertically and fits the width (measured 319px flow in a 319px card, zero internal scroll); `.flow { flex-wrap: wrap }` keeps desktop rows fitting too, and `.flow-scroll { overflow-x: auto }` is now a never-triggered last-resort safety, not the fix; (2) **no vertical clip** — `.slide { overflow: hidden }` → `overflow: visible` so a tall slide grows and the page scrolls between slides instead of cutting content off; (3) **mobile sizing** — a `≤600px` breakpoint reduces heading/card/padding scale and top-aligns slides so content fits comfortably. `min-width: 0` on grid children and `overflow-wrap: anywhere` on `p`/`li`/`code` are added. The CSS lives only in the HTML renders (the Slidev `*-presentation.md` sources carry none of it). The dark visual identity, section ids, CSP guard, and reduced-motion guard are unchanged. Both Playwright deck specs (`test/playwright/applied-deck.spec.mjs`, `observability-deck.spec.mjs`) are hardened to enforce **fit** at mobile (390×844): a settle barrier (`waitForLoadState("networkidle")` + `waitForFunction(innerWidth === 390)` + `document.fonts.ready`) removes the cold-start false-fail that measured desktop geometry; the horizontal check now fails if **any** element's `getBoundingClientRect().right > innerWidth + 1` (the `overflow-x:auto` scroller exemption is dropped — diagrams must fit) and asserts `document.scrollingElement.scrollWidth <= innerWidth + 1` (no horizontal page scroll); a vertical-clip check fails any section whose `clientHeight < scrollHeight` while `overflow-y` is `hidden`/`clip`; a guard-the-guard test confirms the fit check fails on a deliberately-wide element. Each spec also captures one mobile named state.

## 0.4.0

### Added

- **Opt-in human reviewer/assignee handoff at the pre-approval gate** (#920, pairs with #910). A new `approval.humanHandoff` config (`{ enabled (default false), candidatesFrom: ["codeowners"|"recent-committers"], assignees: [...] }`) plus `scripts/github/resolve-handoff-candidates.mjs` resolve a deduped, priority-ordered candidate list (configured `assignees` → CODEOWNERS last-match-wins for the PR's changed paths → recent committers via `git log`, excluding the PR author/bots; team handles flagged). `dev-loops gate offer-human-handoff --repo <o/n> --pr <n> [--assign <login>] [--request-review <login>]` prints the offer and, only on an explicit `--assign`/`--request-review` flag, runs `gh pr edit --add-assignee/--add-reviewer` — OFFER-only, never auto-assigns. Surfaced at the human-merge handoff so `autonomy.humanMergeOnly` routes the PR to a named human instead of parking silently. Disabled by default; fail-soft per source.
- **Provider-agnostic CI watcher `dev-loops loop watch-ci`** (#917). A block-waiting watcher (`scripts/github/probe-ci-status.mjs`) that polls a PR's combined check-run + commit-status state for the current head SHA until terminal or timeout — covering GitHub Actions, CircleCI, and any external commit-status/check-run, unlike Actions-only `gh run watch`. It short-circuits to `changed` when the head SHA advances mid-wait so the loop re-baselines. The no-checks path is race-safe: a fresh push where a provider (CircleCI/Actions) hasn't posted its first status yet is NOT settled green on the first poll — the watcher awaits a 2-consecutive-zero-check-poll grace before settling `none`→`success`, treats PR `statusCheckRollup`-expected-but-unreported checks as pending, and never fabricates green from a transient `gh api`/parse failure (an errored fetch forces pending; a persistent error settles `timeout`, never success).
- **`autonomy.humanMergeOnly` — fixed, non-overridable human-merge rule** (#910). Repos with a hard "a human must perform the merge" rule can now set `autonomy.humanMergeOnly: true` in `.devloops`, making merge an enforced repo invariant rather than a per-run default an explicit instruction can unlock. When set: `resolveAutonomyStopAt` always includes `merge` (even if `stopAt` is `[]`); the new authoritative gate `resolveEffectiveMergeAuthorized(mergeAuthorized, config)` fails closed — it returns `false` regardless of the `mergeAuthorized` envelope flag / explicit "merge" instruction — and the lifecycle resolver (`resolveLifecycleState`) therefore never advances to the terminal merge state, parking instead at the `pre_approval_gate` human-merge handoff. The agent still runs the full mechanical pre-merge evidence check and reports merge-ready, but never runs `gh pr merge` itself. The `queue run` path routes its `--merge-authorized` flag through the same gate. New resolvers `resolveHumanMergeOnly` / `resolveEffectiveMergeAuthorized` in `@dev-loops/core/config`; see [skills/docs/merge-preconditions.md](skills/docs/merge-preconditions.md).
- **Managed worktree lifecycle** (#909). dev-loops now owns the full worktree lifecycle through one shared canonical-path resolver. (1) **Namespaced naming:** loop-owned worktrees live at `tmp/worktrees/dev-loops/<kind>-<number>` (e.g. `issue-909`, `pr-908`) with no branch suffix, so the path is recomputable from the issue/PR number alone — `resolveWorktreePath({ repoRoot, kind, number })` (in `@dev-loops/core/loop/handoff-envelope`) is the single source of truth for create/provision/cleanup. (1a) **Lifecycle entrypoint:** `scripts/loop/ensure-worktree.mjs --repo-root <p> (--issue <n> | --pr <n>)` is the canonical create+provision command — it fetches the base remote, creates the worktree at the canonical path (or reuses one that already exists there, reporting a conflict instead of clobbering a different branch), then invokes the provisioning core in the same step, printing `{ ok, path, created|reused, provision }`. (2) **Auto-provisioning:** a new `.devloops` `worktree` section (`copyOnInit` / `linkOnInit`, both opt-in arrays of repo-relative literal paths or glob patterns) drives `scripts/loop/provision-worktree.mjs`, which copies (`fs.cp`) or absolute-symlinks the configured gitignored files/dirs from the main checkout into a fresh worktree — directories recurse, sources outside the main checkout are rejected, missing sources / empty globs fail soft, and reuse is idempotent. It does not run `npm install` and is not a `node_modules` mirror. (3) **Namespace-scoped cleanup:** `scripts/loop/cleanup-worktree.mjs` resolves the canonical path and runs `git worktree remove --force` + `git worktree prune` from the main checkout, refusing any path not under `tmp/worktrees/dev-loops/` and failing soft on git errors. See [docs/worktree-guidance.md](docs/worktree-guidance.md).
- **Consumer migration guide** (#769): [`docs/migrating-to-dev-loops.md`](docs/migrating-to-dev-loops.md) walks existing `pi-dev-loops` consumers through every breaking change — package name (`pi-dev-loops`→`dev-loops`, `@pi-dev-loops/core`→`@dev-loops/core`), repo slug, the full `PI_*`→`DEVLOOPS_*` env-var mapping (a deliberate clean break with no aliases), and the `.devloops` config location. Linked from the README. The env vars are not shimmed by design (`0.x`, YAGNI); the legacy `.pi/dev-loop/settings.yaml` config path still loads with a deprecation warning.

### Changed

- **Post-merge board archive is now a standard step of the post-merge hook** (#918). `archive-done-items.mjs` (applying `queue.archiveOlderThanDays`, default 7d) is wired into the canonical `merge-preconditions.md` "Post-merge" surface alongside worktree cleanup, and the copilot-pr-followup post-merge step is no longer framed as merely optional. Operator-induced (runs after merge); best-effort — the hook ignores a non-zero exit so a failed archive never blocks merge completion. NOT a cron/scheduled job.
- **Queue management surfaced under `dev-loops queue`** (#912). The queue board management commands (`add`, `list`, `reorder`, `move`, `sync-status`, `archive-done`, `ensure`) are now discoverable and runnable under `dev-loops queue <sub>` alongside the existing `queue run` — `dev-loops queue --help` lists them all with one-line descriptions. They delegate to the same `scripts/projects/*.mjs` implementations; `dev-loops project <sub>` is retained as a back-compat alias group (lowest-churn: the routing table is data-driven, so `queue` reuses the existing script mappings). Flag consistency: `queue add` now accepts `--column <name>` for the Status column (matching `queue list`), with `--status <name>` kept as a back-compat alias. `move`/`sync-status` keep their distinct `--to-column`. Removes the only reason to hand-write `gh api graphql` for queue work.
- **BREAKING: Node floor raised `>=20` → `>=24`** (#911). `engines.node` is now `>=24` in both `dev-loops` and `@dev-loops/core` (the latter previously declared no floor). CI already runs Node 24; this makes the supported floor explicit and unlocks Node 24 stdlib (e.g. native `fsp.glob`/`path.matchesGlob`). Consumers on Node < 24 must upgrade.
- **BREAKING: all `PI_*` environment variables renamed to `DEVLOOPS_*` — no aliases, no fallback** (#905). Completing the env-var neutralization left half-done by the rebrand (#763, surfaced in #769), every dev-loops-owned operational env var is now `DEVLOOPS_*`-only; the previous neutral-first alias pattern (which honored `PI_SUBAGENT_RUN_ID` / `PI_SUBAGENT_AVAILABLE` as fallbacks) is removed. This is a deliberate `0.x` breaking change — consumers must rename their env vars (migration covered by #769). Mapping: `PI_SUBAGENT_RUN_ID`→`DEVLOOPS_RUN_ID`, `PI_SUBAGENT_AVAILABLE`→`DEVLOOPS_SUBAGENT_AVAILABLE`, `PI_PREFLIGHT_BYPASS`→`DEVLOOPS_PREFLIGHT_BYPASS`, `PI_PREPUSH_BYPASS`→`DEVLOOPS_PREPUSH_BYPASS`, `PI_WORKTREE_BYPASS`→`DEVLOOPS_WORKTREE_BYPASS`, `PI_DEV_LOOPS_DEBUG`→`DEVLOOPS_DEBUG`, `PI_DEV_LOOP_STALE_RUNNER_MAX_AGE_MS`→`DEVLOOPS_STALE_RUNNER_MAX_AGE_MS`, `PI_DEV_LOOP_DETACHED`→`DEVLOOPS_DETACHED`. The Pi-runtime-injected vars dev-loops reads only to *integrate* with the Pi harness (`PI_SESSION`, `PI_INTERACTIVE`, `PI_AGENT_SESSIONS_DIR`, `PI_SUBAGENT_SESSIONS_DIR`, `PI_SUBAGENT_ASYNC_RUNS_DIR`, `PI_SUBAGENT_ASYNC_RESULTS_DIR`) are external Pi-platform contract vars and intentionally unchanged. A `cli-harness-agnostic` guard now asserts the code is harness-agnostic: no dev-loops-owned `PI_*` env var survives, and the Pi-runtime-injected vars may only be read at the harness-adapter boundary (`pi-adapter.mjs`, `conductor-monitor.mjs`) — a `PI_*` read anywhere else in code fails.

- **Remaining `pi-dev-loops` → `dev-loops` identity references aligned** (#906, closes #768). Residual stale-slug strings in a contract doc (and its generated `.claude` mirror) were corrected, guarded by the `docs-identity-contract` test so user-facing identity surfaces stay consistent.

### Fixed

- **`queue run` no longer fabricates `done` for undispatched items** (#913, data-integrity). The queue driver is a deterministic adapter over the board, not the orchestration harness — but its missing-orchestrator path fell back to a per-entry `{ ok: true, pr: null }`, which silently marked every `Next Up` item `done` and moved it to **Done** with `pr: null`/`runId: null` in ~1s without any work happening (a single resolve pass would "complete" an entire backlog untouched). The driver now requires a verifiable terminal signal (an orchestrator-supplied result, e.g. a merged PR) before reflecting an item to Done; with no orchestrator wired (`runEntry`) in the current harness, `dev-loops queue run` is a no-op that leaves every board column unchanged and reports `reason: "no-orchestrator"`. The legit reflect path (real merged PR → Done) is preserved.
- **Queue board `Next Up` membership now resolves from a title-only `.devloops` config** (#904, closes #901). `resolveNextUpOrder` passed the project number as a raw number, which `list-queue-items`' string-only `--project` guard rejected — so a board configured by `queue.boardTitle` alone reported "Board configured but unavailable; nothing to run" and never read `Next Up`. The project ref is now passed as a string.

## 0.3.0

### Added

- **Gate fan-out/fan-in review sub-loop** (epic #867, #895). The draft and pre-approval gates now run as a real fan-out on a **build-once neutral context bundle**: a deterministic context-builder script resolves review angles and builds ONE neutral bundle (full diff + adjacent code), then each independent, read-only `review` agent is seeded with that identical bundle verbatim and scoped to one angle, and a fan-in step consolidates the per-angle verdicts into a disposition ledger. The cost win is work-dedup (build once vs. N× re-derivation; a shared-prefix prompt-cache is an opportunistic bonus) — there is no fork primitive and no Workflow-tool dependency. Verdicts record their execution mode (`--execution-mode fanout_fanin | inline_single_agent`, with `--inline-reason`; #875) so the audit trail shows how the gate was actually run. See [docs/gate-review-sub-loop-contract.md](docs/gate-review-sub-loop-contract.md).
  - **Context-builder handoff + dynamic angles** (#880, #895). A `write-gate-context` step emits the per-gate scope/diff artifact plus a deterministic, neutral `adjacentCode` bundle (each changed file's 1-hop import callers/callees/imports, with size guards + a stripped/truncated/missing manifest) that every reviewer is seeded with verbatim, and angles are resolved dynamically (configurable `mandatory` set plus `gates.dynamicAngles`), bounded by `gates.maxFanoutReviewers` (default 8).
  - **Independent scoped reviewers + fan-in consolidation** (#881, #895). Per-angle `review` agents are independent fresh-context Agents seeded with the neutral bundle (never inheriting the main agent's state); they emit structured findings, and `consolidateFanin` merges them and computes the `fanout_fanin` verdict against `blockCleanOnFindingSeverities` (`must-fix`, `worth-fixing-now`).
  - **Full-diff + adversarial scoped review with scope widening** (#886, #885, #895). The context-builder builds the full PR diff and a generous adjacent-code bundle once, and reviewers use it as their base and widen only per-angle when needed. Reviewers run adversarially against the complete change — this surfaced real defects (arg coercion, head-SHA casing, markdown injection, dead seams) that the prior single-pass review missed.
- **Fan-out findings posted to the PR** (#888, #887). The gate posts a single marker-tagged, idempotent PR comment listing its findings so Copilot and humans see them, and the loop fixes/resolves its own findings as it does Copilot comments. Opt out with `gates.postFindingsComments: false` (default on).
- **Configured board drives queue membership** (#884, #864). A configured GitHub Projects board's `Next Up` column is now the authoritative source of queue membership and ordering (not just status); emptiness reports a precise verdict (`queue_empty` / `board_empty` / `board_unavailable`) instead of a misleading generic message.

### Changed

- **Gate fan-out evidence enforcement is now ON by default** (#882, #879, epic #867 final phase). A clean gate verdict requires the gate to have run via `--execution-mode fanout_fanin` with a findings-log ledger for the head SHA; the pre-merge evidence check fails closed otherwise. Repos can opt out with `gates.requireFanoutEvidence: false`.
- **Board status auto-syncs on dev-loop transitions** (#883, #874). A linked issue's board Status column is synced on loop transitions (e.g. PR opened → `In Progress`, merged → `Done`) via local `gh` auth — best-effort and non-fatal. Repairs the `move-queue-item` lookup that passed numeric (not string) project/item refs.

### Fixed

- **Skill shims import `@dev-loops/core` via its package specifier** (#890). `skills/dev-loop/scripts/log-bash-exit-1.mjs` and `phase-files.mjs` previously reached into core through cross-package relative paths (`../../../packages/core/src/...`), which are broken on disk for npm consumers because the published `dev-loops` package ships `skills/` but not `packages/core/`. They now import via the `@dev-loops/core` `exports` map. A contract test guards against reintroducing relative cross-package imports under `skills/`.
- **Draft-gate deadlock on ready PRs resolved** (#891). Posting a `draft_gate` verdict on a PR that is already ready-for-review (e.g. opened directly as ready) no longer dead-ends. `upsert-checkpoint-verdict` now (a) treats an already-satisfied draft gate as an idempotent no-op instead of a hard error, and (b) when a ready PR still needs clean draft-gate evidence, performs the draft→post→ready transition automatically — preserving the caller's execution mode (`fanout_fanin`), findings, and ledger. This is the fanout-aware analogue of `reconcile-draft-gate` (which only posts inline and so cannot satisfy `requireFanoutEvidence` on the draft gate).
- **PR self-assignment is now mechanically enforced** (#894). The draft-PR wrapper is renamed `scripts/github/create-draft-pr.mjs` → `scripts/github/create-pr.mjs` (`dev-loops pr create-draft` → `dev-loops pr create`, with the old subcommand kept as a deprecated alias). It now defaults `--assignee @me` when no `--assignee` is given (while still honoring an explicit `--assignee <login>`), so every PR opened through the canonical path is ALWAYS a draft and is always assigned — self-assigned by default — closing the silent gap where unassigned PRs (e.g. #889, #892, #893) missed the owner's assignee inbox. A new contract guard (`test/contracts/canonical-pr-creation-contract.test.mjs`) fails if any skill/agent procedure doc instructs opening a PR with raw `gh pr create`.
- **Post-round-cap convergence deadlock resolved** (#896). At the Copilot round cap with clean threads + green CI, a post-cap head that Copilot will not re-review now routes to a clean fallback that permits the `pre_approval_gate` to review the current head, instead of dead-ending at `ready_to_rerequest_review` (the deadlock #848 intended to prevent). Root cause: the coordination-context loader did not pass the resolved config into the loop interpreter, so `maxCopilotRounds` was unseen and `ROUND_CAP_CLEAN_FALLBACK` never resolved; the draft-gate round-reset is now a shared helper so `request-copilot-review` and `detect-pr-gate-coordination-state` agree on the round count. Genuinely-blocked states (failing/unconfirmed CI, unresolved feedback, conflicts, missing draft-gate evidence) still hold.
- **Gate verdict renders consolidated per-angle findings structurally** (#898). A `fanout_fanin` verdict comment renders the per-angle fan-in findings as a readable list (per-angle verdict + findings) via a new `--findings-json`, instead of collapsing the summary to a single run-on line. The gate-evidence parse contract is preserved (a single-line digest still anchors the `Findings summary:` field, and `gateEvidenceNote` is carried), input shape is validated (per-angle or flat-grouped; unrecognized input is rejected rather than silently dropped), and `--findings-summary` remains the inline fallback.

## 0.2.8

### Added

- **Local post-merge board archive** (#869). The dev-loop post-merge step archives `Done`-column board items older than a configurable threshold (`.devloops` `queue.archiveOlderThanDays`, default 7d) using local `gh` auth — best-effort, non-fatal, no CI/cron/PAT. On-demand `dev-loops project archive-done` is unchanged.
- **Gate execution-mode disclosure scaffolding** (#867, partial). Gate verdicts can record `--execution-mode` / `--inline-reason`; opt-in `gates.requireFanoutEvidence` (default off) is available. (Live fan-out/fan-in execution remains follow-up.)

### Fixed

- **`dev-loops project move` repaired** (#865). Item lookup now resolves both issue/PR number and node-id refs against a single paginated board-item list; fixes the `ITEM_NOT_FOUND` (unpaginated `first:10`) and the invalid `ProjectV2.item` GraphQL query.

### Changed

- **Index-based arg parsers migrated to `node:util.parseArgs`** (#857, #870). The remaining `argv[++i]` parsers across `scripts/projects`, `scripts/loop`, `scripts/claude`, and `archive-done-items.mjs` now use `parseArgs`; CLI contracts preserved and boolean flags reject an explicit inline `=value`.

### Documentation

- **Tooling-internals anti-pattern promoted** (#861, #863). The "use the CLI/`--help`/`skills/docs/` instead of reading tooling source" rule is now a canonical entry in `skills/docs/anti-patterns.md`, with a local failure-triage fast path and pointers from the `developer`/`fixer` agents.

## 0.2.7

### Fixed

- **Deterministic, harness-aware dev-loops CLI invocation** (#801, #833). Pi runtime skills/agents now invoke the package-local `node <dev-loops-package-root>/cli/index.mjs`; the generated Claude tree pins `npx dev-loops@<version>` (version injected at generation time) so the plugin and CLI no longer drift.
- **Round-cap Copilot-gate deadlock resolved** (#848). At the round cap with clean threads + green CI, the loop routes to a clean fallback instead of dead-ending at `waiting_for_copilot_review` when a lingering reviewer assignment / post-cap push leaves the head unreviewed. The pre-approval gate still reviews any post-cap head.
- **Draft-gate ordering after external un-draft** (#836). Verified + regression-guarded: a non-draft PR without clean `draft_gate` evidence is routed to `reconcile_draft_gate` and cannot merge; the relayed-authorization deadlock is moot under the single-agent Claude harness.

### Added

- **Projects board reorder + Done-cleanup CLI** (#789). `project reorder move-to-top|move-after|order` (with `--dry-run`, diff-friendly output, cross-column fail-closed) and `project archive-done [--older-than]`.
- **Loop-state-driven board status sync** (#793). Board Status column is derived from the loop state via a pure, config-driven mapping (`queue.statusColumns` / `queue.stateColumnMap`), opt-in, fail-open, reverse-safe.

### Changed

- **Arg parsing migrated to `node:util.parseArgs`** (#808). All hand-rolled `while/shift` parsers (49 scripts/modules + 3 core files) now use `parseArgs` via shared adapters, with CLI contracts preserved. (Index-based parsers tracked in #857.)

## 0.2.6

### Fixed

- **Claude plugin hooks are self-contained** (#843). The bundled PreToolUse/PostToolUse hooks
  imported a bare `@dev-loops/core`, which is unresolvable from the marketplace plugin cache (no
  `node_modules` there), so every hook crashed on load — the two PreToolUse gates were silently
  failing open. The asset generator now emits a vendored, relative-import hook bundle
  (`.claude/hooks/_*.mjs`) from the canonical core modules, drift-guarded by the no-drift check.
- **Retrospective gate is opt-in for consumers** (#841). `extension-defaults.yaml` shipped
  `requireRetrospective`/`requireRetrospectiveGate: true`, forcing the retrospective merge gate on
  every consumer's product PRs against the code default and the contract. Both now default `false`;
  the dev-loops repo opts in via its own `.devloops`.
- **Dev mode is opt-in for consumers** (#846). `extension-defaults.yaml` shipped
  `devModeDefault: true`, pushing every consumer's product phases into the loop's self-improvement
  mode (which edits the loop's own skill/agent prompts). Now defaults `false`; the dev-loops repo
  opts in via `.devloops`.

### Added

- **Merge-blocking PR-title gate** (#842). The gate pipeline now flags `WIP`/`[WIP]`/`DRAFT`/
  `DO NOT MERGE`/`🚧` (case-insensitive) in the PR **title**, blocking the draft→ready transition
  and — for non-draft PRs — entry to the pre-approval gate and final approval. Documented in the
  merge-preconditions and PR-lifecycle contracts.
- **Effective async-start mode is surfaced** (#834). The handoff envelope now reports
  `asyncStartEffective` and `asyncStartRelaxedBy` alongside the unchanged configured
  `asyncStartMode`, so the Claude harness relaxation (`required`→`allowed`) is visible instead of
  reading as a contradiction.

### Changed

- **Deduplicated PR aggregation** (#809). The duplicated `listOpenPrs` helper is extracted into a
  shared `scripts/loop/_loop-pr-aggregation.mjs` and reused by `conductor-monitor.mjs` and
  `run-conductor-cycle.mjs`. No behavior change.

## 0.2.5

### Changed

- **Claude Code: the Copilot PR follow-up loop runs inline** (#838, completing the umbrella
  collapse from #837). The copilot-pr-followup skill's Pi "persistence model" — *subagents do
  bounded work and exit on the wait boundary; the main session re-dispatches* — is now scoped to
  Pi via `<!-- pi-only -->`. Under the Claude harness the single dev-loop agent runs the
  `watch → fix/reply/resolve → re-request → watch` loop **inline**: the helper-owned wait tools
  (`dev-loops loop watch-cycle`, `gh run watch`, `dev-loops gate probe-copilot`) block inline and return, so
  the agent keeps looping until terminal or the watch budget expires — no exit-and-redispatch. The
  outer-loop checkpoint, watch budget, the forbidden-shell-watcher rules, and the gate requirements
  are unchanged and harness-agnostic. Pi behavior is unchanged.

## 0.2.4

### Changed

- **Claude Code: the dev-loop runs as a single agent** (#837). The Pi "umbrella" execution model —
  a strictly read-only main agent that must dispatch an async `dev-loop` subagent, with all
  mutations and state-changing CLI (`gate`/`pr`/`loop`) confined to that subagent — is now scoped
  to Pi only. Under the Claude harness the dev-loop agent performs the steps directly: it reads and
  writes repo files, runs git/PR operations, runs the `dev-loops` CLI, and **posts gate verdicts
  under the operating session's identity** (fixing clean gates that previously stalled, unable to
  record their verdict without separate "coordinator authority"). The `gh pr ready` draft-gate
  guard still applies, and the read-only boundary remains available opt-in via
  `DEVLOOPS_MAIN_AGENT_READONLY=1`. Implemented by scoping the Pi read-only/dispatch contract in
  `main-agent-contract.md` and the dev-loop skill's startup procedure behind `<!-- pi-only -->`
  markers; the asset generator now applies that stripping to bundled contract docs too, so the
  Claude plugin ships the single-agent model while Pi keeps the full contract. Pi behavior is
  unchanged. (Follow-up #838 tracks the copilot-pr-followup/conductor async-execution model.)

## 0.2.3

### Added

- **Opt out of the Copilot review gate via `refinement.maxCopilotRounds: 0`** (#832). For repos
  without a Copilot reviewer configured (or that prefer local-harness-only review), setting
  `maxCopilotRounds: 0` disables the external Copilot review cycle entirely — the loop runs
  `draft_gate → pre_approval_gate` with no Copilot request or wait. The config schema now accepts
  `0` (`nonnegative`; negative still rejected); `evaluatePrGateCoordination` routes `0` through the
  existing `internal_only` path, `shouldGuardCopilotReviewRequest` never forces a request at `0`,
  and the watch-cycle handoff (`copilot-pr-handoff`) skips the request too. Default (`5`) unchanged.
  Documented in the README, extension config docs, and the `copilot-pr-followup` skill.

## 0.2.2

### Fixed

- **Claude Code: dev-loop no longer dead-ends on the async-start contract** (#830). Running
  `/dev-loop` from the installed plugin failed immediately because `dev-loops loop startup`
  enforces an async-start contract — it requires a run-id env marker (`DEVLOOPS_RUN_ID` /
  `PI_SUBAGENT_RUN_ID`) that Pi injects when dispatching an async subagent but Claude Code's
  Agent tool does not. That contract guards against detached, uninspectable background
  processes, a risk that does not exist under Claude's Agent model (each subagent run is
  visible and inspectable). The async requirement remains configurable via
  `workflow.asyncStartMode` (`required` | `allowed`); under the Claude harness it is now
  **relaxed to `allowed` at runtime** via `resolveEffectiveAsyncStartMode`, which consults the
  new `isClaudeHarness` helper (`CLAUDECODE=1`) in `@dev-loops/core/loop/run-context`. An
  explicit `DEVLOOPS_RUN_ID` still resolves as `valid`, and Pi behavior is unchanged (outside
  Claude the configured mode is honored verbatim).
- The async-start CLI contract test is now hermetic — it clears `CLAUDECODE` (and the run-id
  markers) so the rejection path is exercised regardless of the harness the suite runs under.
- The generated `dev-loop` skill prose no longer claims `PI_SUBAGENT_RUN_ID` is *required* — it
  now describes the async run-id marker (`DEVLOOPS_RUN_ID` / `PI_SUBAGENT_RUN_ID` alias) and notes
  the Claude-harness relaxation, so the plugin's docs match the runtime behavior. Subagent
  spawning via the `dev-loop` agent is confirmed correctly wired: it grants the `Agent` tool
  (the current subagent-spawning tool, renamed from `Task` in Claude Code v2.1.63) and the
  strategy skills delegate to the worker agents (`developer`/`quality`/`refiner`/`fixer`/`review`/`docs`).

## 0.2.1

### Added

- **Claude Code marketplace catalog** (#828): ship `.claude-plugin/marketplace.json` at the repo
  root so the repo can be added as a plugin marketplace (`/plugin marketplace add mfittko/dev-loops`,
  or the *Manage Plugins → Marketplaces → Add* UI) and the plugin installed with
  `/plugin install dev-loops@dev-loops`. The catalog's single plugin entry sources the existing
  in-repo plugin at `./.claude`; the plugin version stays authoritative in `plugin.json`. A
  contract test locks the catalog shape, and `.claude-plugin/` is added to the npm `files`
  allowlist. Verified end-to-end with `claude plugin validate` + `marketplace add`/`install`
  (4 skills, 7 agents, 2 hooks).

### Changed

- `plugin.json` now declares an `author` (clears the marketplace-validation warning).
- README "Claude Code plugin" section drops the `(preview)` framing and documents marketplace
  install; the two CLI help lines that said plugin packaging was "in progress" are updated.

## 0.2.0

### Added — Claude Code harness (agent-harness-agnostic dev-loop)

dev-loops is now dual-harness: it runs under both Pi and Claude Code. Pi behavior is unchanged.

- **Harness adapter seam** (#770): a neutral `ExtensionHarnessAdapter` (exec + lifecycle +
  command registration + ui) with Pi and Claude adapters; `@dev-loops/core/harness`.
- **Neutral run-id contract** (#771): `DEVLOOPS_RUN_ID` (with `PI_SUBAGENT_RUN_ID` as a
  backward-compatible alias) via `@dev-loops/core/loop/run-context`; all runner-coordination /
  async-start readers route through it.
- **Generated `.claude` assets** (#772, #816, #817): a deterministic generator emits
  `.claude/agents` + `.claude/skills` from the canonical Pi sources (`@dev-loops/core/claude/
  asset-generation`), with the Pi→Claude tool-name mapping, bundled shared contract docs +
  templates, and Pi-runtime-only prose stripped via `<!-- pi-only -->` markers.
- **Claude hooks + read-only enforcement** (#773): PreToolUse Bash draft-gate guard + Write/Edit
  main-agent read-only guard (`@dev-loops/core/claude/hook-decisions`), opt-in via
  `DEVLOOPS_MAIN_AGENT_READONLY`.
- **CLI Pi-neutrality** (#774): `npx dev-loops --help`/`status` run with no `@earendil-works/pi-*`
  present; Pi-only install strings no longer shown unconditionally.
- **Headless entry** (#775): a `claude -p` headless dev-loop entry (`@dev-loops/core/claude/
  headless-entry`) that mints + propagates the run id, plus an offline read-only CI/Docker smoke
  (`npm run smoke:headless`); the Pi Docker smoke is preserved (dual-harness).
- **Claude Code plugin** (#818, #824): `.claude/.claude-plugin/plugin.json` (plugin root
  `.claude/`) bundling the dev-loop agents, skills, and hooks —
  `claude --plugin-dir .claude` loads 4 skills, 7 agents, 2 hooks.

### Changed

- `@dev-loops/core` bumped to `^0.2.0` (new `claude/*`, `loop/run-context`, and
  `loop/bash-command-classify` exports).

## 0.1.3

### Fixed

- Removed a stale `defaults.yaml` from the `files` allowlist and regenerated the lockfile (#806).

## 0.1.2

### Changed

- Ship the extension-packaged dev-loop defaults only; removed the duplicated
  `.pi/dev-loop/defaults.yaml` (#805).

## 0.1.1

### Changed

- Renamed the Pi peer dependencies to the `@earendil-works/pi-*` scope (#799).

## 0.1.0

### Added

- Initial publishable `dev-loops` v0.1.0 package metadata.
- Primary npm package name is the unscoped `dev-loops` (`@mfittko/dev-loops` kept only as a documented fallback).
- Public npm provenance and access configuration.
- `@dev-loops/core` `^0.1.0` dependency for the extracted scoped runtime package.
- CLI entrypoint `dev-loops` via `./cli/index.mjs`.
- Repository, bugs, and homepage URLs pointing to `mfittko/dev-loops`.

### Removed

- Broken `postinstall` lifecycle script that failed on consumer installs.
