Relevance-Scoped Rule Injection

workflow beta ~892 tokens updated 2026-08-04

Opt-in, backward-compatible relevance-scoped rule injection plus a tiered always-on safety core. Off by default: with the flag unset, all rules load exactly as before. When enabled, surfaces a pointer to the task-relevant rule subset while always including the safety core.

Tags

  • rules
  • tokens
  • context
  • safety-core
  • opt-in
  • session-start

README

relevance-injection

Opt-in, backward-compatible relevance-scoped rule injection plus a tiered always-on safety core.

Off by default. With the opt-in flag unset, all rules load exactly as before — this module changes nothing about the default install. When enabled, a SessionStart hook surfaces a pointer to the task-relevant subset of rules while always including the safety core.

What It Does

CCGM auto-loads every installed module's rules on every session. This module offers a way to scope attention (not access) to the rules relevant to the current task, addressing the always-on rule-token load — without ever removing a rule file or changing default behavior.

Two pieces:

  1. Tiered safety core (rules/relevance-injection.md): an authoritative precedence for the always-on Iron Laws (safety/permissions > confusion protocol > TDD/verification > the rest), so they are tiered rather than nine-way flat. This is documentation + metadata, not a behavior change.

  2. Opt-in injection (hooks/relevance-inject.py + lib/relevance_select.py): when CCGM_RELEVANCE_INJECTION=true is set in ~/.claude/.ccgm.env, the hook emits an additionalContext pointer naming the safety core plus the profile-relevant modules. Selection is deterministic and lives in the pure, tested relevance_select library. When the flag is unset, the hook no-ops.

The applicability field

Modules may add an optional applicability field to their module.json (schema: lib/applicability-schema.json). Absent or {"always": true} == always applicable (preserves pre-feature behavior). Otherwise {"langs": [...]} and/or {"taskTypes": [...]} scope the module to a profile.

Enabling

# ~/.claude/.ccgm.env
CCGM_RELEVANCE_INJECTION=true
CCGM_RELEVANCE_LANGS=python,typescript        # optional
CCGM_RELEVANCE_TASKTYPES=backend,testing      # optional

/rules-scope: generate a repo's claudeMdExcludes block

A third, independent piece: lib/rules_scope.py (driven by the /rules-scope command) inspects a repo and proposes a claudeMdExcludes array for that repo's .claude/settings.json, suppressing installed CCGM rule files that are irrelevant to it (e.g. tailwind/shadcn rules in a backend-only repo). Dry run by default; --write applies the proposal. This is unrelated to the opt-in injection feature above and needs no flag to use — see commands/rules-scope.md for the full contract.

python3 lib/rules_scope.py             # print the proposal for cwd; write nothing
python3 lib/rules_scope.py --write     # apply it to <cwd>/.claude/settings.json

The generated file is machine-scoped. --write puts this machine's absolute, resolved rule-file paths into claudeMdExcludes. Commit it and pull it on a different machine (a teammate, or the same operator with a different ccgmRoot), and none of those paths match — every "excluded" rule silently loads again there instead of staying suppressed. That is the safe failure direction (nothing is ever wrongly dropped), but it does mean the committed file only takes effect on the machine that generated it until re-run with --write there. See commands/rules-scope.md for detail.

Manual Installation

mkdir -p ~/.claude/rules ~/.claude/hooks ~/.claude/lib ~/.claude/commands
cp rules/relevance-injection.md        ~/.claude/rules/relevance-injection.md
cp hooks/relevance-inject.py           ~/.claude/hooks/relevance-inject.py
cp hooks/instructions-loaded-log.py    ~/.claude/hooks/instructions-loaded-log.py
cp lib/relevance_select.py             ~/.claude/lib/relevance_select.py
cp lib/loaded_log.py                   ~/.claude/lib/loaded_log.py
cp lib/rules_scope.py                  ~/.claude/lib/rules_scope.py
cp lib/applicability-schema.json       ~/.claude/lib/applicability-schema.json
cp commands/rules-scope.md             ~/.claude/commands/rules-scope.md
# then merge settings.partial.json into ~/.claude/settings.json
# (registers the SessionStart and InstructionsLoaded hooks)

Files

File Description
rules/relevance-injection.md Tiered safety-core precedence + how the opt-in feature works
hooks/relevance-inject.py SessionStart hook; no-op unless the opt-in flag is set
hooks/instructions-loaded-log.py InstructionsLoaded hook; appends one JSONL record per loaded instruction file to ~/.claude/rule-loading/loaded-{date}.jsonl
lib/relevance_select.py Pure, deterministic selection library (safety core + applicability matching)
lib/loaded_log.py Reads the rule-loading log: parse_log() and assert_loaded()
lib/rules_scope.py /rules-scope generator: detect_repo_profile(), propose_excludes(), write_settings()
lib/applicability-schema.json JSON Schema for the optional module.json applicability field
commands/rules-scope.md /rules-scope command: generate/apply a repo's claudeMdExcludes block
settings.partial.json Registers the SessionStart and InstructionsLoaded hooks

Will install

Path Action Target Type
rules/relevance-injection.md rules/relevance-injection.md rule
hooks/relevance-inject.py hooks/relevance-inject.py hook
hooks/instructions-loaded-log.py hooks/instructions-loaded-log.py hook
lib/relevance_select.py lib/relevance_select.py lib
lib/loaded_log.py lib/loaded_log.py lib
lib/rules_scope.py lib/rules_scope.py lib
lib/applicability-schema.json lib/applicability-schema.json lib
commands/rules-scope.md commands/rules-scope.md command
settings.partial.json merge settings.json settings

Dependencies

Required by

No other module depends on this one.

Included in presets

Not included in any preset.

Install this module

Agent prompt

Recommended for agent users -- hands the whole install off to your assistant.

Fetch https://cd23a9be.ccgm-site.pages.dev/modules/relevance-injection.md and install this module into my Claude Code setup.

Native plugin marketplace

One command via the native plugin marketplace -- additive, does not merge settings.json.

claude plugin install relevance-injection@ccgm

The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.

Manual, per file

Full control -- copy exactly the files you want from the sections below.

Files

Files

rule (1)

rules/relevance-injection.md

# Relevance-Scoped Rule Injection + Tiered Safety Core

CCGM installs every selected module's rule files to `~/.claude/rules/`, where
Claude Code auto-loads all of them on every session. With a full install that
is a large, always-on token load. This module provides an **opt-in,
backward-compatible** way to scope rule attention to the current task while
guaranteeing the safety core is always present.

**This is opt-in. With the feature off (the default), nothing changes.** All
rules load exactly as before.

## The Tiered Safety Core (authoritative precedence)

The Iron Laws are not a flat list. When two disciplines could conflict, this
ordering is authoritative. The always-on minimal core is surfaced in this
precedence and is **never** scoped away by relevance selection, regardless of
task profile:

| Tier | Modules | Why it is non-negotiable |
|------|---------|--------------------------|
| 0 — safety / permissions | `git-workflow`, `hooks` | History-altering git safety, protected-branch enforcement, permission gating. A breach here is irreversible or destructive. |
| 1 — confusion protocol | `autonomy` | Stop and ask at architectural forks; do not guess on high-stakes ambiguity. Prevents wrong-direction work that the lower tiers would then faithfully execute. |
| 2 — TDD + verification | `test-driven-development`, `verification` | No production code without a failing test; no completion claim without fresh evidence. These gate correctness. |
| 3 — debugging + delegation discipline | `systematic-debugging`, `subagent-patterns` | Root cause before fix; spec + status protocol for delegated work. |

Read top-down: a Tier-0 safety rule wins over a Tier-2 convenience. "Violating
the letter of a rule is violating the spirit" applies most strongly at the top
of this table.

The machine-readable form of this ordering lives in
`lib/relevance_select.py` (`SAFETY_CORE_TIERS`). Keep the two in sync.

## How selection works

Each module's `module.json` may carry an optional `applicability` field
(schema: `lib/applicability-schema.json`):

