diff --git a/plugins/claude-config/.claude-plugin/plugin.json b/plugins/claude-config/.claude-plugin/plugin.json index 368f7fd8d..895eab797 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.27.1", + "version": "0.27.2", "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 a9399ea0f..5fcf62238 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -3,6 +3,81 @@ 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.27.2] + +### Fixed + +- **`conflict-scan.sh`: the coordinated-directive boundary honored only a subset of the mandate + tokens, so the most common phrasing silently dropped real conflicts.** `COORD_ERE` carried a + hand-copied token list that had fallen behind `MANDATE_ERE`: `use`, `present`, and `ask` were in + the classifier and absent from the coordinator. ``Never use `Bash` and use `Read` `` therefore + found no boundary, `Read` inherited the leading `never`, and its pair with ``Never use `Read` `` + went unreported — while ``Always use `Read` `` produced a false conflict from the same misreading. + `always` masked the gap throughout, being present in both lists. The coordinator is now COMPOSED + from the two classifier alternations rather than restated, so the divergence that caused this is + unrepresentable; two regression cases cover the bare-`use` and `present` forms. + +- **`audit-instructions`: the skill hardcoded `~/.claude` in the very paths its own rule forbids + hardcoding.** Phase A resolves the user root as `${CLAUDE_CONFIG_DIR:-~/.claude}` and says never to + hardcode it, yet the auto-memory entrypoint, the scope-filter list, and the memory-layer surface + list all named `~/.claude` literally. `CLAUDE_CONFIG_DIR` relocates the whole tree including + `projects/`, so a hardcoded default read a store the session no longer writes. Every operative path + now resolves against that root; quoted upstream text and the `${CLAUDE_CONFIG_DIR:-~/.claude}` form + itself are unchanged. + +- **`audit-instructions`: I3 named a `skills:` preload as a valid deferral destination, which defers + nothing.** The check rejects `@path` imports because they load unconditionally, then offered a + preload — but the full content of each skill named in an agent's `skills:` field is injected into + every dispatch, exactly the load profile the check exists to avoid, as the skill's own co-residency + table states. I3 now permits only conditional runtime invocation, and says to report that no safe + deferral is available rather than proposing a preload. + +- **`audit-instructions`: Phase A never inventoried a subagent's own memory, so a criterion it + declares in scope could not fire.** The co-residency table graded an agent-definition-versus-its-own + `memory` contradiction as real, but no inventory step reached that `MEMORY.md`. Phase A now + enumerates it per scope (`user` under the resolved user root, `project`, `local`), and the + co-residency table carries its own row. + +- **`audit-instructions`: the liveness gate resolved a closed five-input list that omitted hook + enablement.** A hook that cannot fire carries no live instruction text, so a pass comparing against + it grades a dead surface. The gate now resolves `disableAllHooks` **per settings scope** — a user, + project, or local disable cannot reach managed hooks, so managed hook text stays live and must not + be dropped with the rest — together with `allowManagedHooksOnly` and its force-enabled-plugin + exemption. + +- **`audit-instructions`: a nested project memory pair was routed to a check that never discovers the + file.** The skill routed any project-scope pair to `claude-memory`'s C6, which discovers with + `find . -maxdepth 1`, so a `src/api/CLAUDE.md` pair was graded by neither pass. The boundary is now + **root-level** project, matching the routing table the criteria reference already owned. Scoped + narrowly: `.claude/rules/**` still routes to C6, whose rules discovery is recursive. + +- **`conflict-scan.sh`: `and` coordinating an opposite directive was not a window boundary.** "Always + use `Read` and never use `Bash`" against "Never use `Read`" yielded zero candidates, because the + first entity's window swallowed the second directive's `never` and took its polarity; the same line + with `but never` yielded one. A **bare** `and` cannot be the boundary — "never use `Bash` and + `Grep`" is one directive over two objects, and cutting there strips the token governing the second. + The boundary therefore requires a polarity token after the coordinator, and is consumed + asymmetrically: a leading window resumes after the coordinator alone so that token still classifies + its entity. Three cases cover the fix and the false negative it must not introduce. + +- **`audit-instructions`: I14's startup set omitted `./.claude/CLAUDE.md`.** A project keeping its + memory there loads it at launch exactly as `./CLAUDE.md` would, so naming only the bare path let the + redundant read of the active file escape the check. Both supported root locations are now in the + set, matching what Phase A already inventories. + +- **`audit-instructions`: I14 exempted supporting documents unconditionally, ignoring startup + imports.** `@path` imports are expanded into context at launch, recursively, so a startup file + carrying `@AGENTS.md` or `@docs/CONTRIBUTING.md` makes that document resident and an instruction to + go read it is the redundant retrieval the check exists to find. The exemption now applies only to + what no active startup import reaches, resolved the way I15 already resolves imports. + +- **`setup` and the README: `awk` and `sort` were scoped to one skill and are used by three.** + `check-plugin-drift.sh` (both), `permission-rule-check.sh` (both), and `fix-plugin-drift.sh` + (`sort`) call them with no prerequisite check, so `audit` and `audit-permission-grants` fail + mid-run on a bare `command not found` rather than on a named prerequisite. Only + `conflict-scan.sh` probes and `exit 2`s. Both surfaces now name all three skills, and the README's + requirements section names `awk` and `sort` alongside `jq` and `curl`. + ## [0.27.1] ### Changed diff --git a/plugins/claude-config/README.md b/plugins/claude-config/README.md index b4b8aece9..12ca2d50b 100644 --- a/plugins/claude-config/README.md +++ b/plugins/claude-config/README.md @@ -187,9 +187,17 @@ from `raw.githubusercontent.com` (read-only; a failed fetch degrades to SKIP). ## Requirements The bundled scripts run in `bash` (Claude Code's Bash-tool shell on every platform; -[Git Bash](https://code.claude.com/docs/en/setup#set-up-on-windows) on native Windows) and require -`jq`; the plugin-drift check additionally requires `curl`. Run `/claude-config:setup` (`check` by -default) to verify these prerequisites; `apply` gives platform install guidance and re-verifies. +[Git Bash](https://code.claude.com/docs/en/setup#set-up-on-windows) on native Windows). The +JSON-parsing scripts require `jq`; the plugin-drift check additionally requires `curl`; and `awk` +and `sort` are required across three skills, not one — `audit`'s plugin-drift check (both) and its +fix (`sort`), `audit-permission-grants`' rule check (both), and `audit-instructions`' conflict pass +(both). Only the conflict pass probes for them and `exit 2`s naming the one that is missing; the +others call them unguarded, so an absent `awk` or `sort` surfaces there as a bare `command not +found` partway through a run. Both ship with every POSIX userland, so a missing one means a minimal +shell environment (Git Bash, a `busybox` shim) rather than an absent package — install a full +userland (Git for Windows; `gawk`/`mawk` plus `coreutils` on Linux; `brew install gawk coreutils` +on macOS). Run `/claude-config:setup` (`check` by default) to verify these prerequisites; `apply` +gives platform install guidance and re-verifies. ## License diff --git a/plugins/claude-config/skills/audit-instructions/SKILL.md b/plugins/claude-config/skills/audit-instructions/SKILL.md index c631587ae..4ad86148a 100644 --- a/plugins/claude-config/skills/audit-instructions/SKILL.md +++ b/plugins/claude-config/skills/audit-instructions/SKILL.md @@ -45,7 +45,8 @@ concerns its siblings already cover — route rather than re-answer: repeated stumble evidence — is `unhobble` (same plugin): this skill judges instruction *text* against doctrine; unhobble measures the *model*. -On **memory-layer surfaces** (CLAUDE.md, CLAUDE.local.md, `.claude/rules/`, `~/.claude/rules/`), +On **memory-layer surfaces** (CLAUDE.md, CLAUDE.local.md, `.claude/rules/`, and `rules/` under the +user root Phase A resolves), this skill runs only the model-era checks I6–I28. It never runs or reports the hygiene checks I1–I5 (line-necessity, length, placement, inferable content, rule-to-hook) on these surfaces — that instruction-memory hygiene layer belongs to the `claude-memory` plugin. When that plugin is @@ -75,7 +76,7 @@ never which surfaces are read. Phase A always inventories the full comparison se relation between two surfaces and a scoped run still needs the counterpart: - `claude-md` — findings on user + project CLAUDE.md and CLAUDE.local.md -- `rules` — findings on `.claude/rules/` and `~/.claude/rules/` +- `rules` — findings on `.claude/rules/` and `rules/` under the user root Phase A resolves - `skills` — findings on skill bodies and their context/reference files - `agents` — findings on agent definition markdown - `hooks` — findings on hook instruction text: prompt-type hook text, and handler output injected @@ -174,15 +175,29 @@ official memory and `.claude`-directory docs (cited in the report's Sources line **The tree does not decide what is live.** Before the inventory is handed to any lane, resolve the session's effective liveness controls — the launch directory, the merged `claudeMdExcludes`, -`--setting-sources`, and the additional-directory inputs — then drop what they exclude and add the -memory files they contribute. A walk of the project tree alone both invents surfaces that are dead -in this session and misses live ones that are not in the tree at all. The controls, their official -sources, and the `liveness-unresolved` marking for values an out-of-session inventory cannot read -are in [reference/conflict-criteria.md](reference/conflict-criteria.md), which owns the gate; name -the resolved controls in the report's tier-transparency line. +`--setting-sources`, the additional-directory inputs, **and effective hook enablement** — then drop +what they exclude and add the memory files they contribute. A walk of the project tree alone both +invents surfaces that are dead in this session and misses live ones that are not in the tree at all. +The controls, their official sources, and the `liveness-unresolved` marking for values an +out-of-session inventory cannot read are in +[reference/conflict-criteria.md](reference/conflict-criteria.md), which owns the gate; name the +resolved controls in the report's tier-transparency line. + +**Hook enablement is a liveness control, and configuration alone does not establish it.** +`disableAllHooks` turns off hooks without removing them, so an enabled plugin's handler, or one wired +in project settings, sits on disk unable to run — and a gate that cannot run this session constrains +nothing to compare against. Its reach is all hooks with exactly one carve-out, and that carve-out is +what makes this a per-scope resolution rather than a per-file one: set in user, project, or local +settings it cannot disable **managed** hooks, so managed hook text stays live against it and must not +be dropped with the rest; only a managed-level `disableAllHooks` reaches those too. The mirror +control, `allowManagedHooksOnly`, cuts the other way and blocks user, project, and plugin hooks, +exempting plugins force-enabled in managed `enabledPlugins`. Omit every hook surface they disable — +both kinds, prompt-type and context-injecting alike, since neither reaches this session when the +handler never fires — and report both resolved values with the other controls. Exclude from the **editable** set, and hold for the routing subsection: auto-memory -(`~/.claude/projects//memory/`, owned by `claude-memory`), installed plugin-cache content, +(`projects//memory/` under the resolved user root, owned by `claude-memory`), installed +plugin-cache content, and any managed materialization per the Scope boundary. Record each surface found and each surface skipped, so the report's tier-transparency line can name both. @@ -193,7 +208,10 @@ involving one still carries the no-change representation and its routing recomme - **Auto memory, when it is on** — the `MEMORY.md` entrypoint at the effective auto-memory location (the highest-precedence scope that sets `autoMemoryDirectory`, otherwise - `~/.claude/projects//memory/`). **Resolve the effective enabled state first, by + `projects//memory/` under the **resolved** user root above, never a hardcoded + `~/.claude` — `CLAUDE_CONFIG_DIR` moves `projects/` with the rest of the tree, so a hardcoded + default misses the live `MEMORY.md` and compares against a store the session no longer + writes). **Resolve the effective enabled state first, by precedence — not by any single scope's value.** `CLAUDE_CODE_DISABLE_AUTO_MEMORY` is authoritative wherever it is set (`=1` off, `=0` on, even against `autoMemoryEnabled: false`); with the variable unset, apply settings precedence (managed > local > project > user) to `autoMemoryEnabled`, which @@ -209,6 +227,21 @@ involving one still carries the no-change representation and its routing recomme actually loads is compared (the first 200 lines or 25KB); topic files beside it are read on demand and are not resident. Ownership is unchanged: `claude-memory` still owns auto memory, and a finding here routes there rather than editing it. +- **Each enabled agent's own memory, under that same gate** — an agent definition carrying a + `memory` field gets its **own** memory directory, separate from the main conversation's and named + per agent, and that subagent reads and writes its own `MEMORY.md` there. The field's value is the + scope, and each scope has its own location: `user` → `agent-memory//` under the + **resolved** user root above (never a hardcoded `~/.claude`, for the reason the entry above gives), + `project` → `.claude/agent-memory//`, `local` → + `.claude/agent-memory-local//`. + [reference/conflict-criteria.md](reference/conflict-criteria.md) keeps an agent-definition-versus- + its-own-memory contradiction in scope precisely because those two *do* co-reside in that subagent, + so this inventory has to reach it: enumerate that `MEMORY.md` for every inventoried agent whose + definition enables the field, under the same loaded-portion bound. The gate is the effective state + resolved just above: subagent memory is part of auto memory, so with auto memory off the `memory` + field has no effect and the subagent launches without the memory instructions or the memory tool + access — an agent memory left on disk after the switch flipped is not inventoried. + Read-only and `claude-memory`-owned exactly as the main entrypoint is. - **Org-managed policy** — the managed-policy `CLAUDE.md`, any `claudeMd` value in managed settings, and hook instruction text configured in managed settings, of **both** kinds above. All three are live instruction text, and a managed hook contradicting a project skill is exactly the conflict I15 @@ -398,5 +431,8 @@ catalog). - Not memory-layer hygiene — checks I1–I5 on CLAUDE.md/rules route to `claude-memory`'s `audit` skill when installed, and upstream-owned plugin-cache or managed materializations route to the owning repository rather than being edited here. -- Does not grade a contradiction whose two halves both sit in project-scope `CLAUDE.md` / - `CLAUDE.local.md` / `.claude/rules/**` — that is `claude-memory:audit`'s C6. +- Does not grade a contradiction whose two halves both sit in **root-level** project `CLAUDE.md` / + `CLAUDE.local.md` / `.claude/rules/**` — that is `claude-memory:audit`'s C6. A **nested** + `CLAUDE.md` / `CLAUDE.local.md` side keeps the pair here, because C6 never discovers that file; + [reference/conflict-criteria.md](reference/conflict-criteria.md) owns the routing table and its + evidence. diff --git a/plugins/claude-config/skills/audit-instructions/reference/conflict-criteria.md b/plugins/claude-config/skills/audit-instructions/reference/conflict-criteria.md index 14af61a28..a83f94614 100644 --- a/plugins/claude-config/skills/audit-instructions/reference/conflict-criteria.md +++ b/plugins/claude-config/skills/audit-instructions/reference/conflict-criteria.md @@ -95,9 +95,10 @@ shapes without this gate produces noise, because most surface pairs never co-loa | `.claude/rules/*` with `paths` | Only when a matching file is read | memory: "only apply when Claude is working with files matching the specified patterns" | | Skill body | Only once invoked, then for the rest of the session | skills: "a skill's body loads only when it's used" | | Auto memory `MEMORY.md` | Every **main** session, first 200 lines or 25KB — **not** in a subagent, except a fork | memory: "The main conversation's auto memory isn't loaded into subagents; the exception is a fork" | +| A subagent's **own** auto memory `MEMORY.md` (its `memory` field) | Every dispatch of **that** subagent, first 200 lines or 25KB — never the main session, and never another agent's | subagents: the field "gives the subagent a persistent directory"; its system prompt "includes the first 200 lines or 25KB of `MEMORY.md` in the memory directory"; memory: "A subagent's own auto memory, enabled with the subagent `memory` field, is a separate directory" | | Skill bundled `reference/`, `context/` file | Only when Claude reads it | skills: "letting Claude access detailed reference material only when needed" | | Agent definition (its own subagent) | Always, as that subagent's system prompt — **alongside the full CLAUDE.md hierarchy** | subagents, "What loads at startup" | -| Skill named in an agent's `skills:` field | Always, in that subagent | subagents: "The full content of each listed skill is injected, not only the description" | +| Skill named in an agent's `skills:` field | Always, in that subagent | subagents: "The full content of each listed skill is injected into the subagent's context at startup" | | Prompt-type hook text | **Never** — see "A prompt hook's text is not an instruction" below | hooks: a `prompt` hook "send[s] a prompt to a Claude model for single-turn evaluation" | | Handler **stdout** on `SessionStart`, `UserPromptSubmit`, `UserPromptExpansion` | From injection onward, as ordinary message history | hooks: "The exceptions are `UserPromptSubmit`, `UserPromptExpansion`, and `SessionStart`, where stdout is added as context that Claude can see and act on" | | Handler `hookSpecificOutput.additionalContext` on a **main-session** event | From injection onward, at the position the event dictates | hooks: "Where the reminder appears depends on the event" — session start, alongside the prompt, next to the tool result, or at the end of the turn | @@ -247,8 +248,10 @@ conditional pairs, because the worked example below is one. ## Prerequisite: effective liveness, which the tree does not determine Co-residency asks *when* a surface loads. This gate asks a prior question — **whether it loads at -all in this session** — and the answer is not a function of the file tree. Five session-level inputs -change it, all from [memory](https://code.claude.com/docs/en/memory): +all in this session** — and the answer is not a function of the file tree. Six session-level inputs +change it — the first five from [memory](https://code.claude.com/docs/en/memory), the last from +[hooks](https://code.claude.com/docs/en/hooks), because a hook's instruction text is only as live as +the handler that carries it: - **Launch directory.** "if you run Claude Code in `foo/bar/`, it loads instructions from `foo/bar/CLAUDE.md`, `foo/CLAUDE.md`, and any `CLAUDE.local.md` files alongside them" — which @@ -264,15 +267,29 @@ change it, all from [memory](https://code.claude.com/docs/en/memory): directory" — live surfaces a walk of the project tree never sees. - **A declined external-import approval.** "If you decline, the imports stay disabled and the dialog doesn't appear again" — persistent, machine-local, and invisible in the tree. +- **Effective hook enablement, which resolves per scope and not per file.** "To temporarily disable + all hooks without removing them, set `"disableAllHooks": true` in your settings file" — the + configured entry survives, so the tree still shows a hook that cannot fire. It "respects the + managed settings hierarchy": `disableAllHooks` "set in user, project, or local settings can't + disable those managed hooks. Only `disableAllHooks` set at the managed settings level can disable + managed hooks", so a lower-scope disable leaves managed hook text **live** and it must not be + dropped with the rest. The mirror control cuts the other way: "Enterprise administrators can use + `allowManagedHooksOnly` to block user, project, and plugin hooks. Hooks from plugins force-enabled + in managed settings `enabledPlugins` are exempt." Either one silences instruction text this + session while leaving it on disk, and neither is readable from the tree. A pass that skips this gate reports conflicts between instructions one side of which is dead, and misses live counterparts that were never inventoried. Both failures are silent, and both are reproducible only on the machine that produced them. **So resolve effective liveness before pairing, and record what you resolved.** Take the session's -launch directory and the merged effective values of `claudeMdExcludes`, `--setting-sources`, and the -additional-directory inputs; drop excluded and source-skipped surfaces from the comparison set, and -add the memory files the additional directories contribute. Where a value cannot be resolved — an +launch directory, the merged effective values of `claudeMdExcludes`, `--setting-sources`, and the +additional-directory inputs, and `disableAllHooks` **at each settings scope** together with +`allowManagedHooksOnly`; drop excluded and source-skipped surfaces from the comparison set, drop +every hook surface the resolved enablement silences — prompt-type and context-injecting alike, since +neither reaches this session when the handler never fires — while keeping managed hook text against a +user, project, or local `disableAllHooks`, and add the memory files the additional directories +contribute. Where a value cannot be resolved — an inventory taken outside the session it describes, a declined import that leaves no trace in the tree — mark the affected surfaces `liveness-unresolved` and report pairs touching them as such rather than grading them. **Name the resolved controls in the pass's tier-transparency line**: a diff --git a/plugins/claude-config/skills/audit-instructions/reference/criteria.md b/plugins/claude-config/skills/audit-instructions/reference/criteria.md index 0d8af8a88..5fd23173b 100644 --- a/plugins/claude-config/skills/audit-instructions/reference/criteria.md +++ b/plugins/claude-config/skills/audit-instructions/reference/criteria.md @@ -210,8 +210,13 @@ Tier `mechanical` · Authority `ANTHROPIC-DOCS` · Severity `warning` · Surface in its own context, and path-scoped rules are invisible there (), so proposing one for instructions the agent needs removes them from every dispatch rather than deferring them. Name a destination the agent itself - reaches — a skill the agent's definition invokes or preloads, or text kept in the definition — and - never a `paths:`-scoped rule. + reaches — a skill the agent's definition **invokes at runtime**, or text kept in the definition — + and never a `paths:`-scoped rule. **A `skills:` preload is not such a destination**: the full + content of each listed skill is injected into every dispatch of that agent, so the content is + resident for every unrelated use exactly as it was in the definition, and the move defers nothing — + the same disqualification `@path` imports carry above. When the agent has no conditional runtime + invocation to move the content to, report that no safe deferral is available rather than proposing + a preload that satisfies this check's letter and changes the load profile not at all. - **Adjacent axis:** this check is load *timing*. Definition-site *locality* — an instruction sitting away from the thing it governs — is I16, and an instruction can be correctly deferred here and still misplaced there. @@ -222,7 +227,8 @@ Tier `mechanical` · Authority `ANTHROPIC-DOCS` · Severity `warning` · Surface descriptions are loaded into context so Claude knows what's available, but full skill content only loads when invoked", the combined `description` and `when_to_use` text "is truncated at 1,536 characters in the skill listing to reduce context usage", and "Plugin skills are not affected by - `skillOverrides`." + `skillOverrides`."; subagents, on a skill named in an agent's `skills:` field — "The full content + of each listed skill is injected into the subagent's context at startup." ### I4: Inferable or redundant content @@ -663,14 +669,18 @@ Tier `mechanical` · Authority `ANTHROPIC-DOCS` · Severity `info` · Surfaces: skill bodies. - **Detect:** an instruction directing the agent to go read a surface the main conversation loads at - startup and therefore already carries — the **root** `CLAUDE.md`, the user `CLAUDE.md` at the - **resolved** `${CLAUDE_CONFIG_DIR:-~/.claude}`, the **root** `CLAUDE.local.md`, unconditional - project rules (no `paths` frontmatter), and managed policy files. Two qualifiers are load-bearing. + startup and therefore already carries — the **root** project `CLAUDE.md` in **either** supported + location (`./CLAUDE.md` **or** `./.claude/CLAUDE.md`), the user `CLAUDE.md` at the **resolved** + `${CLAUDE_CONFIG_DIR:-~/.claude}`, the **root** `CLAUDE.local.md`, unconditional + project rules (no `paths` frontmatter), and managed policy files. Three qualifiers are load-bearing. Root-level: the startup guarantee is scoped to the hierarchy discovered from the launch directory, not to every file of that name in the tree. Resolved: `CLAUDE_CONFIG_DIR` moves the whole config tree, so a hardcoded `~/.claude/CLAUDE.md` both flags a read that is now necessary and misses the - redundant read of the configured path. Phase A resolves this variable already (`SKILL.md:76-78`); - match it. The read spends a turn to retrieve text that is already present. + redundant read of the configured path. Either location: a project that keeps its memory at + `./.claude/CLAUDE.md` loads it at startup exactly as `./CLAUDE.md` would, so a set naming only the + bare path lets the redundant read of the active file escape this check entirely. Phase A resolves + the variable and inventories both project locations already; match it. The read spends a turn to + retrieve text that is already present. - **Remediate:** cut the retrieval step and state the requirement the read was meant to satisfy. - **Must NOT flag: anything that loads on demand rather than at startup.** The guarantee this check rests on covers the hierarchy *the main conversation loads*, which is not the whole memory family. @@ -682,7 +692,13 @@ skill bodies. established, leave it. - **Must NOT flag:** an instruction to read a surface that is *not* auto-loaded — `AGENTS.md`, contributing guides, ADRs, CI workflow files, per-ecosystem convention docs. Those are ordinary - progressive disclosure. **Any read where the file is the operation's subject rather than its + progressive disclosure — **but only while no active startup import reaches them.** A startup file + that carries `@docs/CONTRIBUTING.md`, or the `@AGENTS.md` the docs themselves recommend for an + `AGENTS.md` repo, has that file expanded into context at launch, so the document is resident and + an instruction to go read it is exactly the redundant retrieval this check exists to find. + **Resolve the startup set's `@path` imports first** — recursively, to memory's documented maximum + depth of four hops — and add what they reach to the loaded set; this exemption applies only to what + no such import reaches. **Any read where the file is the operation's subject rather than its instructions** — auditing it, editing it, patching it, reporting on it, or anything else needing current disk contents. The startup copy is a snapshot taken at launch; another process can have changed the file since, and a pre-edit read cut on the grounds that "it is already in context" @@ -694,6 +710,11 @@ skill bodies. `~/.claude/CLAUDE.md`, project rules, `CLAUDE.local.md`, and managed policy files." The qualifier *the main conversation loads* is what bounds this check: memory documents lazy loading for "path-specific rules or lazy-loaded files in subdirectories", so those are outside the guarantee. + memory, on `@path` imports — "Imported files are expanded and loaded into context at launch + alongside the CLAUDE.md that references them", and "Imported files can recursively import other + files, with a maximum depth of four hops" — is what puts an imported supporting document inside it; + memory's `AGENTS.md` guidance recommends exactly such an import, and requires it on Windows, where + the symlink alternative needs elevation. ### I15: Cross-surface instruction conflict diff --git a/plugins/claude-config/skills/audit-instructions/scripts/conflict-scan.sh b/plugins/claude-config/skills/audit-instructions/scripts/conflict-scan.sh index 4d5be731b..68a1ad547 100755 --- a/plugins/claude-config/skills/audit-instructions/scripts/conflict-scan.sh +++ b/plugins/claude-config/skills/audit-instructions/scripts/conflict-scan.sh @@ -63,11 +63,11 @@ EOF } case "${1:-}" in - -h | --help) - usage - exit 0 - ;; - *) ;; +-h | --help) + usage + exit 0 + ;; +*) ;; esac # Check the tools this script actually executes. `mapfile < <(… | sort -u)` @@ -107,10 +107,12 @@ BACKTICK=$(printf '\140') ENTITY_ERE="[A-Z][a-z]+([A-Z][a-z]*)+|${BACKTICK}[A-Z][a-z]+${BACKTICK}" # Prohibition tokens — the I6 set, kept semantically identical to # instruction-scan.sh so the two scans classify a line's polarity the same way. -PROHIBIT_ERE='[^a-z](never|do not|don[^a-z]?t|must ?not|mustn[^a-z]?t|should ?not|shouldn[^a-z]?t)[^a-z]' +PROHIBIT_ALT='never|do not|don[^a-z]?t|must ?not|mustn[^a-z]?t|should ?not|shouldn[^a-z]?t' +PROHIBIT_ERE="[^a-z](${PROHIBIT_ALT})[^a-z]" # Mandate tokens. Checked only after prohibition, so "never use X" is a # prohibition rather than an ambiguous both-polarity line. -MANDATE_ERE='[^a-z](must|always|mandator(y|ily)|require[ds]?|shall|use|present|ask)[^a-z]' +MANDATE_ALT='must|always|mandator(y|ily)|require[ds]?|shall|use|present|ask' +MANDATE_ERE="[^a-z](${MANDATE_ALT})[^a-z]" # Exception clauses — flagged, not suppressed. Whether the exception reaches the # other surface is gate 4, which the lane decides. EXCEPTION_ERE='[^a-z](unless|except|only when|only if|other than)[^a-z]' @@ -128,6 +130,26 @@ CONDITIONAL_ERE='[^a-z](only when|only if|unless|requires?|gated|enabled|is on|w # temporal conjunction as often as a contrastive one ("use X while the flag is # set" must not split). BOUNDARY_ERE='([.;!?] |[^a-z],? *(but|whereas|though|although|yet)[^a-z]|, while[^a-z])' +# Coordinated second directive — `and` joining a directive that carries its OWN +# polarity token ("always use `Read` and never use `Bash`"). Opposite polarities +# coordinate with `and` as readily as with a contrastive, and without a boundary +# the first entity's trailing window swallows the second directive's token and +# takes its polarity. A bare `and` is NOT a boundary: it usually joins the +# objects of one directive ("never use `Bash` and `Grep`"), where cutting would +# strip the token that governs the second object. Requiring the polarity token +# is what separates the two readings. +# +# The token set is COMPOSED from the two classifier alternations rather than +# retyped. A hand-copied list drifts the moment either classifier gains a token: +# `use|present|ask` were added to MANDATE_ERE and not here, so `Never use X and +# use Y` found no boundary, Y inherited the leading `never`, and a real conflict +# with `Never use Y` went unreported while `Always use Y` reported a false one. +# Composition makes that divergence unrepresentable. +COORD_ERE="[^a-z],? *and +(${PROHIBIT_ALT}|${MANDATE_ALT})[^a-z]" +# The coordinator alone. A LEADING window resumes after this much of a COORD +# match rather than after the whole of it, so the second directive's polarity +# token stays on the entity's side of the cut and still classifies it. +COORD_HEAD_ERE='[^a-z],? *and +' # Polarity is read from a window around each entity mention, not from the whole # line. A prose line often carries a prohibition about one object and names an @@ -143,7 +165,32 @@ mapfile -t rows < <( [[ -f "$file" ]] && printf '%s\n' "$file" done | awk -v w="$WINDOW_CHARS" -v entpat="$ENTITY_ERE" -v prohibit="$PROHIBIT_ERE" \ -v mandate="$MANDATE_ERE" -v exception="$EXCEPTION_ERE" -v gated="$GATED_ERE" \ - -v conditional="$CONDITIONAL_ERE" -v bnd="$BOUNDARY_ERE" ' + -v conditional="$CONDITIONAL_ERE" -v bnd="$BOUNDARY_ERE" -v coord="$COORD_ERE" \ + -v coordhead="$COORD_HEAD_ERE" ' + # Leading half of a window: resume after the LAST boundary before the entity. + # Contrastives and sentence marks are consumed whole; a coordinated directive + # is consumed only up to its coordinator, leaving its polarity token in the + # returned text because that token is what governs this entity. + function cut_lead(t, cut, off, tail, cs, hl) { + cut = 0; off = 0; tail = t + while (match(tail, bnd)) { + off += RSTART + RLENGTH - 1 + cut = off + tail = substr(tail, RSTART + RLENGTH) + } + if (cut > 0) t = substr(t, cut + 1) + cut = 0; off = 0; tail = t + while (match(tail, coord)) { + cs = RSTART + match(substr(tail, cs), coordhead) + hl = RLENGTH + off += cs + hl - 1 + cut = off + tail = substr(tail, cs + hl) + } + if (cut > 0) t = substr(t, cut + 1) + return t + } function classify(file, lineno, ent, prewindow, postwindow, window, pol, exc, key) { # Every test here reads `window`, which the caller builds from the # sentence-bounded halves plus the mention itself. Nothing classifies an @@ -200,18 +247,16 @@ mapfile -t rows < <( # entities at opposite polarity ("always use X but never use Y"), while # ordinary comma-separated prose keeps its polarity throughout, so # cutting on every comma would drop the token that governs the entity. + # + # A coordinated second directive bounds the window too, but + # asymmetrically: a contrastive carries no polarity of its own and is + # consumed whole, whereas the coord pattern matches THROUGH the second + # polarity token, so a leading window resumes after the coordinator + # alone and keeps that token. See cut_lead. post = substr(pad, e + 2, w) if (match(post, bnd)) post = substr(post, 1, RSTART - 1) - pre = substr(pad, ws + 1, s - ws) - precut = 0 - preoff = 0 - pretail = pre - while (match(pretail, bnd)) { - preoff += RSTART + RLENGTH - 1 - precut = preoff - pretail = substr(pretail, RSTART + RLENGTH) - } - if (precut > 0) pre = substr(pre, precut + 1) + if (match(post, coord)) post = substr(post, 1, RSTART - 1) + pre = cut_lead(substr(pad, ws + 1, s - ws)) # The full window is rebuilt from the bounded halves plus the mention, # so mandate, gate and exception tests see the same sentence the # polarity tests do rather than the raw span. diff --git a/plugins/claude-config/skills/audit-instructions/scripts/conflict-scan.test.sh b/plugins/claude-config/skills/audit-instructions/scripts/conflict-scan.test.sh index 2c21f0cd7..69d7fede8 100755 --- a/plugins/claude-config/skills/audit-instructions/scripts/conflict-scan.test.sh +++ b/plugins/claude-config/skills/audit-instructions/scripts/conflict-scan.test.sh @@ -30,14 +30,14 @@ assert_exit() { } assert_contains() { case "$2" in - *"$3"*) pass "$1" ;; - *) fail "$1" "expected to contain: $3" ;; + *"$3"*) pass "$1" ;; + *) fail "$1" "expected to contain: $3" ;; esac } assert_not_contains() { case "$2" in - *"$3"*) fail "$1" "unexpected substring: $3" ;; - *) pass "$1" ;; + *"$3"*) fail "$1" "unexpected substring: $3" ;; + *) pass "$1" ;; esac } @@ -328,8 +328,8 @@ NOCOMMA="$TEST_TMPDIR/no-comma.md" cat >"$NOCOMMA" <<'EOF' Always use `Read` but never use `Bash` for file inspection. EOF -assert_contains "an unpunctuated contrastive does not flip the earlier entity" "$(bash "$SCRIPT" "$NOCOMMA" "$READNO")" "|Read|" -assert_contains "the entity after an unpunctuated contrastive is still prohibited" "$(bash "$SCRIPT" "$BASHYES2" "$NOCOMMA")" "|Bash|" +assert_contains "an unpunctuated contrastive does not flip the earlier entity" "$(bash "$SCRIPT" "$NOCOMMA" "$READNO")" "|Read|" +assert_contains "the entity after an unpunctuated contrastive is still prohibited" "$(bash "$SCRIPT" "$BASHYES2" "$NOCOMMA")" "|Bash|" # --- Case 32 (MUST NOT FLAG): temporal `while` is not a contrastive --------- # "use X while the flag is set" is one clause; splitting it would drop the @@ -338,7 +338,65 @@ TEMPORAL="$TEST_TMPDIR/temporal.md" cat >"$TEMPORAL" <<'EOF' Always use `WebFetch` while the offline flag is unset. EOF -assert_eq "temporal 'while' does not split the clause" "1" "$(bash "$SCRIPT" --count "$TEMPORAL" "$POSTPOSED")" +assert_eq "temporal 'while' does not split the clause" "1" "$(bash "$SCRIPT" --count "$TEMPORAL" "$POSTPOSED")" + +# --- Case 33: `and` coordinating an opposite directive splits too ----------- +# Opposite directives coordinate with `and` as readily as with a contrastive. +# Without the split the earlier entity swallows the later `never` and is read as +# prohibited, so it fails to pair with a real prohibition and drops the conflict. +COORDAND="$TEST_TMPDIR/coord-and.md" +cat >"$COORDAND" <<'EOF' +Always use `Read` and never use `Bash` for file inspection. +EOF +assert_contains "a coordinated prohibition does not flip the earlier entity" \ + "$(bash "$SCRIPT" "$COORDAND" "$READNO")" "|Read|" +assert_contains "the entity inside the coordinated clause is still prohibited" \ + "$(bash "$SCRIPT" "$BASHYES2" "$COORDAND")" "|Bash|" + +# --- Case 34: the mirror order coordinates the same way ---------------------- +COORDMIRROR="$TEST_TMPDIR/coord-mirror.md" +cat >"$COORDMIRROR" <<'EOF' +Never use `Bash` and always use `Read` for file inspection. +EOF +assert_contains "a coordinated mandate is not overtaken by the leading prohibition" \ + "$(bash "$SCRIPT" "$COORDMIRROR" "$READNO")" "|Read|" +assert_contains "the leading prohibition still classifies its own entity" \ + "$(bash "$SCRIPT" "$BASHYES2" "$COORDMIRROR")" "|Bash|" + +# --- Case 34b: the coordinator honors EVERY mandate token, not a subset ------ +# COORD_ERE is composed from the two classifier alternations. When it carried a +# hand-copied list instead, `use|present|ask` were in MANDATE_ERE and absent +# here: `Never use X and use Y` found no boundary, Y inherited the leading +# `never`, and the pair with `Never use Y` went unreported. `always` is the +# control — it was in both lists, so it passed throughout and hid the gap. +COORDBARE="$TEST_TMPDIR/coord-bare-mandate.md" +cat >"$COORDBARE" <<'EOF' +Never use `Bash` and use `Read` for file inspection. +EOF +assert_contains "a bare-'use' coordinated mandate splits like 'always use'" \ + "$(bash "$SCRIPT" "$COORDBARE" "$READNO")" "|Read|" + +COORDPRESENT="$TEST_TMPDIR/coord-present.md" +cat >"$COORDPRESENT" <<'EOF' +Never use `Bash` and present `Read` to the operator. +EOF +assert_contains "a coordinated 'present' splits like every other mandate token" \ + "$(bash "$SCRIPT" "$COORDPRESENT" "$READNO")" "|Read|" + +# --- Case 35 (MUST NOT FLAG): a bare `and` joins objects, not directives ----- +# "never use `Bash` and `Grep`" is one directive over two objects. Splitting on +# every `and` would strip the `never` that governs the second object, so the +# polarity token after the coordinator is what makes it a boundary. +BAREAND="$TEST_TMPDIR/bare-and.md" +cat >"$BAREAND" <<'EOF' +Never use `Bash` and `Grep` for file inspection. +EOF +GREPYES="$TEST_TMPDIR/grep-yes.md" +cat >"$GREPYES" <<'EOF' +Always use `Grep` for file inspection. +EOF +assert_contains "a bare 'and' does not strip the token governing the second object" \ + "$(bash "$SCRIPT" "$BAREAND" "$GREPYES")" "|Grep|" # --- Case 16: a missing runtime prerequisite exits 2 ------------------------ # The tools the script executes are awk and sort, not grep. `mapfile < <(… | diff --git a/plugins/claude-config/skills/setup/SKILL.md b/plugins/claude-config/skills/setup/SKILL.md index 1aabe73b7..085301e86 100644 --- a/plugins/claude-config/skills/setup/SKILL.md +++ b/plugins/claude-config/skills/setup/SKILL.md @@ -26,13 +26,17 @@ what they actually do, don't recite this file — then run each probe via Bash a table with one remediation line per FAIL — read-only; leave every file untouched. The runtime scripts and their tools: -- `${CLAUDE_PLUGIN_ROOT}/skills/audit/scripts/check-plugin-drift.sh` — jq **and** curl -- `${CLAUDE_PLUGIN_ROOT}/skills/audit/scripts/check-structure.sh` and `fix-plugin-drift.sh` — jq +- `${CLAUDE_PLUGIN_ROOT}/skills/audit/scripts/check-plugin-drift.sh` — jq **and** curl, plus awk and sort +- `${CLAUDE_PLUGIN_ROOT}/skills/audit/scripts/check-structure.sh` — jq; `fix-plugin-drift.sh` — jq plus sort - `${CLAUDE_PLUGIN_ROOT}/skills/audit-automation-gaps/scripts/inventory.sh` — jq -- `${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-grants/scripts/permission-rule-check.sh` — jq +- `${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-grants/scripts/permission-rule-check.sh` — jq plus awk and sort - `${CLAUDE_PLUGIN_ROOT}/skills/audit-instructions/scripts/instruction-scan.sh` — grep only (POSIX; no jq) - `${CLAUDE_PLUGIN_ROOT}/skills/audit-instructions/scripts/conflict-scan.sh` — awk **and** sort (no jq) +Only `conflict-scan.sh` probes for awk and sort; the three above it call them with no guard, so read +each script's actual calls rather than trusting a single script's prerequisite block to speak for the +plugin. + 1. **`jq`** — `command -v jq`. FAIL if absent: the JSON-parsing scripts need it (`inventory.sh` degrades to an empty inventory; the others `exit 2` with an install remediation). Missing `jq` blocks the three JSON-parsing audit skills (`audit`, `audit-automation-gaps`, `audit-permission-grants`); @@ -40,10 +44,13 @@ and their tools: 2. **`curl`** — `command -v curl`. FAIL if absent, but scoped: only the plugin-drift check (`check-plugin-drift.sh`) uses it and `exit 2`s without it. The rest of `audit` and the other three skills still run — say so in the remediation line. -3. **`awk` and `sort`** — `command -v awk`, `command -v sort`. FAIL if either is absent: they are what - `conflict-scan.sh` executes, and it `exit 2`s without them. Scoped to `audit-instructions`' conflict - pass — the rest of that skill and the other three still run. Report them by name rather than as one - row, since a minimal shell can carry one and not the other. +3. **`awk` and `sort`** — `command -v awk`, `command -v sort`. FAIL if either is absent, and **not** + scoped to one skill: `conflict-scan.sh` executes both and `exit 2`s naming the missing one, while + `check-plugin-drift.sh` (both), `permission-rule-check.sh` (both), and `fix-plugin-drift.sh` + (`sort`) reach them with no prerequisite check at all — so `audit` and `audit-permission-grants` + fail mid-run on a bare `command not found` rather than on a named prerequisite. Say in the + remediation line that this FAIL reaches three skills, not just `audit-instructions`. Report `awk` + and `sort` by name rather than as one row, since a minimal shell can carry one and not the other. 4. **Bash shell** — INFO: the scripts are bash (arrays, `[[ ]]`, process substitution, `BASH_SOURCE`), run through Claude Code's Bash tool — the bash shell on every platform, Git Bash on native Windows. Report the resolved interpreter; FAIL only if no bash is resolvable. @@ -98,8 +105,9 @@ this skill never installs system packages: shims are where this shows up. Remediate by installing a full userland rather than the single tool: Git for Windows, which bundles both; the distribution's `gawk`/`mawk` and `coreutils` on Linux; `brew install gawk coreutils` on macOS. Report the two separately, since a minimal shell can carry - one and not the other. Scoped to `audit-instructions`' conflict pass, which `exit 2`s without them - — the rest of that skill and the other three still run. + one and not the other. Three skills depend on them — `audit`, `audit-permission-grants`, and + `audit-instructions` — and only the last `exit 2`s cleanly, so do not offer the other two as still + working meanwhile. - **no resolvable bash:** also not remediable by one package — the scripts use arrays, `[[ ]]`, process substitution, and `BASH_SOURCE`, so they need a real bash on `PATH`: Git for Windows on native Windows, the distribution's `bash` elsewhere. Nothing bundled runs until it resolves, so