From 643ec2272c2614be8098b91a77b185c6fcefc195 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:32:33 -0400 Subject: [PATCH 1/4] fix(claude-config,claude-memory): gate audit-pass on containment not a flag, refuse non-git targets, and let claude-memory see user-global surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audit-pass said its report goes under ${CLAUDE_PLUGIN_DATA}, "which resolves outside any target repository". That is false by construction: the directory resolves to ~/.claude/plugins/data/{id}/ and no documented setting relocates it, so it is inside any target at or above ~. True for a repo under C:/Projects/, stated as a universal, and a dotfiles repo falsifies it. Worse, the machinery that exists for exactly that situation was gated on the --report-to flag rather than on the condition the flag happens to create. Name- based refusal, the pre-write exclusion entry, the operator disclosure and the digest exclusion were all written under that bullet, so a run against a target at or above ~ wrote into its own scan set with no exclusion and no disclosure, then reported indeterminate about itself every time. The governing condition is now report_path ⊆ target_root, evaluated on every run, with --report-to demoted to one instance. Class 4 is stated as that predicate rather than a two-item list so a later member inherits the rule instead of needing someone to extend a list. Assertions 2.1-2.4 are re-scoped on containment — 2.1 was falsified by the same class as 2.4, not just 2.4 — and 2.6 adds the default-path twin of the redirect disclosure. Separately, SKILL.md called target "the git repository to audit" while the gate required only "resolves to the active project root", so a non-git directory passed into a contract with no branch for it: no no-git state-key fallback, a scan baseline defined as HEAD plus digest, a Class 3 exclusion over git worktree list with no fallback, an assertion stated over git status --porcelain, and — the one that is a capability loss rather than a missing derivation — suppression enacted only by the tracked layer, so no acceptance would ever persist. It is refused, using the existing non-zero channel rather than a new exit code, and the refusal states that cost. The operator settled this fork toward refuse; closing the class is deliberate. These are independent: refusing non-git targets does not retire the containment work, because the class that falsifies the containment claim is a git repository rooted at or above $HOME. evals 1 and 20 graded the false premise as a pass condition, so a corrected run would have failed the suite. Both rewritten onto the predicate; eval 30 grades the refusal. All 29 shipped cases were read; no others carry the premise. claude-memory's audit could not see the surfaces claude-config hands it. Step 1 discovery was two finds rooted at the cwd, while audit-instructions' surface partition delegates ~/.claude/rules/ here by name — so ~/.claude/CLAUDE.md, which loads in every session of every project, was audited by nothing. Discovery moves into a script so it can do the half inline find cannot: tag every file with its scope. Without that, widening would fire project-scoped C9 on ~/.claude/CLAUDE.md and FAIL it for not stating a repo's build commands — under-coverage traded for a false positive. Step 2 routes on the tag, R-checks apply at both scopes, and Step 3 gains a cross-scope conflict pass since both layers load together. Recorded because the filed report argued from the wrong line: claude-memory's criteria.md:224 excludes personal files from C9 as not repo-scoped, which cuts against the argument. The load-bearing seam is audit-instructions' criteria.md:96. Closes #2228 Closes #2229 Closes #2230 Closes #2231 Closes #2232 Co-Authored-By: Claude Opus 5 (1M context) --- .../claude-config/.claude-plugin/plugin.json | 2 +- plugins/claude-config/CHANGELOG.md | 62 +++++++ plugins/claude-config/README.md | 11 +- .../claude-config/skills/audit-pass/SKILL.md | 45 ++++- .../skills/audit-pass/evals/evals.json | 29 +++- .../audit-pass/reference/determinism-tiers.md | 16 +- .../audit-pass/reference/exclusion-set.md | 24 ++- .../reference/report-location-and-schema.md | 46 ++++-- .../claude-memory/.claude-plugin/plugin.json | 2 +- plugins/claude-memory/CHANGELOG.md | 39 +++++ plugins/claude-memory/README.md | 5 + plugins/claude-memory/skills/audit/SKILL.md | 7 + .../skills/audit/context/audit.md | 31 +++- .../scripts/discover-instruction-surfaces.sh | 110 +++++++++++++ .../discover-instruction-surfaces.test.sh | 154 ++++++++++++++++++ 15 files changed, 533 insertions(+), 50 deletions(-) create mode 100755 plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh create mode 100755 plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh diff --git a/plugins/claude-config/.claude-plugin/plugin.json b/plugins/claude-config/.claude-plugin/plugin.json index ce56ac2c38..c101dce510 100644 --- a/plugins/claude-config/.claude-plugin/plugin.json +++ b/plugins/claude-config/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-config", - "version": "0.30.0", + "version": "0.31.0", "description": "Seven configuration-health skills (plus setup) for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-instructions (locally-owned instruction surfaces vs current model capability — proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), audit-prompting-postures (the additive lane — posture guidance the prompting guide says a component's purpose needs but the component does not carry), audit-pass (one coordinated, ordered, resumable pass over a named target — three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate — delegating every check to the plugin that owns it), and unhobble (the empirical bare-baseline experiment: reversibly strip a repo's standing instructions, log real stumbles against the current model, re-add only what evidence earns).", "author": { "name": "Melodic Software", diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index ce6dfbec85..b904c6d630 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -3,6 +3,68 @@ All notable changes to the `claude-config` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.31.0] + +`audit-pass` changes what it does on two target classes, which is why this is a minor: a target that is +not a git repository is now refused, and a target at or above `~` now gets the self-exclusion and +disclosure that only `--report-to` runs used to get. The three fixes are independent — the refusal does +**not** subsume the containment work, because the class that falsified the containment claim is a *git* +repository rooted at or above `$HOME`, which a dotfiles repo is. + +### Fixed + +- **`audit-pass` claimed its report lands "outside any target repository", and that is false by + construction.** `${CLAUDE_PLUGIN_DATA}` resolves to `~/.claude/plugins/data/{id}/` + ([plugins reference](https://code.claude.com/docs/en/plugins-reference), verified 2026-08-11), and no + documented setting relocates it — so the default report path is *inside* any target at or above `~`. + The sentence was true for an ordinary repo under `C:/Projects/…` and stated as a universal, while a + whole reachable target class falsifies it. It is now stated as what it is: outside a target below `~`, + inside one at or above it. That claim was load-bearing for the read-only contract's headline property + and for assertions 2.1 and 2.4. +- **And the machinery that exists for exactly that situation was gated on the wrong thing.** Name-based + refusal, the pre-write exclusion entry, the operator disclosure, and the digest exclusion were all + written under the `--report-to` bullet. The flag is one way a report path ends up inside the target; + it was never the definition. The governing condition is now the predicate `report_path ⊆ target_root`, + evaluated against the resolved path on every run, with `--report-to` demoted to one instance of it. + Class 4 of the exclusion set is stated as that predicate rather than as a two-item list, so a member + added later inherits the rule instead of needing someone to remember the list. +- **The consequence this retires:** a run against a dotfiles repository, or against `~` itself, wrote + its report into its own scan set with no exclusion entry and no disclosure, and then reported + `indeterminate` on its own determinism gate — every time, purely for doing what it was asked. The + digest exclusion in `determinism-tiers.md` is re-keyed on containment for the same reason. +- **Assertions 2.1–2.4 are re-scoped on containment, and 2.6 is added.** 2.1 ("`git status --porcelain` + is empty") was falsified by the same class as 2.4, not just 2.4. 2.6 is the default-path twin of the + redirect disclosure: a run against a target at or above `~` with no `--report-to` discloses that its + report path is contained and names it, so a contained write is never silent. + +### Changed + +- **`audit-pass` refuses a target that is not a git repository.** `SKILL.md` called `target` "the git + repository to audit" while the gate required only that it "resolve to the active project root" — so a + non-git directory passed, and the contract then had no branch for it in five places: the state key has + a no-remote fallback and no no-git one; the scan baseline is defined as HEAD plus the state digest; + Class 3 exclusion derives worktrees from `git worktree list` and, unlike Class 1, is given no fallback; + assertion 2.1 is stated over `git status --porcelain`; and — the one that is a permanent capability + loss rather than a missing derivation — suppression is enacted only by the team layer, which is the + *tracked* layer, so no suppression is ever enactable on such a target. An operator could accept a + finding there and have the acceptance silently fail to persist, forever. + + **This closes a target class, deliberately.** The alternative was specifying all five branches, which + obliges the contract to promise a capability it cannot deliver on that class. The refusal reuses the + existing channel — non-zero, before Phase 0, naming the resolved path and the reason — rather than + minting a new exit code, and it states the cost out loud, naming the suppression consequence in + particular, so it does not read as an arbitrary restriction. + +### Added + +- **Eval 30 `non-git-target-is-refused-not-half-specified`**, and evals 1 and 20 rewritten. This is the + row that had to land first: eval 1's expectation graded "writes the report under `CLAUDE_PLUGIN_DATA` + **rather than** into the scan set", and eval 20's graded the digest exclusion as a `--report-to` + property — so the suite passed a defect-exhibiting run and would have failed the corrected one. Eval 1 + now grades that the containment predicate is *evaluated*, and that where it holds the path is recorded + before the write and disclosed; eval 20 grades that the exclusion is keyed on containment and not on + the flag. Reading all 29 shipped cases at HEAD found no others carrying the premise. + ## [0.30.0] Two graded outputs move, which is why this is a minor rather than a patch: a baseline deny finding can diff --git a/plugins/claude-config/README.md b/plugins/claude-config/README.md index dc46971cd5..b53126eea4 100644 --- a/plugins/claude-config/README.md +++ b/plugins/claude-config/README.md @@ -109,6 +109,13 @@ and one human gate per run. Findings report in three tiers — derived (exact eq judged (a stability tolerance whose violation fails the run's self-check), delegated. `/doctor` is an operator handoff, never a dispatch, because it is interactive. +**The target must be a git repository**, and one that resolves to the active project root. Both halves +are refused non-zero rather than reinterpreted. The git requirement is not incidental: the state key, +the scan baseline, the worktree exclusion, and the top read-only assertion are all defined over git, +and suppression is enacted only by the *tracked* layer — so on a non-git target no suppression would +ever persist. Audit such a directory by opening it as a repository, or through the delegated skills +directly. + ```shell /claude-config:audit-pass # read-only pass over the current repo /claude-config:audit-pass --opinion # include the default-off OPINION-tier checks @@ -181,7 +188,9 @@ automatically. If you used `/claude-config-audit:memory-health`, install it expl No `userConfig`. One tracked consumer-project file — `audit-pass`'s suppression record, above. Persistent plugin state: `audit-pass` writes its run reports and manifests under -`${CLAUDE_PLUGIN_DATA}`, outside any target repository, so a run never writes into its own scan set. +`${CLAUDE_PLUGIN_DATA}`, which resolves under `~` — outside a target below the home directory, and +inside one at or above it. A run never *scans* what it wrote: where the resolved report path is +contained in the target, the run excludes that path before writing and says so. Network: `audit` fetches official docs pages and each registered marketplace's `marketplace.json` from `raw.githubusercontent.com` (read-only; a failed fetch degrades to SKIP). diff --git a/plugins/claude-config/skills/audit-pass/SKILL.md b/plugins/claude-config/skills/audit-pass/SKILL.md index 179052fff3..5170f43829 100644 --- a/plugins/claude-config/skills/audit-pass/SKILL.md +++ b/plugins/claude-config/skills/audit-pass/SKILL.md @@ -57,17 +57,49 @@ Parse `$ARGUMENTS`: **delegated** interfaces — each would have to accept and honor a target root — and belongs to those skills rather than this one. The argument itself survives because the state key, the lock, and the report are already keyed on the resolved root. + + **The gate enforces both halves of that first sentence: the active project root, *and* a git + repository.** A resolved `target` that is not inside a git repository is refused the same way — + non-zero, before Phase 0 does any work, naming the resolved path and the reason, writing nothing. + Requiring only "the active project root" let a non-git directory through into a contract with no + branch for it, and the run then went quiet in five places rather than one: + + - the state key (§3) has a no-**remote** fallback and no no-**git** one, and "canonicalized repo + root" is undefined without a repository; + - the scan baseline is *the target's HEAD commit and the run's state digest*, and HEAD does not + exist; + - Class 3 exclusion derives worktrees from `git worktree list`, and unlike Class 1 it is given no + fallback; + - assertion 2.1 is stated over `git status --porcelain`, so the top read-only assertion is + unevaluable; + - and — the one that is a permanent capability loss rather than a missing derivation — **only the + team layer enacts a suppression**, and the team layer is the *tracked* layer. With nothing + tracked, no suppression is ever enactable on such a target, so an operator could accept a finding + and have the acceptance silently fail to persist, forever. + + **The refusal says that cost out loud** rather than reading as an arbitrary restriction, and it names + the suppression consequence in particular. Refusing closes a target class deliberately; it is not a + side effect. The alternative — specifying all five branches — was considered and rejected, because + the last of them obliges the contract to promise a capability it can never deliver on that class. + A non-git directory is audited by opening it as a repository, or by the delegated skills directly. - **`--fix`** — the explicit mutation override. Absent, the pass writes nothing into the target. - **`--opinion`** — run the `OPINION`-tier checks the delegated catalogs declare default-off. - **`--resume`** — resume the most recent incomplete run for this target's state key. -- **`--report-to `** — redirect the report into the target tree. **The redirecting run adds - that path to its own exclusion set before writing** — not only for later runs, or the two runs' - derived sets could not be equal — and says so in its output. The destination is accepted only if it - is an `audit-pass`-owned report or a new path that is **not a recognized instruction surface**; +- **`--report-to `** — redirect the report into the target tree. The destination is accepted only + if it is an `audit-pass`-owned report or a new path that is **not a recognized instruction surface**; anything else is refused non-zero, naming the file. Refused on name rather than on existence, because `--report-to CLAUDE.md` against a repo that has none would *create* a live instruction surface out of a JSON report and then hide it from every later scan. + **The self-exclusion obligation is not this flag's.** It belongs to the predicate + `report_path ⊆ target_root`: **any** run whose resolved report path is contained in the target adds + that path to its own exclusion set before writing — not only for later runs, or the two runs' derived + sets could not be equal — and says so in its output. `--report-to` is one way containment arises. The + **default** path is another, because `${CLAUDE_PLUGIN_DATA}` resolves under `~` and is inside any + target at or above it. Full statement in + [reference/report-location-and-schema.md](reference/report-location-and-schema.md) §2 and + [reference/exclusion-set.md](reference/exclusion-set.md) Class 4. + ## Phase 0 — Resolve, key, lock Resolve the target root, compute the state key, and take the lock posture for the mode — read-only @@ -350,4 +382,7 @@ recalibration. defect this skill's whole shape exists to avoid. - Never reads another plugin's files. Cross-plugin cooperation is invocation only. - Never edits managed policy or a user-scope file, in any mode. -- Never writes into its own scan set without `--report-to`, and never scans what it wrote. +- Never scans what it wrote. Where its resolved report path is contained in the target — by + `--report-to`, or by `${CLAUDE_PLUGIN_DATA}` resolving under `~` for a target at or above it — the + path is excluded before the write and the containment is disclosed. Never silently. +- Never audits a target that is not a git repository. It refuses, and says what the refusal costs. diff --git a/plugins/claude-config/skills/audit-pass/evals/evals.json b/plugins/claude-config/skills/audit-pass/evals/evals.json index 5eddf1dc5c..c02c3e49a4 100644 --- a/plugins/claude-config/skills/audit-pass/evals/evals.json +++ b/plugins/claude-config/skills/audit-pass/evals/evals.json @@ -5,12 +5,14 @@ "id": 1, "name": "bare-invocation-is-read-only", "prompt": "/claude-config:audit-pass", - "expected_output": "Runs one coordinated read-only pass over the resolved target: three-scope inventory first, then delegated lanes, then a report written under CLAUDE_PLUGIN_DATA. It writes nothing into the target tree — git status --porcelain stays empty — because mutation is only reachable behind the explicit --fix override.", + "expected_output": "Runs one coordinated read-only pass over the resolved target: three-scope inventory first, then delegated lanes, then a report written under CLAUDE_PLUGIN_DATA at runs///findings.json. It applies no fix, because mutation is only reachable behind the explicit --fix override. It also evaluates whether the resolved report path is contained in the target root — CLAUDE_PLUGIN_DATA resolves under ~, so containment holds whenever the target is at or above the home directory — and where it holds, records that path in the run's own exclusion set before writing and says so in its output.", "files": [], "expectations": [ - "Writes no file into the target repository, leaving git status --porcelain empty", - "Does not apply any fix, and states that --fix is the explicit override that would enable one", - "Writes the report under CLAUDE_PLUGIN_DATA rather than into the scan set" + "Applies no fix, and states that --fix is the explicit override that would enable one", + "Names the resolved report path under CLAUDE_PLUGIN_DATA at runs///findings.json", + "Evaluates whether the resolved report path is contained in the target root, rather than asserting the default path is outside it", + "Where containment holds, records the report path in THIS run's own exclusion set before writing it, and discloses that in the run output", + "Where containment does not hold, writes nothing into the target tree, leaving git status --porcelain empty" ] }, { @@ -260,12 +262,13 @@ "id": 20, "name": "dirty-file-edited-without-changing-the-count", "prompt": "/claude-config:audit-pass another session edited a file that was already showing in git status while you ran — same branch, same number of modified files, nothing added or removed. Gate still good?", - "expected_output": "No. It reports indeterminate. HEAD is unchanged and the count of dirty paths is unchanged, and neither is what the gate measures: the state digest pairs every path with the content hash of its current bytes, so editing an already-dirty file changes the digest even though the count holds still. Swapping one dirty path for another is the same class and is caught the same way. A count-based gate would have reported this run as deterministic while different lanes read different states, which is the failure the digest exists to close. The pass's own artifacts — a --report-to path inside the target — are excluded from the digest on the same list that excludes them from the scan, so a redirected run does not invalidate its own gate by writing its report; any other file appearing or changing is still a moved tree.", + "expected_output": "No. It reports indeterminate. HEAD is unchanged and the count of dirty paths is unchanged, and neither is what the gate measures: the state digest pairs every path with the content hash of its current bytes, so editing an already-dirty file changes the digest even though the count holds still. Swapping one dirty path for another is the same class and is caught the same way. A count-based gate would have reported this run as deterministic while different lanes read different states, which is the failure the digest exists to close. The pass's own artifacts are excluded from the digest on the same list that excludes them from the scan — that exclusion is keyed on containment, so it covers any report path the run will write that lies inside the target root, whether it got there by --report-to or by CLAUDE_PLUGIN_DATA resolving under a target at or above ~. So writing its own report never invalidates the run's own gate; any other file appearing or changing is still a moved tree.", "files": [], "expectations": [ "Reports indeterminate even though HEAD and the dirty-path count are both unchanged", "Names the per-path content hash, not the count, as what detects the change, and covers the swapped-path case as the same class", - "States that the pass's own artifacts are excluded from the digest as well as the scan, so a --report-to write does not make the run indeterminate" + "States that the pass's own artifacts are excluded from the digest as well as the scan, so writing its own report does not make the run indeterminate", + "Keys that exclusion on the report path being contained in the target root — NOT on --report-to having been passed — so a contained default report path is excluded too" ] }, { @@ -354,6 +357,20 @@ "Confirms the legacy lock is still reclaimable once no lease under the state key is live, so the age bound is not defeated permanently", "Surfaces the deliberate over-deferral — a live read-only run's lease also defers a legacy lock, since a lease records no run mode — as fail-closed and bounded, and scoped to pre-rule locks only" ] + }, + { + "id": 30, + "name": "non-git-target-is-refused-not-half-specified", + "prompt": "/claude-config:audit-pass ~/notes — it is not a git repository, just a directory of markdown, but it is where I stand and it has a CLAUDE.md.", + "expected_output": "Refuses, non-zero, before Phase 0 does any work. The target argument is the git repository to audit, and the gate enforces that rather than only 'resolves to the active project root' — the two used to disagree, which let a non-git directory through into a contract that cannot describe it. The refusal names the resolved path and the reason, and states what a non-git target would cost rather than leaving it implicit: the state key has a no-remote fallback but no no-git one, the scan baseline is defined as HEAD plus the state digest, Class 3 exclusion derives worktrees from git worktree list, assertion 2.1 is stated over git status --porcelain, and — the one that is a permanent capability loss rather than a missing derivation — suppression is enacted only by the team layer, which is the TRACKED layer, so no suppression would ever be enactable on such a target. It writes nothing, and it does not fall back to a degraded pass.", + "files": [], + "expectations": [ + "Refuses the non-git target and exits non-zero rather than running a partial or degraded pass", + "Names the resolved path and the reason, in the same shape as the existing not-the-active-project-root refusal", + "Refuses BEFORE doing any Phase 0 work, and writes nothing anywhere", + "Names at least the suppression consequence — the team layer is the tracked layer, so no suppression is enactable without a repository — rather than refusing without a stated cost", + "Does NOT invent a non-git state key, scan baseline, or Class 3 fallback in order to proceed" + ] } ] } diff --git a/plugins/claude-config/skills/audit-pass/reference/determinism-tiers.md b/plugins/claude-config/skills/audit-pass/reference/determinism-tiers.md index f4c1a11c90..5b8c50f648 100644 --- a/plugins/claude-config/skills/audit-pass/reference/determinism-tiers.md +++ b/plugins/claude-config/skills/audit-pass/reference/determinism-tiers.md @@ -64,13 +64,17 @@ So the run **measures** its own precondition: evaluate P1–P3 as though the tree held still while different lanes in fact read different states. Pairing each path with its content is what makes both movements visible. - **The run's own artifacts are excluded from the digest, on the same list that excludes them from - the scan.** A `--report-to` path inside the target appears in `git status --porcelain` the moment - the report is written, which is between the scan-baseline and audit-endpoint captures — so a digest over *every* - dirty path makes the redirected run fail its own determinism gate as `indeterminate`, every time, - purely because it did what it was asked to do. Recording the path in the scan exclusion set does + the scan.** A report path inside the target appears in `git status --porcelain` the moment the report + is written, which is between the scan-baseline and audit-endpoint captures — so a digest over *every* + dirty path makes that run fail its own determinism gate as `indeterminate`, every time, purely + because it did what it was asked to do. Recording the path in the scan exclusion set does not reach the digest; the exclusion has to apply to both, and it is one list precisely so the two - cannot diverge. What is excluded is the pass's own class-4 artifact set and nothing else: a - *different* file appearing or changing is still a moved tree and still `indeterminate`. + cannot diverge. **The exclusion is keyed on containment, not on `--report-to`** — Class 4's predicate + is `write_path ⊆ target_root`, so it covers the default `${CLAUDE_PLUGIN_DATA}` path just as well + whenever the target sits at or above `~`. Keying it on the flag was the defect that made every run + against such a target report `indeterminate` about itself. What is excluded is the pass's own class-4 + artifact set and nothing else: a *different* file appearing or changing is still a moved tree and + still `indeterminate`. - If either capture differs, the determinism gate is reported **`indeterminate`**, never `passed` and never `failed`, naming both captures and what moved. - **Two endpoint captures detect a net change, not a transient one.** A file mutated and reverted diff --git a/plugins/claude-config/skills/audit-pass/reference/exclusion-set.md b/plugins/claude-config/skills/audit-pass/reference/exclusion-set.md index 44f656bd73..a9c9d8cd0d 100644 --- a/plugins/claude-config/skills/audit-pass/reference/exclusion-set.md +++ b/plugins/claude-config/skills/audit-pass/reference/exclusion-set.md @@ -48,16 +48,26 @@ without being told about it. ## Class 4 — the pass's own artifacts +**Derivation — one predicate, not a list of flags.** *Any path this run will write that is contained in +the resolved target root* is excluded, recorded before the baseline is taken, and stated in the run's +output. The membership test is `write_path ⊆ target_root`, evaluated against resolved paths. The two +members below are the ways that condition arises today, not the definition of it — anything added later +inherits the rule by satisfying the predicate, and no list has to be remembered and extended. + - **The suppression record** (`.claude/audit-pass.md` and its cascade layers). Excluded from the scan set: otherwise suppressing a finding changes the tree and perturbs the next run, which would make the idempotence property unfalsifiable. -- **A redirected report.** A run given `--report-to ` records that path in **its own** - exclusion list before it writes, and every subsequent run keeps it there; the run states this in - its output. Recording it only from run 2 onward would leave the path in one run's derived-tier - exclusion artifact and absent from the other's, and the derived tier is held to exact equality - across runs. The path is recorded whether or not a file exists there yet — the exclusion is about - the path the run is about to write. Scanning your own previous report is the failure the rule - exists to prevent. +- **The run's own report, wherever it lands inside the target.** A run whose resolved report path is + contained in the target root records that path in **its own** exclusion list before it writes, and + every subsequent run keeps it there; the run states this in its output. `--report-to ` is one + way the path becomes contained — **the default path is another**, because `${CLAUDE_PLUGIN_DATA}` + resolves under `~` and is therefore inside any target at or above it. Keying this on the flag instead + of on containment was a defect: it left a run against a dotfiles repository, or against `~` itself, + writing into its own scan set with no exclusion entry and then failing its own determinism gate. + Recording it only from run 2 onward would leave the path in one run's derived-tier exclusion artifact + and absent from the other's, and the derived tier is held to exact equality across runs. The path is + recorded whether or not a file exists there yet — the exclusion is about the path the run is about to + write. Scanning your own previous report is the failure the rule exists to prevent. ## Suppression against an excluded path is a hard error diff --git a/plugins/claude-config/skills/audit-pass/reference/report-location-and-schema.md b/plugins/claude-config/skills/audit-pass/reference/report-location-and-schema.md index da622b0119..a7270339d7 100644 --- a/plugins/claude-config/skills/audit-pass/reference/report-location-and-schema.md +++ b/plugins/claude-config/skills/audit-pass/reference/report-location-and-schema.md @@ -7,17 +7,32 @@ Terms: [terms.md](terms.md). Full index: [run-contract.md](run-contract.md). ## 2. Where the report lives -**A run never writes into its own scan set.** If run 1 writes a report into the tree, run 2's tree is -not unchanged and the idempotence property is unfalsifiable by construction. - -- The report goes under `${CLAUDE_PLUGIN_DATA}`, which resolves outside any target repository and - survives plugin updates, at `runs///findings.json`. -- `--report-to ` redirects it into the target tree. **The redirecting run records that path in - its own exclusion set, before it writes** — not only for subsequent runs — and says so in its - output. Deferring the record to run 2 would put the path in one run's derived-tier exclusion - artifact and not the other's, and 2.2 requires those two derived sets to be equal. The path is - recorded whether or not a file exists there yet: the exclusion is about the path this run is about - to write, not about what it found there. +**A run never scans what it wrote.** If run 1 writes a report into the scanned tree and run 2 reads it, +run 2's tree is not unchanged and the idempotence property is unfalsifiable by construction. + +**The governing condition is containment, not a flag.** Whether the run must protect itself from its own +report is decided by the predicate `report_path ⊆ target_root`, evaluated against the **resolved** report +path on every run. `--report-to` is one way that condition becomes true; it was never the definition of +it, and gating the machinery on the flag left the default path unprotected wherever the same condition +held. + +- The report goes under `${CLAUDE_PLUGIN_DATA}` at `runs///findings.json`, which + survives plugin updates. **State its location precisely, because a whole target class turns on it:** + that directory resolves to `~/.claude/plugins/data/{id}/` + ([plugins reference](https://code.claude.com/docs/en/plugins-reference), verified 2026-08-11), and no + documented setting relocates it. It is therefore **outside** a target below `~` and **inside** any + target at or above it. The default path is *usually* outside the scan set and is **not + unconditionally** outside it — a dotfiles repository, or `~` itself, is a target where containment + holds by construction, and the older unconditional claim was false there. +- `--report-to ` redirects the report, which makes containment hold whenever the destination lies + inside the target. +- **Whenever containment holds — by either route — the run records that path in its own exclusion set + before it writes**, and says so in its output. Not only for subsequent runs: deferring the record to + run 2 would put the path in one run's derived-tier exclusion artifact and not the other's, and 2.2 + requires those two derived sets to be equal. The path is recorded whether or not a file exists there + yet — the exclusion is about the path this run is about to write, not about what it found there. +- **Where containment does not hold, none of this is owed** and the run writes its report without an + exclusion entry, because there is nothing to exclude from a tree the path is not in. - **A redirect destination is accepted only if it is an `audit-pass`-owned report, or a new path that is not a recognized instruction surface.** Recording the path unconditionally is right for the *exclusion* and no licence to *write*: `--report-to CLAUDE.md` would overwrite an audited @@ -39,11 +54,12 @@ not unchanged and the idempotence property is unfalsifiable by construction. | # | Assertion | |---|---| -| 2.1 | After a run against a clean git worktree with no redirect, `git status --porcelain` is empty. | +| 2.1 | After a run against a clean git worktree whose **resolved report path is not contained in the target root**, `git status --porcelain` is empty. Scoped on containment rather than on "no redirect", because the default path is contained too whenever the target is at or above `~`, and the unscoped form was false there. | | 2.5 | `--report-to ` exits non-zero naming the file, writes nothing, and leaves the file byte-identical — including when the path is an audited instruction surface. | -| 2.2 | With a redirect, a second run's scan set excludes the redirected path, and the two runs' derived identity sets are still equal. | -| 2.3 | The first run under `--report-to` records the redirected path in its own exclusion artifact before writing the report, whether or not that path already exists. | -| 2.4 | A run under `--report-to ` against an otherwise-unchanging tree reports the determinism gate as satisfied, not `indeterminate` — writing its own report does not move its own state digest. | +| 2.2 | Where the report path is contained, a second run's scan set excludes it, and the two runs' derived identity sets are still equal. | +| 2.3 | The first run whose report path is contained records that path in its own exclusion artifact before writing the report, whether or not that path already exists, and whether it became contained by `--report-to` or by default resolution. | +| 2.4 | A run whose report path is contained in the target, against an otherwise-unchanging tree, reports the determinism gate as satisfied, not `indeterminate` — writing its own report does not move its own state digest. Holds for the default path under a target at or above `~` exactly as it holds under `--report-to`. | +| 2.6 | A run against a target at or above `~` with **no** `--report-to` discloses that its default report path is contained, and names it — the default-path twin of the redirect disclosure, so a contained write is never silent. | ## 7. Report schema diff --git a/plugins/claude-memory/.claude-plugin/plugin.json b/plugins/claude-memory/.claude-plugin/plugin.json index 47185cb6e5..2b5281ffef 100644 --- a/plugins/claude-memory/.claude-plugin/plugin.json +++ b/plugins/claude-memory/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-memory", - "version": "0.7.1", + "version": "0.8.0", "description": "Keeps a repo's Claude Code memory layer healthy and under your control, against criteria derived from official Claude Code documentation. The audit skill checks the instruction/memory layer (CLAUDE.md, CLAUDE.local.md, .claude/rules/, auto-memory) with a deterministic script-backed spine plus judgment-tier checks. The stateless skill inspects, disables, and (confirm-gated) purges Claude-written auto memory across all settings scopes.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-memory/CHANGELOG.md b/plugins/claude-memory/CHANGELOG.md index f4f3156cd3..bb68fd03ff 100644 --- a/plugins/claude-memory/CHANGELOG.md +++ b/plugins/claude-memory/CHANGELOG.md @@ -3,6 +3,45 @@ All notable changes to the `claude-memory` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.0] + +The audit now covers two surfaces it never could before, which is why this is a minor. + +### Fixed + +- **`audit`: the user-global instruction surfaces were audited by nothing at all.** Step 1 discovery was + two bare `find` commands rooted at the current directory — `find . -maxdepth 1 -name "CLAUDE.md"` and + `find .claude/rules -name "*.md"` — so it could only ever see project scope. Meanwhile + `claude-config`'s `audit-instructions` partitions memory-layer hygiene to this skill and names + **`~/.claude/rules/`** explicitly in the handoff (`audit-instructions/reference/criteria.md:96`). One + skill delegated a user-global surface by name; the receiving skill's discovery could not reach it. So + `~/.claude/CLAUDE.md`, which loads in *every* session in *every* project, was checked by neither — and + under-coverage reads as a clean report. + + Discovery now resolves `${CLAUDE_CONFIG_DIR:-$HOME/.claude}` for both `CLAUDE.md` and `rules/*.md`, + reusing the same config-root resolution the memory-dir resolver already carries rather than + re-deriving it. + + *(Recorded because the originating report argued this from a different line — + `reference/criteria.md:224`, the C9 carve-out for personal files. Read in context that line **excludes** + personal files from C9 as "not repo-scoped", which cuts against the argument rather than for it. The + seam above is the load-bearing mechanism, and it needs no interpretation.)* + +### Added + +- **`scripts/discover-instruction-surfaces.sh` + tests.** Discovery is a script now because the fix has + a second half that inline `find` cannot carry: **every file is tagged with the scope it loads from.** + Widening discovery without that would have traded under-coverage for a false positive — C9 is + project-scoped and its own criteria row says to skip personal files, so an unscoped widening would fire + C9 on `~/.claude/CLAUDE.md` and FAIL it for not stating a repo's build and test commands. Step 2 now + routes on the emitted scope, and the R-checks apply at both scopes because a user rule loads every + session in every project. 23 checks in the sibling `*.test.sh` style, including the Git Bash case where + the config root is a Windows path with a drive letter. +- **Step 3 gains a cross-scope consistency pass.** Both layers load together, so a user instruction that + contradicts a project one is a live conflict rather than a layering choice, and one the project already + states is redundant context on every run. The report names which scope each side came from, because the + resolution differs — only one of the two is yours to edit on behalf of the repo. + ## [0.7.1] ### Changed diff --git a/plugins/claude-memory/README.md b/plugins/claude-memory/README.md index 73bdda8f29..dd8dd2cb61 100644 --- a/plugins/claude-memory/README.md +++ b/plugins/claude-memory/README.md @@ -24,6 +24,11 @@ index and orphan always-loaded rules) yields identical findings on identical rep checks apply fixed criteria with model reading. Reports persist to the plugin's data directory — they audit contributor-personal auto-memory, so they never land in the repo. +Scope covers **both** layers that load every session: the project's `CLAUDE.md` / `CLAUDE.local.md` / +`.claude/rules/`, and the user-global `${CLAUDE_CONFIG_DIR:-~/.claude}/CLAUDE.md` and +`${CLAUDE_CONFIG_DIR:-~/.claude}/rules/`. Every discovered file is tagged with its scope, so +project-scoped criteria skip personal files instead of reporting a repo-scoped finding against one. + ```shell /claude-memory:audit # audit (default) /claude-memory:audit fix # apply findings with per-item approval diff --git a/plugins/claude-memory/skills/audit/SKILL.md b/plugins/claude-memory/skills/audit/SKILL.md index 153cee245e..2b08ccd3c0 100644 --- a/plugins/claude-memory/skills/audit/SKILL.md +++ b/plugins/claude-memory/skills/audit/SKILL.md @@ -33,9 +33,16 @@ the `audit` and `automation-gaps` skills in the `claude-config` plugin). | Project instructions | `CLAUDE.md` | Every session, full | Yes | | Local overrides | `CLAUDE.local.md` | Every session, full | Yes | | Rules | `.claude/rules/**/*.md` | Every session (unconditional) or on-demand (path-scoped) | Yes | +| **User instructions** | `${CLAUDE_CONFIG_DIR:-~/.claude}/CLAUDE.md` | Every session, full, in **every** project | Yes | +| **User rules** | `${CLAUDE_CONFIG_DIR:-~/.claude}/rules/**/*.md` | Same as project rules, in every project | Yes | | Auto-memory | `~/.claude/projects//memory/` | First 200 lines / 25KB of MEMORY.md | Yes | | Settings, hooks, MCP, agents, skills | Various | Various | No — use `claude-config`'s `audit` / `automation-gaps` | +The two user-scope rows are in scope because they load in every session regardless of where it starts, +and because `claude-config`'s `audit-instructions` partitions memory-layer surfaces here **by name**, +`~/.claude/rules/` included. Discovery tags every file with its scope so project-scoped criteria (C9) +skip personal files rather than reporting a repo-scoped finding against one. + ## Scope boundary (route out) This audit owns instruction-layer **health**: structure, size, placement, and index integrity of diff --git a/plugins/claude-memory/skills/audit/context/audit.md b/plugins/claude-memory/skills/audit/context/audit.md index a6d1919399..7221bbe576 100644 --- a/plugins/claude-memory/skills/audit/context/audit.md +++ b/plugins/claude-memory/skills/audit/context/audit.md @@ -8,11 +8,13 @@ instruction/memory files. Find files in scope: ```bash -# CLAUDE.md files -find . -maxdepth 1 -name "CLAUDE.md" -o -name "CLAUDE.local.md" 2>/dev/null - -# Rules files -find .claude/rules -name "*.md" -type f 2>/dev/null +# CLAUDE.md and rules files, PROJECT and USER scope, each tagged with its scope. +# The bundled script resolves ${CLAUDE_CONFIG_DIR:-$HOME/.claude} the same way the +# memory-dir resolver does. A bare `find .` sees project scope only, which left +# ~/.claude/CLAUDE.md and ~/.claude/rules/*.md audited by nothing — they load in +# every session, and audit-instructions' surface partition hands them here by name. +bash "${CLAUDE_PLUGIN_ROOT}/skills/audit/scripts/discover-instruction-surfaces.sh" +# Output: \t\t — scope is `project` or `user` # Auto-memory — CURRENT repo only. A bare `~/.claude/projects/*/memory/` glob # matches every project on a multi-project machine and resolves alphabetical-first @@ -21,15 +23,23 @@ MEMORY_DIR=$(bash "${CLAUDE_PLUGIN_ROOT}/skills/audit/scripts/resolve-memory-dir ls "$MEMORY_DIR"/*.md 2>/dev/null ``` -For each file found, record: path, line count, visible line count (excluding HTML comments). +For each file found, record: **scope**, path, line count, visible line count (excluding HTML comments). +Carry the scope forward — Step 2 routes on it, and a check applied at the wrong scope is a false +positive rather than extra coverage. ## Step 2: Run checks Read [../reference/criteria.md](../reference/criteria.md), then execute every applicable check against each discovered file. Apply by entity type: -- **C1-C9**: CLAUDE.md and CLAUDE.local.md (C9 is project-scoped — skip for CLAUDE.local.md) -- **R1-R4**: `.claude/rules/` files +- **C1-C9**: CLAUDE.md and CLAUDE.local.md, at either scope +- **C9 is project-scoped — skip it for CLAUDE.local.md AND for every `user`-scope file.** The criteria + file says so directly: C9 applies to project CLAUDE.md only, and `~/.claude/CLAUDE.md` is "not + repo-scoped". This is the reason Step 1 emits a scope tag. A user-scope `CLAUDE.md` carrying no build + and test commands is correct, not a FAIL, and reporting one there would be a false positive + manufactured by the wider discovery +- **R1-R4**: `.claude/rules/` files, at either scope — a `user`-scope rule loads in every session, so + the always-loaded cost R-checks are about applies to it at least as strongly as to a project rule - **C7/R3 (currency)**: version pins and counts are checked against the repo's own pin files (`global.json`, `.nvmrc`, `.python-version`, `.mcp.json`, or ecosystem equivalents). File-path-existence currency is **agent judgment**: read each path reference in context — instructional files cite @@ -69,6 +79,11 @@ After per-file checks, cross-reference: 1. Compare CLAUDE.md sections against `.claude/rules/` for contradictions 2. Compare CLAUDE.md against CLAUDE.local.md for redundancy 3. Check if any CLAUDE.md instruction is already enforced by rule, hook, or analyzer +4. Compare the **user**-scope surfaces against the project ones. Both load together in every session + here, so a user instruction that contradicts a project one is a live conflict rather than a + layering choice, and a user instruction the project already states is redundant context on every + run. Report the contradiction against the pair, and say which scope each side came from — the + resolution differs, since only one of the two is yours to edit on behalf of the repo ## Step 4: Generate report diff --git a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh new file mode 100755 index 0000000000..264dd702a3 --- /dev/null +++ b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# discover-instruction-surfaces.sh — enumerate the CLAUDE.md and rules files in audit scope, +# each tagged with the scope it loads from. +# +# Why this exists: Step 1 discovery used to be two bare `find` commands rooted at the +# current directory (`find . -maxdepth 1 -name CLAUDE.md`, `find .claude/rules ...`), so it +# could only ever see PROJECT-scope files. The user-global surfaces — `~/.claude/CLAUDE.md` +# and `~/.claude/rules/*.md` — load in every session and were reachable by neither this +# skill nor `claude-config:audit-instructions`, whose surface partition explicitly hands +# `~/.claude/rules/` here by name. One skill delegated a user-global surface; the receiving +# skill's discovery could not reach it, so nothing audited it. +# +# Scope tagging is not cosmetic. Several criteria are project-scoped (C9 is the live case), +# and widening discovery WITHOUT a scope field would make them fire on personal files that +# are out of their remit — trading under-coverage for false positives. The caller routes on +# the emitted scope rather than guessing from the path shape. +# +# Config root honors CLAUDE_CONFIG_DIR the same way the sibling resolver does: per the +# official .claude-directory doc, setting it relocates every `~/.claude` path. +# +# Advisory: prints what it finds, ALWAYS exits 0. A missing surface is not an error — +# most repos have no CLAUDE.local.md and many machines have no user-scope CLAUDE.md. +# +# Usage: +# discover-instruction-surfaces.sh # TAB-separated: +# discover-instruction-surfaces.sh --scope user # only the user-scope surfaces +# discover-instruction-surfaces.sh --help +# +# Output columns: +# scope project | user +# kind claude-md | claude-local-md | rule +# path absolute for user scope, as-found for project scope + +set -uo pipefail + +SCOPE_FILTER="all" + +while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + cat <<'EOF' +discover-instruction-surfaces.sh — list in-scope CLAUDE.md and rules files with their scope tag. + +Usage: discover-instruction-surfaces.sh [--scope project|user|all] [--help] + +Emits one TAB-separated record per file: + + scope project — CLAUDE.md / CLAUDE.local.md at the current root, and .claude/rules/*.md + user — ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/CLAUDE.md and .../rules/*.md + kind claude-md | claude-local-md | rule + +User-scope files load in EVERY session regardless of where the session starts, so they are +in audit scope. They are tagged so project-scoped criteria (C9) can skip them rather than +reporting a repo-scoped finding against a personal file. + +Always exits 0. A surface that does not exist is simply not emitted. +EOF + exit 0 + ;; + --scope) + shift + SCOPE_FILTER="${1:-all}" + ;; + *) + printf 'discover-instruction-surfaces.sh: unknown argument: %s\n' "$1" >&2 + exit 0 + ;; + esac + shift +done + +emit() { + # $1 scope, $2 kind, $3 path + case "$SCOPE_FILTER" in + all) ;; + "$1") ;; + *) return 0 ;; + esac + printf '%s\t%s\t%s\n' "$1" "$2" "$3" +} + +# --- project scope ----------------------------------------------------------- +# Depth 1 by design: CLAUDE.md files nested deeper are subtree memory that loads only +# on demand, and are not this checklist's subject. + +[[ -f "CLAUDE.md" ]] && emit project claude-md "CLAUDE.md" +[[ -f "CLAUDE.local.md" ]] && emit project claude-local-md "CLAUDE.local.md" + +if [[ -d ".claude/rules" ]]; then + while IFS= read -r rule; do + [[ -n "$rule" ]] && emit project rule "$rule" + done < <(find ".claude/rules" -name "*.md" -type f 2>/dev/null | LC_ALL=C sort) +fi + +# --- user scope -------------------------------------------------------------- +# Same resolution as resolve-memory-dir.sh: CLAUDE_CONFIG_DIR relocates the whole +# `~/.claude` tree when set, so the instruction surfaces move with it. +config_root="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" + +if [[ -n "$config_root" && -d "$config_root" ]]; then + [[ -f "$config_root/CLAUDE.md" ]] && emit user claude-md "$config_root/CLAUDE.md" + + if [[ -d "$config_root/rules" ]]; then + while IFS= read -r rule; do + [[ -n "$rule" ]] && emit user rule "$rule" + done < <(find "$config_root/rules" -name "*.md" -type f 2>/dev/null | LC_ALL=C sort) + fi +fi + +exit 0 diff --git a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh new file mode 100755 index 0000000000..6ec8246a72 --- /dev/null +++ b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# Regression tests for discover-instruction-surfaces.sh (self-contained — ships with the plugin). +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/discover-instruction-surfaces.sh" + +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +FAILED=0 +CASE_NUM=0 + +pass() { + CASE_NUM=$((CASE_NUM + 1)) + printf 'PASS: %s\n' "$1" +} +fail() { + CASE_NUM=$((CASE_NUM + 1)) + FAILED=$((FAILED + 1)) + printf 'FAIL: %s\n detail: %s\n' "$1" "$2" >&2 +} +assert_contains() { + case "$2" in + *"$3"*) pass "$1" ;; + *) fail "$1" "expected to contain: $3" ;; + esac +} +assert_not_contains() { + case "$2" in + *"$3"*) fail "$1" "unexpected substring: $3" ;; + *) pass "$1" ;; + esac +} +assert_exit() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected exit $2, got $3"; fi +} + +# Every case runs with CLAUDE_CONFIG_DIR pointed at a fixture, so the suite never +# reads — or reports on — the real `~/.claude` on the machine running it. +run_in() { + # $1 = project dir, $2 = config dir, rest = script args + local proj="$1" conf="$2" + shift 2 + (cd "$proj" && CLAUDE_CONFIG_DIR="$conf" bash "$SCRIPT" "$@") +} + +# --- fixtures ---------------------------------------------------------------- + +PROJ="$TEST_TMPDIR/proj" +CONF="$TEST_TMPDIR/conf" +mkdir -p "$PROJ/.claude/rules" "$CONF/rules" + +printf '# project memory\n' >"$PROJ/CLAUDE.md" +printf '# personal overrides\n' >"$PROJ/CLAUDE.local.md" +printf '# a project rule\n' >"$PROJ/.claude/rules/project-rule.md" +printf '# nested, out of scope\n' >"$PROJ/.claude/rules/nested-not-a-rule.txt" +mkdir -p "$PROJ/sub" +printf '# subtree memory, out of scope\n' >"$PROJ/sub/CLAUDE.md" + +printf '# user memory\n' >"$CONF/CLAUDE.md" +printf '# a user rule\n' >"$CONF/rules/user-rule.md" + +# --- the regression this script exists for ----------------------------------- +# The old inline `find . -maxdepth 1` discovery could not see either user-scope +# surface. These two assertions fail against that implementation and pass here. + +OUT="$(run_in "$PROJ" "$CONF")" +RC=$? + +assert_exit "exits 0" 0 "$RC" +assert_contains "user-scope CLAUDE.md is discovered" "$OUT" "$CONF/CLAUDE.md" +assert_contains "user-scope CLAUDE.md carries the user tag" "$OUT" "$(printf 'user\tclaude-md\t%s' "$CONF/CLAUDE.md")" +assert_contains "user-scope rule is discovered" "$OUT" "$CONF/rules/user-rule.md" +assert_contains "user-scope rule carries the user tag" "$OUT" "$(printf 'user\trule\t%s' "$CONF/rules/user-rule.md")" + +# --- project scope still works, and is still tagged -------------------------- + +assert_contains "project CLAUDE.md is tagged project" "$OUT" "$(printf 'project\tclaude-md\tCLAUDE.md')" +assert_contains "CLAUDE.local.md gets its own kind" "$OUT" "$(printf 'project\tclaude-local-md\tCLAUDE.local.md')" +assert_contains "project rule is tagged project" "$OUT" "$(printf 'project\trule\t.claude/rules/project-rule.md')" + +# --- scope tagging is what lets C9 skip personal files ----------------------- +# C9 is project-scoped. The caller routes on the emitted scope, so a user-scope +# file must never be emitted with a project tag. + +assert_not_contains "user CLAUDE.md is never tagged project" "$OUT" "$(printf 'project\tclaude-md\t%s' "$CONF/CLAUDE.md")" + +# --- exclusions -------------------------------------------------------------- + +assert_not_contains "non-.md under rules is not discovered" "$OUT" "nested-not-a-rule.txt" +assert_not_contains "subtree CLAUDE.md is out of scope (depth 1 by design)" "$OUT" "sub/CLAUDE.md" + +# --- --scope filter ---------------------------------------------------------- + +OUT_USER="$(run_in "$PROJ" "$CONF" --scope user)" +assert_contains "--scope user emits user surfaces" "$OUT_USER" "$CONF/CLAUDE.md" +assert_not_contains "--scope user suppresses project surfaces" "$OUT_USER" "$(printf 'project\t')" + +OUT_PROJ="$(run_in "$PROJ" "$CONF" --scope project)" +assert_contains "--scope project emits project surfaces" "$OUT_PROJ" "$(printf 'project\tclaude-md\tCLAUDE.md')" +assert_not_contains "--scope project suppresses user surfaces" "$OUT_PROJ" "$(printf 'user\t')" + +# --- absent surfaces are absent, not errors ---------------------------------- + +EMPTY_PROJ="$TEST_TMPDIR/empty-proj" +EMPTY_CONF="$TEST_TMPDIR/empty-conf" +mkdir -p "$EMPTY_PROJ" "$EMPTY_CONF" + +OUT_EMPTY="$(run_in "$EMPTY_PROJ" "$EMPTY_CONF")" +RC_EMPTY=$? +assert_exit "exits 0 with nothing to report" 0 "$RC_EMPTY" +if [[ -z "$OUT_EMPTY" ]]; then + pass "emits nothing when no surface exists" +else + fail "emits nothing when no surface exists" "got: $OUT_EMPTY" +fi + +# A config root that does not exist at all must not crash the run. +OUT_NOCONF="$(run_in "$PROJ" "$TEST_TMPDIR/does-not-exist")" +RC_NOCONF=$? +assert_exit "exits 0 when the config root is absent" 0 "$RC_NOCONF" +assert_contains "still discovers project surfaces without a config root" "$OUT_NOCONF" "$(printf 'project\tclaude-md\tCLAUDE.md')" +assert_not_contains "emits no user rows without a config root" "$OUT_NOCONF" "$(printf 'user\t')" + +# --- Git Bash: a Windows-style config root with a drive letter --------------- +# HOME and CLAUDE_CONFIG_DIR are Windows paths on Git Bash. The resolver siblings +# carry explicit workarounds for this; discovery must not regress it. + +if command -v cygpath >/dev/null 2>&1; then + WIN_CONF="$(cygpath -w "$CONF" 2>/dev/null)" + if [[ -n "$WIN_CONF" ]]; then + OUT_WIN="$(run_in "$PROJ" "$WIN_CONF" --scope user)" + assert_contains "discovers user CLAUDE.md under a Windows-form config root" "$OUT_WIN" "CLAUDE.md" + assert_contains "tags it user under a Windows-form config root" "$OUT_WIN" "$(printf 'user\tclaude-md\t')" + else + pass "skipped: cygpath produced no Windows path" + fi +else + pass "skipped: cygpath unavailable (not Git Bash)" +fi + +# --- unknown argument is advisory, not fatal --------------------------------- + +run_in "$PROJ" "$CONF" --nonsense >/dev/null 2>&1 +assert_exit "unknown argument still exits 0 (advisory contract)" 0 "$?" + +printf '\n%d checks run, %d failed\n' "$CASE_NUM" "$FAILED" +if [[ "$FAILED" -gt 0 ]]; then + printf 'RESULT: FAIL\n' + exit 1 +fi +printf 'RESULT: PASS — all %d checks passed.\n' "$CASE_NUM" +exit 0 From d0eee422f978ac9364205e94a35128b09cdaa39a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:51:57 -0400 Subject: [PATCH 2/4] fix(claude-memory,claude-config): dedupe the dotfiles scope overlap, respect path-scoped rules, and name the directory a non-git refusal refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all real. The dotfiles case eats its own tail. When the project root IS the home directory — the same target shape this PR's audit-pass half calls ordinary — `.claude/rules` relative to the cwd and ~/.claude/rules are the SAME directory, so discovery emitted every rule twice under two path spellings: a duplicate finding per rule, and a Step 3 comparison of a file against itself. The two roots are now compared canonically and, where they coincide, each file is emitted once with scope `both`. It satisfies either --scope filter, because the file really is reachable by each layer. CLAUDE.md cannot collide this way — project discovery is depth-1 at the cwd while the user copy sits inside the config root — and a case pins that. Path-scoped rules are not always-loaded. A user rule carrying `paths:` is absent until a matching file is read, so "a user rule loads every session" over-claimed and would have manufactured repo-relative currency, redundancy and contradiction findings for rules that cannot apply here. Step 2 and the Step 3 comparison now say to establish co-residency from the rule's own frontmatter first. And the non-git refusal could not name what it refused in the case it exists for. With no explicit target and no CLAUDE_PROJECT_DIR the documented default is git rev-parse --show-toplevel, which fails outside a repository — so there was no resolved root to put in the message. The diagnostic falls back to the current directory. That fallback is for the message only and never becomes a target. Suite grows 23 -> 32 checks. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/claude-config/CHANGELOG.md | 12 +++-- .../claude-config/skills/audit-pass/SKILL.md | 11 +++- .../skills/audit-pass/evals/evals.json | 2 +- plugins/claude-memory/CHANGELOG.md | 14 ++++- .../skills/audit/context/audit.md | 16 ++++-- .../scripts/discover-instruction-surfaces.sh | 52 +++++++++++++++++-- .../discover-instruction-surfaces.test.sh | 37 +++++++++++++ 7 files changed, 129 insertions(+), 15 deletions(-) diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index b904c6d630..3409d9de53 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -51,9 +51,15 @@ repository rooted at or above `$HOME`, which a dotfiles repo is. **This closes a target class, deliberately.** The alternative was specifying all five branches, which obliges the contract to promise a capability it cannot deliver on that class. The refusal reuses the - existing channel — non-zero, before Phase 0, naming the resolved path and the reason — rather than - minting a new exit code, and it states the cost out loud, naming the suppression consequence in - particular, so it does not read as an arbitrary restriction. + existing channel — non-zero, before Phase 0, naming the path and the reason — rather than minting a + new exit code, and it states the cost out loud, naming the suppression consequence in particular, so + it does not read as an arbitrary restriction. + + **It names the directory even when resolution produced nothing**, which is the case the refusal is + most for: on a bare invocation with no `${CLAUDE_PROJECT_DIR}`, the documented default is + `git rev-parse --show-toplevel`, and that fails outside a repository — leaving no resolved root to put + in the message. The diagnostic falls back to the current directory so the refusal can say what it + refused. That fallback is for the message only and never becomes a target. ### Added diff --git a/plugins/claude-config/skills/audit-pass/SKILL.md b/plugins/claude-config/skills/audit-pass/SKILL.md index 5170f43829..be4bcfd44d 100644 --- a/plugins/claude-config/skills/audit-pass/SKILL.md +++ b/plugins/claude-config/skills/audit-pass/SKILL.md @@ -59,8 +59,15 @@ Parse `$ARGUMENTS`: and the report are already keyed on the resolved root. **The gate enforces both halves of that first sentence: the active project root, *and* a git - repository.** A resolved `target` that is not inside a git repository is refused the same way — - non-zero, before Phase 0 does any work, naming the resolved path and the reason, writing nothing. + repository.** A `target` that is not inside a git repository is refused the same way — non-zero, + before Phase 0 does any work, naming the path and the reason, writing nothing. + + **Name the directory, not an empty string.** In the case this refusal is *for*, the default + resolution above produces nothing: with no explicit `target` and no `${CLAUDE_PROJECT_DIR}`, + `git rev-parse --show-toplevel` fails outside a repository and there is no resolved root to report. + So for the diagnostic only, fall back to the current directory and name **that** — a refusal that + cannot say which path it refused is barely better than a silent one. The fallback is for the message; + it never becomes a target. Requiring only "the active project root" let a non-git directory through into a contract with no branch for it, and the run then went quiet in five places rather than one: diff --git a/plugins/claude-config/skills/audit-pass/evals/evals.json b/plugins/claude-config/skills/audit-pass/evals/evals.json index c02c3e49a4..b17e3e1eb5 100644 --- a/plugins/claude-config/skills/audit-pass/evals/evals.json +++ b/plugins/claude-config/skills/audit-pass/evals/evals.json @@ -366,7 +366,7 @@ "files": [], "expectations": [ "Refuses the non-git target and exits non-zero rather than running a partial or degraded pass", - "Names the resolved path and the reason, in the same shape as the existing not-the-active-project-root refusal", + "Names the directory it refused and the reason, in the same shape as the existing not-the-active-project-root refusal — and names it even on a bare invocation where git rev-parse --show-toplevel produced nothing, falling back to the current directory for the message", "Refuses BEFORE doing any Phase 0 work, and writes nothing anywhere", "Names at least the suppression consequence — the team layer is the tracked layer, so no suppression is enactable without a repository — rather than refusing without a stated cost", "Does NOT invent a non-git state key, scan baseline, or Class 3 fallback in order to proceed" diff --git a/plugins/claude-memory/CHANGELOG.md b/plugins/claude-memory/CHANGELOG.md index bb68fd03ff..933b1cba92 100644 --- a/plugins/claude-memory/CHANGELOG.md +++ b/plugins/claude-memory/CHANGELOG.md @@ -35,8 +35,20 @@ The audit now covers two surfaces it never could before, which is why this is a project-scoped and its own criteria row says to skip personal files, so an unscoped widening would fire C9 on `~/.claude/CLAUDE.md` and FAIL it for not stating a repo's build and test commands. Step 2 now routes on the emitted scope, and the R-checks apply at both scopes because a user rule loads every - session in every project. 23 checks in the sibling `*.test.sh` style, including the Git Bash case where + session in every project. 32 checks in the sibling `*.test.sh` style, including the Git Bash case where the config root is a Windows path with a drive letter. +- **A third scope value, `both`, for the dotfiles case.** When the project root *is* the home directory — + the same target shape the sibling `audit-pass` fix calls ordinary — `.claude/rules` relative to the cwd + and `~/.claude/rules` are the **same directory**, so a naive widening emits every rule twice under two + path spellings: a duplicate finding per rule, and a cross-scope comparison of a file against itself. + The two roots are compared canonically and, where they coincide, each file is emitted once as `both`. + It satisfies either `--scope` filter, because the file really is reachable by each layer. `CLAUDE.md` + cannot collide this way — project discovery is depth-1 at the cwd while the user copy sits inside the + config root — and a case pins that too. +- **Path-scoped rules are not assumed loaded.** A user rule carrying `paths:` frontmatter is absent until + a matching file is read, so a repo-relative currency or redundancy finding against one is valid only + where its `paths:` can match in *this* project. Step 2 and the Step 3 comparison both say to establish + co-residency first rather than treating every discovered user rule as live here. - **Step 3 gains a cross-scope consistency pass.** Both layers load together, so a user instruction that contradicts a project one is a live conflict rather than a layering choice, and one the project already states is redundant context on every run. The report names which scope each side came from, because the diff --git a/plugins/claude-memory/skills/audit/context/audit.md b/plugins/claude-memory/skills/audit/context/audit.md index 7221bbe576..3471b9cc52 100644 --- a/plugins/claude-memory/skills/audit/context/audit.md +++ b/plugins/claude-memory/skills/audit/context/audit.md @@ -38,8 +38,15 @@ each discovered file. Apply by entity type: repo-scoped". This is the reason Step 1 emits a scope tag. A user-scope `CLAUDE.md` carrying no build and test commands is correct, not a FAIL, and reporting one there would be a false positive manufactured by the wider discovery -- **R1-R4**: `.claude/rules/` files, at either scope — a `user`-scope rule loads in every session, so - the always-loaded cost R-checks are about applies to it at least as strongly as to a project rule +- **R1-R4**: `.claude/rules/` files, at either scope. **Read the rule's `paths:` frontmatter before + applying a check that assumes it is loaded.** An always-loaded user rule (no `paths:`) costs context + in every session of every project, so the R-checks apply to it at least as strongly as to a project + rule. A *path-scoped* user rule is absent until a matching file is read, so a repo-relative currency + or redundancy finding against one is only valid where its `paths:` can match in **this** project — + check that first rather than assuming co-residency +- **Scope `both`**: one physical file both layers reach, which happens when the project root is the + home directory (a dotfiles repo) and `.claude/rules` *is* `~/.claude/rules`. Discovery emits it once + with this tag. Report it once, and never compare it against itself in Step 3 - **C7/R3 (currency)**: version pins and counts are checked against the repo's own pin files (`global.json`, `.nvmrc`, `.python-version`, `.mcp.json`, or ecosystem equivalents). File-path-existence currency is **agent judgment**: read each path reference in context — instructional files cite @@ -83,7 +90,10 @@ After per-file checks, cross-reference: here, so a user instruction that contradicts a project one is a live conflict rather than a layering choice, and a user instruction the project already states is redundant context on every run. Report the contradiction against the pair, and say which scope each side came from — the - resolution differs, since only one of the two is yours to edit on behalf of the repo + resolution differs, since only one of the two is yours to edit on behalf of the repo. + **Two exclusions.** A `both`-scoped file is one file, not a pair: never compare it with itself. And + a path-scoped user rule only co-resides where its `paths:` can match here, so establish that before + calling it a contradiction or a redundancy ## Step 4: Generate report diff --git a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh index 264dd702a3..738dce77ab 100755 --- a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh +++ b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh @@ -27,9 +27,14 @@ # discover-instruction-surfaces.sh --help # # Output columns: -# scope project | user +# scope project | user | both # kind claude-md | claude-local-md | rule # path absolute for user scope, as-found for project scope +# +# `both` means one PHYSICAL file that both layers reach — the dotfiles case, where the +# project root is the home directory and `.claude/rules` IS `~/.claude/rules`. Such a +# file is emitted once, never twice under two path spellings, so it cannot produce a +# duplicate finding or be compared against itself in the cross-scope pass. set -uo pipefail @@ -47,6 +52,8 @@ Emits one TAB-separated record per file: scope project — CLAUDE.md / CLAUDE.local.md at the current root, and .claude/rules/*.md user — ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/CLAUDE.md and .../rules/*.md + both — one physical file both layers reach (project root IS the config + root's parent, i.e. a home-directory dotfiles repo). Emitted once. kind claude-md | claude-local-md | rule User-scope files load in EVERY session regardless of where the session starts, so they are @@ -71,14 +78,46 @@ done emit() { # $1 scope, $2 kind, $3 path + # A `both` record satisfies every filter: the file really is reachable by each layer, + # so suppressing it from either view would hide a surface that view is about. case "$SCOPE_FILTER" in all) ;; "$1") ;; - *) return 0 ;; + *) [[ "$1" == "both" ]] || return 0 ;; esac printf '%s\t%s\t%s\n' "$1" "$2" "$3" } +# Canonical physical path of a directory, or empty when it does not resolve. +# `pwd -P` because the two rules dirs can be the SAME directory reached by two +# different strings — see the overlap note below. +canon_dir() { + [[ -d "$1" ]] || return 0 + (cd "$1" 2>/dev/null && pwd -P) || true +} + +# --- scope overlap ----------------------------------------------------------- +# When the audited project root IS the home directory — a dotfiles repo, which the +# sibling audit-pass contract calls an ordinary target — `.claude/rules` relative to +# the cwd and `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/rules` are the SAME directory. The +# same physical rule would otherwise be emitted twice under two different path +# strings, producing a duplicate finding per rule and a cross-scope comparison of a +# file against itself. So the two are compared canonically and, where they coincide, +# each file is emitted ONCE with scope `both`. +# +# Only rules can collide. Project CLAUDE.md discovery is depth-1 at the cwd, while the +# user copy lives at `/CLAUDE.md` — a different file even when the repo +# root is `$HOME`. +config_root="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" + +proj_rules_canon="$(canon_dir ".claude/rules")" +user_rules_canon="$(canon_dir "$config_root/rules")" + +rules_overlap=0 +if [[ -n "$proj_rules_canon" && "$proj_rules_canon" == "$user_rules_canon" ]]; then + rules_overlap=1 +fi + # --- project scope ----------------------------------------------------------- # Depth 1 by design: CLAUDE.md files nested deeper are subtree memory that loads only # on demand, and are not this checklist's subject. @@ -87,20 +126,23 @@ emit() { [[ -f "CLAUDE.local.md" ]] && emit project claude-local-md "CLAUDE.local.md" if [[ -d ".claude/rules" ]]; then + proj_rule_scope=project + [[ "$rules_overlap" -eq 1 ]] && proj_rule_scope=both while IFS= read -r rule; do - [[ -n "$rule" ]] && emit project rule "$rule" + [[ -n "$rule" ]] && emit "$proj_rule_scope" rule "$rule" done < <(find ".claude/rules" -name "*.md" -type f 2>/dev/null | LC_ALL=C sort) fi # --- user scope -------------------------------------------------------------- # Same resolution as resolve-memory-dir.sh: CLAUDE_CONFIG_DIR relocates the whole # `~/.claude` tree when set, so the instruction surfaces move with it. -config_root="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" if [[ -n "$config_root" && -d "$config_root" ]]; then [[ -f "$config_root/CLAUDE.md" ]] && emit user claude-md "$config_root/CLAUDE.md" - if [[ -d "$config_root/rules" ]]; then + # Suppressed entirely when the two rules dirs coincide — those files were already + # emitted above, once, as `both`. + if [[ -d "$config_root/rules" && "$rules_overlap" -eq 0 ]]; then while IFS= read -r rule; do [[ -n "$rule" ]] && emit user rule "$rule" done < <(find "$config_root/rules" -name "*.md" -type f 2>/dev/null | LC_ALL=C sort) diff --git a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh index 6ec8246a72..15fe71e1cd 100755 --- a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh +++ b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh @@ -140,6 +140,43 @@ else pass "skipped: cygpath unavailable (not Git Bash)" fi +# --- the dotfiles case: project root IS the config root's parent ------------- +# `.claude/rules` relative to cwd and $CONFIG/rules are then the SAME directory. +# Emitting each file twice under two path spellings would produce a duplicate +# finding per rule and a cross-scope comparison of a file against itself. + +HOMEREPO="$TEST_TMPDIR/homerepo" +mkdir -p "$HOMEREPO/.claude/rules" +printf '# shared rule\n' >"$HOMEREPO/.claude/rules/shared-rule.md" +printf '# user memory in a dotfiles repo\n' >"$HOMEREPO/.claude/CLAUDE.md" +printf '# repo root memory\n' >"$HOMEREPO/CLAUDE.md" + +OUT_HOME="$(run_in "$HOMEREPO" "$HOMEREPO/.claude")" + +RULE_ROWS="$(printf '%s\n' "$OUT_HOME" | grep -c 'shared-rule\.md' || true)" +if [[ "$RULE_ROWS" == "1" ]]; then + pass "overlapping rules dir emits each rule exactly once" +else + fail "overlapping rules dir emits each rule exactly once" "got $RULE_ROWS rows: $OUT_HOME" +fi +assert_contains "the overlapping rule is tagged both" "$OUT_HOME" "$(printf 'both\trule\t.claude/rules/shared-rule.md')" +assert_not_contains "the overlapping rule is not also tagged project" "$OUT_HOME" "$(printf 'project\trule\t')" +assert_not_contains "the overlapping rule is not also tagged user" "$OUT_HOME" "$(printf 'user\trule\t')" + +# CLAUDE.md cannot collide even here: project discovery is depth-1 at the cwd, and +# the user copy lives one level down inside the config root. +assert_contains "repo-root CLAUDE.md is still project" "$OUT_HOME" "$(printf 'project\tclaude-md\tCLAUDE.md')" +assert_contains "config-root CLAUDE.md is still user" "$OUT_HOME" "$(printf 'user\tclaude-md\t%s/CLAUDE.md' "$HOMEREPO/.claude")" + +# A `both` record satisfies either filter — the file really is reachable by each layer. +OUT_HOME_USER="$(run_in "$HOMEREPO" "$HOMEREPO/.claude" --scope user)" +assert_contains "--scope user still shows the both-tagged rule" "$OUT_HOME_USER" "shared-rule.md" +OUT_HOME_PROJ="$(run_in "$HOMEREPO" "$HOMEREPO/.claude" --scope project)" +assert_contains "--scope project still shows the both-tagged rule" "$OUT_HOME_PROJ" "shared-rule.md" + +# The non-overlapping fixture must NOT regress into `both`. +assert_not_contains "distinct rules dirs never produce a both tag" "$OUT" "$(printf 'both\t')" + # --- unknown argument is advisory, not fatal --------------------------------- run_in "$PROJ" "$CONF" --nonsense >/dev/null 2>&1 From 57fd12229752dc8ebd7ad2331117053e71036d23 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:09:30 -0400 Subject: [PATCH 3/4] fix(claude-memory): canonicalize CLAUDE.md too, and say which CLAUDE.md R1 compares against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My own comment asserted CLAUDE.md could not collide across scopes, and it was wrong. It covered only the repo-rooted-at-~ layout, where the depth-1 CLAUDE.md and /CLAUDE.md really are distinct. In the other common dotfiles layout — ~/.claude itself tracked as the repo — they are the same physical file, and the rules-only overlap guard missed it, so that file was emitted twice. Both roots are now compared canonically, files as well as directories, via a canon_file that resolves the containing dir and re-appends the basename rather than depending on readlink -f. Six cases pin the layout, including that a normal distinct-root project never regresses into a both-tagged CLAUDE.md. Separately, R1 asks whether a rule duplicates "CLAUDE.md" — unambiguous while only one could ever be in scope, ambiguous the moment this PR put two there. R1 now pairs within a scope: a user rule against the user CLAUDE.md, a project rule against the project one, because R1 is a redundancy the owner of that layer fixes by deleting one of the two and only a same-scope pair is theirs to fix. Cross-scope overlap stays with Step 3, which reports it against the pair and names each side; routing it through R1 as well would report one overlap twice and address it to the wrong person. Suite 32 -> 38 checks. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/claude-memory/CHANGELOG.md | 30 +++++--- .../skills/audit/context/audit.md | 10 ++- .../skills/audit/reference/criteria.md | 9 +++ .../scripts/discover-instruction-surfaces.sh | 72 +++++++++++++------ .../discover-instruction-surfaces.test.sh | 34 ++++++++- 5 files changed, 119 insertions(+), 36 deletions(-) diff --git a/plugins/claude-memory/CHANGELOG.md b/plugins/claude-memory/CHANGELOG.md index 933b1cba92..9c4b9cd672 100644 --- a/plugins/claude-memory/CHANGELOG.md +++ b/plugins/claude-memory/CHANGELOG.md @@ -34,21 +34,29 @@ The audit now covers two surfaces it never could before, which is why this is a Widening discovery without that would have traded under-coverage for a false positive — C9 is project-scoped and its own criteria row says to skip personal files, so an unscoped widening would fire C9 on `~/.claude/CLAUDE.md` and FAIL it for not stating a repo's build and test commands. Step 2 now - routes on the emitted scope, and the R-checks apply at both scopes because a user rule loads every - session in every project. 32 checks in the sibling `*.test.sh` style, including the Git Bash case where - the config root is a Windows path with a drive letter. -- **A third scope value, `both`, for the dotfiles case.** When the project root *is* the home directory — - the same target shape the sibling `audit-pass` fix calls ordinary — `.claude/rules` relative to the cwd - and `~/.claude/rules` are the **same directory**, so a naive widening emits every rule twice under two - path spellings: a duplicate finding per rule, and a cross-scope comparison of a file against itself. - The two roots are compared canonically and, where they coincide, each file is emitted once as `both`. - It satisfies either `--scope` filter, because the file really is reachable by each layer. `CLAUDE.md` - cannot collide this way — project discovery is depth-1 at the cwd while the user copy sits inside the - config root — and a case pins that too. + routes on the emitted scope, and the R-checks apply at both scopes — an always-loaded user rule costs + context in every session of every project, so they apply to it at least as strongly as to a project + rule. 38 checks in the sibling `*.test.sh` style, including the Git Bash case where the config root is + a Windows path with a drive letter. +- **A third scope value, `both`, for the two dotfiles layouts where one physical file is reachable by + each layer.** A repo rooted at `~` — the same target shape the sibling `audit-pass` fix calls ordinary + — makes `.claude/rules` relative to the cwd and `~/.claude/rules` the **same directory**. A repo rooted + at `~/.claude` itself, an equally common layout, additionally makes the depth-1 `CLAUDE.md` and + `~/.claude/CLAUDE.md` the **same file**. Either way a naive widening emits the file twice under two + path spellings: a duplicate finding, and a cross-scope comparison of a file against itself. Paths are + now canonicalized and compared, and where they coincide the file is emitted once as `both`, which + satisfies either `--scope` filter because the file really is reachable by each layer. - **Path-scoped rules are not assumed loaded.** A user rule carrying `paths:` frontmatter is absent until a matching file is read, so a repo-relative currency or redundancy finding against one is valid only where its `paths:` can match in *this* project. Step 2 and the Step 3 comparison both say to establish co-residency first rather than treating every discovered user rule as live here. +- **R1 says which `CLAUDE.md` it compares against.** "Does this rule duplicate content already in + CLAUDE.md?" was unambiguous while only one could ever be in scope; with two it was not. R1 now pairs + within a scope — a user rule against the user `CLAUDE.md`, a project rule against the project one — + because R1 is a redundancy the owner of that layer fixes by deleting one of the two, and only a + same-scope pair is theirs to fix. Cross-scope overlap is real and belongs to the Step 3 pass, which + reports it against the pair and names each side's scope; routing it through R1 as well would report + one overlap twice and address it to the wrong person. - **Step 3 gains a cross-scope consistency pass.** Both layers load together, so a user instruction that contradicts a project one is a live conflict rather than a layering choice, and one the project already states is redundant context on every run. The report names which scope each side came from, because the diff --git a/plugins/claude-memory/skills/audit/context/audit.md b/plugins/claude-memory/skills/audit/context/audit.md index 3471b9cc52..6587f56745 100644 --- a/plugins/claude-memory/skills/audit/context/audit.md +++ b/plugins/claude-memory/skills/audit/context/audit.md @@ -44,9 +44,13 @@ each discovered file. Apply by entity type: rule. A *path-scoped* user rule is absent until a matching file is read, so a repo-relative currency or redundancy finding against one is only valid where its `paths:` can match in **this** project — check that first rather than assuming co-residency -- **Scope `both`**: one physical file both layers reach, which happens when the project root is the - home directory (a dotfiles repo) and `.claude/rules` *is* `~/.claude/rules`. Discovery emits it once - with this tag. Report it once, and never compare it against itself in Step 3 +- **R1 pairs within a scope.** A user rule's duplication check runs against the *user* `CLAUDE.md`, a + project rule's against the *project* one — see R1's "Which CLAUDE.md" note. Cross-scope overlap is + Step 3's, not R1's, or one overlap gets reported twice +- **Scope `both`**: one physical file that both layers reach. Two dotfiles layouts produce it — a repo + rooted at `~` (where `.claude/rules` *is* `~/.claude/rules`) and a repo rooted at `~/.claude` itself + (where the depth-1 `CLAUDE.md` *is* `~/.claude/CLAUDE.md`). Discovery emits such a file once with + this tag. Report it once, and never compare it against itself in Step 3 - **C7/R3 (currency)**: version pins and counts are checked against the repo's own pin files (`global.json`, `.nvmrc`, `.python-version`, `.mcp.json`, or ecosystem equivalents). File-path-existence currency is **agent judgment**: read each path reference in context — instructional files cite diff --git a/plugins/claude-memory/skills/audit/reference/criteria.md b/plugins/claude-memory/skills/audit/reference/criteria.md index ebf766d26d..97f42c861c 100644 --- a/plugins/claude-memory/skills/audit/reference/criteria.md +++ b/plugins/claude-memory/skills/audit/reference/criteria.md @@ -266,6 +266,15 @@ keeps this check from flagging a repo that followed the other half of the same p **How to check**: Compare rule content against CLAUDE.md sections. Flag significant overlap. +**Which CLAUDE.md** — two can be in scope now, so name the pairing rather than leaving it to be +guessed. Compare against the one at the rule's **own scope**: a project rule against the project +`CLAUDE.md`, a user rule against the user `CLAUDE.md`, a `both`-scoped rule against whichever +`CLAUDE.md` shares its scope. R1 is a redundancy the owner of that layer fixes by deleting one of the +two, and only a same-scope pair is theirs to fix. Overlap **across** scopes is real and is not R1 — the +audit workflow's cross-scope consistency step owns it, reports it against the pair, and names which +side each came from. Routing it here as well would report one overlap twice and address it to the +wrong person. + ### R2: Path Scoping Fit [INFO] **What**: Should this rule carry `paths:` frontmatter so it loads only when matching files are read? diff --git a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh index 738dce77ab..6352333530 100755 --- a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh +++ b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh @@ -31,10 +31,12 @@ # kind claude-md | claude-local-md | rule # path absolute for user scope, as-found for project scope # -# `both` means one PHYSICAL file that both layers reach — the dotfiles case, where the -# project root is the home directory and `.claude/rules` IS `~/.claude/rules`. Such a -# file is emitted once, never twice under two path spellings, so it cannot produce a -# duplicate finding or be compared against itself in the cross-scope pass. +# `both` means one PHYSICAL file that both layers reach. Two dotfiles layouts do this: +# a repo rooted at `~` (where `.claude/rules` IS `~/.claude/rules`), and a repo rooted +# at `~/.claude` itself (where `CLAUDE.md` at depth 1 IS `~/.claude/CLAUDE.md`, and the +# rules dirs coincide too). Such a file is emitted once, never twice under two path +# spellings, so it cannot produce a duplicate finding or be compared against itself in +# the cross-scope pass. set -uo pipefail @@ -52,8 +54,8 @@ Emits one TAB-separated record per file: scope project — CLAUDE.md / CLAUDE.local.md at the current root, and .claude/rules/*.md user — ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/CLAUDE.md and .../rules/*.md - both — one physical file both layers reach (project root IS the config - root's parent, i.e. a home-directory dotfiles repo). Emitted once. + both — one physical file both layers reach (a repo rooted at ~, or one + rooted at ~/.claude itself). Emitted once, not twice. kind claude-md | claude-local-md | rule User-scope files load in EVERY session regardless of where the session starts, so they are @@ -89,25 +91,39 @@ emit() { } # Canonical physical path of a directory, or empty when it does not resolve. -# `pwd -P` because the two rules dirs can be the SAME directory reached by two -# different strings — see the overlap note below. +# `pwd -P` because two dirs can be the SAME directory reached by two different +# strings — see the overlap note below. canon_dir() { [[ -d "$1" ]] || return 0 (cd "$1" 2>/dev/null && pwd -P) || true } +# Canonical physical path of a FILE, or empty. Resolves the containing directory and +# re-appends the basename, so it works without readlink -f (absent on some platforms). +canon_file() { + [[ -f "$1" ]] || return 0 + local d b + d="$(canon_dir "$(dirname "$1")")" + [[ -n "$d" ]] || return 0 + b="$(basename "$1")" + printf '%s/%s' "$d" "$b" +} + # --- scope overlap ----------------------------------------------------------- -# When the audited project root IS the home directory — a dotfiles repo, which the -# sibling audit-pass contract calls an ordinary target — `.claude/rules` relative to -# the cwd and `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/rules` are the SAME directory. The -# same physical rule would otherwise be emitted twice under two different path -# strings, producing a duplicate finding per rule and a cross-scope comparison of a -# file against itself. So the two are compared canonically and, where they coincide, -# each file is emitted ONCE with scope `both`. +# Two layouts make a project-scope path and a user-scope path the SAME physical file, +# and both are real. Emitting such a file twice under two path strings would produce a +# duplicate finding and a cross-scope comparison of a file against itself, so wherever +# the canonical paths coincide the file is emitted ONCE with scope `both`. # -# Only rules can collide. Project CLAUDE.md discovery is depth-1 at the cwd, while the -# user copy lives at `/CLAUDE.md` — a different file even when the repo -# root is `$HOME`. +# 1. Project root IS the home directory (a `~` dotfiles repo — the shape the sibling +# audit-pass contract calls an ordinary target). Then `.claude/rules` relative to +# the cwd and `/rules` are one directory. +# 2. Project root IS the config root (`~/.claude` itself tracked as the repo, which +# is a common dotfiles layout). Then `CLAUDE.md` at depth 1 and +# `/CLAUDE.md` are one file — and so are the two rules dirs. +# +# Case 2 is why the CLAUDE.md comparison exists rather than being argued away: it is +# only the `~`-rooted case that leaves the two CLAUDE.md files distinct. config_root="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" proj_rules_canon="$(canon_dir ".claude/rules")" @@ -118,11 +134,23 @@ if [[ -n "$proj_rules_canon" && "$proj_rules_canon" == "$user_rules_canon" ]]; t rules_overlap=1 fi +proj_md_canon="$(canon_file "CLAUDE.md")" +user_md_canon="$(canon_file "$config_root/CLAUDE.md")" + +md_overlap=0 +if [[ -n "$proj_md_canon" && "$proj_md_canon" == "$user_md_canon" ]]; then + md_overlap=1 +fi + # --- project scope ----------------------------------------------------------- # Depth 1 by design: CLAUDE.md files nested deeper are subtree memory that loads only # on demand, and are not this checklist's subject. -[[ -f "CLAUDE.md" ]] && emit project claude-md "CLAUDE.md" +if [[ -f "CLAUDE.md" ]]; then + proj_md_scope=project + [[ "$md_overlap" -eq 1 ]] && proj_md_scope=both + emit "$proj_md_scope" claude-md "CLAUDE.md" +fi [[ -f "CLAUDE.local.md" ]] && emit project claude-local-md "CLAUDE.local.md" if [[ -d ".claude/rules" ]]; then @@ -138,7 +166,11 @@ fi # `~/.claude` tree when set, so the instruction surfaces move with it. if [[ -n "$config_root" && -d "$config_root" ]]; then - [[ -f "$config_root/CLAUDE.md" ]] && emit user claude-md "$config_root/CLAUDE.md" + # Suppressed when it is the same physical file as the project one, already emitted + # above as `both`. + if [[ -f "$config_root/CLAUDE.md" && "$md_overlap" -eq 0 ]]; then + emit user claude-md "$config_root/CLAUDE.md" + fi # Suppressed entirely when the two rules dirs coincide — those files were already # emitted above, once, as `both`. diff --git a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh index 15fe71e1cd..ff275ebc65 100755 --- a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh +++ b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh @@ -163,11 +163,41 @@ assert_contains "the overlapping rule is tagged both" "$OUT_HOME" "$(printf 'bot assert_not_contains "the overlapping rule is not also tagged project" "$OUT_HOME" "$(printf 'project\trule\t')" assert_not_contains "the overlapping rule is not also tagged user" "$OUT_HOME" "$(printf 'user\trule\t')" -# CLAUDE.md cannot collide even here: project discovery is depth-1 at the cwd, and -# the user copy lives one level down inside the config root. +# In THIS layout (repo rooted at ~) the two CLAUDE.md files really are distinct: +# project discovery is depth-1 at the cwd, the user copy is one level down. assert_contains "repo-root CLAUDE.md is still project" "$OUT_HOME" "$(printf 'project\tclaude-md\tCLAUDE.md')" assert_contains "config-root CLAUDE.md is still user" "$OUT_HOME" "$(printf 'user\tclaude-md\t%s/CLAUDE.md' "$HOMEREPO/.claude")" +# --- the OTHER dotfiles layout: the repo IS the config root ------------------ +# `~/.claude` tracked as the repo. Now `CLAUDE.md` at depth 1 and +# `$config_root/CLAUDE.md` are the SAME file, and the rules dirs coincide too. + +CONFREPO="$TEST_TMPDIR/confrepo" +mkdir -p "$CONFREPO/rules" +printf '# the one CLAUDE.md\n' >"$CONFREPO/CLAUDE.md" +printf '# the one rule\n' >"$CONFREPO/rules/only-rule.md" + +# cwd == config root: `.claude/rules` does not exist here, so only the CLAUDE.md +# collision is in play — which is exactly the case the rules-only guard missed. +OUT_CONF="$(run_in "$CONFREPO" "$CONFREPO")" + +MD_ROWS="$(printf '%s\n' "$OUT_CONF" | grep -c 'claude-md' || true)" +if [[ "$MD_ROWS" == "1" ]]; then + pass "repo rooted at the config root emits CLAUDE.md exactly once" +else + fail "repo rooted at the config root emits CLAUDE.md exactly once" "got $MD_ROWS rows: $OUT_CONF" +fi +assert_contains "the collided CLAUDE.md is tagged both" "$OUT_CONF" "$(printf 'both\tclaude-md\tCLAUDE.md')" +assert_not_contains "it is not also emitted as a user row" "$OUT_CONF" "$(printf 'user\tclaude-md\t')" +assert_not_contains "it is not also emitted as a project row" "$OUT_CONF" "$(printf 'project\tclaude-md\t')" + +# And it satisfies either filter, same as a both-tagged rule. +OUT_CONF_USER="$(run_in "$CONFREPO" "$CONFREPO" --scope user)" +assert_contains "--scope user shows the both-tagged CLAUDE.md" "$OUT_CONF_USER" "$(printf 'both\tclaude-md\t')" + +# A distinct-config layout must not regress into a both-tagged CLAUDE.md. +assert_not_contains "distinct roots never produce a both-tagged CLAUDE.md" "$OUT" "$(printf 'both\tclaude-md\t')" + # A `both` record satisfies either filter — the file really is reachable by each layer. OUT_HOME_USER="$(run_in "$HOMEREPO" "$HOMEREPO/.claude" --scope user)" assert_contains "--scope user still shows the both-tagged rule" "$OUT_HOME_USER" "shared-rule.md" From 02ea9cf190f953475f581987978a05ef2df41b41 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:19:36 -0400 Subject: [PATCH 4/4] fix(claude-memory): correct the per-layout collision claim, and give a both-scoped rule an R1 partner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two comment/doc errors, both mine, both caught by review reading the code against the prose rather than trusting it. The header claimed a repo rooted at ~/.claude collides CLAUDE.md "and so are the two rules dirs". It does not: project rules there resolve to /.claude/rules, which is not /rules. My own fixture already agreed with the code and not the comment. The truth is a clean asymmetry — each layout collides exactly ONE surface. A repo rooted at ~ collides the rules dir and leaves the two CLAUDE.md files distinct; a repo rooted at ~/.claude collides CLAUDE.md and leaves the rules dirs distinct. That is precisely why the two comparisons are computed independently rather than from one flag, and it is now stated that way and pinned in both directions instead of being asserted in a comment. The asymmetry also broke R1's new pairing rule. "A both-scoped rule against whichever CLAUDE.md shares its scope" has no target: both-scoped RULES arise only in the ~-rooted layout, and that is exactly the layout where no both-scoped CLAUDE.md exists. Such a rule now compares against every CLAUDE.md in scope, with each finding attributed to the scope of the one it overlapped — not double-reporting, because the rule really is loaded alongside both. Suite 38 -> 44 checks. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/claude-memory/CHANGELOG.md | 26 +++++++----- .../skills/audit/reference/criteria.md | 18 ++++++--- .../scripts/discover-instruction-surfaces.sh | 40 ++++++++++--------- .../discover-instruction-surfaces.test.sh | 24 +++++++++++ 4 files changed, 75 insertions(+), 33 deletions(-) diff --git a/plugins/claude-memory/CHANGELOG.md b/plugins/claude-memory/CHANGELOG.md index 9c4b9cd672..b157b57063 100644 --- a/plugins/claude-memory/CHANGELOG.md +++ b/plugins/claude-memory/CHANGELOG.md @@ -36,16 +36,21 @@ The audit now covers two surfaces it never could before, which is why this is a C9 on `~/.claude/CLAUDE.md` and FAIL it for not stating a repo's build and test commands. Step 2 now routes on the emitted scope, and the R-checks apply at both scopes — an always-loaded user rule costs context in every session of every project, so they apply to it at least as strongly as to a project - rule. 38 checks in the sibling `*.test.sh` style, including the Git Bash case where the config root is + rule. 44 checks in the sibling `*.test.sh` style, including the Git Bash case where the config root is a Windows path with a drive letter. - **A third scope value, `both`, for the two dotfiles layouts where one physical file is reachable by - each layer.** A repo rooted at `~` — the same target shape the sibling `audit-pass` fix calls ordinary - — makes `.claude/rules` relative to the cwd and `~/.claude/rules` the **same directory**. A repo rooted - at `~/.claude` itself, an equally common layout, additionally makes the depth-1 `CLAUDE.md` and - `~/.claude/CLAUDE.md` the **same file**. Either way a naive widening emits the file twice under two - path spellings: a duplicate finding, and a cross-scope comparison of a file against itself. Paths are - now canonicalized and compared, and where they coincide the file is emitted once as `both`, which - satisfies either `--scope` filter because the file really is reachable by each layer. + each layer.** A naive widening emits such a file twice under two path spellings: a duplicate finding, + and a cross-scope comparison of a file against itself. Paths are now canonicalized and compared, and + where they coincide the file is emitted once as `both`, which satisfies either `--scope` filter + because the file really is reachable by each layer. + + **Each layout collides exactly one surface, which is why the two comparisons are computed + independently rather than from one flag.** A repo rooted at `~` — the target shape the sibling + `audit-pass` fix calls ordinary — makes `.claude/rules` and `~/.claude/rules` the same **directory**, + while its two `CLAUDE.md` files stay distinct. A repo rooted at `~/.claude` itself makes the depth-1 + `CLAUDE.md` and `~/.claude/CLAUDE.md` the same **file**, while its rules dirs stay distinct — project + rules there resolve to `~/.claude/.claude/rules`, not `~/.claude/rules`. Cases pin the asymmetry in + both directions. - **Path-scoped rules are not assumed loaded.** A user rule carrying `paths:` frontmatter is absent until a matching file is read, so a repo-relative currency or redundancy finding against one is valid only where its `paths:` can match in *this* project. Step 2 and the Step 3 comparison both say to establish @@ -56,7 +61,10 @@ The audit now covers two surfaces it never could before, which is why this is a because R1 is a redundancy the owner of that layer fixes by deleting one of the two, and only a same-scope pair is theirs to fix. Cross-scope overlap is real and belongs to the Step 3 pass, which reports it against the pair and names each side's scope; routing it through R1 as well would report - one overlap twice and address it to the wrong person. + one overlap twice and address it to the wrong person. A `both`-scoped rule is the one case with no + same-scope partner — it arises only in the `~`-rooted layout, where the two `CLAUDE.md` files stay + distinct — so it compares against each `CLAUDE.md` in scope, attributing every finding to the scope of + the one it overlapped. - **Step 3 gains a cross-scope consistency pass.** Both layers load together, so a user instruction that contradicts a project one is a live conflict rather than a layering choice, and one the project already states is redundant context on every run. The report names which scope each side came from, because the diff --git a/plugins/claude-memory/skills/audit/reference/criteria.md b/plugins/claude-memory/skills/audit/reference/criteria.md index 97f42c861c..45a5024b7b 100644 --- a/plugins/claude-memory/skills/audit/reference/criteria.md +++ b/plugins/claude-memory/skills/audit/reference/criteria.md @@ -268,12 +268,18 @@ keeps this check from flagging a repo that followed the other half of the same p **Which CLAUDE.md** — two can be in scope now, so name the pairing rather than leaving it to be guessed. Compare against the one at the rule's **own scope**: a project rule against the project -`CLAUDE.md`, a user rule against the user `CLAUDE.md`, a `both`-scoped rule against whichever -`CLAUDE.md` shares its scope. R1 is a redundancy the owner of that layer fixes by deleting one of the -two, and only a same-scope pair is theirs to fix. Overlap **across** scopes is real and is not R1 — the -audit workflow's cross-scope consistency step owns it, reports it against the pair, and names which -side each came from. Routing it here as well would report one overlap twice and address it to the -wrong person. +`CLAUDE.md`, a user rule against the user `CLAUDE.md`. R1 is a redundancy the owner of that layer fixes +by deleting one of the two, and only a same-scope pair is theirs to fix. Overlap **across** scopes is +real and is not R1 — the audit workflow's cross-scope consistency step owns it, reports it against the +pair, and names which side each came from. Routing it here as well would report one overlap twice and +address it to the wrong person. + +**A `both`-scoped rule has no same-scope partner, so it pairs with each.** `both` means one physical +rule file that each layer loads, which arises in a `~`-rooted repo — and there the two `CLAUDE.md` +files stay distinct, so there is no `both`-scoped `CLAUDE.md` to pair against. Compare such a rule +against **every** `CLAUDE.md` in scope, and attribute each finding to the scope of the `CLAUDE.md` it +overlapped. That is not double-reporting: the rule is genuinely loaded alongside both, and a +duplication against either is a real redundancy for that layer. ### R2: Path Scoping Fit [INFO] diff --git a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh index 6352333530..2164b0eb6c 100755 --- a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh +++ b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.sh @@ -31,12 +31,12 @@ # kind claude-md | claude-local-md | rule # path absolute for user scope, as-found for project scope # -# `both` means one PHYSICAL file that both layers reach. Two dotfiles layouts do this: -# a repo rooted at `~` (where `.claude/rules` IS `~/.claude/rules`), and a repo rooted -# at `~/.claude` itself (where `CLAUDE.md` at depth 1 IS `~/.claude/CLAUDE.md`, and the -# rules dirs coincide too). Such a file is emitted once, never twice under two path -# spellings, so it cannot produce a duplicate finding or be compared against itself in -# the cross-scope pass. +# `both` means one PHYSICAL file that both layers reach. Two dotfiles layouts do this, +# each colliding exactly one surface: a repo rooted at `~` collides the RULES dir +# (`.claude/rules` IS `~/.claude/rules`), and a repo rooted at `~/.claude` itself +# collides CLAUDE.md (the depth-1 `CLAUDE.md` IS `~/.claude/CLAUDE.md`). Such a file is +# emitted once, never twice under two path spellings, so it cannot produce a duplicate +# finding or be compared against itself in the cross-scope pass. set -uo pipefail @@ -110,20 +110,24 @@ canon_file() { } # --- scope overlap ----------------------------------------------------------- -# Two layouts make a project-scope path and a user-scope path the SAME physical file, -# and both are real. Emitting such a file twice under two path strings would produce a -# duplicate finding and a cross-scope comparison of a file against itself, so wherever -# the canonical paths coincide the file is emitted ONCE with scope `both`. +# Two dotfiles layouts make a project-scope path and a user-scope path the SAME physical +# file. Emitting such a file twice under two path strings would produce a duplicate +# finding and a cross-scope comparison of a file against itself, so wherever the +# canonical paths coincide the file is emitted ONCE with scope `both`. # -# 1. Project root IS the home directory (a `~` dotfiles repo — the shape the sibling -# audit-pass contract calls an ordinary target). Then `.claude/rules` relative to -# the cwd and `/rules` are one directory. -# 2. Project root IS the config root (`~/.claude` itself tracked as the repo, which -# is a common dotfiles layout). Then `CLAUDE.md` at depth 1 and -# `/CLAUDE.md` are one file — and so are the two rules dirs. +# Each layout collides exactly ONE of the two surfaces, which is why both guards below +# are needed and neither can be argued away from the other: # -# Case 2 is why the CLAUDE.md comparison exists rather than being argued away: it is -# only the `~`-rooted case that leaves the two CLAUDE.md files distinct. +# 1. Project root IS the home directory (a `~`-rooted dotfiles repo — the shape the +# sibling audit-pass contract calls an ordinary target). +# rules: `.claude/rules` == `/rules` -> COLLIDES +# CLAUDE.md: `./CLAUDE.md` vs `/CLAUDE.md` -> distinct +# 2. Project root IS the config root (`~/.claude` itself tracked as the repo). +# CLAUDE.md: `./CLAUDE.md` == `/CLAUDE.md` -> COLLIDES +# rules: `.claude/rules` resolves to `/.claude/rules`, +# which is NOT `/rules` -> distinct +# +# So the two comparisons are computed independently rather than from one flag. config_root="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" proj_rules_canon="$(canon_dir ".claude/rules")" diff --git a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh index ff275ebc65..e3284ad011 100755 --- a/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh +++ b/plugins/claude-memory/skills/audit/scripts/discover-instruction-surfaces.test.sh @@ -198,6 +198,30 @@ assert_contains "--scope user shows the both-tagged CLAUDE.md" "$OUT_CONF_USER" # A distinct-config layout must not regress into a both-tagged CLAUDE.md. assert_not_contains "distinct roots never produce a both-tagged CLAUDE.md" "$OUT" "$(printf 'both\tclaude-md\t')" +# --- each layout collides exactly ONE surface, not both ---------------------- +# This asymmetry is why the two comparisons are computed independently rather than +# from one flag, so pin it in both directions. +# +# Layout 1 (repo at ~): rules collide, CLAUDE.md does not — asserted above via +# OUT_HOME (both-tagged rule, and project/user CLAUDE.md rows still separate). +assert_contains "layout 1 collides rules" "$OUT_HOME" "$(printf 'both\trule\t')" +assert_not_contains "layout 1 leaves CLAUDE.md distinct" "$OUT_HOME" "$(printf 'both\tclaude-md\t')" + +# Layout 2 (repo at ~/.claude): CLAUDE.md collides, rules do NOT — project rules +# would live at $config_root/.claude/rules, which is not $config_root/rules. +CONFREPO2="$TEST_TMPDIR/confrepo2" +mkdir -p "$CONFREPO2/rules" "$CONFREPO2/.claude/rules" +printf '# the one CLAUDE.md\n' >"$CONFREPO2/CLAUDE.md" +printf '# user-layer rule\n' >"$CONFREPO2/rules/user-layer.md" +printf '# project-layer rule, a genuinely different file\n' >"$CONFREPO2/.claude/rules/project-layer.md" + +OUT_CONF2="$(run_in "$CONFREPO2" "$CONFREPO2")" + +assert_contains "layout 2 collides CLAUDE.md" "$OUT_CONF2" "$(printf 'both\tclaude-md\tCLAUDE.md')" +assert_not_contains "layout 2 leaves the rules dirs distinct" "$OUT_CONF2" "$(printf 'both\trule\t')" +assert_contains "layout 2 emits the project-layer rule as project" "$OUT_CONF2" "$(printf 'project\trule\t.claude/rules/project-layer.md')" +assert_contains "layout 2 emits the user-layer rule as user" "$OUT_CONF2" "$(printf 'user\trule\t%s/rules/user-layer.md' "$CONFREPO2")" + # A `both` record satisfies either filter — the file really is reachable by each layer. OUT_HOME_USER="$(run_in "$HOMEREPO" "$HOMEREPO/.claude" --scope user)" assert_contains "--scope user still shows the both-tagged rule" "$OUT_HOME_USER" "shared-rule.md"