- **Absent** or `{"always": true}` — the module's rules are always surfaced.
  This is the backward-compatible default: a module that does not declare
  applicability behaves exactly as it did before this feature existed.
- `{"langs": [...]}` and/or `{"taskTypes": [...]}` — the module is surfaced
  only when the session's profile intersects a declared dimension. Matching is
  OR across dimensions and deliberately permissive: over-inclusion is safe,
  under-inclusion would risk dropping a relevant discipline.

The safety core (above) is always selected even if a core module declared an
`applicability` constraint — core membership wins.

## Enabling the feature

Strictly opt-in via `~/.claude/.ccgm.env`:

```
CCGM_RELEVANCE_INJECTION=true
CCGM_RELEVANCE_LANGS=python,typescript      # optional task profile
CCGM_RELEVANCE_TASKTYPES=backend,testing    # optional task profile
```

With the flag set, the `SessionStart` hook emits a short pointer
(`additionalContext`) listing the safety core plus the profile-relevant
modules. The rule files themselves remain on disk and loadable — the pointer
biases routing, it does not gate access. With the flag unset, the hook is a
no-op.

## Why a pointer, not file removal

This module never deletes or relocates rule files and never disables the
auto-load path. It is additive: the most it can do is inject one extra block
of context. That property is what makes the feature safe to ship without
changing default behavior or risking an existing install.
command (1)

commands/rules-scope.md

---
description: Propose (and optionally write) a claudeMdExcludes block that suppresses CCGM rules irrelevant to this repo
argument-hint: "[--write]"
---

# /rules-scope - Generate a Repo's claudeMdExcludes Block

Inspects the current repo, decides which installed CCGM rule files are
irrelevant to it, and proposes a `claudeMdExcludes` array for that repo's
`.claude/settings.json`. Turns a per-machine hand-edit into a generated,
reviewable, committable artifact.

**Dry run by default.** Nothing is written unless `--write` is passed.

## Usage

```
/rules-scope             # print the proposal; write nothing
/rules-scope --write     # print the proposal AND write it to .claude/settings.json
```

## When to invoke

- Onboarding a repo whose tech stack clearly does not need every installed
  CCGM module's rules (e.g. a Rust-only service does not need `tailwind`,
  `shadcn`, `supabase`, or `mcp-development` loaded every session).
- Revisiting a repo's `.claude/settings.json` after installing new CCGM
  modules, to see whether the exclusion proposal changed.

## When NOT to invoke

- On a repo that genuinely uses most of CCGM's tech-specific tooling — the
  proposal will legitimately come back small or empty, which is correct,
  not a bug.
- To remove a rule you personally find noisy but that is actually relevant
  to this repo's stack. `claudeMdExcludes` is a repo-wide, committed
  decision, not a personal preference toggle.

## How it works

1. Run `python3 modules/relevance-injection/lib/rules_scope.py` (or the
   installed `~/.claude/lib/rules_scope.py`, if the module is installed)
   from the target repo's root, or pass the repo path as an argument.
2. The script reads the installed CCGM manifest
   (`~/.claude/.ccgm-manifest.json`) for the set of installed modules, and
   inspects the target repo for language/framework markers
   (`detect_repo_profile()`).
3. It proposes excluding two kinds of rule file:
   - **tech-specific** (repo-profile-gated): rule files belonging to a
     module whose `module.json` declares `"category": "tech-specific"`
     (`tailwind`, `shadcn`, `supabase`, `cloudflare`, `mcp-development`),
     proposed only when the repo shows no marker for that module (e.g. no
     `tailwind.config.*` and no `tailwindcss` dependency -> `tailwind.md`
     and `frontend-css.md` are proposed).
   - **niche** (not repo-profile-gated): a small, conservative, hand-picked
     set of rule files about specific CCGM meta-workflows (the nightly
     dreaming pipeline, the Argus visual-convergence loop, SSH to a
     configured remote box, ...) that are rarely in play regardless of the
     target repo's tech stack.
4. It prints every proposed row (module, rule file, category, estimated
   token cost) and a total. **Nothing is written at this point.**
5. With `--write`, it merges the proposed paths into
   `<repo>/.claude/settings.json`'s `claudeMdExcludes` array, preserving
   every other key in the file untouched, and creating the file if it does
   not exist.

## Safety rules (this is what makes exclusion safe to automate)

- **Never proposes a `PINNED_FLOOR` module's rules.** `PINNED_FLOOR` is the
  seven `SAFETY_CORE_TIERS` modules (`git-workflow`, `hooks`, `autonomy`,
  `test-driven-development`, `verification`, `systematic-debugging`,
  `subagent-patterns`) plus `identity`, `live-testing-guard`,
  `git-worktrees`, `model-vetting`, and `branch-guard` — derived from
  `relevance_select.safety_core_modules()` plus four named additions, in
  exactly one place (`lib/rules_scope.py`'s `PINNED_FLOOR`), never
  re-typed. This is checked explicitly in code, not merely true by
  construction of the two candidate categories above.
- **Never writes outside the target repo's `.claude/settings.json`.** It
  never touches `~/.claude/rules/`, `~/.claude/hooks/`, or any other
  machine-global CCGM state — the rule files stay installed and readable;
  only their auto-load into THIS repo's sessions is suppressed.
- **Merges, never overwrites.** An existing `claudeMdExcludes` array is
  extended (deduplicated), and every other key already in the file is
  preserved untouched.
- **Dry run by default; `--write` is required to modify anything.** A
  silent rule-dropping command would be exactly the failure this tool
  exists to prevent.

## Why this differs from installing fewer modules

Uninstalling a module makes its rules absent everywhere, permanently, for
every repo. `claudeMdExcludes` suppresses auto-loading a rule into ONE
repo's sessions while the rule file stays installed, readable on demand,
and untouched for every other repo on the machine. The rule can still be
read directly (`Read ~/.claude/rules/<file>.md`) if a task in the excluded
repo turns out to need it after all — exclusion narrows what auto-loads,
it does not delete anything.

## A load-bearing detail: paths are resolved, not symlink paths

Claude Code's `claudeMdExcludes` matches against the REAL, symlink-resolved
path of the loaded instruction file — not the `~/.claude/rules/<file>.md`
symlink path CCGM's installer creates under `linkMode`. This was verified
empirically (headless `claude -p`, the `InstructionsLoaded` hook as the
oracle): excluding the symlink path did nothing; excluding the real,
resolved path worked. `rules_scope.py` handles this automatically
(`os.path.realpath()` on the installed location) — nothing to configure.

## Another load-bearing detail: the generated file is machine-scoped

Every path `--write` puts into `claudeMdExcludes` is an absolute,
machine-specific path (this machine's `ccgmRoot` plus the module's
rule-file target, realpath-resolved). If `<repo>/.claude/settings.json` is
committed and pulled onto a **different machine** — a teammate, or the same
operator with a different `ccgmRoot` — none of those absolute paths will
match that machine's own installed rule files.

**The failure direction is safe.** A path that resolves to nothing simply
does not match anything Claude Code loads, so on a mismatched machine every
"excluded" rule silently **loads again** — the opposite of the failure this
tool exists to prevent. Nothing is dropped that should have loaded. Nothing
in this generator currently detects or warns about the mismatch; re-running
`/rules-scope --write` on the second machine regenerates the file with that
machine's own resolved paths. Decide with that in mind before committing a
generated `.claude/settings.json` to a repo other machines will check out.

## Cross-references

- Library: `modules/relevance-injection/lib/rules_scope.py`
  (`detect_repo_profile()`, `propose_excludes()`, `write_settings()`)
- Plan: `~/code/plans/ccgm-dynamic-rule-injection/plan.md` Epic 0.5
- `modules/relevance-injection/lib/relevance_select.py` — the safety-core
  precedence and manifest-reading helpers this tool reuses rather than
  duplicating
hook (2)

hooks/relevance-inject.py

#!/usr/bin/env python3
"""SessionStart hook: opt-in relevance-scoped rule injection (issue #695).

PURPOSE
-------
CCGM installs every selected module's rules to ~/.claude/rules/, where Claude
Code auto-loads ALL of them on every session (~53k tokens always-on). This
hook offers an OPT-IN alternative: at fresh session start, surface a short
pointer to the SUBSET of rules relevant to the current task profile, while
guaranteeing the safety core is always surfaced.

CRITICAL SAFETY PROPERTY
------------------------
This hook is a strict NO-OP unless an explicit opt-in flag is set:

    CCGM_RELEVANCE_INJECTION=true   in ~/.claude/.ccgm.env

When the flag is unset (the default for every existing and new install), the
hook reads stdin, finds the flag absent, and returns without emitting
anything. Claude Code's normal all-rules-always-loaded behavior is completely
untouched. The feature can only ever ADD a pointer; it never removes a rule
file from disk and never suppresses the auto-load path.

It additionally fires only on source == "startup" (not resume/compact), and
only injects a non-authoritative POINTER (additionalContext) — the rule files
themselves still live in ~/.claude/rules/ and remain loadable. The pointer
biases attention toward the relevant subset; it does not gate access.

This hook deliberately keeps the latent/deterministic split clean: all
selection logic lives in the pure relevance_select library (testable), and
this file only does I/O wiring.
"""
from __future__ import annotations

import json
import os
import sys
from pathlib import Path

ENV_FILE = Path.home() / ".claude" / ".ccgm.env"
FLAG = "CCGM_RELEVANCE_INJECTION"
LANGS_VAR = "CCGM_RELEVANCE_LANGS"
TASKS_VAR = "CCGM_RELEVANCE_TASKTYPES"

# The selection library is installed alongside this hook's repo copy, and at
# ~/.claude/lib/relevance_select.py once CCGM installs it. Make both importable.
_HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(Path.home() / ".claude" / "lib"))
sys.path.insert(0, str(_HERE.parent / "lib"))

try:
    import relevance_select  # type: ignore
except Exception:  # pragma: no cover - import guard; hook must never crash a session
    relevance_select = None


def _read_env() -> "dict[str, str]":
    """Parse ~/.claude/.ccgm.env into a flat dict. Missing file -> {}."""
    out: "dict[str, str]" = {}
    if not ENV_FILE.exists():
        return out
    try:
        with open(ENV_FILE, encoding="utf-8") as fh:
            for line in fh:
                line = line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                key, val = line.split("=", 1)
                out[key.strip()] = val.strip()
    except OSError:
        return {}
    return out


def _truthy(val: "str | None") -> bool:
    return (val or "").strip().lower() in ("true", "1", "yes")


def _split_csv(val: "str | None") -> "list[str]":
    if not val:
        return []
    return [p.strip() for p in val.replace(";", ",").split(",") if p.strip()]


def _installed_modules() -> "list[str]":
    """Read the installed-module list from the global CCGM manifest."""
    manifest = Path.home() / ".claude" / ".ccgm-manifest.json"
    if not manifest.exists():
        return []
    try:
        with open(manifest, encoding="utf-8") as fh:
            data = json.load(fh)
        mods = data.get("modules")
        return [m for m in mods if isinstance(m, str)] if isinstance(mods, list) else []
    except (OSError, json.JSONDecodeError, ValueError):
        return []


def _modules_dir(env: "dict[str, str]") -> "str | None":
    """Locate the CCGM repo modules/ dir from the manifest's ccgmRoot."""
    manifest = Path.home() / ".claude" / ".ccgm-manifest.json"
    if manifest.exists():
        try:
            with open(manifest, encoding="utf-8") as fh:
                root = json.load(fh).get("ccgmRoot")
            if isinstance(root, str) and root:
                cand = os.path.join(root, "modules")
                if os.path.isdir(cand):
                    return cand
        except (OSError, json.JSONDecodeError, ValueError):
            pass
    return None


def build_context(env: "dict[str, str]") -> "str | None":
    """Build the additionalContext pointer, or None if nothing to emit.

    Pure-ish: depends only on env + filesystem state, no stdin. Returns None
    when the feature is disabled or the data needed for selection is missing —
    in every "None" case the caller emits nothing and behavior is unchanged.
    """
    if not _truthy(env.get(FLAG)):
        return None
    if relevance_select is None:
        return None

    modules_dir = _modules_dir(env)
    installed = _installed_modules()
    if not modules_dir or not installed:
        return None

    langs = _split_csv(env.get(LANGS_VAR))
    task_types = _split_csv(env.get(TASKS_VAR))

    selected = relevance_select.select_modules(
        installed, modules_dir, langs=langs, task_types=task_types
    )
    if not selected:
        return None

    core = [m for m in selected if relevance_select.is_safety_core(m)]
    situational = [m for m in selected if not relevance_select.is_safety_core(m)]

    lines = [
        "<ccgm-relevance-injection>",
        "Relevance-scoped rule injection is ON for this session. The rules in",
        "~/.claude/rules/ remain loaded; this pointer highlights the subset most",
        "relevant to the current task profile so you route through them first.",
        "",
        "ALWAYS-ON safety core (highest precedence, never scoped away):",
        "  " + ", ".join(core),
    ]
    if situational:
        lines += [
            "",
            "Relevant to this profile"
            + (f" (langs={','.join(langs)}" if langs else " (")
            + (f" taskTypes={','.join(task_types)})" if task_types else ")"),
            "  " + ", ".join(situational),
        ]
    lines += [
        "",
        "The safety core is non-negotiable regardless of profile. If a task",
        "touches a discipline not listed above, consult its rule anyway.",
        "</ccgm-relevance-injection>",
    ]
    return "\n".join(lines) + "\n"


def main() -> None:
    try:
        hook_input = json.load(sys.stdin)
    except (json.JSONDecodeError, ValueError, EOFError):
        hook_input = {}

    # Only fire on fresh sessions, matching session-start-enforce.py.
    if hook_input.get("source", "") != "startup":
        return

    env = _read_env()
    context = build_context(env)
    if context:
        sys.stdout.write(context)


if __name__ == "__main__":
    main()

hooks/instructions-loaded-log.py

#!/usr/bin/env python3
"""InstructionsLoaded hook: log which instruction files Claude Code loads.

PURPOSE (plan.md Epic 7 -- `~/code/plans/ccgm-dynamic-rule-injection/plan.md`)
------------------------------------------------------------------------------
This is the deterministic measurement oracle for the rest of the dynamic
rule-loading plan: it turns "did the right rule load?" from a model
judgment into a fact recorded on disk. Registers on the `InstructionsLoaded`
hook event with no matcher and appends one JSONL record per invocation to
`~/.claude/rule-loading/loaded-{YYYY-MM-DD}.jsonl`.

OBSERVED PAYLOAD SHAPE (confirmed live, `claude -p` 2.1.220, project-level
settings.json, recorded in decisions.md)
------------------------------------------------------------------------------
Claude Code invokes this hook ONCE PER LOADED INSTRUCTION FILE (not once per
session with a batched list). Each stdin payload is a flat JSON object:

    {
      "session_id": "...",
      "transcript_path": "...",
      "cwd": "...",
      "hook_event_name": "InstructionsLoaded",
      "file_path": "/absolute/path/to/the/loaded/file.md",
      "memory_type": "User",          # observed value; others are plausible
      "load_reason": "session_start"  # observed value; others are plausible
    }

This logger extracts those fields directly (so downstream parsing in
lib/loaded_log.py is a flat-field read, not a nested-list search) AND keeps
the full redacted raw payload under "raw" as a forward-compat safety net --
if Anthropic adds or renames a field, the raw payload still has it even
before the structured extraction above is updated.

DESIGN CONSTRAINTS (same as every CCGM hook)
------------------------------------------------------------------------------
  - NEVER blocks the host action: always exits 0, even on a malformed or
    empty stdin payload.
  - Applies hook_utils.redact_secrets() to the raw payload BEFORE it is
    stored, same as autoheal's event logger.
  - Uses hook_utils.file_locked_append() so concurrent clones / concurrent
    hook invocations within one session cannot interleave or tear a write.
  - Log directory is overridable via $CCGM_RULE_LOADING_DIR for tests.
"""
from __future__ import annotations

import datetime as _dt
import json
import os
import sys

sys.path.insert(0, os.path.expanduser("~/.claude/lib"))
import hook_utils  # noqa: E402


def _rule_loading_dir() -> str:
    """Resolve the rule-loading data directory. Tests can override via env."""
    override = os.environ.get("CCGM_RULE_LOADING_DIR")
    if override:
        return override
    return os.path.expanduser("~/.claude/rule-loading")


def _today_iso() -> str:
    return _dt.datetime.now(_dt.timezone.utc).date().isoformat()


def _now_iso() -> str:
    return _dt.datetime.now(_dt.timezone.utc).isoformat()


def _str_or_none(value: object) -> "str | None":
    return value if isinstance(value, str) and value else None


def _redacted_raw(data: dict) -> dict:
    """Return `data` with any secret-shaped substring redacted.

    Round-trips through JSON so the result is guaranteed JSON-serializable
    even if `data` contains non-JSON-native values (defensive; hook stdin is
    already parsed JSON so this is normally a no-op transform).
    """
    try:
        raw_text = json.dumps(data, default=str)
    except (TypeError, ValueError):
        return {}
    redacted_text = hook_utils.redact_secrets(raw_text)
    try:
        result = json.loads(redacted_text)
    except (json.JSONDecodeError, ValueError):
        return {}
    return result if isinstance(result, dict) else {}


def build_record(data: dict) -> dict:
    """Build the JSONL record for one InstructionsLoaded invocation.

    Pure function of `data` (plus wall-clock time) so it is directly
    testable without stdin or filesystem I/O.
    """
    return {
        "hook_event_name": _str_or_none(data.get("hook_event_name")) or "InstructionsLoaded",
        "timestamp": _now_iso(),
        "session_id": _str_or_none(data.get("session_id")),
        "cwd": _str_or_none(data.get("cwd")),
        "file_path": _str_or_none(data.get("file_path")),
        "memory_type": _str_or_none(data.get("memory_type")),
        "load_reason": _str_or_none(data.get("load_reason")),
        "raw": _redacted_raw(data),
    }


def main() -> None:
    try:
        data = hook_utils.read_hook_input()
        record = build_record(data)
        target = os.path.join(_rule_loading_dir(), "loaded-" + _today_iso() + ".jsonl")
        hook_utils.file_locked_append(target, json.dumps(record))
    except Exception:
        # NEVER block the session. A logger failure must be invisible to the
        # host event, same contract as every other CCGM observability hook.
        pass
    sys.exit(0)


if __name__ == "__main__":
    main()
lib (4)

lib/relevance_select.py

#!/usr/bin/env python3
"""Deterministic relevance-scoped rule selection for CCGM.

This is the pure, side-effect-free core of the opt-in relevance-injection
feature (issue #695). It answers one question:

    Given the installed modules and an (optional) task profile, which rule
    files should be surfaced for this session?

It does NOT read stdin, write files, or touch the network. The SessionStart
hook (relevance-inject.py) wires this library to Claude Code; everything that
can be tested in isolation lives here.

Design invariants (each pinned by a test in tests/):

  1. SAFETY CORE IS ALWAYS INCLUDED. Regardless of profile, the always-on
     minimal core (safety/permissions, confusion-protocol, TDD, verification,
     autonomy, ...) is selected. A bad profile can never drop an Iron Law.

  2. ABSENT `applicability` == ALWAYS. A module with no `applicability` field
     in its module.json is treated as always-applicable. This preserves
     today's behavior: before this feature, every installed rule loaded
     unconditionally, so the default for an unclassified module must remain
     "load it."

  3. EXPLICIT {"always": true} == ALWAYS. Same as absent, but declared.

  4. SELECTION IS DETERMINISTIC. Same inputs -> same ordered output. No
     randomness, no clock, no filesystem ordering leaking through. Output is
     sorted by (tier, module, file) so two runs are byte-identical.

The hook only ever calls this library when an explicit opt-in flag is set.
When the flag is unset the hook no-ops and Claude Code's normal
all-rules-always-loaded path is completely untouched. This library is dead
code in the default configuration; it cannot change default behavior.
"""
from __future__ import annotations

import json
import os
from typing import Iterable


# ---------------------------------------------------------------------------
# Safety core tiering.
#
# The tier ordering is the AUTHORITATIVE precedence for the always-on minimal
# core. It is documented in rules/relevance-injection.md as well; keep the two
# in sync. Lower tier number == higher precedence == surfaced first.
#
# These modules are ALWAYS selected, regardless of their module.json
# `applicability` field and regardless of the task profile. They are the Iron
# Laws that must hold in every session. Listing a module here is a deliberate
# statement that the rule is non-negotiable safety/discipline, not
# situational guidance.
# ---------------------------------------------------------------------------
SAFETY_CORE_TIERS: "list[list[str]]" = [
    # Tier 0 — safety / permissions / git-history protection. Highest precedence.
    ["git-workflow", "hooks"],
    # Tier 1 — confusion protocol: stop and ask at architectural forks.
    ["autonomy"],
    # Tier 2 — TDD + verification: no code without a failing test; no claim
    #          without fresh evidence.
    ["test-driven-development", "verification"],
    # Tier 3 — everything else in the core that should never be scoped away.
    ["systematic-debugging", "subagent-patterns"],
]


def safety_core_modules() -> "list[str]":
    """Flat, precedence-ordered list of the always-on core module names."""
    flat: "list[str]" = []
    for tier in SAFETY_CORE_TIERS:
        flat.extend(tier)
    return flat


def safety_core_tier(module: str) -> int:
    """Tier index for a core module, or a large sentinel for non-core.

    Used as the primary sort key so core rules sort ahead of situational
    rules and in their declared precedence order.
    """
    for idx, tier in enumerate(SAFETY_CORE_TIERS):
        if module in tier:
            return idx
    return len(SAFETY_CORE_TIERS) + 1


def is_safety_core(module: str) -> bool:
    """True iff `module` is part of the always-on safety core."""
    return safety_core_tier(module) <= len(SAFETY_CORE_TIERS)


# ---------------------------------------------------------------------------
# Applicability matching.
# ---------------------------------------------------------------------------
def _as_lower_set(values: "Iterable[str] | None") -> "set[str]":
    if not values:
        return set()
    return {str(v).strip().lower() for v in values if str(v).strip()}


def module_is_applicable(
    applicability: "dict | None",
    langs: "Iterable[str] | None" = None,
    task_types: "Iterable[str] | None" = None,
) -> bool:
    """Decide whether a module's rules apply to a given task profile.

    Rules (in order):

      * `applicability` is None or {} or {"always": true}  -> ALWAYS applies.
        (Backward-compat: an unclassified module loaded unconditionally
        before this feature, so it must continue to.)

      * Otherwise the module declares `langs` and/or `taskTypes` constraints.
        The module applies if the profile INTERSECTS any declared dimension:
          - if `langs` is declared, a profile lang in it makes it applicable;
          - if `taskTypes` is declared, a profile task type in it makes it
            applicable.
        Matching is OR across dimensions: a Python file (lang match) pulls in
        a module even if the task type does not match its taskTypes, and vice
        versa. This is deliberately permissive — over-inclusion is safe
        (you see a rule you did not strictly need); under-inclusion risks
        dropping a relevant discipline.

      * A module that declares constraints but matches NOTHING in the profile
        is excluded. This is the only case where a rule is dropped, and it can
        only ever apply to a module that explicitly opted out of "always".

    `applicability` shape:
        {"always": true}
        {"langs": ["python", "typescript"]}
        {"taskTypes": ["frontend", "css"]}
        {"langs": ["python"], "taskTypes": ["backend"]}
    """
    if not applicability:
        return True
    if applicability.get("always") is True:
        return True

    declared_langs = _as_lower_set(applicability.get("langs"))
    declared_tasks = _as_lower_set(applicability.get("taskTypes"))

    # A malformed entry with neither dimension is treated as "always" rather
    # than silently dropping the rule — fail safe toward inclusion.
    if not declared_langs and not declared_tasks:
        return True

    profile_langs = _as_lower_set(langs)
    profile_tasks = _as_lower_set(task_types)

    if declared_langs and (profile_langs & declared_langs):
        return True
    if declared_tasks and (profile_tasks & declared_tasks):
        return True
    return False


# ---------------------------------------------------------------------------
# module.json reading (the only filesystem touch; still no stdin/network).
# ---------------------------------------------------------------------------
def read_module_manifest(modules_dir: str, module: str) -> "dict | None":
    """Load modules/<module>/module.json, or None if absent/unparseable."""
    path = os.path.join(modules_dir, module, "module.json")
    try:
        with open(path, "r", encoding="utf-8") as fh:
            data = json.load(fh)
        return data if isinstance(data, dict) else None
    except (OSError, json.JSONDecodeError, ValueError):
        return None


def rule_files_for_module(manifest: "dict | None") -> "list[str]":
    """Return the target paths of all type=='rule' files in a manifest."""
    if not manifest:
        return []
    files = manifest.get("files")
    if not isinstance(files, dict):
        return []
    out: "list[str]" = []
    for entry in files.values():
        if isinstance(entry, dict) and entry.get("type") == "rule":
            target = entry.get("target")
            if isinstance(target, str) and target:
                out.append(target)
    return sorted(out)


# ---------------------------------------------------------------------------
# Top-level selection.
# ---------------------------------------------------------------------------
def select_modules(
    installed_modules: "Iterable[str]",
    modules_dir: str,
    langs: "Iterable[str] | None" = None,
    task_types: "Iterable[str] | None" = None,
) -> "list[str]":
    """Return the deterministic, precedence-ordered set of selected modules.

    A module is selected if EITHER:
      * it is part of the safety core (always), OR
      * its `applicability` matches the profile (absent/always counts as
        matching everything).

    Output ordering: safety-core tier first (by precedence), then everything
    else alphabetically. Always deduplicated.
    """
    installed = list(dict.fromkeys(installed_modules))  # de-dup, keep order
    selected: "set[str]" = set()

    for module in installed:
        if is_safety_core(module):
            selected.add(module)
            continue
        manifest = read_module_manifest(modules_dir, module)
        applicability = None
        if manifest:
            ap = manifest.get("applicability")
            applicability = ap if isinstance(ap, dict) else None
        if module_is_applicable(applicability, langs=langs, task_types=task_types):
            selected.add(module)

    return sorted(
        selected,
        key=lambda m: (safety_core_tier(m), m),
    )


def select_rule_files(
    installed_modules: "Iterable[str]",
    modules_dir: str,
    langs: "Iterable[str] | None" = None,
    task_types: "Iterable[str] | None" = None,
) -> "list[tuple[str, str]]":
    """Return [(module, rule_target_path), ...] for the selected modules.

    Deterministic: ordered by (safety-core tier, module, rule file).
    """
    out: "list[tuple[str, str]]" = []
    for module in select_modules(
        installed_modules, modules_dir, langs=langs, task_types=task_types
    ):
        manifest = read_module_manifest(modules_dir, module)
        for target in rule_files_for_module(manifest):
            out.append((module, target))
    return out

lib/loaded_log.py

"""Parsing library for the InstructionsLoaded measurement log (Epic 7).

Consumes the JSONL log written by
`modules/relevance-injection/hooks/instructions-loaded-log.py` at
`~/.claude/rule-loading/loaded-{YYYY-MM-DD}.jsonl` (or wherever
`$CCGM_RULE_LOADING_DIR` points a test at).

Two entry points, both pure functions of the filesystem (no hook I/O):

    parse_log(path) -> list[dict]
        Read a single JSONL log file into a list of record dicts.

    assert_loaded(path, rule_path) -> None
        Raise if `rule_path` was NOT recorded as loaded in the log at
        `path`. Raises a DIFFERENT, distinctly named exception depending
        on whether the log itself is missing (LogMissingError) versus
        present but not naming the rule (RuleNotLoadedError). This
        distinction is load-bearing for Epic 1's negative arm: a missing
        log must never be silently read as "the rule was absent", or a
        broken harness would masquerade as a passing negative assertion.

See modules/relevance-injection/hooks/instructions-loaded-log.py for the
observed payload shape this module parses.
"""
from __future__ import annotations

import json
import os


class LogMissingError(Exception):
    """The InstructionsLoaded log file does not exist at all.

    Distinct from RuleNotLoadedError on purpose: a missing log means the
    assertion could not be evaluated (the hook never fired, the wrong
    path was checked, the session never ran) -- it is not evidence that
    the rule was absent from a session that actually ran.
    """


class RuleNotLoadedError(Exception):
    """The log exists and parses, but no record names the given rule path."""


def parse_log(path: "str | os.PathLike") -> "list[dict]":
    """Parse a JSONL InstructionsLoaded log into a list of record dicts.

    - Missing file -> raises FileNotFoundError (standard, distinctly
      named; assert_loaded() below translates this into LogMissingError
      for its own contract).
    - Empty file -> returns [].
    - A line that is not valid JSON, or that parses to something other
      than a JSON object, is skipped -- never raised. One corrupt line
      must not prevent every other record in the log from being read.
    - A line carrying invalid UTF-8 never raises. The decode happens in
      the line iterator, outside any json.loads() guard, so without
      `errors="replace"` a single corrupt byte would abort the whole
      parse and discard every other record in the file -- including the
      valid ones written before it. With replacement, the undecodable
      bytes become U+FFFD and the line is then treated like any other:
      kept if it still parses as a JSON object, dropped if it does not.

      A retained record is safe for assert_loaded() for two reasons, and
      the weaker one is not sufficient alone. Exact and bare-form matches
      compare the whole path, which cannot equal a real rule path once it
      contains U+FFFD. The suffix branch compares only the tail, so in
      principle a record could carry corruption in its leading directories
      and still match a real rule -- but that record names the rule it
      claims to name, so treating it as loaded is correct, and the hook's
      own writes use ensure_ascii=True and can never emit invalid bytes at
      all. Corruption reaching this path is external, and it does not
      respect path boundaries.
    - A line that parses to a JSON object is appended to the result
      as-is.
    """
    if not os.path.isfile(path):
        raise FileNotFoundError(path)

    records: "list[dict]" = []
    with open(path, encoding="utf-8", errors="replace") as fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            try:
                parsed = json.loads(line)
            except (json.JSONDecodeError, ValueError):
                continue
            if isinstance(parsed, dict):
                records.append(parsed)
    return records


def _loaded_file_paths(records: "list[dict]") -> "set[str]":
    """Collect every `file_path` named across all records.

    Reads the top-level `file_path` field the hook extracts directly, and
    falls back to the same field inside the preserved `raw` payload -- so
    a record written before a future field-name change (or one that hit
    the extraction's `None` fallback for any reason) is not silently
    invisible to this check.
    """
    paths: "set[str]" = set()
    for record in records:
        file_path = record.get("file_path")
        if isinstance(file_path, str) and file_path:
            paths.add(file_path)

        raw = record.get("raw")
        if isinstance(raw, dict):
            raw_file_path = raw.get("file_path")
            if isinstance(raw_file_path, str) and raw_file_path:
                paths.add(raw_file_path)
    return paths


def _matches(loaded_path: str, rule_path: str) -> bool:
    """True if `loaded_path` (as recorded) corresponds to `rule_path`.

    Exact match covers the common case where the caller passes the same
    absolute path the hook recorded. Suffix match on a normalized
    "/"-separated tail covers a caller passing a shorter, module-relative
    path such as "rules/tailwind.md" against a recorded absolute path
    like "/Users/x/code/ccgm/modules/tailwind/rules/tailwind.md".
    """
    if loaded_path == rule_path:
        return True
    normalized_rule = rule_path.lstrip("/")
    return loaded_path.endswith("/" + normalized_rule) or loaded_path == normalized_rule


def assert_loaded(path: "str | os.PathLike", rule_path: str) -> None:
    """Assert that `rule_path` was recorded as a loaded instruction file.

    Raises:
        LogMissingError: the log file at `path` does not exist. This is
            NEVER treated as "the rule was not loaded" -- it means the
            assertion has no evidence to evaluate at all.
        RuleNotLoadedError: the log exists and parses, but no record
            names `rule_path` as a loaded file.

    Returns None (no exception) if at least one record names the rule.
    """
    try:
        records = parse_log(path)
    except FileNotFoundError as exc:
        raise LogMissingError(f"InstructionsLoaded log not found: {path}") from exc

    loaded_paths = _loaded_file_paths(records)
    for loaded_path in loaded_paths:
        if _matches(loaded_path, rule_path):
            return

    raise RuleNotLoadedError(
        f"{rule_path!r} was not recorded as loaded in {path} "
        f"({len(records)} record(s), {len(loaded_paths)} distinct file(s) loaded)"
    )

lib/rules_scope.py

#!/usr/bin/env python3
"""Generate a repo's `claudeMdExcludes` block (plan.md Epic 0.5, issue #952).

WHAT THIS DOES
--------------
Inspects a target repo, decides which INSTALLED CCGM rule files are
irrelevant to it, and proposes (or, with --write, applies) a
`claudeMdExcludes` array in that repo's `.claude/settings.json`. This turns
a per-machine hand-edit into a generated, reviewable, committable artifact
-- Claude Code's own `claudeMdExcludes` settings key already suppresses a
user-level `~/.claude/rules/*.md` file from loading when a project-layer
`.claude/settings.json` names it (verified empirically -- see "PATH
RESOLUTION" below for the specific gotcha that verification surfaced).

Dry run by default. Nothing is written unless --write is passed.

WHY THIS EXISTS (plan.md Epic 0.5)
-----------------------------------
`claudeMdExcludes` was recorded as a rejected alternative through six plan
reviews because a hand-edited, per-machine settings file "is not a
shippable product answer." Testing it found that objection only holds for
a HAND-edited file -- a GENERATED one is exactly as shippable as any other
CCGM artifact. This script is that generator. It is deliberately
independent of Epic 2's (deferred) per-rule-file tier/stakes taxonomy: it
falls back to (a) the manifest's existing `category: "tech-specific"` field
for repo-profile-gated exclusion, and (b) a small, conservative, hand-picked
list for repo-profile-INDEPENDENT "niche CCGM workflow" exclusion.

SAFETY RULES (enforced here AND by tests/test_rules_scope.py)
---------------------------------------------------------------
1. NEVER propose excluding a PINNED_FLOOR module's rules. PINNED_FLOOR is
   derived from `relevance_select.safety_core_modules()` (the seven
   SAFETY_CORE_TIERS modules) plus the same four additional pinned names
   plan.md section 3.3 names -- imported/derived from one place, not
   re-typed, so this list can never quietly drift from the platform's own
   safety-core definition.
2. NEVER write outside the target repo's `.claude/settings.json`, and
   MERGE rather than overwrite: an existing `claudeMdExcludes` array is
   extended and every other key in the file is preserved.
3. Dry run by default; `--write` is required to modify anything.

PATH RESOLUTION -- a load-bearing empirical finding
----------------------------------------------------
`claudeMdExcludes` matches against the REAL, symlink-resolved path of the
loaded instruction file -- NOT the `~/.claude/rules/<file>.md` symlink path
CCGM's installer creates under `linkMode`. This was verified directly
(2026-08-03, headless `claude -p`, project-level `.claude/settings.json`,
the `InstructionsLoaded` hook as the oracle -- same technique the Epic 0.5
experiment and Epic 7 used):

  - excluding `~/.claude/rules/mcp-development.md` (the symlink path,
    absolute or `~`-relative) did NOT suppress loading -- the file still
    appeared in the InstructionsLoaded log, correctly recorded at its
    real path `/Users/x/code/ccgm/modules/mcp-development/rules/mcp-development.md`.
  - excluding that REAL, resolved path DID suppress it (46 records
    instead of 47; the file was absent; every other rule still loaded).

So every path this module writes into `claudeMdExcludes` is
`os.path.realpath()` of the installed location. Under `linkMode` that
resolves to the canonical repo file; under a `--copy` install (no
symlink), `realpath()` of a plain file is the file itself -- so this one
code path is correct in both install modes without a branch.
"""
from __future__ import annotations

import argparse
import fnmatch
import json
import os
import sys
import tempfile

_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
    sys.path.insert(0, _HERE)

import relevance_select  # noqa: E402 -- reuse the existing pure helpers


# ---------------------------------------------------------------------------
# PINNED_FLOOR -- the single source of truth for "never propose excluding
# this module's rules." plan.md's intended future home for this constant
# is Epic 3's lib/rule_tiering.py (deferred, not built in this epic); until
# that lands, THIS is authoritative for the exclusion tool and should be
# imported from here rather than re-typed. Derived, not hardcoded twice:
# the safety-core half comes straight from relevance_select.safety_core_modules(),
# so the two lists can never silently drift apart.
# ---------------------------------------------------------------------------
PINNED_FLOOR: "tuple[str, ...]" = tuple(relevance_select.safety_core_modules()) + (
    "identity",
    "live-testing-guard",
    "git-worktrees",
    "model-vetting",
    "branch-guard",
)

TECH_SPECIFIC_CATEGORY = "tech-specific"

# Conservative, hand-picked fallback for Epic 2's not-yet-existing per-rule-
# file tier/stakes taxonomy (this epic's own "Inputs" contract: use Epic 2's
# assignment if it exists, else fall back to a conservative built-in list).
# Maps module name -> None (propose every rule file the module ships) or a
# set of specific module-relative rule targets (propose only that subset).
#
# Deliberately narrow and NOT repo-profile-gated: these are rules about a
# specific CCGM meta-workflow (a nightly pipeline, a visual-convergence
# loop, SSH to a configured remote box, ...) that is rarely in play
# regardless of the target repo's tech stack, so detecting relevance from
# repo files does not apply the way it does for the tech-specific category.
#
# `self-improving` ships TWO rule files and only one is listed: its other
# file, `rules/learnings-store.md`, is explicitly `high` stakes in the
# plan's own tier-assignment work (a destructive-git-operation risk), so it
# is never proposed here even though its sibling file in the same module
# is a safe, ordinary "index"-shaped rule. Modules omitted from this dict
# entirely are simply never proposed by this category -- always the safe
# direction, since it can only under-propose, never over-propose.
NICHE_MODULE_RULE_TARGETS: "dict[str, set[str] | None]" = {
    "agent-native": None,
    "argus": None,
    "autoheal": None,
    "browser-automation": None,
    "dreaming": None,
    "multi-agent": None,
    "remote-server": None,
    "self-improving": {"rules/self-improving.md"},
    "youtube-transcripts": None,
}

# Directories a repo-profile walk should never descend into: build output,
# dependency caches, and VCS metadata. Also skips any dot-directory.
_SKIP_DIRS = {
    "node_modules", "vendor", "dist", "build", "target", "out",
    "venv", ".venv", "__pycache__", "coverage",
}
_MAX_WALK_DEPTH = 4


def _walk(repo_path: str):
    """Yield (dirpath, dirnames, filenames) under `repo_path`, pruning
    heavy/irrelevant directories and bounding depth so this can never
    become an accidental full-disk walk on a large or deeply nested repo.
    `dirnames` is pruned in place (the `os.walk` topdown contract), so
    callers see the same pruned view this function used internally.
    """
    repo_path = os.path.abspath(repo_path)
    base_depth = repo_path.rstrip(os.sep).count(os.sep)
    for dirpath, dirnames, filenames in os.walk(repo_path):
        depth = dirpath.rstrip(os.sep).count(os.sep) - base_depth
        dirnames[:] = sorted(
            d for d in dirnames if d not in _SKIP_DIRS and not d.startswith(".")
        )
        if depth >= _MAX_WALK_DEPTH - 1:
            dirnames[:] = []
        yield dirpath, dirnames, filenames


def _has_file_matching(repo_path: str, patterns: "list[str]") -> bool:
    """True if any file under `repo_path` matches any of `patterns`
    (fnmatch-style, e.g. "tailwind.config.*")."""
    for _dirpath, _dirnames, filenames in _walk(repo_path):
        for fname in filenames:
            if any(fnmatch.fnmatch(fname, pat) for pat in patterns):
                return True
    return False


def _has_dir_named(repo_path: str, names: "list[str]") -> bool:
    """True if any directory under `repo_path` is named exactly one of `names`."""
    wanted = set(names)
    for _dirpath, dirnames, _filenames in _walk(repo_path):
        if wanted & set(dirnames):
            return True
    return False


def _package_json_dependency_names(path: str) -> "set[str]":
    try:
        with open(path, encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, ValueError):
        return set()
    if not isinstance(data, dict):
        return set()
    names: "set[str]" = set()
    for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
        section = data.get(key)
        if isinstance(section, dict):
            names.update(section.keys())
    return names


def _has_dependency_substring(repo_path: str, substrings: "list[str]") -> bool:
    """True if any package.json under `repo_path` declares a dependency
    whose name contains one of `substrings` (case-insensitive)."""
    needles = [s.lower() for s in substrings]
    for dirpath, _dirnames, filenames in _walk(repo_path):
        if "package.json" not in filenames:
            continue
        for dep in _package_json_dependency_names(os.path.join(dirpath, "package.json")):
            dep_l = dep.lower()
            if any(n in dep_l for n in needles):
                return True
    return False


def _text_file_contains(repo_path: str, filenames: "set[str]", substrings: "list[str]") -> bool:
    """True if any file under `repo_path` named one of `filenames` contains
    (case-insensitive) any of `substrings`. Used for Python-ecosystem
    manifests (requirements.txt, pyproject.toml) that have no single
    canonical dependency schema the way package.json does."""
    needles = [s.lower() for s in substrings]
    for dirpath, _dirnames, fnames in _walk(repo_path):
        for fname in fnames:
            if fname not in filenames:
                continue
            try:
                with open(os.path.join(dirpath, fname), encoding="utf-8", errors="ignore") as fh:
                    text = fh.read().lower()
            except OSError:
                continue
            if any(n in text for n in needles):
                return True
    return False


_PY_DEP_FILES = {"requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"}


def detect_repo_profile(repo_path: str) -> "dict[str, bool]":
    """Inspect `repo_path` for signals that a tech-specific CCGM module's
    rules are relevant to it. Returns {module_name: bool}; True means "this
    module's rules apply here -- do not propose excluding them."

    Bounded, read-only filesystem inspection only (module.json's declared
    `category: "tech-specific"` set drives which module names appear here
    -- see propose_excludes()). A repo this cannot positively identify
    (e.g. a bare Rust crate with no web/backend markers at all) yields
    every entry False, which is the conservative-toward-EXCLUSION direction
    for this category -- the opposite of PINNED_FLOOR's conservative-
    toward-INCLUSION default, and deliberately so: this category exists
    specifically to identify genuinely-irrelevant tech-specific rules.
    """
    tailwind = _has_file_matching(repo_path, ["tailwind.config.*"]) or _has_dependency_substring(
        repo_path, ["tailwindcss"]
    )
    shadcn = _has_file_matching(repo_path, ["components.json"]) or _has_dependency_substring(
        repo_path, ["shadcn"]
    )
    supabase = (
        _has_dir_named(repo_path, ["supabase"])
        or _has_dependency_substring(repo_path, ["supabase"])
        or _text_file_contains(repo_path, _PY_DEP_FILES, ["supabase"])
    )
    cloudflare = _has_file_matching(
        repo_path, ["wrangler.toml", "wrangler.json", "wrangler.jsonc"]
    ) or _has_dependency_substring(repo_path, ["wrangler", "cloudflare"])
    mcp_development = (
        _has_file_matching(repo_path, [".mcp.json", "mcp.json"])
        or _has_dependency_substring(repo_path, ["@modelcontextprotocol/sdk", "fastmcp"])
        or _text_file_contains(repo_path, _PY_DEP_FILES, ["fastmcp", "modelcontextprotocol"])
    )
    return {
        "tailwind": tailwind,
        "shadcn": shadcn,
        "supabase": supabase,
        "cloudflare": cloudflare,
        "mcp-development": mcp_development,
    }


def _resolved_rule_path(target: str, home: str) -> str:
    """Resolve a module-relative rule target (e.g. "rules/tailwind.md") to
    the REAL, symlink-resolved absolute path Claude Code actually loads.
    See the module docstring's "PATH RESOLUTION" section for why this
    matters -- the `~/.claude/rules/<file>.md` symlink path itself does
    NOT work as a `claudeMdExcludes` entry.
    """
    installed = os.path.join(home, ".claude", target)
    return os.path.realpath(installed)


def propose_excludes(
    repo_profile: "dict[str, bool]",
    modules_dir: str,
    installed_modules: "list[str]",
    home: "str | None" = None,
) -> "list[dict]":
    """Return the sorted list of exclude-candidate rows.

    Each row: {"module", "rule" (module-relative target), "category"
    ("tech-specific" or "niche"), "path" (the resolved absolute path to
    put in claudeMdExcludes)}.

    Never includes a PINNED_FLOOR module (checked explicitly here, in
    addition to being true by construction of the two candidate sets
    below -- belt and suspenders per this epic's own safety-rules
    contract). Never includes a module absent from `installed_modules`.
    """
    home = home or os.path.expanduser("~")
    installed = set(installed_modules)
    rows: "list[dict]" = []

    # --- Category 1: tech-specific, repo-profile-gated -----------------
    for module in sorted(installed):
        if module in PINNED_FLOOR:
            continue
        manifest = relevance_select.read_module_manifest(modules_dir, module)
        if not manifest or manifest.get("category") != TECH_SPECIFIC_CATEGORY:
            continue
        if repo_profile.get(module, False):
            continue  # detected as relevant in this repo -- keep it loaded
        for target in relevance_select.rule_files_for_module(manifest):
            rows.append(
                {
                    "module": module,
                    "rule": target,
                    "category": "tech-specific",
                    "path": _resolved_rule_path(target, home),
                }
            )

    # --- Category 2: niche CCGM workflow, NOT repo-profile-gated -------
    for module in sorted(NICHE_MODULE_RULE_TARGETS):
        if module in PINNED_FLOOR or module not in installed:
            continue
        manifest = relevance_select.read_module_manifest(modules_dir, module)
        if not manifest:
            continue
        subset = NICHE_MODULE_RULE_TARGETS[module]
        all_targets = relevance_select.rule_files_for_module(manifest)
        targets = all_targets if subset is None else [t for t in all_targets if t in subset]
        for target in targets:
            rows.append(
                {
                    "module": module,
                    "rule": target,
                    "category": "niche",
                    "path": _resolved_rule_path(target, home),
                }
            )

    rows.sort(key=lambda r: (r["module"], r["rule"]))
    return rows


def write_settings(settings_path: str, exclude_paths: "list[str]") -> dict:
    """Merge `exclude_paths` into `settings_path`'s `claudeMdExcludes`
    array, creating the file (and its parent directory) if missing.

    Preserves every other top-level key untouched. Deterministic and
    idempotent: calling this twice with the same `exclude_paths` produces
    a byte-identical file (sorted keys, sorted/deduplicated excludes list).

    Raises ValueError if `settings_path` exists but is not a JSON object --
    refusing to guess is safer than silently clobbering a hand-edited file.

    MACHINE-SCOPED OUTPUT -- read before committing this file. Every path
    this writes is the ABSOLUTE, machine-specific real path resolved by
    `_resolved_rule_path()` at generation time (this machine's `ccgmRoot`,
    realpath-resolved). If `<repo>/.claude/settings.json` is committed and
    pulled onto a different machine -- a teammate, or the same operator with
    a different `ccgmRoot` -- none of the absolute paths will match that
    machine's own installed rule files. The failure direction is safe: a
    path that resolves to nothing simply does not match anything Claude Code
    loads, so every "excluded" rule silently LOADS again for that user rather
    than some other rule being wrongly dropped. This generator does not
    detect or warn about that mismatch at write time; regenerating with
    `/rules-scope --write` on the second machine re-resolves the paths
    correctly. A relative or environment-variable-templated path form would
    remove this footgun if Claude Code's `claudeMdExcludes` supports one --
    that has not been tested here, and building it is a design change beyond
    this fix's scope.
    """
    existing: dict = {}
    if os.path.exists(settings_path):
        with open(settings_path, encoding="utf-8") as fh:
            text = fh.read()
        if text.strip():
            try:
                parsed = json.loads(text)
            except ValueError as exc:
                raise ValueError(
                    f"{settings_path} exists but is not valid JSON; refusing to "
                    f"overwrite it: {exc}"
                ) from exc
            if not isinstance(parsed, dict):
                raise ValueError(
                    f"{settings_path} does not contain a JSON object at the top level"
                )
            existing = parsed

    current = existing.get("claudeMdExcludes")
    current_list = list(current) if isinstance(current, list) else []
    existing["claudeMdExcludes"] = sorted(set(current_list) | set(exclude_paths))

    parent = os.path.dirname(settings_path)
    if parent:
        os.makedirs(parent, exist_ok=True)

    # Write atomically: tempfile in the same directory, then os.replace().
    #
    # open(path, "w") truncates to zero bytes AT OPEN TIME, before any content
    # is written. An interrupt between the open and the close -- Ctrl-C, an OOM
    # kill, a full disk -- would leave the user's settings.json empty or
    # half-written, destroying every pre-existing key: hook registrations,
    # permission rules, a hand-maintained claudeMdExcludes. This function's
    # whole contract is to merge into that file without disturbing anything
    # else, so losing it on a crash is the one failure it must not have.
    #
    # os.replace() is atomic within a filesystem, and the tempfile is created
    # alongside the target so the rename never crosses a device boundary. Same
    # pattern as modules/dreaming/lib/dream_analyze.py::_write_json_atomic.
    fd, tmp_path = tempfile.mkstemp(
        dir=parent or ".", prefix=".settings-", suffix=".json.tmp"
    )
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as fh:
            fh.write(json.dumps(existing, indent=2, sort_keys=True))
            fh.write("\n")
        os.replace(tmp_path, settings_path)
    except BaseException:
        # Leave the original untouched on any failure, KeyboardInterrupt included.
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
    return existing


# ---------------------------------------------------------------------------
# CLI wiring -- I/O only; every function above is importable and testable
# without a real ~/.claude install.
# ---------------------------------------------------------------------------
def _default_manifest_path() -> str:
    return os.path.expanduser("~/.claude/.ccgm-manifest.json")


def _load_manifest(path: str) -> "dict | None":
    try:
        with open(path, encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, ValueError):
        return None
    return data if isinstance(data, dict) else None


def _token_estimate(path: str) -> int:
    try:
        with open(path, "rb") as fh:
            return len(fh.read()) // 4
    except OSError:
        return 0


def build_arg_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description=(
            "Propose (and, with --write, apply) a claudeMdExcludes block for a "
            "repo's installed-but-irrelevant CCGM rules (plan.md Epic 0.5)."
        )
    )
    p.add_argument("repo_path", nargs="?", default=".", help="Repo to scope (default: cwd)")
    p.add_argument(
        "--write",
        action="store_true",
        help="Write the proposal into <repo>/.claude/settings.json. Default is a dry run.",
    )
    p.add_argument(
        "--manifest",
        default=None,
        help="Override path to the installed CCGM manifest (mainly for testing).",
    )
    return p


def main(argv: "list[str] | None" = None) -> int:
    args = build_arg_parser().parse_args(argv)
    repo_path = os.path.abspath(args.repo_path)

    manifest_path = args.manifest or _default_manifest_path()
    manifest = _load_manifest(manifest_path)
    if not manifest:
        print(f"No installed CCGM manifest found at {manifest_path}. Nothing to scope.")
        return 1

    ccgm_root = manifest.get("ccgmRoot")
    installed_modules = manifest.get("modules")
    if not isinstance(ccgm_root, str) or not ccgm_root or not isinstance(installed_modules, list):
        print(f"{manifest_path} is missing 'ccgmRoot' or 'modules'; cannot proceed.")
        return 1

    modules_dir = os.path.join(ccgm_root, "modules")
    profile = detect_repo_profile(repo_path)
    proposed = propose_excludes(profile, modules_dir, installed_modules)

    if not proposed:
        print(f"No exclusion candidates found for {repo_path}.")
        return 0

    print(f"Proposed claudeMdExcludes for {repo_path}:\n")
    header = f"{'MODULE':<20} {'CATEGORY':<14} {'RULE':<40} TOKENS"
    print(header)
    total_tokens = 0
    for row in proposed:
        tokens = _token_estimate(row["path"])
        total_tokens += tokens
        print(f"{row['module']:<20} {row['category']:<14} {row['rule']:<40} {tokens}")
    print(f"\n{len(proposed)} rule file(s), ~{total_tokens} tokens.")

    if not args.write:
        print("\nDry run -- nothing written. Re-run with --write to apply.")
        return 0

    settings_path = os.path.join(repo_path, ".claude", "settings.json")
    write_settings(settings_path, [row["path"] for row in proposed])
    print(f"\nWrote {len(proposed)} exclude(s) to {settings_path}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

lib/applicability-schema.json

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "https://ccgm/relevance-injection/applicability-schema.json",
  "title": "module.json applicability field",
  "description": "Optional field on a CCGM module.json that scopes when the module's rules are relevant. ABSENT or {\"always\": true} == always applicable (preserves pre-feature behavior where every installed rule loaded unconditionally). This schema documents the field; it is consumed only by the opt-in relevance-injection feature and is ignored by the default install path.",
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "always": {
      "type": "boolean",
      "description": "When true, the module's rules are always surfaced regardless of task profile. Equivalent to omitting the applicability field entirely."
    },
    "langs": {
      "type": "array",
      "description": "Programming languages / ecosystems this module's rules pertain to. The module is selected when the session's language profile intersects this list. Lowercase, e.g. python, typescript, javascript, ruby, go, rust, css.",
      "items": { "type": "string" },
      "uniqueItems": true
    },
    "taskTypes": {
      "type": "array",
      "description": "Task categories this module's rules pertain to. The module is selected when the session's task profile intersects this list. Lowercase, e.g. frontend, backend, database, infra, testing, debugging, docs, design.",
      "items": { "type": "string" },
      "uniqueItems": true
    }
  },
  "examples": [
    { "always": true },
    { "langs": ["python"] },
    { "taskTypes": ["frontend", "design"] },
    { "langs": ["typescript", "javascript"], "taskTypes": ["frontend"] }
  ]
}
settings (1)

settings.partial.json

Merged into ~/.claude/settings.json -- a fragment, not a replacement.

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "python3 $HOME/.claude/hooks/relevance-inject.py",
            "timeout": 5000
          }
        ]
      }
    ],
    "InstructionsLoaded": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 $HOME/.claude/hooks/instructions-loaded-log.py",
            "timeout": 5000
          }
        ]
      }
    ]
  }
}