Skip to content

v3.64.0 - Guards That Notice Their Own Blind Spots

Choose a tag to compare

@edmondscommerce edmondscommerce released this 14 Sep 23:51
· 3582 commits to main since this release

Release v3.64.0 - Guards That Notice Their Own Blind Spots

Release Date: 2026-09-14
Type: Minor Release

Summary

This release is a dogfooding-driven hardening pass. Four separate
false-positive/false-negative classes in the command-judging guards are
closed — a heredoc or -m/-F message VALUE read as a command, a
multi-line command convicted past its own end, a backslash-newline
word-split that evaded every guard, and a malformed or unreadable config
silently taking an event down. Alongside that, the daemon gains several new
"notices its own blind spot" checks: a second session upgrading its own
installed daemon, a git pull that left config or handler code unloaded, a
reference clone read without pulling first, and a QA/acceptance run graded
by a daemon that predates the working tree it is judging. Three niggles
ledgers (Plans 00377, 00405, 00407) and one release-review follow-up ledger
(Plan 00364) are worked to zero. It ships as MINOR rather than PATCH because
it adds eleven new handlers, a new reference_repos config block, and
several new CLI verbs (status-line-explained, check-source-fresh,
reference-repos, issue-report, deploy-core-docs) — see Changes below.
One breaking change: plan-qa --json renames level to severity,
carried by a mandatory post-upgrade task — see Breaking Changes below.

Highlights

Everyone

  • A wait loop that watches a wrapper's $! is now caught. When a Bash
    command backgrounds a job behind setsid, nohup sh -c, timeout or
    env and then asks about $!, that pid can belong to the wrapper, not
    the job — setsid in particular forks and its parent exits at once, so
    kill -0 $! reports the job finished while it is still running, which is
    exactly how a real incident lost its first waiter. The message names three
    remedies: a pidfile the job writes itself, pgrep -P <wrapper-pid>, or
    waiting on a log marker. The two verdicts differ, so it is worth knowing
    which you will get: setsid is denied, because its parent exits
    immediately and the wait is guaranteed wrong; nohup sh -c, timeout and
    env are advisory, because those may or may not fork and the pid may
    well be the job's.
  • repair no longer blames a missing uv for a lock failure.
    hooks-daemon repair answered every FileNotFoundError with "install
    uv", including one raised by the venv build lock contending with another
    daemon or repair — that gap now reads as "the lock just became free"
    instead, and the "install uv" message is scoped to the spawn that can
    actually mean it.
  • A failing git listing no longer reads as a clean slate.
    release-slate-check used to treat a broken git worktree list or
    git for-each-ref as "no branches ahead, no live worktrees" — a failed
    read is now carried into the report as Slate: UNDETERMINED, exiting 1
    rather than 0; --accept does not rescue it.
  • Private lint output directories, and a hook that no longer stalls on a
    wide character range.
    The Kotlin and Rust lint strategies used to write
    compiler output to a fixed, guessable shared-temp path — a symlink-redirect
    risk on a multi-user host — and now write to a private, unguessable
    per-process directory. secret_file_guard's expansion cap on a bracket
    character range now applies before the range is built rather than after,
    cutting a 13-second cost for a wide literal range on a PreToolUse hot path.
  • The plan QA commit gate stops paying per plan folder. A git commit
    used to ask git what was staged once per plan folder — 363 subprocesses
    for one commit in this repository — and now asks once. Alongside it: a
    non-UTF8 PLAN.md no longer raises out of the commit gate, settings.json
    is written through a temp file and renamed into place, and transport verify names a down daemon instead of reporting the failure as a broken
    exit-code translation.
  • A worktree's hooks now reach the worktree's own daemon. A deployed
    relay forwarder bakes absolute paths, so a linked worktree — which
    inherits .claude/hooks/* byte for byte — used to dial the MAIN
    checkout's socket and be judged by the main checkout's config and
    handlers, with no error anywhere saying so.
  • The QA audits no longer pass by scanning nothing. Two audit scripts
    matched their exclusion patterns against each file's ABSOLUTE path, so a
    checkout merely living under a matching directory name — a git worktree
    under untracked/worktrees/<branch>/ — excluded itself entirely and
    reported a clean scan having looked at nothing. Both now fail loudly when
    they collect zero candidate files, instead of reporting silence as safety.
  • A declared project wins at any depth, and editing an existing .md is
    never a location violation.
    markdown_organization denied an edit of an
    existing .md file inside a first-party clone installed two vendor/
    levels deep, with no escape hatch that rescued it. A projects:
    declaration is now consulted before the built-in inference, and a .md
    file that already exists is no longer treated as a NEW-file location
    violation.
  • setup_worktree.sh now runs to the end, and its venv can run QA. Its
    editable-install check used to crash the script silently right after
    "Venv created" (a uv-managed venv ships no pip), and the venv it built
    had no dev extra, so import pytest failed in every fresh worktree.
    Both are fixed.
  • A daemon restart in a git worktree no longer regenerates and commits
    CLAUDE.md on the worktree's own branch,
    closing a source of conflicts
    between a worktree's branch and main on every merge back.
  • The upgrade route now works from a fresh clone of a client repository.
    A teammate cloning an existing client repo gets its committed config but
    no gitignored .claude/hooks-daemon/ checkout or venv — a state the
    documented upgrade route used to refuse outright. scripts/upgrade.sh now
    clones the daemon fresh, reads the previous version from the committed
    .claude/HOOKS-DAEMON.md, and skips the daemon-stop step when there is
    nothing to stop.
  • Hooks now reach a daemon whose socket fell back to the runtime
    directory, and an upgrade always serves the project it was given.
    When a
    project's path is too long for the AF_UNIX socket limit, the daemon
    relocates its socket and PID file — but the hook launcher only followed
    the socket there, so every hook reported daemon_startup_failed while
    status showed the daemon RUNNING. Install and upgrade also now anchor
    to the project root they were given instead of the shell's current
    directory.
  • The post-upgrade reports are bounded, offloaded to files, and chunked
    for subagents.
    A canary upgrade across the full v3 span delivered 143 KB
    of combined stdout from check-truth-changes and
    check-config-migrations, of which roughly sixty of seventy-three
    truth-change entries never reached the agent past Claude Code's exit-1
    truncation — silently. Both commands now write their full report to a
    file and print a bounded summary; check-truth-changes also writes one
    self-contained chunk file per topic, and the upgrade skill dispatches one
    subagent per chunk in parallel.
  • lsp_noise_checker, a new SessionStart advisory. Claude Code injects
    a language server's diagnostics after every edit, and a stream carrying
    noise from trees that are not project code trains the reader to skim it.
    One check per supported language present (Python, TypeScript/JavaScript,
    Go, Rust, PHP) now reports a server that isn't told to exclude the
    daemon's runtime directory, the plan directory, vendored/build trees and
    the remote-docs tree — with that language's exact fix — plus a companion
    check for a language server that started before its config was last
    written. pyright is also now a QA gate in this repository, driving the
    count of real, unsuppressed diagnostics to zero.
  • hooks-daemon status-line-explained explains every status-line icon.
    Answers "what does this icon mean, and what does its current value mean?"
    for every status-line segment in a project, closing a gap
    explain-rule/explain-handler could not reach, since a status-line
    segment is advisory-only and declares no blocking rule.
  • A stale daemon can no longer pass or fail QA silently. The acceptance
    harness dispatches through the live daemon socket, so a daemon that
    predates the working tree it is grading used to produce both a false
    failure (denying correct code because the daemon still ran the old,
    broken version) and the more dangerous false pass (a genuinely broken
    tree sailing through because a stale daemon still answered with the old,
    correct code). The daemon now reports a content fingerprint of the code
    it loaded at startup, and every socket-dispatched acceptance test compares
    it before running any probe-specific assertion — hooks-daemon check-source-fresh runs the same check on demand. QA stays read-only;
    nothing restarts the daemon automatically.
  • The bracket-trick rewrite in self_matching_process_probe now names the
    actual offending text.
    A probe followed by an unescaped fallback message
    (|| echo "no <name>") could still self-match on the fallback text even
    after the probe itself was bracket-tricked; the deny message now names the
    exact text still responsible instead of silently repeating advice already
    applied.
  • A fully completed plan is closed, and both human-approval gates are now
    real, opt-in keys.
    The deployed workflow document used to tell every
    agent to "ask user for approval before marking plan complete" with
    nothing in the daemon enforcing it — either work stalled on a human who
    never asked for the gate, or the instruction was ignored. It now says what
    the daemon does: a fully completed plan is closed by the agent that
    completed it, unless plan_workflow.close_requires_human_approval is turned
    on, in which case plan_close_approval denies the status FLIP and names
    hooks-daemon approve-plan-close NNNNN. Its sibling gate is a different
    thing on a different trigger, and the two are easy to conflate:
    worktree.merge_to_main_requires_human_approval turns on
    merge_to_main_approval, which denies a git merge / gh pr merge into
    the default branch run from the MAIN checkout and names
    hooks-daemon approve-merge <branch>. It never fires on a PLAN.md edit,
    so turning it on does not gate plan closes. A new docs-QA check blocks any
    future daemon-owned document from prescribing an unenforced approval gate.
  • plan-qa --sweep and docs-qa --sweep are now registered QA tools, and
    a merge no longer lands drift unremarked.
    Both sweeps always exited
    non-zero on findings but were never wired into the QA gate, CI or the
    release-slate check — a merge once resurrected an archived plan folder
    and its findings survived a fully green run of all three. Any finding at
    either severity now fails QA. A new merge_qa_report advisory also runs
    after git merge/pull/rebase and reports only what that operation
    actually introduced, since none of the commit-time gates ever see those
    three commands.
  • A plan-folder-scoped glob on the next line is no longer read as a
    discovery scan, and a \<newline> line continuation is now recognised.

    Listing the files of ONE named plan folder — whose number is already in
    the path — used to be refused because an echo on one line could reach
    across the newline and borrow a glob character from an unrelated next
    line.
  • A word split across two lines no longer evades every blocking guard
    (security fix).
    The shell REMOVES a backslash-newline line continuation
    and joins the two halves into one word — git pu\+newline+sh --force
    really does run git push --force — but the daemon substituted a space
    instead, splitting the word into two tokens that matched nothing. Every
    guard reading a Bash command through the shared command reader, including
    destructive_git, was evadable this way.
  • An unreadable or malformed config no longer takes the whole event
    down.
    Secret redaction's word-list resolver was documented as never
    raising, but missed pydantic's ValidationError and yaml.YAMLError;
    a config with a syntax error, one unrecognised key, or a legacy/newer
    spelling could fail the tool call being routed. Malformed config is now a
    reported configuration error, and an unusable config makes redaction
    inert (logged, not silent) rather than crashing the event.
  • A newline now ends the command being judged, across four blocking
    handlers.
    Their "stays inside this sub-command" character class listed
    ;, & and | but not the newline, so a pattern could run past the end
    of its own line and convict the next one — a force-push deny on a later
    line's unrelated grep -f, a force-branch-delete deny on a safe
    git branch -d, a squash-merge deny on the word --squash inside an
    echo, and a cd-into-daemon-directory deny on the very command its own
    message recommends running instead. Joining the two lines with &&
    always reversed the verdict, which was the tell.
  • Four guards corrected by the release review. A git merge named only
    in a commit message body is no longer read by merge_to_main_approval as
    a real merge; a cd named in prose inside a quoted heredoc is no longer
    read by daemon_location_guard as a real directory change;
    deployed_artefact_drift now reads the project's configured plan
    directory instead of a hardcoded default; and (security)
    issue_filing_gate now covers gh's default repository resolution, not
    only an explicit --repo/GH_REPO.

Operators

  • BREAKING: plan-qa --json renames level to severity. See
    "⚠️ BREAKING CHANGES" below — this release carries a mandatory
    post-upgrade task for anyone whose tooling parses that verb's JSON output.
  • An archival that forgets the age-out is now blocked at commit. The
    main plan index keeps only the 30 highest-numbered completed rows, ageing
    older ones into Completed/README.md in the same commit as the archival
    that displaces them — until now nothing enforced that, and three
    consecutive archival commits in this repository skipped it silently. A new
    plan-QA check, index-retention-window, now runs at the commit gate and
    in the sweep.
  • The ccy supervisor follows up a line it typed but did not submit. When
    its own Enter fails to submit an injected /goal, continue or
    /effort line — which left one /goal sitting unsubmitted for eight
    hours in a real session — it now remembers the line as its own and presses
    Enter for it (at most twice, 15 seconds apart) at the next lull. The
    goal-injection cap is also now a rolling one-hour budget rather than five
    per process lifetime.
  • hooks-daemon worktree-reap no longer risks reaping a worktree that
    just started, and its branch delete now actually works.
    A worktree
    created moments ago for an actively-working agent looked identical to a
    finished, stale one and was listed as safe to reap; it is now also
    checked for a live process and an under-15-minutes age. Separately, the
    reap-success branch delete passed git a ref shape it rejects outright, so
    every reaped worktree kept its branch behind — now fixed.
  • bin/hooks-daemon's project anchor now actually reaches the command.
    Running the wrapper by absolute path from another directory — a worktree's
    wrapper invoked from the main checkout, say — used to silently act on the
    WRONG project because argparse's own subparser default overwrote the
    anchor; now the wrapper's own project always wins unless you supply
    --project-root yourself.
  • A drifted daemon-owned file can now actually be repaired, and a new
    advisory tells you when one has.
    agents install <name> --force is the
    explicit escape when a deployed agent no longer matches any shipped
    revision (previously the daemon refused and recommended the very command
    that had just refused), and deploy-core-docs refreshes
    CLAUDE/core/*.core.md outside a full upgrade. A new SessionStart
    advisory compares every deployed daemon-owned file against its template
    and names the repair command, staying silent when nothing has drifted.
  • A commit message describing --force is no longer read as a force
    push (security fix).
    destructive_git judged raw command text, so a
    heredoc BODY or an -m/-F message VALUE that merely documented a flag —
    including the common git commit -F - <<'EOF' && git push origin main
    idiom — could be denied as the command it described. Inert spans are now
    blanked before the command is judged, across every rule the handler
    enforces.
  • Agent deployments wrongly frozen as "customised" upgrade again. Four
    shipped revisions across the built-in agents were never recorded in the
    ledger that tells a genuine local edit apart from a merely-outdated file,
    so a pristine file installed during one of those windows was permanently
    refused every upgrade with a message blaming an edit that was never made.
    Nothing to do — those files start upgrading again.
  • worktree-reap stops refusing worktrees over files its own gitignore
    update cannot see.
    git status runs inside each worktree against that
    worktree's PINNED .gitignore, so a generated path ignored only after
    the worktree was created read as unsaved work forever. Only untracked
    paths are ever dropped this way — a modified tracked file still refuses,
    regardless of any ignore rule. The refusal message was rewritten too, and
    that is the half an operator with stale worktrees wants today: it used to
    say "remove it by hand" and "N need a human", which was simply untrue —
    removal was never blocked — and it caused five stale worktrees in this
    repository to be handed back to a human again and again. It now says
    removal is not blocked and names the command.
  • A branch name containing -f- is no longer read as a force push, and
    clustered short flags now are (security fix, reported as issue #37).

    git push origin feature/lane-f-adoption used to be denied with no force
    flag anywhere in it; force markers must now START a whitespace-delimited
    argument. The more serious half of the same investigation: git push -uf origin main (and -fu, -nf) were never blocked in any prior release,
    because the literal -f never appears in a short-option cluster — that is
    now caught too.
  • transport on/off now establish the deployed state they report,
    instead of trusting the config alone.
    A config match used to answer
    "already <state> — nothing to do" without ever checking the deployed
    forwarders, so a drifted deployment could report success while every hook
    kept routing through the wrong transport. A drifted state is now
    regenerated, the daemon restarted, and the result verified — a converged
    project still pays nothing. If you script the toggle, note the exit
    code can now be non-zero where it was always 0.
  • Declare crons your project always wants, and have them re-asserted
    every session.
    Claude Code crons cannot persist across sessions or past
    seven days, full stop — a new persistent_crons config block lets a
    project declare the jobs it wants, and a new SessionStart handler
    re-states the declaration every session so they can be re-created. Off by
    default; states what was declared, never what is missing.
  • A pull that leaves the daemon stale now says so. The daemon imports
    its config and handler code once, at startup, and never hot-reloads — a
    git pull bringing in a new blocking handler or a retuned priority left
    the project believing it was protected while nothing new had actually
    loaded, and nothing said so. A new advisory reports exactly which paths a
    merge/pull/rebase changed and names the restart command; it also
    catches the tracked .claude/HOOKS-DAEMON.md version marker moving out
    of step with the gitignored installed clone.
  • A stale .claude/hooks-daemon/ clone now says which version it is. A
    leftover old clone meeting newer tracked config used to fail every hook
    for the whole session with daemon_startup_failed, pointing at logs and
    restart — neither of which fixes a version mismatch. Startup now names
    both versions and the exact upgrade or regenerate command, and upgrade
    computes an honest version range from the tracked assets rather than the
    stale clone.
  • A gate you switched off is no longer announced as your CLAUDE.md's
    active policy.
    A handler that is loaded and enabled but inert because
    its own config key is at its default used to be listed in the generated
    instructions as enforced fact — indistinguishable to an agent from a
    project that really did require it, and two finished plans in this
    repository sat open for days waiting on an approval that was never
    needed. Handlers can now report themselves dormant and are left out of
    the generated document entirely.

Handler Authors

  • The optimise report is scored by the registry's own gates, and a
    broken handler no longer aborts the whole verb.
    /hooks-daemon optimise
    used to score enabled/disabled with its own incomplete copy of
    registration's rules; both now share one predicate. A handler whose
    constructor raises now appears as "could not be assessed" instead of
    taking down the report for every other handler.
  • An acceptance test's contradiction error now names the field you
    declared
    , instead of a field the derivation step had already populated
    behind the scenes.
  • The config package has one validator. A second, never-run,
    hand-maintained JSON-schema validator that only understood 3 of the
    daemon's 31 wired event types is gone; ConfigValidator, HandlersConfig
    and PluginConfig.event_type all derive their coverage from the same
    wired-event catalogue now, pinned by a test.

Client Projects

  • daemon_restart_verifier becomes a project handler — no config change
    needed.
    It only ever fired inside the hooks-daemon repository itself, so
    it never did anything for a client project; it is now dogfooded as a
    project-level handler in this repository's own
    .claude/project-handlers/. A leftover config key validates cleanly and
    is simply ignored.
  • A running daemon now notices its own upgrade. When a different
    session or process upgrades this project's installed daemon, the one
    already running kept serving the old code with nothing saying so. A new
    advisory compares the in-memory version against what is actually
    installed on every turn and names both versions plus the restart command
    when they differ — silent otherwise, and dormant in a self-install
    checkout.
  • A reference clone is fresh before you read it. Tracking read-only
    clones of upstream repos under untracked/repos/ is a convention this
    project shares with its siblings; a new reference_repos config block
    and paired handlers fetch and fast-forward what is provably safe at
    session start, then deny a READ of a clone that is stale or off its
    default branch at PreToolUse time — with no network I/O on that hot path.
    A project with no such directory sees nothing at all.
  • One unsecurable per-event socket no longer costs them all. Only
    reaches a project running the per-event transport. A single failed
    chmod while securing one of the daemon's 31 per-event sockets used to
    propagate out of start() and fail daemon startup entirely; it is now
    covered by the same best-effort guard binding already had.
  • An upstream issue body now comes from a generator, not a hand-written
    report (security governance).
    A gh issue create against the
    hooks-daemon repository's own PUBLIC tracker is now denied unless
    --body-file names a document hooks-daemon issue-report produced — the
    generator collects a controlled field set and never gathers a hostname,
    git remote, .env, config dump or logs at all. BUG_REPORTING.md is now
    the whole procedure end to end.

⚠️ BREAKING CHANGES

Configuration Changes

  • plan-qa --json finding key — Renamed level → severity in
    v3.64.0
    • Why: docs-qa --json and plan-qa --json described the same
      finding shape under two different field names. That is not cosmetic —
      it already produced a live defect: a reader keyed on one name silently
      drops every finding from the other verb while still counting it toward
      the total, which is how a QA wrapper once printed 3 findings (0 block, 0 advise), a summary contradicting itself. plan-qa --json now emits
      severity and nothing else, matching docs-qa --json. Shipped with no
      deprecation window, deliberately: emitting both spellings for a release
      would make the two-names defect itself correct by policy for that
      release, and would not avoid the break either — it would only defer it.
    • Migration: Any tooling that parses plan-qa --json and reads
      finding["level"] (or .get("level"), .level, jq '.findings[].level')
      should read severity instead. The values are unchanged — this is a
      key rename and nothing more. See the mandatory post-upgrade task below
      for the full detection and migration procedure.
    • Guide:
      v3.63.0-to-v3.64.0 Upgrade Guide

No handler was removed from behaviour available to a client project.
daemon_restart_verifier is removed as a built-in handler (see Removed in
the Changes below), but it never fired outside this repository's own
checkout, so no client config loses any enforcement — a leftover config key
validates cleanly and is ignored.

Changes

See CHANGELOG.md for the full,
categorised list of changes in this release.

Post-Upgrade Tasks

CLAUDE/UPGRADES/v3/v3.63.0-to-v3.64.0/post-upgrade-tasks/ has one
critical task: rewriting any call site that parses plan-qa --json and
reads the level key to read severity instead (see Breaking Changes
above). It applies to any project whose tooling parses that verb's JSON
output; a project that never parses it is unaffected. Read the task file
for the detection command and the full migration procedure — an agent
upgrading this project should treat it as blocking the next step until
acknowledged.

Installation

New Installations

mkdir -p .claude
git clone -b v3.64.0 https://github.com/Edmonds-Commerce-Limited/claude-code-hooks-daemon.git .claude/hooks-daemon

# The installer builds the fingerprint-keyed venv, installs the package and
# starts the daemon.
bash .claude/hooks-daemon/scripts/install_version.sh "$PWD" "$PWD/.claude/hooks-daemon"

Upgrading Existing Installations

cp .claude/hooks-daemon.yaml .claude/hooks-daemon.yaml.backup
git -C .claude/hooks-daemon fetch --tags

bash .claude/hooks-daemon/scripts/upgrade.sh --project-root "$PWD" "v3.64.0"

.claude/hooks-daemon/bin/hooks-daemon restart

Use upgrade.sh (Layer 1), not upgrade_version.sh (Layer 2) directly —
Layer 1 is the documented entry point; invoking Layer 2 on its own skips
steps Layer 1 is responsible for sequencing.

After upgrading, read the Post-Upgrade Tasks section above — this
release ships one critical task.

Testing

  • Tests: 23,370 passed, 0 failed, 8 skipped — measured by the full
    ./scripts/qa/llm_qa.py all gate (Step 8 of
    CLAUDE/development/RELEASING.md), which reported 30/30 checks passed
    against a freshly restarted daemon. Coverage 95.4%.
  • Known-behaviour gaps: three tests are pinned as strict xfail, each
    naming the plan that will close it — a quoted destructive operand and
    pushd evading R-DAEMON-DIR-CD (both Plan 00408), and a markdown heredoc
    mentioning sed (Plan 00260). None is a regression: each was verified
    against the v3.63.0 tree and behaves identically there. Two of the three
    were found by this release's own review rather than by a user.
  • Type Safety: MyPy strict mode compliant
  • Security: Bandit scan clean
  • QA gate: ./scripts/qa/llm_qa.py all — to be run at the QA gate after
    this document was drafted, with the daemon restarted on the bumped source
  • Acceptance: MINOR bump — full acceptance suite per
    CLAUDE/development/RELEASING.md Step 12.

Release Review

This release closes three niggles ledgers (Plans 00377, 00405, 00407) and
one release-review follow-up ledger from v3.63.0 (Plan 00364), alongside the
substantive new work detailed in the Highlights above and the full CHANGELOG
entry.

Contributors

  • joseph
  • Claude (Co-Authored-By across this release's commits)

Full Changelog

Compare: v3.63.0...v3.64.0