Compare commits
No commits in common. "main" and "v5.12.5" have entirely different histories.
164 changed files with 894 additions and 11294 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "config-audit",
|
||||
"description": "Multi-agent workflow for analyzing, reporting, and optimizing Claude Code configuration across your entire machine",
|
||||
"version": "5.13.0",
|
||||
"version": "5.12.5",
|
||||
"author": {
|
||||
"name": "Kjell Tore Guttormsen"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ All command files MUST include:
|
|||
---
|
||||
name: plugin:command
|
||||
description: Short description of what this command does
|
||||
allowed-tools: Read, Write, Bash, Agent
|
||||
allowed-tools: Read, Write, Bash, Task
|
||||
model: sonnet
|
||||
---
|
||||
```
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -38,8 +38,5 @@ NEXT-SESSION-PROMPT*.local.md
|
|||
*.local.md
|
||||
*.local.json
|
||||
*.local.sh
|
||||
# Local-only dogfood harnesses: they index the operator's private config by line
|
||||
# number (see docs/subtraction-fasit.local.md) and must never reach the public mirror.
|
||||
*.local.mjs
|
||||
.DS_Store
|
||||
.claude/
|
||||
|
|
|
|||
436
CHANGELOG.md
436
CHANGELOG.md
|
|
@ -5,442 +5,6 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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-48` — `campaign 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-49` — `posture` 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-50` — `knowledge-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-42` — `manifest` 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-38` — `fix-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-21` — `drift-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-27` — `drift` 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-31` — `fix` 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**).
|
||||
|
||||
## [5.13.0] - 2026-07-31
|
||||
|
||||
### 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-11`…`M-BUG-20`, `M-BUG-22`…`M-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-validator` — `globToRegex` 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.
|
||||
|
||||
## [5.12.5] - 2026-06-26
|
||||
|
||||
### Summary
|
||||
|
|
|
|||
100
CLAUDE.md
100
CLAUDE.md
|
|
@ -1,10 +1,13 @@
|
|||
# Config-Audit Plugin
|
||||
|
||||
Claude Code Configuration Intelligence — know if your config is correct, find what could improve it, fix it automatically. Three pillars: **Health** (deterministic scanners), **Opportunities** (context-aware recommendations), **Action** (auto-fix with backup/rollback).
|
||||
Claude Code Configuration Intelligence — know if your configuration is correct, find what could improve it, fix it automatically.
|
||||
|
||||
Per-command flags, patterns, and feature lists live in `README.md` and `/config-audit help`. This file carries what's invariant for working on the plugin.
|
||||
## What this plugin does
|
||||
|
||||
**Positioning vs. built-in `/doctor` (measured 2026-08-03, binding):** we are the deterministic/reproducible/all-scope/zero-quota side; `/doctor` is usage-weighted one-shot judgment. Never build a feature whose whole value is duplicating a `/doctor` check — see README «config-audit vs. the built-in /doctor» and `docs/v5.13-model-routing-effort-deadref-plan.md` §A.
|
||||
Analyzes and optimizes Claude Code configuration across three pillars:
|
||||
- **Health** — Deterministic scanners verify correctness, consistency, and completeness
|
||||
- **Opportunities** — Context-aware recommendations for features that could benefit your project
|
||||
- **Action** — Auto-fix with backup/rollback
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -12,15 +15,15 @@ Per-command flags, patterns, and feature lists live in `README.md` and `/config-
|
|||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/config-audit` | Full audit with auto-scope detection |
|
||||
| `/config-audit posture` | A-F health scorecard (10 quality areas) |
|
||||
| `/config-audit tokens` | Prompt-cache-aware token hotspots, each tagged with its load pattern; cache-aware |
|
||||
| `/config-audit manifest` | Ranked table of every token source + always-loaded subtotal |
|
||||
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
|
||||
| `/config-audit optimize` | Mechanism-fit lens (procedure→skill, lifecycle→hook, path→rule, never→permission). Agent-driven, **not byte-stable**. `--subtract` adds the subtraction axis (what no longer earns its always-loaded rent, `BP-SUB-001`) — opt-in, proposes only; `--subtract --apply` executes the removals the operator picks |
|
||||
| `/config-audit` | Full audit with auto-scope detection (no setup needed) |
|
||||
| `/config-audit posture` | Quick health scorecard (A-F grades, 10 quality areas incl. Token Efficiency, Plugin Hygiene) |
|
||||
| `/config-audit tokens` | prompt-cache-aware token hotspots (8 patterns: cache-breaking, redundant perms, deep imports, oversized cascade, bloated SKILL.md desc, MCP tool-schema budget, MCP tool-schema deferral, stale plugin-cache disk-cleanup), each ranked hotspot tagged with its load pattern (always / on-demand / external) — **cache-aware** (stale `~/.claude/plugins/cache` versions excluded by default; only each plugin's active version counts; `--no-exclude-cache` for the full walk), optional `--accurate-tokens` API calibration, `--with-telemetry-recipe` cache-hit recipe pointer |
|
||||
| `/config-audit manifest` | Ranked table of every token source (CLAUDE.md, rules, agents, skills, output styles, MCP, hooks) sorted by estimated tokens, each tagged with its load pattern (always-loaded / on-demand / external) + an always-loaded subtotal ("tokens that enter context every turn") |
|
||||
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact (incl. a conditional `disableBundledSkills` lever when the active skill listing is over budget — remediation companion to SKL `CA-SKL-002`) |
|
||||
| `/config-audit optimize` | Optimization lens (mechanism-fit) — config that works but fits a better mechanism: procedure→skill (CA-OPT-001, deterministic), lifecycle→hook / unscoped path→rule / "never"→permission (prose-judgment via opus `optimization-lens-agent`). Hybrid motor; every finding cites a best-practices-register rule. Agent-driven, **not byte-stable** |
|
||||
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
|
||||
| `/config-audit rollback` | Restore configuration from backup |
|
||||
| `/config-audit plan` | Create action plan from findings |
|
||||
| `/config-audit plan` | Create action plan from audit findings |
|
||||
| `/config-audit implement` | Execute plan with backups + auto-verify |
|
||||
| `/config-audit help` | Show all commands |
|
||||
|
||||
|
|
@ -30,9 +33,9 @@ Per-command flags, patterns, and feature lists live in `README.md` and `/config-
|
|||
|---------|-------------|
|
||||
| `/config-audit drift` | Compare current config against saved baseline |
|
||||
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
|
||||
| `/config-audit whats-active` | Read-only inventory of active plugins/skills/agents/MCP/hooks/CLAUDE.md (with token estimates, and `model`/`effort` per agent) |
|
||||
| `/config-audit knowledge-refresh` | Refresh the best-practices register (stale check + web poll). Human-approved writes; **not byte-stable** |
|
||||
| `/config-audit campaign` | Machine-wide audit ledger + token bill across repos. Human-approved writes; **not byte-stable** |
|
||||
| `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) |
|
||||
| `/config-audit knowledge-refresh` | Keep the best-practices register fresh — deterministic stale check (sources older than ~90d) + web candidate poll (CC changelog + Anthropic blog); **human-approved writes only** (Verifiseringsplikt). The "living" half of the knowledge base. Web/judgment-driven, **not byte-stable** |
|
||||
| `/config-audit campaign` | Machine-wide audit campaign — durable ledger ABOVE sessions: per-repo lifecycle (pending→audited→planned→implemented) + machine-wide roll-up by severity + **machine-wide always-loaded token bill** (`refresh-tokens` live cross-repo sweep — shared global layer counted once + per-repo deltas) + cross-repo prioritized backlog + plan **export** (drop a planned repo's plan into its own `docs/`), resumable across sessions. Read-only report (campaign-cli) + **human-approved** writes via deterministic write/export CLIs. THIN: tracks+routes state, reuses existing implement/rollback for execution. Judgment-driven, **not byte-stable** |
|
||||
| `/config-audit discover` | Run discovery phase only |
|
||||
| `/config-audit analyze` | Run analysis phase only |
|
||||
| `/config-audit interview` | Gather user preferences (opt-in) |
|
||||
|
|
@ -48,67 +51,84 @@ Per-command flags, patterns, and feature lists live in `README.md` and `/config-
|
|||
| planner-agent | Create action plan | opus | yellow | Read, Glob, Write |
|
||||
| implementer-agent | Execute changes | sonnet | magenta | Read, Write, Edit, Bash, Glob |
|
||||
| verifier-agent | Verify results | sonnet | purple | Read, Glob, Grep |
|
||||
| feature-gap-agent | Feature recommendations | opus | green | Read, Glob, Grep, Write |
|
||||
| optimization-lens-agent | Mechanism-fit precision gate | opus | orange | Read, Glob, Grep, Write |
|
||||
| feature-gap-agent | Context-aware feature recommendations | opus | green | Read, Glob, Grep, Write |
|
||||
| optimization-lens-agent | Mechanism-fit precision gate (prose-judgment lens cases) | opus | orange | Read, Glob, Grep, Write |
|
||||
|
||||
## Hooks
|
||||
|
||||
| Event | Script | Purpose |
|
||||
|-------|--------|---------|
|
||||
| PreToolUse | `auto-backup-config.mjs` | Backup config files before Edit/Write |
|
||||
| PostToolUse | `post-edit-verify.mjs` | Verify after Edit/Write, block on new critical/high |
|
||||
| SessionStart | `session-start.mjs` | Check for active (unfinished) sessions |
|
||||
| Stop | `stop-session-reminder.mjs` | Remind about current session phase |
|
||||
| PreToolUse | `auto-backup-config.mjs` | Auto-backup config files before Edit/Write |
|
||||
| PostToolUse | `post-edit-verify.mjs` | Verify config files after Edit/Write, block on new critical/high |
|
||||
| SessionStart | `session-start.mjs` | Checks for active (unfinished) sessions |
|
||||
| Stop | `stop-session-reminder.mjs` | Reminds about current session phase |
|
||||
|
||||
## Reference docs (read on demand)
|
||||
|
||||
- `docs/scanner-internals.md` — scanner inventory, lib modules, action engines, knowledge base, per-scanner/per-block implementation notes (design rationale, primary-source verification, byte-stability lessons)
|
||||
- `docs/humanizer.md` — plain-language output (v5.1.0), humanizer vocabularies, output modes
|
||||
- **Scanner inventory, lib modules, action engines, knowledge base, per-scanner/per-block implementation notes:** `docs/scanner-internals.md`
|
||||
- **Plain-language output (v5.1.0), humanizer vocabularies, output modes:** `docs/humanizer.md`
|
||||
|
||||
## Plain-Language Output (v5.1.0)
|
||||
## Plain-Language Output (v5.1.0) — summary
|
||||
|
||||
Default output of all commands routes through `humanizeEnvelope` (`lib/humanizer.mjs`), decorating each finding with `userImpactCategory`, `userActionLanguage`, and `relevanceContext`. `--raw` and `--json` bypass the humanizer for byte-stable v5.0.0 output. Full detail: `docs/humanizer.md`.
|
||||
Default output of all 18 commands routes through `humanizeEnvelope` from `lib/humanizer.mjs`. Findings get three decorated fields:
|
||||
|
||||
- `userImpactCategory` — Configuration mistake / Conflict / Wasted tokens / Dead config / Missed opportunity
|
||||
- `userActionLanguage` — Fix this now / Fix soon / Fix when convenient / Optional cleanup / FYI (derived from severity)
|
||||
- `relevanceContext` — `affects-everyone` (default) / `affects-this-machine-only` (`*.local.*` files) / `test-fixture-no-impact`
|
||||
|
||||
`--raw` bypasses the humanizer for byte-stable v5.0.0 output. `--json` is also byte-stable. Full detail and Wave 5 lessons: `docs/humanizer.md`.
|
||||
|
||||
## Suppressions
|
||||
|
||||
Create `.config-audit-ignore` at project root — one exact ID or glob per line (`CA-SET-003`, `CA-GAP-*`). Suppressed findings are tracked in the envelope's `suppressed_findings` for audit trail. Disable with `--no-suppress`.
|
||||
Create `.config-audit-ignore` at project root to suppress known findings:
|
||||
```
|
||||
CA-SET-003 # Exact ID
|
||||
CA-GAP-* # Glob pattern (all GAP findings)
|
||||
```
|
||||
Suppressed findings tracked in envelope's `suppressed_findings` for audit trail. Disable with `--no-suppress`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Workflow: `/config-audit → discover + analyze (auto) → plan → implement → verify`. Auto-detects scope from git context; override with `full|repo|home|current`; `--delta` for incremental. Session state lives under `~/.claude/config-audit/sessions/{id}/` (scope.yaml, discovery.json, state.yaml, findings/, analysis-report.md, action-plan.md, backups/, implementation-log.md).
|
||||
### Workflow
|
||||
```
|
||||
/config-audit → discover + analyze (auto) → plan → implement → verify
|
||||
```
|
||||
Default: auto-detects scope from git context. Override with `/config-audit full|repo|home|current`. Delta mode: `--delta` (incremental).
|
||||
|
||||
Finding ID format: `CA-{SCANNER}-{NNN}` — e.g. `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`, `CA-TOK-005`, `CA-CPS-001`, `CA-SKL-001`, `CA-OST-001`, `CA-OPT-001`, `CA-AGT-001`.
|
||||
### Session Directory
|
||||
```
|
||||
~/.claude/config-audit/sessions/{session-id}/
|
||||
├── scope.yaml, discovery.json, state.yaml
|
||||
├── findings/, analysis-report.md, action-plan.md
|
||||
├── backups/, implementation-log.md
|
||||
└── interview.md (if interview run)
|
||||
```
|
||||
|
||||
**GAP dimensions vs. levers (invariant).** `GAP_CHECKS` holds the 24 *dimensions* — always evaluated, always counted in the utilization denominators (`TIER_COUNTS` / `TOTAL_DIMENSIONS` in `scoring.mjs`, and `TITLE_TO_ID` there). A *lever* is a finding the scanner emits after the loop and only under a measured condition; it carries no tier, never enters those denominators, and is registered in the exported `LEVERS` object (code + title in one place, because the finding-code guard needs the code and the humanizer-coverage guard needs the title). Adding a dimension moves every user's utilization score and can flip the reported `segment` in a frozen baseline — adding a lever cannot. When a check is only meaningful for configs that already have some feature, it is a lever.
|
||||
|
||||
**`{NNN}` names the CHECK, never the emission position (invariant).** `scanners/lib/finding-codes.mjs` is the single authority: every `finding()` call passes a `code`, and an undeclared or missing one **throws** — there is no counter fallback, because a fallback lets a half-converted scanner ship IDs that look valid. Adding a check takes the next free number for that scanner, never the next source-order position; removing one moves its key to `RETIRED_CODES` and its number is never reissued. 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 that `fix-engine` verification uses. Frozen `v5.0.0` baselines mask IDs (`tests/helpers/mask-finding-ids.mjs`) instead of re-deriving them; the check→number pairs are pinned exhaustively in `tests/lib/finding-codes.test.mjs`.
|
||||
### Finding ID Format
|
||||
`CA-{SCANNER}-{NNN}` — e.g. `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`, `CA-TOK-005`, `CA-CPS-001`, `CA-DIS-001`, `CA-COL-001`, `CA-SKL-001`, `CA-OST-001`, `CA-OPT-001`, `CA-AGT-001`
|
||||
|
||||
## Conventions
|
||||
|
||||
Enforced conventions live in `.claude/rules/` (auto-loaded as project instructions):
|
||||
- `ux-rules.md` — output/narration/formatting for all commands (never dump raw JSON, narrate before each step, space-separated command suggestions)
|
||||
Enforced project conventions live in `.claude/rules/` (auto-loaded as project instructions):
|
||||
|
||||
- `ux-rules.md` — output/narration/formatting rules for all commands (never dump raw JSON, narrate before each step, space-separated command suggestions)
|
||||
- `command-development.md` — required command frontmatter + `plugin:action` naming
|
||||
- `agent-development.md` — agent frontmatter + "when to use" conventions
|
||||
- `state-management.md` — update `state.yaml` after every workflow phase
|
||||
|
||||
Coding style: scanners are zero-dependency Node ESM; new findings use the `CA-{SCANNER}-{NNN}` ID format; byte-stable CLIs are verified against frozen `tests/snapshots/v5.0.0/` baselines.
|
||||
|
||||
**Write-scope gate (invariant).** Every write target is classified by `scanners/lib/write-scope.mjs` before it reaches an approval surface, and the **scope class decides the gate's strength — never the command asking**. Five command-owned policies would drift apart the way five copies of the lever table did. `SCOPE_CLASSES` is the single source for class, gate (`silent`/`disclose`/`require-ok`), wording and predicate; templates render `disclosures[]` from `write-scope-cli.mjs` rather than restating what a class means. Two orderings in that object are load-bearing and were measured, not reasoned about: `plugin-managed` before `user-scope` (both `~/.claude/config-audit/` and legacy `~/.config-audit/` are live, so the other order fires the gate on every session write and gets it switched off), and `user-scope` before `cross-repo` (`~/.claude/.git` exists, so a plain `.git`-upward walk calls `~/.claude/CLAUDE.md` merely "another repo" and silently downgrades the strongest gate). `disclose` ≠ `require-ok`: `campaign export` is cross-repo *by design*, so tightening it into a refusal breaks the feature. Distinct from the `require-target-dir.mjs` guard, which asks whether a scan **root** is readable (exit 3) — a different invariant, not to be merged.
|
||||
|
||||
**Subtraction floor (invariant).** `optimize --subtract` is the only lens that proposes removing config, so `scanners/lib/floor-exclusion.mjs` runs as a deterministic pre-step *before* the judge — a load-bearing block is never a candidate, and that guarantee must not be moved into the agent prompt. Two rules follow from it: (1) **staleness is not a deletion signal** — an outdated version pin inside a floor block is a `drift`/`CA-CML` dead-reference concern; (2) **tier 2 ≠ tier 3** — a compensatory block that keeps earning its place returns, and reporting it as dead weight is wrong even when the label matches. Norwegian keywords need the Unicode boundaries in `subtraction-prefilter.mjs`; JS `\b` is ASCII-only, so `/\bunngå\b/` silently never matches.
|
||||
|
||||
**Subtraction write path (invariant).** `--apply` routes through `scanners/lib/subtraction-write.mjs`, never through `fix-engine` or the `plan`/`implement` pipeline, and both exclusions are **measured**: the subtraction axis is absent from the orchestrated envelope, so `verifyFixes`' re-scan would mark every removal `verified` whether or not it happened (a success-shaped no-op), and the findings pipeline needs a finding code — which names a deterministic check, not a prose judgement. Three properties are load-bearing and each has a guard seen red against its own defect: removals are validated against the ORIGINAL content and applied in **descending** line order (an ascending pass shifts later spans out from under themselves); the **range** check is not redundant with the text check (`line: 0` makes `slice(-1, 0)` empty, so an empty `text` matches and `splice(-1, 1)` deletes the file's LAST line); and `createBackup` skips a nonexistent path while still returning an id, so coverage of every file about to be written is **asserted from the manifest** before a byte changes. The floor is *repeated* here, not moved: `floor-exclusion` still vetoes before anything is proposed, and the engine refuses a load-bearing block again so a hand-built approval cannot route around it. The archive rule (`mv` to `_archive/`) is file-level and does not apply to a block excision — the timestamped backup is the recovery artifact, and inventing a second copy with no restorer behind it would be worse than none.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
node --test 'tests/**/*.test.mjs'
|
||||
```
|
||||
|
||||
Test fixtures in `tests/fixtures/`. Per-scanner and per-build-block implementation notes (design rationale, primary-source verification, byte-stability lessons) live in `docs/scanner-internals.md` → **Implementation notes**.
|
||||
1279 tests across 72 test files (23 lib + 39 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`.
|
||||
|
||||
Per-scanner and per-build-block implementation notes (design rationale, primary-source verification, byte-stability lessons) live in `docs/scanner-internals.md` → **Implementation notes**.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Session directories accumulate — use `/config-audit cleanup` to manage
|
||||
- Scanners run on Node.js ≥ 18 (uses node:test, node:fs/promises)
|
||||
- Scanners run on Node.js >= 18 (uses node:test, node:fs/promises)
|
||||
- Plugin CLAUDE.md files in node_modules should be excluded via scope
|
||||
|
|
|
|||
217
README.md
217
README.md
|
|
@ -1,46 +1,27 @@
|
|||
# config-audit
|
||||
# Config-Audit Plugin for Claude Code
|
||||
|
||||
Multi-agent workflow for analyzing, reporting, and optimizing Claude Code configuration across your entire machine
|
||||
|
||||
Know if your configuration is correct. Find what could improve it. Fix it automatically.
|
||||
> Know if your configuration is correct. Find what could improve it. Fix it automatically.
|
||||
|
||||
> **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See [GOVERNANCE.md](GOVERNANCE.md) for the full model and what upstream provides.
|
||||
|
||||
*AI-generated: all code produced by Claude Code through dialog-driven development. Every change is human-directed, reviewed, and validated before commit. Per Anthropic Consumer Terms §4, ownership of outputs is assigned to the user; this plugin is licensed MIT.*
|
||||
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 16 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, cross-plugin collision, output-style, and always-loaded agent-listing-budget detection. Zero external dependencies.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
|
||||
claude plugin install config-audit@ktg-plugin-marketplace
|
||||
```
|
||||
|
||||
Or enable directly in `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"config-audit@ktg-plugin-marketplace": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed
|
||||
- Node.js 18+ — the scanners also run standalone from a clone, with no other dependencies
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [What's New in v5.4.0](#whats-new-in-v540)
|
||||
- [What Is This?](#what-is-this)
|
||||
- [The Configuration Problem](#the-configuration-problem)
|
||||
- [Quick Start](#quick-start)
|
||||
|
|
@ -58,13 +39,37 @@ Or enable directly in `~/.claude/settings.json`:
|
|||
- [Testing](#testing)
|
||||
- [Gotchas](#gotchas)
|
||||
- [Data Storage & Safety Guarantees](#data-storage--safety-guarantees)
|
||||
- [Non-goals](#non-goals)
|
||||
- [config-audit vs. the built-in /doctor](#config-audit-vs-the-built-in-doctor)
|
||||
- [Changelog](#changelog)
|
||||
- [What This Plugin Does Not Cover](#what-this-plugin-does-not-cover)
|
||||
- [Version History](#version-history)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## What's New in v5.4.0
|
||||
|
||||
**Plugin-hygiene & settings-validation hardening.** Three additive findings extend the plugin and
|
||||
settings surfaces — no new scanner, so the count stays **13**:
|
||||
|
||||
- **PLH plugin-folder shadowing** (`CA-PLH-015`) — flags a `plugin.json` component-path key in the
|
||||
*replaces* set (`commands`/`agents`/`outputStyles`) that points at a custom path while the
|
||||
default folder of that name still exists, so the folder is silently ignored (dead config).
|
||||
Mirrors Claude Code's own warning in `/doctor`, `claude plugin list`, and the `/plugin` detail
|
||||
view. `skills` is excluded (it *adds to* the default scan, never shadows), as are
|
||||
`hooks`/`mcpServers`/`lspServers` (own merge rules); a custom path resolving *into* the default
|
||||
folder is not flagged.
|
||||
- **PLH `skills:`-array validation** (`CA-PLH-016`) — validates each `plugin.json` `skills` entry
|
||||
(string or array) resolves to an existing directory inside the plugin root; flags `non-string`,
|
||||
`escapes-root`, `not-found`, and `not-a-directory` entries. Mirrors `claude plugin validate`.
|
||||
- **SET `autoMode` structure + dead-config** — checks that `autoMode` is an object whose only keys
|
||||
are `environment`/`allow`/`soft_deny`/`hard_deny`, each a string array (the literal `"$defaults"`
|
||||
is valid); unknown sub-keys and wrong types are flagged (medium). Separately, `autoMode` placed
|
||||
in **shared** project settings (`.claude/settings.json`) is flagged as dead config (low) —
|
||||
Claude Code's classifier does not read it there.
|
||||
|
||||
All three extend existing PLH and SET scanners. `--json` and `--raw` output remain byte-stable.
|
||||
|
||||
---
|
||||
|
||||
## What Is This?
|
||||
|
||||
Claude Code reads instructions from at least 7 different file types across multiple scopes: `CLAUDE.md`, `settings.json`, `.claude/rules/`, `hooks.json`, `.mcp.json`, `.claudeignore`, and `settings.local.json`. Each can exist at project level, user level, or both. Plugins add more. The system is powerful — but nobody tells you what you're using wrong, what you're missing, or what's silently conflicting.
|
||||
|
|
@ -149,7 +154,28 @@ Also **Grade A** — with only 3 opportunities remaining. This project has CLAUD
|
|||
|
||||
## Quick Start
|
||||
|
||||
Install first — see [Install](#install) above.
|
||||
### Prerequisites
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed
|
||||
- Node.js 18+ (for standalone CLI tools)
|
||||
|
||||
### Installation
|
||||
|
||||
Add the marketplace and browse plugins with `/plugin`:
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
|
||||
```
|
||||
|
||||
Or enable directly in `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"config-audit@ktg-plugin-marketplace": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### First Scan
|
||||
|
||||
|
|
@ -172,7 +198,7 @@ The CLI tools work standalone — no Claude Code session needed, just Node.js 18
|
|||
|
||||
Most configuration tools stop at "is it valid?" Config-audit goes further: **what could improve your setup, and is it relevant to your project?**
|
||||
|
||||
The feature opportunity scanner checks 24 dimensions and groups recommendations by impact:
|
||||
The feature opportunity scanner checks 25 dimensions and groups recommendations by impact:
|
||||
|
||||
| Impact Level | Focus | Examples |
|
||||
|--------------|-------|---------|
|
||||
|
|
@ -180,20 +206,9 @@ The feature opportunity scanner checks 24 dimensions and groups recommendations
|
|||
| **Worth Considering** | Workflow efficiency | Path-scoped rules, modular `@imports`, custom agents |
|
||||
| **Explore** | Nice-to-have | Keybindings, status line, output styles, agent teams |
|
||||
|
||||
Alongside the dimensions sit four **conditional levers** — recommendations that only make
|
||||
sense under a measured condition, so they stay silent otherwise. One of them is model/effort
|
||||
routing (`CA-GAP-028`): when you have your own subagents and *not one* of them names a
|
||||
`model:` or an `effort:`, every delegated task runs on the main conversation's model, because
|
||||
`model` defaults to `inherit`. It fires only when you actually have agents, and goes quiet the
|
||||
moment any of them routes either axis — so a deliberate everything-on-one-model setup is not
|
||||
nagged. Writing `model: inherit` out in full does not count as routing; it is the default
|
||||
spelled out.
|
||||
|
||||
Each recommendation is **context-aware** — it considers what your project actually contains. A solo TypeScript project gets different suggestions than a team Python monorepo. Recommendations include *why* (backed by Anthropic's official guidance) and *how* (concrete steps).
|
||||
|
||||
Run `/config-audit feature-gap` to see what's relevant to your project. To see what each agent
|
||||
currently runs on, `/config-audit whats-active` lists `model` and `effort` per agent, and
|
||||
`/config-audit manifest` shows them on the agent rows.
|
||||
Run `/config-audit feature-gap` to see what's relevant to your project.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -264,8 +279,6 @@ Your team configuration changes over time. Track it:
|
|||
| `/config-audit manifest` | Ranked table of every token source (CLAUDE.md, rules, agents, skills, output styles, MCP, hooks) sorted by estimated tokens — each tagged with its **load pattern** (always-loaded / on-demand / external) plus an **always-loaded subtotal** ("≈X tokens enter context every turn before you type"). Component-level: no coarse plugin roll-up (it would double-count) |
|
||||
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
|
||||
| `/config-audit optimize` | Optimization lens (mechanism-fit): config that works but fits a better mechanism — procedure→skill, lifecycle→hook, unscoped path→rule, "never"→permission. Hybrid motor (deterministic pre-filter + opus precision gate), every finding cites a best-practices-register rule |
|
||||
| `/config-audit optimize --subtract` | **Subtraction lens** — the inverse question no other command asks: what no longer earns its always-loaded rent? Ranks CLAUDE.md blocks that correct general model *behaviour* rather than stating a local fact, split into **dead** (never missed) and **earned** (returns if the model stumbles), with the token payoff (`BP-SUB-001`). **Load-bearing local facts are excluded deterministically before the judge sees anything** — remotes, versions, paths, filenames, policy invariants and unresolvable entity names are never candidates, and an ordered list is treated as a contract. Opt-in and proposes only; add `--apply` to execute the removals you pick. Pair with `--global` to reach the user-level CLAUDE.md, where the always-loaded cost actually sits |
|
||||
| `/config-audit optimize --subtract --apply` | **Execute approved removals.** You pick which blocks go by number; nothing is inferred. Every removal is checked against the file as it reads *now* — an approval that no longer matches is refused rather than applied to whatever moved into those lines — and the floor is re-asserted at write time, so a load-bearing block cannot be removed even by a hand-built approval. A dry run always precedes the write, the backup's manifest is verified to cover the file being written before a byte changes, and `/config-audit rollback` restores it. A removal targeting your machine-wide `~/.claude/CLAUDE.md` is **refused until you approve that scope explicitly** — it costs, and saves, in every project on every turn |
|
||||
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
|
||||
| `/config-audit rollback` | Restore configuration from a previous backup |
|
||||
| `/config-audit plan` | Generate prioritized action plan from audit findings |
|
||||
|
|
@ -308,7 +321,7 @@ By default, `/config-audit` auto-detects scope from your git context. Override w
|
|||
| `mcp-config-validator.mjs` | MCP | Invalid server types, exposed env vars, unknown fields |
|
||||
| `import-resolver.mjs` | IMP | Broken @imports, circular references, deep chains, tilde path issues |
|
||||
| `conflict-detector.mjs` | CNF | Settings contradictions across scopes, permission conflicts, hook duplicates |
|
||||
| `feature-gap-scanner.mjs` | GAP | 24 feature checks shown as opportunities, not grades — plus four conditional levers: a `disableBundledSkills` recommendation when the active skill listing is over budget, a **CLI-over-MCP** lever when tool schemas are forced upfront, a **filter-before-Claude-reads** lever when a hook injects unfiltered output into `additionalContext` (companion to the HKV advisory; cites the documented `filter-test-output.sh` pattern), and **agent model/effort routing** (`CA-GAP-028`) when authored subagents exist and none pins either axis (cites `BP-MODEL-001/002`). Levers are not dimensions: they stay out of the utilization denominators |
|
||||
| `feature-gap-scanner.mjs` | GAP | 25 feature checks shown as opportunities, not grades — plus a conditional `disableBundledSkills` recommendation when the active skill listing is over budget, and a conditional **filter-before-Claude-reads** lever when a hook injects unfiltered output into `additionalContext` (companion to the HKV advisory; cites the documented `filter-test-output.sh` pattern) |
|
||||
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascades, bloated skill descriptions, MCP tool-schema budget, and stale `~/.claude/plugins/cache` versions (disk-cleanup, zero live-context impact) — cache-aware ranking excludes superseded plugin versions by default (`--no-exclude-cache` to include) |
|
||||
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31–150 of the CLAUDE.md cascade — beyond Pattern A's top-30 window but still re-loaded every turn — **plus** volatile content inside `@import`-ed files (inlined into the cached prefix, one hop, otherwise invisible to per-file scans) |
|
||||
| `disabled-in-schema-scanner.mjs` | DIS | Dead/ineffective permission entries: (1) tools in BOTH `permissions.deny` and `permissions.allow` — deny wins (incl. the `Tool(*)` deny-all glob, equivalent to a bare deny); (2) unanchored allow wildcards (`*`, `B*`, `mcp__*`) that Claude Code silently skips — valid only as `mcp__<server>__*`; (3) `Tool(param:value)` rules whose key is the tool's own canonicalizing field (`command`/`file_path`/`path`/`notebook_path`/`url`) — CC ignores these and emits a startup warning |
|
||||
|
|
@ -477,12 +490,7 @@ Skills activate automatically when your question matches their trigger patterns.
|
|||
|
||||
### Finding ID Format
|
||||
|
||||
Every finding carries an ID of the form `CA-{SCANNER}-{NNN}` — where `{SCANNER}` is the scanner prefix (see table above) and `{NNN}` **names the check**, not the finding's position in a run. Examples: `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`.
|
||||
|
||||
Two consequences worth knowing before you pin one:
|
||||
|
||||
- **The same check always has the same ID.** It does not move when you fix an unrelated finding, when a check stops firing, or when a later release adds or retires one. That is what makes an ID safe to write into `.config-audit-ignore`. A number withdrawn from service is never reissued — a suppression naming a retired check goes dead rather than quietly matching a different one.
|
||||
- **An ID is not unique per finding.** One check failing in three files produces three findings that share an ID; `file` and `line` tell them apart. Suppressing by ID suppresses the check everywhere in scope.
|
||||
Every finding has a unique ID: `CA-{SCANNER}-{NNN}` — where `{SCANNER}` is the scanner prefix (see table above) and `{NNN}` is a sequential number. Examples: `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`.
|
||||
|
||||
### Suppression
|
||||
|
||||
|
|
@ -501,8 +509,6 @@ CA-PLH-*
|
|||
|
||||
Suppressed findings are tracked in the scan envelope's `suppressed_findings` array for audit trail — nothing is silently hidden. Use `--no-suppress` to see everything.
|
||||
|
||||
A pattern that names no known check is reported back in the envelope's `unknown_suppressions` array, so a pin that has gone stale (a typo, or a check retired in a later release) is visible instead of silently protecting nothing. Scanner-wide globs like `CA-GAP-*` are validated only down to the prefix, which is why a glob is the durable way to pin a whole scanner.
|
||||
|
||||
---
|
||||
|
||||
## Examples & Self-Audit
|
||||
|
|
@ -551,7 +557,6 @@ Shared modules used by all scanners — useful if you're reading the source or e
|
|||
| `suppression.mjs` | `.config-audit-ignore` parsing, finding suppression, audit trail |
|
||||
| `active-config-reader.mjs` | Read-only inventory of plugins/skills/MCP/hooks/CLAUDE.md cascade with token estimates |
|
||||
| `tokenizer-api.mjs` | Anthropic `count_tokens` wrapper for `--accurate-tokens` (v5 N5); 5s timeout, 429 backoff, key masking |
|
||||
| `write-scope.mjs` | Classifies a write target against the current project (`SCOPE_CLASSES`, `classifyWriteTarget()`); one source for class, gate strength and wording |
|
||||
|
||||
### Action Engines
|
||||
|
||||
|
|
@ -564,7 +569,6 @@ Shared modules used by all scanners — useful if you're reading the source or e
|
|||
| `manifest.mjs` | CLI: ranked component-level source table w/ load-pattern accounting (v5 N2; v5.6 B) |
|
||||
| `whats-active.mjs` | CLI: read-only active-config inventory (v3.1.0+) |
|
||||
| `token-hotspots-cli.mjs` | CLI: token hotspots ranking with optional `--accurate-tokens` |
|
||||
| `write-scope-cli.mjs` | CLI: classify write targets before an approval surface (`--target`, repeatable) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -591,41 +595,6 @@ date, and a `confidence`. It is the source of truth for the optimization lens (O
|
|||
`scanners/lib/best-practices-register.mjs` (zero-dependency, native JSON). See
|
||||
`docs/v5.7-optimization-lens-plan.md`.
|
||||
|
||||
### The subtraction floor
|
||||
|
||||
`optimize --subtract` is the only lens that proposes *removing* configuration, so it carries a
|
||||
guarantee the others do not need: **a load-bearing block is never a candidate.** Precision here
|
||||
is asymmetric — a missed dead line costs a few tokens per turn, while a deleted load-bearing
|
||||
line costs a wrong remote or a broken script — so the floor is decided in code
|
||||
(`scanners/lib/floor-exclusion.mjs`), before the opus judge sees anything, rather than being
|
||||
left to prose judgement.
|
||||
|
||||
A block is floored when it carries an underivable local literal (inline code span, rooted path,
|
||||
domain, concrete filename, version pin), when it states a policy invariant (secrets,
|
||||
credentials, production, prompt-injection boundaries — floor *by decision*, not by
|
||||
classification), or when it names a capitalized entity the mechanism cannot resolve without a
|
||||
dictionary. That last rule is a deliberate conservative default: it declines to decide and
|
||||
keeps the block, paying in recall rather than risk.
|
||||
|
||||
The write half (`--apply`) keeps the same asymmetry. It is not a `fix` action and not a
|
||||
`plan`/`implement` step, and both exclusions are measurements rather than preferences: the
|
||||
subtraction axis never enters the orchestrated envelope, so `fix`'s re-scan verification would
|
||||
report every removal as verified whether or not it happened — a success-shaped no-op — and the
|
||||
findings pipeline expects a finding code, which by invariant names a deterministic check, not a
|
||||
prose judgement. `scanners/lib/subtraction-write.mjs` owns the execution instead, and it
|
||||
re-asserts the floor rather than trusting that the pre-filter already did: the veto stays where
|
||||
it is, and is simply repeated as the last red line before the delete.
|
||||
|
||||
Granularity is the **leaf block** — one list item including its wrapped continuation lines, or
|
||||
one paragraph — with two structural exceptions: a paragraph ending in `:` merges with the list
|
||||
it introduces, and an *ordered* list is treated as a contract whose steps inherit floor from
|
||||
any sibling. Unordered lists deliberately do not inherit, so a load-bearing bullet and a
|
||||
disposable one can coexist in the same list. Measured against a hand-built ground truth over
|
||||
a real 250-line CLAUDE.md (48 classified blocks, 19 of them genuinely ambiguous, ~65 % floor):
|
||||
**zero load-bearing blocks proposed**, 11 of 18 deletable line-ranges surfaced, ≈18 % of an
|
||||
always-loaded file. On a well-maintained config this axis is mostly a no-op — which is itself
|
||||
the finding, and the reason precision-over-recall is the only defensible tuning.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
|
@ -634,7 +603,7 @@ the finding, and the reason precision-over-recall is the only defensible tuning.
|
|||
node --test 'tests/**/*.test.mjs'
|
||||
```
|
||||
|
||||
That one command runs the whole suite from a clean clone: **1477 tests, 370 suites** (measured 2026-08-03, all passing). Nothing runs it automatically — there is no CI in this organisation, so the command above is the verification, not a badge. Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`).
|
||||
1168 tests across 67 test files (22 lib + 35 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -677,74 +646,22 @@ This plugin is cautious by design — configuration files are important, and a b
|
|||
| **Verification pass** | A separate agent confirms changes actually work |
|
||||
| **Human-in-the-loop** | You approve the plan before anything is implemented |
|
||||
| **Post-edit guard** | Hook blocks the session if a new critical/high finding is introduced |
|
||||
| **Scope disclosed before every write** | Each write target is classified against the project you are in, and the approval surface says when a change leaves it |
|
||||
|
||||
### Writes That Leave Your Project
|
||||
|
||||
config-audit reads configuration across projects and machine-wide, so some of what
|
||||
it proposes does not land where you are standing. A count of files cannot tell those
|
||||
cases apart: a plan that edits `~/.claude/CLAUDE.md` and one that edits your
|
||||
project's own `CLAUDE.md` are both "1 file".
|
||||
|
||||
Every write target is therefore classified before you are asked to approve it, and
|
||||
the class — not the command — decides how strong the gate is:
|
||||
|
||||
| Where the write lands | What happens |
|
||||
|---|---|
|
||||
| Inside the project you are working in | No extra gate; the usual confirmation applies |
|
||||
| config-audit's own session state and backups | No extra gate; this is the plugin's bookkeeping, not your configuration |
|
||||
| Your machine-wide Claude configuration (`~/.claude`) | Stated plainly, and it needs an explicit go-ahead — a change here affects every project you open |
|
||||
| A different project | Stated plainly, including that directories will be created there. `campaign export` does this deliberately, so this is disclosure, not refusal |
|
||||
| Anywhere else | Stated plainly, and it needs an explicit go-ahead |
|
||||
|
||||
Where a machine-wide or cross-project write is involved, the safe option is listed
|
||||
first — the default is never "proceed".
|
||||
|
||||
---
|
||||
|
||||
## Non-goals
|
||||
## What This Plugin Does Not Cover
|
||||
|
||||
- **Runtime behavior** — this plugin audits configuration files, not what Claude actually does at runtime. For runtime defense, see [llm-security](https://git.fromaitochitta.com/open/llm-security)
|
||||
- **Runtime behavior** — this plugin audits configuration files, not what Claude actually does at runtime. For runtime defense, see [claude-code-llm-security](https://git.fromaitochitta.com/open/claude-code-llm-security)
|
||||
- **Secret scanning** — config-audit checks for structural issues, not leaked credentials. Use llm-security for secret detection
|
||||
- **Custom scanner rules** — scanners check against known Claude Code configuration schemas. Custom rule definitions are not supported
|
||||
- **Remote/team configuration** — managed settings, SSO-provisioned config, and organization-level policies are detected as gaps but not managed
|
||||
|
||||
---
|
||||
|
||||
## config-audit vs. the built-in /doctor
|
||||
|
||||
Claude Code ships `/doctor` (alias `/checkup`, v2.1.205+): an agent-driven setup checkup that
|
||||
diagnoses and — with your confirmation — fixes issues. The overlap with config-audit was
|
||||
measured (2026-08-03, CC 2.1.220, prediction-before-measurement protocol), and the two tools
|
||||
divide cleanly:
|
||||
|
||||
| | Built-in `/doctor` | config-audit |
|
||||
|---|---|---|
|
||||
| Engine | Model-driven judgment, one machine, one run | Deterministic scanners, byte-stable, reproducible |
|
||||
| Cost | A full agent session per run (quota) | Free, seconds, scriptable/CI-able |
|
||||
| Scope | Current setup; trims **checked-in** CLAUDE.md only | All scopes incl. **local/private** files, plus cross-repo `campaign` |
|
||||
| Evidence | **Usage telemetry** (transcripts, lifetime counters) — its unique edge | Static analysis with provenance-stamped best-practices register |
|
||||
| Memory | None between runs | Baselines (`drift`), suppressions with audit trail, backup/rollback |
|
||||
|
||||
Division of labor per area: `/doctor` parses settings — the SET scanner validates the schema
|
||||
exhaustively (unknown/deprecated keys, types, whole cascade). `/doctor` measures hook
|
||||
*latency* — the HKV scanner validates hook *correctness*. `/doctor` judges conflicts per run —
|
||||
the CNF scanner detects them deterministically. `/doctor` trims derivable content from
|
||||
checked-in CLAUDE.md — `optimize --subtract` covers **all** scopes with a coded load-bearing
|
||||
floor (`/doctor` itself refers local-file trimming to `optimize --subtract`). `/doctor`
|
||||
estimates context weight once — `tokens`/`manifest` measure it deterministically and
|
||||
cache-aware. Run `/doctor` for a usage-weighted one-shot cleanup; run config-audit for
|
||||
reproducible, all-scope, zero-quota auditing.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
Full detail in [CHANGELOG.md](CHANGELOG.md). Highlights per release:
|
||||
## Version History
|
||||
|
||||
| Version | Date | Highlights |
|
||||
|---------|------|-----------|
|
||||
| **5.13.0** | 2026-07-31 | "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 plus **14 bugs** (`M-BUG-11`…`M-BUG-20`, `M-BUG-22`…`M-BUG-25`). **Added — `optimize --subtract` (`BP-SUB-001`):** the subtraction axis, asking what no longer earns its always-loaded rent. Opt-in, proposes only, and the only lens that removes config — so a **load-bearing block is never a candidate**, decided in code (`scanners/lib/floor-exclusion.mjs`) *before* the judge runs, never in prose. Verified against a hand-built ground truth written before any classifier existed: **zero load-bearing blocks proposed**, 11/18 groups, ~756 tok ≈ 18% of a ~4300-token file. **Fixed — `rollback` (`M-BUG-22/23/24/25`):** nothing agreed on where a backup lives; `listBackups()` returned 9 phantom test backups and 0 of 4 real ones, and `restoreBackup` returned `{restored:[],failed:[]}` — a **success-shaped no-op** — because `parseManifest` knew only one of the two manifest spellings in use. Canonical root now, legacy kept readable, unparseable manifests **throw**. **`M-BUG-19`:** `globToRegex` corrupted mid-pattern `/**/`, flagging live rules dead. **`M-BUG-18`/`M-BUG-20`:** the subagent harness won't write report-shaped `.md` (analyze now persists the returned report), and parallel agents clobbered the shared log with `Write` (pinned to Bash `>>`). **`M-BUG-11`/`M-BUG-13`:** `optimize` and `feature-gap` scanned vendored plugin config a user cannot act on — `optimize` candidates **454→45**, `feature-gap` **~0 (masked) → 18** opportunities. **`M-BUG-12`/`M-BUG-14`/`M-BUG-15`/`M-BUG-16`/`M-BUG-17`:** plain-language output that contradicted its own evidence (posture's `--output-file` never humanized; four finding types with no humanizer entry). Known, deliberately unfixed: `rollback` cannot delete files `implement` *created* — it now reports them (`createdNotRemoved`) instead of failing silently; automatic deletion of user files gets its own design. No count change (scanners **16**, agents **7**, commands **21**, hooks **4**); frozen v5.0.0 untouched, SC-5 regenerated once for two humanized titles. **1398** tests (+54). |
|
||||
| **5.12.5** | 2026-06-26 | "Dogfood denoise" — a samle-release of the Fase-3 scanner false-positive batch (`M-BUG-2/6/7/8/10`, all dogfooding finds on the maintainer's real machine). Five scanners stop counting non-user / non-live config as the user's: **CNF** (`M-BUG-2`) excludes files under `.claude/plugins/` from conflict analysis (`isPluginBundled`) — installed plugins' bundled settings/hooks/fixtures are not a user-resolvable cascade (dogfood **339→0**, Conflicts was an F on pure plugin noise). **file-discovery** (`M-BUG-8`) adds `backups` to `SKIP_DIRS` — a `backups/` tree holds frozen copies, never live config (dogfood files-under-`/backups/` **36→0**, 717 live retained). **token estimator** (`M-BUG-6`) strips block-level `<!-- -->` HTML comments from CLAUDE.md sizing — CC strips them before injection, so they were never always-loaded tokens (dogfood ~3386→3301, ~85 tok). **CPS** (`M-BUG-7`) skips fenced/inline code and whitelists CC-stable path vars (`${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}`) before cache-buster matching (dogfood **5→2**). **SET** (`M-BUG-10`) typo-gates the unknown-settings-key finding — the CC schema is passthrough (verified against the 2.1.193 binary), so an unknown key is forward-compatible, not an error; it now flags only a near-miss of a known key (levenshtein ≤2), severity medium→low, +6 binary-verified `KNOWN_KEYS` (dogfood **6→0**). No count change (scanners **16**, agents **7**, commands **21**, hooks **4**); all five are byte-stable — frozen v5.0.0 + SC-5 + default-output snapshots untouched, no re-seed (each fixture's findings are genuinely unchanged). **1344** tests (+37). |
|
||||
| **5.12.4** | 2026-06-26 | "Rooted rules" — fixes `M-BUG-9` (dogfooding find) in `scanners/rules-validator.mjs`: the RUL "Rule path pattern matches no files" check now resolves a rule's `paths:`/`globs:` pattern against the rule's **own project root** (the dir containing its `.claude/`), not the outer scan root. Previously `countGlobMatches` globbed against the scan target and `collectProjectFiles`' `depth>4` cutoff never reached deep matching files, so a live rule in a **nested repo** (e.g. a marketplace checkout under `~/.claude`) was wrongly flagged "never activates" (high) — a false F-grade for anyone with rules in a nested repo. The fix derives each rule's project root, collects+globs per root (cached), and skips the check for user-global rules (`root === $HOME`), which scope against the active project at runtime. Same scope-conflation family as `M-BUG-1/2`. No count change (scanners **16**, agents **7**, commands **21**); the fix is a no-op when `projectRoot === targetPath` (the common single-repo scan), so frozen v5.0.0 + default-output snapshots stay byte-stable. **1307** tests (+2 TDD: nested-repo false-positive + HOME guard). |
|
||||
| **5.12.3** | 2026-06-26 | "Phantom agents" — fixes `M-BUG-3/4/5` (dogfooding finds) in `scanners/lib/active-config-reader.mjs`: `enumerateAgents` now counts only the agents Claude Code actually **registers**. Per the official subagents doc, an agent needs valid `name`+`description` frontmatter, and CC scans recursively and silently skips frontmatter-less files. The reader previously (`M-BUG-5`) counted every `.md` regardless of frontmatter, (`M-BUG-3`) never recursed into agent subdirs, and (`M-BUG-4`) double-counted when the project dir equals the user dir (scanning `$HOME` — root cause, also affecting rules/output-styles). Real-machine verify: user-agent count **13→0** (all 12 user agents + `REMEMBER.md` are frontmatter-less → CC registers none), HOME `project`-dup **13→0**; corrected always-loaded baseline ≈ **53** (was 66). Agent enumeration is machine-dependent and absent from the frozen snapshots, so the v5.0.0 + SC-5 + default-output snapshots stay byte-stable; no count change (scanners **16**, agents **7**, commands **21**). **1305** tests. |
|
||||
|
|
@ -780,6 +697,8 @@ Full detail in [CHANGELOG.md](CHANGELOG.md). Highlights per release:
|
|||
| **1.0.0** | 2026-02-11 | Cross-platform support |
|
||||
| **0.7.0** | 2026-02-07 | Initial version (version reset from inflated 1.2.0) |
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for full details.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
|
|
|||
|
|
@ -51,16 +51,11 @@ In `--raw` mode, fall back to v5.0.0 severity prefiks and verbatim scanner title
|
|||
5. **Identify optimizations**: Rules to globalize, missing configs, orphaned files
|
||||
6. **Security scan**: Aggregate secret warnings, check for insecure patterns
|
||||
7. **CLAUDE.md quality assessment**: Score each file against rubric, assign letter grades
|
||||
8. **Generate report**: Compose the comprehensive markdown report — group findings by `userImpactCategory`, lead with `userActionLanguage`
|
||||
8. **Generate report**: Write comprehensive markdown report — group findings by `userImpactCategory`, lead with `userActionLanguage`
|
||||
|
||||
## Output
|
||||
|
||||
Return the complete report as your final message — do not write it to a file
|
||||
yourself. The Claude Code subagent harness instructs agents not to write
|
||||
report/analysis files; your text output IS the deliverable. The orchestrating
|
||||
command saves your returned report verbatim to
|
||||
`~/.claude/config-audit/sessions/{session-id}/analysis-report.md` for the
|
||||
downstream plan/interview/status phases.
|
||||
Write to: `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
|
||||
|
||||
**Output MUST NOT exceed 300 lines.** Prioritize findings by severity. Use tables, not prose.
|
||||
|
||||
|
|
@ -188,4 +183,4 @@ Verify report: all findings referenced, recommendations actionable, severity lev
|
|||
|
||||
- Process findings in memory (typically < 1MB total)
|
||||
- Generate report in single pass
|
||||
- No file modifications (read-only; the report is returned as your final message)
|
||||
- No file modifications (read-only except report output)
|
||||
|
|
|
|||
|
|
@ -146,11 +146,6 @@ Move content from one file to another.
|
|||
|
||||
Append to: `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
|
||||
|
||||
**Append discipline (shared log):** other implementer agents may be writing this
|
||||
log concurrently. ALWAYS append your entry with a Bash `>>` heredoc;
|
||||
NEVER use the Write or Edit tool on the log file — a full-file Write silently
|
||||
clobbers entries other agents appended after you read the file.
|
||||
|
||||
### Success
|
||||
|
||||
```markdown
|
||||
|
|
|
|||
|
|
@ -33,39 +33,6 @@ whether the line is *really* that kind of instruction:
|
|||
| `claude-md-lifecycle-phrasing` | BP-MECH-001 | a recurring automation the model is *told* to perform ("after every commit, run X") — something that should happen deterministically, not at the model's discretion | a **hook** (PreToolUse / PostToolUse / Stop) |
|
||||
| `unscoped-path-specific-instruction` | BP-MECH-002 | a constraint that only applies when a *specific* file/path/glob is touched, sitting in root CLAUDE.md where it loads every turn regardless | a **path-scoped rule** (`.claude/rules/` with `paths:` frontmatter) |
|
||||
| `never-instruction` | BP-MECH-004 | an *absolute* prohibition — something that must NEVER happen, where relying on the model to remember is the wrong guarantee | a **permission deny rule** or PreToolUse hook |
|
||||
| `compensatory-instruction` | BP-SUB-001 | **`--subtract` mode only.** an instruction that corrects general model *behaviour* rather than stating a local fact — so it pays an always-loaded token cost without telling the model anything it could not work out | **removal**, re-added only if the model actually stumbles |
|
||||
|
||||
## The subtraction lens (`--subtract` only)
|
||||
|
||||
Present only when the payload has a `subtract` block. It asks the inverse of
|
||||
every other lens: *what is no longer earning its always-loaded rent?* Three
|
||||
things make it different, and all three are non-negotiable.
|
||||
|
||||
**1. The floor is not yours to decide.** A deterministic pre-step has already
|
||||
excluded every block carrying a local fact — a code span, path, domain, version
|
||||
pin, policy invariant, or an unresolved capitalized entity — plus the steps of
|
||||
any ordered list whose siblings carry one. You never see those blocks, and you
|
||||
must not reason about whether some *other* block ought to be deleted. Judge only
|
||||
what you are given. Precision is asymmetric: a missed dead line costs a few
|
||||
tokens per turn; a deleted load-bearing line costs a wrong remote, a broken
|
||||
script, or a lost afternoon.
|
||||
|
||||
**2. Staleness is NOT a deletion signal.** A block that pins an outdated version
|
||||
("use Opus 4.8") is a *dead-reference* problem for `drift` / `CA-CML`, not a
|
||||
subtraction finding. The instruction is still load-bearing — it encodes a
|
||||
decision only the operator can make; it is merely out of date. Recommending
|
||||
deletion because content looks stale is a category error. Say "this looks
|
||||
outdated" if you must, but never as a removal candidate.
|
||||
|
||||
**3. Tier 2 is not tier 3.** Deletable splits into *earned* (compensatory, but
|
||||
this model still stumbles on it, so it returns) and *dead* (never missed). Sort
|
||||
every candidate into one of the two and say which. A block that has visibly
|
||||
earned its place — its subject matter recurs in the repo's own history — is tier
|
||||
2 even when its classification is "compensatory". Reporting it as dead weight is
|
||||
wrong even though the label matches.
|
||||
|
||||
Rank kept candidates by always-loaded token cost, and state the total payoff.
|
||||
Frame it as *rent*, never as a mistake: this config was correct when written.
|
||||
|
||||
## Input
|
||||
|
||||
|
|
@ -78,10 +45,6 @@ You receive an `optimize-lens` payload (JSON) with:
|
|||
`recommendation`, `severity`, `source`). Only CONFIRMED register rules reach
|
||||
you.
|
||||
- `register` — the full confirmed prose-judgment entries, for reference.
|
||||
- `subtract` — **present only under `--subtract`.** `{ enabled, candidates,
|
||||
register, detectors }`. Each candidate spans `line`–`endLine` (a whole leaf
|
||||
block, not one line) and carries `signalText` plus the BP-SUB-001 register
|
||||
block. Everything load-bearing was already removed before you saw this.
|
||||
|
||||
Always **Read the actual CLAUDE.md file(s)** named in the candidates before
|
||||
judging — `signalText` is one line out of context; the surrounding lines decide
|
||||
|
|
@ -141,15 +104,6 @@ Write `optimization-lens-report.md` to the session directory (≤120 lines).
|
|||
{Brief, honest: candidates you dropped and why — "line 22 mentions a path but is
|
||||
a cross-reference, not an instruction." This is the precision gate showing its
|
||||
work. Keep to a few lines.}
|
||||
|
||||
## No longer earning its rent (--subtract only)
|
||||
|
||||
{Omit entirely unless the payload has a `subtract` block. Two sub-lists —
|
||||
**Dead** (tier 3, out and never missed) and **Earned** (tier 2, out but likely
|
||||
to return) — ranked by token cost, with a payoff total. For each:}
|
||||
**{file}:{line}-{endLine}** — {what the block says, in one line} · ~{N} tok/turn
|
||||
Tier: {dead | earned — and why}
|
||||
Source: {register.source.url}
|
||||
```
|
||||
|
||||
Omit any section with zero kept findings (except keep the "left alone" note when
|
||||
|
|
|
|||
|
|
@ -171,10 +171,18 @@ Total backup size: ~6.4 KB
|
|||
**Rationale**:
|
||||
Code style rules found in 3 projects are identical. Moving to global reduces duplication.
|
||||
|
||||
**Content outline** (describe it — do not inline the file):
|
||||
Language preferences, then commit format. The implementer reads the source
|
||||
files and writes the content itself; a full file body pasted here is what the
|
||||
200-line budget above forbids.
|
||||
**Content**:
|
||||
```markdown
|
||||
# Code Style Rules
|
||||
|
||||
## Language Preferences
|
||||
- TypeScript > JavaScript
|
||||
- Explicit > implicit
|
||||
- Lesbarhet > cleverness
|
||||
|
||||
## Commit Format
|
||||
- Conventional Commits: `type(scope): description`
|
||||
```
|
||||
|
||||
**Validation**:
|
||||
- File exists after creation
|
||||
|
|
|
|||
|
|
@ -20,16 +20,9 @@ Generate comprehensive analysis report from discovery findings.
|
|||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Resolve the session and verify its state
|
||||
### Step 1: Verify session state
|
||||
|
||||
Find the session first — never guess which one `{session-id}` refers to:
|
||||
|
||||
```
|
||||
Glob: ~/.claude/config-audit/sessions/*/state.yaml
|
||||
Sort by modification time — the most recently modified session wins
|
||||
```
|
||||
|
||||
Every `{session-id}` below is that session's id. Read its `state.yaml` using the Read tool and verify discovery phase completed. If the Glob returns nothing, or discovery hasn't completed, tell the user: "Discovery hasn't been run yet. Start with `/config-audit discover` or just run `/config-audit` for a full audit."
|
||||
Read `~/.claude/config-audit/sessions/{session-id}/state.yaml` using the Read tool and verify discovery phase completed. If not, tell the user: "Discovery hasn't been run yet. Start with `/config-audit discover` or just run `/config-audit` for a full audit."
|
||||
|
||||
### Step 2: Tell the user what's happening
|
||||
|
||||
|
|
@ -44,16 +37,17 @@ This includes hierarchy mapping, conflict detection, and prioritized recommendat
|
|||
|
||||
Tell the user: **"Generating analysis (this takes about 30 seconds)..."**
|
||||
|
||||
Check whether `$ARGUMENTS` contains `--raw`. Carry the answer yourself: the agent
|
||||
prompt below is **not** a shell, so a variable assigned in a bash block cannot be
|
||||
referenced from it. Substitute `{mode}` literally with `--raw` or `humanized`.
|
||||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
```
|
||||
|
||||
```
|
||||
Agent(subagent_type: "config-audit:analyzer-agent")
|
||||
model: sonnet
|
||||
prompt: |
|
||||
Analyze all findings in: ~/.claude/config-audit/sessions/{session-id}/findings/
|
||||
Mode: {mode} ("humanized" = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
|
||||
Mode: $RAW_FLAG (empty = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
|
||||
Generate comprehensive report covering:
|
||||
1. Executive summary with key metrics, grouped by userImpactCategory
|
||||
2. Hierarchy map visualization
|
||||
|
|
@ -66,21 +60,12 @@ Agent(subagent_type: "config-audit:analyzer-agent")
|
|||
raw severity. The humanizer already replaced jargon-heavy
|
||||
title/description/recommendation strings with plain-language
|
||||
equivalents — render them verbatim, do not paraphrase.
|
||||
Return the complete report as your final message. Do not write it
|
||||
to a file — the orchestrating command saves it to the session directory.
|
||||
Output to: ~/.claude/config-audit/sessions/{session-id}/analysis-report.md
|
||||
```
|
||||
|
||||
### Step 4: Save the report
|
||||
### Step 4: Present summary
|
||||
|
||||
The agent returns the complete report as its final message — the Claude Code
|
||||
subagent harness instructs agents not to write report/analysis files themselves,
|
||||
so the command must persist it. Write the returned report verbatim (no edits,
|
||||
no truncation) to `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
|
||||
using the Write tool. Downstream phases (`plan`, `interview`, `status`) read this file.
|
||||
|
||||
### Step 5: Present summary
|
||||
|
||||
After saving the report, show a brief summary:
|
||||
After the agent completes, read the generated report and show a brief summary:
|
||||
|
||||
```markdown
|
||||
### Analysis Complete
|
||||
|
|
@ -99,6 +84,6 @@ Full report: `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
|
|||
- **`/config-audit fix`** — Auto-fix deterministic issues right away
|
||||
```
|
||||
|
||||
### Step 6: Update state
|
||||
### Step 5: Update state
|
||||
|
||||
Update `state.yaml` with all four fields `.claude/rules/state-management.md` requires: `current_phase: "analyze"`, `completed_phases` (append `analyze` to the existing array — read it first), `next_phase: "plan"`, and `updated_at`. A write that names only two of the four silently deletes the other two.
|
||||
Update `state.yaml` with `current_phase: "analyze"`, `next_phase: "plan"`.
|
||||
|
|
|
|||
|
|
@ -62,8 +62,7 @@ From `$ARGUMENTS`, pick the mode:
|
|||
- `export <path>` → export a planned repo's action plan into that repo's own `docs/`.
|
||||
- `help` → show this surface and stop.
|
||||
|
||||
Every write step derives its own date stamp inside its own block — there is no shared one to
|
||||
set here, because each fenced block runs as a separate process.
|
||||
Set a shared date stamp for any write: `TODAY=$(date +%F)`.
|
||||
|
||||
### Step 2: Always report current state first
|
||||
|
||||
|
|
@ -148,9 +147,6 @@ If already initialized, say so and stop (no clobber). Otherwise tell the user wh
|
|||
then create it:
|
||||
|
||||
```bash
|
||||
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
|
||||
# in an earlier block is empty by the time this one runs.
|
||||
TODAY=$(date +%F)
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs init \
|
||||
--reference-date "$TODAY" \
|
||||
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
|
||||
|
|
@ -174,23 +170,13 @@ at `~/.claude/config-audit/campaign-ledger.json`." Then suggest `add`.
|
|||
them in one call (idempotent — already-tracked repos are skipped, not reset):
|
||||
|
||||
```bash
|
||||
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
|
||||
# in an earlier block is empty by the time this one runs.
|
||||
TODAY=$(date +%F)
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs add "<path1>" "<path2>" ... \
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs add <path1> <path2> ... \
|
||||
--reference-date "$TODAY" \
|
||||
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
(For a single repo with a custom display name, add `--name "<name>"`.) Read the result file and
|
||||
report what was `added`, `addedUnverified`, and `skipped`, then re-show the repo table.
|
||||
|
||||
`addedUnverified[]` holds paths that were tracked but could **not** be read right now (they do
|
||||
not exist, or are not directories). They are tracked deliberately — an unmounted volume is a
|
||||
legitimate reason for a repo to be missing today — but they must be named, not glossed over:
|
||||
"Tracked, but I couldn't read `<path>` — check for a typo, or mount it before the next token
|
||||
sweep." An unreported phantom row stays in the backlog forever and quietly widens every
|
||||
machine-wide total.
|
||||
report what was `added` vs `skipped`, then re-show the repo table.
|
||||
|
||||
### Step 5 (mode `set-status`): Transition a repo — propose, approve, write
|
||||
|
||||
|
|
@ -208,12 +194,9 @@ roll-up stays meaningful. Two honest sources, in order of preference:
|
|||
On approval:
|
||||
|
||||
```bash
|
||||
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
|
||||
# in an earlier block is empty by the time this one runs.
|
||||
TODAY=$(date +%F)
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs set-status "<path>" "<status>" \
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs set-status <path> <status> \
|
||||
--reference-date "$TODAY" \
|
||||
[--findings '{"critical":0,"high":0,"medium":0,"low":0}'] [--session "<id>"] \
|
||||
[--findings '{"critical":0,"high":0,"medium":0,"low":0}'] [--session <id>] \
|
||||
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
|
|
@ -232,9 +215,6 @@ replaces, never accumulates), and **skips — never aborts on** — any repo tha
|
|||
the user it will read each tracked repo's live config (a few seconds per repo), then on approval:
|
||||
|
||||
```bash
|
||||
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
|
||||
# in an earlier block is empty by the time this one runs.
|
||||
TODAY=$(date +%F)
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs refresh-tokens \
|
||||
--reference-date "$TODAY" \
|
||||
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
|
||||
|
|
@ -255,9 +235,6 @@ not buried in a session dir. This step copies it there, byte-faithfully.
|
|||
that carries an `action-plan.md` (i.e. `/config-audit plan` has run there). Run without `--write`:
|
||||
|
||||
```bash
|
||||
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
|
||||
# in an earlier block is empty by the time this one runs.
|
||||
TODAY=$(date +%F)
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-export-cli.mjs --repo "<path>" \
|
||||
--reference-date "$TODAY" \
|
||||
--output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $?
|
||||
|
|
@ -275,24 +252,9 @@ no/corrupt ledger). Read `~/.claude/config-audit/sessions/campaign-export.json`
|
|||
— the first ~12 lines of `document` only, never the whole file, never the raw JSON (UX rules).
|
||||
Ask for explicit approval to write it.
|
||||
|
||||
Showing the path is not the same as saying it leaves this repo. Classify it first:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<targetPath>" --repo "$PWD" --output-file ~/.claude/config-audit/sessions/campaign-export-scope.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Read that file and render each distinct string in `disclosures[]` verbatim before the
|
||||
approval question. Exporting into another repo is what this command is *for*, so the
|
||||
gate here **discloses and does not refuse** — say that the write lands in a different
|
||||
project and that a `docs/` directory will be created there if it is missing. Do not
|
||||
turn this into a refusal.
|
||||
|
||||
**On approval, write it** (the CLI does the faithful copy — do NOT hand-write the file):
|
||||
|
||||
```bash
|
||||
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
|
||||
# in an earlier block is empty by the time this one runs.
|
||||
TODAY=$(date +%F)
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-export-cli.mjs --repo "<path>" --write \
|
||||
--reference-date "$TODAY" \
|
||||
--output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $?
|
||||
|
|
|
|||
|
|
@ -75,13 +75,7 @@ Manage and clean up accumulated config-audit sessions in `~/.claude/config-audit
|
|||
- Warn before deleting active sessions: "Session {id} is still active (phase: {phase}). Delete anyway?"
|
||||
|
||||
6. **Execute cleanup**:
|
||||
- **Validate the id before it ever reaches `rm -rf`.** Each `{session-id}`
|
||||
must match `^[0-9]{8}_[0-9]{6}$` (the id format `discover` generates) or be
|
||||
an existing directory name read verbatim from the Glob in step 1. If an id
|
||||
is empty or fails to match, **refuse to delete it**, report it, and continue
|
||||
with the rest. An empty id expands the path to
|
||||
`~/.claude/config-audit/sessions//`, which deletes *every* session.
|
||||
- For each validated session: `rm -rf ~/.claude/config-audit/sessions/{session-id}/`
|
||||
- For each session to delete: `rm -rf ~/.claude/config-audit/sessions/{session-id}/`
|
||||
- Track deleted count and freed space
|
||||
|
||||
7. **Output summary**:
|
||||
|
|
|
|||
|
|
@ -83,41 +83,29 @@ This is a silent infrastructure step — do NOT show output to the user.
|
|||
|
||||
### Step 3: Run scanners and posture assessment
|
||||
|
||||
Tell the user: **"Running 16 configuration scanners..."**
|
||||
Tell the user: **"Running 12 configuration scanners..."**
|
||||
|
||||
Run both scanners and posture in a single Bash command. Default mode runs the humanizer, so each finding in `scan-results.json` carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. If the user passed `--raw`, thread it through to both CLIs to get v5.0.0 verbatim output.
|
||||
|
||||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -qE -- '(^| )--raw( |$)'; then RAW_FLAG="--raw"; fi
|
||||
# Set to the ONE scope flag the detected scope calls for, otherwise leave empty.
|
||||
# Exactly one token: zsh does not word-split an unquoted expansion, so a variable
|
||||
# holding "--flag value" would reach argv as a single unrecognised argument.
|
||||
# A placeholder in square brackets does not start with a dash either, so both
|
||||
# CLIs' arg loops would take it as the TARGET PATH instead of a flag.
|
||||
SCOPE_FLAG="" # e.g. SCOPE_FLAG="--full-machine" or SCOPE_FLAG="--global"
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json $SCOPE_FLAG $RAW_FLAG >/dev/null 2>/dev/null; ORCH_STATUS=$?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json $SCOPE_FLAG $RAW_FLAG >/dev/null 2>~/.claude/config-audit/sessions/{session-id}/posture-stderr.txt; POSTURE_STATUS=$?
|
||||
echo "$ORCH_STATUS $POSTURE_STATUS"
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json [--full-machine] [--global] $RAW_FLAG 2>/dev/null; node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json [--full-machine] [--global] $RAW_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Use `--full-machine` for `full` scope, `--global` for `home` scope. For `repo` and `current`, pass the resolved path directly.
|
||||
|
||||
Two exit codes are echoed — the orchestrator's first, posture's second. They must be read **independently**; a single trailing `echo $?` would report only the last command, hiding an orchestrator failure behind posture's success.
|
||||
|
||||
- both in `0`, `1`, `2` → continue normally
|
||||
- **either** is `3` → tell user: "Scanner encountered an unexpected error. Try `/config-audit posture` for a quick check instead." and stop.
|
||||
|
||||
Posture's stderr goes to a **file**, not `/dev/null`: it carries the humanized scorecard headline that step 6 renders, and that headline exists nowhere in the JSON payload. Writing it to a file keeps UX rule 2 intact (the user still never sees raw scanner output) while leaving the text readable.
|
||||
Check the echoed exit code:
|
||||
- `0`, `1`, or `2` → continue normally
|
||||
- `3` → tell user: "Scanner encountered an unexpected error. Try `/config-audit posture` for a quick check instead." and stop.
|
||||
|
||||
### Step 4: Analyze results
|
||||
|
||||
Tell the user: **"Scanners complete. Preparing your results..."**
|
||||
|
||||
Read all three output files using the Read tool:
|
||||
Read BOTH output files using the Read tool:
|
||||
- `~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json`
|
||||
- `~/.claude/config-audit/sessions/{session-id}/posture.json`
|
||||
- `~/.claude/config-audit/sessions/{session-id}/posture-stderr.txt` — the humanized scorecard. Take the `Health: {grade} ({score}/100) — {prose}` headline from it; that prose is not in either JSON payload.
|
||||
|
||||
Extract these metrics from the JSON:
|
||||
|
||||
|
|
@ -158,7 +146,7 @@ Present results using this template. The humanizer has already replaced jargon-h
|
|||
|
||||
**Health: {overallGrade}** | {qualityAreaCount} areas scanned
|
||||
|
||||
{Use the `Health: …` headline read from `posture-stderr.txt` in step 4 — it carries grade-context prose already. Avoid hardcoding a separate per-grade prose ladder. If that file is missing or empty, say the grade plainly without inventing prose for it.}
|
||||
{Use the headline line from the humanized stderr scorecard — it carries grade-context prose already. Avoid hardcoding a separate per-grade prose ladder.}
|
||||
|
||||
Scanned {files_scanned} files | {real_finding_count} findings ({severity_breakdown})
|
||||
{If test_fixture_count > 0: "({test_fixture_count} additional findings in test fixtures were excluded.)"}
|
||||
|
|
@ -172,11 +160,9 @@ Scanned {files_scanned} files | {real_finding_count} findings ({severity_breakdo
|
|||
| Settings | {grade} | {count} | {status} |
|
||||
| Hooks | {grade} | {count} | {status} |
|
||||
| Rules | {grade} | {count} | {status} |
|
||||
| MCP | {grade} | {count} | {status} |
|
||||
| MCP Servers | {grade} | {count} | {status} |
|
||||
| Imports | {grade} | {count} | {status} |
|
||||
| Conflicts | {grade} | {count} | {status} |
|
||||
| Token Efficiency | {grade} | {count} | {status} |
|
||||
| Plugin Hygiene | {grade} | {count} | {status} |
|
||||
|
||||
{For the status column, use the humanized title from the most-severe finding in that area, or a one-phrase plain-language summary. Findings carry userImpactCategory which already groups by impact bucket — use that vocabulary, not raw scanner names.}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,19 +72,14 @@ Run the scan orchestrator silently to discover and scan files. Default mode emit
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
# Set to the flag itself for full/home scope, otherwise leave empty. Never pass
|
||||
# a placeholder wrapped in square brackets: it does not start with a dash, so
|
||||
# the orchestrator's arg loop takes it as the SCAN TARGET and silently scans a
|
||||
# path that does not exist.
|
||||
SCOPE_FLAGS="" # e.g. SCOPE_FLAGS="--full-machine" or SCOPE_FLAGS="--global"
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json $SCOPE_FLAGS $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json [--full-machine] [--global] $RAW_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Check exit code: 0/1/2 → normal. 3 → "Discovery encountered an error. Try a narrower scope."
|
||||
|
||||
### Step 6: Save scope and state
|
||||
|
||||
Write `scope.yaml` and `state.yaml` to session directory. Update state with all four fields `.claude/rules/state-management.md` requires: `current_phase: "discover"`, `completed_phases: [discover]`, `next_phase: "analyze"`, and `updated_at`. The last two are what make an interrupted run resumable.
|
||||
Write `scope.yaml` and `state.yaml` to session directory. Update state with `current_phase: "discover"`, `next_phase: "analyze"`.
|
||||
|
||||
### Step 7: Present summary
|
||||
|
||||
|
|
|
|||
|
|
@ -29,10 +29,10 @@ Tell the user: **"Saving current configuration as baseline..."**
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs "<path>" --save --name "<baseline-name>" --json $RAW_FLAG 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <path> --save --name <baseline-name> $RAW_FLAG 2>/dev/null
|
||||
```
|
||||
|
||||
`--save` writes its human confirmation to **stderr**, which `2>/dev/null` discards — pass `--json` so the `{saved, name, path}` object lands on stdout. Read stdout for confirmation. Tell the user:
|
||||
Read stdout for confirmation. Tell the user:
|
||||
|
||||
```markdown
|
||||
### Baseline Saved
|
||||
|
|
@ -50,16 +50,10 @@ Tell the user: **"Comparing current configuration against baseline..."**
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs "<path>" --baseline "<name>" --output-file /tmp/config-audit-drift.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <path> --baseline <name> $RAW_FLAG 2>/dev/null
|
||||
```
|
||||
|
||||
Exit codes: `0` = stable/improving, `1` = degrading (both normal — present the result either way), `3` = a real error.
|
||||
|
||||
Then read `/tmp/config-audit-drift.json` with the **Read tool**. The default-mode report itself goes to stderr, so `--output-file` is the only way this command sees the diff at all.
|
||||
|
||||
**Check `_baselineAnchor` first.** If the baseline was saved from a different directory than the one being scanned, the diff is not a drift signal — every baseline finding shows as "resolved" and every current finding as "new", which renders as a falsely reassuring "improving" trend. When the anchor differs, say so plainly and offer to re-anchor with `/config-audit drift --save` instead of presenting the numbers as drift.
|
||||
|
||||
In default mode the diff sections are humanized — finding titles, descriptions, and recommendations have already been replaced with plain-language equivalents. New/resolved/changed finding lists carry `userImpactCategory`, `userActionLanguage`, and `relevanceContext` so you can group and prioritize without re-deriving severity prose. If `--raw` was passed, the v5.0.0 diff is verbatim — present it in a code block as-is.
|
||||
Read stdout. In default mode the diff sections are humanized — finding titles, descriptions, and recommendations have already been replaced with plain-language equivalents. New/resolved/changed finding lists carry `userImpactCategory`, `userActionLanguage`, and `relevanceContext` so you can group and prioritize without re-deriving severity prose. If `--raw` was passed, the v5.0.0 diff is verbatim — present it in a code block as-is.
|
||||
|
||||
If baseline not found, tell the user:
|
||||
|
||||
|
|
@ -102,14 +96,9 @@ When iterating new/resolved findings, prefer `userActionLanguage` over raw `seve
|
|||
If `$ARGUMENTS` contains `--list`:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs --list --output-file /tmp/config-audit-baselines.json 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs --list 2>/dev/null
|
||||
```
|
||||
|
||||
The human-readable listing goes to stderr, which `2>/dev/null` discards — read
|
||||
`/tmp/config-audit-baselines.json` with the Read tool and render the `baselines`
|
||||
array (`name`, `findingCount`, `savedAt`) as a table. If the array is empty, tell
|
||||
the user no baselines are saved yet and point at `/config-audit drift --save`.
|
||||
|
||||
### What's next
|
||||
|
||||
After viewing drift:
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ Generate session ID (`YYYYMMDD_HHmmss`) if no active session exists.
|
|||
mkdir -p ~/.claude/config-audit/sessions/{session-id}/findings 2>/dev/null
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json $RAW_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
If exit code is non-zero: "Assessment couldn't run. Check that the path exists and contains configuration files."
|
||||
|
|
@ -55,8 +55,8 @@ Extract GAP findings from `scannerEnvelope.scanners` (find scanner with `scanner
|
|||
|
||||
Detect project context:
|
||||
```bash
|
||||
test -f "<target-path>"/package.json && echo "has_package_json" || echo "no_package_json"
|
||||
ls "<target-path>"/*.py "<target-path>"/requirements.txt "<target-path>"/pyproject.toml 2>/dev/null | head -3
|
||||
test -f <target-path>/package.json && echo "has_package_json" || echo "no_package_json"
|
||||
ls <target-path>/*.py <target-path>/requirements.txt <target-path>/pyproject.toml 2>/dev/null | head -3
|
||||
```
|
||||
|
||||
### Step 4: Build numbered recommendations
|
||||
|
|
@ -128,23 +128,15 @@ If the user picks numbers: parse the selection and proceed to Step 6.
|
|||
|
||||
For each selected recommendation:
|
||||
|
||||
1. **Create backup** of any files that will be modified.
|
||||
|
||||
Do **not** reach for `fix-cli.mjs` here. It is dry-run by default, so calling
|
||||
it without `--apply` creates no backup at all and returns `backupId: null` —
|
||||
and calling it *with* `--apply` would execute unrelated auto-fixes that the
|
||||
user never selected. Copy the files yourself:
|
||||
|
||||
1. **Create backup** of any files that will be modified:
|
||||
```bash
|
||||
BACKUP_DIR=~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files
|
||||
mkdir -p "$BACKUP_DIR" 2>/dev/null
|
||||
# repeat per file that will be touched:
|
||||
cp "<file-to-modify>" "$BACKUP_DIR/" 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <target-path> --json 2>/dev/null
|
||||
```
|
||||
|
||||
Tell the user where the copies landed. These are plain file copies — they are
|
||||
restored by copying them back, **not** by `/config-audit rollback`, which only
|
||||
knows about backups written by `fix` and `implement`.
|
||||
Or create manual backup:
|
||||
```bash
|
||||
mkdir -p ~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files/ 2>/dev/null
|
||||
```
|
||||
Copy each file that will be touched.
|
||||
|
||||
2. **Apply the template** from gap-closure-templates.md. Use the Write or Edit tool to create or modify the relevant configuration file.
|
||||
|
||||
|
|
@ -159,12 +151,9 @@ Implementing 3 recommendations...
|
|||
|
||||
4. **Verify** by re-running posture:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --json --output-file /tmp/config-audit-verify.json >/dev/null 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --json --output-file /tmp/config-audit-verify-$$.json 2>/dev/null
|
||||
```
|
||||
|
||||
Use the Read tool on `/tmp/config-audit-verify.json` for the new `overallGrade`
|
||||
and score — stdout is discarded on purpose (the same envelope is 255 KB).
|
||||
|
||||
### Step 7: Show results
|
||||
|
||||
```markdown
|
||||
|
|
|
|||
|
|
@ -15,13 +15,8 @@ Auto-fix deterministic configuration issues. Scans, plans fixes, backs up origin
|
|||
- `$ARGUMENTS` may contain:
|
||||
- A target path (default: current working directory)
|
||||
- `--dry-run`: Show fix plan without applying
|
||||
- `--global`: Include user-scope config (`~/.claude`) in the scan **and** the fix run
|
||||
- `--raw`: Pass-through to scanners; produces v5.0.0 verbatim envelope (bypasses the humanizer) for byte-stable diff tooling
|
||||
|
||||
`--global` must be passed to **every** step below. The scan that builds the table and
|
||||
the scan that plans the fixes are two different runs; if only one of them sees the
|
||||
user scope, the plan and the table describe different config.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Greet and scan
|
||||
|
|
@ -39,11 +34,7 @@ Parse flags and run scanners silently. Default mode emits humanized JSON — eac
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
# Set to --global when the user asked for global scope, otherwise leave empty.
|
||||
# A placeholder in square brackets does not start with a dash, so the arg loop
|
||||
# would take it as the scan/fix TARGET instead of a flag.
|
||||
GLOBAL_FLAG=""
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs "<path>" --output-file /tmp/config-audit-fix-scan.json $GLOBAL_FLAG $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <path> --output-file /tmp/config-audit-fix-scan-$$.json [--global] $RAW_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit code 3 → tell user: "Scanner error. Try `/config-audit posture` to check your configuration."
|
||||
|
|
@ -53,31 +44,13 @@ Exit code 3 → tell user: "Scanner error. Try `/config-audit posture` to check
|
|||
Run fix planner silently. The fix-cli emits humanized prose to stderr in default mode and v5.0.0-shape JSON to stdout when `--json` is set; we use `--json` here for structured data and let the humanizer-aware rendering layer (this command's prose output below) supply the plain-language wording from the scan envelope above:
|
||||
|
||||
```bash
|
||||
# Re-assign here: each fenced block is its own Bash call, so the value
|
||||
# set in Step 1 is empty by the time this block runs.
|
||||
GLOBAL_FLAG="" # --global when the user asked for global scope
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs "<path>" $GLOBAL_FLAG --output-file /tmp/config-audit-fix-plan.json 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> --json 2>/dev/null
|
||||
```
|
||||
|
||||
Exit codes: 0 = plan produced, 2 = one or more fixes failed (apply step only), 3 = argument or tool error. On 3, show the stderr message — an unknown flag is rejected by design, not silently ignored.
|
||||
|
||||
Read `/tmp/config-audit-fix-plan.json` using the Read tool. Cross-reference each fix-plan entry against the humanized scan envelope (`/tmp/config-audit-fix-scan.json`) by finding ID to recover the humanized `title`/`description`/`recommendation` plus `userImpactCategory`/`userActionLanguage` for grouping.
|
||||
Read the JSON output using the Read tool. Cross-reference each fix-plan entry against the humanized scan envelope (`/tmp/config-audit-fix-scan-$$.json`) by finding ID to recover the humanized `title`/`description`/`recommendation` plus `userImpactCategory`/`userActionLanguage` for grouping.
|
||||
|
||||
### Step 3: Present fix plan
|
||||
|
||||
First classify where the auto-fixable entries write. With `--global` the run
|
||||
takes `~/.claude` into the *fix* pass, so machine-wide and project rows land in
|
||||
one table; without a marker they read as equally local. Pass one `--target` per
|
||||
distinct file in the auto-fixable set:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<file-1>" --target "<file-2>" --repo "$PWD" --output-file /tmp/config-audit-fix-scope.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit 0 = classified; 3 = argument error (show the stderr message). Read
|
||||
`/tmp/config-audit-fix-scope.json` and carry each file's `scopeClass` into the
|
||||
table below.
|
||||
|
||||
Show what will be fixed and what needs manual attention. Group by `userActionLanguage` so the urgency phrasing stays consistent with the rest of the toolchain:
|
||||
|
||||
```markdown
|
||||
|
|
@ -89,9 +62,9 @@ Show what will be fixed and what needs manual attention. Group by `userActionLan
|
|||
|
||||
#### {userActionLanguage}
|
||||
|
||||
| # | ID | Issue | File | Scope |
|
||||
|---|-----|-------|------|-------|
|
||||
| 1 | {id} | {humanized title} | {file} | {scopeClass, or blank when "in-repo"} |
|
||||
| # | ID | Issue | File |
|
||||
|---|-----|-------|------|
|
||||
| 1 | {id} | {humanized title} | {file} |
|
||||
|
||||
**Manual ({M} issues — require human judgment), grouped by impact:**
|
||||
|
||||
|
|
@ -104,10 +77,7 @@ Show what will be fixed and what needs manual attention. Group by `userActionLan
|
|||
|
||||
### Step 4: Confirm with user
|
||||
|
||||
If not `--dry-run`, ask for confirmation. Render each distinct string in the scope
|
||||
payload's `disclosures[]` verbatim first.
|
||||
|
||||
When `requiresApproval` is false:
|
||||
If not `--dry-run`, ask for confirmation:
|
||||
|
||||
```
|
||||
AskUserQuestion:
|
||||
|
|
@ -118,45 +88,24 @@ AskUserQuestion:
|
|||
- "Cancel"
|
||||
```
|
||||
|
||||
When `requiresApproval` is true — which is what `--global` produces, since
|
||||
`~/.claude` is machine-wide — the question MUST say so and the safe option MUST
|
||||
come first:
|
||||
|
||||
```
|
||||
AskUserQuestion:
|
||||
question: "{K} of {N} fixes change configuration outside this project. Apply all {N}?"
|
||||
options:
|
||||
- "Show dry-run only"
|
||||
- "Yes — apply all, including outside this project"
|
||||
- "Cancel"
|
||||
```
|
||||
|
||||
### Step 5: Apply fixes
|
||||
|
||||
If confirmed, apply:
|
||||
|
||||
```bash
|
||||
# Re-assign here: each fenced block is its own Bash call, so the value
|
||||
# set in Step 1 is empty by the time this block runs.
|
||||
GLOBAL_FLAG="" # --global when the user asked for global scope
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs "<path>" --apply $GLOBAL_FLAG --output-file /tmp/config-audit-fix-applied.json 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> --apply --json 2>/dev/null
|
||||
```
|
||||
|
||||
Read `/tmp/config-audit-fix-applied.json` with the Read tool to get applied/failed counts and the backup ID. Exit code 2 means at least one fix failed — report it; `failed[]` carries the reason per fix.
|
||||
Read the JSON output to get applied/failed counts and backup location.
|
||||
|
||||
### Step 6: Show results
|
||||
|
||||
Run a quick posture check to measure improvement:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<path>" --json --output-file /tmp/config-audit-fix-posture.json >/dev/null 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <path> --json --output-file /tmp/config-audit-fix-posture-$$.json 2>/dev/null
|
||||
```
|
||||
|
||||
Use the Read tool on `/tmp/config-audit-fix-posture.json` and take `overallGrade`
|
||||
and the score from there. That read is the only source for the numbers below:
|
||||
`--json` prints the same envelope to stdout, but 255 KB of raw JSON in the
|
||||
transcript to recover one grade is exactly the waste this plugin exists to find.
|
||||
|
||||
Present results:
|
||||
|
||||
```markdown
|
||||
|
|
@ -190,8 +139,7 @@ Run `/config-audit plan` to get a step-by-step guide for addressing these.
|
|||
|
||||
## Safety
|
||||
|
||||
- Backup is **mandatory** — every fix creates a backup first, including file renames (the source file is backed up before the rename, so rollback can restore it at its original path)
|
||||
- Backup is **mandatory** — every fix creates a backup first
|
||||
- Dry-run by default — user must confirm before changes
|
||||
- Verify after fix — re-scans in the **same scope** the fix run used, so a `--global` run is verified against user scope too
|
||||
- Verify after fix — re-scans to confirm findings resolved
|
||||
- Rollback always available — `/config-audit rollback <backup-id>`
|
||||
- A failed fix is reported, never swallowed — exit 2 plus a `failed[]` entry
|
||||
|
|
|
|||
|
|
@ -22,47 +22,24 @@ Execute the action plan with full backup, verification, and rollback support.
|
|||
|
||||
### Step 1: Parse flags, load and verify
|
||||
|
||||
Check whether `$ARGUMENTS` contains `--raw`. Carry the answer yourself: the agent
|
||||
prompt in Step 4 is **not** a shell, so a variable assigned in a bash block cannot
|
||||
be referenced from it. Substitute `{mode}` literally with `--raw` or `humanized`.
|
||||
|
||||
Find the most recent session with a plan (use the **Glob tool** for
|
||||
`~/.claude/config-audit/sessions/*/state.yaml`, then Read the newest match — Read
|
||||
does not expand `*`). If none: "No action plan found. Run `/config-audit plan` first."
|
||||
|
||||
Use the Read tool on the action plan and count actions.
|
||||
|
||||
Now classify where those actions actually write. A plan whose actions target
|
||||
`~/.claude/CLAUDE.md` and a plan whose actions target `./CLAUDE.md` are the same
|
||||
count of actions — presenting only the count made a machine-wide change look
|
||||
identical to a project-local one. Pass one `--target` per distinct file the plan
|
||||
touches (absolute paths, as written in the plan):
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<file-1>" --target "<file-2>" --repo "$PWD" --output-file /tmp/config-audit-implement-scope.json 2>/dev/null; echo $?
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
```
|
||||
|
||||
Exit 0 = classified; 3 = argument error (show the stderr message). Read
|
||||
`/tmp/config-audit-implement-scope.json`. Tell the user:
|
||||
Find the most recent session with a plan. If none: "No action plan found. Run `/config-audit plan` first."
|
||||
|
||||
Use the Read tool on the action plan and count actions. Tell the user:
|
||||
|
||||
```
|
||||
## Implementing Action Plan
|
||||
|
||||
Found {N} actions to execute across {M} files.
|
||||
A backup will be created before any changes are made.
|
||||
|
||||
{For each target whose `gate` is not "silent", one line:}
|
||||
- `{target}` — {scopeClass}
|
||||
```
|
||||
|
||||
### Step 2: Get user approval
|
||||
|
||||
Render each distinct string in `disclosures[]` verbatim before asking — they are
|
||||
already plain-language, and the payload carries them so this template never has
|
||||
to restate what a scope class means.
|
||||
|
||||
When `requiresApproval` is false, ask as before:
|
||||
|
||||
```
|
||||
AskUserQuestion:
|
||||
question: "Ready to implement {N} actions? Backup created automatically — you can roll back with one command."
|
||||
|
|
@ -72,53 +49,16 @@ AskUserQuestion:
|
|||
- "Cancel"
|
||||
```
|
||||
|
||||
When `requiresApproval` is true, the question MUST name the scope, and the
|
||||
safe option MUST come first — a plan that edits machine-wide configuration
|
||||
affects every project the user opens, so the default must not be "proceed":
|
||||
|
||||
```
|
||||
AskUserQuestion:
|
||||
question: "This plan changes configuration outside this project ({K} of {M} files). Proceed?"
|
||||
options:
|
||||
- "Review plan first" (then show the plan file path)
|
||||
- "Yes — change files outside this project too"
|
||||
- "Cancel"
|
||||
```
|
||||
|
||||
### Step 3: Create backup
|
||||
|
||||
Create backup silently, and **print the backup ID** — Step 6 has to tell the user
|
||||
how to roll back, and a timestamp that only ever existed inside a command
|
||||
substitution cannot be quoted later. Shell state does not survive to the next
|
||||
block, so capture the printed value and substitute it literally from here on:
|
||||
Create backup silently:
|
||||
|
||||
```bash
|
||||
BACKUP_ID=$(date +%Y%m%d_%H%M%S)
|
||||
mkdir -p ~/.claude/config-audit/backups/"$BACKUP_ID"/files/ 2>/dev/null
|
||||
echo "$BACKUP_ID"
|
||||
mkdir -p ~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files/ 2>/dev/null
|
||||
```
|
||||
|
||||
Use the printed ID wherever `{backup-id}` appears below. Never invent or re-derive
|
||||
it with a second `date` call — a run that straddles a second boundary would hand
|
||||
the user a rollback ID that does not exist.
|
||||
|
||||
Copy each file to be modified. Generate `manifest.yaml` with checksums.
|
||||
|
||||
The manifest is what `/config-audit rollback` reads, so it MUST carry both lists:
|
||||
|
||||
```yaml
|
||||
files: # pre-existing files this run will MODIFY
|
||||
- backup: files/root/CLAUDE.md
|
||||
original: /abs/path/CLAUDE.md
|
||||
sha256: <sha256 of the pre-change content>
|
||||
created: # files this run will CREATE (no backup can exist)
|
||||
- /abs/path/.claude/rules/post-quality.md
|
||||
```
|
||||
|
||||
Record every `create`-type action under `created:`. Rollback cannot restore a
|
||||
file that never existed, but it must be able to tell the user which files it is
|
||||
leaving behind — a half-restored target is only dangerous when it is silent.
|
||||
|
||||
Tell the user: **"Backup created. Implementing actions..."**
|
||||
|
||||
### Step 4: Execute actions
|
||||
|
|
@ -131,15 +71,13 @@ Agent(subagent_type: "config-audit:implementer-agent")
|
|||
prompt: |
|
||||
Execute action: {action-id}
|
||||
File: {file-path}, Type: {create|modify|delete}
|
||||
Mode: {mode} ("humanized" = humanized progress prose; "--raw" = v5.0.0 verbatim)
|
||||
Mode: $RAW_FLAG (empty = humanized progress prose; "--raw" = v5.0.0 verbatim)
|
||||
Details: {changes}
|
||||
Verify backup exists, make change, validate syntax.
|
||||
When logging progress, use the humanized title/userActionLanguage
|
||||
fields from the action plan (the planner already rendered them) —
|
||||
do not re-derive severity prose. Append result to:
|
||||
~/.claude/config-audit/sessions/{session-id}/implementation-log.md
|
||||
Append with Bash `>>` (heredoc) — NEVER the Write tool on this log;
|
||||
parallel agents share it and a full-file Write clobbers their entries.
|
||||
```
|
||||
|
||||
Show progress between groups using the humanized titles already present in the action plan:
|
||||
|
|
@ -162,19 +100,7 @@ Agent(subagent_type: "config-audit:verifier-agent")
|
|||
1. Modified files exist and are syntactically valid
|
||||
2. New files created correctly
|
||||
3. No new conflicts introduced
|
||||
Return your findings as your final message. Do NOT write them to a file —
|
||||
this agent is read-only by design (tools: Read, Glob, Grep) and has no
|
||||
write tool; instructing it to write a report is a contract it cannot keep.
|
||||
```
|
||||
|
||||
Append the verifier's returned findings to the log yourself, with Bash `>>`
|
||||
(heredoc) — never the Write tool, for the same reason as Step 4:
|
||||
|
||||
```bash
|
||||
cat >> ~/.claude/config-audit/sessions/{session-id}/implementation-log.md <<'EOF'
|
||||
## Verification
|
||||
{verifier findings}
|
||||
EOF
|
||||
Report to: ~/.claude/config-audit/sessions/{session-id}/implementation-log.md
|
||||
```
|
||||
|
||||
If verifier finds issues: one retry with implementer agent. If still failing: report and suggest rollback.
|
||||
|
|
@ -186,49 +112,27 @@ If verifier finds issues: one retry with implementer agent. If still failing: re
|
|||
|
||||
**{succeeded} succeeded** | {failed} failed | {skipped} skipped
|
||||
|
||||
{If score improved, run quick posture and show:}
|
||||
Score impact: {old_grade} → {new_grade} (+{delta} points)
|
||||
|
||||
{If failed > 0:}
|
||||
{failed} action(s) couldn't be completed — see log for details.
|
||||
|
||||
**Backup location:** `~/.claude/config-audit/backups/{backup-id}/`
|
||||
**Rollback:** `/config-audit rollback {backup-id}`
|
||||
**Backup location:** `~/.claude/config-audit/backups/{timestamp}/`
|
||||
**Rollback:** `/config-audit rollback {timestamp}`
|
||||
**Full log:** `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
|
||||
```
|
||||
|
||||
**On reporting a score.** Only quote a grade *change* if the pre-change grade was
|
||||
actually captured before Step 4 ran. Once the files are edited, only the new grade
|
||||
is measurable — a delta computed after the fact has no source and must not be
|
||||
invented. To offer one, measure first in Step 1 and again here:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --output-file /tmp/config-audit-implement-posture.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Then Read `/tmp/config-audit-implement-posture.json`. Both the `--output-file` and
|
||||
the `2>/dev/null` are required by the output rules — a bare scanner call would put
|
||||
diagnostic output in front of the user. If no pre-change grade was captured, report
|
||||
the new grade alone and say nothing about a delta.
|
||||
|
||||
### Step 7: Update state
|
||||
|
||||
Update `state.yaml` with all four fields `.claude/rules/state-management.md` requires:
|
||||
|
||||
- `current_phase: "implement"`
|
||||
- `completed_phases`: append `implement` to the existing array (read it first; never replace it)
|
||||
- `next_phase: null`
|
||||
- `updated_at`: current timestamp
|
||||
|
||||
A full-file Write that names only two of the four silently deletes the other two.
|
||||
Update `state.yaml` with `current_phase: "implement"`, `next_phase: null`.
|
||||
|
||||
## Rollback
|
||||
|
||||
If the user requests rollback at any point:
|
||||
1. Read `manifest.yaml` from backup
|
||||
2. Restore each file and verify checksums
|
||||
3. **Report — do not delete — the files this run created.** Rollback restores from
|
||||
backup, and no backup can exist for a file that did not exist before. Those
|
||||
paths stay on disk; `/config-audit rollback` lists them under "Left in place"
|
||||
so the user can remove them deliberately. Promising deletion here would leave a
|
||||
half-restored config that reads as a clean rollback.
|
||||
3. Delete newly created files
|
||||
4. Update state to `rolled_back`
|
||||
|
||||
## Error Handling
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ Gather user preferences to inform the action plan.
|
|||
|
||||
## IMPORTANT: Inline Execution Only
|
||||
|
||||
This command runs AskUserQuestion **directly in the main context** — NOT via an `Agent` subagent.
|
||||
AskUserQuestion requires synchronous terminal interaction and does not work when delegated to an `Agent` subagent.
|
||||
This command runs AskUserQuestion **directly in the main context** — NOT via a Task subagent.
|
||||
AskUserQuestion requires synchronous terminal interaction and does not work when delegated to a Task subagent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
@ -32,29 +32,10 @@ AskUserQuestion requires synchronous terminal interaction and does not work when
|
|||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
```
|
||||
|
||||
1. **Resolve the session, then load its state**:
|
||||
|
||||
```
|
||||
Glob: ~/.claude/config-audit/sessions/*/state.yaml
|
||||
Sort by modification time — the most recently modified session wins
|
||||
```
|
||||
|
||||
Every path below substitutes that session's id for `{session-id}`. Never guess
|
||||
it: if the Glob returns nothing, say "No audit session found — run
|
||||
`/config-audit discover` first" and exit. Read the session's `state.yaml` and
|
||||
verify `completed_phases` contains `analyze`; if it doesn't, tell the user
|
||||
analysis hasn't run yet and exit. Then read the analysis report for context.
|
||||
2. **Conduct interview inline**: Use AskUserQuestion tool directly (never delegate it to a subagent via `Agent` — a subagent cannot hold the interactive turn). Adapt questions based on analysis findings.
|
||||
1. **Load session state**: Verify analysis phase completed, read analysis report for context
|
||||
2. **Conduct interview inline**: Use AskUserQuestion tool directly (NOT via Task). Adapt questions based on analysis findings.
|
||||
3. **Save interview results**: Write to `~/.claude/config-audit/sessions/{session-id}/interview.md`
|
||||
4. **Update state** (see state-management rule), with one bound specific to this
|
||||
command: interview is optional and can be run against a session that already
|
||||
moved past it. If `completed_phases` already contains a later phase (`plan`,
|
||||
`implement`, `verify`), do **not** rewind `current_phase` and do not re-add a
|
||||
phase already in `completed_phases` — append `interview` only if it is absent,
|
||||
leave `current_phase`/`next_phase` pointing at the furthest phase reached, and
|
||||
tell the user the preferences will apply the next time `/config-audit plan`
|
||||
runs. Rewinding a finished session is how its progress gets lost. Always set
|
||||
`updated_at` to the current timestamp, whichever branch above applies.
|
||||
4. **Update state** (see state-management rule)
|
||||
5. **Output summary**
|
||||
|
||||
## Interview Questions
|
||||
|
|
|
|||
|
|
@ -44,21 +44,14 @@ re-verification) and polling for new Claude Code practices...
|
|||
### Step 2: Run the stale-check CLI
|
||||
|
||||
```bash
|
||||
# Pass the threshold as its OWN quoted argument. Building "--stale-after 30" into
|
||||
# one variable and expanding it unquoted only works if the shell word-splits —
|
||||
# bash does, zsh (the macOS default) does not, and there the flag silently
|
||||
# reverted to the 90-day default while the command reported success.
|
||||
TODAY=$(date +%F)
|
||||
STALE_AFTER_DAYS=$(echo "$ARGUMENTS" | sed -nE 's/.*--stale-after[ =]+([0-9]+).*/\1/p')
|
||||
if [ -n "$STALE_AFTER_DAYS" ]; then
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/knowledge-refresh-cli.mjs \
|
||||
--reference-date "$TODAY" --stale-after "$STALE_AFTER_DAYS" \
|
||||
--output-file ~/.claude/config-audit/sessions/knowledge-refresh.json 2>/dev/null; echo $?
|
||||
else
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/knowledge-refresh-cli.mjs \
|
||||
--reference-date "$TODAY" \
|
||||
--output-file ~/.claude/config-audit/sessions/knowledge-refresh.json 2>/dev/null; echo $?
|
||||
STALE_AFTER=""
|
||||
if echo "$ARGUMENTS" | grep -qE -- '--stale-after'; then
|
||||
STALE_AFTER="--stale-after $(echo "$ARGUMENTS" | sed -nE 's/.*--stale-after[ =]+([0-9]+).*/\1/p')"
|
||||
fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/knowledge-refresh-cli.mjs \
|
||||
--reference-date "$TODAY" $STALE_AFTER \
|
||||
--output-file ~/.claude/config-audit/sessions/knowledge-refresh.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit code **0** = all fresh, **1** = some stale (advisory, normal), **3** = real error →
|
||||
|
|
@ -107,27 +100,16 @@ Be explicit: **"I will not change any file until you approve specific items."**
|
|||
|
||||
### Step 6: Apply approved writes (only the approved ones)
|
||||
|
||||
**Where the register lives — say this before writing.** The register is part of the plugin,
|
||||
and the stale check above read it from `${CLAUDE_PLUGIN_ROOT}/knowledge/best-practices.json`
|
||||
(the `registerPath` field in the payload names the exact file). For a marketplace install that
|
||||
is the plugin cache, so **an edit there is discarded by the next plugin upgrade** — the durable
|
||||
home for an approved change is the plugin's own checkout. Tell the user which of the two they
|
||||
are about to write to, using the `registerPath` they can see, before asking for approval.
|
||||
|
||||
For each approved item:
|
||||
1. Edit `${CLAUDE_PLUGIN_ROOT}/knowledge/best-practices.json` — bump `source.verified`, update
|
||||
the `claim`/`recommendation`, or append the new entry. Keep the file's 2-space JSON
|
||||
formatting. Use the anchored path, never a bare `knowledge/…` — a relative path resolves
|
||||
against the user's current repo, which is not the file the CLI read.
|
||||
2. If a `${CLAUDE_PLUGIN_ROOT}/knowledge/*.md` mirror states the same fact, update it too so
|
||||
the human-readable mirror doesn't drift from the register.
|
||||
1. Edit `knowledge/best-practices.json` — bump `source.verified`, update the `claim`/
|
||||
`recommendation`, or append the new entry. Keep the file's 2-space JSON formatting.
|
||||
2. If a `knowledge/*.md` mirror states the same fact, update it too so the human-readable
|
||||
mirror doesn't drift from the register.
|
||||
3. **Validate before declaring done** — re-run the register schema check and confirm zero errors:
|
||||
```bash
|
||||
node --test ${CLAUDE_PLUGIN_ROOT}/tests/lib/best-practices-register.test.mjs 2>&1 | tail -5
|
||||
```
|
||||
This test loads the register through the same anchored path, so it validates the file you
|
||||
just edited — that only holds while step 1 uses the anchored path too. If validation fails,
|
||||
revert that edit and report it — never leave the register invalid.
|
||||
If validation fails, revert that edit and report it — never leave the register invalid.
|
||||
|
||||
Report exactly what changed (ids + fields), and what was deferred to manual review.
|
||||
|
||||
|
|
|
|||
|
|
@ -39,9 +39,10 @@ First non-flag argument is the path (default `.`). Recognized flags:
|
|||
Tell the user: **"Building token-source manifest for `<path>`..."**
|
||||
|
||||
```bash
|
||||
TMPFILE="/tmp/ca-manifest-$$.json"
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs "<path>" --output-file /tmp/config-audit-manifest.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs <path> --output-file "$TMPFILE" $RAW_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
**Exit code handling:**
|
||||
|
|
@ -51,14 +52,14 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs "<path>" --output-file /tmp/con
|
|||
### Step 3: If `--json` was requested, cat the file and stop
|
||||
|
||||
```bash
|
||||
cat /tmp/config-audit-manifest.json
|
||||
cat "$TMPFILE"
|
||||
```
|
||||
|
||||
Do NOT render the table in JSON mode.
|
||||
|
||||
### Step 4: Read JSON and render
|
||||
|
||||
Use the Read tool on `/tmp/config-audit-manifest.json`. Extract `meta.repoPath`, `total`, `summary`, and `sources[]`. Lead with the **always-loaded subtotal** (the headline), then render the top 20 sources (or fewer if the manifest is shorter):
|
||||
Use the Read tool on `$TMPFILE`. Extract `meta.repoPath`, `total`, `summary`, and `sources[]`. Lead with the **always-loaded subtotal** (the headline), then render the top 20 sources (or fewer if the manifest is shorter):
|
||||
|
||||
```markdown
|
||||
**Token-source manifest for `<repoPath>`** — ~{total} tokens total
|
||||
|
|
@ -69,11 +70,10 @@ Use the Read tool on `/tmp/config-audit-manifest.json`. Extract `meta.repoPath`,
|
|||
|
||||
| Rank | Kind | Name | Source | Tokens | Load |
|
||||
|------|------|------|--------|--------|------|
|
||||
| 1 | {kind} | `<name>` | {source} | ~{estimated_tokens} | {loadPattern} |
|
||||
| 1 | {kind} | `<name>` | {source} | ~{estimated_tokens} | {load} |
|
||||
| ... | ... | ... | ... | ... | ... |
|
||||
|
||||
_Load column: **always** / **on-demand** / **external**. Append `°` when `derivationConfidence` is `inferred` (no primary-doc row pins it exactly)._
|
||||
_Agent rows carry `model` and `effort`. When either is set, append it to the name — `` `reviewer` (haiku/low) `` — using `inherit` / `default` for the unset side. Leave the suffix off entirely when both are null; a row of "inherit/default" on every agent is noise, and `/config-audit feature-gap` is where that becomes a finding._
|
||||
_Estimates assume ~4 chars/token (Claude ballpark). Real token count varies ±15%._
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ is hybrid: a cheap deterministic pre-filter finds candidates, then the opus
|
|||
- **Lifecycle phrasing → hooks** (BP-MECH-001)
|
||||
- **Unscoped path-specific instructions → path-scoped rules** (BP-MECH-002)
|
||||
- **Absolute "never" prohibitions → permissions / hooks** (BP-MECH-004)
|
||||
- **`--subtract`:** instructions that no longer earn their always-loaded rent (BP-SUB-001)
|
||||
|
||||
Each finding cites its register rule + source URL. A clean CLAUDE.md returns "no
|
||||
opportunities" — that is a good result, not a failure.
|
||||
|
|
@ -35,30 +34,7 @@ opportunities" — that is a good result, not a failure.
|
|||
|
||||
Split `$ARGUMENTS` into a path (first non-flag argument; default: current working
|
||||
directory) and flags. Recognized flags: `--global` (include the user `~/.claude`
|
||||
cascade in discovery), `--subtract` (add the subtraction axis, below) and
|
||||
`--apply` (execute approved removals — Step 7).
|
||||
|
||||
`--apply` only means anything alongside `--subtract`. If it is present without
|
||||
it, say so and continue with the ordinary lens run:
|
||||
|
||||
```
|
||||
`--apply` executes approved subtraction removals, so it needs `--subtract` too.
|
||||
Running the ordinary lens; re-run with `--subtract --apply` to remove anything.
|
||||
```
|
||||
|
||||
**`--subtract` — the inverse question.** Every other lens asks what to *add* or
|
||||
*move*; this one asks what no longer earns its always-loaded rent. It is opt-in
|
||||
because it asks something different, and because deleting is not undoable by
|
||||
reading. Pair it with `--global` to reach the user-level CLAUDE.md, where the
|
||||
always-loaded cost actually sits (it loads in every repo, every session).
|
||||
|
||||
If `--subtract` is present, say so up front:
|
||||
|
||||
```
|
||||
Also running the subtraction axis — instructions that cost tokens every turn
|
||||
without telling me anything I couldn't work out. Load-bearing local facts
|
||||
(remotes, versions, paths, policy) are excluded before anything is judged.
|
||||
```
|
||||
cascade in discovery).
|
||||
|
||||
Tell the user:
|
||||
|
||||
|
|
@ -76,9 +52,7 @@ Generate a session ID (`YYYYMMDD_HHmmss`) if no active session exists.
|
|||
mkdir -p ~/.claude/config-audit/sessions/{session-id} 2>/dev/null
|
||||
GLOBAL_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--global"; then GLOBAL_FLAG="--global"; fi
|
||||
SUBTRACT_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--subtract"; then SUBTRACT_FLAG="--subtract"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/optimize-lens-cli.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/optimize-lens.json $GLOBAL_FLAG $SUBTRACT_FLAG 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/optimize-lens-cli.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/optimize-lens.json $GLOBAL_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit code 0 is normal. Only exit code 3 is a real error → "The lens couldn't run.
|
||||
|
|
@ -90,13 +64,8 @@ Read `~/.claude/config-audit/sessions/{session-id}/optimize-lens.json` with the
|
|||
Read tool. It has `deterministic` (already-confirmed OPT findings), `candidates`
|
||||
(pre-filter candidates with register provenance), `register`, and `counts`.
|
||||
|
||||
Under `--subtract` it also has a `subtract` block (`candidates`, `register`) and
|
||||
`counts.subtractCandidates`. Each subtraction candidate spans `line`–`endLine`
|
||||
(a whole block). Include the whole `subtract` block when spawning the agent.
|
||||
|
||||
**Early exit:** if `counts.deterministic === 0` and `counts.candidates === 0`
|
||||
(and, under `--subtract`, `counts.subtractCandidates === 0`), skip the agent and
|
||||
tell the user plainly:
|
||||
**Early exit:** if `counts.deterministic === 0` and `counts.candidates === 0`,
|
||||
skip the agent and tell the user plainly:
|
||||
|
||||
```
|
||||
✓ No mechanism-fit opportunities found.
|
||||
|
|
@ -134,78 +103,7 @@ If the agent kept nothing from the candidates (all dropped) but there were
|
|||
deterministic findings, show those; if it kept nothing at all, show the clean
|
||||
result from Step 3.
|
||||
|
||||
### Step 7: Apply approved removals (`--subtract --apply` only)
|
||||
|
||||
Skip this step entirely unless BOTH flags are present and the agent kept at
|
||||
least one subtraction finding. Removal is the only thing this plugin does that
|
||||
takes configuration away, so nothing here happens without a named choice.
|
||||
|
||||
**7a — show what is on the table, with honest sizing.** List the kept
|
||||
subtraction findings numbered, each with its file, line span and first line of
|
||||
text. Do not imply a bigger win than there is:
|
||||
|
||||
```
|
||||
Removing all of these saves roughly {n} tokens per turn — on a typical
|
||||
always-loaded CLAUDE.md that is around a fifth of the file, not most of it.
|
||||
```
|
||||
|
||||
Ask which to remove: numbers, `all`, or `none`. `none` ends the command.
|
||||
|
||||
**7b — write the approval file.** With the **Write** tool, write the operator's
|
||||
choice to `~/.claude/config-audit/sessions/{session-id}/subtraction-approved.json`
|
||||
(absolute path — a relative one resolves against the user's CWD). Take `file`,
|
||||
`line`, `endLine` and `signalText` verbatim from the Step 3 payload; `text` must
|
||||
be the `signalText` byte-for-byte, because the engine refuses a removal whose
|
||||
text no longer matches the file:
|
||||
|
||||
```json
|
||||
{ "sessionId": "{session-id}",
|
||||
"removals": [ { "file": "...", "line": 12, "endLine": 15, "text": "..." } ] }
|
||||
```
|
||||
|
||||
**7c — dry run first.** Always. It costs one call and proves the spans still
|
||||
match before anything is written.
|
||||
|
||||
`--repo` is the **session's own root (`$PWD`), never the scanned path**. It is
|
||||
what the target is classified *against*: pass the scan target and
|
||||
`~/.claude/CLAUDE.md` classifies as `in-repo`, which drops the gate to `silent`
|
||||
on the one target that most needs it (measured — the same silent downgrade as a
|
||||
naive `.git`-upward walk, arriving through a different door).
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/subtraction-write-cli.mjs --approved ~/.claude/config-audit/sessions/{session-id}/subtraction-approved.json --repo "$PWD" --dry-run --output-file ~/.claude/config-audit/sessions/{session-id}/subtraction-dryrun.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Read the payload. Exit 3 is a real error (bad or unreadable approval file).
|
||||
Report any `refused` entry with its `reason` before going further —
|
||||
`block-mismatch` means the file changed since the scan (re-run the lens),
|
||||
`floor` means the block is load-bearing and will never be removable.
|
||||
|
||||
**7d — the scope gate.** If the dry-run payload has `requiresApproval: true`,
|
||||
show every line in `disclosures` verbatim and ask for an explicit go-ahead. This
|
||||
is the machine-wide case (`~/.claude/CLAUDE.md`): the change costs — and saves —
|
||||
in every project, on every turn, so it is not the same decision as editing the
|
||||
CLAUDE.md in front of you. Without a clear yes, stop here.
|
||||
|
||||
**7e — apply.** Same command without `--dry-run`, adding `--approve-scope` only
|
||||
if the operator gave that go-ahead in 7d:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/subtraction-write-cli.mjs --approved ~/.claude/config-audit/sessions/{session-id}/subtraction-approved.json --repo "$PWD" --output-file ~/.claude/config-audit/sessions/{session-id}/subtraction-result.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
**7f — report.** From the result payload, tell the user: what was removed (file
|
||||
+ line span + the text, from `applied`), what was refused and why (`refused`
|
||||
with `reason`), and how to undo it:
|
||||
|
||||
```
|
||||
Backed up as {backupId} — `/config-audit rollback {backupId}` restores every
|
||||
file exactly as it was.
|
||||
```
|
||||
|
||||
Never report a run as successful when `counts.applied` is 0.
|
||||
|
||||
### Step 8: Next steps
|
||||
### Step 6: Next steps
|
||||
|
||||
End with context-sensitive next steps, explaining WHY each is useful:
|
||||
|
||||
|
|
@ -219,20 +117,7 @@ End with context-sensitive next steps, explaining WHY each is useful:
|
|||
|
||||
- This command is **agent-driven and not byte-stable** — its output is a
|
||||
human-facing report, deliberately outside the deterministic snapshot suite.
|
||||
- `--subtract` **proposes; only `--apply` writes**, and only blocks the operator
|
||||
named. Every removal is preceded by a backup whose manifest is verified to
|
||||
cover the file being written, and `/config-audit rollback` restores it.
|
||||
- **Removal is not a `fix` action and not a `plan`/`implement` step**, by
|
||||
measurement rather than preference: the subtraction axis never enters the
|
||||
orchestrated envelope, so `fix`'s re-scan verification would mark every
|
||||
removal verified whether or not it happened, and the findings pipeline would
|
||||
require a finding code — which names a deterministic check, not a prose
|
||||
judgement. `subtraction-write-cli.mjs` owns the execution instead.
|
||||
- The subtraction floor is deterministic and runs *before* the agent, so a
|
||||
load-bearing block is never a candidate. It errs toward keeping: on a
|
||||
well-maintained config this axis is mostly a no-op, and that is a good result.
|
||||
- The deterministic half (CA-OPT-001) also rides in the normal orchestrated
|
||||
audit; this command adds the prose-judgment half on top.
|
||||
- Without `--apply`, no files are modified. To act on a mechanism-fit finding,
|
||||
use `/config-audit plan` → `/config-audit implement` (backup + rollback) or
|
||||
edit by hand.
|
||||
- No files are modified. To act on a finding, use `/config-audit plan` →
|
||||
`/config-audit implement` (backup + rollback) or edit by hand.
|
||||
|
|
|
|||
|
|
@ -22,11 +22,7 @@ Generate a prioritized action plan based on analysis results.
|
|||
|
||||
### Step 1: Verify session state
|
||||
|
||||
Find the most recent session with analysis completed using the **Glob tool** on `~/.claude/config-audit/sessions/*/state.yaml`, then Read the newest match. The Read tool takes one literal path and does not expand `*` — pointing it at the glob makes this step report "no analysis results" even when a valid session exists.
|
||||
|
||||
If no session is found: "No analysis results found. Run `/config-audit` first to scan your configuration."
|
||||
|
||||
Then confirm the report itself exists — a session can carry a valid `state.yaml` and still be missing its report. Read `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`. If it is absent: "Session {session-id} has no analysis report. Run `/config-audit analyze` to generate it." Stop — the planner agent has nothing to read.
|
||||
Find the most recent session with analysis completed using the Read tool on `~/.claude/config-audit/sessions/*/state.yaml`. If none found: "No analysis results found. Run `/config-audit` first to scan your configuration."
|
||||
|
||||
### Step 2: Tell the user what's happening
|
||||
|
||||
|
|
@ -39,10 +35,10 @@ Actions are ordered by impact, with risk assessment and dependency tracking.
|
|||
|
||||
### Step 3: Parse flags and spawn planner agent
|
||||
|
||||
Check whether `$ARGUMENTS` contains `--raw`. Carry the answer yourself: the agent
|
||||
prompt below is **not** a shell, so a variable assigned in a bash block cannot be
|
||||
referenced from it. Substitute `{mode}` literally with `--raw` or with `humanized`
|
||||
when writing the prompt.
|
||||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
```
|
||||
|
||||
Tell the user: **"Generating your action plan (this takes about 30 seconds)..."**
|
||||
|
||||
|
|
@ -53,7 +49,7 @@ Agent(subagent_type: "config-audit:planner-agent")
|
|||
Generate action plan based on:
|
||||
- Analysis: ~/.claude/config-audit/sessions/{session-id}/analysis-report.md
|
||||
- Interview: ~/.claude/config-audit/sessions/{session-id}/interview.md (if exists)
|
||||
Mode: {mode} ("humanized" = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
|
||||
Mode: $RAW_FLAG (empty = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
|
||||
Create a prioritized plan that consumes the humanized finding fields:
|
||||
- Group actions by userImpactCategory (e.g., "Configuration mistake",
|
||||
"Conflict", "Wasted tokens", "Missed opportunity", "Dead config")
|
||||
|
|
@ -73,22 +69,7 @@ Agent(subagent_type: "config-audit:planner-agent")
|
|||
|
||||
### Step 4: Present the plan summary
|
||||
|
||||
Read the generated plan, then classify the files its actions target. This summary
|
||||
IS the approval surface — there is no separate confirmation step here, so a plan
|
||||
that proposes writing to machine-wide configuration has to say so where the user
|
||||
reads it. Pass one `--target` per distinct file the plan touches:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<file-1>" --target "<file-2>" --repo "$PWD" --output-file /tmp/config-audit-plan-scope.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit 0 = classified; 3 = argument error (show the stderr message). Read
|
||||
`/tmp/config-audit-plan-scope.json`. If `gate` is not `"silent"`, render each
|
||||
distinct string in `disclosures[]` verbatim directly under the action table, and
|
||||
mark the affected rows — not in a footnote further down, where a user scanning the
|
||||
table would miss it.
|
||||
|
||||
Show a concise overview:
|
||||
Read the generated plan and show a concise overview:
|
||||
|
||||
```markdown
|
||||
### Action Plan Ready
|
||||
|
|
@ -113,14 +94,7 @@ You can edit the plan file to remove, reorder, or modify actions before implemen
|
|||
|
||||
### Step 5: Update state
|
||||
|
||||
Update `state.yaml` with all four fields `.claude/rules/state-management.md` requires — a partial write drops the fields that make an interrupted run resumable:
|
||||
|
||||
- `current_phase: "plan"`
|
||||
- `completed_phases`: append `plan` to the existing array (read it first; never overwrite it with a fresh list)
|
||||
- `next_phase: "implement"`
|
||||
- `updated_at`: current timestamp
|
||||
|
||||
The planner agent may already have written these. Read the file before writing and preserve whichever fields it set — a full-file Write that names only two fields silently deletes the other two.
|
||||
Update `state.yaml` with `current_phase: "plan"`, `next_phase: "implement"`.
|
||||
|
||||
## Plan Modification
|
||||
|
||||
|
|
|
|||
|
|
@ -32,21 +32,15 @@ Auditing {N} plugin(s) for structure, frontmatter quality, and cross-plugin conf
|
|||
|
||||
### Step 2: Run scanner
|
||||
|
||||
Run silently for each plugin. Default mode writes a humanized JSON payload to `--output-file` where each PLH finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. `--raw` is passed through verbatim when present, and prints the byte-stable v5.0.0 envelope on stdout instead.
|
||||
Run silently for each plugin. Default mode emits a humanized JSON envelope where each PLH finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. `--raw` is passed through verbatim when present.
|
||||
|
||||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs "<path>" --output-file /tmp/config-audit-plugin-health.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <path> $RAW_FLAG 2>/dev/null
|
||||
```
|
||||
|
||||
Read `/tmp/config-audit-plugin-health.json` with the Read tool. Exit codes 0, 1 and 2 are normal; only 3 is a real error.
|
||||
|
||||
The payload carries three things the report needs:
|
||||
|
||||
- `plugins[]` — one row per plugin: `name`, `declaredName`, `commandCount`, `agentCount`, `findingCount`, `score`, `grade`. Use these for the table; never estimate a grade yourself.
|
||||
- `cross_plugin_findings[]` — the namespace-collision and shared-command-name findings, already separated from the per-plugin ones (they also carry `crossPlugin: true` in `findings`).
|
||||
- `findings[]` — every finding, humanized.
|
||||
Read stdout output (JSON) using the Read tool. Parse findings.
|
||||
|
||||
### Step 3: Present results
|
||||
|
||||
|
|
@ -55,7 +49,7 @@ The payload carries three things the report needs:
|
|||
|
||||
| Plugin | Grade | Commands | Agents | Status |
|
||||
|--------|-------|----------|--------|--------|
|
||||
| {plugins[].name} | {plugins[].grade} ({plugins[].score}) | {plugins[].commandCount} | {plugins[].agentCount} | {Good/Issues found} |
|
||||
| {name} | {grade} ({score}) | {cmd_count} | {agent_count} | {Good/Issues found} |
|
||||
| ... | ... | ... | ... | ... |
|
||||
|
||||
{If cross-plugin issues:}
|
||||
|
|
|
|||
|
|
@ -42,35 +42,27 @@ Run silently — JSON goes to a file, the humanized scorecard prints to stderr (
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --output-file /tmp/config-audit-posture.json $RAW_FLAG >/dev/null 2>/tmp/config-audit-posture-stderr.txt; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --output-file /tmp/config-audit-posture-$$.json $RAW_FLAG 2>/tmp/config-audit-posture-stderr-$$.txt; echo $?
|
||||
```
|
||||
|
||||
Both paths are fixed literals, repeated literally in every later step: each
|
||||
```bash fence is its own process, so a `$$`-derived path could never be named
|
||||
again. `>/dev/null` is required, not cosmetic — with `--raw` the scanner writes
|
||||
the full envelope to stdout *as well as* the file (`posture.mjs:101`), which is
|
||||
255 KB on a real repo.
|
||||
|
||||
If exit code is non-zero, tell the user: "Assessment couldn't complete. Check that the path exists and contains Claude Code configuration files."
|
||||
|
||||
If `--raw` was passed, treat the captured stderr as v5.0.0-shape verbatim text and present it as-is in a code block; skip the humanized rendering steps below.
|
||||
|
||||
### Step 3: Read and interpret results
|
||||
|
||||
Use the Read tool on `/tmp/config-audit-posture.json`. Extract:
|
||||
Read the JSON output file using the Read tool. Extract:
|
||||
|
||||
- `overallGrade`, `opportunityCount`
|
||||
- `areas[]` — each with `name`, `grade`, `score`, `findingCount`
|
||||
- `scannerEnvelope.scanners[].findings[]` — when surfacing individual findings, prefer the humanizer-provided fields: `userImpactCategory` (e.g., "Configuration mistake", "Wasted tokens"), `userActionLanguage` (e.g., "Fix this now", "Fix soon", "Optional cleanup"), and `relevanceContext` ("affects-everyone", "affects-this-machine-only", "test-fixture-no-impact"). These let you group and prioritize without hardcoded severity-to-prose mappings.
|
||||
|
||||
Also use the Read tool on `/tmp/config-audit-posture-stderr.txt` — its body is the humanized scorecard (grade headline, area-score block, opportunity hint). You can present it verbatim or interleave its lines with the JSON-driven table.
|
||||
Also Read the captured stderr file — its body is the humanized scorecard (grade headline, area-score block, opportunity hint). You can present it verbatim or interleave its lines with the JSON-driven table.
|
||||
|
||||
### Step 4: Present the scorecard
|
||||
|
||||
```markdown
|
||||
**Health: {overallGrade}** | (area count: take it from the humanized scorecard's
|
||||
"N areas reviewed" line — do NOT use `areas.length`, which counts Feature
|
||||
Coverage; the table below excludes it, so the two would disagree)
|
||||
**Health: {overallGrade}** | {qualityAreaCount} areas scanned
|
||||
|
||||
{Use the headline line from the humanized stderr scorecard — it carries grade-context prose already (e.g., " Health: A (97/100) — Healthy setup, only minor polish needed"). Do not re-derive an A/B/C/D prose table here; the humanizer owns that vocabulary.}
|
||||
|
||||
|
|
@ -101,19 +93,19 @@ Avoid hardcoded grade-to-prose ladders here — the humanized scorecard headline
|
|||
|
||||
Run drift comparison silently:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs "<target-path>" --output-file /tmp/config-audit-posture-drift.json 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <target-path> 2>/dev/null
|
||||
```
|
||||
|
||||
Use the Read tool on `/tmp/config-audit-posture-drift.json` and append a "Configuration Drift" section showing what changed since the last baseline. Both scanners report to stderr in default mode, which `2>/dev/null` discards — the payload is the only readable output.
|
||||
Read stdout output and append a "Configuration Drift" section showing what changed since the last baseline.
|
||||
|
||||
**If `--plugin-health` flag is present:**
|
||||
|
||||
Run plugin health scanner silently:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs "<target-path>" --output-file /tmp/config-audit-posture-plh.json 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <target-path> 2>/dev/null
|
||||
```
|
||||
|
||||
Use the Read tool on `/tmp/config-audit-posture-plh.json` and append a "Plugin Health" section, using its `plugins[]` rows for per-plugin grades.
|
||||
Read stdout output and append a "Plugin Health" section.
|
||||
|
||||
**If both flags:** Use `scanners/lib/report-generator.mjs` to produce a unified markdown report.
|
||||
|
||||
|
|
@ -121,9 +113,5 @@ Use the Read tool on `/tmp/config-audit-posture-plh.json` and append a "Plugin H
|
|||
|
||||
If a config-audit session exists, save results:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --json --output-file ~/.claude/config-audit/sessions/<session-id>/posture.json >/dev/null 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --json --output-file ~/.claude/config-audit/sessions/<session-id>/posture.json 2>/dev/null
|
||||
```
|
||||
|
||||
This is a second scan on purpose: the session file stores the raw v5.0.0 shape,
|
||||
while step 2 wrote the humanized one. `>/dev/null` matters most here — `--json`
|
||||
sends the same envelope to stdout regardless of `--output-file`.
|
||||
|
|
|
|||
|
|
@ -45,21 +45,7 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
|
|||
### Restore mode (with backup ID)
|
||||
|
||||
1. Read the list of changes from `~/.claude/config-audit/backups/{backup-id}/manifest.yaml` using the Read tool
|
||||
2. Classify the `original:` paths before showing them. A restore writes to the
|
||||
absolute path recorded at backup time, which may be machine-wide even when the
|
||||
backup was taken from a project — so the file list must be rendered as the
|
||||
absolute originals, never shortened to a repo-relative-looking form that
|
||||
implies the write stays local:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<original-1>" --target "<original-2>" --repo "$PWD" --output-file /tmp/config-audit-rollback-scope.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit 0 = classified; 3 = argument error (show the stderr message). Read
|
||||
`/tmp/config-audit-rollback-scope.json`, render each distinct string in
|
||||
`disclosures[]` verbatim, then ask for confirmation.
|
||||
|
||||
When `requiresApproval` is false:
|
||||
2. Show files that will be restored — ask for confirmation:
|
||||
```
|
||||
AskUserQuestion:
|
||||
question: "Restore 3 files from backup 20260403_163045?"
|
||||
|
|
@ -67,15 +53,6 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
|
|||
- "Yes, restore"
|
||||
- "Cancel"
|
||||
```
|
||||
|
||||
When `requiresApproval` is true, name the scope and put the safe option first:
|
||||
```
|
||||
AskUserQuestion:
|
||||
question: "This restores {K} of 3 files to locations outside this project. Restore all 3?"
|
||||
options:
|
||||
- "Cancel"
|
||||
- "Yes — restore, including outside this project"
|
||||
```
|
||||
3. For each file in the list of changes:
|
||||
a. Read the backup file from `~/.claude/config-audit/backups/{backup-id}/files/{safeName}`
|
||||
b. Write to the original path
|
||||
|
|
@ -83,22 +60,10 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
|
|||
4. Show result:
|
||||
```
|
||||
Restored 3 files from backup 20260403_163045
|
||||
- /abs/path/.claude/settings.json (checksum verified)
|
||||
- /abs/path/hooks/hooks.json (checksum verified)
|
||||
- .claude/settings.json (checksum verified)
|
||||
- hooks/hooks.json (checksum verified)
|
||||
- .claude/rules/typescript.md (checksum verified)
|
||||
```
|
||||
5. **Report what rollback cannot undo.** A backup only holds files that already
|
||||
existed, so files the implement step CREATED survive the restore. If the
|
||||
manifest has a `created:` section (or `restoreBackup()` returns a non-empty
|
||||
`createdNotRemoved`), list those paths and say plainly that they remain:
|
||||
```
|
||||
Left in place — created by implement, no backup exists:
|
||||
- .claude/rules/post-quality.md
|
||||
- guidelines/posting-rhythm.md
|
||||
Remove them manually if you want the pre-implement state exactly.
|
||||
```
|
||||
Never finish a restore without this section when the list is non-empty; a
|
||||
silently half-restored target reads as a clean rollback.
|
||||
|
||||
### Delete mode
|
||||
|
||||
|
|
@ -109,14 +74,9 @@ If user says "delete" after listing, confirm and remove the backup directory.
|
|||
Use the backup and rollback libraries directly:
|
||||
```javascript
|
||||
import { listBackups, restoreBackup, deleteBackup } from '../scanners/rollback-engine.mjs';
|
||||
import { parseManifest, getBackupDir } from '../scanners/lib/backup.mjs';
|
||||
import { parseManifest } from '../scanners/lib/backup.mjs';
|
||||
```
|
||||
|
||||
Both read `~/.claude/config-audit/backups` and fall back to the pre-v2.2.0
|
||||
`~/.config-audit/backups`, so a backup made before the move still resolves;
|
||||
`listBackups()` flags those with `legacy: true`. Prefer this API over ad-hoc
|
||||
`cp` — it verifies the checksum before and after each write.
|
||||
|
||||
Or via Bash:
|
||||
```bash
|
||||
# List backups
|
||||
|
|
|
|||
|
|
@ -37,13 +37,8 @@ When `--raw` is in `$ARGUMENTS`, render the raw `current_phase` field value verb
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
ALL_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -qw -- "all"; then ALL_FLAG="all"; fi
|
||||
```
|
||||
|
||||
When `ALL_FLAG` is set, skip steps 2–4 and render the **List All Sessions**
|
||||
table below instead of a single session's status.
|
||||
|
||||
2. **Find active session**:
|
||||
```
|
||||
Glob: ~/.claude/config-audit/sessions/*/state.yaml
|
||||
|
|
@ -133,13 +128,11 @@ All config-audit sessions:
|
|||
| 20250120_160000 | implement | 2025-01-20 16:00 |
|
||||
```
|
||||
|
||||
## Resuming a session
|
||||
## Resume Session
|
||||
|
||||
There is no `resume` command. Sessions are selected by recency: every
|
||||
session-aware command globs `~/.claude/config-audit/sessions/*/state.yaml` and
|
||||
takes the most recently modified one.
|
||||
If multiple sessions exist:
|
||||
```
|
||||
/config-audit resume {session-id}
|
||||
```
|
||||
|
||||
To continue an older session, run its next phase directly — `/config-audit plan`,
|
||||
`/config-audit implement`, and so on read `next_phase` from the state file. If
|
||||
the wrong session keeps winning, delete the stale ones with
|
||||
`/config-audit cleanup`.
|
||||
Sets that session as active and continues from last phase.
|
||||
|
|
|
|||
|
|
@ -40,25 +40,12 @@ Tell the user: **"Analysing token hotspots for `<path>`..."**
|
|||
Default mode (no `--json`, no `--raw`) emits a humanized JSON envelope: each finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` in addition to the v5.0.0 fields. Pass `--raw` through verbatim if the user requested it.
|
||||
|
||||
```bash
|
||||
TMPFILE="/tmp/config-audit-tokens-$$.json"
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
# Set each to the flag itself when the user asked for it, otherwise leave empty.
|
||||
# A placeholder in square brackets does not start with a dash, so the CLI's arg
|
||||
# loop would take it as the TARGET PATH instead of a flag.
|
||||
GLOBAL_FLAG="" # --global
|
||||
CACHE_FLAG="" # --no-exclude-cache
|
||||
JSON_FLAG="" # --json
|
||||
TELEMETRY_FLAG="" # --with-telemetry-recipe
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs "<path>" --output-file /tmp/config-audit-tokens.json $GLOBAL_FLAG $CACHE_FLAG $JSON_FLAG $TELEMETRY_FLAG $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file "$TMPFILE" [--global] [--no-exclude-cache] $RAW_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
`--json` and `--with-telemetry-recipe` must be threaded here, not just
|
||||
documented: the CLI is what turns them on. `--json`/`--raw` make the payload
|
||||
byte-stable v5.0.0 (the humanizer is skipped, `token-hotspots-cli.mjs:127`), and
|
||||
`--with-telemetry-recipe` is what adds `telemetry_recipe_path`. `>/dev/null` is
|
||||
required because those two modes also print the payload to stdout even with
|
||||
`--output-file` set (`token-hotspots-cli.mjs:137`).
|
||||
|
||||
**Exit code handling:**
|
||||
- `0` → continue
|
||||
- `3` → tell user: "Couldn't analyse tokens. Check that the path exists and is a directory." Stop.
|
||||
|
|
@ -66,14 +53,14 @@ required because those two modes also print the payload to stdout even with
|
|||
### Step 3: If `--json` was requested, cat the file and stop
|
||||
|
||||
```bash
|
||||
cat /tmp/config-audit-tokens.json
|
||||
cat "$TMPFILE"
|
||||
```
|
||||
|
||||
Do NOT render tables in JSON mode.
|
||||
|
||||
### Step 4: Read JSON and render
|
||||
|
||||
Use the Read tool on `/tmp/config-audit-tokens.json`. Extract:
|
||||
Use the Read tool on `$TMPFILE`. Extract:
|
||||
|
||||
- `total_estimated_tokens` — top-line number
|
||||
- `hotspots[]` — top 10 ranked sources; each carries a **load pattern** (`loadPattern` ∈ always / on-demand / external, plus `survivesCompaction` / `derivationConfidence`)
|
||||
|
|
@ -122,7 +109,7 @@ _Estimates assume ~4 chars/token (Claude ballpark). Real token count varies ±20
|
|||
### Step 5: Cleanup and next steps
|
||||
|
||||
```bash
|
||||
rm -f /tmp/config-audit-tokens.json
|
||||
rm -f "$TMPFILE"
|
||||
```
|
||||
|
||||
```markdown
|
||||
|
|
|
|||
|
|
@ -33,14 +33,10 @@ Split `$ARGUMENTS` into a path and flags. Path is the first non-flag argument. D
|
|||
Tell the user: **"Reading active configuration for `<path>`..."**
|
||||
|
||||
```bash
|
||||
TMPFILE="/tmp/ca-whats-active-$$.json"
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
# Set each to the flag itself when the user asked for it, otherwise leave empty.
|
||||
# A placeholder in square brackets does not start with a dash, so the scanner's
|
||||
# arg loop would take it as the TARGET PATH instead of a flag.
|
||||
VERBOSE_FLAG="" # --verbose
|
||||
SUGGEST_FLAG="" # --suggest-disables
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs "<path>" --output-file /tmp/config-audit-whats-active.json $VERBOSE_FLAG $SUGGEST_FLAG $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs <path> --output-file "$TMPFILE" [--verbose] [--suggest-disables] $RAW_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
**Exit code handling:**
|
||||
|
|
@ -50,14 +46,14 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs "<path>" --output-file /tmp
|
|||
### Step 3: If `--json` was requested, cat the file and stop
|
||||
|
||||
```bash
|
||||
cat /tmp/config-audit-whats-active.json
|
||||
cat "$TMPFILE"
|
||||
```
|
||||
|
||||
Do NOT render tables in JSON mode.
|
||||
|
||||
### Step 4: Read JSON and render
|
||||
|
||||
Use the Read tool on `/tmp/config-audit-whats-active.json`. Extract:
|
||||
Use the Read tool on `$TMPFILE`. Extract:
|
||||
|
||||
- `meta.repoPath`, `meta.durationMs`, `meta.gitRoot`, `meta.projectKey`
|
||||
- `totals.estimatedTokens.grandTotal` (and subtotals)
|
||||
|
|
@ -94,17 +90,7 @@ Render as markdown:
|
|||
|-------|--------|--------|
|
||||
| {name} | {source}{if pluginName: ` (${pluginName})`} | ~{estimatedTokens} |
|
||||
|
||||
### Agents ({agents.length}, ~{agents subtotal} tokens)
|
||||
|
||||
| Agent | Source | Model | Effort | Tokens |
|
||||
|-------|--------|-------|--------|--------|
|
||||
| {name} | {source}{if pluginName: ` (${pluginName})`} | {model or "inherit"} | {effort or "session default"} | ~{estimatedTokens} |
|
||||
|
||||
Skip this section entirely when `agents` is empty. `model: null` is rendered as
|
||||
*inherit* and `effort: null` as *session default* — both are the documented
|
||||
defaults, so a blank cell would read as missing data rather than as the choice
|
||||
it is. If no agent pins either column, say so in one sentence: every delegated
|
||||
task then costs what the session costs.
|
||||
### MCP Servers ({mcpServers.length}, ~{mcpServers subtotal} tokens)
|
||||
|
||||
| Server | Source | Status | Command |
|
||||
|--------|--------|--------|---------|
|
||||
|
|
@ -163,7 +149,7 @@ Do NOT suggest items you can't name concrete redundancy for. If you can't find 3
|
|||
### Step 7: Cleanup and next steps
|
||||
|
||||
```bash
|
||||
rm -f /tmp/config-audit-whats-active.json
|
||||
rm -f "$TMPFILE"
|
||||
```
|
||||
|
||||
```markdown
|
||||
|
|
|
|||
|
|
@ -1,400 +0,0 @@
|
|||
# Brief — Delete-and-Rebuild (config subtraction)
|
||||
|
||||
**Status:** BRIEF, not a plan. No code, no chunk breakdown, no version number committed.
|
||||
Written 2026-07-29 so a session starting Thursday evening has a durable starting point.
|
||||
STATE.md is gitignored in this repo, so this file — not STATE — is the record.
|
||||
|
||||
---
|
||||
|
||||
## 1. Trigger and its provenance
|
||||
|
||||
The operator relayed a third-party YouTube summary (Hyper Automation Labs) of a talk
|
||||
Boris Cherny reportedly gave at Y Combinator Startup School, one day after Opus 5
|
||||
shipped. Claims attributed to him in that summary:
|
||||
|
||||
- Anthropic deleted ~80 % of Claude Code's own system prompt when Opus 5 landed.
|
||||
- Advice to users: every six months, delete your CLAUDE.md, your skills, your hooks —
|
||||
see what the model does.
|
||||
- Rebuild method: delete everything, use it, add one line back only when the model
|
||||
stumbles on the same thing repeatedly.
|
||||
- The model measured *slightly more intelligent* with the built-in prompts stripped.
|
||||
|
||||
**Two levels of confidence here, and they must not be collapsed** (updated 2026-07-29
|
||||
after the operator corrected the first draft):
|
||||
|
||||
- **Attribution — confirmed.** The operator watched the recording and identifies Boris
|
||||
Cherny on stage. This is not a channel's claim about who spoke; it is direct
|
||||
observation by the operator. The talk happened and it is him.
|
||||
- **The verbatim figures — still summary-level.** "80 % of the system prompt", "the
|
||||
model measured slightly more intelligent without the prompts", the Bun numbers: these
|
||||
reach us through the channel's editing, not through a primary transcript. They are
|
||||
plausible and consistent with §2, and they are not quoted as fact anywhere below.
|
||||
|
||||
So if this becomes a `BP-*` entry in the best-practices register, the source field reads
|
||||
*"Boris Cherny, YC Startup School talk (attribution confirmed by operator); figures via
|
||||
third-party summary, not primary-verified"* — not "unverified", and not a bare citation
|
||||
either. Getting a primary transcript for the figures is a nice-to-have, never a
|
||||
prerequisite: the feature is argued from this repo's own logic (§3), and this repo has
|
||||
one scar from treating a plausible quote as load-bearing fact («Fable low ≈ Opus high»,
|
||||
fabricated, rejected 2026-07-14).
|
||||
|
||||
**What the operator has affirmed as in scope:** delete CLAUDE.md, then rebuild by adding
|
||||
back what the model needs help with — *starting with what it must have*. That last clause
|
||||
is not a detail; it is the design constraint in §6.0.
|
||||
|
||||
## 2. Verified ground truth (checked against the local CLI, 2026-07-29)
|
||||
|
||||
These *are* facts, and they are what make an empirical variant buildable:
|
||||
|
||||
| Fact | How verified |
|
||||
|---|---|
|
||||
| `claude --bare` exists — "Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets `CLAUDE_CODE_SIMPLE=1`." | `claude --help` |
|
||||
| `claude --system-prompt <prompt>` — replaces the session system prompt | `claude --help` |
|
||||
| `--append-system-prompt`, `--add-dir`, `--setting-sources <user,project,local>`, `--plugin-dir`, `--settings`, `--agents` — all present and all usable to compose an isolated config for a controlled run | `claude --help` |
|
||||
|
||||
So the `CLAUDE_CODE_SIMPLE=1` env var the video calls "undocumented" is, in this CLI
|
||||
version, a documented flag (`--bare`). That matters: an A/B ablation harness would not
|
||||
need an undocumented hook.
|
||||
|
||||
## 3. Why this fits this repo (the argument that does *not* depend on §1)
|
||||
|
||||
The plugin has three pillars — Health, Opportunities, Action. Every existing command
|
||||
answers a question on the **addition** axis:
|
||||
|
||||
- `feature-gap` — what could you add?
|
||||
- `optimize` — what would fit a better mechanism?
|
||||
- `posture` / `tokens` / `manifest` — how good/expensive is what you have?
|
||||
- `fix` / `implement` — apply changes.
|
||||
|
||||
**Nothing answers the subtraction question: what is no longer earning its rent?**
|
||||
`feature-gap` has no inverse. That is a real hole, independent of who said what on
|
||||
a stage.
|
||||
|
||||
Two further reasons this belongs *here* specifically:
|
||||
|
||||
1. **Nothing in the repo measures instruction age.** Verified by grep over `scanners/`:
|
||||
`stale` appears only for knowledge-register entry age (`lib/knowledge-refresh.mjs`)
|
||||
and for stale plugin-cache versions (`scan-orchestrator.mjs`, M-BUG-11). No scanner
|
||||
touches `git blame`, `mtime`, or the vintage of a CLAUDE.md block. An instruction
|
||||
written for Sonnet 3.5 and an instruction written last week are indistinguishable to
|
||||
every current scanner.
|
||||
2. **Deleting bravely is only sane if you can undo it.** That is already pillar three:
|
||||
`lib/backup.mjs`, `rollback-engine.mjs`, `auto-backup-config.mjs` (PreToolUse), and
|
||||
`drift`'s `saveBaseline` / `loadBaseline` / `diffEnvelopes`. The safety net exists;
|
||||
the feature that would use it does not. This is arguably the strongest framing:
|
||||
*config-audit is already the infrastructure that makes "delete it and see" a
|
||||
measurement rather than a gamble.*
|
||||
|
||||
## 4. Reusable machinery (do not rebuild these)
|
||||
|
||||
| Need | Already exists |
|
||||
|---|---|
|
||||
| Snapshot config before deleting | `scanners/lib/baseline.mjs` (`saveBaseline`), used by `drift` |
|
||||
| Diff before/after | `diffEnvelopes` in the same module |
|
||||
| Backup + restore individual files w/ sha256 manifest | `scanners/lib/backup.mjs`, `scanners/rollback-engine.mjs` |
|
||||
| Cost of each source, always-loaded subtotal | `scanners/manifest.mjs`, `token-hotspots.mjs` |
|
||||
| Human-approved-writes command pattern | `knowledge-refresh`, `campaign` (both non-byte-stable by design) |
|
||||
|
||||
## 5. Candidate shapes (sketches — pick on Thursday, do not pre-commit)
|
||||
|
||||
All three inherit the floor constraint in §6.0: whatever the shape, load-bearing local
|
||||
facts are never deletion candidates, and a rebuild restores them first. A shape that
|
||||
cannot express that distinction is disqualified regardless of how cheap it is.
|
||||
|
||||
**A. Deterministic vintage scanner (`CA-VIN-*`).** Per instruction block in CLAUDE.md /
|
||||
rules / skills / hooks: age from git history, plus a compensatory-phrasing signal
|
||||
(blocks that exist to correct model behaviour — "ALWAYS", "never forget", "read the
|
||||
whole file first", "don't guess"). Output: ranked deletion candidates with age + token
|
||||
cost + why it looks compensatory. Cheapest, most testable, fits the existing scanner
|
||||
architecture, and composes with `tokens`/`manifest` for the payoff figure.
|
||||
|
||||
> **🔴 The age half of this shape is DEAD — measured 2026-07-31 (økt #40), see §8's
|
||||
> updated checklist.** Per-line `git blame` over four real instruction files gives
|
||||
> single-date shares of 88 % / 55 % / 100 % / 58 %: instruction blocks trace back to bulk
|
||||
> commits, so per-block age carries almost no information. Worse, `blame` reports
|
||||
> *last touch*, not vintage — a reformatting commit (e.g. this repo's own `96e32df`,
|
||||
> "trim project CLAUDE.md to invariants") makes old instructions look young, which biases
|
||||
> the signal rather than merely thinning it. That also kills the `mtime` fallback.
|
||||
> **§8 pre-registered this exact outcome and its consequence: shape A's primary signal
|
||||
> collapses to the compensatory-phrasing heuristic alone, and §7.2 is answered — the
|
||||
> classification needs the agent layer.** Do not resurrect age as a "weak secondary
|
||||
> signal"; that is the drift the pre-registration existed to prevent. What survives of
|
||||
> shape A is the *deterministic phrasing + token-cost pre-filter*, feeding a
|
||||
> precision-gated judge (the `optimize` architecture).
|
||||
|
||||
**B. Protocol command (`/config-audit rebuild`).** The delete → live with it → earned
|
||||
re-add ledger loop, spanning sessions: archive current config, record what was archived,
|
||||
and maintain a ledger where a line only returns when the operator records that the model
|
||||
actually stumbled on it. Highest fidelity to the source idea; needs cross-session state
|
||||
(the `sessions/` machinery already exists) and is inherently not byte-stable.
|
||||
|
||||
**C. Measured ablation harness.** Use `--bare` + `--system-prompt`/`--add-dir` to run the
|
||||
same prompt with and without a block and compare. Closest to a real verifier, most
|
||||
expensive in tokens, weakest determinism. Probably a later `--experimental` sub-mode of
|
||||
A or B rather than its own command.
|
||||
|
||||
Likely landing: **A first** (deterministic, testable, immediately useful), with B as the
|
||||
workflow that consumes A's output. C stays a documented idea until A+B exist.
|
||||
|
||||
> **DECIDED 2026-07-31 (#40), superseding the sketch above — see §7 q2/q3.** Shape A does
|
||||
> not ship as a standalone `CA-VIN-*` scanner: its age signal is dead, and what remains of
|
||||
> it (a phrasing + token-cost pre-filter) is precisely the front half of `optimize`'s
|
||||
> existing hybrid motor. **The landing is `/config-audit optimize --subtract`** — a fourth
|
||||
> `lensCheck` class emitting `CA-OPT-*` findings, with a deterministic **floor-exclusion**
|
||||
> step ahead of the judge so a load-bearing block is never a candidate. Shape B (the
|
||||
> earned-re-add ledger) stays out of v1 precisely because it is what would demand
|
||||
> cross-session state and therefore a command of its own. Shape C unchanged: documented,
|
||||
> not built.
|
||||
|
||||
## 6. Hard constraints (carry these into any implementation)
|
||||
|
||||
### 6.0 The floor: compensatory vs load-bearing instructions
|
||||
|
||||
**This is the invariant all three shapes in §5 must respect, and it is what makes the
|
||||
feature safe to ship at all.** Not every line in a CLAUDE.md is the same kind of thing:
|
||||
|
||||
| Class | What it is | Test | Disposition |
|
||||
|---|---|---|---|
|
||||
| **Compensatory** | An instruction correcting model *behaviour* — "read the whole file first", "don't guess", "ALWAYS verify", "think before you code" | A smarter model would do this unprompted | **Deletion candidate.** Returns only when earned. |
|
||||
| **Load-bearing** | A *local fact* the model cannot derive at any capability level — "never GitHub, only Forgejo at git.fromaitochitta.com", "system bash is 3.2, no `declare -A`", "test with `node --test 'tests/**/*.test.mjs'`", "`~/.claude` is not git-tracked" | No amount of intelligence produces this from the codebase alone | **Floor. Never a deletion candidate.** |
|
||||
|
||||
Model capability erodes the first class and does nothing to the second. That is the whole
|
||||
mechanism behind the source idea — and it means "delete your CLAUDE.md" is only correct
|
||||
for one of the two classes. A tool that treats them alike would delete the operator's
|
||||
Forgejo constraint because Opus 5 "is smart enough now", which is a category error: the
|
||||
model isn't failing at intelligence there, it simply cannot know.
|
||||
|
||||
**Consequence for the rebuild ordering.** A rebuild is not one undifferentiated
|
||||
add-back-on-stumble loop. It is three tiers:
|
||||
|
||||
1. **Floor — goes back immediately, no trial period.** Load-bearing facts. The config is
|
||||
never in a state where these are absent; a "delete everything" that drops them is a
|
||||
broken experiment, not a brave one.
|
||||
2. **Earned — out, returns only on repeated observed stumbling.** Compensatory
|
||||
instructions that turn out to still be needed by *this* model.
|
||||
3. **Dead — out and never missed.** The payoff, measurable in tokens via `manifest`.
|
||||
|
||||
A third class sits deliberately outside the axis: **policy prohibitions** ("never commit
|
||||
secrets"). These may well be things the model would honour unprompted, but their cost of
|
||||
being wrong is asymmetric and they are cheap. They stay in the floor by decision, not by
|
||||
classification. Do not let a "the model knows this now" argument reach them.
|
||||
|
||||
The hard part is tier 1 vs tier 2, and that classification — not the deletion mechanics —
|
||||
is the real engineering problem in this brief. Precision is asymmetric: a missed dead line
|
||||
costs a few tokens per turn; a deleted load-bearing line costs a wrong remote, a broken
|
||||
bash script, or a lost afternoon.
|
||||
|
||||
### 6.1 Existing repo constraints
|
||||
|
||||
- **`~/.claude` IS git-tracked as of 2026-07-26 — but archive by `mv` into `_archive/`
|
||||
anyway, never `rm`.** Premise corrected 2026-07-31 (økt #40); the rule it was used to
|
||||
justify is unchanged. Ground truth: `/Users/ktg/.claude` is a git repo, 7 commits,
|
||||
initial commit `13cd708` 2026-07-26, remote `backup
|
||||
/Volumes/DharmaBackup/claude-config.git` (external volume — not Forgejo, not GitHub),
|
||||
47 files tracked (`hooks/` 19, `commands/` 14, `scripts/` 9, `CLAUDE.md`,
|
||||
`settings.json`, `learnings/`, `docs/`). The rule survives on different grounds: only
|
||||
those 47 files are recoverable, the history is days old, and the remote lives on a
|
||||
volume that may not be mounted. Two consequences for this feature — (a) `~/.claude`
|
||||
age evidence is bounded by the repo's own birth date and is therefore worthless, and
|
||||
(b) any untracked file there (`memory/`, `coord/`, session state) is still
|
||||
unrecoverable after `rm`.
|
||||
- **`settings.json` is pathguard-protected** — no Edit/Write. Use `jq` + temp file +
|
||||
atomic `mv`, with operator OK ([[settings-json-pathguard-write]]).
|
||||
- **New scanner ⇒ the 7-step byte-stability checklist** ([[adding-scanner-byte-stability]]):
|
||||
scanner + orchestrator + scoring area-map + strip-added-scanner + humanizer count +
|
||||
SC-5 + humanizer wiring (`SCANNER_TO_CATEGORY` entry and `TRANSLATIONS.static` per RAW
|
||||
title — M-16/M-17). Frozen `tests/snapshots/v5.0.0/` must stay untouched.
|
||||
- **TDD is inviolable** — red tests before implementation, including for .md contracts.
|
||||
- **`feat:` commits require non-trivial diffs in both README.md and CLAUDE.md**
|
||||
([[docs-gate-feat-requires-readme-claudemd]]).
|
||||
|
||||
## 7. Open questions for Thursday
|
||||
|
||||
> **STATUS 2026-07-31 (#40): q1, q2, q3 are CLOSED below — decided on measured evidence
|
||||
> (dead age signal) plus the hand-built fasit (`docs/subtraction-fasit.local.md`), under
|
||||
> the operator's standing delegation of technical/design calls. q4 was already closed
|
||||
> 2026-07-29. Do not re-litigate without new evidence.**
|
||||
>
|
||||
> **The landing: `/config-audit optimize --subtract` — a fourth lens class in the existing
|
||||
> hybrid motor, not a new command and not a new scanner.** Full rationale in q3.
|
||||
|
||||
1. Scope of the deletion candidate set: user-level `~/.claude` only, project only, or both?
|
||||
(Age evidence is much stronger for project-level, per §5A.)
|
||||
|
||||
**CLOSED — both, and user-level is mandatory in v1.** The argument that pointed at
|
||||
project-level was git-age evidence, and that evidence no longer exists (§5A box), so
|
||||
scope now follows *payoff and testability* instead. Two things force user-level in:
|
||||
§8's blocking floor test is defined over the operator's global CLAUDE.md, and that file
|
||||
is where the always-loaded cost actually sits (~4 300 tok every turn, every repo,
|
||||
every session — vs a project CLAUDE.md that loads only in its own repo). Project-level
|
||||
comes along because the same motor reads it and because two of §8's four floor anchors
|
||||
turned out to live there.
|
||||
*Implementation note, not a reopening:* `optimize` today takes a repo `target`. Reading
|
||||
`~/.claude/CLAUDE.md` is a target-resolution change, and per §6.1 the `~/.claude` write
|
||||
rules apply — but this mode proposes, it never writes.
|
||||
2. **The core question, given §6.0:** can compensatory-vs-load-bearing be classified
|
||||
deterministically with acceptable precision, or does it need the agent layer (like
|
||||
`optimize`'s precision-gated `optimization-lens-agent`, which stays silent when
|
||||
unsure)? Note the two signals are independent — a load-bearing fact can be old, and a
|
||||
compensatory instruction can be new — so age alone can never carry this call. Prior:
|
||||
a deterministic pre-filter feeding a precision-gated judge, which is exactly the
|
||||
`optimize` architecture already in the repo.
|
||||
**Design the §8 fasit around the ambiguous middle, not the poles.** The four named
|
||||
must-survive items are clear-cut and any mechanism will get them right; the gate is
|
||||
really decided by blocks like "Conventional Commits: `type(scope): beskrivelse`"
|
||||
(local convention, or a nag the model would follow anyway?), "commit ofte med
|
||||
beskrivende meldinger" (pure behaviour correction?), or the model-routing rubric
|
||||
(a table of local policy that reads like advice). Include 3–5 such blocks
|
||||
deliberately. A fasit built only from obvious cases will pass a tool that fails on
|
||||
real config.
|
||||
|
||||
**CLOSED — it cannot be done deterministically. Deterministic pre-filter → precision-
|
||||
gated judge, which is the `optimize` architecture verbatim.** Two independent lines of
|
||||
evidence, both gathered before any code:
|
||||
- *The age signal is gone* (§5A box). It was the only deterministic input that was
|
||||
going to do real discriminating work; phrasing heuristics alone are what remain.
|
||||
- *The fasit shows phrasing alone is not enough.* The blocks that decide the gate all
|
||||
require reading **content against container** — a load-bearing fact wearing generic
|
||||
phrasing, or vice versa:
|
||||
|
||||
| Fasit block | The trap |
|
||||
|---|---|
|
||||
| B-27 (defensive shell-scripting) | Reads as universal advice ("quote your variables"), but each line records a *specific local incident*, and "keep test code ASCII-clean" is inseparable from bash 3.2 (B-26, a §8 floor anchor). **A phrasing regex deletes this and fails the gate.** |
|
||||
| B-32b ("never work in another repo") | Sits inside a bullet list of compensatory anti-patterns, formatted identically to its neighbours, but encodes the polyrepo structure and names `coord-send`. Container says delete, content says floor. |
|
||||
| B-05 vs B-06 | Adjacent preference bullets, near-identical form, opposite calls. |
|
||||
| B-30a / B-30b / B-30c | Three consecutive bullets under one heading, three different calls (Conventional Commits is floor, "lesbarhet > cleverness" is not). |
|
||||
|
||||
No regex separates these; a judge reading them in context can.
|
||||
- *Two failure modes the judge must be prompted against, both found in the fasit:*
|
||||
**staleness is not a deletion signal** (B-29 pins Opus 4.8 while the session runs
|
||||
Opus 5 — that is a `drift`/dead-reference finding about a **floor** block), and
|
||||
**tier-2 is not tier-3** (B-22, premiss-verifisering, is compensatory by class yet
|
||||
earned itself again during this very session).
|
||||
|
||||
**Design consequence — the floor is NOT the judge's call.** Load-bearing exclusion runs
|
||||
as a deterministic pre-filter step *before* the judge sees anything, so a floor block is
|
||||
never a candidate at all. §8's gate is blocking and precision is asymmetric; making it
|
||||
depend on a probabilistic judge would be the wrong guarantee. The judge then decides
|
||||
only compensatory-tier questions on the remainder, and stays silent when unsure exactly
|
||||
as `optimization-lens-agent` already does.
|
||||
|
||||
3. Does this ship as its own command, or as a `--subtract` mode of `optimize`?
|
||||
Both are defensible; command count is already 21.
|
||||
|
||||
**CLOSED — a `--subtract` mode of `optimize`.** The discriminator is whether v1 needs
|
||||
cross-session state: it does not. v1 ranks deletion candidates and reports the payoff;
|
||||
the earned-re-add **ledger** (shape B) is what would require `sessions/` state, and it
|
||||
is deliberately not in v1. Without the ledger there is nothing a separate command buys.
|
||||
|
||||
What the mode inherits by not being new (verified against the code, 2026-07-31):
|
||||
|
||||
| Requirement | `optimize` already has it |
|
||||
|---|---|
|
||||
| Deterministic pre-filter → opus judge | `optimization-lens-scanner.mjs` (`lens-prefilter`) → `optimization-lens-agent` |
|
||||
| Precision gate, silent when unsure | Stated in the agent's own prompt |
|
||||
| "Not a mistake" framing | Every `optimize` finding is a *Missed opportunity* — exactly right for a line that works but no longer earns its rent |
|
||||
| Register-backed provenance | `knowledge/best-practices.json`, CONFIRMED-only |
|
||||
| Non-byte-stable by design | Already documented as such in CLAUDE.md |
|
||||
| Finding IDs | `CA-OPT-*` — **no new `CA-VIN-*` scanner** |
|
||||
|
||||
And what it avoids: the 7-step byte-stability checklist (§6.1), a scanners badge bump
|
||||
16 → 17, snapshot risk against frozen `tests/snapshots/v5.0.0/`, and a 22nd command.
|
||||
**Proportionality is the clinching argument.** The fasit puts the honest ceiling at
|
||||
~850–1 400 always-loaded tokens on a ~4 300-token file — real (~20 %), but nowhere near
|
||||
the source anecdote's 80 %, and 26 of 34 blocks are floor. On a well-maintained config
|
||||
the subtraction axis is mostly a no-op. That payoff justifies a mode on an existing
|
||||
motor; it does not justify a new scanner plus a new command. ("Starte ambisiøse tiltak
|
||||
når en konfig-justering holder" is a named anti-pattern.)
|
||||
|
||||
Registry shape: a 4th `lensCheck` class alongside the three in the agent's table, with
|
||||
its own `BP-SUB-001` register rule. `--subtract` is the flag because the subtraction
|
||||
axis should not fire on a plain `/config-audit optimize` run — it asks a different
|
||||
question and needs the operator to have opted into it.
|
||||
4. ~~Ordering against the existing queue.~~ **Decided 2026-07-29:** the operator
|
||||
prioritized this work ahead of pipeline step 4 (`rollback`), to start Thursday
|
||||
2026-07-30. Do not re-litigate. The open part is only what follows it — the
|
||||
prior order stands underneath: step 4 `rollback`, then the M-11→M-20 batch
|
||||
release, then the v5.13 plan.
|
||||
|
||||
## 8. Verifisering (testable criteria)
|
||||
|
||||
**Before building anything:**
|
||||
|
||||
- [x] **DONE 2026-07-31 (#40).** `grep -rniE 'blame|mtime|birthtime' scanners/` returns
|
||||
zero hits → §3.1 confirmed still true at build time.
|
||||
- [x] **DONE 2026-07-31 (#41).** `node scanners/drift-cli.mjs . --save --name pre-subtraction`
|
||||
succeeds and `lib/baseline.mjs` round-trips → §4 reuse is real, not assumed.
|
||||
Written envelope is `{meta, scanners[16], aggregate, _baseline}` with
|
||||
`_baseline.target_path` = the repo (15 findings, score 55). Run with no flags
|
||||
beyond `--save --name`, since M-BUG-21 makes an unknown flag's value silently
|
||||
become the scan target.
|
||||
- [x] **RESOLVED 2026-07-31 (#41) by not needing it.** `BP-SUB-001` was written
|
||||
`confirmed`, and asserts **nothing** from §1 — no "80 %", no ablation figure, no
|
||||
Cherny attribution. Its claim is grounded entirely in the Anthropic steering blog
|
||||
already cited by BP-MECH-001–004, re-verified the same day: *"Every line loads into
|
||||
every session for every engineer working in the repo, whether it's relevant to their
|
||||
task or not. This consumes tokens and dilutes adherence"*, *"Build commands,
|
||||
directory layout, monorepo structure, coding conventions, and team norms all fit
|
||||
naturally here"*, and *"Keep CLAUDE.md under 200 lines"*. The anecdote motivated the
|
||||
feature; it is not a premise of the shipped rule.
|
||||
- [x] **DONE 2026-07-31 (#40) — THE AGE SIGNAL DOES NOT EXIST. Collapse condition met.**
|
||||
Per-line `git blame` (not `git log`; per-line is the question) over four real
|
||||
instruction files:
|
||||
|
||||
| File | Lines | Largest single-date share |
|
||||
|---|---|---|
|
||||
| `~/.claude/CLAUDE.md` | 250 | **88 %** (2026-07-26 = repo init; max age 5 days) |
|
||||
| `config-audit/CLAUDE.md` | 102 | **55 %** (2026-04-08) |
|
||||
| `config-audit/.claude/rules/ux-rules.md` | 32 | **100 %** (one commit) |
|
||||
| `llm-security/CLAUDE.md` | 110 | **58 %** (2026-04-08) |
|
||||
|
||||
Blocks trace back to bulk commits exactly as the pre-registration feared, and
|
||||
`blame` measures last-touch rather than vintage (a reformat resets it), so the
|
||||
signal is *biased*, not merely sparse. **Consequence, as pre-registered: shape A's
|
||||
primary signal is gone and §7.2 is answered — deterministic phrasing/cost
|
||||
pre-filter → precision-gated judge.** See the boxed note in §5A.
|
||||
- [x] **DONE 2026-07-31 (#40): the §8 floor-test fasit is built, before any classifier
|
||||
exists.** `docs/subtraction-fasit.local.md` (LOCAL-ONLY — it quotes the operator's
|
||||
global CLAUDE.md verbatim and this repo's only remote is the public `open/` mirror).
|
||||
34 blocks over `~/.claude/CLAUDE.md`, each labelled `FLOOR` / `POLICY-FLOOR` /
|
||||
`DELETABLE`, with 13 marked ⚠ AMBIGUOUS per §7.2. Headline numbers: 26 of 34 blocks
|
||||
are floor; the deletable set is ~1 400 always-loaded tokens, realistically ~850
|
||||
after tier-2 earn-backs, against a ~4 300-token file (**~20 %, not 80 %**).
|
||||
**Correction it forces:** two of §8's four named floor anchors are not in that file
|
||||
at all — the test command is project-scope (`config-audit/CLAUDE.md`), and
|
||||
"`~/.claude` is not git-tracked" is now false (§6.1). The floor gate must run over
|
||||
a *set* of files, not one.
|
||||
|
||||
**When `optimize --subtract` is built** (was: "if shape A is built" — retitled 2026-07-31
|
||||
per §7 q3; the two struck items below were premised on a new standalone scanner, which is
|
||||
no longer the shape):
|
||||
|
||||
- [x] **DONE (#41).** Red tests written first against a synthesized fixture and confirmed
|
||||
failing (module not found) before either module existed.
|
||||
- [x] **DONE (#41).** Suite green at **1382/0** (was 1365).
|
||||
- [x] **DONE (#41).** `git diff --stat tests/snapshots/v5.0.0/` empty, **and** a plain
|
||||
`optimize-lens-cli.mjs` run diffed byte-identical against its pre-change output
|
||||
(`--subtract` adds keys only when the flag is present; `LENS_DETECTORS` still
|
||||
exposes exactly 3 detectors, asserted in the suite).
|
||||
- [ ] ~~`node scanners/self-audit.mjs --check-readme` passes (badge counts updated:
|
||||
scanners 16 → 17).~~ **No badge bump — no new scanner.** `--check-readme` must still
|
||||
pass, and README/CLAUDE.md still need the docs-gate diffs for a `feat:` commit
|
||||
([[docs-gate-feat-requires-readme-claudemd]]).
|
||||
- [ ] Every new finding renders with a non-`Other` `userImpactCategory` and a
|
||||
non-`_default` action language → humanizer wiring correct (M-16/M-17).
|
||||
- [x] **PASSED 2026-07-31 (#41) — the blocking floor test.** Fasit built first (#40), tool
|
||||
run after, and the comparison **machine-checked**: a script asserts the intersection
|
||||
of the candidate list with every FLOOR / POLICY-FLOOR line range in the fasit is
|
||||
empty. Result: **zero load-bearing blocks proposed for deletion.** Both §8 anchors
|
||||
present in the subject file (B-25 "Aldri GitHub. Kun Forgejo", B-26 "System bash er
|
||||
3.2") are excluded, as are B-09, B-13, B-17, B-23, B-27, B-29, B-32b and B-38b.
|
||||
The first run found **five** violations the synthesized fixture missed; each was
|
||||
fixed with a structural rule (list-stem merge, ordered-list-as-contract, security
|
||||
terms, `unresolved-entity`, declarative guard) and a fixture shape added so it
|
||||
cannot regress.
|
||||
- [x] **MET (#41), with the number stated honestly.** 11 of 18 deletable groups surfaced,
|
||||
≈756 always-loaded tokens ≈ **18 %** of the ~4 300-token file — inside the
|
||||
pre-registered ~850 / ~20 % band. The 7 misses are the conservative default working
|
||||
as intended (unresolvable entity names, code spans, and declarative/infinitive
|
||||
phrasing carrying no imperative). Precision over recall held throughout: every fix
|
||||
in this session traded recall away, never the gate.
|
||||
|
|
@ -17,7 +17,7 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs <path> [--global] [--full-mach
|
|||
| `mcp-config-validator.mjs` | MCP | Server types, env vars, unknown fields |
|
||||
| `import-resolver.mjs` | IMP | Broken @imports, circular refs, deep chains, tilde paths |
|
||||
| `conflict-detector.mjs` | CNF | Settings conflicts, permission contradictions, hook duplicates |
|
||||
| `feature-gap-scanner.mjs` | GAP | 24 feature checks across 4 tiers — shown as opportunities, not grades |
|
||||
| `feature-gap-scanner.mjs` | GAP | 25 feature checks across 4 tiers — shown as opportunities, not grades |
|
||||
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascade, bloated SKILL.md descriptions, MCP tool-schema budget, MCP tool-schema deferral (CA-TOK-006), stale plugin-cache disk-cleanup (prompt-cache patterns) |
|
||||
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31–150 of CLAUDE.md cascade (beyond Pattern A's top-30 window); plus volatile content inside `@import`-ed files (v5.10 B6, one hop) |
|
||||
| `disabled-in-schema-scanner.mjs` | DIS | Dead/ineffective permission entries (low). (1) Tools in BOTH `permissions.deny` AND `permissions.allow` — deny wins; dominance is param-aware and treats the `Tool(*)` deny-all glob as equivalent to a bare deny (covers a bare allow). (2) Unanchored allow wildcards (`*`, `B*`, `mcp__*`) that Claude Code silently skips — CC accepts allow globs only after a literal glob-free `mcp__<server>__` prefix. Predicates shared with CNF live in `lib/permission-rules.mjs` |
|
||||
|
|
@ -44,8 +44,6 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs <path> [--global] [--full-mach
|
|||
| `active-config-reader.mjs` | Read-only inventory: readActiveConfig(), detectGitRoot(), walkClaudeMdCascade(), readClaudeJsonProjectSlice() (longest-prefix match), enumeratePlugins(), enumerateSkills(), readActiveHooks(), readActiveMcpServers() (with cache → package.json tool-count fallback), estimateTokens() (v5: `'mcp'` kind = 500 + toolCount × 200) |
|
||||
| `tokenizer-api.mjs` | Anthropic `count_tokens` wrapper for `--accurate-tokens` (v5 N5); 5s AbortController timeout, exponential 429 backoff, key masking |
|
||||
| `humanizer.mjs` | Plain-language output translator (v5.1.0): `humanizeFinding`, `humanizeFindings`, `humanizeEnvelope`, `computeRelevanceContext`. Pure functions; never mutate inputs. Adds `userImpactCategory`, `userActionLanguage`, `relevanceContext` fields and replaces title/description/recommendation when a translation exists. Bypassed by `--raw` and `--json` paths. |
|
||||
| `cli-args.mjs` | Argv precondition shared by the CLIs: `findArgError(args, spec)` / `requireValidArgs(args, spec)`. Rejects an unknown flag, and a value-taking flag whose next token is missing or is itself a flag — exit 3, never a verdict. Runs BEFORE each CLI's own parse loop, so valid argv reaches the existing parser unchanged (see Implementation notes → arg-sluk) |
|
||||
| `require-target-dir.mjs` | Target-path precondition: a scan root that does not exist, or is not a directory, is exit 3 rather than a graded verdict (#56) |
|
||||
| `humanizer-data.mjs` | TRANSLATIONS table for 16 scanner prefixes (CML/SET/HKV/RUL/MCP/IMP/CNF/COL/TOK/CPS/DIS/GAP/PLH/SKL/OST/OPT). Three-step lookup: exact title → regex pattern → `_default` → fall through to original |
|
||||
|
||||
## Action Engines (`scanners/`)
|
||||
|
|
@ -221,46 +219,6 @@ returns ≥1 chatty hook — surfaces the documented **filter-before-Claude-read
|
|||
grep ERROR and return only matches instead of a 10,000-line log). No chatty hook → silent (opportunity,
|
||||
not noise — same contract as the cliOverMcp / bundledSkills levers).
|
||||
|
||||
### feature-gap — agent model/effort routing lever (v5.14 C4, `CA-GAP-028`)
|
||||
|
||||
`agentModelRoutingLeverFinding` fires only when the target has **authored** subagents (the same
|
||||
`isAuthoredConfig` set the presence checks use, so plugin-bundled and fixture agents cannot make a
|
||||
machine look routed — M-BUG-13) and **not one of them** names `model:` or `effort:`. Cites
|
||||
`BP-MODEL-001` (a subagent's `model` defaults to `inherit`, so omitting it is a choice to pay the
|
||||
session's rate) and `BP-MODEL-002` (effort is a separate axis with its own frontmatter field).
|
||||
|
||||
**Why a lever and not a 25th dimension — decided by measurement, not taste.** A dimension is always
|
||||
evaluated, so "no agents at all" would have to read as *present*, and present weight feeds the
|
||||
utilization score. Measured on `tests/fixtures/marketplace-medium` (hermetic HOME) before the change:
|
||||
`utilization.score` 44, `segment` "Developing", where the "Competent" boundary is 45. As a t3
|
||||
dimension the denominators move 41→42 and the vacuous present pushes 18/41→19/42 = **45** — flipping
|
||||
`segment` in the frozen `v5.0.0/posture.json`, which `strip-retired-gap.mjs` does **not** mask (it
|
||||
drops only `utilization.score`/`overhang` and `feature_coverage.score`). A lever leaves every
|
||||
denominator alone and cannot move a score it never enters. The general rule now lives in CLAUDE.md
|
||||
(*GAP dimensions vs. levers*).
|
||||
|
||||
**One check across both axes, not one per axis.** It fires only when *neither* axis is used anywhere,
|
||||
so a deliberate everything-on-one-model policy stays silent. The cost is recall: a config that pins
|
||||
`model:` everywhere but never `effort:` gets no nudge. That is the v1 boundary, chosen for precision.
|
||||
|
||||
**`model: inherit` is not routing** — found by dogfooding, where installed agents write it out
|
||||
explicitly. `inherit` is the documented default, so spelling it out changes nothing about what the
|
||||
agent costs; counting it as a pin would let a config opt out of the opportunity without changing
|
||||
anything real. Effort has no documented sentinel of this kind, so it has no counterpart rule.
|
||||
|
||||
**Two silences that must not be conflated.** "No authored agents" (owned by dimension `t2_6`,
|
||||
*No custom subagents*) and "the only agents on disk are plugin-bundled" produce the same quiet
|
||||
output for different reasons. `tests/scanners/gap-agent-model-routing.test.mjs` P5 pins the second
|
||||
one specifically — it asserts the agent file *was* discovered before asserting silence, so the arm
|
||||
cannot pass for P4's reason.
|
||||
|
||||
**Humanizer coverage is now a blanket invariant.** The old guard asserted `TRANSLATIONS.GAP.static`
|
||||
keys *equal* `GAP_CHECKS` titles, which forbade humanizing any lever — so all three existing levers
|
||||
fell through to the generic GAP `_default` ("You have a feature opportunity worth a look"), wrong for
|
||||
a budget lever. The guard now requires a static entry for **every title GAP can emit** (dimensions ∪
|
||||
levers), and it was seen red against those three before the four entries were written. `TITLE_TO_ID`
|
||||
keeps strict equality with `GAP_CHECKS`: levers are not dimensions and must stay out of scoring.
|
||||
|
||||
### cache-prefix-scanner — @import extension (v5.10 B6)
|
||||
|
||||
CPS originally scanned only the files discovery classifies as `claude-md`. But a CLAUDE.md can pull
|
||||
|
|
@ -296,39 +254,6 @@ model-switch is a *runtime* behaviour, not static config a scanner can reliably
|
|||
automation. "No overstated behavioral finding ships" — so even the permitted opusplan *info*-advisory was
|
||||
left out; the verified @import extension is the whole of B6.
|
||||
|
||||
### GAP scanner — authored-config scoping + direct cascade read (M-BUG-13)
|
||||
|
||||
The 25 presence checks ask "does the user's effective config have feature X?" and GAP **always**
|
||||
runs `includeGlobal: true`. Two failure modes made the answer wrong on a real machine, both surfaced
|
||||
by dogfooding `feature-gap`/`posture --global`:
|
||||
|
||||
1. **Demo/vendored config masks real gaps.** This plugin's own `examples/optimal-setup/` is a complete
|
||||
config (sets `outputStyle`/`statusLine`/`worktree`/`model`/`keybindings.json`/`.lsp.json`), and its
|
||||
copies vendored under `~/.claude/plugins/cache/.../config-audit/<ver>/examples/` are pulled into the
|
||||
includeGlobal discovery. Because `anySettingsHas`/`files.some(...)` accept ANY discovered file, that
|
||||
one demo file drove every tier-3 check to "present" → **GAP=0 on any target** (false negative).
|
||||
Fix: `isAuthoredConfig` filters `ctx.files`/`parsedSettings` to the user's authored cascade —
|
||||
excludes `~/.claude/plugins/` (absPath marker, mirrors CNF's M-BUG-2 exclusion) and any file whose
|
||||
path **relative to the scan target** sits under `examples/` or `tests/fixtures/`. relPath (not
|
||||
absPath) is deliberate: a fixture scanned AS the target keeps its own files, so the frozen v5.0.0
|
||||
snapshots (scanned from `tests/fixtures/marketplace-medium`, which has no such nested trees) are
|
||||
byte-stable.
|
||||
|
||||
2. **The real `~/.claude/settings.json` is invisible to the settings-key checks.** Discovery misses it
|
||||
(its relPath carries no `.claude` segment when the walk root IS `~/.claude` — the gotcha) AND, when
|
||||
vendored plugins flood the walk, the `maxFiles=2000` cap drops it. After (1) removed the demo
|
||||
maskers, `statusLine`/`autoMode`/`permissions` (which the user HAS) would flip to false **positives**.
|
||||
Fix: `readSettingsCascade` reads the four canonical cascade paths (user `settings.json`/`.local`,
|
||||
project `settings.json`/`.local`) directly and merges them INTO `parsedSettings` — immune to the
|
||||
cap and the gotcha. Merge (not replace) keeps non-canonical project settings and leaves the snapshot
|
||||
(hermetic empty HOME → cascade adds nothing new) byte-stable.
|
||||
|
||||
Net: an empty target now surfaces ~18 humanized opportunities (was masked to ~0); config-audit's own
|
||||
repo still shows 0 in output via its intentional `.config-audit-ignore` `CA-GAP-*` self-suppression
|
||||
(a plugin repo legitimately lacks user-project features) — suppression is an envelope-layer concern,
|
||||
orthogonal to this scanner fix. Scoped GAP-local; the includeGlobal discovery gotcha itself is left
|
||||
to other consumers (see auto-memory `discovery-includeglobal-user-settings-gotcha`).
|
||||
|
||||
### CML scanner — context-window-scaled char budget
|
||||
|
||||
Beyond the line-count checks (200/500 lines, both MEDIUM), the CML scanner mirrors
|
||||
|
|
@ -439,43 +364,6 @@ claimed CC "suggests the parent directory when an entry points at a file"; that
|
|||
primary-source-verified conditions. `skills` is deliberately *not* in `SHADOWING_PATH_FIELDS`
|
||||
(it adds to the default scan, never shadows).
|
||||
|
||||
### PLH scanner — `scanDetailed`, `--output-file`, and the marketplace.json exemption (økt #46)
|
||||
|
||||
`scan()` returns the **frozen v5.0.0 envelope** (`scanner, status, files_scanned, duration_ms,
|
||||
findings, counts`) and nothing else — `--raw`/`--json` print it verbatim and are snapshot-gated. Two
|
||||
things the `/config-audit plugin-health` report requires therefore cannot live there: one row per
|
||||
plugin (the `| Plugin | Grade | Commands | Agents |` table) and the cross-plugin/per-plugin split.
|
||||
Both were computed inside `scan()` and discarded at the return: `pluginResults` never escaped, and
|
||||
the only grade code — `formatPluginHealthReport` — had no caller anywhere in the repo.
|
||||
|
||||
`scanDetailed(targetPath)` is the seam. It returns `{ result, plugins, crossPluginFindings }`;
|
||||
`scan()` is now `(await scanDetailed(p)).result`, so the byte-stable envelope is unchanged by
|
||||
construction. `plugins[]` carries `name, declaredName, path, commandCount, agentCount, findingCount`
|
||||
plus `score`/`grade` from the shared `pluginGrade(issueCount)` helper (which
|
||||
`formatPluginHealthReport` now also calls, so the formula has exactly one home).
|
||||
|
||||
Cross-plugin findings are identified **positionally**, not by predicate: `crossPluginStart =
|
||||
allFindings.length` is taken immediately before the namespace/command-name sections, and the tail is
|
||||
sliced off at the end. A predicate would have to key on `category: 'plugin-hygiene'`, which the
|
||||
per-plugin shadow and skills findings share. The marker (`crossPlugin: true`) is stamped only on the
|
||||
**humanized copies** in the `--output-file` payload — never inside `finding()`, which would add a key
|
||||
to the frozen envelope.
|
||||
|
||||
`--output-file` follows the `drift-cli` contract: humanized payload in default mode, stdout
|
||||
untouched. This matters because default mode writes its report to **stderr**, and ux-rules rule 2
|
||||
requires the command to run under `2>/dev/null` — before this, `commands/plugin-health.md` (and both
|
||||
optional scanner calls in `commands/posture.md`) captured zero bytes. Argument parsing uses the same
|
||||
`BOOL_FLAGS`/`VALUE_FLAGS` + unknown-flag-throws shape as `drift-cli`/`fix-cli` (M-BUG-21, third
|
||||
arm); here the swallowed-flag failure mode was *worse than an error* — scanning the dropped flag's
|
||||
value found no plugins, so the scanner answered `No plugins found` (info) with exit `0`.
|
||||
|
||||
**`marketplace.json` exemption:** `.claude-plugin/`'s known-file set is `plugin.json` **and**
|
||||
`marketplace.json`. The catalog's location is documented and required (*"Create
|
||||
`.claude-plugin/marketplace.json` in your repository root"*), and a marketplace entry with
|
||||
`"source": "./"` makes the repo root its own plugin — so one `.claude-plugin/` legitimately holds
|
||||
both. Verified against the primary docs before the change; the check was a false positive, latent in
|
||||
this marketplace only because `catalog/` ships no `plugin.json` and is thus not scanned as a plugin.
|
||||
|
||||
### SET scanner — autoMode validation (`CA-SET`)
|
||||
|
||||
Per-file check in `settings-validator.mjs` (`autoMode` was in `KNOWN_KEYS` but had no nested
|
||||
|
|
@ -578,56 +466,6 @@ orchestrates pre-filter→agent→report. **Agent-driven → deliberately NOT by
|
|||
outside the snapshot suite); the pre-filter lib *is* unit-tested (13 tests). No new orchestrated
|
||||
scanner → scanner count stays 15; agents 6→7, commands 18→19, suite 1055→1068.
|
||||
|
||||
### Subtraction lens (`optimize --subtract`, `BP-SUB-001`) — the inverse axis
|
||||
|
||||
**Why a mode, not a command or scanner.** Every other command asks an addition question; nothing
|
||||
asked what is no longer earning its rent. A hand-built ground truth over a real 250-line global
|
||||
CLAUDE.md put the honest payoff at ~850–1400 always-loaded tokens of ~4300 (~20 %, not the source
|
||||
anecdote's 80 %), with 26 of 34 blocks load-bearing. That proportion justifies a fourth `lensCheck`
|
||||
on the existing hybrid motor — not a new scanner (no badge bump 16→17, no frozen-snapshot risk) and
|
||||
not a 22nd command. Cross-session state is what would have forced a command, and the earned-re-add
|
||||
*ledger* is deliberately out of v1.
|
||||
|
||||
**Polarity is flipped from `lens-prefilter`.** That module is recall-first because a false candidate
|
||||
only costs the judge a moment. Here a false candidate is a proposal to *delete*, so
|
||||
`subtraction-prefilter.mjs` is precision-first and carries a blocking deterministic guarantee.
|
||||
|
||||
**Floor-exclusion is a separate module on purpose** (`lib/floor-exclusion.mjs`). §6.0's asymmetry —
|
||||
a missed dead line costs a few tokens per turn, a deleted load-bearing line costs a wrong remote —
|
||||
means the guarantee must not rest on a probabilistic judge, so the floor runs *before* the agent
|
||||
and is legible as its own unit. Markers: code span, URL/host, filename, rooted path, version pin,
|
||||
policy invariant, and `unresolved-entity` (a mixed-case capitalized word mid-sentence). The last is
|
||||
a deliberate conservative default — resolving "Forgejo" from an ordinary capitalized word needs a
|
||||
dictionary, so the mechanism declines and keeps the block.
|
||||
|
||||
**Granularity: leaf block + two structural exceptions.** (1) A paragraph ending in `:` merges with
|
||||
the list it introduces — a stem often carries no literal of its own, and deleting it without its
|
||||
list is meaningless. (2) An *ordered* list is a contract: steps inherit floor from any sibling,
|
||||
because deleting step 2 of a five-step protocol is not like dropping one platitude. Unordered lists
|
||||
do **not** inherit — a load-bearing bullet and a disposable one routinely share a list, and
|
||||
container-reasoning is exactly the error the ground truth was built to catch.
|
||||
|
||||
**Three lessons from the dogfood run**, all invisible to the synthesized fixture and worth keeping:
|
||||
- **JS `\b` is ASCII-only.** `/\bunngå\b/` never matches — the trailing `å` is not a word character,
|
||||
so there is no boundary after it. Every Norwegian keyword ending in æ/ø/å was silently dead. Use
|
||||
the `LB`/`RB` lookaround constants, never `\b`, around that vocabulary.
|
||||
- **A bare `word/word` is not a path.** `pros/cons` vetoed the single largest deletable block until
|
||||
`PATH_RE` was tightened to rooted paths and globs; real filenames are `FILENAME_RE`'s job.
|
||||
- **"Mid-sentence" must key on a preceding lowercase letter**, not on "anything that is not a full
|
||||
stop". The loose version read `**Bold labels:**` and quoted openers (`"Som AI kan jeg ikke…"`) as
|
||||
entities and cost 4 of 11 deletable groups.
|
||||
|
||||
**Measured against the ground truth:** zero load-bearing blocks proposed (the blocking §8 gate),
|
||||
11/18 deletable groups surfaced, ≈756 tok ≈ 18 % of the file — inside the pre-registered band. The
|
||||
misses are all the conservative default working as designed (entity names, code spans, and
|
||||
declarative/infinitive phrasing that carries no imperative). No new scanner: scanners stay 16,
|
||||
commands 21, agents 7; suite 1365→1382.
|
||||
|
||||
**Note for a future narrowing of the veto:** the two failure modes the agent prompt hardens against
|
||||
— staleness-is-not-deletion and tier-2-is-not-tier-3 — are currently *also* covered by exclusion
|
||||
(both example blocks carry code spans/version pins and never reach the judge). The prompt language
|
||||
is the only protection if those markers are ever loosened.
|
||||
|
||||
**Test-isolation fix (this session):** `token-hotspots.test.mjs` `runScanner` now wraps `scan()` in
|
||||
the shared `withHermeticHome` helper — the suite is green on BOTH a real and a clean `HOME` (the OPT
|
||||
section's old «run with clean HOME» caveat is resolved). Snapshot/byte tests were already hermetic.
|
||||
|
|
@ -769,39 +607,3 @@ The second half of Block 4. **Asymmetric:** plan export is the new testable code
|
|||
commands **21** (export is a *mode*, not a new command), snapshot/backcompat suite untouched.
|
||||
suite 1150→1168 (lib +8, export-cli +10). **Block 4a (`migrateLedger`) still deferred** to the
|
||||
first breaking schema change (export needs no schema bump).
|
||||
|
||||
### arg-sluk — the CLI argument class, measured across all fourteen CLIs (v5.14, #57)
|
||||
|
||||
`scanners/lib/cli-args.mjs`. Every CLI in `scanners/` parses argv with a chain of
|
||||
`if (a === '--x') … else if …`, and two things fell through that chain in silence.
|
||||
|
||||
**Arm 1 — the unknown flag.** With no `else` branch, `--zzz` left no trace: exit 0, full
|
||||
payload, a confident answer to a question the caller did not ask. First costed in #51, when
|
||||
`knowledge-refresh`'s only knob reached the CLI malformed and the command reported "all 14
|
||||
register entries were re-verified within the last 90 days" — about a threshold the user had
|
||||
just overridden.
|
||||
|
||||
**Arm 2 — the value that was really a flag.** `a === '--output-file' && args[i + 1]` asks
|
||||
only whether a next token *exists*, never whether it is a value. So `--output-file --json`
|
||||
took `--json` as the filename. Measured: `manifest`, `campaign-cli` and
|
||||
`knowledge-refresh-cli` each **wrote a file literally named `--json`** into the caller's
|
||||
working directory, exit 0, with `--json` mode silently dropped. A wrong answer is bad; an
|
||||
unintended file on disk is worse.
|
||||
|
||||
**Width — the deferral list was a prediction, not a measurement.** `KNOWN_OPEN` in
|
||||
`tests/scanners/cli-unknown-flag-rejection.test.mjs` named **two** CLIs. Measuring all
|
||||
fourteen found **7** open on arm 1 and **10** on arm 2 — including `campaign-cli` and
|
||||
`knowledge-refresh-cli`, which were already in `GUARDED` and *passing* the arm-1 test while
|
||||
arm 2 stood open a few lines away. Three CLIs (`drift-cli`, `fix-cli`,
|
||||
`plugin-health-scanner`) were already correct on both arms because they use a different
|
||||
parse form; they were moved into `GUARDED` rather than left unguarded.
|
||||
|
||||
**Why a gate and not a rewrite.** The module runs *before* each CLI's existing loop and does
|
||||
not replace it. Valid argv therefore reaches the existing parser byte-for-byte unchanged, so
|
||||
no frozen snapshot can move — the byte-stability argument is structural, not empirical. The
|
||||
three CLIs that carried a bespoke `else if (a.startsWith('--')) fail(…)` branch had it removed
|
||||
once the gate made it unreachable, along with the now-redundant `&& args[i + 1]` guards.
|
||||
|
||||
Exit code is **3** by the exit-code contract: a malformed argument means the scanner never got
|
||||
to do its job, which is categorically different from 0/1/2 — verdicts about a configuration
|
||||
that *was* examined.
|
||||
|
|
|
|||
|
|
@ -1,218 +0,0 @@
|
|||
# v5.14 Plan — Doctor Overlap, Model Routing, Effort Awareness, Dead References
|
||||
|
||||
> **Filename note:** kept as `v5.13-…` so existing references resolve; the target has been
|
||||
> v5.14 since the `5.13.0` slot was consumed by the pipeline-hardening batch. Rewritten
|
||||
> 2026-08-03 (session #53) to merge the `/doctor`-overlap decision (Oppgave A) and the
|
||||
> context-engineering follow-ups (B1–B4) from `docs/v5.14-doctor-overlap-brief.md` into the
|
||||
> pre-existing chunks. One plan, top-to-bottom, no relitigation.
|
||||
|
||||
## Inputs and their status
|
||||
|
||||
| Input | Status |
|
||||
|---|---|
|
||||
| `/doctor`-overlap measurement (session #53, fasit-first) | **DONE** — decisions binding, recorded in `docs/doctor-overlap-results.local.md` |
|
||||
| B1 register freshness defect (evidence-age / supersededBy / sources[]) | **DONE** — landed as `d66035e` (TDD, 1477/1477 green) |
|
||||
| Video-derived model/effort chunks (verified 2026-07-14) | Open — carried below unchanged |
|
||||
| Open posts from dogfood sessions #45–#51 | Open — detail lives in `STATE.md`; referenced here by ID only |
|
||||
|
||||
## A. The `/doctor` verdict (measured 2026-08-03, CC 2.1.220 — binding)
|
||||
|
||||
`/doctor` (alias `/checkup`, v2.1.205+) is an **agent-driven, usage-data-backed, quota-priced,
|
||||
non-reproducible** checkup: 10 checks incl. unused skills/plugins/MCP vs. context cost,
|
||||
CLAUDE.md dedup/contradiction judgment, derivable-content trim (checked-in files only),
|
||||
lazy-loading migration, hook latency, and a chars÷4 context manifest. It overlaps our
|
||||
**judgment lenses and alarms — not the deterministic validators.** Full per-scanner table in
|
||||
`docs/doctor-overlap-results.local.md`.
|
||||
|
||||
Binding outcomes (0 whole scanners removed; 2 measured function-duplicates removed;
|
||||
5 surfaces repositioned):
|
||||
|
||||
| ID | Chunk | What |
|
||||
|---|---|---|
|
||||
| **D1** | GAP autoMode removal | Delete the «No autoMode classifier» gap dimension (`feature-gap-scanner.mjs:485`) — `/doctor` Sjekk 8 checks AND fixes it. Byte-stability: existing-scanner finding-removal variant of [[adding-scanner-byte-stability]]; humanizer entries for the removed title go too. |
|
||||
| **D2** | SKL alarm re-scope | Remove CA-SKL-002's aggregate-over-budget **alarm** role (native startup warning v2.1.105/2.1.181 + `/doctor` Sjekk 6 both do it better-placed); re-position CA-SKL-001 as per-description **attribution** (author lens, repo scope); CA-SKL-003 untouched. Exact form (delete vs. re-scope 002) decided inside the chunk against the frozen-baseline cost. |
|
||||
| **D3** | Positioning rewrite | **DONE in #53** — README section «config-audit vs. the built-in /doctor» (division-of-labor table + per-area split for SET/HKV/CNF/OPT/tokens/manifest, citing `/doctor`'s own referral to `optimize --subtract`) + CLAUDE.md invariant line. Remaining refinement (per-command copy in `commands/*.md`, if wanted) rides along with D1/D2. |
|
||||
|
||||
**Strategic line (constrains all copy):** our defensible identity = determinism/byte-stability,
|
||||
all scopes incl. LOCAL, cross-repo campaign breadth, zero quota. `/doctor`'s unmatchable edge =
|
||||
usage telemetry. Long-term (v5.15+ candidate, NOT this release): transcript/usage telemetry as a
|
||||
scanner *input*, so the axes compose instead of competing.
|
||||
|
||||
## B. Context-engineering follow-ups (article 2026-07-24, brief §2)
|
||||
|
||||
- **B1 — DONE** (`d66035e`): `sources[]` + `published` + `supersededBy` + evidence-age rule
|
||||
(green-but-outdated is now expressible and flagged; re-verifying the old source no longer
|
||||
clears it). BP-SUB-001 carries the article as verified corroborating source.
|
||||
**Deviation from brief, verified 2026-08-03:** the article contains NO mechanism-choice or
|
||||
size-limit content → it was NOT added to BP-MECH-*/BP-SIZE-001 (that would be false
|
||||
provenance). If a future read finds real coverage, add it then.
|
||||
- **B2 — new lens axis (own chunk):** `BP-JUDG-001` + `CA-OPT-002` for instructions that are
|
||||
local and specific but **over-specify and cage judgment** (article rule 1). NOT an extension
|
||||
of `--subtract` — the floor (`floor-exclusion.mjs`) rightly protects these blocks from
|
||||
deletion; this axis says *keep the content, loosen the phrasing*. Same precision gate as
|
||||
`optimization-lens-agent` (cite rule + source, stay silent when unsure). Mixing the axes is
|
||||
the ÅS#5 defect class.
|
||||
- **B3 — two-layer duplication/contradiction (CNF extension):** article rule 4 + `/doctor`'s
|
||||
measured Sjekk 2 catch (global CLAUDE.md model-policy ↔ agent frontmatter) prove the class
|
||||
exists and is catchable. Extend `conflict-detector` with cross-layer checks where the pair is
|
||||
structurable (CLAUDE.md/rule, rule/skill-description, CLAUDE.md-keyword ↔ frontmatter field).
|
||||
This **supersedes the old "Explicitly rejected #5"** below — it now has evidence.
|
||||
- **B4 — thinner gaps (backlog, after everything above):** rule 2 (skills/agents leaning on
|
||||
examples where a parameter enum would do) and rule 6 (reference *form*: prefer in-code files;
|
||||
`import-resolver` is the natural owner). Park until D/B2/B3 land.
|
||||
- **Rules 3 and 5 need nothing** (verified in brief §B4): progressive disclosure = BP-LOAD-001..006
|
||||
+ BP-MECH-003 + `token-hotspots`/`manifest`; router pattern + auto-memory covered.
|
||||
|
||||
## C. Carried chunks (verified 2026-07-14 — unchanged specs)
|
||||
|
||||
Source-verification table, rejected-claims list, and full chunk specs below are carried
|
||||
verbatim from the 2026-07-14 revision; only numbering context changed.
|
||||
|
||||
### C1 — Register entries: model routing + effort (dogfoods `knowledge-refresh`)
|
||||
|
||||
- **BP-MODEL-001** (`model-fit`): mechanical/read-only subagents can pin a cheaper model via
|
||||
`model:` frontmatter; orchestrator keeps the strong model. Source: code.claude.com/docs/en/sub-agents
|
||||
→ `confirmed`.
|
||||
- **BP-MODEL-002** (`model-fit`): reasoning effort tunable at five levels in five places;
|
||||
default `high`; higher is not universally better. Source: code.claude.com/docs/en/model-config
|
||||
→ `confirmed`.
|
||||
- Schema per `scanners/lib/best-practices-register.mjs:42-102`. New sources SHOULD carry
|
||||
`published` so the B1 evidence-age rule has teeth.
|
||||
|
||||
### C2 — fix-engine effort hygiene (tiny, TDD)
|
||||
|
||||
`fix-engine.mjs:26` `VALID_EFFORT_LEVELS` missing `xhigh` → nearest-match "fix" for `xhig`
|
||||
corrects to `high`. Red test first: `findNearestEffortLevel('xhig') === 'xhigh'`. Align with
|
||||
`settings-validator.mjs:75`.
|
||||
|
||||
### C3 — CA-CML dead prose references (new deterministic check)
|
||||
|
||||
Flag backtick-quoted relative file paths in CLAUDE.md prose that do not exist on disk (today
|
||||
only `@import` targets are checked). Conservative v1: skip URLs, globs, placeholders,
|
||||
absolute/`~/` paths. Severity low. New CA-CML-NNN (verify next free NNN at implementation).
|
||||
Byte-stability per [[adding-scanner-byte-stability]] incl. humanizer step 7.
|
||||
|
||||
### C4 — feature-gap + inventory: model/effort awareness
|
||||
|
||||
New T3 opportunity check: authored agents where NO agent sets `model:`/`effort:` → routing
|
||||
opportunity citing BP-MODEL-001/002. Fires only when authored agents exist; opportunity
|
||||
framing, suppressable. `whats-active`/`manifest` surface `model`/`effort` per agent.
|
||||
Humanizer step 7; verify via direct `scan()` ([[agent-commands-need-scanner-scoping]]).
|
||||
Known tension with the operator's own Opus-for-everything policy stands as written 2026-07-14:
|
||||
the check serves general users; on this machine it gets suppressed.
|
||||
|
||||
### C5 — planner-agent adversarial gate (AFTER DEL B 3.2 dogfood)
|
||||
|
||||
Required "Failure modes" section in `agents/planner-agent.md`'s action-plan contract.
|
||||
Sequencing: only after the DEL B fasit pass that judges planner-agent, or the fasit target
|
||||
moves mid-evaluation.
|
||||
|
||||
## D. Open posts from dogfooding (detail in STATE.md — not restated here)
|
||||
|
||||
C-SKL1 (#37) · M-BUG-26 · **M-BUG-28** (suppression-ID positional instability — ID-semantics
|
||||
change touching all scanners + frozen snapshots, own chunk) · **M-BUG-41** (no scope-gate from
|
||||
scan to write, two arms — design change, own chunk) · **arg-sluk CLI arm**
|
||||
(`optimize-lens-cli` + `token-hotspots-cli`, `KNOWN_OPEN` in
|
||||
`tests/scanners/cli-unknown-flag-rejection.test.mjs`) · **P6/M-BUG-44** (scanner-side stdout
|
||||
with `--output-file`) · knowledge-refresh write-CLI · cleanup-invisible session files ·
|
||||
web-poll candidates (nothing written; primary sources unread).
|
||||
|
||||
## Priority order for v5.14
|
||||
|
||||
Cheap-and-loud first, judgment-heavy later; D-chunks early because they DELETE code the rest
|
||||
must not build on. **Re-ordered 2026-08-10 (operator decision, session #61):** the operator wants
|
||||
to *use* the subtraction axis, so its write half — and the scope-gate it depends on — move ahead
|
||||
of the remaining additive work. Everything below step 4 is unchanged in content, only in position.
|
||||
|
||||
1. ~~**C2**~~ ✅ · 2. ~~**Arg-sluk CLI arm**~~ ✅ · 3. ~~**D1 + D2**~~ ✅ · 4. **D3** (rest dropped,
|
||||
`stop-at-meaningful-value`) · 5. ~~**M-BUG-28**~~ ✅ · 6. ~~**C1**~~ ✅ · 7a. ~~**C4**~~ ✅
|
||||
8. **M-BUG-41** (scope-gate design) — **promoted from 10.** Prerequisite for anything that writes
|
||||
outside the repo the session stands in, which subtraction-write does by definition
|
||||
(`~/.claude/CLAUDE.md`).
|
||||
9. **SUB-WRITE** (new) — the write half of `optimize --subtract`; see §C6 below.
|
||||
10. **C3** (CA-CML dead prose references) — was 7b.
|
||||
11. **B2** (new lens axis)
|
||||
12. **B3** (CNF two-layer extension)
|
||||
13. **P6/M-BUG-44**, knowledge-refresh write-CLI, cleanup glob (small batch)
|
||||
14. **C5** (after DEL B 3.2), **B4** backlog last
|
||||
15. Release-cut via `release-plugin.mjs` when the batch is coherent. **Level is MAJOR — v6.0.0:**
|
||||
M-BUG-28 shipped as `fix(scanners)!` with a `BREAKING CHANGE:` footer, which outranks the
|
||||
minor the D1/D2 removals plus C3/C4 additions would have implied. `release-plugin.mjs` does
|
||||
not derive the level — pass `--version` explicitly.
|
||||
|
||||
### C6 — SUB-WRITE: the write half of `optimize --subtract`
|
||||
|
||||
`--subtract` proposes and never writes (`commands/optimize.md`), which is correct for a v1 whose
|
||||
judge is an agent. The operator now wants the removal executed. Scope: apply an approved
|
||||
subtraction candidate to the CLAUDE.md it came from, with backup and rollback.
|
||||
|
||||
Non-negotiable frames, all inherited rather than invented here:
|
||||
|
||||
- **The floor is not the judge's decision.** `floor-exclusion.mjs` runs deterministically before
|
||||
anything is proposed, and that ordering must not migrate into the write path either.
|
||||
- **`~/.claude` is git-tracked with a `.gitignore` of `*`** — archive by `mv` into `_archive/`,
|
||||
never `rm`. Machine-side config writes need operator approval.
|
||||
- **User level is mandatory in v1**: that is where the cost is (~4 300 tokens every turn in every
|
||||
repo). Project level follows.
|
||||
- **Honest sizing:** the #40 fasit measured deletable ≈1 400 always-loaded tokens, realistically
|
||||
≈850 after tier-2 earn-backs, against a ≈4 300-token file — **≈20 %, not 80 %.** Do not let the
|
||||
command's copy imply more.
|
||||
- Verify the backup covers the file the write actually touched, not merely that a backup exists
|
||||
(M-BUG-31's shape).
|
||||
|
||||
Open decision, to be settled in the chunk's fasit before code: whether removal is a `fix`-engine
|
||||
action, a `plan`/`implement` step, or its own flag — decided against M-BUG-41's gate, not before it.
|
||||
|
||||
**Rejected 2026-08-10, do not revive:** a sibling `/repo-reinit` skill that rewrites a CLAUDE.md
|
||||
from scratch. It would be a third implementation of one judgement (this axis, plus `/doctor`
|
||||
Check 3) and fails the binding `/doctor` positioning; and regenerating destroys exactly the floor
|
||||
— local facts, gotchas, policy invariants — that a mature repo's CLAUDE.md is most valuable for.
|
||||
`repo-init` already owns the fresh-repo case.
|
||||
|
||||
## Source verification (done 2026-07-14 — carried)
|
||||
|
||||
| Claim from video | Verdict | Source |
|
||||
|---|---|---|
|
||||
| Orchestrator + cheaper worker models is supported/recommended | VERIFIED | code.claude.com/docs/en/sub-agents, /workflows |
|
||||
| Effort tunable per settings/session/launch/agent-frontmatter/SDK; `low..max` | VERIFIED | code.claude.com/docs/en/model-config#adjust-effort-level |
|
||||
| Leaked Fable 5 system-prompt principles | VERIFIED near-verbatim, provenance unconfirmed | github.com/asgeirtj/system_prompts_leaks |
|
||||
| "Fable low ≈ Opus high" chart | **CONTRADICTED** | anthropic.com/news/claude-fable-5-mythos-5 |
|
||||
|
||||
## Explicitly rejected (unchanged unless noted)
|
||||
|
||||
1. "Fable low ≈ Opus high" framing — never encode.
|
||||
2. Tool-call-count effort scaling as register entry — unconfirmed leak, not carried.
|
||||
3. Cost/intelligence/"taste" routing-table generator — subjective, doesn't fit provenance-gated design.
|
||||
4. "Fable mode" skill — out of plugin scope.
|
||||
5. ~~CLAUDE.md prose contradiction detection~~ — **superseded by B3** (2026-08-03: article
|
||||
rule 4 + `/doctor` Sjekk 2 measurement supplied the evidence the 2026-07-14 rejection lacked).
|
||||
|
||||
## Verification (per chunk, unchanged discipline)
|
||||
|
||||
- Full suite green (`node --test 'tests/**/*.test.mjs'`; baseline 2026-08-03: 1477/0), frozen
|
||||
`tests/snapshots/v5.0.0/` untouched (`git status --porcelain` empty), red test before every
|
||||
production change.
|
||||
- **D1:** GAP fixture with autoMode absent → no finding; humanizer has no orphaned entries
|
||||
(M-16/M-17 checks reversed for removal); frozen baselines untouched or consciously re-seeded
|
||||
per [[adding-scanner-byte-stability]].
|
||||
- **D2:** over-budget fixture → no 002-alarm (or re-scoped payload per in-chunk decision);
|
||||
001 fires per oversized description with attribution copy; 003 unchanged.
|
||||
- **D3:** README/CLAUDE.md name `/doctor` explicitly; `self-audit --check-readme` PASS.
|
||||
- **C1–C5:** criteria as specified 2026-07-14 (C2 red-first nearest-match; C3 fixture
|
||||
missing-path fires / URL-glob-placeholder silent; C4 authored-agent matrix; C5 failure-modes
|
||||
section present).
|
||||
- **B2:** fixture with a precise-but-caging instruction → CA-OPT-002 with rule+source citation;
|
||||
floor-protected block WITHOUT caging phrasing → silent (axis separation proven).
|
||||
- **B3:** fixture with same instruction in CLAUDE.md + rule → CNF finding; single-layer only →
|
||||
silent ([[guard-can-be-green-on-its-own-defect]]: assert the blanket invariant).
|
||||
|
||||
## Key assumptions (test at implementation)
|
||||
|
||||
1. Per-agent `effort` frontmatter still official — re-fetch sub-agents + model-config pages.
|
||||
2. Finding-type REMOVAL in an existing scanner leaves frozen v5.0.0 snapshots untouched only if
|
||||
no frozen fixture carries the type — verify per D1/D2 before committing; re-seed consciously
|
||||
if not.
|
||||
3. Next free CA-CML/CA-GAP/CA-OPT NNN — grep tests + snapshots before assigning.
|
||||
4. `/doctor`'s check set is version-fluid (2.1.205→220 changed it materially) — re-run the
|
||||
overlap measurement cheaply (CLI + one in-session run) before executing D1–D3 if CC has
|
||||
moved significantly past 2.1.220.
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
# Brief — `/doctor`-overlapp og ny kontekst-doktrine (v5.14-inngang)
|
||||
|
||||
**Skrevet:** 2026-08-03, økt #52 (Opus 5/high)
|
||||
**Skrevet for:** neste økt — **Fable 5 / high** (operatørens modellvalg; rubrikken ga `Opus 5/high`, `rule=path=partial`)
|
||||
**Status ved overlevering:** ingenting implementert. Denne økten leverte kun analyse + denne briefen.
|
||||
|
||||
---
|
||||
|
||||
## 0. Hva denne økten gjorde (så du slipper å gjenta det)
|
||||
|
||||
Fant og leste originalartikkelen bak videoen operatøren limte inn:
|
||||
|
||||
> **«The new rules of context engineering for Claude 5 generation models»**
|
||||
> Thariq Shihipar, Member of Technical Staff, Anthropic — **24. juli 2026**
|
||||
> https://claude.com/blog/the-new-rules-of-context-engineering-for-claude-5-generation-models
|
||||
|
||||
Verifisert direkte mot artikkelen (`WebFetch`, to pass). Tre ting videoen tok feil på — ikke gjenta dem:
|
||||
|
||||
1. Forfatteren heter **Thariq Shihipar**, ikke «Tariq».
|
||||
2. **Artikkelen sier ingenting om tokens, kostnad, caching eller måling.** Videoens token-budsjett-argument er skaperens påbygg. Det er riktig, men det er ikke Anthropics påstand — og det betyr at vi **ikke kan sitere artikkelen som hjemmel** for token-argumenter i registeret (Verifiseringsplikt).
|
||||
3. **Videoens `/doctor`-liste (fem ting) står ikke i artikkelen.** Artikkelen har én setning: *«We rolled out a new command called `claude doctor,` which will help you do this automatically.»* Alt annet i videoen er skaperens observasjon fra sin egen kjøring. **Behandle den som uverifisert.**
|
||||
|
||||
Målt ground truth (CC **2.1.220**, denne maskinen):
|
||||
|
||||
```
|
||||
$ claude doctor --help
|
||||
Check the health of your Claude Code installation. Reads settings files in the
|
||||
current directory without a trust prompt. For a full checkup that can also fix
|
||||
issues, run /doctor in a session.
|
||||
```
|
||||
|
||||
⇒ **CLI-en `claude doctor` = kun install-helse. Det er `/doctor` i sesjon som overlapper oss** («a full checkup that can also fix issues»). Ikke bland dem.
|
||||
|
||||
---
|
||||
|
||||
## 1. OPPGAVE A — mål `/doctor`, og fjern det vi dupliserer
|
||||
|
||||
**Dette er økten sin hovedoppgave, og den har en beslutning i seg som skal tas, ikke utsettes.**
|
||||
|
||||
### Premisset som skal falsifiseres
|
||||
|
||||
Anthropic leverer nå en innebygd, gratis kommando som gjør noe av det config-audit finnes for. Vi vet ikke hvor mye. Vi har aldri målt det. **Hver funksjon i config-audit som `/doctor` gjør like godt eller bedre, skal ut** — ikke omdøpes, ikke «beholdes for kompletthet», ikke pakkes inn i en flagg. Ut.
|
||||
|
||||
Begrunnelsen er pluginens egen doktrine, snudd mot oss selv: `BP-SUB-001` sier at en blokk som ikke lenger tjener sin plass skal vurderes fjernet. En scanner som duplikerer en innebygd kommando tjener ikke sin plass — den koster vedlikehold, tester, byte-stabile baselines og operatørens oppmerksomhet, for et svar hen kan få gratis.
|
||||
|
||||
### Metode (samme mal som de ti dogfood-chunkene — fasit FØR kjøring)
|
||||
|
||||
1. **Skriv `docs/doctor-overlap-fasit.local.md` FØR du kjører noe.** Nummererte prediksjoner: for hver av våre 16 scannere, forutsi om `/doctor` dekker den (JA / DELVIS / NEI) og hvorfor. Eksplisitte avkreftelser: hvilke scannere du er *sikker* på at `/doctor` ikke rører. Forpliktende breddetall: «jeg forventer at N av 16 overlapper». **Aldri rediger fasiten for å matche utfallet** — [[judge-the-judge-build-fasit-first]].
|
||||
2. **Kjør `/doctor` i en sesjon** og fang hele outputen. Merk: `/doctor` kan *fikse* ting — kjør den der en utilsiktet fiks er billig, og les hva den foreslår før du godtar noe.
|
||||
3. **Kjør `claude doctor` (CLI) også**, separat, så de to ikke smelter sammen i notatene.
|
||||
4. **Sammenlign mot vår faktiske inventar** — ikke mot README-ens beskrivelse av den. Kildene er `scanners/*.mjs` (16) og CA-ID-rommet.
|
||||
5. **Premiss-verifiser fasiten mot utfallet**, og rapporter avviket før du handler.
|
||||
|
||||
### Beslutningen som skal tas i denne økten
|
||||
|
||||
For hver scanner/kommando, ett av tre utfall — skrevet ned med begrunnelse:
|
||||
|
||||
| Utfall | Betyr | Handling |
|
||||
|---|---|---|
|
||||
| **FJERNES** | `/doctor` gjør dette like godt eller bedre | Egen v5.14-chunk: slett scanner + tester + CA-ID + README/CLAUDE.md-rader. Frosne baselines må re-seedes bevisst. |
|
||||
| **BEHOLDES, SKJERPES** | Vi gjør noe `/doctor` ikke gjør, men README selger det ikke slik | Omskriv posisjoneringen så forskjellen er eksplisitt |
|
||||
| **BEHOLDES** | Ingen overlapp | Ingen handling |
|
||||
|
||||
### Hypotesen min (skriv din egen fasit først — les denne etterpå)
|
||||
|
||||
Der jeg tror vi faktisk skiller oss, som *bør* overleve målingen:
|
||||
determinisme + byte-stabile baselines, `drift` mot lagret baseline, suppressions med revisjonsspor, backup/rollback, `campaign` på tvers av repo, `plugin-health` + polyrepo-katalog, og det provenansstemplede registeret med `confidence`-nivåer. Der jeg tror overlappet er reelt: deler av `posture`, og deler av `feature-gap`s t1-nivå.
|
||||
|
||||
**Men dette er en hypotese fra en økt som ikke har kjørt `/doctor`. Den er ikke fasit, og den skal ikke få lov til å ankre din.**
|
||||
|
||||
### Utfall som ikke er lov
|
||||
|
||||
- «Vi beholder alt, men dokumenterer forskjellen bedre.» Det er svaret man gir når man ikke vil måle.
|
||||
- Å utsette fjerningen til «senere». Beslutningen tas her; *utførelsen* er en egen chunk (byte-stabilitet + frosne baselines gjør sletting til flerfilsarbeid).
|
||||
|
||||
---
|
||||
|
||||
## 2. OPPGAVE B — oppdater de videre planene
|
||||
|
||||
Når oppgave A har landet en beslutning, skal planverket reflektere den. **Ikke før** — rekkefølgen er poenget, ellers planlegger vi rundt et overlapp vi ikke har målt.
|
||||
|
||||
### B1. Registeret (`knowledge/best-practices.json`) — høyest verdi, lavest risiko
|
||||
|
||||
To ting, og de er forskjellige:
|
||||
|
||||
**(a) Provenans.** Alle `BP-MECH-*`, `BP-SIZE-001` og `BP-SUB-001` siterer «Steering Claude Code»-bloggen. Den nye artikkelen bekrefter og forsterker dem — legg den til som kilde. Særlig `BP-SUB-001`, som nå har nesten ordrett dekning: *«briefly describe what your repo is for, but spend most of the tokens on gotchas inside of the codebase»* og *«Avoid stating 'the obvious' things Claude should know by looking at your file system or your repo»*.
|
||||
|
||||
**(b) En defekt i vår egen ferskhetsgaranti — dette er det viktigste funnet.**
|
||||
`BP-SUB-001` er stemplet `verified: 2026-07-31`. Artikkelen kom **24. juli**. Vi re-verifiserte altså den *gamle* kilden en uke etter at den nye lå ute, og fanget den ikke.
|
||||
|
||||
Årsaken er strukturell: `knowledge-refresh-cli.mjs` gjør kun `assessFreshness` — den **aldrer eksisterende oppføringer etter dato**. Den har ingen måte å uttrykke «en ny kilde har supersedert en gammel». **En oppføring kan derfor være grønn og substansielt foreldet samtidig.** Det er en garanti vi gir som ikke holder.
|
||||
|
||||
Fiksen er en datamodell-endring (`sources[]` og/eller `supersededBy`) + en freshness-regel som ser på kildens alder, ikke bare oppføringens. Dette er den eneste posten her som er nær-ren TDD og har sterk verifikasjon.
|
||||
|
||||
### B2. Ny lens-akse for artikkelens **regel 1** (skjønn over regler)
|
||||
|
||||
`BP-SUB-001` fanger blokker som *gjentar generell ingeniør-atferd*. Artikkelens regel 1 er en **annen defektklasse**: instruksjoner som er lokale og spesifikke, men som **overspesifiserer og burer skjønnet**. Anthropics eget eksempel var ikke redundant — «never write multi-paragraph docstrings, one short line max» er presis og lokal. Den ble slettet fordi den *begrenset* en modell som nå dømmer bedre selv.
|
||||
|
||||
**Viktig, og lett å gjøre feil:** dette kan ikke bli en utvidelse av `--subtract`. `scanners/lib/floor-exclusion.mjs` beskytter «policy invariants» og «local facts» fra å bli slettekandidater — nettopp kategorien artikkelen sier ofte er for stram. Gulvet gjør riktig jobb for `--subtract`; regel 1 trenger sin **egen akse**: *behold innholdet, løsne formuleringen*, ikke *fjern blokka*. Å blande dem er ÅS#5-defektklassen om igjen (to akser presset inn i ett vokabular).
|
||||
|
||||
Foreslått: `BP-JUDG-001` + `CA-OPT-002`, med samme presisjonsgate som `optimization-lens-agent` allerede har (siter regel + kilde, ti stille når usikker).
|
||||
|
||||
### B3. Regel 4 — vi måler feil akse
|
||||
|
||||
Vi har duplikatdeteksjon (`claude-md-linter` 3+ repetisjon, `conflict-detector` hook-duplikater, `CA-TOK-002` permissions). **Alt er innenfor ett lag.** Artikkelens regel 4 handler om samme instruksjon i **to lag** — hos oss: CLAUDE.md *og* en rule, rule *og* en skill-beskrivelse, CLAUDE.md *og* en agent-prompt. Sannsynligvis en utvidelse av `conflict-detector` (den kjenner allerede flere lag), ikke en ny scanner.
|
||||
|
||||
### B4. Regel 2 og 6 — ekte hull, men tynnere
|
||||
|
||||
- **Regel 2** (eksempler → grensesnitt): ingen scanner ser på om en skill/agent lener seg på eksempler der en parameter-enum ville gjort jobben. `skill-listing-scanner` teller tegn, ikke form.
|
||||
- **Regel 6** (rike referanser): *«prefer files that are in code as it provides clear, high-fidelity instructions»*, *«a HTML mockup of a design will generally produce better results than a description or screenshot»*. Vi har ingen oppfatning om referanse-*form*. Som eier av `@`-referanser (`import-resolver.mjs`) er vi det naturlige stedet.
|
||||
|
||||
**Regel 3 og 5 krever ingenting.** Progressiv avdekking *er* `BP-LOAD-001..006` + `BP-MECH-003` + `token-hotspots` + `manifest`; router-mønsteret dekkes av `t2_2`/`t2_3`; auto-memory av `t2_4`. Bekreftet, ikke endret.
|
||||
|
||||
### B5. Skriv om `docs/v5.13-model-routing-effort-deadref-plan.md` (v5.14-planen)
|
||||
|
||||
Den bærer i dag: C-SKL1 (#37), M-BUG-26, M-BUG-28, M-BUG-41 (to armer), arg-sluk-klassens CLI-arm (`optimize-lens-cli` + `token-hotspots-cli`), P6/M-BUG-44 scanner-siden. **Alt dette står fortsatt.** Oppgave A og B1–B4 skal flettes inn og prioriteres mot det — ikke legges oppå som en parallell plan. Detaljene på de åpne postene ligger i `STATE.md`; ikke gjenskap dem her.
|
||||
|
||||
---
|
||||
|
||||
## 3. Rekkefølge
|
||||
|
||||
1. Fasit for `/doctor`-overlappet → kjør → premiss-verifiser → **beslutning per scanner**
|
||||
2. B1 registerfiksen (TDD, sterk verifikasjon — den eneste posten her som har det)
|
||||
3. B5 omskriving av v5.14-planen med A + B1–B4 innflettet
|
||||
4. Oppdater `STATE.md` + `README`/`CLAUDE.md`-posisjonering mot `/doctor`
|
||||
|
||||
**Scope-grense:** ingen sletting av scannere i denne økten. Beslutningen tas og skrives ned; utførelsen er egne chunks, fordi frosne baselines og byte-stabilitet gjør sletting til flerfilsarbeid med egen verifikasjon.
|
||||
|
||||
---
|
||||
|
||||
## 4. Om modellvalget
|
||||
|
||||
Rubrikken ga `Opus 5/high` (rad 3, `rule=path=partial`). Operatøren valgte **Fable 5** for denne økten. Konsekvenser å være klar over:
|
||||
|
||||
- **Ingen advisor.** Fable godtar kun Fable-advisor, og Fable er ikke valgbar som advisor i CC 2.1.220. Fallback-raden (`Sonnet 5/xhigh --advisor opus`) er derfor *ikke* tilgjengelig som billigere utvei i denne økten.
|
||||
- `route-last` for neste økt skal registrere `model=Fable 5; effort=high` + om oppgave A faktisk ble lukket. Det er dataene som avgjør om Fable-radene i rubrikken noen gang blir levende policy.
|
||||
|
|
@ -6,11 +6,7 @@ import { readdirSync, readFileSync, existsSync } from 'fs';
|
|||
import { join, basename } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
// Canonical location since v2.2.0. The pre-v2.2.0 path is kept as a fallback so
|
||||
// sessions created before the move are still detected (see commands/cleanup.md).
|
||||
const canonicalSessionsDir = join(homedir(), '.claude', 'config-audit', 'sessions');
|
||||
const legacySessionsDir = join(homedir(), '.config-audit', 'sessions');
|
||||
const sessionsDir = existsSync(canonicalSessionsDir) ? canonicalSessionsDir : legacySessionsDir;
|
||||
const sessionsDir = join(homedir(), '.config-audit', 'sessions');
|
||||
|
||||
if (!existsSync(sessionsDir)) {
|
||||
process.exit(0);
|
||||
|
|
|
|||
|
|
@ -6,11 +6,7 @@ import { readdirSync, readFileSync, statSync, existsSync } from 'fs';
|
|||
import { join, basename, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
// Canonical location since v2.2.0. The pre-v2.2.0 path is kept as a fallback so
|
||||
// sessions created before the move are still detected (see commands/cleanup.md).
|
||||
const canonicalSessionsDir = join(homedir(), '.claude', 'config-audit', 'sessions');
|
||||
const legacySessionsDir = join(homedir(), '.config-audit', 'sessions');
|
||||
const sessionsDir = existsSync(canonicalSessionsDir) ? canonicalSessionsDir : legacySessionsDir;
|
||||
const sessionsDir = join(homedir(), '.config-audit', 'sessions');
|
||||
|
||||
if (!existsSync(sessionsDir)) {
|
||||
console.log('{}');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"version": 1,
|
||||
"note": "Machine-readable best-practices register. SOURCE OF TRUTH for the optimization lens (v5.7 CA-OPT). Human-readable mirror lives in knowledge/*.md. Every entry is provenance-stamped (source.url + source.verified; optional corroborating sources[] with published dates feed the evidence-age freshness rule; source.supersededBy marks a source replaced by a newer one) and carries a confidence; only CONFIRMED claims are consumed user-facing (Verifiseringsplikt). Curated manually + by /config-audit knowledge-refresh (human-approved). Seeded from docs/v5.5-steering-model-plan.md V-rows + the Anthropic 'Steering Claude Code' blog.",
|
||||
"note": "Machine-readable best-practices register. SOURCE OF TRUTH for the optimization lens (v5.7 CA-OPT). Human-readable mirror lives in knowledge/*.md. Every entry is provenance-stamped (source.url + source.verified) and carries a confidence; only CONFIRMED claims are consumed user-facing (Verifiseringsplikt). Curated manually + by /config-audit knowledge-refresh (human-approved). Seeded from docs/v5.5-steering-model-plan.md V-rows + the Anthropic 'Steering Claude Code' blog.",
|
||||
"entries": [
|
||||
{
|
||||
"id": "BP-MECH-001",
|
||||
|
|
@ -12,11 +12,7 @@
|
|||
"severity": "low",
|
||||
"category": "mechanism-fit",
|
||||
"lensCheck": "claude-md-lifecycle-phrasing",
|
||||
"source": {
|
||||
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
|
||||
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-MECH-002",
|
||||
|
|
@ -28,11 +24,7 @@
|
|||
"severity": "low",
|
||||
"category": "mechanism-fit",
|
||||
"lensCheck": "unscoped-path-specific-instruction",
|
||||
"source": {
|
||||
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
|
||||
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-MECH-003",
|
||||
|
|
@ -44,11 +36,7 @@
|
|||
"severity": "low",
|
||||
"category": "mechanism-fit",
|
||||
"lensCheck": "procedure-in-claude-md",
|
||||
"source": {
|
||||
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
|
||||
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-MECH-004",
|
||||
|
|
@ -60,11 +48,7 @@
|
|||
"severity": "low",
|
||||
"category": "mechanism-fit",
|
||||
"lensCheck": "never-instruction",
|
||||
"source": {
|
||||
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
|
||||
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-MECH-005",
|
||||
|
|
@ -76,11 +60,7 @@
|
|||
"severity": "medium",
|
||||
"category": "mechanism-fit",
|
||||
"lensCheck": "CA-OST-001",
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/output-styles",
|
||||
"title": "Output styles",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
"source": { "url": "https://code.claude.com/docs/en/output-styles", "title": "Output styles", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-LOAD-001",
|
||||
|
|
@ -89,11 +69,7 @@
|
|||
"confidence": "confirmed",
|
||||
"category": "loading-model",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/context-window",
|
||||
"title": "Context window — what survives compaction",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
"source": { "url": "https://code.claude.com/docs/en/context-window", "title": "Context window — what survives compaction", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-LOAD-002",
|
||||
|
|
@ -102,11 +78,7 @@
|
|||
"confidence": "confirmed",
|
||||
"category": "loading-model",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/memory",
|
||||
"title": "Memory — path-specific rules",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
"source": { "url": "https://code.claude.com/docs/en/memory", "title": "Memory — path-specific rules", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-LOAD-003",
|
||||
|
|
@ -115,11 +87,7 @@
|
|||
"confidence": "confirmed",
|
||||
"category": "loading-model",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/context-window",
|
||||
"title": "Context window — what survives compaction",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
"source": { "url": "https://code.claude.com/docs/en/context-window", "title": "Context window — what survives compaction", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-LOAD-004",
|
||||
|
|
@ -128,11 +96,7 @@
|
|||
"confidence": "confirmed",
|
||||
"category": "loading-model",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/skills",
|
||||
"title": "Skills",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
"source": { "url": "https://code.claude.com/docs/en/skills", "title": "Skills", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-LOAD-005",
|
||||
|
|
@ -141,11 +105,7 @@
|
|||
"confidence": "confirmed",
|
||||
"category": "loading-model",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/hooks",
|
||||
"title": "Hooks",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
"source": { "url": "https://code.claude.com/docs/en/hooks", "title": "Hooks", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-LOAD-006",
|
||||
|
|
@ -154,11 +114,7 @@
|
|||
"confidence": "confirmed",
|
||||
"category": "loading-model",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/sub-agents",
|
||||
"title": "Subagents",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
"source": { "url": "https://code.claude.com/docs/en/sub-agents", "title": "Subagents", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-SIZE-001",
|
||||
|
|
@ -169,11 +125,7 @@
|
|||
"severity": "medium",
|
||||
"category": "size-budget",
|
||||
"lensCheck": "CA-CML-001",
|
||||
"source": {
|
||||
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
|
||||
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
|
||||
},
|
||||
{
|
||||
"id": "BP-SIZE-002",
|
||||
|
|
@ -183,86 +135,7 @@
|
|||
"severity": "low",
|
||||
"category": "size-budget",
|
||||
"lensCheck": "CA-SKL-002",
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/skills",
|
||||
"title": "Skills",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-SUB-001",
|
||||
"claim": "Every line of CLAUDE.md loads into every session whether or not it is relevant, which consumes tokens and dilutes adherence. A line that states a local fact the model cannot derive (build commands, directory layout, conventions, team norms) earns that cost; a line that only restates general engineering behaviour pays it without being the kind of content CLAUDE.md is for, and is a candidate for removal.",
|
||||
"mechanism": "deletion",
|
||||
"appliesTo": "claude-md",
|
||||
"recommendation": "Review the block for removal, then re-add it only if the model actually stumbles on it repeatedly. Local facts (remotes, versions, paths, conventions) and policy invariants are the floor and are never removal candidates.",
|
||||
"confidence": "confirmed",
|
||||
"severity": "low",
|
||||
"category": "subtraction",
|
||||
"lensCheck": "compensatory-instruction",
|
||||
"source": {
|
||||
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
|
||||
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
|
||||
"verified": "2026-07-31"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://claude.com/blog/the-new-rules-of-context-engineering-for-claude-5-generation-models",
|
||||
"title": "The new rules of context engineering for Claude 5 generation models",
|
||||
"published": "2026-07-24",
|
||||
"verified": "2026-08-03",
|
||||
"note": "Near-verbatim coverage: 'briefly describe what your repo is for, but spend most of the tokens on gotchas inside of the codebase'; 'Avoid stating the obvious things Claude should know by looking at your file system or your repo.'"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "BP-MODEL-001",
|
||||
"claim": "A subagent's `model` frontmatter field defaults to `inherit`, so a subagent that names no model runs on the main conversation's model. Routing mechanical or read-only subagents to a cheaper alias (`haiku`, `sonnet`) while the orchestrating session keeps the stronger model is the documented way to control cost. The pin is not absolute: Claude Code resolves the model as CLAUDE_CODE_SUBAGENT_MODEL, then a per-invocation `model` parameter, then the frontmatter, then the main conversation's model.",
|
||||
"mechanism": "model",
|
||||
"appliesTo": "agent",
|
||||
"recommendation": "Set `model:` explicitly on subagents whose work is mechanical or read-only (search, extraction, summarisation) and leave the orchestrator on the stronger model. Omitting the field is not a neutral default — it inherits, so every subagent costs what the session costs.",
|
||||
"confidence": "confirmed",
|
||||
"severity": "low",
|
||||
"category": "model-fit",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/sub-agents",
|
||||
"title": "Create custom subagents — supported frontmatter fields / choose a model",
|
||||
"verified": "2026-08-10"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://claude.com/blog/claude-model-and-effort-level-in-claude-code",
|
||||
"title": "Choosing a Claude model and effort level in Claude Code",
|
||||
"published": "2026-07-07",
|
||||
"verified": "2026-08-10",
|
||||
"note": "Verbatim: 'Pick a smaller model when the work is routine. For example, edits you can describe precisely, mechanical changes, or questions about code that's already in context.'"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "BP-MODEL-002",
|
||||
"claim": "Reasoning effort is an axis separate from model choice: five levels (`low`, `medium`, `high`, `xhigh`, `max`) on current models, four on Opus 4.6 and Sonnet 4.6, which omit `xhigh`; the default is `high` on every model that supports effort except Opus 4.7, which defaults to `xhigh`. Higher is not universally better — `max` \"can improve performance on demanding tasks but may show diminishing returns and is prone to overthinking\". Effort is settable in six places: `/effort`, the slider in `/model`, the `--effort` flag, CLAUDE_CODE_EFFORT_LEVEL, `effortLevel` in settings, and `effort:` in skill or subagent frontmatter; the environment variable takes precedence over all of them.",
|
||||
"mechanism": "effort",
|
||||
"appliesTo": "agent",
|
||||
"recommendation": "Treat effort as a per-task dial rather than a global maximum: pin a lower `effort:` in the frontmatter of mechanical skills and subagents, and reserve `xhigh`/`max` for work whose product is judgment. The scale is calibrated per model, so the same level name is not the same amount of thinking across models — and CLAUDE_CODE_EFFORT_LEVEL silently overrides every other source, so verify which level is actually in force.",
|
||||
"confidence": "confirmed",
|
||||
"severity": "low",
|
||||
"category": "model-fit",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/model-config",
|
||||
"title": "Model configuration — adjust effort level / set the effort level",
|
||||
"verified": "2026-08-10"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://claude.com/blog/claude-model-and-effort-level-in-claude-code",
|
||||
"title": "Choosing a Claude model and effort level in Claude Code",
|
||||
"published": "2026-07-07",
|
||||
"verified": "2026-08-10",
|
||||
"note": "Verbatim: 'Claude will be more predisposed to double-checking additional hypotheses or verifying correctness at higher effort levels, but it generally won't artificially inflate usage for simple tasks at higher effort levels.'; 'In fact, our team pays close attention to \"overthinking\" during model training as it degrades effectiveness.'"
|
||||
}
|
||||
]
|
||||
"source": { "url": "https://code.claude.com/docs/en/skills", "title": "Skills", "verified": "2026-06-20" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ export async function scan(_targetPath, _discovery) {
|
|||
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'description-bloat',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Agent description is long (re-sent every turn in the always-loaded listing)',
|
||||
description:
|
||||
|
|
@ -92,7 +91,6 @@ export async function scan(_targetPath, _discovery) {
|
|||
if (aggregate.overBudget) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'aggregate-listing-budget',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Aggregate agent listing may exceed the always-loaded budget',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -157,7 +157,6 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'volatile-in-prefix',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Volatile content inside cached prefix breaks reuse',
|
||||
description:
|
||||
|
|
@ -200,7 +199,6 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'volatile-in-import',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Volatile content in @imported file breaks cached prefix',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { findArgError } from './lib/cli-args.mjs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
loadLedger,
|
||||
validateLedger,
|
||||
|
|
@ -33,32 +32,20 @@ import {
|
|||
defaultLedgerPath,
|
||||
} from './lib/campaign-ledger.mjs';
|
||||
|
||||
/**
|
||||
* Usage error. Throws rather than calling process.exit(): exit() discards
|
||||
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
|
||||
* `Error: ` text and sets the same exit code 3, so callers see no difference.
|
||||
*/
|
||||
class CliUsageError extends Error {}
|
||||
|
||||
function fail(message) {
|
||||
throw new CliUsageError(message);
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
/** Flag surface, measured 2026-08-09. The gate runs BEFORE the loop below, so the
|
||||
* loop no longer needs to re-check that a value followed its flag. */
|
||||
const ARG_SPEC = { value: ['--ledger-file', '--output-file'] };
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const argError = findArgError(args, ARG_SPEC);
|
||||
if (argError) fail(argError);
|
||||
let ledgerFile = null;
|
||||
let outputFile = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--ledger-file') ledgerFile = args[++i];
|
||||
else if (a === '--output-file') outputFile = args[++i];
|
||||
if (a === '--ledger-file' && args[i + 1]) ledgerFile = args[++i];
|
||||
else if (a === '--output-file' && args[i + 1]) outputFile = args[++i];
|
||||
}
|
||||
|
||||
const ledgerPath = resolve(ledgerFile || defaultLedgerPath());
|
||||
|
|
@ -109,18 +96,17 @@ async function main() {
|
|||
}
|
||||
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
|
||||
process.exitCode = exitCode;
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
const isDirectRun =
|
||||
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch((err) => {
|
||||
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
|
||||
process.stderr.write(`${prefix}: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@
|
|||
import { resolve, join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import {
|
||||
loadLedger,
|
||||
validateLedger,
|
||||
|
|
@ -45,15 +44,9 @@ import { planExportPath, buildPlanExportDocument } from './lib/campaign-export.m
|
|||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Usage error. Throws rather than calling process.exit(): exit() discards
|
||||
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
|
||||
* `Error: ` text and sets the same exit code 3, so callers see no difference.
|
||||
*/
|
||||
class CliUsageError extends Error {}
|
||||
|
||||
function fail(message) {
|
||||
throw new CliUsageError(message);
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
/** Default session store: next to the ledger, OUTSIDE the plugin dir. */
|
||||
|
|
@ -79,9 +72,9 @@ function parseArgs(argv) {
|
|||
|
||||
async function emit(payload, outputFile, exitCode) {
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
process.exitCode = exitCode;
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
|
@ -166,8 +159,7 @@ const isDirectRun =
|
|||
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch((err) => {
|
||||
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
|
||||
process.stderr.write(`${prefix}: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,8 +43,7 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
createLedger,
|
||||
addRepo,
|
||||
|
|
@ -58,63 +57,32 @@ import {
|
|||
} from './lib/campaign-ledger.mjs';
|
||||
import { readActiveConfig } from './lib/active-config-reader.mjs';
|
||||
import { buildManifest, splitManifestByOwnership } from './manifest.mjs';
|
||||
import { findArgError } from './lib/cli-args.mjs';
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Usage error. Throws rather than calling process.exit(): exit() discards
|
||||
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
|
||||
* `Error: ` text and sets the same exit code 3, so callers see no difference.
|
||||
*/
|
||||
class CliUsageError extends Error {}
|
||||
|
||||
function fail(message) {
|
||||
throw new CliUsageError(message);
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
/** Flag surface, measured 2026-08-09. The gate runs BEFORE the loop below, so the
|
||||
* loop no longer needs to re-check that a value followed its flag. */
|
||||
const ARG_SPEC = {
|
||||
value: ['--ledger-file', '--reference-date', '--output-file', '--name', '--findings', '--session'],
|
||||
};
|
||||
|
||||
/** Parse argv into a subcommand, positional args, and the flag map. */
|
||||
function parseArgs(argv) {
|
||||
const argError = findArgError(argv, ARG_SPEC);
|
||||
if (argError) fail(argError);
|
||||
const positionals = [];
|
||||
const flags = { ledgerFile: null, referenceDate: null, outputFile: null, name: null, findings: null, session: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--ledger-file') flags.ledgerFile = argv[++i];
|
||||
else if (a === '--reference-date') flags.referenceDate = argv[++i];
|
||||
else if (a === '--output-file') flags.outputFile = argv[++i];
|
||||
else if (a === '--name') flags.name = argv[++i];
|
||||
else if (a === '--findings') flags.findings = argv[++i];
|
||||
else if (a === '--session') flags.session = argv[++i];
|
||||
if (a === '--ledger-file' && argv[i + 1] !== undefined) flags.ledgerFile = argv[++i];
|
||||
else if (a === '--reference-date' && argv[i + 1] !== undefined) flags.referenceDate = argv[++i];
|
||||
else if (a === '--output-file' && argv[i + 1] !== undefined) flags.outputFile = argv[++i];
|
||||
else if (a === '--name' && argv[i + 1] !== undefined) flags.name = argv[++i];
|
||||
else if (a === '--findings' && argv[i + 1] !== undefined) flags.findings = argv[++i];
|
||||
else if (a === '--session' && argv[i + 1] !== undefined) flags.session = argv[++i];
|
||||
else if (a.startsWith('--')) fail(`unknown flag "${a}"`);
|
||||
else positionals.push(a);
|
||||
}
|
||||
return { subcommand: positionals[0], rest: positionals.slice(1), flags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this path actually be read as a repo right now?
|
||||
*
|
||||
* `readActiveConfig` resolves any string and its sub-readers all tolerate ENOENT, so a repo
|
||||
* that does not exist yields an EMPTY config instead of an error. Without this check the
|
||||
* sweep records a phantom repo as successfully swept with 0 tokens, and the machine-wide
|
||||
* bill claims coverage it does not have. A missing path is reported, never rejected — an
|
||||
* unmounted volume is a legitimate reason for a tracked repo to be absent today.
|
||||
*/
|
||||
async function isReadableRepoDir(path) {
|
||||
try {
|
||||
return (await stat(path)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Load an existing ledger, treating a parse error as a hard failure (never clobber corrupt data). */
|
||||
async function loadOrFail(path) {
|
||||
try {
|
||||
|
|
@ -126,9 +94,9 @@ async function loadOrFail(path) {
|
|||
|
||||
async function emit(payload, outputFile, exitCode) {
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
process.exitCode = exitCode;
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
|
@ -172,7 +140,6 @@ async function main() {
|
|||
let ledger = loaded === null ? createLedger({ now }) : loaded;
|
||||
|
||||
const added = [];
|
||||
const addedUnverified = [];
|
||||
const skipped = [];
|
||||
for (const p of paths) {
|
||||
const resolved = resolve(p);
|
||||
|
|
@ -180,17 +147,13 @@ async function main() {
|
|||
// --name applies only to a lone path; multi-add lets the lib derive each basename.
|
||||
const name = paths.length === 1 ? flags.name || undefined : undefined;
|
||||
ledger = addRepo(ledger, { path: p, name }, { now });
|
||||
if (present) skipped.push(resolved);
|
||||
else if (await isReadableRepoDir(resolved)) added.push(resolved);
|
||||
// Tracked either way, but never silently vouched for: the command reports these
|
||||
// separately so a typo does not become a permanent phantom row in the backlog.
|
||||
else addedUnverified.push(resolved);
|
||||
(present ? skipped : added).push(resolved);
|
||||
}
|
||||
await saveLedger(ledgerPath, ledger);
|
||||
return emit(
|
||||
{
|
||||
status: 'ok', action: 'add', written: true, autoInitialized, ledgerPath,
|
||||
added, addedUnverified, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
|
||||
added, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
|
||||
},
|
||||
flags.outputFile,
|
||||
0,
|
||||
|
|
@ -247,13 +210,6 @@ async function main() {
|
|||
let sharedSummary = null;
|
||||
|
||||
for (const repo of ledger0.repos) {
|
||||
// Check readability FIRST: readActiveConfig returns an empty config for a path that
|
||||
// does not exist rather than throwing, so the catch below would never see it and the
|
||||
// repo would be recorded as swept with a 0-token delta — a bill that looks complete.
|
||||
if (!(await isReadableRepoDir(repo.path))) {
|
||||
skipped.push({ path: repo.path, reason: 'repo path is not readable (does not exist or is not a directory)' });
|
||||
continue;
|
||||
}
|
||||
let split;
|
||||
try {
|
||||
const activeConfig = await readActiveConfig(repo.path, { verbose: false });
|
||||
|
|
@ -290,8 +246,7 @@ const isDirectRun =
|
|||
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch((err) => {
|
||||
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
|
||||
process.stderr.write(`${prefix}: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter, extractSections, findImports } from './lib/yaml-parser.mjs';
|
||||
import { lineCount, truncate } from './lib/string-utils.mjs';
|
||||
|
|
@ -62,7 +62,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
return scannerResult(SCANNER, 'ok', [
|
||||
finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-claude-md',
|
||||
severity: SEVERITY.high,
|
||||
title: 'No CLAUDE.md found',
|
||||
description: 'No CLAUDE.md files were discovered. This is the primary configuration surface for Claude Code.',
|
||||
|
|
@ -92,7 +91,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (file.scope === 'project' && relDir !== '.' && relDir !== '.claude' && lines > 5) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'nested-not-reinjected',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Nested CLAUDE.md is not re-injected after compaction',
|
||||
description: `${file.relPath} is a nested (subdirectory) CLAUDE.md. It loads when Claude reads a file in that directory, but after a context compaction it is not re-injected (only the project-root CLAUDE.md is) — its instructions silently drop until a file in that directory is read again.`,
|
||||
|
|
@ -111,7 +109,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (lines > MAX_ABSOLUTE_LINES) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'over-500-lines',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md exceeds 500 lines',
|
||||
description: `${file.relPath} has ${lines} lines. A file this size loads in full on every turn (token cost) and, on smaller-context models, can crowd out instructions. Large-context models tolerate longer files when the cache prefix stays stable — raw line count is no longer an absolute adherence threshold (CC 2.1.169 scales it by context window).`,
|
||||
|
|
@ -123,7 +120,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
} else if (lines > MAX_RECOMMENDED_LINES) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'over-200-lines',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md exceeds recommended 200 lines',
|
||||
description: `${file.relPath} has ${lines} lines. Under ~200 lines is the safe default across models; larger is fine on large-context models when the cache prefix stays stable. A long file still costs tokens every turn.`,
|
||||
|
|
@ -146,7 +142,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
// Conservative 200k anchor — byte-identical to the pre-B8 finding.
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'over-char-budget',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md exceeds Claude Code\'s performance-warning threshold',
|
||||
description: `${file.relPath} is ${withCommas(chars)} chars. Claude Code shows a startup warning ("Large CLAUDE.md will impact performance ... chars > 40.0k") once a CLAUDE.md passes ~40.0k chars on a 200k-context model — it loads in full on every turn. CC 2.1.169 scales that threshold with the context window, so on a ${withCommas(LARGE_CONTEXT_WINDOW)}-token model it relaxes to ~${withCommas(CLAUDE_MD_CHAR_WARN_LARGE)} chars and you are likely within it.`,
|
||||
|
|
@ -161,7 +156,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
const threshLabel = withCommas(charThreshold);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'over-char-budget',
|
||||
severity: advisory ? SEVERITY.info : SEVERITY.medium,
|
||||
title: 'CLAUDE.md exceeds Claude Code\'s performance-warning threshold',
|
||||
description: `${file.relPath} is ${withCommas(chars)} chars, over the ~${threshLabel}-char performance-warning threshold Claude Code applies at a ${winLabel}-token context window (it scales the ~40.0k-char @ 200k warning by the context window, CC 2.1.169) — it loads in full on every turn.` +
|
||||
|
|
@ -178,7 +172,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (lines < 3) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'nearly-empty',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md is nearly empty',
|
||||
description: `${file.relPath} has only ${lines} lines.`,
|
||||
|
|
@ -204,7 +197,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (missingSections.length > 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-sections',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Missing recommended sections',
|
||||
description: `${file.relPath} is missing: ${missingSections.join(', ')}`,
|
||||
|
|
@ -220,7 +212,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (sections.length === 0 && lines > 10) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-headings',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md has no markdown headings',
|
||||
description: `${file.relPath} has ${lines} lines but no ## headings. Structured content with headers improves Claude's ability to find and follow instructions.`,
|
||||
|
|
@ -237,7 +228,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (imp.path.includes('..') && imp.path.split('..').length > 3) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'deep-relative-import',
|
||||
severity: SEVERITY.low,
|
||||
title: '@import with deep relative path',
|
||||
description: `${file.relPath}:${imp.line} imports "${truncate(imp.path, 60)}" with multiple parent traversals.`,
|
||||
|
|
@ -255,7 +245,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (htmlComments > 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'html-comments',
|
||||
severity: SEVERITY.info,
|
||||
title: 'Uses HTML comments',
|
||||
description: `${file.relPath} uses ${htmlComments} HTML comment(s). These are stripped before injection, saving tokens.`,
|
||||
|
|
@ -277,7 +266,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (duplicates.length > 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'repeated-content',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Repeated content detected',
|
||||
description: `${file.relPath} has ${duplicates.length} line(s) repeated 3+ times.`,
|
||||
|
|
@ -293,7 +281,6 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (todos.length > 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'todo-markers',
|
||||
severity: SEVERITY.info,
|
||||
title: 'Contains TODO/FIXME markers',
|
||||
description: `${file.relPath} has ${todos.length} TODO/FIXME/HACK marker(s).`,
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ export async function scan(_targetPath, _discovery) {
|
|||
];
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'skill-user-vs-plugin',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Skill name "${name}" collides between user-level and plugin sources`,
|
||||
description:
|
||||
|
|
@ -98,7 +97,6 @@ export async function scan(_targetPath, _discovery) {
|
|||
const pluginNames = pluginSkills.map(s => s.pluginName);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'skill-multi-plugin',
|
||||
severity: SEVERITY.low,
|
||||
title: `Skill name "${name}" used by multiple plugins`,
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -129,7 +129,6 @@ export async function scan(targetPath, discovery) {
|
|||
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'settings-key-conflict',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Settings key conflict: "${key}"`,
|
||||
description: `Key "${key}" has different values across scopes. ${details}`,
|
||||
|
|
@ -161,7 +160,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (rulesIntersect(allowRule, denyRule)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'permission-allow-deny',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Permission allow/deny conflict',
|
||||
description: `"${allowRule}" is allowed in ${a.scope} (${a.file}) but denied in ${b.scope} (${b.file}).`,
|
||||
|
|
@ -179,7 +177,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (rulesIntersect(allowRule, denyRule)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'permission-allow-deny',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Permission allow/deny conflict',
|
||||
description: `"${allowRule}" is allowed in ${b.scope} (${b.file}) but denied in ${a.scope} (${a.file}).`,
|
||||
|
|
@ -230,7 +227,6 @@ export async function scan(targetPath, discovery) {
|
|||
const [event, matcher] = key.split(':');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'duplicate-hook',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Duplicate hook definition',
|
||||
description: `Hook "${event}" with matcher "${matcher}" is defined in ${uniqueSources.length} sources.`,
|
||||
|
|
|
|||
|
|
@ -113,7 +113,6 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'deny-and-allow',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Tool listed in both permissions.deny and permissions.allow',
|
||||
description:
|
||||
|
|
@ -135,7 +134,6 @@ export async function scan(targetPath, discovery) {
|
|||
const evidence = `allow: ${ineffective.slice(0, 5).map(e => `"${e}"`).join(', ')}`;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'ineffective-allow-wildcard',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Ineffective allow wildcard — Claude Code ignores this rule',
|
||||
description:
|
||||
|
|
@ -162,7 +160,6 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'forbidden-param-deny',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Permission rule silently ignored — deny/ask uses a forbidden param key',
|
||||
description:
|
||||
|
|
@ -187,7 +184,6 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'forbidden-param-allow',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Permission rule silently ignored — allow uses a forbidden param key (dead config)',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -5,23 +5,17 @@
|
|||
* Compare current configuration against a saved baseline.
|
||||
* Usage:
|
||||
* node drift-cli.mjs <path> --save [--name my-baseline]
|
||||
* node drift-cli.mjs <path> [--baseline my-baseline] [--json] [--output-file path]
|
||||
* node drift-cli.mjs <path> [--baseline my-baseline] [--json]
|
||||
* node drift-cli.mjs --list
|
||||
* Unknown options and value-less --name/--baseline/--output-file exit 3.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { diffEnvelopes, formatDiffReport } from './lib/diff-engine.mjs';
|
||||
import { saveBaseline, loadBaseline, listBaselines } from './lib/baseline.mjs';
|
||||
import { humanizeFindings } from './lib/humanizer.mjs';
|
||||
|
||||
const BOOL_FLAGS = ['--save', '--list', '--json', '--raw', '--global'];
|
||||
const VALUE_FLAGS = ['--name', '--baseline', '--output-file'];
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let targetPath = '.';
|
||||
|
|
@ -31,50 +25,30 @@ async function main() {
|
|||
let jsonMode = false;
|
||||
let rawMode = false;
|
||||
let includeGlobal = false;
|
||||
let outputFile = null;
|
||||
|
||||
// M-BUG-21: this loop used to end in `else if (!arg.startsWith('-')) targetPath = arg`,
|
||||
// with no unknown-flag branch. An unrecognised flag was dropped silently and its
|
||||
// VALUE fell through to targetPath — so `--output-file /tmp/x.json` scanned
|
||||
// /tmp/x.json, a path that does not exist, yielding a near-empty scan and
|
||||
// therefore permanent phantom drift. A missing value for --name was equally
|
||||
// silent and destructive: it left baselineName at 'default' and OVERWROTE the
|
||||
// default baseline. Both now fail loudly (exit 3) instead.
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (BOOL_FLAGS.includes(arg)) {
|
||||
if (arg === '--save') save = true;
|
||||
else if (arg === '--list') list = true;
|
||||
else if (arg === '--json') jsonMode = true;
|
||||
else if (arg === '--raw') rawMode = true;
|
||||
else if (arg === '--global') includeGlobal = true;
|
||||
} else if (VALUE_FLAGS.includes(arg)) {
|
||||
const value = args[i + 1];
|
||||
if (value === undefined || value.startsWith('-')) {
|
||||
throw new Error(`Option ${arg} requires a value.`);
|
||||
}
|
||||
if (arg === '--name' || arg === '--baseline') baselineName = value;
|
||||
else outputFile = value;
|
||||
i++;
|
||||
} else if (arg.startsWith('-')) {
|
||||
throw new Error(
|
||||
`Unknown option: ${arg}\n` +
|
||||
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
|
||||
);
|
||||
} else {
|
||||
targetPath = arg;
|
||||
if (args[i] === '--save') {
|
||||
save = true;
|
||||
} else if (args[i] === '--name' && args[i + 1]) {
|
||||
baselineName = args[++i];
|
||||
} else if (args[i] === '--baseline' && args[i + 1]) {
|
||||
baselineName = args[++i];
|
||||
} else if (args[i] === '--list') {
|
||||
list = true;
|
||||
} else if (args[i] === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (args[i] === '--raw') {
|
||||
rawMode = true;
|
||||
} else if (args[i] === '--global') {
|
||||
includeGlobal = true;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
targetPath = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
// --- List mode ---
|
||||
if (list) {
|
||||
const result = await listBaselines();
|
||||
// commands/drift.md runs this with `2>/dev/null` (ux-rules rule 2). The
|
||||
// human listing below goes to stderr, so without --output-file the command
|
||||
// received 0 bytes and could render nothing. The flag was already accepted
|
||||
// by the arg parser; only list mode ignored it.
|
||||
if (outputFile) await writeOutputFile(outputFile, JSON.stringify(result, null, 2) + '\n', 'utf-8');
|
||||
if (jsonMode || rawMode) {
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
} else {
|
||||
|
|
@ -91,12 +65,7 @@ async function main() {
|
|||
process.stderr.write('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await requireTargetDir(resolve(targetPath)))) {
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --- Save mode ---
|
||||
|
|
@ -115,7 +84,7 @@ async function main() {
|
|||
process.stderr.write(`\nBaseline "${result.name}" saved to ${result.path}\n`);
|
||||
process.stderr.write(`Findings: ${envelope.aggregate.total_findings}\n`);
|
||||
}
|
||||
return;
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --- Drift mode (default) ---
|
||||
|
|
@ -134,28 +103,7 @@ async function main() {
|
|||
process.stderr.write(`Baseline "${baselineName}" not found.\n`);
|
||||
process.stderr.write(`Save one first: node drift-cli.mjs <path> --save\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// M-BUG-27: a baseline carries the path it was saved from, but nothing ever
|
||||
// compared it against the current target. Diffing a repo against a baseline
|
||||
// anchored elsewhere produced 100% phantom drift — every baseline finding
|
||||
// "resolved", every current finding "new" — and reported it as trend
|
||||
// "improving": a reassuring and entirely false signal, on the DEFAULT
|
||||
// baseline. The warning goes to stderr in every mode; stdout stays
|
||||
// byte-identical to the frozen v5.0.0 shape.
|
||||
const baselineTarget = baseline._baseline?.target_path || '';
|
||||
const currentTarget = resolve(targetPath);
|
||||
const anchorMatches = !baselineTarget || baselineTarget === currentTarget;
|
||||
if (baselineTarget && baselineTarget !== currentTarget) {
|
||||
process.stderr.write(
|
||||
`\nWarning: baseline "${baselineName}" was saved from a different target path.\n` +
|
||||
` baseline: ${baselineTarget}\n` +
|
||||
` current: ${currentTarget}\n` +
|
||||
` The two scans cover different trees, so this diff is not a drift signal.\n` +
|
||||
` Re-anchor with: drift-cli.mjs ${currentTarget} --save --name ${baselineName}\n\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run current scan
|
||||
|
|
@ -167,41 +115,25 @@ async function main() {
|
|||
// Diff
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
|
||||
// Default mode: humanize finding-bearing diff fields before report rendering.
|
||||
// `_baselineAnchor` rides here and NOT in the raw shape: commands/drift.md runs
|
||||
// the CLI under `2>/dev/null`, so the stderr warning above never reaches the
|
||||
// caller that has to act on it. --json/--raw stay v5.0.0-shaped.
|
||||
const humanizedDiff = {
|
||||
...diff,
|
||||
_baselineAnchor: { matches: anchorMatches, baselineTarget, currentTarget },
|
||||
newFindings: humanizeFindings(diff.newFindings || []),
|
||||
resolvedFindings: humanizeFindings(diff.resolvedFindings || []),
|
||||
unchangedFindings: humanizeFindings(diff.unchangedFindings || []),
|
||||
movedFindings: humanizeFindings(diff.movedFindings || []),
|
||||
};
|
||||
|
||||
if (jsonMode || rawMode) {
|
||||
// --json and --raw both write the raw v5.0.0-shape diff (byte-identical).
|
||||
process.stdout.write(JSON.stringify(diff, null, 2) + '\n');
|
||||
} else {
|
||||
// Default mode: humanize finding-bearing diff fields before report rendering.
|
||||
const humanizedDiff = {
|
||||
...diff,
|
||||
newFindings: humanizeFindings(diff.newFindings || []),
|
||||
resolvedFindings: humanizeFindings(diff.resolvedFindings || []),
|
||||
unchangedFindings: humanizeFindings(diff.unchangedFindings || []),
|
||||
movedFindings: humanizeFindings(diff.movedFindings || []),
|
||||
};
|
||||
const report = formatDiffReport(humanizedDiff);
|
||||
process.stderr.write('\n' + report + '\n');
|
||||
}
|
||||
|
||||
// ux-rules rule 2: every scanner Bash call uses `--output-file <path>` and the
|
||||
// command reads the file with the Read tool. drift-cli had no such flag, and
|
||||
// its default-mode report goes to stderr — which commands/drift.md discarded
|
||||
// via `2>/dev/null` while instructing the agent to "read stdout". The command
|
||||
// captured nothing. Matches posture.mjs: raw diff in --json/--raw, humanized
|
||||
// otherwise; stdout is unaffected.
|
||||
if (outputFile) {
|
||||
const fileDiff = (jsonMode || rawMode) ? diff : humanizedDiff;
|
||||
await writeOutputFile(outputFile, JSON.stringify(fileDiff, null, 2), 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
}
|
||||
|
||||
// Exit code: 0=stable/improving, 1=degrading
|
||||
process.exitCode = diff.summary.trend === 'degrading' ? 1 : 0;
|
||||
if (diff.summary.trend === 'degrading') process.exit(1);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Only run CLI if invoked directly
|
||||
|
|
@ -209,6 +141,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
/**
|
||||
* GAP Scanner — Feature Gap Scanner
|
||||
* Compares actual configuration against complete Claude Code feature register.
|
||||
* 24 gap dimensions across 4 tiers, plus four conditional levers (bundled-skills
|
||||
* budget, CLI-over-MCP, hook-output filtering, agent model/effort routing) which
|
||||
* fire only under a measured condition and are therefore NOT dimensions: they
|
||||
* stay out of the scoring denominators. Always runs with includeGlobal: true.
|
||||
* 25 gap dimensions across 4 tiers, plus a conditional disableBundledSkills
|
||||
* budget-lever check (remediation companion to SKL CA-SKL-002, fires only under
|
||||
* measured skill-listing pressure). Always runs with includeGlobal: true.
|
||||
* Finding IDs: CA-GAP-NNN
|
||||
*/
|
||||
|
||||
import { resolve, join, sep } from 'node:path';
|
||||
import { resolve } from 'node:path';
|
||||
import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
|
|
@ -47,68 +46,6 @@ function isTargetLocal(ctx, f) {
|
|||
return f.absPath.startsWith(ctx.targetPath);
|
||||
}
|
||||
|
||||
// Files that are test/demo/vendored config — NOT part of the user's authored
|
||||
// cascade — must not satisfy "is feature X present?" checks, or they mask real
|
||||
// gaps. The canonical case: this plugin's own examples/optimal-setup sets
|
||||
// outputStyle/statusLine/worktree/model/keybindings/.lsp.json, and (because GAP
|
||||
// always runs includeGlobal) its copies vendored under ~/.claude/plugins/cache
|
||||
// drive every tier-3 presence check to "present" — hiding the user's real gaps
|
||||
// on ANY target. Two classes to exclude:
|
||||
// - plugin-bundled: anything under ~/.claude/plugins/ (absPath marker, mirrors
|
||||
// the CNF conflict-detector exclusion from M-BUG-2).
|
||||
// - nested demo/test data: a file whose path RELATIVE TO THE SCAN TARGET sits
|
||||
// under an examples/ or tests/fixtures/ subtree. relPath (not absPath) is
|
||||
// deliberate: a fixture scanned AS the target keeps its own files, so the
|
||||
// frozen v5.0.0 byte-snapshots (scanned from tests/fixtures/marketplace-medium)
|
||||
// are untouched. (M-BUG-13)
|
||||
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
|
||||
|
||||
/**
|
||||
* @param {import('./lib/file-discovery.mjs').ConfigFile} file
|
||||
* @returns {boolean} true if the file is part of the user's authored config
|
||||
*/
|
||||
function isAuthoredConfig(file) {
|
||||
if (file.absPath.includes(PLUGIN_TREE_MARKER)) return false;
|
||||
const segs = (file.relPath || '').split(sep);
|
||||
if (segs.includes('examples')) return false;
|
||||
const ti = segs.indexOf('tests');
|
||||
if (ti !== -1 && segs[ti + 1] === 'fixtures') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the user→project→local settings cascade directly from the filesystem.
|
||||
* The settings-key gap checks ask "does the USER's resolved config set X?" — a
|
||||
* question the includeGlobal discovery answers unreliably on a real machine: the
|
||||
* top-level ~/.claude/settings.json is missed (its relPath carries no `.claude`
|
||||
* segment when the walk root IS ~/.claude) and, when many vendored plugins flood
|
||||
* the walk, dropped by the discovery file cap. Reading the canonical cascade
|
||||
* paths directly is immune to both. Merged INTO (not replacing) the discovery
|
||||
* settings so any non-canonical project settings still count and the frozen
|
||||
* snapshots stay byte-stable. (M-BUG-13)
|
||||
* @param {string} targetPath
|
||||
* @returns {Promise<Array<{ key: string, parsed: object }>>}
|
||||
*/
|
||||
async function readSettingsCascade(targetPath) {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const paths = [];
|
||||
if (home) {
|
||||
paths.push(['user', join(home, '.claude', 'settings.json')]);
|
||||
paths.push(['user-local', join(home, '.claude', 'settings.local.json')]);
|
||||
}
|
||||
paths.push(['project', join(targetPath, '.claude', 'settings.json')]);
|
||||
paths.push(['local', join(targetPath, '.claude', 'settings.local.json')]);
|
||||
|
||||
const out = [];
|
||||
for (const [scope, p] of paths) {
|
||||
const content = await readTextFile(p);
|
||||
if (!content) continue;
|
||||
const parsed = parseJson(content);
|
||||
if (parsed && typeof parsed === 'object') out.push({ key: `cascade:${scope}:${p}`, parsed });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const TIER_SEVERITY = {
|
||||
t1: SEVERITY.medium,
|
||||
t2: SEVERITY.low,
|
||||
|
|
@ -116,36 +53,6 @@ const TIER_SEVERITY = {
|
|||
t4: SEVERITY.info,
|
||||
};
|
||||
|
||||
/**
|
||||
* Titles of the conditional levers — findings this scanner emits that are NOT
|
||||
* dimensions in GAP_CHECKS. They fire only under a measured condition, so they
|
||||
* carry no tier and never enter the scoring denominators (TIER_COUNTS /
|
||||
* TOTAL_DIMENSIONS) or the scoring TITLE_TO_ID map.
|
||||
*
|
||||
* Exported as the single source of both the code and the title: the
|
||||
* finding-code registry guard needs the codes, the humanizer coverage guard
|
||||
* needs the titles, and a hand-maintained copy of either list in a test is the
|
||||
* two-copies-drift class. One object so the two cannot disagree.
|
||||
*/
|
||||
export const LEVERS = {
|
||||
bundledSkills: {
|
||||
code: 'bundled-skills-lever',
|
||||
title: 'Bundled skills add to an over-budget skill listing',
|
||||
},
|
||||
cliOverMcp: {
|
||||
code: 'cli-over-mcp-lever',
|
||||
title: 'Prefer CLI over MCP for common operations',
|
||||
},
|
||||
filterHookOutput: {
|
||||
code: 'filter-hook-output-lever',
|
||||
title: 'Filter hook output before it enters context',
|
||||
},
|
||||
agentModelRouting: {
|
||||
code: 'agent-model-routing-lever',
|
||||
title: 'Subagents pin neither model nor effort',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Lazily read and cache file content.
|
||||
* @param {CheckContext} ctx
|
||||
|
|
@ -208,8 +115,7 @@ export function bundledSkillsLeverFinding({ leverPulled, aggregate }) {
|
|||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
code: LEVERS.bundledSkills.code,
|
||||
title: LEVERS.bundledSkills.title,
|
||||
title: 'Bundled skills add to an over-budget skill listing',
|
||||
description:
|
||||
`Your ${aggregate.scanned} active skills already carry ~${aggregate.aggregateTokens} tokens of ` +
|
||||
`description text, over the ${aggregate.budgetTokens}-token listing budget Claude Code allots the ` +
|
||||
|
|
@ -253,8 +159,7 @@ export function cliOverMcpLeverFinding({ assessment } = {}) {
|
|||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
code: LEVERS.cliOverMcp.code,
|
||||
title: LEVERS.cliOverMcp.title,
|
||||
title: 'Prefer CLI over MCP for common operations',
|
||||
description:
|
||||
`Your active project MCP tool schemas (~${assessment.aggregateTokens} tokens) are forced into the ` +
|
||||
'always-loaded prefix every turn rather than deferred (see CA-TOK-006). CLI tools (gh, aws, gcloud, …) ' +
|
||||
|
|
@ -291,8 +196,7 @@ export function filterHookLeverFinding({ flaggedHooks } = {}) {
|
|||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
code: LEVERS.filterHookOutput.code,
|
||||
title: LEVERS.filterHookOutput.title,
|
||||
title: 'Filter hook output before it enters context',
|
||||
description:
|
||||
`${hooks.length} active hook${hooks.length === 1 ? '' : 's'} build hookSpecificOutput.additionalContext ` +
|
||||
"from un-grepped command output (see HKV advisory). That field enters Claude's context on every fire, " +
|
||||
|
|
@ -307,103 +211,8 @@ export function filterHookLeverFinding({ flaggedHooks } = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent model/effort routing lever (C4) — cites BP-MODEL-001/002.
|
||||
*
|
||||
* A LEVER rather than a GAP_CHECKS dimension, and deliberately so. The question
|
||||
* "do your subagents route model/effort?" has no meaningful reading on a config
|
||||
* with no subagents — the `No custom subagents` dimension (t2_6) owns that case,
|
||||
* and firing here too would just double-report it. A dimension can only express
|
||||
* "not applicable" as "present", which would also inflate the utilization
|
||||
* denominator for every agent-less config.
|
||||
*
|
||||
* ONE check across BOTH axes, not two: it fires only when NEITHER `model:` nor
|
||||
* `effort:` appears on ANY authored agent. A deliberate all-on-one-model setup
|
||||
* therefore stays silent, which is the precision the opportunity framing needs.
|
||||
* The cost is recall — a config that pins `model:` everywhere but never uses
|
||||
* `effort:` gets no nudge. That trade is the v1 boundary, not an oversight.
|
||||
*
|
||||
* Pure and exported for unit testing.
|
||||
*
|
||||
* @param {{ agentCount: number, modelPinned: number, effortPinned: number }} counts
|
||||
* @returns {object|null} a GAP finding, or null when there is no opportunity
|
||||
*/
|
||||
export function agentModelRoutingLeverFinding({ agentCount, modelPinned, effortPinned }) {
|
||||
if (!agentCount) return null;
|
||||
if (modelPinned > 0 || effortPinned > 0) return null;
|
||||
|
||||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
code: LEVERS.agentModelRouting.code,
|
||||
title: LEVERS.agentModelRouting.title,
|
||||
description:
|
||||
`All ${agentCount} of your subagents name neither a \`model:\` nor an \`effort:\` in their frontmatter. ` +
|
||||
'The `model` field defaults to `inherit`, so each one runs on the main conversation\'s model — omitting ' +
|
||||
'it is not a neutral default but a choice to pay the session\'s rate for every delegated task ' +
|
||||
'(BP-MODEL-001, https://code.claude.com/docs/en/sub-agents). Reasoning effort is a separate axis with ' +
|
||||
'its own frontmatter field and its own default, so a subagent can be routed on either or both ' +
|
||||
'(BP-MODEL-002, https://code.claude.com/docs/en/model-config).',
|
||||
evidence:
|
||||
`authored_agents=${agentCount}; model_pinned=${modelPinned}; effort_pinned=${effortPinned}; ` +
|
||||
'lever=agent frontmatter `model:` / `effort:` (plugin-bundled and fixture agents excluded)',
|
||||
recommendation:
|
||||
'Pin a cheaper `model:` on the subagents whose work is mechanical or read-only (search, extraction, ' +
|
||||
'summarisation) and leave the orchestrating session on the stronger model; pin a lower `effort:` on the ' +
|
||||
'same ones and reserve the high levels for work whose product is judgement. If running everything on one ' +
|
||||
'model is a deliberate policy, suppress this with `CA-GAP-028` in `.config-audit-ignore`.',
|
||||
category: 'model-fit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count authored agents and how many pin each routing axis.
|
||||
* Frontmatter-only read; an unparseable or frontmatter-less file counts as an
|
||||
* agent that pins nothing, matching what Claude Code would load.
|
||||
* @param {CheckContext} ctx
|
||||
* @returns {Promise<{ agentCount: number, modelPinned: number, effortPinned: number }>}
|
||||
*/
|
||||
async function countAgentRouting(ctx) {
|
||||
let agentCount = 0;
|
||||
let modelPinned = 0;
|
||||
let effortPinned = 0;
|
||||
for (const file of ctx.files.filter(f => f.type === 'agent-md')) {
|
||||
agentCount++;
|
||||
const content = await getContent(ctx, file.absPath);
|
||||
if (!content) continue;
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
if (!frontmatter) continue;
|
||||
if (isRoutingValue(frontmatter.model) && !isDefaultModel(frontmatter.model)) modelPinned++;
|
||||
if (isRoutingValue(frontmatter.effort)) effortPinned++;
|
||||
}
|
||||
return { agentCount, modelPinned, effortPinned };
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a frontmatter value that actually names something. An empty or
|
||||
* whitespace-only `model:` is a no-op in Claude Code, so it must not read as a pin.
|
||||
* @param {*} v
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isRoutingValue(v) {
|
||||
return typeof v === 'string' ? v.trim().length > 0 : v != null && v !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* `inherit` IS the documented default for a subagent's `model` (BP-MODEL-001),
|
||||
* so writing it explicitly routes nothing — the agent still runs on the main
|
||||
* conversation's model. Spelling out a default must not buy silence, or a config
|
||||
* can opt out of the opportunity without changing a single thing about cost.
|
||||
* Effort has no documented sentinel of this kind, so it has no counterpart here.
|
||||
* @param {*} v
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDefaultModel(v) {
|
||||
return typeof v === 'string' && v.trim().toLowerCase() === 'inherit';
|
||||
}
|
||||
|
||||
/** @type {GapCheck[]} */
|
||||
export const GAP_CHECKS = [
|
||||
const GAP_CHECKS = [
|
||||
// --- Tier 1: Foundation ---
|
||||
{
|
||||
id: 't1_1', tier: 't1',
|
||||
|
|
@ -609,11 +418,12 @@ export const GAP_CHECKS = [
|
|||
return false;
|
||||
},
|
||||
},
|
||||
// t3_8 ('No autoMode classifier') retired in v5.14: CC 2.1.226's /doctor
|
||||
// Check 8 covers auto mode with usage-weighted judgement, so an "adopt this
|
||||
// feature" nudge is a pure duplicate under the binding /doctor positioning.
|
||||
// The DETERMINISTIC side stays: SET still validates autoMode structure and
|
||||
// flags it as dead config in shared project settings.
|
||||
{
|
||||
id: 't3_8', tier: 't3',
|
||||
title: 'No autoMode classifier',
|
||||
recommendation: 'Configure autoMode in user/local settings with environment context and allow/deny rules.',
|
||||
check: async (ctx) => anySettingsHas(ctx, 'autoMode'),
|
||||
},
|
||||
|
||||
// --- Tier 4: Team/Enterprise ---
|
||||
{
|
||||
|
|
@ -669,30 +479,18 @@ export async function scan(targetPath, sharedDiscovery) {
|
|||
? sharedDiscovery
|
||||
: await discoverConfigFiles(resolve(targetPath), { includeGlobal: true });
|
||||
|
||||
// Presence checks ("does the user have feature X?") must see only the user's
|
||||
// authored cascade — not bundled/vendored/demo config, which masks real gaps
|
||||
// (M-BUG-13, see isAuthoredConfig).
|
||||
const authoredFiles = discovery.files.filter(isAuthoredConfig);
|
||||
|
||||
// Parse all settings files upfront (authored discovery files) ...
|
||||
// Parse all settings files upfront
|
||||
const parsedSettings = new Map();
|
||||
for (const file of authoredFiles.filter(f => f.type === 'settings-json')) {
|
||||
for (const file of discovery.files.filter(f => f.type === 'settings-json')) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (content) {
|
||||
const parsed = parseJson(content);
|
||||
parsedSettings.set(`${file.scope}:${file.relPath}`, parsed);
|
||||
}
|
||||
}
|
||||
// ... plus the real user→project→local cascade read directly, so settings-key
|
||||
// checks see the true resolved config regardless of the discovery cap/gotcha
|
||||
// (M-BUG-13). Merged, not replacing — keeps non-canonical project settings and
|
||||
// the frozen byte-snapshots unchanged.
|
||||
for (const { key, parsed } of await readSettingsCascade(resolve(targetPath))) {
|
||||
parsedSettings.set(key, parsed);
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
files: authoredFiles,
|
||||
files: discovery.files,
|
||||
targetPath: resolve(targetPath),
|
||||
parsedSettings,
|
||||
fileContents: new Map(),
|
||||
|
|
@ -703,7 +501,6 @@ export async function scan(targetPath, sharedDiscovery) {
|
|||
if (!present) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: gap.id,
|
||||
severity: TIER_SEVERITY[gap.tier],
|
||||
title: gap.title,
|
||||
description: `Feature gap: ${gap.title}. ${gap.recommendation}`,
|
||||
|
|
@ -734,13 +531,6 @@ export async function scan(targetPath, sharedDiscovery) {
|
|||
const hookLever = filterHookLeverFinding({ flaggedHooks });
|
||||
if (hookLever) findings.push(hookLever);
|
||||
|
||||
// Agent model/effort routing lever (C4) — fires only when authored agents
|
||||
// exist and not one of them uses either routing axis. Reads the SAME authored
|
||||
// set as the presence checks, so plugin-bundled and fixture agents cannot
|
||||
// make a machine look routed (M-BUG-13).
|
||||
const routingLever = agentModelRoutingLeverFinding(await countAgentRouting(ctx));
|
||||
if (routingLever) findings.push(routingLever);
|
||||
|
||||
const filesScanned = discovery.files.length;
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,11 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { planFixes, applyFixes, verifyFixes } from './fix-engine.mjs';
|
||||
import { createBackup } from './lib/backup.mjs';
|
||||
import { humanizeFinding } from './lib/humanizer.mjs';
|
||||
|
||||
// `--dry-run` is a no-op alias: dry-run is already the default. It exists because
|
||||
// commands/fix.md documents it in argument-hint, and a documented flag that the
|
||||
// CLI silently drops is the same fail-silent class as the unknown-flag sink below.
|
||||
const BOOL_FLAGS = ['--apply', '--dry-run', '--json', '--raw', '--global'];
|
||||
const VALUE_FLAGS = ['--output-file'];
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let targetPath = '.';
|
||||
|
|
@ -29,36 +21,18 @@ async function main() {
|
|||
let jsonMode = false;
|
||||
let rawMode = false;
|
||||
let includeGlobal = false;
|
||||
let outputFile = null;
|
||||
|
||||
// Same defect class as M-BUG-21 in drift-cli: this loop used to end in
|
||||
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
|
||||
// unknown-flag branch, so an unrecognised flag was dropped silently and its
|
||||
// VALUE became the scan target. Here that is worse than in drift: combined
|
||||
// with --apply it silently moves the WRITE target to another tree.
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (BOOL_FLAGS.includes(arg)) {
|
||||
if (arg === '--apply') apply = true;
|
||||
else if (arg === '--json') jsonMode = true;
|
||||
else if (arg === '--raw') rawMode = true;
|
||||
else if (arg === '--global') includeGlobal = true;
|
||||
// --dry-run: default behaviour, accepted so it is not silently dropped.
|
||||
} else if (VALUE_FLAGS.includes(arg)) {
|
||||
const value = args[i + 1];
|
||||
if (value === undefined || value.startsWith('-')) {
|
||||
throw new Error(`Option ${arg} requires a value.`);
|
||||
}
|
||||
outputFile = value;
|
||||
i++;
|
||||
} else if (arg.startsWith('-')) {
|
||||
throw new Error(
|
||||
`Unknown option: ${arg}\n` +
|
||||
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
|
||||
);
|
||||
} else {
|
||||
targetPath = arg;
|
||||
if (args[i] === '--apply') {
|
||||
apply = true;
|
||||
} else if (args[i] === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (args[i] === '--raw') {
|
||||
rawMode = true;
|
||||
} else if (args[i] === '--global') {
|
||||
includeGlobal = true;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
targetPath = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,11 +41,6 @@ async function main() {
|
|||
|
||||
const resolvedPath = resolve(targetPath);
|
||||
|
||||
if (!(await requireTargetDir(resolvedPath))) {
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!machineMode) {
|
||||
process.stderr.write(`Config-Audit Fix CLI v2.1.0\n`);
|
||||
process.stderr.write(`Target: ${resolvedPath}\n`);
|
||||
|
|
@ -136,21 +105,16 @@ async function main() {
|
|||
let backupId = null;
|
||||
|
||||
if (fixes.length === 0) {
|
||||
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
|
||||
if (machineMode) {
|
||||
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
||||
}
|
||||
if (outputFile) await writeOutputFile(outputFile, JSON.stringify(output, null, 2) + '\n', 'utf-8');
|
||||
return;
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
// Create backup first. file-rename used to be excluded here, so a rule file
|
||||
// whose only defect was its extension was renamed with NO backup entry —
|
||||
// while commands/fix.md promised "every fix creates a backup first" and
|
||||
// handed the user a backupId that could not restore it. The source file is
|
||||
// backed up like any other; rollback recreates it at its original path.
|
||||
const filesToBackup = [...new Set(fixes.map(f => f.file))];
|
||||
// Create backup first
|
||||
const filesToBackup = [...new Set(fixes.filter(f => f.type !== 'file-rename').map(f => f.file))];
|
||||
const backup = createBackup(filesToBackup);
|
||||
backupId = backup.backupId;
|
||||
|
||||
|
|
@ -178,10 +142,7 @@ async function main() {
|
|||
process.stderr.write(`\n Verifying...\n`);
|
||||
}
|
||||
|
||||
// Verification must re-scan the scope the fix run used. It hardcoded
|
||||
// includeGlobal:false, so with --global every untouched global-scope
|
||||
// finding fell out of the re-scan and was reported as verified.
|
||||
const verification = await verifyFixes(envelope, applied, { includeGlobal });
|
||||
const verification = await verifyFixes(envelope, applied);
|
||||
verified = verification.verified;
|
||||
regressions = verification.regressions;
|
||||
|
||||
|
|
@ -190,11 +151,7 @@ async function main() {
|
|||
if (regressions.length > 0) {
|
||||
process.stderr.write(` Regressions: ${regressions.join(', ')}\n`);
|
||||
}
|
||||
// There is no rollback-cli.mjs — the restore path is the command, which
|
||||
// drives rollback-engine.mjs. Pointing at a nonexistent script in the
|
||||
// one message a user reaches for after a bad fix is the worst place for
|
||||
// a dead reference.
|
||||
process.stderr.write(`\n Rollback: /config-audit rollback ${backupId}\n`);
|
||||
process.stderr.write(`\n Rollback: node scanners/rollback-cli.mjs ${backupId}\n`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -208,7 +165,7 @@ async function main() {
|
|||
}
|
||||
|
||||
// JSON output (both --json and --raw write byte-equal v5.0.0-shape stdout)
|
||||
{
|
||||
if (machineMode) {
|
||||
const output = {
|
||||
planned: fixes.map(f => ({
|
||||
findingId: f.findingId,
|
||||
|
|
@ -236,17 +193,7 @@ async function main() {
|
|||
})),
|
||||
backupId,
|
||||
};
|
||||
const serialized = JSON.stringify(output, null, 2) + '\n';
|
||||
if (machineMode) process.stdout.write(serialized);
|
||||
// --output-file carries the same payload to disk. ux-rules rule 2 requires
|
||||
// it: commands run scanners with `2>/dev/null`, so anything the command has
|
||||
// to act on must ride in a file, not in stdout or stderr.
|
||||
if (outputFile) await writeOutputFile(outputFile, serialized, 'utf-8');
|
||||
|
||||
// Exit code follows the convention the other scanners use: 0 PASS,
|
||||
// 2 FAIL, 3 tool error. A failed fix used to exit 0, so a caller could not
|
||||
// tell a clean run from one that silently lost a fix.
|
||||
if (failed.length > 0) process.exitCode = 2;
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -255,6 +202,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { dirname } from 'node:path';
|
|||
import { parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { createBackup } from './lib/backup.mjs';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { VALID_EFFORT_LEVELS as SETTINGS_EFFORT_LEVELS } from './settings-validator.mjs';
|
||||
|
||||
/**
|
||||
* Fix type constants.
|
||||
|
|
@ -23,8 +22,8 @@ const FIX_TYPES = {
|
|||
FILE_RENAME: 'file-rename',
|
||||
};
|
||||
|
||||
/** Valid effortLevel values for nearest-match — the validator's list, not a copy. */
|
||||
const VALID_EFFORT_LEVELS = [...SETTINGS_EFFORT_LEVELS];
|
||||
/** Valid effortLevel values for nearest-match */
|
||||
const VALID_EFFORT_LEVELS = ['low', 'medium', 'high', 'max'];
|
||||
|
||||
/**
|
||||
* Plan fixes from a scanner envelope.
|
||||
|
|
@ -57,21 +56,9 @@ export function planFixes(envelope) {
|
|||
}
|
||||
}
|
||||
|
||||
// Sort fixes by severity weight (critical first), but a file-rename always
|
||||
// sorts after every other fix. A rename moves the file out from under any
|
||||
// later fix that still addresses the old path: a rule file with both
|
||||
// `globs:` and a non-.md extension had the rename applied first, and the
|
||||
// frontmatter fix then failed with ENOENT while the run still exited 0.
|
||||
// `?? 4`, not `|| 4`: critical weighs 0, and `0 || 4` evaluates to 4 — so
|
||||
// critical fixes sorted LAST, the exact opposite of this function's contract
|
||||
// (M-BUG-30). The old test used the same falsy fallback and agreed with the bug.
|
||||
// Sort fixes by severity weight (critical first)
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||
fixes.sort((a, b) => {
|
||||
const aRename = a.type === FIX_TYPES.FILE_RENAME ? 1 : 0;
|
||||
const bRename = b.type === FIX_TYPES.FILE_RENAME ? 1 : 0;
|
||||
if (aRename !== bRename) return aRename - bRename;
|
||||
return (severityOrder[a.severity] ?? 4) - (severityOrder[b.severity] ?? 4);
|
||||
});
|
||||
fixes.sort((a, b) => (severityOrder[a.severity] || 4) - (severityOrder[b.severity] || 4));
|
||||
|
||||
return { fixes, skipped, manual };
|
||||
}
|
||||
|
|
@ -613,29 +600,20 @@ function extractEventFromDescription(description) {
|
|||
* Verify fixes by re-running affected scanners.
|
||||
* @param {object} originalEnvelope - Original scanner envelope
|
||||
* @param {object[]} appliedResults - Results from applyFixes()
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.includeGlobal=false] - Must match the scope the fix run scanned
|
||||
* @returns {Promise<{ verified: string[], regressions: string[], newFindings: object[] }>}
|
||||
*/
|
||||
export async function verifyFixes(originalEnvelope, appliedResults, opts = {}) {
|
||||
export async function verifyFixes(originalEnvelope, appliedResults) {
|
||||
const targetPath = originalEnvelope.meta.target;
|
||||
const verified = [];
|
||||
const regressions = [];
|
||||
const newFindings = [];
|
||||
|
||||
// Re-scan the target in the SAME scope the fix run used. This was hardcoded
|
||||
// to includeGlobal:false: after a --global run, every global-scope finding
|
||||
// was absent from the re-scan and therefore counted as verified — a clean
|
||||
// "fixed" report for files nothing had touched.
|
||||
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: opts.includeGlobal === true });
|
||||
// Re-scan the target
|
||||
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: false });
|
||||
|
||||
// Build the set of fixed finding INSTANCES. A finding ID names the check, so
|
||||
// one check failing in two files yields two findings sharing an ID; keying on
|
||||
// the ID alone marks both fixed when one was, and the untouched sibling — still
|
||||
// present in the re-scan — is then reported as a regression (M-BUG-28).
|
||||
const instanceKey = (findingId, file) => `${findingId}::${file || ''}`;
|
||||
const fixedInstances = new Set(
|
||||
appliedResults.filter(r => r.status === 'applied').map(r => instanceKey(r.findingId, r.file)),
|
||||
// Build set of original finding IDs that were fixed
|
||||
const fixedIds = new Set(
|
||||
appliedResults.filter(r => r.status === 'applied').map(r => r.findingId),
|
||||
);
|
||||
|
||||
// Build set of new finding titles for comparison
|
||||
|
|
@ -649,13 +627,11 @@ export async function verifyFixes(originalEnvelope, appliedResults, opts = {}) {
|
|||
// Check that fixed findings are gone
|
||||
for (const scanner of originalEnvelope.scanners) {
|
||||
for (const f of scanner.findings) {
|
||||
if (!fixedInstances.has(instanceKey(f.id, f.file))) continue;
|
||||
if (!fixedIds.has(f.id)) continue;
|
||||
|
||||
const key = `${f.scanner}:${f.title}:${f.file}`;
|
||||
// For file-rename fixes, the original file path won't exist anymore
|
||||
const fixResult = appliedResults.find(
|
||||
r => instanceKey(r.findingId, r.file) === instanceKey(f.id, f.file),
|
||||
);
|
||||
const fixResult = appliedResults.find(r => r.findingId === f.id);
|
||||
if (fixResult && fixResult.type === 'file-rename') {
|
||||
// Check that the finding doesn't reappear at the new path
|
||||
verified.push(f.id);
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (parsed === null) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid JSON in hooks.json',
|
||||
description: `${file.relPath} contains invalid JSON. All hooks in this file will be ignored.`,
|
||||
|
|
@ -121,7 +120,6 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (typeof hooks !== 'object' || Array.isArray(hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-not-object',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Hooks must be an object with event keys',
|
||||
description: `${file.relPath}: hooks is ${Array.isArray(hooks) ? 'an array' : typeof hooks}. Expected object with event names as keys.`,
|
||||
|
|
@ -137,7 +135,6 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (!VALID_EVENTS.has(event)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unknown-event',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Unknown hook event',
|
||||
description: `${file.relPath}: "${event}" is not a valid hook event. This hook will never fire.`,
|
||||
|
|
@ -152,7 +149,6 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (!Array.isArray(handlers)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'handlers-not-array',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Hook handlers must be an array',
|
||||
description: `${file.relPath}: handlers for "${event}" is not an array.`,
|
||||
|
|
@ -170,7 +166,6 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (typeof handlerGroup.matcher === 'object') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'matcher-not-string',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Matcher must be a string, not an object',
|
||||
description: `${file.relPath}: "${event}" has a matcher that is an object. Matcher should be a simple string like "Bash" or "Edit|Write".`,
|
||||
|
|
@ -185,7 +180,6 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (!handlerGroup.hooks || !Array.isArray(handlerGroup.hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-hooks-array',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Missing hooks array in handler group',
|
||||
description: `${file.relPath}: "${event}" handler group is missing the "hooks" array.`,
|
||||
|
|
@ -201,7 +195,6 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (!hook.type || !VALID_TYPES.has(hook.type)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-handler-type',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hook handler type',
|
||||
description: `${file.relPath}: "${event}" has handler with type "${hook.type || '(missing)'}".`,
|
||||
|
|
@ -223,7 +216,6 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'script-not-found',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Hook script not found',
|
||||
description: `${file.relPath}: "${event}" references script that does not exist.`,
|
||||
|
|
@ -240,7 +232,6 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (verboseCount > VERBOSE_HOOK_LINE_THRESHOLD) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'verbose-output',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Verbose hook output (loud script)',
|
||||
description:
|
||||
|
|
@ -268,7 +259,6 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (ac.flagged) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unfiltered-additional-context',
|
||||
severity: SEVERITY.info,
|
||||
title: 'Hook injects unfiltered output into context',
|
||||
description:
|
||||
|
|
@ -297,7 +287,6 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (typeof hook.timeout !== 'number') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'timeout-not-number',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Hook timeout must be a number',
|
||||
description: `${file.relPath}: "${event}" has non-numeric timeout.`,
|
||||
|
|
@ -309,7 +298,6 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
} else if (hook.timeout < MIN_TIMEOUT || hook.timeout > MAX_TIMEOUT) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'timeout-out-of-range',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Hook timeout outside recommended range',
|
||||
description: `${file.relPath}: "${event}" timeout is ${hook.timeout}ms. Recommended range: ${MIN_TIMEOUT}-${MAX_TIMEOUT}ms.`,
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ async function walkImports(file, chain, reported, findings) {
|
|||
reported.add(`tilde::${resolved}`);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'tilde-path',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Tilde path in @import',
|
||||
description: `@${imp.path} uses ~ which may not expand correctly in all contexts.`,
|
||||
|
|
@ -92,7 +91,6 @@ async function walkImports(file, chain, reported, findings) {
|
|||
reported.add(reportKey);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'broken-link',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Broken @import link',
|
||||
description: `@${imp.path} references a file that does not exist.`,
|
||||
|
|
@ -113,7 +111,6 @@ async function walkImports(file, chain, reported, findings) {
|
|||
const cycle = chain.slice(cycleStart).map(f => basename(f)).join(' → ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'circular-reference',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Circular @import reference',
|
||||
description: `@${imp.path} creates a circular import chain.`,
|
||||
|
|
@ -132,7 +129,6 @@ async function walkImports(file, chain, reported, findings) {
|
|||
reported.add(`deep::${resolved}`);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'deep-chain',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Deep @import chain',
|
||||
description: `@${imp.path} is at depth ${chain.length} (>${MAX_CHAIN_DEPTH} hops).`,
|
||||
|
|
|
|||
|
|
@ -24,32 +24,19 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { loadRegister, REGISTER_PATH } from './lib/best-practices-register.mjs';
|
||||
import { assessFreshness, STALE_AFTER_DAYS_DEFAULT } from './lib/knowledge-refresh.mjs';
|
||||
import { findArgError } from './lib/cli-args.mjs';
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Usage error. Throws rather than calling process.exit(): exit() discards
|
||||
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
|
||||
* `Error: ` text and sets the same exit code 3, so callers see no difference.
|
||||
*/
|
||||
class CliUsageError extends Error {}
|
||||
|
||||
function fail(message) {
|
||||
throw new CliUsageError(message);
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
/** Flag surface, measured 2026-08-09. The gate runs BEFORE the loop below, so the
|
||||
* loop no longer needs to re-check that a value followed its flag. */
|
||||
const ARG_SPEC = { boolean: ['--dry-run'], value: ['--output-file', '--stale-after', '--reference-date'] };
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const argError = findArgError(args, ARG_SPEC);
|
||||
if (argError) fail(argError);
|
||||
let outputFile = null;
|
||||
let staleAfterDays = STALE_AFTER_DAYS_DEFAULT;
|
||||
let referenceDate = null; // null → today
|
||||
|
|
@ -58,12 +45,12 @@ async function main() {
|
|||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--dry-run') dryRun = true;
|
||||
else if (a === '--output-file') outputFile = args[++i];
|
||||
else if (a === '--stale-after') {
|
||||
else if (a === '--output-file' && args[i + 1]) outputFile = args[++i];
|
||||
else if (a === '--stale-after' && args[i + 1] !== undefined) {
|
||||
const n = Number.parseInt(args[++i], 10);
|
||||
if (!Number.isInteger(n) || n < 0) fail('--stale-after must be a non-negative integer (days)');
|
||||
staleAfterDays = n;
|
||||
} else if (a === '--reference-date') {
|
||||
} else if (a === '--reference-date' && args[i + 1]) {
|
||||
referenceDate = args[++i];
|
||||
if (!DATE_RE.test(referenceDate)) fail('--reference-date must be YYYY-MM-DD');
|
||||
}
|
||||
|
|
@ -100,18 +87,17 @@ async function main() {
|
|||
};
|
||||
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
|
||||
process.exitCode = assessment.counts.stale > 0 ? 1 : 0;
|
||||
process.exit(assessment.counts.stale > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
const isDirectRun =
|
||||
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch((err) => {
|
||||
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
|
||||
process.stderr.write(`${prefix}: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -817,7 +817,7 @@ export async function enumerateRules(repoPath, pluginList = []) {
|
|||
*
|
||||
* @param {string} repoPath
|
||||
* @param {Array<{name:string, path:string}>} [pluginList]
|
||||
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, bytes:number, estimatedTokens:number, model:string|null, effort:string|null, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
|
||||
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
|
||||
*/
|
||||
export async function enumerateAgents(repoPath, pluginList = []) {
|
||||
const out = [];
|
||||
|
|
@ -842,11 +842,6 @@ export async function enumerateAgents(repoPath, pluginList = []) {
|
|||
path: f.path,
|
||||
bytes: f.size,
|
||||
estimatedTokens: estimateTokens(f.size, 'frontmatter'),
|
||||
// Routing axes (C4). Explicit null rather than an absent key: `model`
|
||||
// defaults to `inherit` and `effort` to the session level, so a consumer
|
||||
// must be able to read "not pinned" without guessing (BP-MODEL-001/002).
|
||||
model: hasText(frontmatter && frontmatter.model) ? frontmatter.model.trim() : null,
|
||||
effort: hasText(frontmatter && frontmatter.effort) ? frontmatter.effort.trim() : null,
|
||||
...lp,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,29 +10,15 @@ import { join, basename } from 'node:path';
|
|||
import { createHash } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const BACKUP_ROOT = join(homedir(), '.config-audit', 'backups');
|
||||
const MAX_BACKUPS = 10;
|
||||
|
||||
/**
|
||||
* Get the backup root directory path.
|
||||
*
|
||||
* Canonical location is `~/.claude/config-audit/backups` — the path every
|
||||
* command, agent and doc uses. `CONFIG_AUDIT_BACKUP_ROOT` overrides it so tests
|
||||
* never write into the operator's real home.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getBackupDir() {
|
||||
return process.env.CONFIG_AUDIT_BACKUP_ROOT
|
||||
|| join(homedir(), '.claude', 'config-audit', 'backups');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pre-v2.2.0 backup root. Read-only: nothing writes here any more, but
|
||||
* backups made before the move must stay listable and restorable.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getLegacyBackupDir() {
|
||||
return process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT
|
||||
|| join(homedir(), '.config-audit', 'backups');
|
||||
return BACKUP_ROOT;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -77,7 +63,7 @@ export function checksum(content) {
|
|||
*/
|
||||
export function createBackup(files, opts = {}) {
|
||||
const backupId = opts.backupId || generateBackupId();
|
||||
const backupPath = join(getBackupDir(), backupId);
|
||||
const backupPath = join(BACKUP_ROOT, backupId);
|
||||
const filesDir = join(backupPath, 'files');
|
||||
|
||||
mkdirSync(filesDir, { recursive: true });
|
||||
|
|
@ -142,7 +128,7 @@ function serializeManifest(manifest) {
|
|||
* @returns {object}
|
||||
*/
|
||||
export function parseManifest(content) {
|
||||
const result = { created_at: '', backup_id: '', files: [], created: [] };
|
||||
const result = { created_at: '', backup_id: '', files: [] };
|
||||
|
||||
const createdMatch = content.match(/created_at:\s*"([^"]+)"/);
|
||||
if (createdMatch) result.created_at = createdMatch[1];
|
||||
|
|
@ -150,7 +136,7 @@ export function parseManifest(content) {
|
|||
const idMatch = content.match(/backup_id:\s*"([^"]+)"/);
|
||||
if (idMatch) result.backup_id = idMatch[1];
|
||||
|
||||
// Parse file entries — engine format (quoted `original_path:` …).
|
||||
// Parse file entries
|
||||
const fileBlocks = content.split(/\n\s+-\s+original_path:/).slice(1);
|
||||
for (const block of fileBlocks) {
|
||||
const origMatch = block.match(/^\s*"([^"]+)"/);
|
||||
|
|
@ -168,45 +154,6 @@ export function parseManifest(content) {
|
|||
}
|
||||
}
|
||||
|
||||
// Parse file entries — implement-flow format. `commands/implement.md` has the
|
||||
// agent hand-build the backup dir, so real manifests on disk use unquoted
|
||||
// `- backup:` / `original:` / `sha256:`. Reading only the engine format made
|
||||
// restoreBackup a success-shaped no-op on every backup implement produced.
|
||||
if (result.files.length === 0) {
|
||||
const implBlocks = content.split(/\n\s+-\s+backup:/).slice(1);
|
||||
for (const block of implBlocks) {
|
||||
const bpMatch = block.match(/^\s*(\S+)/);
|
||||
const origMatch = block.match(/original:\s*(\S+)/);
|
||||
const csMatch = block.match(/sha256:\s*(\S+)/);
|
||||
|
||||
if (origMatch && bpMatch && csMatch) {
|
||||
result.files.push({
|
||||
originalPath: origMatch[1],
|
||||
backupPath: bpMatch[1],
|
||||
checksum: csMatch[1],
|
||||
sizeBytes: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.backup_id) {
|
||||
const implId = content.match(/^created:\s*(\S+)\s*$/m);
|
||||
if (implId) result.backup_id = implId[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Files the implement step CREATED. A backup cannot hold a file that did not
|
||||
// exist, so rollback can never restore these — but it must be able to say so.
|
||||
const lines = content.split('\n');
|
||||
const createdAt = lines.findIndex(l => /^created:[ \t]*$/.test(l));
|
||||
if (createdAt !== -1) {
|
||||
for (const line of lines.slice(createdAt + 1)) {
|
||||
const item = line.match(/^[ \t]+-[ \t]+(\S+)[ \t]*$/);
|
||||
if (!item) break;
|
||||
result.created.push(item[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -214,10 +161,9 @@ export function parseManifest(content) {
|
|||
* Remove old backups beyond MAX_BACKUPS.
|
||||
*/
|
||||
function cleanupOldBackups() {
|
||||
const backupRoot = getBackupDir();
|
||||
if (!existsSync(backupRoot)) return;
|
||||
if (!existsSync(BACKUP_ROOT)) return;
|
||||
|
||||
const dirs = readdirSync(backupRoot, { withFileTypes: true })
|
||||
const dirs = readdirSync(BACKUP_ROOT, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
.sort();
|
||||
|
|
@ -225,7 +171,7 @@ function cleanupOldBackups() {
|
|||
if (dirs.length > MAX_BACKUPS) {
|
||||
const toDelete = dirs.slice(0, dirs.length - MAX_BACKUPS);
|
||||
for (const dir of toDelete) {
|
||||
rmSync(join(backupRoot, dir), { recursive: true, force: true });
|
||||
rmSync(join(BACKUP_ROOT, dir), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
/**
|
||||
* Argv precondition shared by the CLIs — the companion to `require-target-dir`.
|
||||
*
|
||||
* Every CLI here parses argv with a chain of `if (a === '--x') … else if …`.
|
||||
* Two things fall through that chain silently:
|
||||
*
|
||||
* 1. **An unknown flag.** With no `else` branch, `--zzz` leaves no trace: the
|
||||
* CLI exits 0 with a full payload — a confident answer to a question the
|
||||
* caller did not ask. Measured live (#51): `knowledge-refresh`'s only knob
|
||||
* reached the CLI malformed, was ignored, and the command reported "all 14
|
||||
* entries re-verified within the last 90 days" about a threshold the user
|
||||
* had just overridden.
|
||||
*
|
||||
* 2. **A value-taking flag whose value is another flag.** The guard
|
||||
* `a === '--output-file' && args[i + 1]` asks only whether a next token
|
||||
* exists, never whether it is a *value*. Measured live (#57):
|
||||
* `manifest --output-file --json` wrote a file literally named `--json`
|
||||
* into the caller's working directory, exit 0, with `--json` mode silently
|
||||
* dropped. A wrong answer is bad; an unintended file on disk is worse.
|
||||
*
|
||||
* This runs BEFORE the CLI's own loop and does not replace it. That is
|
||||
* deliberate: valid argv reaches the existing parser byte-for-byte unchanged, so
|
||||
* no frozen snapshot can move. Malformed argv never reaches it at all.
|
||||
*
|
||||
* By the exit-code contract, a malformed argument is exit **3** — the scanner
|
||||
* did not get to do its job — never 0/1/2, which are verdicts about a
|
||||
* configuration that WAS examined.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Find the first thing wrong with `args`.
|
||||
*
|
||||
* @param {string[]} args - argv slice (no node/script entries).
|
||||
* @param {{ boolean?: string[], value?: string[] }} spec - the CLI's flag surface.
|
||||
* @returns {string|null} diagnostic, or null when argv is well-formed.
|
||||
*/
|
||||
export function findArgError(args, spec) {
|
||||
const booleanFlags = new Set(spec.boolean || []);
|
||||
const valueFlags = new Set(spec.value || []);
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
|
||||
// Positionals and subcommands are the CLI's own business.
|
||||
if (!a.startsWith('-')) continue;
|
||||
if (booleanFlags.has(a)) continue;
|
||||
|
||||
if (valueFlags.has(a)) {
|
||||
const next = args[i + 1];
|
||||
if (next === undefined) {
|
||||
return `flag "${a}" needs a value, but nothing followed it`;
|
||||
}
|
||||
if (next.startsWith('-')) {
|
||||
return `flag "${a}" needs a value, but the next argument was another flag: "${next}"`;
|
||||
}
|
||||
i++; // consume the value so it is never re-examined as a positional
|
||||
continue;
|
||||
}
|
||||
|
||||
return `unknown flag "${a}"`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a CLI on well-formed argv. Writes the diagnostic and sets the exit code
|
||||
* itself, so callers stay a two-line guard:
|
||||
*
|
||||
* if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
*
|
||||
* Never throws, and never calls `process.exit()` — an abrupt exit discards
|
||||
* unflushed stdout when the CLI is on a pipe.
|
||||
*
|
||||
* @param {string[]} args - argv slice.
|
||||
* @param {{ boolean?: string[], value?: string[] }} spec - the CLI's flag surface.
|
||||
* @returns {boolean} true when the CLI may proceed.
|
||||
*/
|
||||
export function requireValidArgs(args, spec) {
|
||||
const error = findArgError(args, spec);
|
||||
if (error === null) return true;
|
||||
process.stderr.write(`Error: ${error}\n`);
|
||||
process.exitCode = 3;
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1,320 +0,0 @@
|
|||
/**
|
||||
* Finding-code registry — the authority for the {NNN} in `CA-{SCANNER}-{NNN}`.
|
||||
*
|
||||
* A finding ID names the CHECK, not the finding's position in a run (M-BUG-28).
|
||||
* Before this registry, `{NNN}` came from an emission counter, so the same check
|
||||
* carried different IDs on different configurations: fixing an unrelated earlier
|
||||
* gap silently renumbered every later one, and a `.config-audit-ignore` entry
|
||||
* retargeted to a neighbouring finding without the user changing anything.
|
||||
*
|
||||
* Rules for editing this file:
|
||||
*
|
||||
* 1. A number, once published, belongs to its check forever. Adding a check
|
||||
* takes the next free number for that scanner — never the next source-order
|
||||
* position, and never a number listed in RETIRED.
|
||||
* 2. Removing a check moves its key to RETIRED. The number is never reissued;
|
||||
* a user's suppression must go dead rather than quietly point at a
|
||||
* different finding. (D1 retired GAP `t3_8` under the old scheme, which is
|
||||
* the incident that motivated the registry.)
|
||||
* 3. Several call sites may share one code when they are arms of one check —
|
||||
* e.g. the forward/reverse arms of a permission conflict. Duplicate
|
||||
* emission is legal; a finding is identified by (id, file, line).
|
||||
* 4. Numbers below are NOT all source order: the ones marked "documented"
|
||||
* are pinned by README / command copy that shipped before the registry.
|
||||
*
|
||||
* GAP keys are the `GAP_CHECKS[].id` values from `feature-gap-scanner.mjs`,
|
||||
* which were already stable. They are declared here rather than derived, so
|
||||
* numbers live in exactly one place; `tests/lib/finding-codes.test.mjs` binds
|
||||
* the two together instead of a second copy of the table drifting.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {Record<string, Record<string, number>>}
|
||||
* scanner prefix → check key → number
|
||||
*/
|
||||
export const FINDING_CODES = {
|
||||
// ── CML: claude-md-linter (source order) ────────────────────────────────
|
||||
// `over-char-budget` has two call sites: the conservative 200k anchor and the
|
||||
// `--context-window` calibrated variant. One check, one code.
|
||||
CML: {
|
||||
'no-claude-md': 1,
|
||||
'nested-not-reinjected': 2,
|
||||
'over-500-lines': 3,
|
||||
'over-200-lines': 4,
|
||||
'over-char-budget': 5,
|
||||
'nearly-empty': 6,
|
||||
'missing-sections': 7,
|
||||
'no-headings': 8,
|
||||
'deep-relative-import': 9,
|
||||
'html-comments': 10,
|
||||
'repeated-content': 11,
|
||||
'todo-markers': 12,
|
||||
},
|
||||
|
||||
// ── SET: settings-validator (source order) ──────────────────────────────
|
||||
SET: {
|
||||
'invalid-json': 1,
|
||||
'key-typo': 2,
|
||||
'deprecated-key': 3,
|
||||
'type-mismatch': 4,
|
||||
'invalid-effort-level': 5,
|
||||
'missing-schema': 6,
|
||||
'no-deny-rules': 7,
|
||||
'no-allow-rules': 8,
|
||||
'many-additional-dirs': 9,
|
||||
'automode-not-object': 10,
|
||||
'automode-unknown-subkey': 11,
|
||||
'automode-subkey-not-string-array': 12,
|
||||
'automode-in-shared-settings': 13,
|
||||
'hooks-as-array': 14,
|
||||
},
|
||||
|
||||
// ── HKV: hook-validator (source order) ──────────────────────────────────
|
||||
HKV: {
|
||||
'invalid-json': 1,
|
||||
'hooks-not-object': 2,
|
||||
'unknown-event': 3,
|
||||
'handlers-not-array': 4,
|
||||
'matcher-not-string': 5,
|
||||
'missing-hooks-array': 6,
|
||||
'invalid-handler-type': 7,
|
||||
'script-not-found': 8,
|
||||
'verbose-output': 9,
|
||||
'unfiltered-additional-context': 10,
|
||||
'timeout-not-number': 11,
|
||||
'timeout-out-of-range': 12,
|
||||
},
|
||||
|
||||
// ── RUL: rules-validator (source order) ─────────────────────────────────
|
||||
RUL: {
|
||||
'no-frontmatter': 1,
|
||||
'globs-instead-of-paths': 2,
|
||||
'pattern-matches-nothing': 3,
|
||||
'nearly-empty': 4,
|
||||
'large-unscoped': 5,
|
||||
'large-scoped-lost-after-compaction': 6,
|
||||
'not-markdown': 7,
|
||||
},
|
||||
|
||||
// ── MCP: mcp-config-validator (source order) ────────────────────────────
|
||||
MCP: {
|
||||
'invalid-json': 1,
|
||||
'unknown-server-type': 2,
|
||||
'sse-transport': 3,
|
||||
'unreferenced-env-var': 4,
|
||||
'unknown-server-field': 5,
|
||||
},
|
||||
|
||||
// ── IMP: import-resolver (source order) ─────────────────────────────────
|
||||
IMP: {
|
||||
'tilde-path': 1,
|
||||
'broken-link': 2,
|
||||
'circular-reference': 3,
|
||||
'deep-chain': 4,
|
||||
},
|
||||
|
||||
// ── CNF: conflict-detector ──────────────────────────────────────────────
|
||||
// `permission-allow-deny` covers both arms (allow-in-A/deny-in-B and reverse).
|
||||
CNF: {
|
||||
'settings-key-conflict': 1,
|
||||
'permission-allow-deny': 2,
|
||||
'duplicate-hook': 3,
|
||||
},
|
||||
|
||||
// ── DIS: disabled-in-schema-scanner (source order) ──────────────────────
|
||||
DIS: {
|
||||
'deny-and-allow': 1,
|
||||
'ineffective-allow-wildcard': 2,
|
||||
'forbidden-param-deny': 3,
|
||||
'forbidden-param-allow': 4,
|
||||
},
|
||||
|
||||
// ── CPS: cache-prefix-scanner (CPS-001 documented) ──────────────────────
|
||||
CPS: {
|
||||
'volatile-in-prefix': 1,
|
||||
'volatile-in-import': 2,
|
||||
},
|
||||
|
||||
// ── COL: collision-scanner (source order) ───────────────────────────────
|
||||
COL: {
|
||||
'skill-user-vs-plugin': 1,
|
||||
'skill-multi-plugin': 2,
|
||||
},
|
||||
|
||||
// ── AGT: agent-listing-scanner (both documented; source order matches) ──
|
||||
AGT: {
|
||||
'description-bloat': 1,
|
||||
'aggregate-listing-budget': 2,
|
||||
},
|
||||
|
||||
// ── OST: output-style-scanner (all three documented) ────────────────────
|
||||
OST: {
|
||||
'strips-coding-instructions': 1,
|
||||
'plugin-forces-style': 2,
|
||||
'style-not-found': 3,
|
||||
},
|
||||
|
||||
// ── OPT: optimization-lens-scanner (documented) ─────────────────────────
|
||||
OPT: {
|
||||
'procedure-should-be-skill': 1,
|
||||
},
|
||||
|
||||
// ── SKL: skill-listing-scanner (all three documented) ───────────────────
|
||||
// `aggregate-listing-budget` has two call sites: the conservative 200k anchor
|
||||
// and the calibrated `--context-window` variant. One check, one code.
|
||||
SKL: {
|
||||
'description-over-cap': 1,
|
||||
'aggregate-listing-budget': 2,
|
||||
'oversized-body': 3,
|
||||
},
|
||||
|
||||
// ── TOK: token-hotspots ─────────────────────────────────────────────────
|
||||
// 1/2/3/5/6 are documented (README + commands/tokens.md). `mcp-schema-deferral`
|
||||
// is documented as 006 although it is the 8th call site in source order, so
|
||||
// `cascade-over-budget` and `stale-plugin-cache` take the free 7 and 8.
|
||||
TOK: {
|
||||
'volatile-top': 1,
|
||||
'redundant-permissions': 2,
|
||||
'deep-import-chain': 3,
|
||||
'bloated-skill-description': 4,
|
||||
'mcp-schema-budget': 5,
|
||||
'mcp-schema-deferral': 6,
|
||||
'cascade-over-budget': 7,
|
||||
'stale-plugin-cache': 8,
|
||||
},
|
||||
|
||||
// ── PLH: plugin-health-scanner ──────────────────────────────────────────
|
||||
// 15 and 16 are documented (README v5.4.0 entry) but sit at source positions
|
||||
// 3 and 4; the remaining checks take {1…14, 17, 18, 19} in source order.
|
||||
PLH: {
|
||||
'invalid-plugin-json': 1,
|
||||
'missing-required-field': 2,
|
||||
'missing-plugin-json': 3,
|
||||
'claude-md-missing-section': 4,
|
||||
'missing-claude-md': 5,
|
||||
'command-missing-frontmatter': 6,
|
||||
'command-missing-field': 7,
|
||||
'agent-missing-frontmatter': 8,
|
||||
'agent-missing-field': 9,
|
||||
'agent-ignored-key': 10,
|
||||
'hooks-json-invalid-structure': 11,
|
||||
'hooks-json-array': 12,
|
||||
'hooks-json-invalid': 13,
|
||||
'unknown-plugin-file': 14,
|
||||
'plugin-json-shadows-default': 15,
|
||||
'skills-array-entry': 16,
|
||||
'no-plugins-found': 17,
|
||||
'command-name-collision': 18,
|
||||
'namespace-collision': 19,
|
||||
},
|
||||
|
||||
// ── GAP: feature-gap-scanner ────────────────────────────────────────────
|
||||
// Keys are GAP_CHECKS[].id for dimensions, and the lever code for the
|
||||
// conditional levers the scanner emits after the loop. Numbers 1–24 happen to
|
||||
// follow the current table order because that is how the dimensions were first
|
||||
// published — NOT because position determines the number. A new check takes the
|
||||
// next free number wherever it sits in the file (M-BUG-28).
|
||||
GAP: {
|
||||
t1_1: 1,
|
||||
t1_2: 2,
|
||||
t1_3: 3,
|
||||
t1_4: 4,
|
||||
t1_5: 5,
|
||||
t2_1: 6,
|
||||
t2_2: 7,
|
||||
t2_3: 8,
|
||||
t2_4: 9,
|
||||
t2_5: 10,
|
||||
t2_6: 11,
|
||||
t2_7: 12,
|
||||
t3_1: 13,
|
||||
t3_2: 14,
|
||||
t3_3: 15,
|
||||
t3_4: 16,
|
||||
t3_5: 17,
|
||||
t3_6: 18,
|
||||
t3_7: 19,
|
||||
t4_1: 20,
|
||||
t4_2: 21,
|
||||
t4_3: 22,
|
||||
t4_4: 23,
|
||||
t4_5: 24,
|
||||
'bundled-skills-lever': 25,
|
||||
'cli-over-mcp-lever': 26,
|
||||
'filter-hook-output-lever': 27,
|
||||
'agent-model-routing-lever': 28,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys withdrawn from a scanner. Their numbers are never reissued, so a stale
|
||||
* suppression goes dead instead of silently naming a different check.
|
||||
* @type {Record<string, string[]>}
|
||||
*/
|
||||
export const RETIRED_CODES = {
|
||||
// D1 (4027cdc, 2026-08-09): "No autoMode classifier" — /doctor Check 8 covers
|
||||
// auto mode with usage-weighted judgement, so the nudge went. The number it
|
||||
// occupied under the old counter scheme is not reused.
|
||||
GAP: ['t3_8'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a check key to its published number.
|
||||
* Throws rather than falling back: a fallback would let a half-converted scanner
|
||||
* ship IDs that look valid, which is the silent-degradation class this registry
|
||||
* exists to remove.
|
||||
* @param {string} scanner - scanner prefix, e.g. 'GAP'
|
||||
* @param {string} code - check key, e.g. 't3_7'
|
||||
* @returns {number}
|
||||
*/
|
||||
export function codeNumber(scanner, code) {
|
||||
const table = FINDING_CODES[scanner];
|
||||
if (!table) {
|
||||
throw new Error(`finding(): unknown scanner "${scanner}" — add it to FINDING_CODES`);
|
||||
}
|
||||
if (code === undefined || code === null || code === '') {
|
||||
throw new Error(`finding(): missing "code" for scanner ${scanner} — every finding must name its check`);
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(table, code)) {
|
||||
const retired = (RETIRED_CODES[scanner] || []).includes(code);
|
||||
throw new Error(
|
||||
retired
|
||||
? `finding(): check "${code}" is RETIRED for ${scanner} — retired numbers are never reissued`
|
||||
: `finding(): undeclared check "${code}" for ${scanner} — add it to FINDING_CODES with the next free number`
|
||||
);
|
||||
}
|
||||
return table[code];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a finding ID from a check key.
|
||||
* @param {string} scanner
|
||||
* @param {string} code
|
||||
* @returns {string} e.g. 'CA-GAP-019'
|
||||
*/
|
||||
export function findingId(scanner, code) {
|
||||
return `CA-${scanner}-${String(codeNumber(scanner, code)).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every declared ID, as a flat set — used to validate suppression patterns so a
|
||||
* stale pin is reported instead of silently matching nothing.
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function allFindingIds() {
|
||||
const ids = new Set();
|
||||
for (const [scanner, table] of Object.entries(FINDING_CODES)) {
|
||||
for (const n of Object.values(table)) {
|
||||
ids.add(`CA-${scanner}-${String(n).padStart(3, '0')}`);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scanner prefixes the registry knows about.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function knownScanners() {
|
||||
return Object.keys(FINDING_CODES);
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
/**
|
||||
* floor-exclusion — the deterministic veto that runs BEFORE the subtraction
|
||||
* judge sees anything (v5.13, brief §6.0 / §7 q2).
|
||||
*
|
||||
* The subtraction lens asks "what no longer earns its always-loaded rent?", and
|
||||
* that question is only safe to ask because this module answers a prior one
|
||||
* first: **which blocks are not eligible to be asked about at all?**
|
||||
*
|
||||
* Brief §6.0 splits CLAUDE.md content on one axis:
|
||||
*
|
||||
* compensatory — corrects model *behaviour* ("think before you code").
|
||||
* A more capable model does it unprompted. Deletable.
|
||||
* load-bearing — a *local fact* no amount of intelligence derives from the
|
||||
* codebase ("only Forgejo at git.example.test", "system bash
|
||||
* is 3.2"). FLOOR. Never a deletion candidate.
|
||||
*
|
||||
* Why this is deterministic and not the judge's call: precision is asymmetric.
|
||||
* A missed dead line costs a few tokens per turn; a deleted load-bearing line
|
||||
* costs a wrong remote, a broken script, or a lost afternoon. A blocking
|
||||
* guarantee must not rest on a probabilistic prose judgement, so the floor is
|
||||
* decided here, in code, and the judge only ever ranks what survives.
|
||||
*
|
||||
* The veto keys on **underivable local literals** — the textual fingerprints of
|
||||
* a fact that came from this machine rather than from general engineering
|
||||
* knowledge: an inline code span, a path, a domain, a version pin, a concrete
|
||||
* filename. Plus §6.0's explicit carve-out: policy invariants (secrets,
|
||||
* credentials, production, destructive operations) are floor *by decision, not
|
||||
* by classification* — the model would probably honour them unprompted, but the
|
||||
* cost of being wrong is asymmetric and their token cost is trivial.
|
||||
*
|
||||
* Deliberately over-broad. A false veto costs recall (a dead line survives
|
||||
* another turn); a false clearance costs the guarantee. When in doubt: floor.
|
||||
*
|
||||
* Zero external dependencies. Pure: input text → boolean.
|
||||
*/
|
||||
|
||||
/** An inline code span — the single strongest local-literal signal. */
|
||||
const CODE_SPAN_RE = /`[^`\n]+`/;
|
||||
|
||||
/** A URL or a bare hostname. `.test`/`.local` included for fixtures. */
|
||||
const URL_RE = /https?:\/\/|\b[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+\.(?:com|org|net|io|dev|sh|no|test|local|ai)\b/i;
|
||||
|
||||
/**
|
||||
* A rooted path (`~/x`, `./x`, `/Users/x`) or a glob. Deliberately does NOT
|
||||
* match a bare `word/word`: "pros/cons" is not a path, and treating it as one
|
||||
* vetoed the single largest deletable block in the dogfood run. Real filenames
|
||||
* are FILENAME_RE's job, and backticked paths are CODE_SPAN_RE's.
|
||||
*/
|
||||
const PATH_RE = /(?:^|[\s(«"'])(?:~|\.{1,2})?\/[\w.~/*-]+|\*\*?\//;
|
||||
|
||||
/** A concrete filename with a known extension (STATE.md, .zshenv, foo.sh). */
|
||||
const FILENAME_RE =
|
||||
/\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|md|json|ya?ml|toml|go|rs|java|rb|php|c|cpp|h|hpp|sh|sql|css|scss|html|env|template|lock)\b|(?:^|\s)\.\w+rc\b|\bzshenv\b/;
|
||||
|
||||
/**
|
||||
* A version pin — "bash 3.2", "Opus 4.8", "v5.12.5". A number that specific is
|
||||
* a fact about this machine's world, not general knowledge.
|
||||
*/
|
||||
const VERSION_RE = /\bv?\d+\.\d+(?:\.\d+)?\b/;
|
||||
|
||||
/**
|
||||
* §6.0's carve-out. These stay in the floor by decision: the downside is
|
||||
* asymmetric and the lines are cheap. Do not let a "the model knows this now"
|
||||
* argument reach them.
|
||||
*/
|
||||
const POLICY_RE =
|
||||
/\b(?:secret|secrets|credential|credentials|password|passphrase|api[\s-]?key|access[\s-]?token|keychain|\.env|production|prod|force[\s-]push|rm\s+-rf|destructive|hemmelighet|passord|untrusted|injection|prompt[\s-]injection|angrepsflate|attack[\s-]surface|exfiltrat\w*)\b/i;
|
||||
|
||||
/**
|
||||
* An unresolved local entity: a mixed-case capitalized word appearing
|
||||
* mid-sentence. In config prose that is almost always a product, service or
|
||||
* tool name — "push til deres egne Forgejo-remotes", "Bruk Explore for søk" —
|
||||
* i.e. exactly the local vocabulary that makes a line underivable, but with no
|
||||
* literal syntax for the other markers to key on.
|
||||
*
|
||||
* This marker is the CONSERVATIVE DEFAULT, and it is deliberately blunt: the
|
||||
* mechanism cannot tell "Forgejo" from an ordinary capitalized word without a
|
||||
* dictionary, so it declines to decide and keeps the block. Any finer rule
|
||||
* (lowercase-form-appears-elsewhere, curated entity lists) is a proxy for a
|
||||
* dictionary that would be tuned against one machine's config and fail silently
|
||||
* on the next one. Paying in recall is the direction brief §6.0 mandates.
|
||||
*
|
||||
* All-caps tokens are exempt: this config's emphasis convention is ALDRI /
|
||||
* ALLTID / FØR / MÅ, and acronyms like AI and TDD are generic, not local.
|
||||
*
|
||||
* "Mid-sentence" is keyed on a preceding LOWERCASE letter (or comma) — not on
|
||||
* "anything that is not a full stop". The looser version cost 4 of 11 deletable
|
||||
* groups in the dogfood run by firing on `**Bold labels:**` and on quoted
|
||||
* sentence starts (`"Som AI kan jeg ikke…"`), both of which are sentence
|
||||
* openings dressed in punctuation rather than local vocabulary.
|
||||
*/
|
||||
const ENTITY_RE = /[a-zæøå,;]\s+(?![A-ZÆØÅ]{2,}\b)[A-ZÆØÅ][a-zæøå][\wæøåÆØÅ-]*/;
|
||||
|
||||
/** The ordered veto table — exported so a finding can cite *why* it was floored. */
|
||||
export const FLOOR_MARKERS = Object.freeze([
|
||||
{ name: 'code-span', re: CODE_SPAN_RE, why: 'contains an inline code literal' },
|
||||
{ name: 'url', re: URL_RE, why: 'names a specific host or URL' },
|
||||
{ name: 'filename', re: FILENAME_RE, why: 'names a concrete file' },
|
||||
{ name: 'path', re: PATH_RE, why: 'names a concrete path' },
|
||||
{ name: 'version', re: VERSION_RE, why: 'pins a specific version' },
|
||||
{ name: 'policy', re: POLICY_RE, why: 'is a policy invariant (floor by decision, §6.0)' },
|
||||
{ name: 'unresolved-entity', re: ENTITY_RE, why: 'names a capitalized entity the mechanism cannot resolve' },
|
||||
]);
|
||||
|
||||
/**
|
||||
* The first floor marker present in `text`, or null if the text carries no
|
||||
* underivable local fact.
|
||||
* @param {string} text
|
||||
* @returns {{name:string, why:string}|null}
|
||||
*/
|
||||
export function floorMarker(text) {
|
||||
const s = String(text == null ? '' : text);
|
||||
for (const m of FLOOR_MARKERS) {
|
||||
if (m.re.test(s)) return { name: m.name, why: m.why };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the block must never be proposed for deletion.
|
||||
* @param {string} text
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLoadBearing(text) {
|
||||
return floorMarker(text) !== null;
|
||||
}
|
||||
|
|
@ -435,12 +435,12 @@ export const TRANSLATIONS = {
|
|||
recommendation: 'Consider moving team-wide settings to project scope and keeping personal ones at user or local scope.',
|
||||
},
|
||||
'CLAUDE.md not modular': {
|
||||
title: 'Your instructions all live in one file',
|
||||
description: 'Splitting your instructions into smaller linked files with `@import` or `.claude/rules/` keeps each part focused and easier to maintain.',
|
||||
title: 'Your instructions file is one big block',
|
||||
description: 'Splitting long instructions into smaller linked files makes them easier to maintain and easier on the loading time.',
|
||||
recommendation: 'Break out long sections into separate files and link them with `@import`.',
|
||||
},
|
||||
'No path-scoped rules': {
|
||||
title: 'You haven\'t set up path-scoped rules yet',
|
||||
title: 'Your rules all load on every conversation',
|
||||
description: 'Path-scoped rules only load when you\'re working with files that match — keeps each conversation focused.',
|
||||
recommendation: 'Add scoping to your rules so they only load for the files they apply to.',
|
||||
},
|
||||
|
|
@ -490,7 +490,7 @@ export const TRANSLATIONS = {
|
|||
recommendation: 'Add fields like `model`, `tools`, or `description` to your skill files where useful.',
|
||||
},
|
||||
'No subagent isolation': {
|
||||
title: 'You haven\'t set up subagent isolation yet',
|
||||
title: 'Your subagents share Claude\'s main work folder',
|
||||
description: 'Isolated subagents run in their own copy of the repo so they can\'t accidentally disturb your main work.',
|
||||
recommendation: 'Add `isolation: worktree` to subagents that do destructive or experimental work.',
|
||||
},
|
||||
|
|
@ -499,6 +499,11 @@ export const TRANSLATIONS = {
|
|||
description: 'Dynamic context lets a skill see fresh information (file contents, command output) at the moment it runs, not at the time it was written.',
|
||||
recommendation: 'Use the dynamic-context block in skills that need up-to-date information.',
|
||||
},
|
||||
'No autoMode classifier': {
|
||||
title: 'You haven\'t set up auto-mode classification',
|
||||
description: 'Auto-mode classification helps Claude decide when to act on its own vs. ask you, based on the kind of task.',
|
||||
recommendation: 'Add an auto-mode classifier in your settings if you want this nuance.',
|
||||
},
|
||||
'No project .mcp.json in git': {
|
||||
title: 'Your team has no shared list of connected services',
|
||||
description: 'Without a project-level connected-services file, every teammate has to set up their own connections.',
|
||||
|
|
@ -524,30 +529,6 @@ export const TRANSLATIONS = {
|
|||
description: 'Language-server connections let Claude see types, error messages, and definitions the same way your editor does.',
|
||||
recommendation: 'Set up LSP integration if you work in a typed language.',
|
||||
},
|
||||
// Conditional levers. These are not "a feature you haven't set up" — they
|
||||
// fire only under a measured condition, so the generic _default would
|
||||
// misdescribe them. Every title the scanner can emit needs an entry here
|
||||
// (guarded in tests/scanners/feature-gap-scanner.test.mjs).
|
||||
'Bundled skills add to an over-budget skill listing': {
|
||||
title: 'Built-in skills are crowding an already-full skill list',
|
||||
description: 'Claude Code loads its own built-in skills into the same limited list as yours. Your list is already over budget, so entries risk being cut off and Claude may miss the right skill.',
|
||||
recommendation: 'Turn off the built-in skills to free up room — unless you use them, in which case shorten your own skill descriptions instead.',
|
||||
},
|
||||
'Prefer CLI over MCP for common operations': {
|
||||
title: 'Some connected services load their full tool list every turn',
|
||||
description: 'Most connected services only cost tokens when used, but yours are set to load everything upfront. That weight is there whether you use them or not.',
|
||||
recommendation: 'For services with a command-line equivalent (like `gh` or `aws`), the command line costs nothing until you run it.',
|
||||
},
|
||||
'Filter hook output before it enters context': {
|
||||
title: 'An automation is pasting its full output into the conversation',
|
||||
description: 'An automation that injects its output adds it to every turn that follows. Unfiltered command output can be much larger than the part that actually matters.',
|
||||
recommendation: 'Trim the output inside the script itself, so only the useful lines reach the conversation.',
|
||||
},
|
||||
'Subagents pin neither model nor effort': {
|
||||
title: 'Your helper agents all run at the same cost as your main session',
|
||||
description: 'A subagent that names no model inherits the one you are using, so routine delegated work costs the same as your hardest work. Reasoning effort is a separate dial with the same default.',
|
||||
recommendation: 'Give mechanical agents (search, extraction, summarizing) a smaller model or a lower effort level, and keep the strong settings for the work that needs judgement.',
|
||||
},
|
||||
},
|
||||
patterns: [],
|
||||
_default: {
|
||||
|
|
@ -836,11 +817,6 @@ export const TRANSLATIONS = {
|
|||
description: 'Claude Code keeps every active skill\'s description in one shared listing it reads to choose which skill to use, and that listing has a limited size. Added up, your skills\' descriptions run past that size on a smaller setup, so Claude Code may drop some of them — and stop seeing those skills. This is an estimate; a larger setup has more room.',
|
||||
recommendation: 'Free up room: turn off bundled skills you do not use, collapse the heaviest ones so only their names show, or shorten the longest descriptions. The details show the measured total and the room available.',
|
||||
},
|
||||
'Skill body is large (loads on demand when the skill runs)': {
|
||||
title: 'A skill\'s body is large (it loads only when that skill runs)',
|
||||
description: 'This skill\'s instructions run longer than the rough guidance for a skill body. The body is not part of the always-loaded listing Claude reads every turn — it loads only when you invoke the skill, so it costs nothing until then. Once it loads, though, it stays in context for the rest of that session.',
|
||||
recommendation: 'Move reference material into supporting files the skill opens only when needed, so the body stays lean. For a heavy skill you can also run its body in a separate context with `context: fork` in the skill\'s settings.',
|
||||
},
|
||||
},
|
||||
patterns: [],
|
||||
_default: {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ const SCANNER_TO_CATEGORY = {
|
|||
TOK: 'Wasted tokens',
|
||||
CPS: 'Wasted tokens',
|
||||
SKL: 'Wasted tokens',
|
||||
AGT: 'Wasted tokens',
|
||||
DIS: 'Dead config',
|
||||
GAP: 'Missed opportunity',
|
||||
PLH: 'Configuration mistake',
|
||||
|
|
|
|||
|
|
@ -16,14 +16,6 @@
|
|||
/** Default re-verify cadence: a confirmed best-practice older than this needs a re-check. */
|
||||
export const STALE_AFTER_DAYS_DEFAULT = 90;
|
||||
|
||||
/**
|
||||
* Default evidence cadence: when the NEWEST `published` date across an entry's
|
||||
* sources is older than this, the entry is flagged even if recently re-verified.
|
||||
* Re-verifying the old source does not clear it — only a newer source does
|
||||
* (the BP-SUB-001 defect class: green stamp, substantially outdated evidence).
|
||||
*/
|
||||
export const EVIDENCE_STALE_AFTER_DAYS_DEFAULT = 365;
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
|
|
@ -54,40 +46,15 @@ function verifiedMs(entry) {
|
|||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
/** Parse a YYYY-MM-DD string to UTC-midnight ms, or null. */
|
||||
function dateMs(v) {
|
||||
if (typeof v !== 'string' || !DATE_RE.test(v)) return null;
|
||||
const ms = Date.parse(`${v}T00:00:00Z`);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest `published` across the primary `source` and any corroborating `sources[]`.
|
||||
* Null when no source carries a parseable published date (the evidence-age rule
|
||||
* is then silent for that entry — it cannot judge evidence it cannot date).
|
||||
*/
|
||||
function newestEvidenceMs(entry) {
|
||||
const candidates = [];
|
||||
if (entry && entry.source) candidates.push(entry.source);
|
||||
if (entry && Array.isArray(entry.sources)) candidates.push(...entry.sources);
|
||||
let newest = null;
|
||||
for (const s of candidates) {
|
||||
const ms = dateMs(s && s.published);
|
||||
if (ms !== null && (newest === null || ms > newest)) newest = ms;
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify every register entry as fresh or stale by the age of its source.verified stamp.
|
||||
*
|
||||
* @param {{entries:object[]}} register
|
||||
* @param {{ referenceDate: string|Date, staleAfterDays?: number, evidenceStaleAfterDays?: number }} opts
|
||||
* @param {{ referenceDate: string|Date, staleAfterDays?: number }} opts
|
||||
* @returns {{
|
||||
* referenceDate: string,
|
||||
* staleAfterDays: number,
|
||||
* evidenceStaleAfterDays: number,
|
||||
* stale: Array<{id:string, verified:string|undefined, ageDays:number|null, url:string|undefined, claim:string|undefined, reasons:string[]}>,
|
||||
* stale: Array<{id:string, verified:string|undefined, ageDays:number|null, url:string|undefined, claim:string|undefined}>,
|
||||
* fresh: Array<{id:string, verified:string|undefined, ageDays:number}>,
|
||||
* counts: { total:number, stale:number, fresh:number }
|
||||
* }}
|
||||
|
|
@ -96,10 +63,6 @@ export function assessFreshness(register, opts = {}) {
|
|||
const ref = normalizeReferenceDate(opts.referenceDate);
|
||||
const staleAfterDays =
|
||||
typeof opts.staleAfterDays === 'number' ? opts.staleAfterDays : STALE_AFTER_DAYS_DEFAULT;
|
||||
const evidenceStaleAfterDays =
|
||||
typeof opts.evidenceStaleAfterDays === 'number'
|
||||
? opts.evidenceStaleAfterDays
|
||||
: EVIDENCE_STALE_AFTER_DAYS_DEFAULT;
|
||||
|
||||
const entries = (register && Array.isArray(register.entries)) ? register.entries : [];
|
||||
const stale = [];
|
||||
|
|
@ -108,30 +71,14 @@ export function assessFreshness(register, opts = {}) {
|
|||
for (const e of entries) {
|
||||
const verified = e && e.source ? e.source.verified : undefined;
|
||||
const vms = verifiedMs(e);
|
||||
const reasons = [];
|
||||
let ageDays = null;
|
||||
|
||||
if (vms === null) {
|
||||
// No re-checkable date → needs attention.
|
||||
reasons.push('no-verified-date');
|
||||
} else {
|
||||
ageDays = Math.floor((ref.ms - vms) / DAY_MS);
|
||||
if (ageDays > staleAfterDays) reasons.push('verified-age');
|
||||
// No re-checkable date → needs attention. Stale with ageDays null.
|
||||
stale.push({ id: e && e.id, verified, ageDays: null, url: e && e.source && e.source.url, claim: e && e.claim });
|
||||
continue;
|
||||
}
|
||||
|
||||
// A source explicitly marked as superseded is stale no matter how fresh the
|
||||
// verified stamp is — the stamp certifies the OLD source.
|
||||
if (e && e.source && e.source.supersededBy) reasons.push('superseded');
|
||||
|
||||
// Evidence age: keyed on the newest published date across all sources, so a
|
||||
// re-read of the old source never clears it — only newer evidence does.
|
||||
const evMs = newestEvidenceMs(e);
|
||||
if (evMs !== null && Math.floor((ref.ms - evMs) / DAY_MS) > evidenceStaleAfterDays) {
|
||||
reasons.push('evidence-age');
|
||||
}
|
||||
|
||||
if (reasons.length > 0) {
|
||||
stale.push({ id: e && e.id, verified, ageDays, url: e && e.source && e.source.url, claim: e && e.claim, reasons });
|
||||
const ageDays = Math.floor((ref.ms - vms) / DAY_MS);
|
||||
if (ageDays > staleAfterDays) {
|
||||
stale.push({ id: e.id, verified, ageDays, url: e.source && e.source.url, claim: e.claim });
|
||||
} else {
|
||||
fresh.push({ id: e.id, verified, ageDays });
|
||||
}
|
||||
|
|
@ -140,7 +87,6 @@ export function assessFreshness(register, opts = {}) {
|
|||
return {
|
||||
referenceDate: ref.iso,
|
||||
staleAfterDays,
|
||||
evidenceStaleAfterDays,
|
||||
stale,
|
||||
fresh,
|
||||
counts: { total: entries.length, stale: stale.length, fresh: fresh.length },
|
||||
|
|
|
|||
|
|
@ -5,13 +5,18 @@
|
|||
*/
|
||||
|
||||
import { riskScore, riskBand, verdict } from './severity.mjs';
|
||||
import { findingId } from './finding-codes.mjs';
|
||||
|
||||
let findingCounter = 0;
|
||||
|
||||
/** Reset the finding counter. Call in beforeEach of tests and before each scanner run. */
|
||||
export function resetCounter() {
|
||||
findingCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a finding object. The ID names the CHECK — see `finding-codes.mjs`.
|
||||
* Create a finding object with auto-incremented ID.
|
||||
* @param {object} opts
|
||||
* @param {string} opts.scanner - 3-letter scanner prefix (CML, SET, HKV, RUL, etc.)
|
||||
* @param {string} opts.code - check key declared in FINDING_CODES for this scanner
|
||||
* @param {string} opts.severity - critical | high | medium | low | info
|
||||
* @param {string} opts.title
|
||||
* @param {string} opts.description
|
||||
|
|
@ -25,8 +30,10 @@ import { findingId } from './finding-codes.mjs';
|
|||
* @returns {object}
|
||||
*/
|
||||
export function finding(opts) {
|
||||
findingCounter++;
|
||||
const id = `CA-${opts.scanner}-${String(findingCounter).padStart(3, '0')}`;
|
||||
const result = {
|
||||
id: findingId(opts.scanner, opts.code),
|
||||
id,
|
||||
scanner: opts.scanner,
|
||||
severity: opts.severity,
|
||||
title: opts.title,
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
/**
|
||||
* Target-path precondition shared by the target-taking CLIs.
|
||||
*
|
||||
* A scan target is a scan ROOT. If it does not exist, or is not a directory,
|
||||
* the scanner cannot do its job — and by the plugin's exit-code contract that
|
||||
* is exit 3, not a verdict. Codes 0/1/2 are PASS/WARNING/FAIL *about a
|
||||
* configuration that was examined*; every command template gates on exactly
|
||||
* that distinction, so returning a verdict for an unreadable target sends a
|
||||
* typo'd path through the whole workflow as a clean result.
|
||||
*
|
||||
* Measured before this guard existed (session #56):
|
||||
* node scanners/posture.mjs /nonexistent/path/xyz → exit 0,
|
||||
* "Health: B (86/100) — Good shape — a few items to address"
|
||||
*
|
||||
* The message and exit code here are not new: `manifest.mjs`,
|
||||
* `token-hotspots-cli.mjs`, `whats-active.mjs` and `optimize-lens-cli.mjs`
|
||||
* already carried this exact block inline. This module is where the CLIs that
|
||||
* lacked it get it from; the four that have their own copies are left alone
|
||||
* (consolidating them is a cleanup, not part of this fix).
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* Verify that `absPath` is an existing directory.
|
||||
*
|
||||
* Writes the diagnostic to stderr itself, so callers stay a two-line guard:
|
||||
*
|
||||
* if (!(await requireTargetDir(resolvedPath))) { process.exitCode = 3; return; }
|
||||
*
|
||||
* Never throws, and never calls `process.exit()` — an abrupt exit discards
|
||||
* unflushed stdout when the CLI is on a pipe.
|
||||
*
|
||||
* @param {string} absPath - Resolved absolute target path.
|
||||
* @returns {Promise<boolean>} true when the target is usable as a scan root.
|
||||
*/
|
||||
export async function requireTargetDir(absPath) {
|
||||
try {
|
||||
const s = await stat(absPath);
|
||||
if (!s.isDirectory()) {
|
||||
process.stderr.write(`Error: ${absPath} is not a directory\n`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,17 +20,17 @@ const GRADE_CONTEXT = {
|
|||
|
||||
// --- Tier weights for utilization calculation ---
|
||||
const TIER_WEIGHTS = { t1: 3, t2: 2, t3: 1, t4: 1 };
|
||||
const TIER_COUNTS = { t1: 5, t2: 7, t3: 7, t4: 5 };
|
||||
const TOTAL_DIMENSIONS = 24;
|
||||
const TIER_COUNTS = { t1: 5, t2: 7, t3: 8, t4: 5 };
|
||||
const TOTAL_DIMENSIONS = 25;
|
||||
const MAX_WEIGHTED = Object.entries(TIER_COUNTS).reduce(
|
||||
(sum, [tier, count]) => sum + count * TIER_WEIGHTS[tier],
|
||||
0,
|
||||
); // 5*3 + 7*2 + 7*1 + 5*1 = 41
|
||||
); // 5*3 + 7*2 + 8*1 + 5*1 = 42
|
||||
|
||||
/**
|
||||
* Calculate weighted utilization from GAP scanner findings.
|
||||
* @param {object[]} gapFindings - Array of GAP scanner findings (each has .category = t1|t2|t3|t4)
|
||||
* @param {number} [totalDimensions=24]
|
||||
* @param {number} [totalDimensions=25]
|
||||
* @returns {{ score: number, overhang: number }}
|
||||
*/
|
||||
export function calculateUtilization(gapFindings, totalDimensions = TOTAL_DIMENSIONS) {
|
||||
|
|
@ -102,7 +102,7 @@ function findGapId(finding) {
|
|||
return TITLE_TO_ID[finding.title] || 'unknown';
|
||||
}
|
||||
|
||||
/** Title→ID mapping for all 24 gap checks */
|
||||
/** Title→ID mapping for all 25 gap checks */
|
||||
const TITLE_TO_ID = {
|
||||
'No CLAUDE.md file': 't1_1',
|
||||
'No permissions configured': 't1_2',
|
||||
|
|
@ -123,6 +123,7 @@ const TITLE_TO_ID = {
|
|||
'No advanced skill frontmatter': 't3_5',
|
||||
'No subagent isolation': 't3_6',
|
||||
'No dynamic skill context': 't3_7',
|
||||
'No autoMode classifier': 't3_8',
|
||||
'No project .mcp.json in git': 't4_1',
|
||||
'No custom plugin': 't4_2',
|
||||
'Agent teams not enabled': 't4_3',
|
||||
|
|
@ -403,4 +404,4 @@ export function generateHealthScorecard(areaScores, opportunityCount, options =
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export { TITLE_TO_ID, TIER_WEIGHTS, TIER_COUNTS, TOTAL_DIMENSIONS, MAX_WEIGHTED, MATURITY_LEVELS, SEGMENTS };
|
||||
export { TITLE_TO_ID, TIER_WEIGHTS, TIER_COUNTS, MAX_WEIGHTED, MATURITY_LEVELS, SEGMENTS };
|
||||
|
|
|
|||
|
|
@ -36,23 +36,6 @@ export const DESCRIPTION_CAP = 1536;
|
|||
// The 200k/1M window constants live in context-window.mjs (single source of
|
||||
// truth, shared with the CML CLAUDE.md char-budget check); re-exported here so
|
||||
// existing importers of this module keep working.
|
||||
// D2 re-verification (2026-08-09, CC 2.1.226). CC 2.1.226's /doctor reports a
|
||||
// combined skill+command+agent listing and puts the budget near ~1% (~10,000
|
||||
// tok on a 1M window) — half of ours. Checked against the primary source
|
||||
// before touching the number: the CC changelog contains EXACTLY ONE
|
||||
// budget-fraction statement (L3786, under 2.1.32) and no later entry
|
||||
// supersedes it, so 2% stands and CA-SKL-002 is NOT a /doctor duplicate
|
||||
// carrying a stale figure. /doctor's arithmetic could not be reconciled from
|
||||
// the changelog, and /doctor discloses its own numbers as disk estimates
|
||||
// (chars÷4), so its ~1% is recorded, not adopted.
|
||||
//
|
||||
// NOT VERIFIED, deliberately left alone: L3786 says "skill CHARACTER budget
|
||||
// now scales with context window (2% of context)". We express the budget in
|
||||
// TOKENS (0.02 × 200k = 4000 tok). Whether CC's budget is 2% counted in
|
||||
// characters or in tokens is not resolvable from the changelog wording, and no
|
||||
// primary source settles it — a 4× difference rides on the answer. Changing
|
||||
// the constant on that ambiguity would be a guess; it stays until a primary
|
||||
// source decides it.
|
||||
export const BUDGET_FRACTION = 0.02;
|
||||
export const AGGREGATE_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * CONTEXT_WINDOW_ANCHOR); // 4000
|
||||
export const LARGE_CONTEXT_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * LARGE_CONTEXT_WINDOW); // 20000
|
||||
|
|
|
|||
|
|
@ -1,350 +0,0 @@
|
|||
/**
|
||||
* subtraction-prefilter — deterministic candidate generator for the v5.13
|
||||
* subtraction lens (`/config-audit optimize --subtract`, BP-SUB-001).
|
||||
*
|
||||
* Every other command in this plugin asks an ADDITION question — what could you
|
||||
* add, what would fit a better mechanism, how expensive is what you have. This
|
||||
* module asks the inverse: **what is no longer earning its always-loaded rent?**
|
||||
*
|
||||
* It is the mirror image of `lens-prefilter` in one important way. That module
|
||||
* is recall-first, because a false candidate only costs the judge a moment's
|
||||
* thought. Here a false candidate is a proposal to DELETE something, so the
|
||||
* polarity flips: precision-first, and a hard deterministic floor
|
||||
* (`floor-exclusion`) that the judge is not allowed to override.
|
||||
*
|
||||
* ## Granularity: leaf blocks
|
||||
*
|
||||
* The one design choice the hand-built fasit deliberately left open. A block is
|
||||
* one markdown *leaf*: a list item including its wrapped continuation lines, or
|
||||
* a paragraph. Headings, table rows and fenced code are structural, never
|
||||
* candidates.
|
||||
*
|
||||
* Both halves of that choice are load-bearing, and the fasit tests both:
|
||||
* - It must SPLIT. A numbered list whose steps 2–3 are local facts and whose
|
||||
* steps 1 and 4 are filler is a mixed block; section granularity would have
|
||||
* to keep or drop all four.
|
||||
* - It must NOT split further. A bullet's load-bearing literal often sits on a
|
||||
* wrapped continuation line ("…— `coord-send` er mekanismen"). A
|
||||
* line-granular mechanism severs the first line from the fact that protects
|
||||
* it and proposes a floor block for deletion — the exact failure the gate
|
||||
* exists to prevent.
|
||||
*
|
||||
* ## Two independent guarantees, not one
|
||||
*
|
||||
* A load-bearing block fails to become a candidate for either of two reasons,
|
||||
* and both are needed:
|
||||
* 1. it is *declarative* — "Language: Norwegian for dialogue" states a fact
|
||||
* about the human and corrects no behaviour, so no detector fires; or
|
||||
* 2. `floor-exclusion` vetoes it for carrying an underivable local literal.
|
||||
* Group 1 never reaches the veto at all, which is why the contract is asserted
|
||||
* on the candidate list rather than on either mechanism alone.
|
||||
*
|
||||
* Norwegian and English are both first-class: the config this was designed
|
||||
* against is Norwegian prose carrying English identifiers.
|
||||
*
|
||||
* Zero external dependencies. Pure: input text → candidate array.
|
||||
*/
|
||||
|
||||
import { floorMarker } from './floor-exclusion.mjs';
|
||||
|
||||
/**
|
||||
* The subtraction detector, kept in its OWN table. `LENS_DETECTORS` drives the
|
||||
* plain `optimize` payload's register block, and the subtraction axis must not
|
||||
* fire on a plain run — it asks a different question and the operator has to
|
||||
* opt into it with `--subtract`.
|
||||
*/
|
||||
export const SUBTRACT_DETECTORS = Object.freeze([
|
||||
{ lensCheck: 'compensatory-instruction', registerId: 'BP-SUB-001', mechanism: 'deletion' },
|
||||
]);
|
||||
|
||||
/**
|
||||
* Absolute / insistent phrasing. An instruction that has to shout is usually
|
||||
* correcting behaviour rather than stating a fact.
|
||||
*/
|
||||
/**
|
||||
* Word boundaries that understand æ/ø/å.
|
||||
*
|
||||
* JavaScript's `\b` is ASCII-only, so `/\bunngå\b/` never matches "unngå " —
|
||||
* the trailing "å" is not a word character, so there is no boundary after it.
|
||||
* Every Norwegian keyword ending in æ/ø/å was silently dead until the dogfood
|
||||
* run surfaced it. Do not reintroduce `\b` around this vocabulary.
|
||||
*/
|
||||
const LB = '(?<![\\wæøåÆØÅ])';
|
||||
const RB = '(?![\\wæøåÆØÅ])';
|
||||
|
||||
const ABSOLUTE_RE = new RegExp(
|
||||
LB +
|
||||
'(?:never|always|avoid|don\'t|do not|must not|ensure|remember to|make sure|' +
|
||||
'aldri|alltid|unngå|husk|sørg for|ikke)' +
|
||||
RB,
|
||||
'i',
|
||||
);
|
||||
|
||||
/**
|
||||
* Imperative verbs — the grammatical signature of telling the model how to
|
||||
* behave. Matched anywhere in the block, since Norwegian list prose puts them
|
||||
* after a colon ("…oppgaver: forstå problemet, vurder alternativer").
|
||||
*
|
||||
* Word boundaries matter more than the list length: `\bdocument\b` must not
|
||||
* match "documentation", or the declarative language-preference fact — a floor
|
||||
* block with no local literal to veto it — would become a deletion candidate.
|
||||
*/
|
||||
const IMPERATIVE_RE = new RegExp(
|
||||
LB +
|
||||
'(?:' +
|
||||
// English
|
||||
'think|write|test|commit|use|read|check|ask|verify|stop|start|summarize|' +
|
||||
'wait|match|change|fix|present|identify|keep|prefer|declare|refactor|' +
|
||||
'document|explain|split|review|' +
|
||||
// Norwegian
|
||||
'tenk|skriv|test|commit|bruk|les|sjekk|spør|verifiser|dokumenter|stopp|' +
|
||||
'start|oppsummer|vent|gjør|match|endre|fiks|presenter|identifiser|forstå|' +
|
||||
'vurder|hold|siter|jobb|gjett|push|del|sett|forklar|utfør|følg' +
|
||||
')' +
|
||||
RB,
|
||||
'i',
|
||||
);
|
||||
|
||||
/**
|
||||
* Minimum words for a block whose ONLY signal is an absolute marker. A bare
|
||||
* "Haiku: aldri." is a declarative policy fact wearing the word "aldri", not an
|
||||
* instruction about how to behave — the same reason "Tone: direct and technical"
|
||||
* never fires. Blocks carrying a real imperative verb are exempt from the floor,
|
||||
* so "Test inkrementelt" still surfaces at two words.
|
||||
*/
|
||||
const ABSOLUTE_ONLY_MIN_WORDS = 6;
|
||||
|
||||
const HEADING_RE = /^\s*#{1,6}\s/;
|
||||
const TABLE_RE = /^\s*\|/;
|
||||
const FENCE_RE = /^\s*(?:```|~~~)/;
|
||||
const LIST_ITEM_RE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)/;
|
||||
const ORDERED_ITEM_RE = /^\s*\d+[.)]\s+/;
|
||||
const CONTINUATION_RE = /^\s+\S/;
|
||||
/** A paragraph that introduces the list beneath it ("…tre lag med hver sin ene jobb:"). */
|
||||
const STEM_RE = /:\s*$/;
|
||||
|
||||
/**
|
||||
* Split markdown into leaf blocks.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{startLine:number, endLine:number, text:string, type:string}>}
|
||||
* `type` is one of paragraph | list-item | heading | table | code.
|
||||
*/
|
||||
export function splitLeafBlocks(text) {
|
||||
const lines = String(text == null ? '' : text).split('\n');
|
||||
const blocks = [];
|
||||
let current = null;
|
||||
let inFence = false;
|
||||
|
||||
const flush = () => {
|
||||
if (current) blocks.push(current);
|
||||
current = null;
|
||||
};
|
||||
const indentOf = (line) => (line.match(/^\s*/) || [''])[0].length;
|
||||
const open = (type, i, line) => {
|
||||
current = { startLine: i + 1, endLine: i + 1, text: line, type, indent: indentOf(line) };
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (FENCE_RE.test(line)) {
|
||||
flush();
|
||||
inFence = !inFence;
|
||||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'code' });
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'code' });
|
||||
continue;
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
if (HEADING_RE.test(line)) {
|
||||
flush();
|
||||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'heading' });
|
||||
continue;
|
||||
}
|
||||
if (TABLE_RE.test(line)) {
|
||||
flush();
|
||||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'table' });
|
||||
continue;
|
||||
}
|
||||
if (LIST_ITEM_RE.test(line)) {
|
||||
// Structural exception 1: a paragraph ending in ':' is this list's stem —
|
||||
// it introduces the items rather than standing alone, so it merges with
|
||||
// them. Deleting a stem without its list is meaningless, and a stem often
|
||||
// carries no literal of its own to protect it.
|
||||
//
|
||||
// A list item can be a stem too ("5. …alltid eksplisitt:" over its
|
||||
// sub-bullets) — but only when the following item is nested deeper, or
|
||||
// sibling bullets would glue together.
|
||||
const isStem =
|
||||
current &&
|
||||
STEM_RE.test(current.text) &&
|
||||
(current.type === 'paragraph' || indentOf(line) > current.indent);
|
||||
if (isStem) {
|
||||
current.type = 'list-item';
|
||||
if (current.ordered === undefined) current.ordered = ORDERED_ITEM_RE.test(line);
|
||||
current.endLine = i + 1;
|
||||
current.text += '\n' + line;
|
||||
current.stemMerged = true;
|
||||
current.stemIndent = indentOf(line);
|
||||
continue;
|
||||
}
|
||||
if (current && current.stemMerged && current.endLine === i && indentOf(line) >= current.stemIndent) {
|
||||
// Subsequent items of the same stemmed list join it too.
|
||||
current.endLine = i + 1;
|
||||
current.text += '\n' + line;
|
||||
continue;
|
||||
}
|
||||
// Otherwise a new list item always ends the previous block, even mid-list.
|
||||
flush();
|
||||
open('list-item', i, line);
|
||||
current.ordered = ORDERED_ITEM_RE.test(line);
|
||||
continue;
|
||||
}
|
||||
if (current && CONTINUATION_RE.test(line)) {
|
||||
// Indented wrap — belongs to the block it continues.
|
||||
current.endLine = i + 1;
|
||||
current.text += '\n' + line;
|
||||
continue;
|
||||
}
|
||||
if (current && current.type === 'paragraph') {
|
||||
current.endLine = i + 1;
|
||||
current.text += '\n' + line;
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
open('paragraph', i, line);
|
||||
}
|
||||
flush();
|
||||
|
||||
return blocks.sort((a, b) => a.startLine - b.startLine);
|
||||
}
|
||||
|
||||
/** Blocks that can carry a deletable instruction at all. */
|
||||
const isProse = (block) => block.type === 'paragraph' || block.type === 'list-item';
|
||||
|
||||
const wordCount = (s) => s.trim().split(/\s+/).filter(Boolean).length;
|
||||
|
||||
/**
|
||||
* Does the block instruct behaviour at all? A declarative fact does not, however
|
||||
* absolute its wording: "Haiku: aldri." and "Tone: direct and technical" both
|
||||
* state a decision rather than correcting how the model works.
|
||||
*/
|
||||
function correctsBehaviour(text) {
|
||||
if (IMPERATIVE_RE.test(text)) return true;
|
||||
return ABSOLUTE_RE.test(text) && wordCount(text) >= ABSOLUTE_ONLY_MIN_WORDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural exception 2: an ordered list is a CONTRACT. Numbered steps are a
|
||||
* sequence whose items reference each other, so a floor marker on any step
|
||||
* floors the whole run — deleting step 2 of a five-step session protocol is not
|
||||
* the same kind of act as deleting one bullet from a list of platitudes.
|
||||
*
|
||||
* Unordered lists deliberately do NOT inherit. B-32a and B-32b are opposite
|
||||
* calls inside one bullet list, and "the container decides" is precisely the
|
||||
* reasoning the fasit exists to refute.
|
||||
*
|
||||
* @returns {Set<number>} startLine of every block floored by inheritance
|
||||
*/
|
||||
function orderedContractFloor(blocks, floored) {
|
||||
const inherited = new Set();
|
||||
let run = [];
|
||||
const closeRun = () => {
|
||||
if (run.length > 1 && run.some((b) => floored.has(b.startLine))) {
|
||||
for (const b of run) inherited.add(b.startLine);
|
||||
}
|
||||
run = [];
|
||||
};
|
||||
for (const block of blocks) {
|
||||
const contiguous = run.length > 0 && block.startLine === run[run.length - 1].endLine + 1;
|
||||
if (block.ordered && (run.length === 0 || contiguous)) {
|
||||
run.push(block);
|
||||
} else {
|
||||
closeRun();
|
||||
if (block.ordered) run.push(block);
|
||||
}
|
||||
}
|
||||
closeRun();
|
||||
return inherited;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compensatory-phrasing candidates that survived floor-exclusion.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{lensCheck:string, registerId:string, mechanism:string,
|
||||
* line:number, startLine:number, endLine:number, lineCount:number, text:string}>}
|
||||
*/
|
||||
export function subtractionCandidates(text) {
|
||||
const detector = SUBTRACT_DETECTORS[0];
|
||||
const out = [];
|
||||
const blocks = splitLeafBlocks(text).filter(isProse);
|
||||
|
||||
// 1. The blocking floor veto, evaluated over the WHOLE leaf block — so a
|
||||
// literal on a wrapped continuation line still protects its opening line.
|
||||
const floored = new Set();
|
||||
for (const block of blocks) {
|
||||
if (floorMarker(block.text)) floored.add(block.startLine);
|
||||
}
|
||||
// 2. …then propagated across ordered-list contracts.
|
||||
const inherited = orderedContractFloor(blocks, floored);
|
||||
|
||||
for (const block of blocks) {
|
||||
// 3. Does it correct behaviour at all? A declarative local fact does not.
|
||||
if (!correctsBehaviour(block.text)) continue;
|
||||
if (floored.has(block.startLine) || inherited.has(block.startLine)) continue;
|
||||
|
||||
out.push({
|
||||
lensCheck: detector.lensCheck,
|
||||
registerId: detector.registerId,
|
||||
mechanism: detector.mechanism,
|
||||
line: block.startLine,
|
||||
startLine: block.startLine,
|
||||
endLine: block.endLine,
|
||||
lineCount: block.endLine - block.startLine + 1,
|
||||
text: block.text.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostics for the floor gate: every prose block that fired the detector but
|
||||
* was vetoed, with the marker that saved it. Not user-facing — this is how a
|
||||
* later narrowing of the veto can be checked against the fasit.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{startLine:number, endLine:number, marker:string, why:string}>}
|
||||
*/
|
||||
export function floorExcluded(text) {
|
||||
const blocks = splitLeafBlocks(text).filter(isProse);
|
||||
const floored = new Set();
|
||||
for (const block of blocks) {
|
||||
if (floorMarker(block.text)) floored.add(block.startLine);
|
||||
}
|
||||
const inherited = orderedContractFloor(blocks, floored);
|
||||
|
||||
const out = [];
|
||||
for (const block of blocks) {
|
||||
if (!correctsBehaviour(block.text)) continue;
|
||||
const marker = floorMarker(block.text);
|
||||
if (marker) {
|
||||
out.push({ startLine: block.startLine, endLine: block.endLine, marker: marker.name, why: marker.why });
|
||||
} else if (inherited.has(block.startLine)) {
|
||||
out.push({
|
||||
startLine: block.startLine,
|
||||
endLine: block.endLine,
|
||||
marker: 'ordered-contract',
|
||||
why: 'is a step of an ordered list whose sibling carries a local fact',
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
/**
|
||||
* subtraction-write — the write half of `optimize --subtract` (§C6, chunk #63).
|
||||
*
|
||||
* This is the only path in the plugin that REMOVES configuration, so the split
|
||||
* of labour matters more here than anywhere else: **the judgement is the
|
||||
* agent's, the execution is deterministic.** Everything below is mechanical —
|
||||
* it verifies that the block it was told to remove is still exactly the block
|
||||
* that is there, and refuses otherwise.
|
||||
*
|
||||
* ## Why this is not a `fix-engine` action
|
||||
*
|
||||
* Measured (#63): the subtraction axis appears nowhere in `scan-orchestrator`
|
||||
* or `optimization-lens-scanner` — it is computed inside `optimize-lens-cli`
|
||||
* under `--subtract`. `fix-engine.verifyFixes()` marks a fix `verified` when
|
||||
* the finding is absent from a re-scan, so a subtraction removal would be
|
||||
* verified **whether or not the write happened**: a success-shaped no-op, the
|
||||
* class that made `restoreBackup` silently do nothing before `parseManifest`
|
||||
* learned the second manifest format. And `planFixes` keys on
|
||||
* `finding.autoFixable` + `finding.title` from an envelope, neither of which an
|
||||
* agent prose judgement has.
|
||||
*
|
||||
* Nor is it a `plan`/`implement` step: that pipeline runs on findings, and
|
||||
* `finding-codes.mjs` declares exactly one `OPT` code — for the deterministic
|
||||
* check. Minting a code for a prose judgement breaks that module's invariant
|
||||
* that a code names a deterministic CHECK.
|
||||
*
|
||||
* ## The floor, repeated rather than moved
|
||||
*
|
||||
* §C6 forbids migrating the floor into the write path. That forbids *moving*
|
||||
* the veto, not *repeating* it: `subtraction-prefilter` still consults
|
||||
* `floor-exclusion` before anything is ever proposed, and this module refuses a
|
||||
* load-bearing block again as the last red line before an irreversible-by-
|
||||
* reading delete. A caller that hand-builds an approval therefore cannot route
|
||||
* around the floor.
|
||||
*
|
||||
* ## The archive question
|
||||
*
|
||||
* "`mv` to `_archive/`, never `rm`" is a FILE-level rule; nothing here deletes
|
||||
* a file. The timestamped backup is the recovery artifact — it holds the whole
|
||||
* pre-removal file and `rollback` already restores it. The removed text also
|
||||
* rides back in the payload so the caller can show and log it. A second archive
|
||||
* copy with no restorer behind it would be worse than none.
|
||||
*
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { isLoadBearing } from './floor-exclusion.mjs';
|
||||
import { createBackup } from './backup.mjs';
|
||||
import { classifyWriteTarget, strongestGate } from './write-scope.mjs';
|
||||
|
||||
/** Why a removal did not happen. Every refusal carries exactly one of these. */
|
||||
export const REFUSAL_REASONS = Object.freeze({
|
||||
/** The file no longer reads the way the approval says it does. */
|
||||
BLOCK_MISMATCH: 'block-mismatch',
|
||||
/** `floor-exclusion` vetoes the block — never removable, at any layer. */
|
||||
FLOOR: 'floor',
|
||||
/** The target's scope class needs an explicit go-ahead that was not given. */
|
||||
SCOPE_GATE: 'scope-gate',
|
||||
/** The file could not be read, so it can be neither backed up nor excised. */
|
||||
UNREADABLE: 'unreadable',
|
||||
/** The backup does not cover a file the run was about to write. */
|
||||
BACKUP_INCOMPLETE: 'backup-incomplete',
|
||||
});
|
||||
|
||||
/** True for a line that is empty or whitespace only. */
|
||||
const isBlank = (line) => line === undefined || /^\s*$/.test(line);
|
||||
|
||||
/**
|
||||
* Remove approved blocks from one file's content.
|
||||
*
|
||||
* Pure: no filesystem, no clock. Every span is validated against the ORIGINAL
|
||||
* content and the removals are then applied in descending line order, so an
|
||||
* earlier removal cannot shift a later span out from under itself — the shape
|
||||
* that made `fix-engine` apply a file-rename before a fix that still addressed
|
||||
* the old path, failing with ENOENT while the run exited 0.
|
||||
*
|
||||
* @param {string} content - The file as it is on disk right now.
|
||||
* @param {Array<{line:number, endLine:number, text:string}>} removals
|
||||
* @returns {{content: string, applied: object[], refused: object[]}}
|
||||
*/
|
||||
export function exciseBlocks(content, removals) {
|
||||
const lines = content.split('\n');
|
||||
const applied = [];
|
||||
const refused = [];
|
||||
|
||||
for (const removal of removals) {
|
||||
const { line, endLine } = removal;
|
||||
const inRange =
|
||||
Number.isInteger(line) && Number.isInteger(endLine) &&
|
||||
line >= 1 && endLine >= line && endLine <= lines.length;
|
||||
|
||||
if (!inRange) {
|
||||
refused.push({ ...removal, reason: REFUSAL_REASONS.BLOCK_MISMATCH });
|
||||
continue;
|
||||
}
|
||||
|
||||
// The pre-filter reports `text: block.text.trim()`, so compare trimmed —
|
||||
// a raw slice comparison would refuse every genuine approval.
|
||||
const actual = lines.slice(line - 1, endLine).join('\n');
|
||||
if (actual.trim() !== String(removal.text ?? '').trim()) {
|
||||
refused.push({ ...removal, reason: REFUSAL_REASONS.BLOCK_MISMATCH });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Judged on what is actually in the file, not on what the caller claims is.
|
||||
if (isLoadBearing(actual)) {
|
||||
refused.push({ ...removal, reason: REFUSAL_REASONS.FLOOR });
|
||||
continue;
|
||||
}
|
||||
|
||||
applied.push({ ...removal, text: actual.trim() });
|
||||
}
|
||||
|
||||
const descending = [...applied].sort((a, b) => b.line - a.line);
|
||||
for (const { line, endLine } of descending) {
|
||||
lines.splice(line - 1, endLine - line + 1);
|
||||
// A leaf block sits between blank lines; removing it leaves two in a row.
|
||||
if (line >= 2 && isBlank(lines[line - 2]) && isBlank(lines[line - 1])) {
|
||||
lines.splice(line - 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return { content: lines.join('\n'), applied, refused };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an approved subtraction set to disk, behind the scope gate and a
|
||||
* verified backup.
|
||||
*
|
||||
* The run is all-or-nothing across files: a target that cannot be read aborts
|
||||
* the whole set rather than applying half of one operator decision.
|
||||
*
|
||||
* @param {Array<{file:string, line:number, endLine:number, text:string}>} removals
|
||||
* @param {object} [opts]
|
||||
* @param {string|null} [opts.repoRoot] - Repo root the session stands in.
|
||||
* @param {boolean} [opts.approveScope=false] - Operator's explicit go-ahead for a `require-ok` target.
|
||||
* @param {boolean} [opts.dryRun=false]
|
||||
* @param {string} [opts.home] - Home override, for tests.
|
||||
* @returns {Promise<object>} Verdict payload — never throws for a refused write.
|
||||
*/
|
||||
export async function applySubtraction(removals, opts = {}) {
|
||||
const { repoRoot = null, approveScope = false, dryRun = false, home } = opts;
|
||||
|
||||
const normalized = removals.map((r) => ({ ...r, file: resolve(r.file) }));
|
||||
const files = [...new Set(normalized.map((r) => r.file))];
|
||||
|
||||
const classifyOpts = home ? { home } : {};
|
||||
const targets = files.map((f) => classifyWriteTarget(f, repoRoot, classifyOpts));
|
||||
const gate = strongestGate(targets);
|
||||
const disclosures = [...new Set(targets.map((t) => t.disclosure).filter(Boolean))];
|
||||
|
||||
const base = {
|
||||
gate,
|
||||
requiresApproval: gate === 'require-ok',
|
||||
disclosures,
|
||||
targets,
|
||||
dryRun,
|
||||
backupId: null,
|
||||
applied: [],
|
||||
refused: [],
|
||||
filesWritten: [],
|
||||
};
|
||||
|
||||
// The gate is a verdict about a write, not a tool failure: the caller renders
|
||||
// the disclosure and asks. Nothing is written, and nothing is exit 3 (#62).
|
||||
//
|
||||
// `!dryRun` is load-bearing. The gate guards a WRITE, and a dry run is not
|
||||
// one — refusing it early bought nothing and cost the dry run its whole
|
||||
// purpose on the machine-wide target, which is the mandatory v1 case: the
|
||||
// operator would approve a removal whose spans had never been checked, and
|
||||
// the first run able to discover a stale approval would be the one that
|
||||
// writes. `requiresApproval` is reported either way, so the caller still asks.
|
||||
if (gate === 'require-ok' && !approveScope && !dryRun) {
|
||||
return {
|
||||
...base,
|
||||
refused: normalized.map((r) => ({ ...r, reason: REFUSAL_REASONS.SCOPE_GATE })),
|
||||
};
|
||||
}
|
||||
|
||||
const contents = new Map();
|
||||
const unreadable = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
contents.set(file, await readFile(file, 'utf-8'));
|
||||
} catch {
|
||||
unreadable.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (unreadable.length > 0) {
|
||||
return {
|
||||
...base,
|
||||
refused: normalized.map((r) => ({
|
||||
...r,
|
||||
reason: unreadable.includes(r.file)
|
||||
? REFUSAL_REASONS.UNREADABLE
|
||||
: REFUSAL_REASONS.BACKUP_INCOMPLETE,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const perFile = new Map();
|
||||
const applied = [];
|
||||
const refused = [];
|
||||
for (const file of files) {
|
||||
const result = exciseBlocks(contents.get(file), normalized.filter((r) => r.file === file));
|
||||
perFile.set(file, result);
|
||||
applied.push(...result.applied.map((a) => ({ ...a, file })));
|
||||
refused.push(...result.refused.map((r) => ({ ...r, file })));
|
||||
}
|
||||
|
||||
const toWrite = files.filter((f) => perFile.get(f).applied.length > 0);
|
||||
if (dryRun || toWrite.length === 0) {
|
||||
return { ...base, applied, refused };
|
||||
}
|
||||
|
||||
// `createBackup` skips a path that does not exist and still returns a
|
||||
// manifest and an id, so "a backup was made" is not evidence that THIS file
|
||||
// is recoverable (M-BUG-31's shape). Assert coverage before writing anything.
|
||||
const backup = createBackup(toWrite);
|
||||
const covered = new Set(backup.manifest.files.map((f) => f.originalPath));
|
||||
const uncovered = toWrite.filter((f) => !covered.has(f));
|
||||
if (uncovered.length > 0) {
|
||||
return {
|
||||
...base,
|
||||
backupId: backup.backupId,
|
||||
applied: [],
|
||||
refused: normalized.map((r) => ({ ...r, reason: REFUSAL_REASONS.BACKUP_INCOMPLETE })),
|
||||
};
|
||||
}
|
||||
|
||||
const filesWritten = [];
|
||||
for (const file of toWrite) {
|
||||
await writeFile(file, perFile.get(file).content, 'utf-8');
|
||||
filesWritten.push(file);
|
||||
}
|
||||
|
||||
return { ...base, backupId: backup.backupId, applied, refused, filesWritten };
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { allFindingIds, knownScanners } from './finding-codes.mjs';
|
||||
|
||||
/**
|
||||
* Load suppressions from .config-audit-ignore files.
|
||||
|
|
@ -70,39 +69,6 @@ export function parseIgnoreFile(content) {
|
|||
return suppressions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find suppression patterns that can never match anything.
|
||||
*
|
||||
* A finding ID names a check (see `finding-codes.mjs`), so an exact pin either
|
||||
* names a declared check or names nothing at all. Silently keeping a dead pin
|
||||
* would reproduce, in the other direction, the very failure the check-code
|
||||
* scheme removed: the user believes a finding is suppressed when it is not.
|
||||
*
|
||||
* Globs are validated only down to the scanner prefix — `CA-GAP-*` stays valid
|
||||
* however GAP's checks change, which is why a glob is the safe way to pin.
|
||||
*
|
||||
* @param {Array<{ pattern: string }>} suppressions
|
||||
* @returns {string[]} patterns that match no declared check
|
||||
*/
|
||||
export function unknownSuppressions(suppressions) {
|
||||
if (!suppressions || suppressions.length === 0) return [];
|
||||
|
||||
const ids = allFindingIds();
|
||||
const scanners = new Set(knownScanners());
|
||||
const unknown = [];
|
||||
|
||||
for (const { pattern } of suppressions) {
|
||||
if (pattern.endsWith('-*')) {
|
||||
const scanner = pattern.slice(3, -2); // "CA-GAP-*" → "GAP"
|
||||
if (!scanners.has(scanner)) unknown.push(pattern);
|
||||
continue;
|
||||
}
|
||||
if (!ids.has(pattern)) unknown.push(pattern);
|
||||
}
|
||||
|
||||
return unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply suppressions to a findings array.
|
||||
* @param {object[]} findings - Array of finding objects with .id
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* write-output — the one place a scanner's `--output-file` payload is written.
|
||||
*
|
||||
* Every command in this plugin follows the same contract (`.claude/rules/ux-rules.md`):
|
||||
* run the scanner with `--output-file <path> 2>/dev/null`, check the exit code, then Read
|
||||
* the file. The path the command chooses is frequently one it has never created — e.g.
|
||||
* `commands/campaign.md` writes its report to
|
||||
* `~/.claude/config-audit/sessions/campaign-report.json`, which on a fresh machine does not
|
||||
* exist yet. That is precisely the FIRST run, the case campaign-cli otherwise handles
|
||||
* gracefully by reporting `initialized: false`.
|
||||
*
|
||||
* Before this helper existed, all 13 payload writers called `writeFile` directly and threw
|
||||
* ENOENT there. The exit code was 3, and the command's own exit-code table reads 3 as "the
|
||||
* input is missing or corrupt" — so the user was told the ledger might be corrupt and
|
||||
* warned off the one action that would have fixed anything. `saveLedger` had always created
|
||||
* its parent directory; the payload write simply never did. The asymmetry was accidental.
|
||||
*
|
||||
* Creating the parent is the honest behaviour: the caller asked for a file at a path, and
|
||||
* nothing about a missing intermediate directory is an error the caller can learn from.
|
||||
*/
|
||||
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
/**
|
||||
* Write a scanner payload, creating the parent directory if needed.
|
||||
*
|
||||
* Signature-compatible with `writeFile(path, contents, encoding)` so call sites are a pure
|
||||
* rename — the encoding argument is kept rather than defaulted away.
|
||||
*
|
||||
* @param {string} path - destination file
|
||||
* @param {string} contents - serialized payload
|
||||
* @param {string} [encoding='utf-8']
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function writeOutputFile(path, contents, encoding = 'utf-8') {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, contents, encoding);
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
/**
|
||||
* Write-target scope classification (M-BUG-41).
|
||||
*
|
||||
* The workflow observes configuration across repos, but every write it then
|
||||
* proposes was presented as though it landed where the session stands. Five
|
||||
* arms were measured carrying that hole: `implement` (approval prompt names no
|
||||
* path at all — only a count), `rollback` (renders repo-relative-looking paths
|
||||
* while writing to absolute originals), `fix` (`--global` mixes user-scope and
|
||||
* repo rows into one unmarked table), `plan`, and `campaign export`.
|
||||
*
|
||||
* The gate's STRENGTH comes from the target's scope class, never from which
|
||||
* command is asking. Command-owned policy would be five policies to drift apart
|
||||
* — the shape that put the lever table in five copies (#61). Both required
|
||||
* outcomes then fall out of one table without an exception rule: a plan
|
||||
* exported into another repo is *disclosed* (cross-repo is by design there),
|
||||
* while a rewrite of `~/.claude/CLAUDE.md` *requires explicit approval*,
|
||||
* because it costs in every repo on every turn.
|
||||
*
|
||||
* `silent` means "no gate of its own", not "no approval": the existing
|
||||
* confirmation surfaces stand untouched, and this module only adds location to
|
||||
* them.
|
||||
*
|
||||
* Two orderings below are load-bearing, and both were measured rather than
|
||||
* reasoned about:
|
||||
*
|
||||
* `plugin-managed` before `user-scope` — the canonical
|
||||
* `~/.claude/config-audit/` and the legacy `~/.config-audit/` both exist on a
|
||||
* real machine, and every command writes session state into them. Matched the
|
||||
* other way round, the gate fires on every write ever made and gets switched
|
||||
* off, which is worse than having no gate.
|
||||
*
|
||||
* `user-scope` before `cross-repo` — `~/.claude/.git` exists (the operator's
|
||||
* `~/.claude` is a git repo whose `.gitignore` is `*`). A plain
|
||||
* `.git`-upward-walk therefore answers "another repo" for
|
||||
* `~/.claude/CLAUDE.md`, silently downgrading the strongest gate on the
|
||||
* subtraction axis's primary target to disclosure-only.
|
||||
*
|
||||
* This module classifies. It never writes, never prompts, and never decides
|
||||
* whether an approved write is a good idea.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* True when `child` is `parent` itself or lives underneath it.
|
||||
*
|
||||
* Uses `relative()` rather than `startsWith()`: a sibling directory whose name
|
||||
* merely prefixes the parent's (`my-plugin-2` against `my-plugin`) satisfies
|
||||
* `startsWith` and would skip the gate entirely.
|
||||
*
|
||||
* @param {string} parent - Absolute directory path.
|
||||
* @param {string} child - Absolute path to test.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isWithin(parent, child) {
|
||||
const rel = relative(parent, child);
|
||||
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Default repo-root test. Kept injectable so classification is testable
|
||||
* without a fixture tree.
|
||||
*
|
||||
* @param {string} dir - Absolute directory path.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function defaultIsRepoRoot(dir) {
|
||||
return existsSync(join(dir, '.git'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk upwards from `absPath` looking for the nearest enclosing repo root.
|
||||
*
|
||||
* @param {string} absPath - Absolute path to start from.
|
||||
* @param {(dir: string) => boolean} isRepoRoot - Repo-root predicate.
|
||||
* @returns {string|null} The nearest repo root, or null if there is none.
|
||||
*/
|
||||
function nearestRepoRoot(absPath, isRepoRoot) {
|
||||
let dir = absPath;
|
||||
for (;;) {
|
||||
if (isRepoRoot(dir)) return dir;
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope classes, in match order.
|
||||
*
|
||||
* Declaration order IS match order — this object is the single source for the
|
||||
* class name, its gate, its disclosure wording and its predicate, so no caller
|
||||
* and no test can hold a second copy that drifts.
|
||||
*
|
||||
* @type {Record<string, {gate: 'silent'|'disclose'|'require-ok', disclosure: string|null, matches: Function}>}
|
||||
*/
|
||||
export const SCOPE_CLASSES = {
|
||||
// The plugin's own bookkeeping: session state, backups, ledgers. Not the
|
||||
// user's configuration, and written on essentially every run.
|
||||
'plugin-managed': {
|
||||
gate: 'silent',
|
||||
disclosure: null,
|
||||
matches: (target, ctx) => ctx.pluginRoots.some((root) => isWithin(root, target)),
|
||||
},
|
||||
|
||||
// Where the session stands. The ordinary case.
|
||||
'in-repo': {
|
||||
gate: 'silent',
|
||||
disclosure: null,
|
||||
matches: (target, ctx) => ctx.repoRoot !== null && isWithin(ctx.repoRoot, target),
|
||||
},
|
||||
|
||||
// Machine-wide configuration: loaded in every repo, on every turn, so the
|
||||
// cost of a change here is not confined to the project in front of the user.
|
||||
'user-scope': {
|
||||
gate: 'require-ok',
|
||||
disclosure: 'This writes to your machine-wide Claude configuration, outside this project. '
|
||||
+ 'It affects every project you open, so it needs your explicit go-ahead.',
|
||||
matches: (target, ctx) => isWithin(ctx.userConfigRoot, target),
|
||||
},
|
||||
|
||||
// A different project. Some commands do this by design; the gate is to say
|
||||
// so, not to refuse.
|
||||
'cross-repo': {
|
||||
gate: 'disclose',
|
||||
disclosure: 'This writes into a different project than the one you are working in. '
|
||||
+ 'Any directories it needs there will be created.',
|
||||
matches: (target, ctx) => {
|
||||
const root = nearestRepoRoot(target, ctx.isRepoRoot);
|
||||
return root !== null && root !== ctx.repoRoot;
|
||||
},
|
||||
},
|
||||
|
||||
// Neither this project, nor another project, nor machine-wide config.
|
||||
'outside': {
|
||||
gate: 'require-ok',
|
||||
disclosure: 'This writes to a location outside any project and outside your Claude '
|
||||
+ 'configuration, so it needs your explicit go-ahead.',
|
||||
matches: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Gate strengths, weakest first. Lives here rather than in a caller: a second
|
||||
* copy of this ordering would decide, independently, which gate a multi-target
|
||||
* write shows — the drift shape the class table itself exists to prevent.
|
||||
*/
|
||||
export const GATE_RANK = ['silent', 'disclose', 'require-ok'];
|
||||
|
||||
/**
|
||||
* The strongest gate among already-classified targets. One `require-ok` target
|
||||
* in a set drives the whole surface: a run that would write machine-wide config
|
||||
* does not get to be quiet because most of its other targets are ordinary.
|
||||
*
|
||||
* @param {Array<{gate: string}>} targets
|
||||
* @returns {string} The strongest gate, or 'silent' when there are no targets.
|
||||
*/
|
||||
export function strongestGate(targets) {
|
||||
let worst = 'silent';
|
||||
for (const t of targets) {
|
||||
if (GATE_RANK.indexOf(t.gate) > GATE_RANK.indexOf(worst)) worst = t.gate;
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a write target relative to the repo the session stands in.
|
||||
*
|
||||
* @param {string} targetPath - The path that is about to be written.
|
||||
* @param {string|null} sessionRepoRoot - Repo root of the current session.
|
||||
* @param {object} [options]
|
||||
* @param {(dir: string) => boolean} [options.isRepoRoot] - Repo-root predicate.
|
||||
* @param {string} [options.home] - Override for the home directory.
|
||||
* @returns {{scopeClass: string, gate: string, disclosure: string|null, target: string}}
|
||||
*/
|
||||
export function classifyWriteTarget(targetPath, sessionRepoRoot, options = {}) {
|
||||
const home = options.home ?? homedir();
|
||||
const isRepoRoot = options.isRepoRoot ?? defaultIsRepoRoot;
|
||||
|
||||
const target = resolve(targetPath);
|
||||
const ctx = {
|
||||
repoRoot: sessionRepoRoot === null || sessionRepoRoot === undefined
|
||||
? null
|
||||
: resolve(sessionRepoRoot),
|
||||
userConfigRoot: join(home, '.claude'),
|
||||
// Both roots are live: `backup.mjs` prefers `~/.claude/config-audit/` and
|
||||
// falls back to the legacy `~/.config-audit/`.
|
||||
pluginRoots: [
|
||||
join(home, '.claude', 'config-audit'),
|
||||
join(home, '.config-audit'),
|
||||
],
|
||||
isRepoRoot,
|
||||
};
|
||||
|
||||
for (const [scopeClass, spec] of Object.entries(SCOPE_CLASSES)) {
|
||||
if (spec.matches(target, ctx)) {
|
||||
return { scopeClass, gate: spec.gate, disclosure: spec.disclosure, target };
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable: `outside` matches unconditionally. Kept so a future edit that
|
||||
// narrows the last predicate fails loudly instead of returning undefined.
|
||||
throw new Error(`write-scope: no class matched ${target}`);
|
||||
}
|
||||
|
|
@ -40,13 +40,8 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { writeFile, stat } from 'node:fs/promises';
|
||||
import { readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
|
||||
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
|
||||
const ARG_SPEC = { boolean: ['--json', '--raw'], value: ['--output-file'] };
|
||||
|
||||
// CLAUDE.md cascade files are all discovered by walking UP from the repo, so
|
||||
// each one is always-loaded; the scope only changes the derivation confidence.
|
||||
|
|
@ -119,10 +114,6 @@ export function buildManifest(activeConfig) {
|
|||
name: a.name,
|
||||
source: sourceLabel(a, 'project'),
|
||||
estimated_tokens: a.estimatedTokens || 0,
|
||||
// Routing axes (C4) — named explicitly because withLoadPattern copies the
|
||||
// row plus the load-pattern triple, nothing else from the enumeration.
|
||||
model: a.model ?? null,
|
||||
effort: a.effort ?? null,
|
||||
}, a));
|
||||
}
|
||||
|
||||
|
|
@ -249,7 +240,6 @@ function estimateClaudeMdEntryTokens(file, activeConfig) {
|
|||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
let targetPath = '.';
|
||||
let outputFile = null;
|
||||
let jsonMode = false;
|
||||
|
|
@ -269,13 +259,11 @@ async function main() {
|
|||
const s = await stat(absPath);
|
||||
if (!s.isDirectory()) {
|
||||
process.stderr.write(`Error: ${absPath} is not a directory\n`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
process.exit(3);
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
|
@ -297,7 +285,7 @@ async function main() {
|
|||
const json = JSON.stringify(output, null, 2);
|
||||
|
||||
if (outputFile) {
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
}
|
||||
|
||||
if (jsonMode || rawMode || !outputFile) {
|
||||
|
|
@ -309,6 +297,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (!parsed) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid JSON in MCP config',
|
||||
description: `${file.relPath}: Failed to parse as JSON.`,
|
||||
|
|
@ -76,7 +75,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (config.type && !VALID_SERVER_TYPES.has(config.type)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unknown-server-type',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Unknown MCP server type',
|
||||
description: `${file.relPath}: Server "${name}" has unknown type "${config.type}".`,
|
||||
|
|
@ -90,7 +88,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (config.type === 'sse') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'sse-transport',
|
||||
severity: SEVERITY.info,
|
||||
title: 'SSE server type — consider HTTP',
|
||||
description: `${file.relPath}: Server "${name}" uses "sse" type. The "http" type is the current standard.`,
|
||||
|
|
@ -113,7 +110,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (!hasEnvBlock) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unreferenced-env-var',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Unreferenced env var in args',
|
||||
description: `${file.relPath}: Server "${name}" references \${${varName}} in args but has no env block defining it.`,
|
||||
|
|
@ -131,7 +127,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (!VALID_SERVER_FIELDS.has(key)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unknown-server-field',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Unknown MCP server field',
|
||||
description: `${file.relPath}: Server "${name}" has unknown field "${key}".`,
|
||||
|
|
|
|||
|
|
@ -122,7 +122,6 @@ export async function scan(targetPath, discovery) {
|
|||
findings.push(
|
||||
finding({
|
||||
scanner: SCANNER,
|
||||
code: 'procedure-should-be-skill',
|
||||
severity: SEVERITY.low,
|
||||
title: PROCEDURE_TITLE,
|
||||
description: claim,
|
||||
|
|
|
|||
|
|
@ -24,28 +24,14 @@
|
|||
* Exit codes: 0=ok, 3=unrecoverable error. Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile, readFile, stat } from 'node:fs/promises';
|
||||
import { discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { resetCounter } from './lib/output.mjs';
|
||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { loadRegister, getEntry } from './lib/best-practices-register.mjs';
|
||||
import { prefilterClaudeMd, LENS_DETECTORS } from './lib/lens-prefilter.mjs';
|
||||
import { subtractionCandidates, SUBTRACT_DETECTORS } from './lib/subtraction-prefilter.mjs';
|
||||
import { scan as optScan } from './optimization-lens-scanner.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
|
||||
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
|
||||
const ARG_SPEC = { boolean: ['--global', '--subtract'], value: ['--output-file'] };
|
||||
|
||||
// Files under `.claude/plugins/` are shipped by an installed plugin — vendored
|
||||
// CLAUDE.md plus its bundled tests/fixtures and examples. They are not the user's
|
||||
// authored config, so a mechanism-fit suggestion against them is not actionable
|
||||
// (the user can't edit a file the plugin overwrites on update). Excluded from the
|
||||
// lens regardless of active/stale version. (M-BUG-11; mirrors the M-BUG-2 rule
|
||||
// that keeps plugin-bundled config out of the conflict detector.)
|
||||
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
|
||||
const isPluginBundled = (file) => (file.absPath || '').includes(PLUGIN_TREE_MARKER);
|
||||
|
||||
/** Confirmed register entry for `id`, or null. */
|
||||
function confirmedEntry(register, id) {
|
||||
|
|
@ -55,15 +41,12 @@ function confirmedEntry(register, id) {
|
|||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
let targetPath = '.';
|
||||
let outputFile = null;
|
||||
let includeGlobal = false;
|
||||
let subtract = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--global') includeGlobal = true;
|
||||
else if (args[i] === '--subtract') subtract = true;
|
||||
else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i];
|
||||
else if (!args[i].startsWith('-')) targetPath = args[i];
|
||||
}
|
||||
|
|
@ -73,13 +56,11 @@ async function main() {
|
|||
const s = await stat(absPath);
|
||||
if (!s.isDirectory()) {
|
||||
process.stderr.write(`Error: ${absPath} is not a directory\n`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
process.exit(3);
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
// Load the register once; tolerate its absence (deterministic half still runs).
|
||||
|
|
@ -90,13 +71,8 @@ async function main() {
|
|||
register = null;
|
||||
}
|
||||
|
||||
const rawDiscovery = await discoverConfigFiles(absPath, { includeGlobal });
|
||||
// Scope the lens to the user's authored config: drop plugin-bundled files for
|
||||
// BOTH halves of the motor (the OPT scanner reads discovery.files directly).
|
||||
const discovery = {
|
||||
...rawDiscovery,
|
||||
files: (rawDiscovery.files || []).filter((f) => !isPluginBundled(f)),
|
||||
};
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(absPath, { includeGlobal });
|
||||
|
||||
// ── Deterministic half: the OPT scanner (CA-OPT-001) ──
|
||||
const opt = await optScan(absPath, discovery);
|
||||
|
|
@ -104,11 +80,6 @@ async function main() {
|
|||
// ── Recall half: prose-judgment candidates from the pre-filter ──
|
||||
const claudeMdFiles = (discovery.files || []).filter((f) => f.type === 'claude-md');
|
||||
const candidates = [];
|
||||
// Opt-in only: the subtraction axis asks a different question and must not
|
||||
// fire on a plain `/config-audit optimize` run (brief §7 q3).
|
||||
const subtractCands = [];
|
||||
const subtractEntry = subtract && register ? confirmedEntry(register, 'BP-SUB-001') : null;
|
||||
|
||||
for (const file of claudeMdFiles) {
|
||||
let content;
|
||||
try {
|
||||
|
|
@ -119,37 +90,11 @@ async function main() {
|
|||
const parsed = parseFrontmatter(content);
|
||||
const body = parsed.body || content;
|
||||
const bodyStartLine = parsed.bodyStartLine || 1;
|
||||
|
||||
if (subtractEntry) {
|
||||
for (const cand of subtractionCandidates(body)) {
|
||||
subtractCands.push({
|
||||
file: file.absPath,
|
||||
line: bodyStartLine - 1 + cand.startLine,
|
||||
endLine: bodyStartLine - 1 + cand.endLine,
|
||||
lineCount: cand.lineCount,
|
||||
lensCheck: cand.lensCheck,
|
||||
mechanism: cand.mechanism,
|
||||
signalText: cand.text,
|
||||
register: {
|
||||
id: subtractEntry.id,
|
||||
claim: subtractEntry.claim,
|
||||
recommendation: subtractEntry.recommendation || null,
|
||||
severity: subtractEntry.severity || 'low',
|
||||
source: subtractEntry.source,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const cand of prefilterClaudeMd(body)) {
|
||||
const entry = register ? confirmedEntry(register, cand.registerId) : null;
|
||||
if (!entry) continue; // never surface an unverifiable recommendation
|
||||
candidates.push({
|
||||
// Absolute path: unique + readable. relPath collides across scopes
|
||||
// (a repo-root `CLAUDE.md` and the user-global `~/.claude/CLAUDE.md`
|
||||
// both relPath to `CLAUDE.md`), which would send the agent's Read() to
|
||||
// the wrong file. (M-BUG-11)
|
||||
file: file.absPath,
|
||||
file: file.relPath || file.absPath,
|
||||
line: bodyStartLine - 1 + cand.line,
|
||||
lensCheck: cand.lensCheck,
|
||||
mechanism: cand.mechanism,
|
||||
|
|
@ -197,32 +142,9 @@ async function main() {
|
|||
},
|
||||
};
|
||||
|
||||
// Additive ONLY under --subtract: a plain run's payload must stay byte-identical.
|
||||
if (subtract) {
|
||||
payload.subtract = {
|
||||
enabled: true,
|
||||
candidates: subtractCands,
|
||||
register: subtractEntry
|
||||
? [
|
||||
{
|
||||
id: subtractEntry.id,
|
||||
lensCheck: subtractEntry.lensCheck,
|
||||
claim: subtractEntry.claim,
|
||||
recommendation: subtractEntry.recommendation || null,
|
||||
mechanism: subtractEntry.mechanism || null,
|
||||
severity: subtractEntry.severity || 'low',
|
||||
source: subtractEntry.source,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
detectors: SUBTRACT_DETECTORS.map((d) => ({ ...d })),
|
||||
};
|
||||
payload.counts.subtractCandidates = subtractCands.length;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) {
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
}
|
||||
if (!outputFile) {
|
||||
process.stdout.write(json + '\n');
|
||||
|
|
@ -233,6 +155,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,6 @@ export async function scan(targetPath, _discovery) {
|
|||
if (kci === true) continue;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'strips-coding-instructions',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Custom output style removes built-in coding instructions',
|
||||
description:
|
||||
|
|
@ -128,7 +127,6 @@ export async function scan(targetPath, _discovery) {
|
|||
if (ffp !== true) continue;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'plugin-forces-style',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Plugin output style overrides your selected output style',
|
||||
description:
|
||||
|
|
@ -157,7 +155,6 @@ export async function scan(targetPath, _discovery) {
|
|||
const customNames = styles.map(s => s.name);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'style-not-found',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Configured output style does not exist',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -9,10 +9,8 @@
|
|||
*/
|
||||
|
||||
import { readdir, stat, readFile } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
||||
import { join, basename, resolve, sep } from 'node:path';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { humanizeFindings } from './lib/humanizer.mjs';
|
||||
|
|
@ -222,7 +220,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-plugin-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid plugin.json',
|
||||
description: `plugin.json is not valid JSON in ${pluginName}`,
|
||||
|
|
@ -239,7 +236,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!parsed[field]) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-required-field',
|
||||
severity: SEVERITY.high,
|
||||
title: `Missing required field in plugin.json: ${field}`,
|
||||
description: `Plugin "${pluginName}" plugin.json is missing required field "${field}"`,
|
||||
|
|
@ -261,7 +257,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!(await dirExists(join(pluginDir, defaultDir)))) continue;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'plugin-json-shadows-default',
|
||||
severity: SEVERITY.medium,
|
||||
title: `plugin.json "${key}" path shadows the default ${defaultDir}/ folder`,
|
||||
description:
|
||||
|
|
@ -301,7 +296,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
const m = SKILLS_ENTRY_MESSAGES[problem];
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'skills-array-entry',
|
||||
severity: SEVERITY.medium,
|
||||
title: m.title(entry),
|
||||
description: `Plugin "${pluginName}": ${m.description}`,
|
||||
|
|
@ -317,7 +311,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-plugin-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Missing plugin.json',
|
||||
description: `No .claude-plugin/plugin.json found in ${pluginName}`,
|
||||
|
|
@ -343,7 +336,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!hasSection) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'claude-md-missing-section',
|
||||
severity: SEVERITY.medium,
|
||||
title: `CLAUDE.md missing ${section} section`,
|
||||
description: `Plugin "${pluginName}" CLAUDE.md should have a ${section} table or section`,
|
||||
|
|
@ -355,7 +347,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-claude-md',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Missing CLAUDE.md',
|
||||
description: `Plugin "${pluginName}" has no CLAUDE.md`,
|
||||
|
|
@ -379,7 +370,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!frontmatter) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'command-missing-frontmatter',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Command missing frontmatter',
|
||||
description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`,
|
||||
|
|
@ -393,7 +383,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!frontmatter[key]) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'command-missing-field',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Command missing frontmatter field: ${display}`,
|
||||
description: `Command "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
|
||||
|
|
@ -420,7 +409,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!frontmatter) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'agent-missing-frontmatter',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Agent missing frontmatter',
|
||||
description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`,
|
||||
|
|
@ -434,7 +422,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!frontmatter[key]) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'agent-missing-field',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Agent missing frontmatter field: ${display}`,
|
||||
description: `Agent "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
|
||||
|
|
@ -450,7 +437,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (frontmatter[key] !== undefined) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'agent-ignored-key',
|
||||
severity,
|
||||
title: `Plugin agent sets "${key}", which Claude Code ignores`,
|
||||
description: `Agent "${file}" in plugin "${pluginName}" sets "${key}" in frontmatter, but Claude Code ignores ${key} for plugin subagents — ${key === 'permissionMode' ? 'the agent runs with default permissions, not the restricted mode this implies' : 'this configuration has no effect'}.`,
|
||||
|
|
@ -473,7 +459,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!parsed.hooks || typeof parsed.hooks !== 'object') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-json-invalid-structure',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hooks.json structure',
|
||||
description: `hooks.json in "${pluginName}" missing "hooks" object`,
|
||||
|
|
@ -483,7 +468,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} else if (Array.isArray(parsed.hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-json-array',
|
||||
severity: SEVERITY.high,
|
||||
title: 'hooks.json uses array instead of object',
|
||||
description: `hooks.json "hooks" in "${pluginName}" is an array — must be object with event keys`,
|
||||
|
|
@ -494,7 +478,6 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-json-invalid',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hooks.json',
|
||||
description: `hooks.json is not valid JSON in "${pluginName}"`,
|
||||
|
|
@ -507,18 +490,11 @@ async function scanSinglePlugin(pluginDir) {
|
|||
const pluginMetaDir = join(pluginDir, '.claude-plugin');
|
||||
try {
|
||||
const entries = await readdir(pluginMetaDir);
|
||||
// `marketplace.json` belongs here: it is the documented, required location
|
||||
// for a marketplace catalog (code.claude.com/docs plugin-marketplaces —
|
||||
// "Create `.claude-plugin/marketplace.json` in your repository root"), and a
|
||||
// marketplace entry with `"source": "./"` makes the repo root its own
|
||||
// plugin. Such a repo legitimately carries both files, so flagging the
|
||||
// catalog as an unknown file was a false positive.
|
||||
const known = new Set(['plugin.json', 'marketplace.json']);
|
||||
const known = new Set(['plugin.json']);
|
||||
for (const entry of entries) {
|
||||
if (!known.has(entry)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unknown-plugin-file',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Unknown file in .claude-plugin/',
|
||||
description: `Unexpected file "${entry}" in .claude-plugin/ of "${pluginName}"`,
|
||||
|
|
@ -532,62 +508,27 @@ async function scanSinglePlugin(pluginDir) {
|
|||
return { name: pluginName, declaredName, findings, commandCount, agentCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-plugin score and grade. Single source for both the terminal report and
|
||||
* the --output-file payload — the grade formula used to live only inside
|
||||
* `formatPluginHealthReport`, which nothing called.
|
||||
* @param {number} issueCount
|
||||
* @returns {{ score: number, grade: string }}
|
||||
*/
|
||||
export function pluginGrade(issueCount) {
|
||||
const score = Math.max(0, 100 - issueCount * 10);
|
||||
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
|
||||
return { score, grade };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan one or more plugins and return aggregated results.
|
||||
*
|
||||
* The envelope is frozen at the v5.0.0 shape (byte-stable `--raw`/`--json`), so
|
||||
* per-plugin rows and the cross-plugin/per-plugin split are NOT in it. Callers
|
||||
* that need those — the `--output-file` payload, and therefore
|
||||
* `/config-audit plugin-health` — use `scanDetailed`.
|
||||
*
|
||||
* @param {string} targetPath - Plugin dir or marketplace root
|
||||
* @returns {Promise<object>} Scanner result
|
||||
*/
|
||||
export async function scan(targetPath) {
|
||||
return (await scanDetailed(targetPath)).result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan, and also return what `scan()`'s frozen envelope cannot carry: one row
|
||||
* per plugin (name, declared namespace, component counts, grade) and the
|
||||
* cross-plugin findings as a distinct set.
|
||||
*
|
||||
* @param {string} targetPath - Plugin dir or marketplace root
|
||||
* @returns {Promise<{ result: object, plugins: object[], crossPluginFindings: object[] }>}
|
||||
*/
|
||||
export async function scanDetailed(targetPath) {
|
||||
const start = Date.now();
|
||||
resetCounter();
|
||||
|
||||
const pluginDirs = await discoverPlugins(resolve(targetPath));
|
||||
|
||||
if (pluginDirs.length === 0) {
|
||||
return {
|
||||
result: scannerResult(SCANNER, 'ok', [
|
||||
finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-plugins-found',
|
||||
severity: SEVERITY.info,
|
||||
title: 'No plugins found',
|
||||
description: `No Claude Code plugins found under ${targetPath}`,
|
||||
recommendation: 'Ensure plugins have .claude-plugin/plugin.json',
|
||||
}),
|
||||
], 0, Date.now() - start),
|
||||
plugins: [],
|
||||
crossPluginFindings: [],
|
||||
};
|
||||
return scannerResult(SCANNER, 'ok', [
|
||||
finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
title: 'No plugins found',
|
||||
description: `No Claude Code plugins found under ${targetPath}`,
|
||||
recommendation: 'Ensure plugins have .claude-plugin/plugin.json',
|
||||
}),
|
||||
], 0, Date.now() - start);
|
||||
}
|
||||
|
||||
const allFindings = [];
|
||||
|
|
@ -599,12 +540,6 @@ export async function scanDetailed(targetPath) {
|
|||
allFindings.push(...result.findings);
|
||||
}
|
||||
|
||||
// Everything pushed from here on is a cross-plugin finding — the boundary the
|
||||
// payload uses to split them out (they are flattened into `findings` in the
|
||||
// frozen envelope, where `category: 'plugin-hygiene'` cannot tell them apart
|
||||
// from the per-plugin shadow/skills findings that share it).
|
||||
const crossPluginStart = allFindings.length;
|
||||
|
||||
// Cross-plugin checks: command-name ambiguity across DIFFERENT plugin namespaces.
|
||||
// Commands are namespaced by the plugin's declared name (/name:command), so a
|
||||
// shared command name across DIFFERENT plugins is ambiguity — not a hard
|
||||
|
|
@ -637,7 +572,6 @@ export async function scanDetailed(targetPath) {
|
|||
const namespaceList = entries.map(e => e.namespace).join(', ');
|
||||
allFindings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'command-name-collision',
|
||||
severity: SEVERITY.low,
|
||||
title: `Command name "${cmdName}" used by multiple plugins`,
|
||||
description:
|
||||
|
|
@ -673,7 +607,6 @@ export async function scanDetailed(targetPath) {
|
|||
if (dirs.length < 2) continue;
|
||||
allFindings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'namespace-collision',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Plugin namespace collision: "${declaredName}"`,
|
||||
description:
|
||||
|
|
@ -699,19 +632,7 @@ export async function scanDetailed(targetPath) {
|
|||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
result: scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start),
|
||||
plugins: pluginResults.map((p, idx) => ({
|
||||
name: p.name,
|
||||
declaredName: p.declaredName,
|
||||
path: pluginDirs[idx],
|
||||
commandCount: p.commandCount,
|
||||
agentCount: p.agentCount,
|
||||
findingCount: p.findings.length,
|
||||
...pluginGrade(p.findings.length),
|
||||
})),
|
||||
crossPluginFindings: allFindings.slice(crossPluginStart),
|
||||
};
|
||||
return scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -728,7 +649,9 @@ export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
|
|||
lines.push('');
|
||||
|
||||
for (const p of pluginResults) {
|
||||
const { score, grade } = pluginGrade(p.findings.length);
|
||||
const issueCount = p.findings.length;
|
||||
const score = Math.max(0, 100 - issueCount * 10);
|
||||
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
|
||||
const padding = '.'.repeat(Math.max(1, 25 - p.name.length));
|
||||
lines.push(` ${p.name} ${padding} ${grade} (${score}) ${p.commandCount} commands, ${p.agentCount} agents`);
|
||||
}
|
||||
|
|
@ -752,55 +675,27 @@ export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
|
|||
}
|
||||
|
||||
// --- CLI entry point ---
|
||||
const BOOL_FLAGS = ['--json', '--raw'];
|
||||
const VALUE_FLAGS = ['--output-file'];
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let targetPath = '.';
|
||||
let jsonMode = false;
|
||||
let rawMode = false;
|
||||
let outputFile = null;
|
||||
|
||||
// M-BUG-21, third arm: this loop used to end in
|
||||
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
|
||||
// unknown-flag branch. An unrecognised flag was dropped silently and its
|
||||
// VALUE became the scan target, so `--output-file /tmp/x.json` scanned
|
||||
// /tmp/x.json. Unlike drift-cli, the result LOOKS fine: a non-existent path
|
||||
// discovers no plugins, so the scanner reported "No plugins found" (info) and
|
||||
// exit 0 — a green answer to a question nobody asked. Now it fails loudly.
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (BOOL_FLAGS.includes(arg)) {
|
||||
if (arg === '--json') jsonMode = true;
|
||||
else if (arg === '--raw') rawMode = true;
|
||||
} else if (VALUE_FLAGS.includes(arg)) {
|
||||
const value = args[i + 1];
|
||||
if (value === undefined || value.startsWith('-')) {
|
||||
throw new Error(`Option ${arg} requires a value.`);
|
||||
}
|
||||
outputFile = value;
|
||||
i++;
|
||||
} else if (arg.startsWith('-')) {
|
||||
throw new Error(
|
||||
`Unknown option: ${arg}\n` +
|
||||
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
|
||||
);
|
||||
} else {
|
||||
targetPath = arg;
|
||||
if (args[i] === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (args[i] === '--raw') {
|
||||
rawMode = true;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
targetPath = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await requireTargetDir(resolve(targetPath)))) {
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
const humanizedProgress = !jsonMode && !rawMode;
|
||||
process.stderr.write(humanizedProgress ? `Plugin Health v2.1.0\n` : `Plugin Health Scanner v2.1.0\n`);
|
||||
process.stderr.write(`Target: ${resolve(targetPath)}\n\n`);
|
||||
|
||||
const { result, plugins, crossPluginFindings } = await scanDetailed(targetPath);
|
||||
const result = await scan(targetPath);
|
||||
|
||||
if (jsonMode || rawMode) {
|
||||
// --json and --raw both write the v5.0.0-shape result (byte-identical).
|
||||
|
|
@ -813,24 +708,6 @@ async function main() {
|
|||
for (const f of findings) {
|
||||
process.stderr.write(` [${f.severity}] ${f.title}\n`);
|
||||
}
|
||||
|
||||
// ux-rules rule 2: the command runs with `2>/dev/null`, so anything it must
|
||||
// ACT on has to ride in the --output-file payload. Everything above this
|
||||
// point is stderr, i.e. invisible to `/config-audit plugin-health`.
|
||||
if (outputFile) {
|
||||
const crossIds = new Set(crossPluginFindings.map(f => f.id));
|
||||
for (const f of findings) {
|
||||
if (crossIds.has(f.id)) f.crossPlugin = true;
|
||||
}
|
||||
const payload = {
|
||||
...result,
|
||||
findings,
|
||||
plugins,
|
||||
cross_plugin_findings: findings.filter(f => crossIds.has(f.id)),
|
||||
};
|
||||
await writeOutputFile(outputFile, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -838,6 +715,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,8 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { humanizeEnvelope } from './lib/humanizer.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
import {
|
||||
calculateUtilization,
|
||||
determineMaturityLevel,
|
||||
|
|
@ -23,12 +20,6 @@ import {
|
|||
generateHealthScorecard,
|
||||
} from './lib/scoring.mjs';
|
||||
|
||||
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
|
||||
const ARG_SPEC = {
|
||||
boolean: ['--json', '--raw', '--global', '--full-machine', '--include-fixtures'],
|
||||
value: ['--output-file', '--context-window'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Run posture assessment and return structured result.
|
||||
* @param {string} targetPath
|
||||
|
|
@ -66,7 +57,6 @@ export async function runPosture(targetPath, opts = {}) {
|
|||
// --- CLI entry point ---
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
let targetPath = '.';
|
||||
let outputFile = null;
|
||||
let jsonMode = false;
|
||||
|
|
@ -95,11 +85,6 @@ async function main() {
|
|||
}
|
||||
}
|
||||
|
||||
if (!(await requireTargetDir(resolve(targetPath)))) {
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
const filterFixtures = !args.includes('--include-fixtures');
|
||||
const humanizedProgress = !jsonMode && !rawMode;
|
||||
const result = await runPosture(targetPath, {
|
||||
|
|
@ -129,15 +114,8 @@ async function main() {
|
|||
}
|
||||
|
||||
if (outputFile) {
|
||||
// Consumers (feature-gap.md, posture.md) read scannerEnvelope.scanners[].findings
|
||||
// and group on humanizer fields. posture's result nests the envelope under
|
||||
// `scannerEnvelope`, so humanize THAT (not `result`, which has no top-level
|
||||
// `scanners` array — humanizeEnvelope would no-op). --json/--raw stay raw.
|
||||
const fileEnv = (jsonMode || rawMode)
|
||||
? result
|
||||
: { ...result, scannerEnvelope: humanizeEnvelope(result.scannerEnvelope) };
|
||||
const json = JSON.stringify(fileEnv, null, 2);
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
const json = JSON.stringify(result, null, 2);
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
}
|
||||
}
|
||||
|
|
@ -147,10 +125,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
// 3, not 1. Every command in this plugin is told that 0/1/2 are normal grades
|
||||
// (PASS/WARNING/FAIL) and only 3 is a real error, so exiting 1 here made a crash
|
||||
// indistinguishable from a WARNING — and the command went on to Read a payload file
|
||||
// that was never written.
|
||||
process.exitCode = 3;
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,72 +6,47 @@
|
|||
|
||||
import { readFile, writeFile, readdir, stat, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getBackupDir, getLegacyBackupDir, parseManifest, checksum } from './lib/backup.mjs';
|
||||
|
||||
/**
|
||||
* Resolve a backup id to its directory, canonical root first, then the
|
||||
* pre-v2.2.0 root. Returns null when the id exists in neither.
|
||||
* @param {string} backupId
|
||||
* @returns {Promise<{ path: string, legacy: boolean } | null>}
|
||||
*/
|
||||
async function resolveBackupPath(backupId) {
|
||||
for (const [root, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) {
|
||||
const candidate = join(root, backupId);
|
||||
try {
|
||||
await stat(join(candidate, 'manifest.yaml'));
|
||||
return { path: candidate, legacy };
|
||||
} catch {
|
||||
// try the next root
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
import { getBackupDir, parseManifest, checksum } from './lib/backup.mjs';
|
||||
|
||||
/**
|
||||
* List all available backups.
|
||||
* @returns {Promise<{ backups: object[] }>}
|
||||
*/
|
||||
export async function listBackups() {
|
||||
const backupRoot = getBackupDir();
|
||||
const backups = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Canonical root first; a legacy backup with the same id must not shadow it.
|
||||
for (const [backupRoot, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) {
|
||||
let entries;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(backupRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return { backups: [] };
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const backupPath = join(backupRoot, entry.name);
|
||||
const manifestPath = join(backupPath, 'manifest.yaml');
|
||||
|
||||
try {
|
||||
entries = await readdir(backupRoot, { withFileTypes: true });
|
||||
const manifestContent = await readFile(manifestPath, 'utf-8');
|
||||
const manifest = parseManifest(manifestContent);
|
||||
|
||||
backups.push({
|
||||
id: entry.name,
|
||||
createdAt: manifest.created_at,
|
||||
files: manifest.files.map(f => ({
|
||||
originalPath: f.originalPath,
|
||||
backupPath: f.backupPath,
|
||||
checksum: f.checksum,
|
||||
sizeBytes: f.sizeBytes,
|
||||
})),
|
||||
});
|
||||
} catch {
|
||||
// Skip backups without valid manifest
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || seen.has(entry.name)) continue;
|
||||
|
||||
const backupPath = join(backupRoot, entry.name);
|
||||
const manifestPath = join(backupPath, 'manifest.yaml');
|
||||
|
||||
try {
|
||||
const manifestContent = await readFile(manifestPath, 'utf-8');
|
||||
const manifest = parseManifest(manifestContent);
|
||||
|
||||
seen.add(entry.name);
|
||||
backups.push({
|
||||
id: entry.name,
|
||||
createdAt: manifest.created_at,
|
||||
legacy,
|
||||
files: manifest.files.map(f => ({
|
||||
originalPath: f.originalPath,
|
||||
backupPath: f.backupPath,
|
||||
checksum: f.checksum,
|
||||
sizeBytes: f.sizeBytes,
|
||||
})),
|
||||
created: manifest.created,
|
||||
});
|
||||
} catch {
|
||||
// Skip backups without valid manifest
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
|
|
@ -90,22 +65,22 @@ export async function listBackups() {
|
|||
*/
|
||||
export async function restoreBackup(backupId, opts = {}) {
|
||||
const verify = opts.verify !== false;
|
||||
const resolved = await resolveBackupPath(backupId);
|
||||
if (!resolved) throw new Error(`Backup not found: ${backupId}`);
|
||||
const backupRoot = getBackupDir();
|
||||
const backupPath = join(backupRoot, backupId);
|
||||
const manifestPath = join(backupPath, 'manifest.yaml');
|
||||
|
||||
const backupPath = resolved.path;
|
||||
const manifestContent = await readFile(join(backupPath, 'manifest.yaml'), 'utf-8');
|
||||
// Read manifest
|
||||
let manifestContent;
|
||||
try {
|
||||
manifestContent = await readFile(manifestPath, 'utf-8');
|
||||
} catch {
|
||||
throw new Error(`Backup not found: ${backupId}`);
|
||||
}
|
||||
|
||||
const manifest = parseManifest(manifestContent);
|
||||
const restored = [];
|
||||
const failed = [];
|
||||
|
||||
// A manifest with entries that parsed to nothing would restore nothing while
|
||||
// reporting success. Fail loudly instead.
|
||||
if (manifest.files.length === 0 && /^\s+-\s/m.test(manifestContent)) {
|
||||
throw new Error(`Unreadable manifest for backup ${backupId}: entries present but none parsed`);
|
||||
}
|
||||
|
||||
for (const fileEntry of manifest.files) {
|
||||
const backupFilePath = join(backupPath, fileEntry.backupPath);
|
||||
|
||||
|
|
@ -164,10 +139,7 @@ export async function restoreBackup(backupId, opts = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
// Files implement CREATED are absent from the backup by definition, so they
|
||||
// survive the restore. Report them — a half-restored target is only dangerous
|
||||
// when it is also silent.
|
||||
return { restored, failed, createdNotRemoved: manifest.created, legacy: resolved.legacy };
|
||||
return { restored, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -176,11 +148,17 @@ export async function restoreBackup(backupId, opts = {}) {
|
|||
* @returns {Promise<{ deleted: boolean, error?: string }>}
|
||||
*/
|
||||
export async function deleteBackup(backupId) {
|
||||
const resolved = await resolveBackupPath(backupId);
|
||||
if (!resolved) return { deleted: false, error: `Backup not found: ${backupId}` };
|
||||
const backupRoot = getBackupDir();
|
||||
const backupPath = join(backupRoot, backupId);
|
||||
|
||||
try {
|
||||
await rm(resolved.path, { recursive: true, force: true });
|
||||
await stat(backupPath);
|
||||
} catch {
|
||||
return { deleted: false, error: `Backup not found: ${backupId}` };
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(backupPath, { recursive: true, force: true });
|
||||
return { deleted: true };
|
||||
} catch (err) {
|
||||
return { deleted: false, error: err.message };
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (lines > 5) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-frontmatter',
|
||||
severity: SEVERITY.info,
|
||||
title: 'Rule has no frontmatter (always active)',
|
||||
description: `${file.relPath} has no YAML frontmatter. It will be loaded for ALL files. Add paths: frontmatter to scope it.`,
|
||||
|
|
@ -70,7 +69,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (frontmatter.globs && !frontmatter.paths) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'globs-instead-of-paths',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Rule uses "globs" instead of documented "paths"',
|
||||
description: `${file.relPath} uses "globs:" for scoping. Claude Code's documentation specifies "paths:" as the rule-scoping field; "globs:" is not documented. Rename to "paths:" so the rule scopes as intended.`,
|
||||
|
|
@ -101,7 +99,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (matchCount === 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'pattern-matches-nothing',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Rule path pattern matches no files',
|
||||
description: `${file.relPath}: pattern "${pattern}" matches 0 files. This rule will never activate.`,
|
||||
|
|
@ -120,7 +117,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (lines < 2) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'nearly-empty',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Rule file is nearly empty',
|
||||
description: `${file.relPath} has only ${lines} line(s).`,
|
||||
|
|
@ -134,7 +130,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (!frontmatter?.paths && !frontmatter?.globs && lines > 50) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'large-unscoped',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Large unscoped rule file',
|
||||
description: `${file.relPath} has ${lines} lines and no path scoping. It loads into context for every file interaction.`,
|
||||
|
|
@ -152,7 +147,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (frontmatter?.paths && lines > 50) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'large-scoped-lost-after-compaction',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Large path-scoped rule is lost after compaction',
|
||||
description: `${file.relPath} is path-scoped (${lines} lines). Path-scoped rules load only when a matching file is read, and after a context compaction they are not re-injected until a matching file is read again — so a large scoped rule carrying must-always-hold instructions can silently drop out mid-session.`,
|
||||
|
|
@ -167,7 +161,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (!file.absPath.endsWith('.md')) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'not-markdown',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Rule file is not .md',
|
||||
description: `${file.relPath} is not a .md file. Only .md files are loaded from rules/.`,
|
||||
|
|
@ -258,9 +251,9 @@ function globToRegex(pattern) {
|
|||
.replace(/\/\*\*\//g, '{{GLOBSTAR_SLASH}}')
|
||||
.replace(/\*\*/g, '{{GLOBSTAR}}')
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.replace(/\?/g, '[^/]') // must run BEFORE placeholder restore — '(?:' would corrupt
|
||||
.replace(/\{\{GLOBSTAR_SLASH\}\}/g, '(?:/.+/|/)') // **/ matches 0+ intermediate dirs
|
||||
.replace(/\{\{GLOBSTAR\}\}/g, '.*');
|
||||
.replace(/\{\{GLOBSTAR\}\}/g, '.*')
|
||||
.replace(/\?/g, '[^/]');
|
||||
|
||||
// Handle leading patterns
|
||||
if (!regex.startsWith('.*') && !regex.startsWith('/')) {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,10 @@
|
|||
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
||||
import { resetCounter } from './lib/output.mjs';
|
||||
import { envelope } from './lib/output.mjs';
|
||||
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
|
||||
import { loadSuppressions, applySuppressions, formatSuppressionSummary, unknownSuppressions } from './lib/suppression.mjs';
|
||||
import { loadSuppressions, applySuppressions, formatSuppressionSummary } from './lib/suppression.mjs';
|
||||
import { humanizeEnvelope } from './lib/humanizer.mjs';
|
||||
import { resolveContextWindow } from './lib/context-window.mjs';
|
||||
import { resolveActiveModel } from './lib/active-model.mjs';
|
||||
|
|
@ -35,16 +34,6 @@ import { scan as scanSkillListing } from './skill-listing-scanner.mjs';
|
|||
import { scan as scanAgentListing } from './agent-listing-scanner.mjs';
|
||||
import { scan as scanOutputStyle } from './output-style-scanner.mjs';
|
||||
import { scan as scanOptimizationLens } from './optimization-lens-scanner.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
|
||||
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
|
||||
const ARG_SPEC = {
|
||||
boolean: [
|
||||
'--json', '--raw', '--global', '--full-machine', '--no-suppress',
|
||||
'--include-fixtures', '--exclude-cache', '--no-exclude-cache', '--save-baseline',
|
||||
],
|
||||
value: ['--output-file', '--context-window', '--baseline'],
|
||||
};
|
||||
|
||||
// Directory names that identify test fixture / example directories
|
||||
const FIXTURE_DIR_NAMES = ['tests', 'examples', '__tests__', 'test-fixtures'];
|
||||
|
|
@ -133,6 +122,7 @@ export async function runAllScanners(targetPath, opts = {}) {
|
|||
const results = [];
|
||||
|
||||
for (const scanner of SCANNERS) {
|
||||
resetCounter();
|
||||
const scanStart = Date.now();
|
||||
try {
|
||||
const result = await scanner.fn(resolvedPath, discovery, { contextWindow });
|
||||
|
|
@ -192,14 +182,8 @@ export async function runAllScanners(targetPath, opts = {}) {
|
|||
const shouldSuppress = opts.suppress !== false;
|
||||
let suppressedFindings = [];
|
||||
|
||||
let deadSuppressions = [];
|
||||
|
||||
if (shouldSuppress) {
|
||||
const { suppressions } = await loadSuppressions(resolvedPath);
|
||||
// A pin that names no declared check can never match. Report it: a silently
|
||||
// dead suppression leaves the user believing a finding is hidden when it is
|
||||
// not (M-BUG-28).
|
||||
deadSuppressions = unknownSuppressions(suppressions);
|
||||
if (suppressions.length > 0) {
|
||||
for (const result of results) {
|
||||
const { active, suppressed } = applySuppressions(result.findings, suppressions);
|
||||
|
|
@ -225,19 +209,12 @@ export async function runAllScanners(targetPath, opts = {}) {
|
|||
if (suppressedFindings.length > 0) {
|
||||
env.suppressed_findings = suppressedFindings;
|
||||
}
|
||||
// ux-rules rule 2: commands run scanners with `2>/dev/null`, so anything they
|
||||
// must ACT on rides in the payload, never in a stderr-only warning. Added only
|
||||
// when a dead pin exists, so a config without one is byte-identical.
|
||||
if (deadSuppressions.length > 0) {
|
||||
env.unknown_suppressions = deadSuppressions;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// --- CLI entry point ---
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
let targetPath = '.';
|
||||
let outputFile = null;
|
||||
let saveBaseline = false;
|
||||
|
|
@ -280,11 +257,6 @@ async function main() {
|
|||
const jsonMode = args.includes('--json');
|
||||
const rawMode = args.includes('--raw');
|
||||
|
||||
if (!(await requireTargetDir(resolve(targetPath)))) {
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
const humanizedProgress = !jsonMode && !rawMode;
|
||||
process.stderr.write(humanizedProgress ? `Config-Audit v2.2.0\n` : `Config-Audit Scanner v2.2.0\n`);
|
||||
process.stderr.write(`Target: ${resolve(targetPath)}\n`);
|
||||
|
|
@ -306,7 +278,7 @@ async function main() {
|
|||
const json = JSON.stringify(output, null, 2);
|
||||
|
||||
if (outputFile) {
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
} else {
|
||||
process.stdout.write(json + '\n');
|
||||
|
|
@ -327,12 +299,10 @@ async function main() {
|
|||
process.stderr.write(`Risk: ${agg.risk_score}/100 (${agg.risk_band})\n`);
|
||||
process.stderr.write(`Verdict: ${agg.verdict}\n`);
|
||||
|
||||
// Exit code. Set, never process.exit(): stdout is written ASYNCHRONOUSLY when
|
||||
// it is a pipe, and process.exit() discards whatever is still buffered. Piping
|
||||
// this envelope used to yield truncated, unparseable JSON (246 854 bytes to a
|
||||
// file vs 65 536 to a pipe) — a corruption that looks like a bad file, not a
|
||||
// cut-off. Letting Node exit naturally drains stdout first.
|
||||
process.exitCode = agg.verdict === 'FAIL' ? 2 : agg.verdict === 'WARNING' ? 1 : 0;
|
||||
// Exit code
|
||||
if (agg.verdict === 'FAIL') process.exit(2);
|
||||
if (agg.verdict === 'WARNING') process.exit(1);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Only run CLI if invoked directly
|
||||
|
|
@ -340,6 +310,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,6 @@ import { gradeFromPassRate } from './lib/severity.mjs';
|
|||
import { loadSuppressions, applySuppressions } from './lib/suppression.mjs';
|
||||
import { parseJson } from './lib/yaml-parser.mjs';
|
||||
import { humanizeEnvelope, humanizeFindings } from './lib/humanizer.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
|
||||
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
|
||||
const ARG_SPEC = { boolean: ['--json', '--fix', '--check-readme'] };
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
|
|
@ -334,7 +330,6 @@ export function formatSelfAudit(result) {
|
|||
// --- CLI entry point ---
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
const jsonMode = args.includes('--json');
|
||||
const fixMode = args.includes('--fix');
|
||||
const checkReadmeMode = args.includes('--check-readme');
|
||||
|
|
@ -355,6 +350,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(file
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,11 +71,8 @@ const TYPE_CHECKS = new Map([
|
|||
['wheelScrollAccelerationEnabled', 'boolean'],
|
||||
]);
|
||||
|
||||
/** Valid effortLevel values (CC 2.1.154 added 'xhigh' as the Opus-4.8 top tier).
|
||||
* Exported because the fix engine's nearest-match needs the SAME list: a second
|
||||
* copy there had gone stale on `xhigh` and quietly corrected near-misses on the
|
||||
* top tier down to `high` (C2). One table, no drift. */
|
||||
export const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
/** Valid effortLevel values (CC 2.1.154 added 'xhigh' as the Opus-4.8 top tier) */
|
||||
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
|
||||
/** v5 M6: warn when additionalDirectories grows beyond this — each entry adds
|
||||
* a project root to walks/discovery, inflating per-turn cost and confusing scope. */
|
||||
|
|
@ -121,7 +118,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (parsed === null) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid JSON in settings file',
|
||||
description: `${file.relPath} contains invalid JSON and will be ignored by Claude Code.`,
|
||||
|
|
@ -152,7 +148,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (nearest) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'key-typo',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Possible typo in settings key',
|
||||
description: `${file.relPath}: "${key}" is not a recognized settings.json key, but it closely matches "${nearest}". Claude Code forwards unrecognized keys unchanged (it does not reject them), so if "${key}" is a typo of "${nearest}" the intended setting silently has no effect.`,
|
||||
|
|
@ -169,7 +164,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (parsed[key] !== undefined) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'deprecated-key',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Deprecated settings key',
|
||||
description: `${file.relPath}: "${key}" is deprecated. ${migration}`,
|
||||
|
|
@ -186,7 +180,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (parsed[key] !== undefined && typeof parsed[key] !== expectedType) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'type-mismatch',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Type mismatch in settings',
|
||||
description: `${file.relPath}: "${key}" should be ${expectedType}, got ${typeof parsed[key]}.`,
|
||||
|
|
@ -202,7 +195,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (parsed.effortLevel && !VALID_EFFORT_LEVELS.has(parsed.effortLevel)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-effort-level',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Invalid effortLevel value',
|
||||
description: `${file.relPath}: effortLevel "${parsed.effortLevel}" is not valid.`,
|
||||
|
|
@ -217,7 +209,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (!parsed.$schema) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-schema',
|
||||
severity: SEVERITY.info,
|
||||
title: 'Missing $schema reference',
|
||||
description: `${file.relPath} lacks a $schema reference. Adding one enables autocomplete in VS Code/Cursor.`,
|
||||
|
|
@ -234,7 +225,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (!perms.deny || (Array.isArray(perms.deny) && perms.deny.length === 0)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-deny-rules',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'No deny rules configured',
|
||||
description: `${file.relPath}: No permission deny rules. Claude can access all files including .env and secrets.`,
|
||||
|
|
@ -247,7 +237,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (!perms.allow || (Array.isArray(perms.allow) && perms.allow.length === 0)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-allow-rules',
|
||||
severity: SEVERITY.low,
|
||||
title: 'No allow rules configured',
|
||||
description: `${file.relPath}: No permission allow rules. This means frequent permission prompts for common operations.`,
|
||||
|
|
@ -263,7 +252,6 @@ export async function scan(targetPath, discovery) {
|
|||
parsed.additionalDirectories.length > ADDITIONAL_DIRS_THRESHOLD) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'many-additional-dirs',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Many additionalDirectories entries',
|
||||
description:
|
||||
|
|
@ -290,7 +278,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (typeof am !== 'object' || am === null || Array.isArray(am)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'automode-not-object',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'autoMode must be an object',
|
||||
description: `${file.relPath}: "autoMode" must be an object with environment/allow/soft_deny/hard_deny arrays, got ${Array.isArray(am) ? 'array' : typeof am}.`,
|
||||
|
|
@ -305,7 +292,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (!AUTO_MODE_SUBKEYS.has(subKey)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'automode-unknown-subkey',
|
||||
severity: SEVERITY.medium,
|
||||
title: `autoMode has an unknown sub-key: ${subKey}`,
|
||||
description: `${file.relPath}: "autoMode.${subKey}" is not a recognized sub-key. Valid keys are environment, allow, soft_deny, hard_deny. It is silently ignored — a typo of a real key (e.g. "hard_denies") means those rules never apply.`,
|
||||
|
|
@ -322,7 +308,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (!isStringArray) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'automode-subkey-not-string-array',
|
||||
severity: SEVERITY.medium,
|
||||
title: `autoMode.${subKey} must be an array of strings`,
|
||||
description: `${file.relPath}: "autoMode.${subKey}" must be an array of prose-rule strings (the literal "$defaults" is allowed), got ${Array.isArray(val) ? 'an array with a non-string entry' : typeof val}.`,
|
||||
|
|
@ -339,7 +324,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (file.scope === 'project') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'automode-in-shared-settings',
|
||||
severity: SEVERITY.low,
|
||||
title: 'autoMode in shared project settings is ignored by Claude Code',
|
||||
description: `${file.relPath}: Claude Code does not read "autoMode" from shared project settings (.claude/settings.json), so a checked-in repo cannot inject its own rules. This autoMode block has no effect where it is.`,
|
||||
|
|
@ -357,7 +341,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (Array.isArray(parsed.hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-as-array',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Hooks configured as array instead of object',
|
||||
description: `${file.relPath}: "hooks" must be an object with event keys, not an array. All hooks will be ignored.`,
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ export async function scan(_targetPath, _discovery, opts = {}) {
|
|||
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'description-over-cap',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Skill description exceeds the listing cap (Claude Code truncates it)',
|
||||
description:
|
||||
|
|
@ -117,7 +116,6 @@ export async function scan(_targetPath, _discovery, opts = {}) {
|
|||
// Conservative 200k anchor — byte-identical to the pre-B8 finding.
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'aggregate-listing-budget',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Aggregate skill descriptions may exceed the listing budget',
|
||||
description:
|
||||
|
|
@ -140,7 +138,6 @@ export async function scan(_targetPath, _discovery, opts = {}) {
|
|||
const winLabel = withCommas(window);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'aggregate-listing-budget',
|
||||
severity: advisory ? SEVERITY.info : SEVERITY.low,
|
||||
title: 'Aggregate skill descriptions may exceed the listing budget',
|
||||
description:
|
||||
|
|
@ -176,7 +173,6 @@ export async function scan(_targetPath, _discovery, opts = {}) {
|
|||
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'oversized-body',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Skill body is large (loads on demand when the skill runs)',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* subtraction-write CLI — execute an APPROVED subtraction set (§C6, chunk #63).
|
||||
*
|
||||
* The one path in the plugin that removes configuration. It takes no judgement
|
||||
* of its own: it is handed a set of blocks a human approved, and its whole job
|
||||
* is to refuse anything that no longer matches, is load-bearing, or leaves the
|
||||
* repo without an explicit go-ahead.
|
||||
*
|
||||
* Usage:
|
||||
* node subtraction-write-cli.mjs --approved <path.json>
|
||||
* [--repo <session-repo-root>]
|
||||
* [--approve-scope] [--dry-run]
|
||||
* [--output-file <path>] [--json]
|
||||
*
|
||||
* The approval file is written by MAIN CONTEXT, not by the lens agent —
|
||||
* `optimize.md` renders the candidates, the operator picks, and the command
|
||||
* materializes the choice. That is where the decision actually happens, and it
|
||||
* keeps this path off the unverified agent write surface
|
||||
* ([[subagent-harness-blocks-report-writes]] lists optimize as open).
|
||||
*
|
||||
* { "sessionId": "...",
|
||||
* "removals": [ { "file": "...", "line": 12, "endLine": 15, "text": "..." } ] }
|
||||
*
|
||||
* Exit codes: 0 = verdict, 3 = the CLI could not do its job (bad argv,
|
||||
* unreadable or malformed approval file).
|
||||
*
|
||||
* A gated or refused removal is NOT exit 3. "This write leaves the repo" and
|
||||
* "that block no longer looks like that" are verdicts about a write, and they
|
||||
* ride in the payload — a command cannot act on something that only ever
|
||||
* reached stderr (#62, F3's class).
|
||||
*
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
import { applySubtraction } from './lib/subtraction-write.mjs';
|
||||
|
||||
/** Flag surface. Anything else is exit 3. */
|
||||
const ARG_SPEC = {
|
||||
boolean: ['--json', '--dry-run', '--approve-scope'],
|
||||
value: ['--approved', '--repo', '--output-file'],
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
|
||||
let approvedPath = null;
|
||||
let repo = process.cwd();
|
||||
let outputFile = null;
|
||||
let jsonMode = false;
|
||||
let dryRun = false;
|
||||
let approveScope = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--json') jsonMode = true;
|
||||
else if (args[i] === '--dry-run') dryRun = true;
|
||||
else if (args[i] === '--approve-scope') approveScope = true;
|
||||
else if (args[i] === '--approved') approvedPath = args[++i];
|
||||
else if (args[i] === '--repo') repo = args[++i];
|
||||
else if (args[i] === '--output-file') outputFile = args[++i];
|
||||
}
|
||||
|
||||
if (!approvedPath) {
|
||||
process.stderr.write('Error: --approved <path> is required\n');
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
let approval;
|
||||
try {
|
||||
approval = JSON.parse(await readFile(resolve(approvedPath), 'utf-8'));
|
||||
} catch (err) {
|
||||
process.stderr.write(`Error: could not read approval file: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
// Malformed input is a tool error, not a verdict: an empty or wrong-shaped
|
||||
// approval must not read as "nothing to remove, all done".
|
||||
if (!Array.isArray(approval.removals) || approval.removals.length === 0) {
|
||||
process.stderr.write('Error: approval file has no `removals` array\n');
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
for (const r of approval.removals) {
|
||||
if (!r || typeof r.file !== 'string' || typeof r.text !== 'string') {
|
||||
process.stderr.write('Error: every removal needs `file`, `line`, `endLine` and `text`\n');
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await applySubtraction(approval.removals, { repoRoot: repo, dryRun, approveScope });
|
||||
|
||||
const payload = {
|
||||
meta: {
|
||||
repo: resolve(repo),
|
||||
sessionId: approval.sessionId || null,
|
||||
approvedCount: approval.removals.length,
|
||||
dryRun,
|
||||
},
|
||||
gate: result.gate,
|
||||
requiresApproval: result.requiresApproval,
|
||||
disclosures: result.disclosures,
|
||||
targets: result.targets,
|
||||
backupId: result.backupId,
|
||||
filesWritten: result.filesWritten,
|
||||
// The removed text travels back so the caller can show and log exactly what
|
||||
// left the file. The backup is the recovery artifact; this is the receipt.
|
||||
applied: result.applied,
|
||||
refused: result.refused,
|
||||
counts: {
|
||||
applied: result.applied.length,
|
||||
refused: result.refused.length,
|
||||
filesWritten: result.filesWritten.length,
|
||||
},
|
||||
};
|
||||
|
||||
const json = `${JSON.stringify(payload, null, 2)}\n`;
|
||||
if (outputFile) {
|
||||
await writeOutputFile(outputFile, json);
|
||||
// Nothing on stdout when writing to a file (ux-rules rule 1).
|
||||
} else if (jsonMode) {
|
||||
process.stdout.write(json);
|
||||
} else {
|
||||
for (const a of payload.applied) {
|
||||
process.stdout.write(`${payload.meta.dryRun ? 'would-remove' : 'removed'}\t${a.file}:${a.line}-${a.endLine}\n`);
|
||||
}
|
||||
for (const r of payload.refused) {
|
||||
process.stdout.write(`refused:${r.reason}\t${r.file}:${r.line}-${r.endLine}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (err) {
|
||||
process.stderr.write(`Error: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
}
|
||||
|
|
@ -14,22 +14,12 @@
|
|||
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { writeFile, readFile, stat } from 'node:fs/promises';
|
||||
import { discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { resetCounter } from './lib/output.mjs';
|
||||
import { scan } from './token-hotspots.mjs';
|
||||
import * as tokenizerApi from './lib/tokenizer-api.mjs';
|
||||
import { humanizeFindings } from './lib/humanizer.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
|
||||
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
|
||||
const ARG_SPEC = {
|
||||
boolean: [
|
||||
'--json', '--raw', '--global', '--with-telemetry-recipe',
|
||||
'--accurate-tokens', '--exclude-cache', '--no-exclude-cache',
|
||||
],
|
||||
value: ['--output-file'],
|
||||
};
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TELEMETRY_RECIPE_PATH = resolve(__dirname, '..', 'knowledge', 'cache-telemetry-recipe.md');
|
||||
|
|
@ -59,7 +49,6 @@ async function calibrateAgainstApi(hotspots, apiKey) {
|
|||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
let targetPath = '.';
|
||||
let outputFile = null;
|
||||
let jsonMode = false;
|
||||
|
|
@ -89,15 +78,14 @@ async function main() {
|
|||
const s = await stat(absPath);
|
||||
if (!s.isDirectory()) {
|
||||
process.stderr.write(`Error: ${absPath} is not a directory\n`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
process.exit(3);
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(absPath, { includeGlobal, excludeCache });
|
||||
const result = await scan(absPath, discovery);
|
||||
|
||||
|
|
@ -141,7 +129,7 @@ async function main() {
|
|||
const json = JSON.stringify(payload, null, 2);
|
||||
|
||||
if (outputFile) {
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
}
|
||||
|
||||
if (jsonMode || rawMode || !outputFile) {
|
||||
|
|
@ -153,6 +141,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -400,7 +400,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (detectVolatileTop(content)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'volatile-top',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Cache-breaking volatile content at top of CLAUDE.md',
|
||||
description:
|
||||
|
|
@ -429,7 +428,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (issues.length === 0) continue;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'redundant-permissions',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Redundant permission declarations',
|
||||
description:
|
||||
|
|
@ -454,7 +452,6 @@ export async function scan(targetPath, discovery) {
|
|||
if (depth > MAX_IMPORT_DEPTH) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'deep-import-chain',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Deep @import chain defeats prompt-cache reuse',
|
||||
description:
|
||||
|
|
@ -487,7 +484,6 @@ export async function scan(targetPath, discovery) {
|
|||
const skillName = (fm && fm.name) || f.absPath.split('/').slice(-2, -1)[0] || f.absPath;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'bloated-skill-description',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Bloated skill description (loads on every turn)',
|
||||
description:
|
||||
|
|
@ -539,7 +535,6 @@ export async function scan(targetPath, discovery) {
|
|||
'and user-scopes so per-project budget stays tight.';
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'mcp-schema-budget',
|
||||
severity,
|
||||
title: `High MCP tool-schema budget on server "${m.name}"`,
|
||||
description,
|
||||
|
|
@ -557,7 +552,6 @@ export async function scan(targetPath, discovery) {
|
|||
const fileCount = activeConfig.claudeMd.files?.length ?? 0;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'cascade-over-budget',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md cascade exceeds 10k tokens per turn',
|
||||
description:
|
||||
|
|
@ -590,7 +584,6 @@ export async function scan(targetPath, discovery) {
|
|||
const keys = stale.map(v => v.key);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'stale-plugin-cache',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Stale plugin-cache versions (disk cleanup, zero live-context impact)',
|
||||
description:
|
||||
|
|
@ -661,7 +654,6 @@ export async function scan(targetPath, discovery) {
|
|||
'(gh / aws / gcloud) over MCP for common operations.';
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'mcp-schema-deferral',
|
||||
severity,
|
||||
title: 'MCP tool schemas forced into the always-loaded prefix',
|
||||
file: null,
|
||||
|
|
|
|||
|
|
@ -13,17 +13,11 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { writeFile, stat } from 'node:fs/promises';
|
||||
import { readActiveConfig } from './lib/active-config-reader.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
|
||||
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
|
||||
const ARG_SPEC = { boolean: ['--json', '--raw', '--verbose', '--suggest-disables'], value: ['--output-file'] };
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
let targetPath = '.';
|
||||
let outputFile = null;
|
||||
let jsonMode = false;
|
||||
|
|
@ -47,20 +41,18 @@ async function main() {
|
|||
const s = await stat(absPath);
|
||||
if (!s.isDirectory()) {
|
||||
process.stderr.write(`Error: ${absPath} is not a directory\n`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
process.exit(3);
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const result = await readActiveConfig(absPath, { verbose, suggestDisables });
|
||||
const json = JSON.stringify(result, null, 2);
|
||||
|
||||
if (outputFile) {
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
}
|
||||
|
||||
if (jsonMode || rawMode || !outputFile) {
|
||||
|
|
@ -72,6 +64,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* write-scope CLI — classify the write targets a command is about to touch,
|
||||
* relative to the repo the session stands in (M-BUG-41).
|
||||
*
|
||||
* Exists so the gate has ONE implementation. Five command templates need the
|
||||
* same answer before their approval surface; five prose paraphrases of the
|
||||
* class table would be five policies drifting apart — the shape that put the
|
||||
* lever table in five copies (#61). The templates call this and render what
|
||||
* comes back.
|
||||
*
|
||||
* Usage:
|
||||
* node write-scope-cli.mjs --target <path> [--target <path> ...]
|
||||
* [--repo <session-repo-root>]
|
||||
* [--output-file <path>] [--json]
|
||||
*
|
||||
* Exit codes: 0 = classified, 3 = argument or tool error.
|
||||
*
|
||||
* A gated target is NOT an error exit. The exit-code contract reserves 3 for
|
||||
* "the scanner could not do its job"; "this write leaves the repo" is a verdict
|
||||
* about a write, and it rides in the payload — a command cannot act on
|
||||
* something that only ever reached stderr (F3's class).
|
||||
*
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
import { SCOPE_CLASSES, classifyWriteTarget, strongestGate } from './lib/write-scope.mjs';
|
||||
|
||||
/** Flag surface. Anything else is exit 3. */
|
||||
const ARG_SPEC = { boolean: ['--json'], value: ['--target', '--repo', '--output-file'] };
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
|
||||
const targets = [];
|
||||
let repo = process.cwd();
|
||||
let outputFile = null;
|
||||
let jsonMode = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--json') jsonMode = true;
|
||||
else if (args[i] === '--target') targets.push(args[++i]);
|
||||
else if (args[i] === '--repo') repo = args[++i];
|
||||
else if (args[i] === '--output-file') outputFile = args[++i];
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
process.stderr.write('Error: at least one --target is required\n');
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
const classified = targets.map((t) => classifyWriteTarget(t, repo));
|
||||
const gate = strongestGate(classified);
|
||||
|
||||
const payload = {
|
||||
meta: {
|
||||
repo: resolve(repo),
|
||||
targetCount: classified.length,
|
||||
// The class table travels with the answer so a template never has to
|
||||
// restate what a class means.
|
||||
classes: Object.fromEntries(
|
||||
Object.entries(SCOPE_CLASSES).map(([name, spec]) => [name, { gate: spec.gate }]),
|
||||
),
|
||||
},
|
||||
gate,
|
||||
requiresApproval: gate === 'require-ok',
|
||||
// Distinct disclosure lines, in class order, ready to render verbatim.
|
||||
disclosures: [...new Set(classified.map((t) => t.disclosure).filter(Boolean))],
|
||||
targets: classified,
|
||||
};
|
||||
|
||||
const json = `${JSON.stringify(payload, null, 2)}\n`;
|
||||
if (outputFile) {
|
||||
await writeOutputFile(outputFile, json);
|
||||
// Nothing on stdout when writing to a file: a command that also renders
|
||||
// this would otherwise show the user the raw payload (ux-rules rule 1).
|
||||
} else if (jsonMode) {
|
||||
process.stdout.write(json);
|
||||
} else {
|
||||
for (const t of payload.targets) {
|
||||
process.stdout.write(`${t.scopeClass}\t${t.gate}\t${t.target}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (err) {
|
||||
process.stderr.write(`Error: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
}
|
||||
|
|
@ -41,33 +41,13 @@ async function readCommand(name) {
|
|||
return await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
// plan.md invokes no scanner — it spawns the planner agent. Its only bash
|
||||
// block used to be a `RAW_FLAG=` assignment referenced from the agent prompt
|
||||
// below it; a prompt is not a shell, so the agent received the literal string
|
||||
// `$RAW_FLAG` (confirmed at runtime by the planner agent, session #49).
|
||||
// Removing that block is the fix, so this assertion skips it.
|
||||
const AGENT_DRIVEN = new Set(['plan.md']);
|
||||
|
||||
test('Action: every scanner-invoking file contains a Bash invocation block', async () => {
|
||||
test('Action: every file contains a Bash invocation block', async () => {
|
||||
for (const name of ACTION_FILES) {
|
||||
if (AGENT_DRIVEN.has(name)) continue;
|
||||
const content = await readCommand(name);
|
||||
assert.match(content, BASH_BLOCK_REGEX, `${name} missing bash block`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Action: agent-driven files spawn an Agent instead of a scanner', async () => {
|
||||
for (const name of AGENT_DRIVEN) {
|
||||
const content = await readCommand(name);
|
||||
assert.match(content, /Agent\(subagent_type:/, `${name} should spawn an Agent`);
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/RAW_FLAG=/,
|
||||
`${name} must not assign a shell variable it then references from an agent prompt`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Action: every file references the Read tool', async () => {
|
||||
for (const name of ACTION_FILES) {
|
||||
const content = await readCommand(name);
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
/**
|
||||
* M-BUG-18 — analysis-report.md persistence contract.
|
||||
*
|
||||
* The Claude Code subagent harness instructs spawned agents NOT to write
|
||||
* report/summary/findings/analysis .md files — the parent reads the agent's
|
||||
* final text message, not files it creates. The analyzer-agent therefore
|
||||
* cannot be the one that persists analysis-report.md (verified live: the
|
||||
* agent skipped Write and returned the report inline).
|
||||
*
|
||||
* New contract (orchestrator-writes pattern):
|
||||
* - analyzer-agent returns the complete report as its final message
|
||||
* - the analyze command saves that returned report verbatim to
|
||||
* ~/.claude/config-audit/sessions/{session-id}/analysis-report.md,
|
||||
* which downstream phases (plan, interview, status) read.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
|
||||
const AGENTS_DIR = resolve(__dirname, '..', '..', 'agents');
|
||||
|
||||
test('analyze.md: agent prompt does not tell the agent to write the report file', async () => {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, 'analyze.md'), 'utf-8');
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/Output to:.*analysis-report\.md/,
|
||||
'the spawn prompt must not instruct the subagent to write analysis-report.md — the harness blocks agent-written report files'
|
||||
);
|
||||
});
|
||||
|
||||
test('analyze.md: command saves the returned report to analysis-report.md', async () => {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, 'analyze.md'), 'utf-8');
|
||||
assert.match(
|
||||
content,
|
||||
/return[s]? the complete report as (its|your) final message/i,
|
||||
'analyze.md must state that the agent returns the report inline'
|
||||
);
|
||||
assert.match(
|
||||
content,
|
||||
/Write tool[\s\S]{0,200}analysis-report\.md|analysis-report\.md[\s\S]{0,200}Write tool/,
|
||||
'analyze.md must instruct the command to persist the returned report to analysis-report.md with the Write tool'
|
||||
);
|
||||
});
|
||||
|
||||
test('analyzer-agent.md: output contract is return-inline, not self-write', async () => {
|
||||
const content = await readFile(resolve(AGENTS_DIR, 'analyzer-agent.md'), 'utf-8');
|
||||
assert.match(
|
||||
content,
|
||||
/return the complete report as your final message/i,
|
||||
'analyzer-agent must be told its final message IS the report'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/^Write to: .*analysis-report\.md/m,
|
||||
'analyzer-agent must not carry the old self-write output contract'
|
||||
);
|
||||
});
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
/**
|
||||
* Session #51 — command-template flag-value portability.
|
||||
*
|
||||
* Dogfooding `knowledge-refresh` surfaced a defect that only exists at the
|
||||
* seam between the command template and the shell that runs it:
|
||||
*
|
||||
* STALE_AFTER="--stale-after 30"
|
||||
* node …-cli.mjs --reference-date "$TODAY" $STALE_AFTER --output-file …
|
||||
*
|
||||
* The unquoted `$STALE_AFTER` is meant to split into TWO argv entries. Under
|
||||
* **bash** it does. Under **zsh** — the macOS default since Catalina, and the
|
||||
* shell the Bash tool actually runs on this machine — unquoted parameter
|
||||
* expansions are NOT word-split, so the CLI receives ONE argv entry with the
|
||||
* literal text `--stale-after 30`, matches no known flag, and (because the CLI
|
||||
* silently ignored unknown flags — see cli-unknown-flag-rejection.test.mjs)
|
||||
* falls back to the 90-day default while reporting success. Measured:
|
||||
*
|
||||
* $ STALE_AFTER="--stale-after 30"; set -- $STALE_AFTER; echo $#
|
||||
* 1 # zsh (bash prints 2)
|
||||
* → payload staleAfterDays: 90, exit 0, "✓ All 14 entries fresh"
|
||||
*
|
||||
* The user-facing knob was silently dead. Note the asymmetry that makes this
|
||||
* survivable elsewhere: an EMPTY unquoted expansion yields ZERO argv entries in
|
||||
* both shells, so the `FLAG=""` idiom used by ~25 other sites is portable. Only
|
||||
* a variable that can hold a flag AND its value is affected.
|
||||
*
|
||||
* The invariant asserted here is therefore about VALUE-carrying flags, not
|
||||
* about quoting in general: a command template must never depend on the shell
|
||||
* splitting one variable into a flag plus its argument. Pass the value through
|
||||
* its own quoted variable instead.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
|
||||
|
||||
async function commandFiles() {
|
||||
const entries = await readdir(COMMANDS_DIR);
|
||||
return entries.filter((e) => e.endsWith('.md')).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assignments whose right-hand side contains a flag followed by a value —
|
||||
* i.e. the value only reaches argv if the shell word-splits. Matches both
|
||||
* `X="--flag value"` and `X="--flag $(cmd)"`.
|
||||
*/
|
||||
const MULTIWORD_FLAG_ASSIGN = /^\s*([A-Z_][A-Z0-9_]*)=(["'])(--[a-z0-9-]+)[ \t]+\S.*\2\s*$/;
|
||||
|
||||
test('no command template builds a flag AND its value into one shell variable', async () => {
|
||||
const offenders = [];
|
||||
|
||||
for (const file of await commandFiles()) {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, file), 'utf-8');
|
||||
content.split('\n').forEach((line, i) => {
|
||||
const m = line.match(MULTIWORD_FLAG_ASSIGN);
|
||||
if (m) offenders.push(`${file}:${i + 1} ${m[1]}=${m[2]}${m[3]} …${m[2]}`);
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
'A variable holding "--flag value" only reaches argv correctly if the shell\n' +
|
||||
'word-splits an unquoted expansion. zsh does not. Pass the value in its own\n' +
|
||||
'quoted variable instead:\n' +
|
||||
' N=$(… extract …); [ -n "$N" ] && node cli.mjs --flag "$N"\n' +
|
||||
'Offending assignments:\n ' + offenders.join('\n '),
|
||||
);
|
||||
});
|
||||
|
||||
test('knowledge-refresh.md passes --stale-after with a quoted value', async () => {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, 'knowledge-refresh.md'), 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
!/\$STALE_AFTER\b(?!")/.test(content.replace(/"\$STALE_AFTER"/g, '')),
|
||||
'knowledge-refresh.md still expands a flag-carrying variable unquoted; under zsh the\n' +
|
||||
'threshold silently reverts to the 90-day default while the command reports success.',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
content,
|
||||
/--stale-after "\$[A-Z_]+"/,
|
||||
'knowledge-refresh.md must pass the extracted threshold as its own quoted argument\n' +
|
||||
'(`--stale-after "$STALE_AFTER_DAYS"`), so no word-splitting is required.',
|
||||
);
|
||||
});
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
/**
|
||||
* Session #50 — command-template output-discipline tests.
|
||||
*
|
||||
* Dogfooding the four read commands (`posture`, `tokens`, `manifest`,
|
||||
* `whats-active`) surfaced three defect classes that all live in the same
|
||||
* seam: what a command template PROMISES versus what the scanner behind it
|
||||
* actually does.
|
||||
*
|
||||
* 1. M-BUG-43 — a scanner invoked with `--raw`/`--json` writes the payload
|
||||
* to stdout *even when `--output-file` is set*
|
||||
* (`token-hotspots-cli.mjs:137`, `manifest.mjs:293`,
|
||||
* `whats-active.mjs:60`, `posture.mjs:101`, `drift-cli.mjs`,
|
||||
* `plugin-health-scanner.mjs`). The command templates redirect only
|
||||
* stderr, so the payload lands in the transcript. Measured on this repo:
|
||||
* posture 255 182 B, whats-active 35 922 B, drift 28 316 B,
|
||||
* manifest 23 825 B, tokens 8 768 B. `.claude/rules/ux-rules.md` rule 1
|
||||
* says NEVER show raw JSON — and a plugin that exists to cut token cost
|
||||
* must not dump a quarter-megabyte to report one grade.
|
||||
*
|
||||
* 2. Flags documented in a command's prose that never reach any shell
|
||||
* (`tokens.md`: `--json`, `--with-telemetry-recipe`). The scanner
|
||||
* supports them; the template silently swallows them. Same "green is
|
||||
* worse than an error" shape as the arg-sink class.
|
||||
*
|
||||
* 3. Render contracts that name a field the scanner never emits
|
||||
* (`manifest.md` asked for `{load}`; the payload carries `loadPattern`),
|
||||
* so the column the command's own prose calls the whole point renders
|
||||
* blank for every row.
|
||||
*
|
||||
* Test 3 runs the real scanners against a fixture rather than asserting
|
||||
* against a hardcoded key list — a hardcoded list drifts, a live payload
|
||||
* cannot.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile, readdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..');
|
||||
const COMMANDS_DIR = join(ROOT, 'commands');
|
||||
const FIXTURE = join(ROOT, 'tests', 'fixtures', 'marketplace-medium');
|
||||
|
||||
async function commandFiles() {
|
||||
const entries = await readdir(COMMANDS_DIR);
|
||||
return entries.filter((e) => e.endsWith('.md')).sort();
|
||||
}
|
||||
|
||||
/** Strip `#` comments so the tests never match their own explanatory prose. */
|
||||
function stripComment(line) {
|
||||
const h = line.indexOf('#');
|
||||
return h === -1 ? line : line.slice(0, h);
|
||||
}
|
||||
|
||||
/** Yield [lineNumber, line] for lines inside ```bash fences only. */
|
||||
function bashLines(content) {
|
||||
const out = [];
|
||||
let open = null;
|
||||
content.split('\n').forEach((line, i) => {
|
||||
const m = line.match(/^\s*```(\w*)/);
|
||||
if (m) {
|
||||
open = open === null ? m[1] : null;
|
||||
return;
|
||||
}
|
||||
if (open === 'bash') out.push([i + 1, line]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Yield lines inside ```markdown fences — the render contracts. */
|
||||
function markdownFenceLines(content) {
|
||||
const out = [];
|
||||
let open = null;
|
||||
content.split('\n').forEach((line) => {
|
||||
const m = line.match(/^\s*```(\w*)/);
|
||||
if (m) {
|
||||
open = open === null ? m[1] : null;
|
||||
return;
|
||||
}
|
||||
if (open === 'markdown') out.push(line);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
test('Output discipline: a scanner asked for --output-file never also prints to stdout', async () => {
|
||||
// The rule is blanket and needs no per-scanner allowlist: if the command
|
||||
// asked for the payload in a FILE, the same invocation must not let it reach
|
||||
// the transcript. Invocations with no --output-file are out of scope — there
|
||||
// the payload has nowhere else to go (e.g. `drift --save`, 112 B).
|
||||
const violations = [];
|
||||
for (const name of await commandFiles()) {
|
||||
const content = await readFile(join(COMMANDS_DIR, name), 'utf-8');
|
||||
for (const [lineNo, raw] of bashLines(content)) {
|
||||
const line = stripComment(raw);
|
||||
if (!/node \$\{CLAUDE_PLUGIN_ROOT\}\/scanners\//.test(line)) continue;
|
||||
if (!line.includes('--output-file')) continue;
|
||||
const rawish = /--json\b|--raw\b|\$RAW_FLAG/.test(line);
|
||||
if (!rawish) continue;
|
||||
// Mask `2>` so a stderr redirect is never mistaken for a stdout one.
|
||||
const masked = line.replace(/2>/g, '2@');
|
||||
if (/(^|\s)>\s*\S+/.test(masked)) continue;
|
||||
violations.push(
|
||||
`${name}:${lineNo} runs a scanner in raw/json mode with --output-file but never redirects stdout — the payload lands in the transcript`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], `Unredirected scanner payloads:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
||||
test('Flag threading: every flag documented in prose reaches a shell', async () => {
|
||||
// A flag the user is told to pass must arrive somewhere. Two legitimate
|
||||
// destinations exist: a bash fence (threaded to the scanner) or a documented
|
||||
// control-flow branch handled by the model. The allowlist below carries only
|
||||
// the second kind, each verified by reading the code:
|
||||
// posture --drift / --plugin-health : step 5 runs *different* scanners
|
||||
// fix --dry-run : dry-run is fix-cli's DEFAULT and the
|
||||
// flag is an explicit no-op alias
|
||||
// (`scanners/fix-cli.mjs:18-21`)
|
||||
// knowledge-refresh --no-candidates : skips a web-poll step; never a CLI flag
|
||||
// manifest/whats-active --json : served by `cat` of the payload, whose
|
||||
// content is byte-identical to --raw
|
||||
// (both scanners document --raw as a
|
||||
// no-op; verified by diffing payloads)
|
||||
const PROSE_HANDLED = new Set([
|
||||
'posture.md:--drift',
|
||||
'posture.md:--plugin-health',
|
||||
'fix.md:--dry-run',
|
||||
'knowledge-refresh.md:--no-candidates',
|
||||
'manifest.md:--json',
|
||||
'whats-active.md:--json',
|
||||
]);
|
||||
|
||||
const violations = [];
|
||||
for (const name of await commandFiles()) {
|
||||
const content = await readFile(join(COMMANDS_DIR, name), 'utf-8');
|
||||
const bash = bashLines(content).map(([, l]) => l).join('\n');
|
||||
const documented = new Set();
|
||||
for (const line of content.split('\n')) {
|
||||
const m = line.match(/^\s*[-*]\s+`(--[a-z][a-z0-9-]*)`/);
|
||||
if (m) documented.add(m[1]);
|
||||
}
|
||||
for (const flag of documented) {
|
||||
if (bash.includes(flag)) continue;
|
||||
if (PROSE_HANDLED.has(`${name}:${flag}`)) continue;
|
||||
violations.push(`${name} documents \`${flag}\` but no bash fence ever passes it`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], `Flags dropped between docs and shell:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
||||
test('Render contract: every {field} in a render fence exists in the real payload', async () => {
|
||||
// Derived values the model computes rather than reads. Kept deliberately
|
||||
// short — every entry here is a field the render fence does NOT get from the
|
||||
// scanner, so a long list would hollow out the test.
|
||||
const DERIVED = new Set(['rank']);
|
||||
const CASES = [
|
||||
{ command: 'manifest.md', scanner: 'manifest.mjs', args: [] },
|
||||
{ command: 'tokens.md', scanner: 'token-hotspots-cli.mjs', args: [] },
|
||||
{
|
||||
command: 'whats-active.md',
|
||||
scanner: 'whats-active.mjs',
|
||||
args: ['--verbose', '--suggest-disables'],
|
||||
},
|
||||
];
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'ca-render-'));
|
||||
try {
|
||||
const violations = [];
|
||||
for (const { command, scanner, args } of CASES) {
|
||||
const outFile = join(dir, `${scanner}.json`);
|
||||
await execFileAsync('node', [
|
||||
join(ROOT, 'scanners', scanner), FIXTURE, '--output-file', outFile, ...args,
|
||||
]);
|
||||
const payload = JSON.parse(await readFile(outFile, 'utf-8'));
|
||||
|
||||
const keys = new Set();
|
||||
const walk = (node) => {
|
||||
if (Array.isArray(node)) node.slice(0, 5).forEach(walk);
|
||||
else if (node && typeof node === 'object') {
|
||||
for (const k of Object.keys(node)) {
|
||||
keys.add(k);
|
||||
walk(node[k]);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(payload);
|
||||
|
||||
const content = await readFile(join(COMMANDS_DIR, command), 'utf-8');
|
||||
const refs = new Set();
|
||||
for (const line of markdownFenceLines(content)) {
|
||||
const re = /\{([a-zA-Z][a-zA-Z0-9_.]*)\}/g;
|
||||
let m;
|
||||
while ((m = re.exec(line)) !== null) refs.add(m[1]);
|
||||
}
|
||||
for (const ref of refs) {
|
||||
if (DERIVED.has(ref)) continue;
|
||||
if (ref.endsWith('.length')) {
|
||||
// `{foo.length}` is a count of a collection — assert the collection
|
||||
// itself exists, since that is the part the scanner owns.
|
||||
const base = ref.slice(0, -'.length'.length).split('.').pop();
|
||||
if (!keys.has(base)) {
|
||||
violations.push(`${command}: {${ref}} — no \`${base}\` collection in the payload`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const leaf = ref.split('.').pop();
|
||||
if (!keys.has(leaf) && !keys.has(ref)) {
|
||||
violations.push(`${command}: {${ref}} is never emitted by ${scanner}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], `Render fields the scanner never emits:\n${violations.join('\n')}`);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
/**
|
||||
* Session #56 — placeholders inside runnable bash fences must not be shell-active.
|
||||
*
|
||||
* Dogfooding the ROUTER (`commands/config-audit.md`) surfaced a defect one layer
|
||||
* below the CLIs: the fence on the router's step 3 carries the placeholder
|
||||
* `<target-path>` **bare** — unquoted, preceded by a space. `<` and `>` are
|
||||
* redirection operators. Measured under the shell the Bash tool actually runs
|
||||
* (zsh), in a scratch directory:
|
||||
*
|
||||
* $ node …/scan-orchestrator.mjs <target-path> --output-file scan.json \
|
||||
* >/dev/null 2>/dev/null; node …/posture.mjs <target-path> \
|
||||
* --output-file posture.json 2>/dev/null; echo $?
|
||||
* zsh:1: no such file or directory: target-path
|
||||
* zsh:1: no such file or directory: target-path
|
||||
* 1
|
||||
* → neither file written, neither CLI ever started
|
||||
*
|
||||
* Three properties make this worse than a plain typo:
|
||||
*
|
||||
* 1. **The CLI never runs.** Redirection is resolved by the shell before the
|
||||
* command is executed, so the CLI's own argument validation — the layer that
|
||||
* `cli-unknown-flag-rejection.test.mjs` hardened — never sees it.
|
||||
* 2. **The failure is quiet where it counts.** The echoed status is `1`, and
|
||||
* `1` is inside the band the router's own step 3 classifies as
|
||||
* "continue normally" (0/1/2 = PASS/WARNING/FAIL; only 3 is a real error).
|
||||
* A total non-execution is indistinguishable from a healthy WARNING run.
|
||||
* 3. **The file already knew about the neighbouring hazard.** Two lines above
|
||||
* the offending call sits a comment warning that a *square-bracket*
|
||||
* placeholder "does not start with a dash, so both CLIs' arg loops would
|
||||
* take it as the TARGET PATH instead of a flag" — awareness of the
|
||||
* placeholder class, while carrying a strictly worse member of it.
|
||||
*
|
||||
* The invariant is not "substitute your placeholders" (a template cannot enforce
|
||||
* that). It is that an UNSUBSTITUTED placeholder must fail **loudly, in the
|
||||
* CLI**, not silently in the shell. Quoting achieves exactly that: `"<path>"`
|
||||
* reaches argv as a literal, the CLI reports an unreadable target, and the exit
|
||||
* code means what the router thinks it means.
|
||||
*
|
||||
* Measured breadth at the time of writing: 13 of 21 command files, 33 sites.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
|
||||
|
||||
async function commandFiles() {
|
||||
const entries = await readdir(COMMANDS_DIR);
|
||||
return entries.filter((e) => e.endsWith('.md')).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield the lines that live inside ```bash fences, with their 1-based file line
|
||||
* numbers. Only bash-tagged fences count: an untagged or json fence is prose.
|
||||
*/
|
||||
function bashFenceLines(content) {
|
||||
const out = [];
|
||||
let inFence = false;
|
||||
content.split('\n').forEach((line, i) => {
|
||||
if (/^\s*```/.test(line)) {
|
||||
inFence = /^\s*```bash\s*$/.test(line);
|
||||
return;
|
||||
}
|
||||
if (inFence) out.push({ line, n: i + 1 });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A placeholder that the shell would read as a redirection: `<` at the start of
|
||||
* a word (start of line, or after whitespace / `;` / `|` / `&`), a lowercase
|
||||
* placeholder name, then `>`. A quoted placeholder (`"<path>"`) is excluded by
|
||||
* construction — the `<` is preceded by a quote, not a word boundary.
|
||||
*/
|
||||
const BARE_PLACEHOLDER = /(?:^|[\s;|&(])(<[a-z][a-z0-9._-]*>)/;
|
||||
|
||||
test('no runnable bash fence carries a bare (shell-active) angle-bracket placeholder', async () => {
|
||||
const offenders = [];
|
||||
|
||||
for (const file of await commandFiles()) {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, file), 'utf-8');
|
||||
for (const { line, n } of bashFenceLines(content)) {
|
||||
const m = line.match(BARE_PLACEHOLDER);
|
||||
if (m) offenders.push(`${file}:${n} ${m[1]} in: ${line.trim().slice(0, 90)}`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
'A bare `<name>` inside a bash fence is a REDIRECTION, not an argument. Left\n' +
|
||||
'unsubstituted it fails in the shell before the CLI starts — no output file,\n' +
|
||||
'no CLI diagnostics, and an exit code (1) that the router reads as a normal\n' +
|
||||
'WARNING run. Quote the placeholder (`"<name>"`) so an unsubstituted template\n' +
|
||||
'fails loudly in the CLI instead.\n' +
|
||||
'Offending sites:\n ' + offenders.join('\n '),
|
||||
);
|
||||
});
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
/**
|
||||
* Session #49 — command-template shell-state shape tests.
|
||||
*
|
||||
* Dogfooding the `plan` + `implement` chunk surfaced one root defect with
|
||||
* several arms: **command templates assume shell state survives between
|
||||
* fenced blocks.** It does not. Every ```bash fence is executed as its own
|
||||
* Bash tool call, in its own process:
|
||||
*
|
||||
* - A variable assigned in block N is empty in block N+1.
|
||||
* - `$$` (the PID) differs between calls, so a `/tmp/foo-$$.json` path
|
||||
* created in one block can never be reconstructed in a later one.
|
||||
* - The Read tool expands neither shell variables nor `$$` nor globs; it
|
||||
* takes one literal path.
|
||||
*
|
||||
* Measured arms at the time of writing (all fixed by the accompanying commit):
|
||||
* - `$RAW_FLAG` referenced inside a (non-bash) agent-prompt fence in
|
||||
* analyze.md, plan.md, implement.md — the agent received the literal
|
||||
* string `$RAW_FLAG`, confirmed at runtime by the planner-agent itself.
|
||||
* - `$TMPFILE` referenced across blocks in manifest.md, tokens.md,
|
||||
* whats-active.md, plugin-health.md.
|
||||
* - `$GLOBAL_FLAG` across blocks in fix.md.
|
||||
* - `$TODAY` across blocks in campaign.md (6 sites).
|
||||
* - `$$` temp paths referenced outside their creating fence in fix.md.
|
||||
* - plan.md asked the Read tool to expand
|
||||
* `~/.claude/config-audit/sessions/*_/state.yaml`.
|
||||
*
|
||||
* The hardened pattern already present in drift.md is the target shape: a
|
||||
* fixed literal temp path, repeated literally in every block that needs it.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
|
||||
|
||||
/** Shell variables supplied by the environment, not by a prior block. */
|
||||
const AMBIENT = new Set([
|
||||
'CLAUDE_PLUGIN_ROOT', 'ARGUMENTS', 'HOME', 'PATH', 'PWD', 'USER', 'TMPDIR',
|
||||
]);
|
||||
|
||||
async function commandFiles() {
|
||||
const entries = await readdir(COMMANDS_DIR);
|
||||
return entries.filter((e) => e.endsWith('.md')).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse fenced blocks. Returns { lines, blockIndexOf(lineIdx) } where
|
||||
* blockIndexOf returns -1 for prose outside any fence.
|
||||
*/
|
||||
function parseFences(content) {
|
||||
const lines = content.split('\n');
|
||||
const blocks = [];
|
||||
let open = null;
|
||||
lines.forEach((line, i) => {
|
||||
const m = line.match(/^\s*```(\w*)/);
|
||||
if (!m) return;
|
||||
if (open === null) open = { lang: m[1], start: i };
|
||||
else {
|
||||
blocks.push({ lang: open.lang, start: open.start, end: i });
|
||||
open = null;
|
||||
}
|
||||
});
|
||||
const blockIndexOf = (i) => blocks.findIndex((b) => i > b.start && i < b.end);
|
||||
return { lines, blocks, blockIndexOf };
|
||||
}
|
||||
|
||||
/** Strip `#` comments from a bash line so the test never matches its own prose. */
|
||||
function stripComment(line) {
|
||||
const h = line.indexOf('#');
|
||||
return h === -1 ? line : line.slice(0, h);
|
||||
}
|
||||
|
||||
test('Shell state: no variable is referenced outside the block that assigned it', async () => {
|
||||
const violations = [];
|
||||
for (const name of await commandFiles()) {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
const { lines, blocks, blockIndexOf } = parseFences(content);
|
||||
|
||||
const assignedIn = new Map();
|
||||
lines.forEach((raw, i) => {
|
||||
const bi = blockIndexOf(i);
|
||||
if (bi < 0) return;
|
||||
// An assignment starts a command, and a command starts at the beginning of
|
||||
// the line OR after a separator. Capturing only the line-initial form would
|
||||
// miss the idiomatic status capture `node …; STATUS=$?` — the one form that
|
||||
// MUST trail a command — and report the variable as never assigned.
|
||||
const re = /(?:^|[;&|]|&&|\|\|)\s*([A-Z_][A-Z0-9_]*)=/g;
|
||||
let m;
|
||||
while ((m = re.exec(stripComment(raw))) !== null) {
|
||||
if (!assignedIn.has(m[1])) assignedIn.set(m[1], new Set());
|
||||
assignedIn.get(m[1]).add(bi);
|
||||
}
|
||||
});
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const line = stripComment(raw);
|
||||
const bi = blockIndexOf(i);
|
||||
const re = /\$\{?([A-Z_][A-Z0-9_]*)\}?/g;
|
||||
let m;
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
const v = m[1];
|
||||
if (AMBIENT.has(v)) continue;
|
||||
// Skip the assignment site itself (`FOO=$FOO...` right-hand side is fine).
|
||||
const eq = line.indexOf('=');
|
||||
if (/^\s*[A-Z_][A-Z0-9_]*=/.test(line) && line.indexOf(m[0]) < eq) continue;
|
||||
const where = assignedIn.get(v);
|
||||
if (!where) {
|
||||
violations.push(`${name}:${i + 1} $${v} is never assigned in any block`);
|
||||
} else if (bi < 0) {
|
||||
violations.push(
|
||||
`${name}:${i + 1} $${v} referenced in prose/agent-prompt — no shell expands it there`,
|
||||
);
|
||||
} else if (!where.has(bi)) {
|
||||
const lang = blocks[bi].lang || 'none';
|
||||
violations.push(
|
||||
`${name}:${i + 1} $${v} referenced in block ${bi} (lang=${lang}) but assigned only in block(s) ${[...where].join(', ')} — separate Bash calls, separate processes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
assert.deepEqual(violations, [], `Cross-block shell-variable references:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
||||
test('Shell state: no $$ temp path is referenced outside the block that created it', async () => {
|
||||
const violations = [];
|
||||
for (const name of await commandFiles()) {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
const { lines, blockIndexOf } = parseFences(content);
|
||||
|
||||
const firstSeen = new Map();
|
||||
const scan = (raw, i, cb) => {
|
||||
const re = /(\/tmp\/[A-Za-z0-9._-]*\$\$[A-Za-z0-9._-]*)/g;
|
||||
let m;
|
||||
while ((m = re.exec(stripComment(raw))) !== null) cb(m[1], i);
|
||||
};
|
||||
lines.forEach((raw, i) => scan(raw, i, (p) => {
|
||||
if (!firstSeen.has(p)) firstSeen.set(p, { blk: blockIndexOf(i), line: i + 1 });
|
||||
}));
|
||||
lines.forEach((raw, i) => scan(raw, i, (p) => {
|
||||
const origin = firstSeen.get(p);
|
||||
if (origin.line === i + 1) return;
|
||||
const bi = blockIndexOf(i);
|
||||
if (bi < 0) {
|
||||
violations.push(`${name}:${i + 1} ${p} referenced in prose — the Read tool cannot expand $$`);
|
||||
} else if (bi !== origin.blk) {
|
||||
violations.push(
|
||||
`${name}:${i + 1} ${p} referenced in block ${bi} but created in block ${origin.blk} — $$ is a different PID there`,
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
assert.deepEqual(violations, [], `Unresolvable $$ temp paths:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
||||
test('Shell state: no $$ appears in any temp path at all', async () => {
|
||||
// Session #50 closed a blind spot in the test above: it only flags a `$$`
|
||||
// path that is *referenced twice*, because it compares each occurrence to
|
||||
// the block that created it. A path written once and then read via prose
|
||||
// ("Read the JSON output file using the Read tool") has no second
|
||||
// occurrence — so posture.md sat green through #49 while being unreadable
|
||||
// by construction: the PID is never printed, so no later step can name the
|
||||
// file. Measured live: written by PID 21614, read attempted from PID 23772.
|
||||
//
|
||||
// The invariant is therefore blanket, not relational: a command template
|
||||
// must not put `$$` in a temp path at all. The hardened pattern from
|
||||
// drift.md — one fixed literal path, repeated literally — is the only
|
||||
// shape that survives the fence boundary.
|
||||
const violations = [];
|
||||
for (const name of await commandFiles()) {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
content.split('\n').forEach((raw, i) => {
|
||||
const re = /(\/tmp\/[A-Za-z0-9._-]*\$\$[A-Za-z0-9._-]*)/g;
|
||||
let m;
|
||||
while ((m = re.exec(stripComment(raw))) !== null) {
|
||||
violations.push(
|
||||
`${name}:${i + 1} ${m[1]} — $$ differs per Bash call; no later step can name this file`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
assert.deepEqual(violations, [], `Unresolvable $$ temp paths:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
||||
test('Read tool: never asked to expand a glob', async () => {
|
||||
const violations = [];
|
||||
for (const name of await commandFiles()) {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
content.split('\n').forEach((raw, i) => {
|
||||
// Only flag when the glob is the *object* of a Read instruction, i.e.
|
||||
// "Read tool on `<glob>`" / "Read `<glob>`". Prose that merely explains
|
||||
// that Read cannot expand a glob is the fix, not the defect — matching
|
||||
// any co-occurrence on the line would flag this repo's own warning text.
|
||||
const m = raw.match(/\bRead(?:\s+the)?(?:\s+tool)?\s+(?:tool\s+)?on\s+`([~/][^`]*)`|\bRead\s+`([~/][^`]*)`/);
|
||||
if (!m) return;
|
||||
const path = m[1] ?? m[2];
|
||||
if (!path.includes('*')) return;
|
||||
violations.push(`${name}:${i + 1} Read tool pointed at a glob \`${path}\` — use Glob`);
|
||||
});
|
||||
}
|
||||
assert.deepEqual(violations, [], `Read-tool glob misuse:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
||||
test('state.yaml: phase commands name all four fields the rule requires', async () => {
|
||||
// .claude/rules/state-management.md mandates current_phase, completed_phases,
|
||||
// next_phase, updated_at after EVERY phase. A command that writes the file
|
||||
// while naming only two fields silently drops the other two.
|
||||
const REQUIRED = ['current_phase', 'completed_phases', 'next_phase', 'updated_at'];
|
||||
const violations = [];
|
||||
for (const name of await commandFiles()) {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
if (!/Update\s+`?state\.yaml`?|Update state/i.test(content)) continue;
|
||||
const missing = REQUIRED.filter((f) => !content.includes(f));
|
||||
if (missing.length) {
|
||||
violations.push(`${name} updates state.yaml but never names: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], `Incomplete state.yaml contracts:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Session #51 — the instruction that CAUSED the $TODAY defect must not outlive its fix.
|
||||
*
|
||||
* #49 fixed campaign.md by re-deriving `TODAY=$(date +%F)` inside each of the six write
|
||||
* blocks. But step 1 still carried the original prose — "Set a shared date stamp for any
|
||||
* write: `TODAY=$(date +%F)`" — which is not in a fence, cannot set anything, and directly
|
||||
* contradicts the six comments added below it. A template that argues with itself is not a
|
||||
* contract, and the next edit is the one that believes the wrong half.
|
||||
*
|
||||
* The guard above asserts fences; this one asserts that no PROSE line instructs the reader
|
||||
* to establish shell state for later blocks.
|
||||
*/
|
||||
test('no command template instructs shell state to be set outside a fence', async () => {
|
||||
const offenders = [];
|
||||
|
||||
for (const file of await commandFiles()) {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, file), 'utf-8');
|
||||
const { lines, blockIndexOf } = parseFences(content);
|
||||
lines.forEach((line, i) => {
|
||||
if (blockIndexOf(i) !== -1) return; // inside a fence: that is where state belongs
|
||||
if (/`[A-Z_][A-Z0-9_]*=\$\(/.test(line) || /^\s*[A-Z_][A-Z0-9_]*=\$\(/.test(line)) {
|
||||
offenders.push(`${file}:${i + 1} ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
'Prose that tells the reader to set a shell variable implies it survives to a later\n' +
|
||||
'block. It does not — every fence is its own process. Delete the instruction; the\n' +
|
||||
'blocks that need the value derive it themselves:\n ' + offenders.join('\n '),
|
||||
);
|
||||
});
|
||||
|
|
@ -68,33 +68,13 @@ async function readCommand(name) {
|
|||
return await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
// Agent-driven commands invoke no scanner, so they have no bash block to
|
||||
// assert. analyze.md's only bash block used to be a `RAW_FLAG=` assignment
|
||||
// referenced from the agent prompt below it — a prompt is not a shell, so the
|
||||
// agent received the literal string `$RAW_FLAG` (session #49). Removing that
|
||||
// block is the fix; requiring one here would re-assert the defect.
|
||||
const AGENT_DRIVEN = new Set(['analyze.md']);
|
||||
|
||||
test('Group B: every scanner-invoking file contains a Bash invocation block', async () => {
|
||||
test('Group B: every file contains a Bash invocation block', async () => {
|
||||
for (const name of GROUP_B_FILES) {
|
||||
if (AGENT_DRIVEN.has(name)) continue;
|
||||
const content = await readCommand(name);
|
||||
assert.match(content, BASH_BLOCK_REGEX, `${name} missing bash block`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Group B: agent-driven files spawn an Agent instead of a scanner', async () => {
|
||||
for (const name of AGENT_DRIVEN) {
|
||||
const content = await readCommand(name);
|
||||
assert.match(content, /Agent\(subagent_type:/, `${name} should spawn an Agent`);
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/RAW_FLAG=/,
|
||||
`${name} must not assign a shell variable it then references from an agent prompt`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Group B: every file references the Read tool', async () => {
|
||||
for (const name of GROUP_B_FILES) {
|
||||
const content = await readCommand(name);
|
||||
|
|
@ -152,29 +132,3 @@ test('status.md: preserves current_phase machine field and adds humanized phase
|
|||
`status.md must include at least 3 humanized phase labels; found ${present.length}: ${present.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Økt #46 — ux-rules rule 2 for the plugin-health scanner.
|
||||
//
|
||||
// plugin-health.md passed the humanized-field assertion above while the data it
|
||||
// names was unreachable: the scanner had no --output-file, and its default-mode
|
||||
// report went to stderr, which the command discards with `2>/dev/null`. A .md
|
||||
// contract test that only greps for prose cannot catch that — these assert the
|
||||
// plumbing that makes the prose true.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('plugin-health.md invokes the scanner with --output-file (ux-rules rule 2)', async () => {
|
||||
const content = await readCommand('plugin-health.md');
|
||||
const call = content.split('\n').find(l => l.includes('plugin-health-scanner.mjs'));
|
||||
assert.ok(call, 'plugin-health.md must invoke plugin-health-scanner.mjs');
|
||||
assert.match(call, /--output-file/, 'scanner call must write to a file, not stdout/stderr');
|
||||
});
|
||||
|
||||
test('posture.md invokes the plugin-health and drift scanners with --output-file', async () => {
|
||||
const content = await readCommand('posture.md');
|
||||
for (const scanner of ['plugin-health-scanner.mjs', 'drift-cli.mjs']) {
|
||||
const call = content.split('\n').find(l => l.includes(`scanners/${scanner}`));
|
||||
assert.ok(call, `posture.md must invoke ${scanner}`);
|
||||
assert.match(call, /--output-file/, `${scanner} call in posture.md discards its output`);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
/**
|
||||
* M-BUG-20 — shared implementation-log clobbering under parallel agents.
|
||||
*
|
||||
* implement.md step 4 spawns implementer agents in parallel batches, and every
|
||||
* agent appends its result to the SAME implementation-log.md. Dogfooding
|
||||
* (2026-07-17, throwaway linkedin-posts copy) showed agents satisfying
|
||||
* "Append result to:" with a full-file Write: each agent read the log, added
|
||||
* its entry, and wrote the whole file back — the last writer silently
|
||||
* clobbered 4 of 6 entries.
|
||||
*
|
||||
* Contract: both the command template and the agent prompt must pin the append
|
||||
* mechanism — Bash `>>`, never the Write/Edit tool — on the shared log.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..');
|
||||
|
||||
const APPEND_MECHANISM_REGEX = />>/;
|
||||
const FORBID_WRITE_TOOL_REGEX = /never[^.\n]*\bwrite\b[^.\n]*tool|\bwrite\b[^.\n]*tool[^.\n]*never/i;
|
||||
|
||||
test('implement.md: agent-spawn template pins Bash >> append on the shared log', async () => {
|
||||
const content = await readFile(resolve(ROOT, 'commands', 'implement.md'), 'utf-8');
|
||||
assert.match(content, APPEND_MECHANISM_REGEX,
|
||||
'implement.md must instruct appending to implementation-log.md with Bash >>');
|
||||
assert.match(content, FORBID_WRITE_TOOL_REGEX,
|
||||
'implement.md must forbid the Write tool on the shared implementation log');
|
||||
});
|
||||
|
||||
test('implementer-agent.md: output section pins Bash >> append and forbids Write tool on the log', async () => {
|
||||
const content = await readFile(resolve(ROOT, 'agents', 'implementer-agent.md'), 'utf-8');
|
||||
assert.match(content, APPEND_MECHANISM_REGEX,
|
||||
'implementer-agent.md must instruct appending to the log with Bash >>');
|
||||
assert.match(content, FORBID_WRITE_TOOL_REGEX,
|
||||
'implementer-agent.md must forbid the Write tool on the shared implementation log');
|
||||
});
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
/**
|
||||
* DEL B chunk `interview` (session #47) — command-contract shape tests.
|
||||
*
|
||||
* Every assertion here encodes a defect that was MEASURED against the shipped
|
||||
* command templates before the fix, per the fasit in
|
||||
* `docs/interview-fasit.local.md`:
|
||||
*
|
||||
* - interview.md / analyze.md referenced `{session-id}` with no rule for
|
||||
* resolving WHICH session — every other session-aware command has one.
|
||||
* - interview.md could push a finished session backwards to `interview`
|
||||
* with no guard (state-management.md mandates the write, nothing bounded it).
|
||||
* - status.md advertised `/config-audit resume {session-id}`; no resume
|
||||
* command exists. ux-rules: "Never reference commands that don't exist."
|
||||
* - status.md documented `/config-audit status all` but its flag-parse step
|
||||
* knew only `--raw`.
|
||||
* - cleanup.md ran `rm -rf .../sessions/{session-id}/` with no guard against
|
||||
* an empty/malformed id — an empty expansion deletes every session.
|
||||
* - discover.md carried literal `[--full-machine] [--global]` inside an
|
||||
* executable bash block; `[` is not `-`, so scan-orchestrator's arg loop
|
||||
* turns it into the SCAN TARGET (M-BUG-21 class).
|
||||
* - feature-gap.md called `fix-cli.mjs --json` under the heading
|
||||
* "Create backup". fix-cli is dry-run by default: no backup was created,
|
||||
* `backupId` came back null, and the command then edited config believing
|
||||
* it could roll back (M-BUG-31 class).
|
||||
* - fix-cli.mjs told the user to recover with `node scanners/rollback-cli.mjs`
|
||||
* — a file that does not exist.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..');
|
||||
const COMMANDS_DIR = resolve(ROOT, 'commands');
|
||||
|
||||
const read = (name) => readFileSync(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
|
||||
/** Bash fenced blocks only — prose may legitimately use bracket notation. */
|
||||
function bashBlocks(text) {
|
||||
return [...text.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
test('interview.md and analyze.md resolve WHICH session before using {session-id}', () => {
|
||||
for (const file of ['interview.md', 'analyze.md']) {
|
||||
const text = read(file);
|
||||
assert.match(
|
||||
text,
|
||||
/sessions\/\*\/state\.yaml/,
|
||||
`${file} references {session-id} but never says how to find the session`
|
||||
);
|
||||
assert.match(
|
||||
text,
|
||||
/most recent|most recently modified/i,
|
||||
`${file} must state which session wins when several exist`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('interview.md guards against pushing a finished session backwards', () => {
|
||||
const text = read('interview.md');
|
||||
assert.match(
|
||||
text,
|
||||
/completed_phases/,
|
||||
'interview.md must reason about completed_phases before rewriting current_phase'
|
||||
);
|
||||
assert.match(
|
||||
text,
|
||||
/do not (re-?add|add)|already contains|leave .*completed_phases/i,
|
||||
'interview.md must forbid re-adding a phase already in completed_phases'
|
||||
);
|
||||
});
|
||||
|
||||
test('no command references a /config-audit subcommand that does not exist', () => {
|
||||
// Scope words are arguments to the bare router command, not subcommands.
|
||||
const SCOPE_WORDS = new Set(['current', 'repo', 'home', 'full']);
|
||||
const dead = [];
|
||||
for (const file of readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md'))) {
|
||||
const text = readFileSync(resolve(COMMANDS_DIR, file), 'utf-8');
|
||||
for (const m of text.matchAll(/\/config-audit ([a-z][a-z-]+)/g)) {
|
||||
const word = m[1];
|
||||
if (SCOPE_WORDS.has(word)) continue;
|
||||
if (!existsSync(resolve(COMMANDS_DIR, `${word}.md`))) dead.push(`${file}: /config-audit ${word}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(dead, [], `dead command references: ${dead.join(', ')}`);
|
||||
});
|
||||
|
||||
test('status.md documents how the `all` argument is parsed', () => {
|
||||
const text = read('status.md');
|
||||
assert.match(
|
||||
text,
|
||||
/ALL_FLAG|contains? `all`|\ball\b[^\n]*\$ARGUMENTS|\$ARGUMENTS[^\n]*\ball\b/i,
|
||||
'status.md advertises `/config-audit status all` but never parses `all`'
|
||||
);
|
||||
});
|
||||
|
||||
test('cleanup.md guards its rm -rf against an empty or malformed session id', () => {
|
||||
const text = read('cleanup.md');
|
||||
// The destructive line lives in prose, not a bash block, so scan the whole
|
||||
// file. An earlier version of this test matched the example session ids in
|
||||
// the display table and went green without any guard existing.
|
||||
assert.ok(/rm -rf/.test(text), 'cleanup.md is expected to document a deletion step');
|
||||
assert.match(
|
||||
text,
|
||||
/\^\[0-9\]\{8\}_\[0-9\]\{6\}\$|\^\\d\{8\}_\\d\{6\}\$/,
|
||||
'cleanup.md must state the exact session-id pattern it validates against before deleting'
|
||||
);
|
||||
assert.match(
|
||||
text,
|
||||
/(refuse|abort|skip)[^\n]*(delet|remov)|never[^\n]*empty[^\n]*(id|path)/i,
|
||||
'cleanup.md must say what happens when the id fails validation'
|
||||
);
|
||||
});
|
||||
|
||||
test('no executable bash block passes a bracketed placeholder flag', () => {
|
||||
// `[--full-machine]` does not start with `-`, so the scanner arg loops take
|
||||
// it as the scan target and silently scan a path that does not exist.
|
||||
const offenders = [];
|
||||
for (const file of readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md'))) {
|
||||
for (const block of bashBlocks(readFileSync(resolve(COMMANDS_DIR, file), 'utf-8'))) {
|
||||
if (/\[--[a-z-]+\]/.test(block)) offenders.push(file);
|
||||
}
|
||||
}
|
||||
assert.deepEqual([...new Set(offenders)], [], `bracketed flags inside bash blocks: ${offenders.join(', ')}`);
|
||||
});
|
||||
|
||||
test('feature-gap.md does not present a dry-run fix-cli call as a backup', () => {
|
||||
const text = read('feature-gap.md');
|
||||
for (const block of bashBlocks(text)) {
|
||||
if (!/fix-cli\.mjs/.test(block)) continue;
|
||||
assert.match(
|
||||
block,
|
||||
/--apply/,
|
||||
'feature-gap.md calls fix-cli without --apply; dry-run creates no backup'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('fix-cli.mjs recovery hint points at a command that exists', () => {
|
||||
const text = readFileSync(resolve(ROOT, 'scanners', 'fix-cli.mjs'), 'utf-8');
|
||||
const hints = [...text.matchAll(/scanners\/([a-z-]+\.mjs)/g)].map((m) => m[1]);
|
||||
for (const hint of hints) {
|
||||
assert.ok(
|
||||
existsSync(resolve(ROOT, 'scanners', hint)),
|
||||
`fix-cli.mjs tells the user to run scanners/${hint}, which does not exist`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
/**
|
||||
* Session #51 — knowledge-refresh must write the register it actually read.
|
||||
*
|
||||
* The command reads the register through `knowledge-refresh-cli.mjs`, whose `REGISTER_PATH`
|
||||
* is anchored to the CLI module itself — i.e. `${CLAUDE_PLUGIN_ROOT}/knowledge/
|
||||
* best-practices.json`. For any installed plugin that is the marketplace cache; measured
|
||||
* live during this chunk:
|
||||
*
|
||||
* registerPath: …/.claude/plugins/cache/ktg-plugin-marketplace/config-audit/5.13.0/
|
||||
* knowledge/best-practices.json
|
||||
*
|
||||
* Step 6 then said: Edit `knowledge/best-practices.json` — an UNANCHORED relative path,
|
||||
* which resolves against whatever repo the user happens to be sitting in. Three consequences,
|
||||
* none of them visible at the time:
|
||||
*
|
||||
* 1. For a normal user, that path does not exist in their repo at all, so the approved
|
||||
* change either fails or drops a stray file into their project.
|
||||
* 2. In the plugin's own checkout it resolves to the working tree, so the command reads
|
||||
* one file and writes a different one — the refresh appears to do nothing, because the
|
||||
* CLI keeps reporting the cached copy's dates.
|
||||
* 3. Step 6.3's validation gate runs the plugin's own test file, which loads the register
|
||||
* via the same anchored `REGISTER_PATH`. So the gate validates the copy that was NOT
|
||||
* edited and passes no matter what was written — a guard that cannot see the file it
|
||||
* guards.
|
||||
*
|
||||
* The two register copies were byte-identical on the day this was found, which is exactly
|
||||
* why the defect was invisible to a casual dogfood run.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const FILE = resolve(__dirname, '..', '..', 'commands', 'knowledge-refresh.md');
|
||||
|
||||
/**
|
||||
* Scoped to lines that tell the model to MODIFY the register. Descriptive prose ("this
|
||||
* command keeps knowledge/best-practices.json current") and the closing `git commit`
|
||||
* suggestion name the file without acting on it, and a git path is correctly repo-relative.
|
||||
* The defect was specifically an unanchored path in an imperative write step.
|
||||
*/
|
||||
const WRITE_VERB = /\b(edit|write|append|overwrite|save)\b/i;
|
||||
|
||||
function unanchoredWriteTargets(content) {
|
||||
const out = [];
|
||||
content.split('\n').forEach((line, i) => {
|
||||
if (!WRITE_VERB.test(line)) return;
|
||||
for (const m of line.matchAll(/(\S*)knowledge\/best-practices\.json/g)) {
|
||||
if (!m[1].endsWith('${CLAUDE_PLUGIN_ROOT}/')) out.push(`${i + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
test('the guard still catches the original defect', () => {
|
||||
const original = '1. Edit `knowledge/best-practices.json` — bump `source.verified`, update the `claim`/';
|
||||
assert.equal(
|
||||
unanchoredWriteTargets(original).length,
|
||||
1,
|
||||
'A narrowed guard must still fail on the text it was written for.',
|
||||
);
|
||||
});
|
||||
|
||||
test('every register path in knowledge-refresh.md is anchored to the plugin root', async () => {
|
||||
const content = await readFile(FILE, 'utf-8');
|
||||
const unanchored = unanchoredWriteTargets(content);
|
||||
|
||||
assert.deepEqual(
|
||||
unanchored,
|
||||
[],
|
||||
'A bare `knowledge/best-practices.json` resolves against the user\'s current repo, not\n' +
|
||||
'against the register the CLI actually read. Anchor every reference to\n' +
|
||||
'${CLAUDE_PLUGIN_ROOT}/ so the file that is read, written, and validated is one file:\n ' +
|
||||
unanchored.join('\n '),
|
||||
);
|
||||
});
|
||||
|
||||
test('the write step says where the register really lives', async () => {
|
||||
const content = await readFile(FILE, 'utf-8');
|
||||
assert.match(
|
||||
content,
|
||||
/marketplace|plugin cache|replaced on upgrade|plugin's own checkout/i,
|
||||
'Step 6 must state that the register is part of the installed plugin, so an approved\n' +
|
||||
'edit to a marketplace-installed copy is discarded by the next plugin upgrade and the\n' +
|
||||
'durable change belongs in the plugin\'s own checkout. Silently editing a cache\n' +
|
||||
'directory is the kind of write that looks successful and evaporates.',
|
||||
);
|
||||
});
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
/**
|
||||
* Session #56 — shape invariants for the ROUTER (`commands/config-audit.md`).
|
||||
*
|
||||
* `/config-audit` with no arguments is the plugin's front door: it auto-detects
|
||||
* scope, runs `scan-orchestrator` + `posture`, and renders the result. Dogfooding
|
||||
* it surfaced four defects that a scanner test could not see, because each lives
|
||||
* at the seam between the command template and something it does not own — the
|
||||
* shell, the scanner registry, the area registry, or the stderr stream.
|
||||
*
|
||||
* 1. **The orchestrator's exit code is discarded.** Step 3 runs both CLIs on one
|
||||
* line and appends a single `echo $?`, which reports only the LAST command.
|
||||
* Measured:
|
||||
*
|
||||
* $ node -e "process.exitCode=3" >/dev/null 2>/dev/null; \
|
||||
* node -e "process.exitCode=0" 2>/dev/null; echo $?
|
||||
* 0
|
||||
*
|
||||
* An orchestrator hard-error (exit 3) is therefore invisible to the very gate
|
||||
* step 3 defines for it ("3 → tell user … and stop").
|
||||
*
|
||||
* 2. **The narrated scanner count is stale.** Step 3 tells the user "Running 12
|
||||
* configuration scanners"; the orchestrator registers and runs 16. Asserted
|
||||
* against the registry rather than a literal, so the next scanner addition
|
||||
* cannot re-stale it silently.
|
||||
*
|
||||
* 3. **The Area Breakdown table drops areas.** Posture emits 10 areas (9 quality
|
||||
* areas plus Feature Coverage, which the template excludes by design). The
|
||||
* table hardcodes 7 rows, so `Token Efficiency` and `Plugin Hygiene` — both
|
||||
* real, both graded, one of them a B on this very repo — never reach the
|
||||
* user. Asserted against the area registry in `lib/scoring.mjs`.
|
||||
*
|
||||
* 4. **The template consumes a stream the same file mandates be thrown away.**
|
||||
* Step 6 instructs: "Use the headline line from the humanized stderr
|
||||
* scorecard … Avoid hardcoding a separate per-grade prose ladder." That
|
||||
* headline (`Health: A (93/100) — Healthy setup, only minor polish needed`)
|
||||
* exists ONLY on posture's stderr — measured absent from the JSON payload —
|
||||
* and step 3's fence sends posture's stderr to /dev/null, as UX rule 2
|
||||
* requires. So the router is told to render something it cannot obtain and
|
||||
* forbidden from deriving a replacement; the slot can only be improvised.
|
||||
* `commands/posture.md` already shows the fix in-repo: redirect stderr to a
|
||||
* FILE (`2>/tmp/…-stderr.txt`), which satisfies "the user never sees it"
|
||||
* while keeping the text readable.
|
||||
*
|
||||
* This is the mirror image of [[stderr-only-warnings-invisible-to-commands]]:
|
||||
* there a WARNING was lost to /dev/null, here a REQUIRED INPUT is.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..');
|
||||
const ROUTER = resolve(ROOT, 'commands', 'config-audit.md');
|
||||
|
||||
const router = await readFile(ROUTER, 'utf-8');
|
||||
|
||||
/** Scanner ids the orchestrator actually registers, read from its registry. */
|
||||
async function registeredScanners() {
|
||||
const src = await readFile(resolve(ROOT, 'scanners', 'scan-orchestrator.mjs'), 'utf-8');
|
||||
const block = src.slice(src.indexOf('const SCANNERS = ['));
|
||||
const arr = block.slice(0, block.indexOf('\n];'));
|
||||
return [...arr.matchAll(/\{\s*name:\s*'([A-Z]+)'/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
/** Distinct posture area names, read from the scanner→area registry. */
|
||||
async function registeredAreas() {
|
||||
const src = await readFile(resolve(ROOT, 'scanners', 'lib', 'scoring.mjs'), 'utf-8');
|
||||
const block = src.slice(src.indexOf('const SCANNER_AREA_MAP = {'));
|
||||
const arr = block.slice(0, block.indexOf('\n};'));
|
||||
return [...new Set([...arr.matchAll(/:\s*'([^']+)'/g)].map((m) => m[1]))];
|
||||
}
|
||||
|
||||
test('the router surfaces the exit code of BOTH scanner invocations', async () => {
|
||||
const fence = router.slice(router.indexOf('### Step 3'), router.indexOf('### Step 4'));
|
||||
const chained = fence
|
||||
.split('\n')
|
||||
.filter((l) => (l.match(/\bnode \$\{CLAUDE_PLUGIN_ROOT\}/g) || []).length > 1);
|
||||
|
||||
assert.deepEqual(
|
||||
chained,
|
||||
[],
|
||||
'Step 3 runs scan-orchestrator and posture on ONE line with a single trailing\n' +
|
||||
'`echo $?`, which reports only the LAST command. An orchestrator exit 3 echoes\n' +
|
||||
'as posture\'s 0 and the "3 → stop" gate never fires. Capture each status into\n' +
|
||||
'its own variable and echo both.\n' +
|
||||
'Offending line(s):\n ' + chained.map((l) => l.trim().slice(0, 120)).join('\n '),
|
||||
);
|
||||
|
||||
assert.match(
|
||||
fence,
|
||||
/echo "\$[A-Z_]+ \$[A-Z_]+"/,
|
||||
'Step 3 must echo both captured exit codes (e.g. `echo "$ORCH_STATUS $POSTURE_STATUS"`)\n' +
|
||||
'so the gate can act on either scanner failing.',
|
||||
);
|
||||
});
|
||||
|
||||
test('the scanner count the router narrates matches the orchestrator registry', async () => {
|
||||
const scanners = await registeredScanners();
|
||||
const narrated = [...router.matchAll(/Running (\d+) configuration scanners/g)].map((m) =>
|
||||
Number(m[1]),
|
||||
);
|
||||
|
||||
assert.ok(narrated.length > 0, 'The router should still narrate how many scanners are running.');
|
||||
assert.deepEqual(
|
||||
narrated,
|
||||
narrated.map(() => scanners.length),
|
||||
`The router tells the user it is running ${narrated.join('/')} scanners; the ` +
|
||||
`orchestrator registers ${scanners.length} (${scanners.join(', ')}). A user-facing ` +
|
||||
'count that no test binds to the registry goes stale on the next scanner added.',
|
||||
);
|
||||
});
|
||||
|
||||
test('the Area Breakdown table has a row for every quality area posture emits', async () => {
|
||||
const areas = (await registeredAreas()).filter((a) => a !== 'Feature Coverage');
|
||||
const table = router.slice(router.indexOf('### Area Breakdown'), router.indexOf('{For the status column'));
|
||||
|
||||
const missing = areas.filter((a) => !table.includes(`| ${a} |`));
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
'Posture grades these areas but the router\'s table has no row for them, so they\n' +
|
||||
'are silently dropped from the user\'s results. (Feature Coverage is excluded by\n' +
|
||||
'design — it is reported as opportunities, not as a quality grade.)\n' +
|
||||
`Areas emitted: ${areas.join(', ')}\n` +
|
||||
`Missing rows: ${missing.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the router does not consume a stderr scorecard it discards', async () => {
|
||||
const fence = router.slice(router.indexOf('### Step 3'), router.indexOf('### Step 4'));
|
||||
const dependsOnStderr = /stderr scorecard/i.test(router);
|
||||
const postureDiscardsStderr = /posture\.mjs[^\n]*2>\/dev\/null/.test(fence);
|
||||
|
||||
assert.ok(
|
||||
!(dependsOnStderr && postureDiscardsStderr),
|
||||
'Step 6 renders "the headline line from the humanized stderr scorecard" and forbids\n' +
|
||||
'deriving a grade-prose ladder instead — but step 3 sends posture\'s stderr to\n' +
|
||||
'/dev/null, and the prose is absent from the JSON payload (measured). The slot can\n' +
|
||||
'only be improvised. Capture posture stderr to a FILE and read the headline from\n' +
|
||||
'it, as commands/posture.md already does.',
|
||||
);
|
||||
});
|
||||
|
||||
test('the --raw detection is not substring-based', async () => {
|
||||
const substringMatch = /grep -q -- "--raw"/.test(router);
|
||||
|
||||
assert.ok(
|
||||
!substringMatch,
|
||||
'Measured: `echo "$ARGUMENTS" | grep -q -- "--raw"` turns raw mode ON for `--rawdog`\n' +
|
||||
'and for any path containing `--raw`. Anchor the match to whole arguments.',
|
||||
);
|
||||
assert.match(
|
||||
router,
|
||||
/grep -qE -- '\(\^\| \)--raw\( \|\$\)'/,
|
||||
'The --raw check must match a whole argument, not a substring.',
|
||||
);
|
||||
});
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
/**
|
||||
* SUB-WRITE caller arm — `optimize --subtract --apply` (#63).
|
||||
*
|
||||
* #45/#46/#47 all taught the same lesson: fixing a CLI does not fix the command
|
||||
* that reads its payload. A gate the engine enforces is worth nothing to the
|
||||
* user if the template never renders the disclosure, and a refusal the payload
|
||||
* reports is invisible if the template only ever prints successes.
|
||||
*
|
||||
* This arm is deliberately NOT folded into `write-scope-gate-shape.test.mjs`.
|
||||
* Those five commands classify their targets with `write-scope-cli.mjs` and
|
||||
* then honour the answer in prose; this one hands its targets to a CLI that
|
||||
* refuses the write itself. Requiring it to ALSO call `write-scope-cli.mjs`
|
||||
* would classify the same paths twice, which is the copy the class table exists
|
||||
* to prevent.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
|
||||
|
||||
const optimizeMd = async () => await readFile(resolve(COMMANDS_DIR, 'optimize.md'), 'utf-8');
|
||||
|
||||
/**
|
||||
* Completeness derived from the catalog rather than from a literal: it is the
|
||||
* NEXT command to drive the removal CLI that is at risk, not this one (#57/#62
|
||||
* — a hand-maintained sweep list is a premise, not a measurement).
|
||||
*/
|
||||
async function commandsDrivingTheRemovalCli() {
|
||||
const out = [];
|
||||
for (const name of await readdir(COMMANDS_DIR)) {
|
||||
if (!name.endsWith('.md')) continue;
|
||||
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
if (content.includes('subtraction-write-cli.mjs')) out.push({ name, content });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('the removal CLI has at least one caller — otherwise this whole arm is vacuous', async () => {
|
||||
const callers = await commandsDrivingTheRemovalCli();
|
||||
assert.ok(
|
||||
callers.length >= 1,
|
||||
'No command drives subtraction-write-cli.mjs. Every assertion below would pass over an\n' +
|
||||
'empty list, which is how a caller-arm guard goes green on a feature nobody can reach.',
|
||||
);
|
||||
});
|
||||
|
||||
test('every caller anchors the CLI and keeps its payload off the screen', async () => {
|
||||
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
|
||||
assert.match(
|
||||
content,
|
||||
/\$\{CLAUDE_PLUGIN_ROOT\}\/scanners\/subtraction-write-cli\.mjs/,
|
||||
`${name} must anchor the CLI at \${CLAUDE_PLUGIN_ROOT} — a relative path resolves against\n` +
|
||||
"the user's working directory, and this one deletes configuration.",
|
||||
);
|
||||
assert.match(
|
||||
content,
|
||||
/subtraction-write-cli\.mjs[^\n]*--output-file[^\n]*2>\/dev\/null/,
|
||||
`${name} must invoke it as \`--output-file <path> 2>/dev/null\` (ux-rules rule 2).`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every caller dry-runs before it writes', async () => {
|
||||
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
|
||||
assert.match(
|
||||
content,
|
||||
/--dry-run/,
|
||||
`${name} writes without proving the spans still match first. A stale approval is the\n` +
|
||||
'expected case here — the file may have been edited since the scan.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no caller classifies the write against the path it scanned', async () => {
|
||||
// Measured (#63): `--repo "<target-path>"` under `--global` hands the CLI
|
||||
// `~/.claude` as the session root, so `~/.claude/CLAUDE.md` classifies
|
||||
// `in-repo` and the gate drops to `silent` — 29 removals applied with no
|
||||
// approval asked. `--repo` is what a target is classified AGAINST; it is the
|
||||
// session's own root. Every other gated template already passes `$PWD`.
|
||||
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
|
||||
for (const line of content.split('\n')) {
|
||||
// Invocations only. Prose that merely names the CLI (the Notes section
|
||||
// explaining why removal is not a `fix` action) carries no argv, and
|
||||
// matching it made this guard red against its own fixed template.
|
||||
if (!/^node\s.*subtraction-write-cli\.mjs/.test(line.trim())) continue;
|
||||
const repoArg = line.match(/--repo\s+("[^"]*"|\S+)/);
|
||||
assert.ok(repoArg, `${name} must pass --repo explicitly on every removal-CLI invocation.`);
|
||||
assert.equal(
|
||||
repoArg[1],
|
||||
'"$PWD"',
|
||||
`${name} passes ${repoArg[1]} as --repo. Anything but the session root can classify a\n` +
|
||||
'machine-wide target as in-repo and silently downgrade the strongest gate on this axis.',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('every caller surfaces the scope gate in the user\'s words', async () => {
|
||||
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
|
||||
assert.match(
|
||||
content,
|
||||
/requiresApproval/,
|
||||
`${name} must branch on \`requiresApproval\`. The engine refuses the write, but a template\n` +
|
||||
'that never asks leaves the user staring at a run that did nothing.',
|
||||
);
|
||||
assert.match(
|
||||
content,
|
||||
/disclosures/,
|
||||
`${name} must render the payload's \`disclosures[]\` verbatim. Wording paraphrased per\n` +
|
||||
'command is a policy copy that drifts.',
|
||||
);
|
||||
// Whitespace-tolerant: markdown wraps, and a bare space would let line
|
||||
// length decide green/red (#62, [[guard-can-be-green-on-its-own-defect]]).
|
||||
assert.match(
|
||||
content,
|
||||
/every\s+project/,
|
||||
`${name} must say, in words, that a machine-wide removal costs and saves in every project.\n` +
|
||||
'The class name alone is vocabulary the user has not been taught.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every caller reports refusals with their reason, not just successes', async () => {
|
||||
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
|
||||
assert.match(
|
||||
content,
|
||||
/refused/,
|
||||
`${name} must report the payload's \`refused\` entries. A removal silently dropped reads\n` +
|
||||
'as a removal that happened.',
|
||||
);
|
||||
for (const reason of ['block-mismatch', 'floor']) {
|
||||
assert.ok(
|
||||
content.includes(reason),
|
||||
`${name} must explain \`${reason}\` — the two reasons a user can actually act on. One\n` +
|
||||
'means re-run the scan, the other means the block is load-bearing and never goes.',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('every caller tells the user how to undo the removal', async () => {
|
||||
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
|
||||
assert.match(
|
||||
content,
|
||||
/backupId/,
|
||||
`${name} must surface the backup id from the payload.`,
|
||||
);
|
||||
assert.match(
|
||||
content,
|
||||
/config-audit\s+rollback/,
|
||||
`${name} must name the command that restores the file. A backup nobody is told about is\n` +
|
||||
'not a safety net.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the subtraction copy does not oversell the saving', async () => {
|
||||
// Measured in the #40 fasit: ~1 400 deletable tokens, ~850 after tier-2
|
||||
// earn-backs, against a ~4 300-token file — a fifth, not most of it. Copy
|
||||
// that implies more is a defect of this feature, not a rounding difference.
|
||||
const content = await optimizeMd();
|
||||
// No trailing `\b` after the percent alternative: `%` is a non-word
|
||||
// character, so `\b` there demands a word character NEXT — and "80% of the
|
||||
// file" has a space. Measured green against exactly that mutation before the
|
||||
// anchor was dropped; the same ASCII-only `\b` trap as `/\bunngå\b/`.
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/most\s+of\s+(?:the|your)\s+(?:file|config)|majority\s+of\s+(?:the|your)\s+file|\b(?:[5-9]\d|100)\s*%/i,
|
||||
'optimize.md implies the subtraction axis removes most of a CLAUDE.md. The measured figure\n' +
|
||||
'is around a fifth, and the honest number is the whole point of a deletion feature.',
|
||||
);
|
||||
});
|
||||
|
||||
test('optimize.md still says what runs without --apply', async () => {
|
||||
const content = await optimizeMd();
|
||||
assert.match(
|
||||
content,
|
||||
/Without\s+`--apply`,\s+no\s+files\s+are\s+modified/,
|
||||
'The default must stay stated: `--subtract` alone proposes. A reader who skims the flag\n' +
|
||||
'list needs to know which half of the axis writes.',
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue