• v6.0.0 b35ff449e8

    v6.0.0 Stable

    ktg released this 2026-08-18 18:58:21 +00:00 | 2 commits to main since this release

    Signed by ktg
    SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q

    Summary

    "Prose is not a contract" — a MAJOR release whose theme is a class of defect rather than a feature
    area. Across three sweeps (Q1, Q2, Q_AUDIT) the same shape kept surfacing: a command template stated
    an invariant in prose, code on the other side depended on it, and nothing checked that the two still
    agreed. The write-scope gate was policy paraphrased in five templates while exactly one writer
    imported it. The argv a template built was never checked against the CLI receiving it —
    --stale-after 30 arrived as a single argument, matched no flag, and the command reported success
    about a threshold the user had just overridden. And the data contracts — backup manifests,
    state.yaml, scope.yaml — are hand-built by the model and parsed by engines that know one frozen
    example. The first two are now enforced in code and tests; the third is measured and ranked in
    docs/q-audit-prose-invariants.md, with the recovery path (rollback) at the top as the surface
    that runs precisely when the user is already in trouble.

    Breaking — a finding ID's {NNN} names the check, not its emission position (M-BUG-28).
    IDs are therefore not unique per finding: one check failing in three files emits three findings
    sharing an ID, and (id, file, line) is the instance key. Any consumer keying on id alone must
    move to the triple. scanners/lib/finding-codes.mjs is now the single authority — an undeclared or
    missing code throws, with no counter fallback, because a fallback lets a half-converted scanner
    ship IDs that look valid. Retired numbers are never reissued. Frozen v5.0.0 baselines mask IDs
    rather than re-deriving them.

    37 commits since 5.13.0. 1752 tests, 0 failing. GAP dimensions 25 → 24 (one /doctor
    duplicate retired). No component-count change: scanners 16, agents 7, commands 21,
    hooks 4, knowledge entries 8.

    Fixed

    • M-BUG-45/config-audit knowledge-refresh --stale-after N was silently dead under zsh. The
      command built STALE_AFTER="--stale-after 30" and expanded it unquoted, relying on the shell to
      split it into two argv entries. bash does; zsh — the macOS default since Catalina — does not.
      The CLI received one argv entry reading --stale-after 30, matched no flag, and fell back to the
      90-day default while reporting success: "✓ All 14 register entries were re-verified within the last
      90 days" — a true-sounding sentence about a threshold the user had just overridden. Measured:
      set -- $STALE_AFTER; echo $# prints 1 under zsh, 2 under bash. The threshold is now passed as its
      own quoted argument, and a guard rejects any command template that packs a flag and its value into
      one variable.
    • M-BUG-46 — four CLIs accepted unknown flags in silence. No else branch at all in the parse
      loop, so an unrecognised flag vanished without a trace: a typo'd --ledger-file made
      campaign-cli report confidently on the default ledger instead of the one the caller named, and
      a mistyped --stale-after reverted to 90 days. This is what made M-BUG-45 silent rather than
      loud. campaign-cli and knowledge-refresh-cli now fail with exit 3 and name the offending flag;
      optimize-lens-cli and token-hotspots-cli share the defect and are closed together with their
      positional-swallow arm in the v5.14 argument-handling work (tracked in the guard's KNOWN_OPEN).
    • M-BUG-47 — the machine-wide token bill counted repos it could not read. refresh-tokens
      routed a repo to skipped[] only when readActiveConfig threw, but that function resolves any
      path and its sub-readers all tolerate ENOENT, so a repo that does not exist yields an empty config
      instead of an error. Measured: a phantom path landed in swept[] with a 0-token delta,
      skipped[] was empty, and the roll-up claimed reposWithTokens: 3 for a machine with two real
      repos — so the command's own honesty clause ("name those repos plainly so the user knows the bill
      omits them") could never fire. Readability is now checked before the sweep.
    • M-BUG-48campaign add vouched for paths that do not exist. add /finnes/ikke returned
      added: [...] and exit 0, and the phantom row then sat in the backlog permanently. Paths are still
      tracked (an unmounted volume is a legitimate reason for a repo to be absent today) but are now
      reported separately as addedUnverified, and campaign.md names them instead of glossing over them.
    • M-BUG-49posture reported a crash as a passing grade. Its top-level catch set
      process.exitCode = 1, while every command in this plugin is told that "codes 0, 1, 2 are normal
      (PASS/WARNING/FAIL). Only 3 is a real error". A fatal error was therefore indistinguishable from a
      WARNING, and the command went on to Read a payload file that was never written. Measured as the
      single outlier: 1 of 14 scanners. Now exits 3.
    • M-BUG-50knowledge-refresh read one register and wrote another, and the gate saw neither.
      Step 6 said Edit knowledge/best-practices.json — an unanchored relative path — while the CLI reads
      ${CLAUDE_PLUGIN_ROOT}/knowledge/best-practices.json, which for a marketplace install is the plugin
      cache. So a normal user has no such file in their repo at all; in the plugin's own checkout the
      command read the cache and wrote the working tree; and step 6.3's validation gate ran the cached
      test against the cached register — validating the copy that was not edited, and passing no matter
      what was written.
      Every write step is now anchored, and the command states where the register
      actually lives (a marketplace copy is discarded on the next plugin upgrade).
    • No scanner created its --output-file parent directory. saveLedger always did; the payload
      write never did — an accidental asymmetry across all 13 writers. commands/campaign.md writes its
      report under ~/.claude/config-audit/sessions/, so on a fresh machine — precisely the first run
      that campaign-cli otherwise handles gracefully with initialized: false — the write threw ENOENT
      and the command's exit-code table reported it as a possibly-corrupt ledger, steering the user away
      from the one action that would have helped. All payload writes now go through
      scanners/lib/write-output.mjs.
    • M-BUG-40, fifth arm — posture wrote four temp files it could never read back. #49 closed the
      $$/cross-block class in four commands, but posture.md survived it, and so did the guard written
      to prevent exactly this. The guard compared each $$ path against the block that created it, so a
      path written once and then read via prose ("Read the JSON output file using the Read tool") had
      no second occurrence to flag. Measured live: the scanner wrote /tmp/config-audit-posture-21614.json
      from PID 21614 while the next Bash call ran as PID 23772, and the read step had no path to hand the
      Read tool at all. The invariant is now blanket — no $$ in any temp path in any command file
      which also caught fix.md and feature-gap.md. All five sites now use fixed literal paths, repeated
      literally in every step that needs them.
    • M-BUG-43 — commands leaked whole JSON payloads into the transcript. Every scanner except
      scan-orchestrator writes its payload to stdout when --raw/--json is set, even when
      --output-file was given
      — and the command templates redirected only stderr. Measured on a real
      repo: posture 255 182 B, whats-active 35 922 B, drift 28 316 B, manifest 23 825 B, tokens
      8 768 B. fix and feature-gap were the worst case: both ran posture with --json, never read the
      file they wrote, and in practice recovered a single letter grade from a quarter-megabyte dump — in
      the plugin that exists to cut token cost. 13 invocations across 10 command files now redirect stdout,
      and the two commands that needed the data read it from their output file instead.
    • tokens swallowed two documented flags. --json and --with-telemetry-recipe were listed as
      recognized flags but never threaded into the CLI call, so --json returned the humanized payload
      where the docs promised byte-stable v5.0.0 output (measured: 4/4 findings carried humanizer fields,
      and the title read "Your file starts with content that changes between turns" instead of
      "Cache-breaking volatile content at top of CLAUDE.md"), while --with-telemetry-recipe silently
      produced no telemetry_recipe_path — the very flag the command's own closing tip recommends.
    • M-BUG-42manifest asked for a field the scanner never emits. The render contract used
      {load}; the payload carries loadPattern. The Load column — which the command's own prose calls
      the whole point of the view — would render blank for all 96 rows. posture's headline had the same
      shape ({qualityAreaCount}, never emitted) and now takes its count from the humanized scorecard
      rather than areas.length, which counts a Feature Coverage row the table below deliberately excludes.

    Removed

    • GAP dimension No autoMode classifier (D1) — retired as a /doctor duplicate. CC 2.1.226's
      /doctor Check 8 covers auto mode with usage-weighted judgement, and the binding positioning
      (README «config-audit vs. the built-in /doctor») forbids carrying a feature whose whole value is
      duplicating a /doctor check. What is retired is only the "adopt this feature" nudge; the
      deterministic side stays untouched — SET still validates autoMode structure and still flags it
      as dead config in shared project settings. GAP dimensions: 25 → 24.

      The title lived in four tables, not the two the removal was scoped against: the dimension
      list, the scoring TITLE_TO_ID map, the humanizer's static translations, and — the one that
      moves a user-visible number — the scoring denominators (TIER_COUNTS t3 8→7,
      TOTAL_DIMENSIONS 25→24, MAX_WEIGHTED 42→41). findGapId falls back to 'unknown' silently,
      so a partial removal would have degraded without failing. A blanket sync invariant now asserts
      all four against GAP_CHECKS rather than checking occurrences pairwise, and each arm was
      verified red against its own defect.

      Utilization shifts accordingly (fixture: 43 → 44). risk_score, risk_band, verdict,
      overallGrade, maturity and segment are byte-identical across the change — the dimension was
      severity info (zero risk weight) and GAP is excluded from the overall grade.

      Frozen tests/snapshots/v5.0.0/ stays untouched. The removal-twin normalizer
      (tests/helpers/strip-retired-gap.mjs, mirroring strip-added-scanner.mjs) strips the retired
      dimension from whichever side still carries it and re-derives GAP IDs — retiring a dimension from
      mid-list shifts every later ID by one. The derived utilization figures are dropped from
      comparison rather than recomputed, since recomputing them in a test helper would assert the new
      arithmetic against itself; they are covered exactly in tests/lib/scoring.test.mjs. Re-seeding
      the baselines was rejected: it would silently bake in any other drift accumulated across every
      scanner those four files cover.

    Added

    • Four command-template shape tests (1449 → 1453), each verified to fail before the fix: a blanket
      $$ ban, stdout-redirect discipline for any scanner invoked with --output-file in raw/json mode,
      flag threading from prose to shell, and a render-contract test that checks every {field} against a
      live payload generated from a fixture rather than a hardcoded key list, which would drift.

    • M-BUG-40 — command templates assumed shell state survives between fenced blocks. It does not:
      every ```bash fence is executed as its own Bash call, in its own process. A variable
      assigned in one block is empty in the next, and $$ (the PID) differs between calls, so a
      /tmp/foo-$$.json path created in one block can never be reconstructed in a later one. The defect
      was surfaced by dogfooding plan + implement, and confirmed at runtime by the planner agent
      itself
      , which reported that Mode: $RAW_FLAG "arrived literally unsubstituted" — --raw was
      documented in both files while being functionally dead. A machine sweep found the same root in
      20 places across 9 files, far past the two predicted: $RAW_FLAG referenced from non-shell
      agent prompts (analyze, plan, implement); $TMPFILE referenced across blocks in tokens,
      manifest, whats-active and plugin-health, so each command could not read the file it had just
      written; $GLOBAL_FLAG in fix; $TODAY in campaign, which was never assigned in any block
      and passed --reference-date "" to a write CLI; and three $$ temp paths handed to the Read tool
      in fix, which expands neither $$ nor variables. All now follow the hardened drift.md pattern:
      a fixed literal path, or a re-derivation inside each block that needs it.

    • implement handed out a rollback ID it never captured. The backup directory was created with
      mkdir -p .../$(date +%Y%m%d_%H%M%S)/, so the timestamp existed only inside a command
      substitution, while step 6 promised /config-audit rollback {timestamp} — the one message a user
      reads after a bad run. The step now prints BACKUP_ID and substitutes it literally.

    • plan reported "No analysis results found" for valid sessions. Step 1 pointed the Read tool at
      ~/.claude/config-audit/sessions/*/state.yaml; Read takes one literal path and does not expand
      *, so the lookup failed and the command reported the session as missing. It now uses Glob, and
      additionally verifies analysis-report.md exists before spawning the planner agent — a session can
      carry a valid state.yaml and still be missing its report.

    • Phase commands wrote state.yaml with two of the four required fields. .claude/rules/state-management.md
      mandates current_phase, completed_phases, next_phase and updated_at; analyze, discover,
      implement, interview and plan named only a subset. Because the planner agent writes all four,
      a follow-up full-file Write naming two deletes the other two — the fields that make an
      interrupted run resumable.

    • implement documented a rollback semantics that does not exist. Its "## Rollback" section
      promised to "delete newly created files", while rollback.md deliberately leaves them in place and
      lists them under "Left in place" (deletion is unimplemented; M-BUG-26 remains open). The doc now
      mirrors actual behaviour rather than describing a half-restore as clean.

    • implement claimed a score delta with no source, since nothing captured the pre-change grade
      before the edits ran, and its implied posture call omitted both --output-file and 2>/dev/null
      required by the output rules. It now reports a delta only when a pre-change grade was actually
      measured.

    • verifier-agent was instructed to write a report it has no tool to write (tools: Read, Glob, Grep, and "Read-only validation" by design). It now returns findings as its final message and the
      command appends them with Bash >>, preserving both the read-only design and the shared-log
      append discipline.

    • Dead tool name in always-loaded context: .claude/rules/command-development.md taught
      allowed-tools: ... Task while every command uses Agent, and interview.md carried two more
      Task references. planner-agent.md also contradicted itself — line 110 forbids inline file
      content while its own output template demonstrated exactly that, pushing plans past the 200-line
      budget the same file sets.

    Fixed (previously released work)

    • M-BUG-39 — every scanner CLI could truncate its own output when piped. process.exit()
      terminates immediately, but Node writes stdout asynchronously when stdout is a pipe, so whatever
      is still buffered is discarded. scan-orchestrator.mjs measured 246 854 bytes to a file vs
      65 536 to a pipe
      (and 131 072 on another run — the cut point is a nondeterministic flush race),
      handing any machine consumer truncated, unparseable JSON that reads like a corrupt file rather than
      a cut-off. Reported by org-ops, whose census pipes our envelope. The whole class is closed, not
      just the CLI where it was observable: campaign-cli, campaign-export-cli, campaign-write-cli,
      knowledge-refresh-cli, drift-cli and fix-cli all exited the same way on their success paths
      and were green only because their payloads happen to fit the pipe buffer today. All 38 sites across
      14 files now set process.exitCode and return, letting Node exit once stdout drains — the pattern
      self-audit.mjs and llm-security's orchestrator already used. fail() throws instead of exiting so
      it keeps its never-returns contract; exit codes and Error:/Fatal: stderr text are unchanged.
      Guarded by tests/scanners/cli-pipe-integrity.test.mjs: one behavioural test that pipes a >128 KB
      envelope, one class sweep over scanners/*.mjs.
    • M-BUG-36/config-audit drift --list showed nothing. drift-cli.mjs accepted
      --output-file but list mode ignored it, and the listing itself goes to stderr, which
      commands/drift.md discards with 2>/dev/null (ux-rules rule 2). The command received 0 bytes
      and could render no baselines at all. List mode now honours --output-file; --raw/--json stdout
      is unchanged and byte-stable. Fourth instance of the stderr-only class after M-BUG-33.
    • M-BUG-37/config-audit feature-gap promised a backup it never made. Step 6's
      "Create backup" ran fix-cli.mjs <path> --json, but fix-cli is dry-run by default: no backup was
      written and backupId came back null, after which the command edited the user's configuration
      believing it could be restored. Passing --apply would have been worse — it executes unrelated
      auto-fixes the user never selected. The step now copies the files itself and states plainly that
      plain copies are restored by copying them back, not by /config-audit rollback (M-BUG-31 class).
    • M-BUG-38fix-cli.mjs sent users to a script that does not exist. After applying fixes it
      printed Rollback: node scanners/rollback-cli.mjs <id>; there is no rollback-cli.mjs — only
      rollback-engine.mjs, driven by /config-audit rollback. A dead reference in the one message a
      user reaches for after a bad fix. Now points at the command.
    • M-BUG-21 (fourth arm) — command templates fed bracketed placeholder flags to the arg loops.
      Five templates (config-audit.md, discover.md, fix.md, tokens.md, whats-active.md) carried
      literal [--global] / [--full-machine] / [--verbose] inside executable bash blocks. A bracketed
      placeholder does not start with -, so every scanner's else if (!args[i].startsWith('-')) branch
      takes it as the scan target — silently scanning a path that does not exist. Replaced with empty
      shell variables that expand to nothing when the flag does not apply.
    • /config-audit interview and analyze never said which session they act on. Both referenced
      {session-id} with no resolution rule, while every other session-aware command globs
      sessions/*/state.yaml and takes the most recent. Two runs could write to two different sessions.
      Both now resolve the session explicitly and exit when none exists.
    • /config-audit interview could rewind a finished session. The mandated state write had no bound,
      so running the optional interview against a session that had already reached implement reset
      current_phase and re-added phases. It now appends interview only if absent and leaves the
      furthest phase reached intact.
    • /config-audit cleanup interpolated an unvalidated id into rm -rf. An empty or malformed
      {session-id} expands the path to sessions//, deleting every session. The id must now match
      ^[0-9]{8}_[0-9]{6}$ or come verbatim from the directory listing; anything else is refused and
      reported.
    • /config-audit status advertised a command that does not exist. It documented
      /config-audit resume {session-id}; there is no resume command. Replaced with how session
      selection actually works. A test now fails on any /config-audit <word> reference in commands/
      without a matching file.
    • /config-audit status all was documented but never parsed. The flag-parse step knew only
      --raw. It now parses all and routes to the all-sessions table.

    Fixed (previously)

    • M-BUG-21 (third arm) — plugin-health-scanner.mjs swallowed unknown flags, and the wrong
      target looked green.
      The same else if (!args[i].startsWith('-')) targetPath = args[i] loop:
      --output-file /tmp/x.json was dropped and /tmp/x.json became the scan target. Where drift
      produced phantom drift, this produced a reassuring answer — a non-existent path discovers no
      plugins, so the scanner reported No plugins found (info) and exit 0. Unknown options and a
      value-less --output-file now exit 3.

    • M-BUG-33/config-audit plugin-health read zero bytes. The scanner had no --output-file
      (ux-rules rule 2) and its default-mode report goes to stderr, which commands/plugin-health.md
      discards with 2>/dev/null before telling the agent to "read stdout output (JSON)". The command's
      default path could not produce the report it documents. --output-file now writes a humanized
      payload; --raw/--json stdout is unchanged and byte-stable.

    • M-BUG-34 — the report's per-plugin table and Cross-Plugin section were unbuildable. Per-plugin
      data (commandCount, agentCount) and the grade formula never left scan() — the only grade code,
      formatPluginHealthReport, had no caller — and cross-plugin findings were flattened into findings
      behind a category: 'plugin-hygiene' they share with per-plugin findings. The command mandated both,
      so it had to fabricate them. The payload now carries plugins[] (name, declaredName, counts, score,
      grade via the shared pluginGrade) and cross_plugin_findings[] (also marked crossPlugin: true),
      via a new scanDetailed(); scan()'s frozen v5.0.0 envelope is untouched.

    • M-BUG-35.claude-plugin/marketplace.json was reported as an unknown file. It is the
      documented, required location for a marketplace catalog, and a marketplace entry with
      "source": "./" makes the repo root its own plugin — such a repo legitimately carries both files.
      Genuinely unexpected files in .claude-plugin/ are still flagged.

    • commands/posture.md discarded both optional scanners' output. Its --drift and
      --plugin-health sections ran drift-cli.mjs / plugin-health-scanner.mjs in default mode under
      2>/dev/null and read stdout, which is empty in that mode. Both calls now use --output-file.

    • M-BUG-21drift-cli.mjs had no --output-file, and its argument loop turned the missing
      flag into a wrong scan target.
      The loop ended in else if (!arg.startsWith('-')) targetPath = arg
      with no unknown-flag branch, so an unrecognised flag was dropped silently and its value fell
      through to the scan target: drift-cli.mjs . --output-file /tmp/x.json scanned /tmp/x.json, a
      path that does not exist, and reported the resulting near-empty scan as drift — permanently, and
      without a warning. The same silence was destructive for --save --name with the value omitted:
      --name was ignored, the name stayed default, and an existing baseline was overwritten.
      Unknown options and value-less --name/--baseline/--output-file now exit 3.

    • M-BUG-21 (second arm) — /config-audit drift captured nothing at all. commands/drift.md
      ran the CLI under 2>/dev/null and told the agent to "read stdout", but the default-mode report,
      the --save confirmation, and the --list output all go to stderr. All three modes returned
      empty. --output-file now writes the diff (humanized in default mode, raw under --json/--raw,
      matching posture.mjs), and the command reads that file; --save passes --json for its
      confirmation.

    • M-BUG-27drift compared against baselines anchored to a different directory and called it
      "improving".
      diff-engine never checked the baseline's stored target_path against the current
      scan target. Diffing a repo against a baseline saved elsewhere marked every baseline finding
      "resolved" and every current finding "new" — a 100% phantom diff surfacing as a reassuring trend,
      on the default baseline. The CLI now warns on stderr in every mode and carries
      _baselineAnchor {matches, baselineTarget, currentTarget} in the default-mode payload, so a caller
      running under 2>/dev/null can still see it. --json/--raw stdout stays v5.0.0-shaped and the
      frozen drift.json snapshot is untouched.

    • M-BUG-21 (third arm) — fix-cli.mjs had the same unvalidated argument loop, where it moves the
      write target.
      An unrecognised flag was dropped and its value became the scan target, so
      fix-cli.mjs <repo> --output-file /tmp/x.json silently audited /tmp/x.json; with --apply the
      same slip relocates what gets written. Unknown options and value-less --output-file now exit 3.
      --dry-run — documented in commands/fix.md's argument-hint but never implemented — is now
      accepted instead of silently dropped, and --output-file writes the fix payload to disk so
      commands/fix.md can read a file rather than parse stdout it runs under 2>/dev/null.

    • M-BUG-31fix promised a mandatory backup it did not always take. fix-cli.mjs excluded
      file-rename from the backup set, so a rule file whose only defect was its extension was renamed
      with no backup entry — while the command told the user "every fix creates a backup first" and
      handed back a backupId that could not restore it. The source file is now backed up like any other.

    • M-BUG-32 — verification re-scanned a different scope than the fix run. verifyFixes hardcoded
      includeGlobal: false. After a --global run every user-scope finding fell out of the re-scan and
      was therefore counted as verified: a clean "fixed" report for files nothing had touched
      (reproduced against an untouched ~/.claude/CLAUDE.md). It now inherits the run's scope, and
      commands/fix.md passes --global to every step instead of only the display scan.

    • M-BUG-29 — two fixes on one file were applied in an order that guaranteed failure. A rule file
      with both globs: and a non-.md extension had the rename applied first; the frontmatter fix then
      failed with ENOENT. Renames now sort after every other fix.

    • M-BUG-30 — critical fixes sorted last. severityOrder[s] || 4 maps critical (weight 0) to
      4, the opposite of the documented "critical first" contract. The old test used the same falsy
      fallback, so it agreed with the bug. Now ?? 4.

    • A failed fix no longer exits 0. fix-cli.mjs returns 2 when any planned fix failed, matching
      the 0/1/2 = PASS/WARNING/FAIL, 3 = error convention the other scanners follow.

    1420 tests (+10). No count change (scanners 16, agents 7, commands 21, hooks 4).

    Downloads
  • v5.13.0 c60f849d2e

    v5.13.0 Stable

    ktg released this 2026-07-31 15:34:09 +00:00 | 40 commits to main since this release

    Summary

    "Pipeline hardening" — the batch release of everything found by dogfooding the plugin against the
    maintainer's real machine and by walking the analyze → plan → implement → rollback pipeline
    end-to-end on a throwaway repo copy: one new lens mode (optimize --subtract) and 14 bugs
    (M-BUG-11M-BUG-20, M-BUG-22M-BUG-25), every one of them a real defect a user could hit.

    The minor bump is carried by --subtract alone; the other 14 are fixes. Two themes run through them:
    agent-facing commands were scanning config the user cannot act on (plugin-bundled and vendored
    copies masking real findings), and new finding types kept shipping without their matching humanizer
    entry
    , so plain-language output contradicted the finding's own evidence. The rollback chunk found the
    worst class in the repo: a restoreBackup that returned {restored: [], failed: []} — a success-shaped
    no-op
    — because nothing agreed on where a backup lives or what its manifest looks like.

    No count change (scanners 16, agents 7, commands 21, hooks 4). Frozen v5.0.0 snapshots
    untouched throughout; the SC-5 default-output snapshot was regenerated once, for two humanized titles
    only (M-BUG-15). 1398 tests (+54).

    Known and deliberately not fixed in this release: rollback still cannot delete files that
    implement created — a backup cannot hold a file that never existed. It no longer fails silently
    (manifests carry a created: list, restoreBackup returns createdNotRemoved, and rollback.md
    requires the report), but automatic deletion of user files is destructive and gets its own design.
    drift-cli.mjs still lacks --output-file (M-BUG-21).

    Added

    • optimize --subtract — the subtraction axis (BP-SUB-001). Every command so far asked an
      addition question: what to add, what to move, what it costs. Nothing asked what no longer earns its
      always-loaded rent. --subtract adds that as a fourth lensCheck on the existing hybrid motor — a
      mode, not a 22nd command or a 17th scanner, because the measured payoff (~18% of one file) justifies
      a mode and no more. It is opt-in and proposes only.
      It is also the only lens that proposes removing config, so it carries a guarantee the others don't
      need: a load-bearing block is never a candidate. Precision is asymmetric — a missed dead line
      costs a few tokens per turn, a wrongly deleted one costs a broken script or a wrong remote — so the
      floor is decided in code (scanners/lib/floor-exclusion.mjs) before the opus judge sees anything,
      never in prose. That ordering is an invariant, not an implementation detail.
      Granularity is the leaf block, with two structural exceptions: a paragraph ending in : merges with
      the list it introduces, and an ordered list is a contract whose steps inherit floor from any sibling.
      Unordered lists deliberately do not inherit — a load-bearing bullet and a disposable one routinely
      share a list.
      Verified against a hand-built ground truth written before any classifier existed, with the
      comparison machine-checked rather than read by eye: zero load-bearing blocks proposed, 11/18
      deletable groups surfaced, ~756 tok ≈ 18% of a ~4300-token file — inside the pre-registered band. The
      gate is re-runnable via scripts/dogfood-subtraction-gate.local.mjs. Three bugs the dogfood run
      exposed are now covered by fixtures: JS \b is ASCII-only so /\bunngå\b/ never matched (every
      Norwegian keyword ending in æ/ø/å was silently dead); a bare word/word is not a path
      ("pros/cons" vetoed the largest deletable block); "mid-sentence" must key on a preceding lowercase
      letter, or **bold labels:** read as entities and cost 4 of 11 groups.
      BP-SUB-001 is grounded entirely in the Anthropic steering blog already cited by
      BP-MECH-001..004 and asserts nothing from the talk that motivated the feature.

    Fixed

    • rollback — the backup path contract the engine and the commands disagreed on
      (M-BUG-22/M-BUG-23/M-BUG-24/M-BUG-25).
      Pipeline step 4 dogfood: /config-audit rollback
      could not see a single one of the four real backups on this machine, and reported "Backup not found"
      for one sitting right there. Four defects, one root — nothing agreed on where a backup lives or what
      its manifest looks like.
      M-BUG-22 (high): lib/backup.mjs resolved ~/.config-audit/backups (pre-v2.2.0) while every
      command, agent and doc uses ~/.claude/config-audit/backups. The auto-backup hook and fix-cli wrote
      to the first, implement to the second, rollback read only the first — so listBackups() returned
      9 phantom backups from the test suite and 0 of the 4 real ones. The canonical root is now
      ~/.claude/config-audit/backups, with the legacy root kept readable (legacy: true) so older
      backups stay listable and restorable.
      M-BUG-25 (high, the worst failure mode in the file): parseManifest understood only the
      engine's quoted original_path: spelling, but implement hand-builds its manifest with
      - backup:/original:/sha256:. Every implement-made backup parsed to zero files and
      restoreBackup returned {restored: [], failed: []} — success-shaped, and silent. Both formats parse
      now, and a manifest with unparseable entries throws instead of pretending to succeed.
      M-BUG-23: both session hooks watched ~/.config-audit/sessions, which does not exist — "check
      for active sessions" had never fired once. It fires now.
      M-BUG-24: the suite called createBackup() against the developer's real home, leaving nine stray
      backups there while cleanupOldBackups() deletes past ten. The root is overridable via
      CONFIG_AUDIT_BACKUP_ROOT / CONFIG_AUDIT_LEGACY_BACKUP_ROOT, and both test files use it — any new
      test touching createBackup() must too.
      Verified against backup 20260717_032636 on a throwaway copy, through the previously broken engine
      path: 3/3 files restored byte-exact (sha256 match), zero writes outside the copy, backup dir
      unmodified.
    • rules-validatorglobToRegex corrupted mid-pattern /**/ globs (M-BUG-19). The
      ?[^/] replacement ran after the {{GLOBSTAR_SLASH}} placeholder was restored to (?:/.+/|/),
      corrupting the group opener (?: into ([^/]:. Every rule pattern containing a mid-pattern /**/
      silently matched only the zero-dir branch, so live rules were flagged "matches no files" (CA-RUL).
      Found by dogfooding /config-audit implement on a throwaway repo copy: the implementer agent's
      correct posts/**/post.md rule was flagged dead. Fixture outcomes byte-identical.
    • analyze persists the agent-returned report (M-BUG-18). The Claude Code subagent harness
      instructs spawned agents not to write report/summary/findings/analysis .md files — the parent
      reads the final text message. Verified live: analyzer-agent skipped Write entirely, so
      analysis-report.md never landed on disk and the plan/interview/status phases found nothing to read.
      New orchestrator-writes contract: the agent returns the complete report as its final message and the
      analyze command saves it verbatim before presenting the summary. The harness note is file-type
      specific
      plan was dogfooded afterwards and writes action-plan.md without friction, so the same
      fix is not needed there.
    • implement pins >> append discipline on the shared log (M-BUG-20). implement.md spawns
      implementer agents in parallel batches, all appending to the same implementation-log.md. Dogfooding
      showed agents satisfying "append result to:" with a full-file Write — the last writer clobbered 4 of
      6 entries. Both contracts now pin the mechanism: append with a Bash >> heredoc, never the Write/Edit
      tool on a shared log.
    • optimize lens scopes out plugin-bundled CLAUDE.md + keys candidates by absolute path
      (M-BUG-11).
      The lens CLI fed its precision-gate agent every CLAUDE.md discovery returned, including
      the 256 files under ~/.claude/plugins/ — vendored copies across every cached version plus their
      fixtures and examples. optimize --global produced 454 candidates across 92 "files", ~250 of them from
      plugin-internal files a user cannot act on (the plugin overwrites them on update). Second defect:
      candidates were keyed by relPath || absPath, and relPath collides across scopes — a repo-root
      CLAUDE.md and ~/.claude/CLAUDE.md both key to CLAUDE.md, so the two files that actually matter
      merged into one indistinguishable bucket and the agent's Read(file) would resolve the wrong one.
      Dogfood: candidates 454→45, distinct files 92→11, repo vs user-global now distinct.
    • feature-gap scopes presence checks to authored config and reads the settings cascade
      (M-BUG-13).
      The GAP scanner's 25 presence checks ran over the full includeGlobal discovery, so
      this plugin's own examples/optimal-setup (vendored across plugin-cache versions) satisfied every
      tier-3 check — masking real feature gaps to GAP=0 on any target. And the real
      ~/.claude/settings.json was invisible to the settings-key checks (the includeGlobal gotcha plus the
      maxFiles cap), which would have flipped statusLine/autoMode into false positives the moment the
      maskers were removed. Both halves are fixed together: isAuthoredConfig excludes plugin-bundled and
      nested examples//tests/fixtures/ config, and readSettingsCascade reads user→project→local
      directly. Empty target: ~0 (masked) → 18 humanized opportunities.
    • posture --output-file humanizes findings in default mode (M-BUG-12). feature-gap.md and
      posture.md both read findings from posture.mjs --output-file and group on the humanizer fields,
      but posture.mjs only humanized the stderr scorecard — its --output-file JSON wrote the raw
      v5.0.0-shape result, so every finding's humanizer fields were undefined and both commands silently
      degraded to the raw tier-fallback. v5.1.0 plain-language output was dead for feature-gap and for
      posture's finding-level grouping. The payload is now humanized in default mode (applied to
      result.scannerEnvelope, which is where posture nests it); --json/--raw stay raw.
    • AGT findings humanize to "Wasted tokens", not "Other" (M-BUG-17). The agent-listing scanner emits
      an always-loaded per-turn token cost — "the dominant single always-loaded source" — but
      SCANNER_TO_CATEGORY had no AGT entry, so its findings fell through to the Other fallback, a bucket
      that isn't even in the analyzer-agent's category list. All 16 orchestrator scanner prefixes are now
      covered by the category map, closing the class.
    • On-demand copy for the oversized skill-body finding (M-BUG-16). The v5.11 B7 finding measures a
      skill body, which loads only when the skill is invoked — but with no SKL.static entry it fell
      through to SKL._default ("using more of the listing budget than it should"), so the humanized title
      claimed a listing-budget cost and directly contradicted its own humanized evidence ("loads on demand
      only … NOT every turn").
    • Honest absence-state copy for two GAP enhancement findings (M-BUG-15). The "No path-scoped rules"
      and "No subagent isolation" checks fire on an empty collection too, but the humanized titles
      presupposed the feature exists — "Your subagents share Claude's main work folder" appeared in the same
      report as "You haven't set up any specialized helper agents yet". A user cannot simultaneously have no
      subagents and have subagents that lack isolation. Both titles now use the house "You haven't set up X
      yet" framing, which is honest for the zero-state and the has-but-unconfigured state. Fixed in the
      humanizer, not by gating the scanner — a presence gate would have moved the frozen v5.0.0
      marketplace-medium baseline.
    • Size-neutral copy for "CLAUDE.md not modular" (M-BUG-14). The check is a pure presence check with
      no length gate, but the copy claimed the file is "one big block" and that splitting makes it "easier on
      the loading time" — an unconditional size overclaim that simply lies for a ~625-token CLAUDE.md. Copy
      softened to the honest structural framing; no length gate added, which would have made it the only
      size-gated check among its siblings.
    Downloads