From ab3f5fbc9e35eb8423ac2bbc7e7a19c1ea689871 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 30 Oct 2026 22:20:51 -0700 Subject: [PATCH 01/19] feat(repo): repo budget counts .claude/rules Signed-off-by: Nikolay Petrov --- ...030_180000_napetrov_budget-claude-rules.md | 11 +++ dokimi/docs/architecture.md | 6 +- dokimi/docs/repo-evaluation-contract.md | 6 +- dokimi/docs/what-it-measures.md | 2 +- dokimi/dokimi/commands/repo.py | 2 +- dokimi/dokimi/repos/budget.py | 55 ++++++++++- dokimi/dokimi/repos/harness_profile.py | 5 + dokimi/dokimi/repos/profiles/claude-code.yaml | 2 + dokimi/dokimi/schemas/harness_profile.v1.json | 1 + dokimi/dokimi/schemas/repo_budget.v1.json | 2 +- dokimi/tests/test_repo_budget_rules.py | 91 +++++++++++++++++++ dokimi/tests/test_repo_budget_schema.py | 5 +- 12 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md create mode 100644 dokimi/tests/test_repo_budget_rules.py diff --git a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md new file mode 100644 index 000000000..839de418b --- /dev/null +++ b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md @@ -0,0 +1,11 @@ +### Added + +- **`repo budget` counts `.claude/rules`** — every `.claude/rules/**/*.md` file is a `rule` entry. + A rule in the root `.claude/rules/` with no `paths` frontmatter is `start`; one with `paths`, and + every rule of a subdirectory's `.claude/rules/`, is `lazy`. The frontmatter is not counted, since + Claude Code removes it before loading the rule, and an `@` path in a rule is not followed as an + import. Measured on Claude Code 2.1.304: a root rule raised the first prompt by its size, a scoped + or nested one did not, and a rule's `@extra.md` was not expanded. `start` was too low by + every root rule before; `repo_budget.v1` widens `kind` with `rule`. Rules are counted for a + harness profile with the new optional `guidance.rules: true` (`claude-code` only): Codex and + Copilot do not read `.claude/rules`. diff --git a/dokimi/docs/architecture.md b/dokimi/docs/architecture.md index cded3a58b..65094f9c5 100644 --- a/dokimi/docs/architecture.md +++ b/dokimi/docs/architecture.md @@ -300,8 +300,10 @@ next release. after stop-word removal when both have 3+ words left), on AGENTS.md files turned off, on unreadable files (non-UTF-8, dangling or out-of-tree link), and on imports past the harness's hop limit, each of which also trips - `--fail-on-warning`. `start` is not an upper bound: it is too low by what it does not count - (`.claude/rules/*.md` files; only `@path.md` imports are followed, so `@notes.txt` adds nothing; + `--fail-on-warning`. For a profile with `guidance.rules` (Claude Code), `.claude/rules/**/*.md` + files are `rule` entries: `start` in the root `.claude/rules/` without `paths` frontmatter, `lazy` + with it or in a subdirectory's, the frontmatter not counted. `start` is not an upper bound: it is too low by what it does not count + (only `@path.md` imports are followed, so `@notes.txt` adds nothing; skills are read only from real `.claude/skills/*/SKILL.md` directories at the root, so a skill behind a linked directory is not seen, and a nested `/.claude/skills/` skill, which Claude Code loads on the first read in that subdirectory, is not counted; and imports past the profile's `guidance.import_max_depth`, diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index bc60bae94..44ede2c1c 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -46,8 +46,10 @@ SPDX-License-Identifier: Apache-2.0 > no LLM call): root CLAUDE.md files and their `@` imports plus the skill listing at > start (AGENTS.md files and their imports too, unless a root CLAUDE*, `.claude/CLAUDE.md` or > `CLAUDE.local.md` turns them off), nested guidance lazily, skill bodies on demand, and AGENTS.md -> files a root CLAUDE* turns off. The `start` total is not an upper bound: it is too low by what it does -> not count (`.claude/rules/*.md` files, non-`.md` imports, skills behind a linked directory, nested +> files a root CLAUDE* turns off. For a profile with `guidance.rules` (`claude-code`), `.claude/rules/**/*.md` files are `rule` entries: at start in the root +> `.claude/rules/` unless their frontmatter has `paths`, lazily otherwise and in a subdirectory's +> `.claude/rules/`; an `@` path in a rule is not followed. The `start` total is not an upper bound: it is too low by what it does +> not count (non-`.md` imports, skills behind a linked directory, nested > `.claude/skills/` skills, and > imports past the profile's `guidance.import_max_depth`, four hops for Claude Code, which warns > naming what was skipped), and too high by whatever the harness cuts from the skill listing. diff --git a/dokimi/docs/what-it-measures.md b/dokimi/docs/what-it-measures.md index 265663aea..bba4faf11 100644 --- a/dokimi/docs/what-it-measures.md +++ b/dokimi/docs/what-it-measures.md @@ -53,7 +53,7 @@ manifest it owns: - `dokimi repo scan` (L0, no LLM): do the links, paths, identifiers, commands and build labels the guidance names exist in the tree? -- `dokimi repo budget` (no LLM): how many characters of guidance and skill +- `dokimi repo budget` (no LLM): how many characters of guidance, `.claude/rules` and skill listing every model call pays for at start-up, what loads lazily or on demand, and which AGENTS.md files a CLAUDE.md turns off. - `dokimi repo reach` (L1): which guidance files does a given harness actually diff --git a/dokimi/dokimi/commands/repo.py b/dokimi/dokimi/commands/repo.py index 88f64ad09..ce0d0dd6b 100644 --- a/dokimi/dokimi/commands/repo.py +++ b/dokimi/dokimi/commands/repo.py @@ -343,7 +343,7 @@ def cmd_repo_budget(args: argparse.Namespace) -> None: print(f"✗ {exc}", file=sys.stderr) sys.exit(EXIT_USAGE) _, tree = _source_tree(args) - payload = budget(tree, profile.guidance_names, profile.import_max_depth) + payload = budget(tree, profile.guidance_names, profile.import_max_depth, profile.reads_claude_rules) if args.out: try: save_artifact("repo_budget", payload, Path(args.out)) diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index 1351e01e7..411e1b3ea 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -7,12 +7,15 @@ starts at the repository root: - **start**: guidance files at the root, the files a root ``CLAUDE*`` or ``AGENTS.md`` file - ``@import``s (``AGENTS.md`` only when it is not turned off), and the skill + ``@import``s (``AGENTS.md`` only when it is not turned off), the root ``.claude/rules/**/*.md`` + files with no ``paths`` frontmatter when the harness reads rules (their own ``@`` paths are not + imports), and the skill listing (each project skill's ``name``, plus its ``description`` and ``when_to_use`` up to 1,536 characters together; a skill with ``disable-model-invocation: true`` is not listed). Every model call pays for these. -- **lazy**: guidance files in subdirectories, and what a nested CLAUDE.md or AGENTS.md imports, loaded - when the agent works in that subtree. +- **lazy**: guidance files in subdirectories, what a nested CLAUDE.md or AGENTS.md imports, and the + rules of a subdirectory's ``.claude/rules/``, loaded when the agent works in that subtree; rules + with ``paths``, loaded when the agent reads a matching file. - **on demand**: skill bodies, loaded when the agent invokes the skill. - **off**: AGENTS.md files the harness will not read: a root CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md is present and no CLAUDE.md imports them (measured on Claude Code 2.1.286; the reach probe re-measures). @@ -37,6 +40,10 @@ from dokimi.repos.source import RepoTree SKILL_GLOB = re.compile(r"^\.claude/skills/[^/]+/SKILL\.md$") +# Rules are discovered recursively, in the root .claude/rules/ and in a subdirectory's (docs: Memory). +RULE_GLOB = re.compile(r"^(?:.+/)?\.claude/rules/(?:.+/)?[^/]+\.md$") +_FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\Z)", re.S | re.M) +_PATHS_KEY = re.compile(r"^paths[ \t]*:", re.M) MCP_FILE = ".mcp.json" # Claude Code truncates description + when_to_use at 1,536 characters in the listing (docs: Skills). LISTING_TEXT_CAP = 1536 @@ -58,7 +65,8 @@ _STOP = frozenset("a an and the of to for in on with use when or is are be this that it its from by as at".split()) -def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: int | None = None) -> dict[str, Any]: +def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: int | None = None, + rules: bool = False) -> dict[str, Any]: files = tree.files() names = set(guidance_names) every = [f for f in files if f.rsplit("/", 1)[-1] in names] @@ -116,6 +124,7 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in unreadable_warnings.append(warning) entries.append({"path": f, "kind": "guidance", "when": when, "chars": chars}) counted = {_target(tree, f) for f in guidance} + entries += _rules(tree, files if rules else [], counted, start_t, unreadable_warnings) for f in sorted((at_start | lazily) - set(guidance), key=lambda p: (p not in at_start, p)): if _is_resolvable(tree, f) and _target(tree, f) not in counted: counted.add(_target(tree, f)) @@ -217,6 +226,44 @@ def _chars(tree: RepoTree, path: str) -> tuple[int, str | None]: return 0, f"{path}: not readable ({_unreadable_reason(err)}), counted as 0" +def _rules(tree: RepoTree, files: list[str], counted: set[str], start_t: set[str], + warnings: list[str]) -> list[dict[str, Any]]: + """The ``rule`` entries of ``files``, skipping files already ``counted`` (and adding theirs). + + ``budget`` passes no files for a harness that does not read ``.claude/rules`` (``guidance.rules``). + """ + out = [] + for f in sorted(f for f in files if RULE_GLOB.match(f)): + if _target(tree, f) in counted: + continue # a link to a file already counted, or a rule that is also a guidance file + counted.add(_target(tree, f)) + chars, scoped, warning = _rule(tree, f) + if warning: + warnings.append(warning) + # a CLAUDE.md that imports a rule at start loads it then, whatever its frontmatter says + when = "start" if (f.startswith(".claude/rules/") and not scoped) or _target(tree, f) in start_t else "lazy" + out.append({"path": f, "kind": "rule", "when": when, "chars": chars}) + return out + + +def _rule(tree: RepoTree, path: str) -> tuple[int, bool, str | None]: + """Return (chars in context, has ``paths``, warning) for a ``.claude/rules`` file. + + Claude Code removes the frontmatter before loading a rule, so it is not counted. A rule with + ``paths`` in its frontmatter loads only when the agent reads a matching file; measured on + Claude Code 2.1.304, that holds even when the YAML does not parse, which the docs say + loads the rule unconditionally. + """ + try: + text = tree.read_text(path) + except (OSError, ValueError, RuntimeError) as err: # UnicodeDecodeError is a ValueError; RuntimeError a loop + return 0, False, f"{path}: not readable ({_unreadable_reason(err)}), counted as 0" + m = _FRONTMATTER.match(text) + if not m: + return len(text), False, None + return len(text) - m.end(), bool(_PATHS_KEY.search(m.group(1))), None + + def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None) -> tuple[set[str], list[tuple[str, str]]]: """Files the CLAUDE.md ``roots`` ``@import``, transitively (relative to the importing file). diff --git a/dokimi/dokimi/repos/harness_profile.py b/dokimi/dokimi/repos/harness_profile.py index 2d7df27d8..c59d6b4e9 100644 --- a/dokimi/dokimi/repos/harness_profile.py +++ b/dokimi/dokimi/repos/harness_profile.py @@ -76,6 +76,11 @@ def import_max_depth(self) -> int | None: depth = self.guidance.get("import_max_depth") return int(depth) if depth is not None else None + @property + def reads_claude_rules(self) -> bool: + """Whether the harness reads ``.claude/rules/**/*.md`` (``guidance.rules``).""" + return bool(self.guidance.get("rules")) + def applied_args(self, tools: str, present: frozenset[str]) -> list[str]: """The ``args_if_present`` names :meth:`argv` adds: those in ``present``, in the ``all`` tool mode only.""" if tools != "all": # --tools does not limit MCP tools: only here is the repo's MCP config part of the arm diff --git a/dokimi/dokimi/repos/profiles/claude-code.yaml b/dokimi/dokimi/repos/profiles/claude-code.yaml index 54fdf12b2..a180674a7 100644 --- a/dokimi/dokimi/repos/profiles/claude-code.yaml +++ b/dokimi/dokimi/repos/profiles/claude-code.yaml @@ -84,6 +84,8 @@ guidance: imports: "@path" # Memory docs (code.claude.com/docs/en/memory): imports recurse "with a maximum depth of four hops". import_max_depth: 4 + # .claude/rules/**/*.md: measured on CLI 2.1.304, a root rule with no `paths` loads at start. + rules: true notes: >- Measured on CLI 2.1.286: it reads AGENTS.md only when no CLAUDE.md is in the directory chain it starts in, and one CLAUDE.md diff --git a/dokimi/dokimi/schemas/harness_profile.v1.json b/dokimi/dokimi/schemas/harness_profile.v1.json index 956b85b2d..3cb26b746 100644 --- a/dokimi/dokimi/schemas/harness_profile.v1.json +++ b/dokimi/dokimi/schemas/harness_profile.v1.json @@ -52,6 +52,7 @@ "resolution": {"type": "string"}, "imports": {"type": ["string", "null"]}, "import_max_depth": {"type": "integer", "minimum": 1, "description": "How many hops of @imports the harness follows from a guidance file; absent: unlimited."}, + "rules": {"type": "boolean", "description": "The harness reads .claude/rules/**/*.md (repo budget counts them); absent: false."}, "notes": {"type": "string"} } }, diff --git a/dokimi/dokimi/schemas/repo_budget.v1.json b/dokimi/dokimi/schemas/repo_budget.v1.json index b8d6a43d8..27640a5b4 100644 --- a/dokimi/dokimi/schemas/repo_budget.v1.json +++ b/dokimi/dokimi/schemas/repo_budget.v1.json @@ -17,7 +17,7 @@ "additionalProperties": false, "properties": { "path": {"type": "string"}, - "kind": {"enum": ["guidance", "import", "skill listing", "skill body"], "description": "`import` is a file a CLAUDE.md or AGENTS.md `@import`s; a skill has a `skill listing` entry unless it is hidden from the model, and always a `skill body` entry."}, + "kind": {"enum": ["guidance", "import", "rule", "skill listing", "skill body"], "description": "`import` is a file a CLAUDE.md or AGENTS.md `@import`s; `rule` is a `.claude/rules/**/*.md` file, `start` at the root unless its frontmatter has `paths`; a skill has a `skill listing` entry unless it is hidden from the model, and always a `skill body` entry."}, "when": {"enum": ["start", "lazy", "on demand", "off"]}, "chars": {"type": "integer", "minimum": 0, "description": "Characters in context; 0 for an unreadable file, which is also named in `warnings`."} } diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py new file mode 100644 index 000000000..295d95589 --- /dev/null +++ b/dokimi/tests/test_repo_budget_rules.py @@ -0,0 +1,91 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""`repo budget` and `.claude/rules`: when each rule loads, and what of it is counted.""" + +from __future__ import annotations + +import pytest + +from dokimi.repos.budget import budget +from dokimi.repos.source import WorkTree + +NAMES = ("CLAUDE.md", "CLAUDE.local.md", "AGENTS.md") + + +def _tree(tmp_path, files): + for rel, text in files.items(): + p = tmp_path / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + return WorkTree(tmp_path) + + +def _by_path(payload): + return {(e["path"], e["kind"]): e for e in payload["entries"]} + + +@pytest.mark.outcome("cjm-repo-budget") +def test_rules_start_at_the_root_unless_scoped_and_lazily_in_a_subdirectory(tmp_path): + # Measured on Claude Code 2.1.304: a root rule raised the first prompt by its size; a scoped one, + # a nested one and a rule's @path did not (scratch probe, haiku, first-call prompt tokens). + scoped = "---\npaths:\n - \"src/**\"\n---\nscoped body\n" + payload = budget(_tree(tmp_path, { + ".claude/rules/plain.md": "plain body\n", + ".claude/rules/deep/x.md": "deep body\n", + ".claude/rules/scoped.md": scoped, + ".claude/rules/bad.md": "---\npaths: [unclosed\n---\nbad yaml body\n", + ".claude/rules/imp.md": "See @extra.md\n", + "extra.md": "never loaded\n", + ".claude/rules/notes.txt": "not a rule\n", + "sub/.claude/rules/n.md": "nested body\n", + }), NAMES, rules=True) + got = _by_path(payload) + rules = {p: (e["when"], e["chars"]) for (p, k), e in got.items() if k == "rule"} + assert rules == { + ".claude/rules/plain.md": ("start", len("plain body\n")), + ".claude/rules/deep/x.md": ("start", len("deep body\n")), + ".claude/rules/imp.md": ("start", len("See @extra.md\n")), + # the frontmatter is removed before the rule loads; `paths` holds it back even unparsed + ".claude/rules/scoped.md": ("lazy", len("scoped body\n")), + ".claude/rules/bad.md": ("lazy", len("bad yaml body\n")), + "sub/.claude/rules/n.md": ("lazy", len("nested body\n")), + } + assert ("extra.md", "import") not in got + assert payload["totals"]["start"] == len("plain body\n") + len("deep body\n") + len("See @extra.md\n") + assert payload["warnings"] == [] + + +def test_a_rule_does_not_turn_agents_md_off(tmp_path): + got = _by_path(budget(_tree(tmp_path, {"AGENTS.md": "root\n", ".claude/rules/r.md": "rule\n"}), NAMES, rules=True)) + assert got[("AGENTS.md", "guidance")]["when"] == "start" + assert got[(".claude/rules/r.md", "rule")]["when"] == "start" + + +def test_a_rule_a_root_claude_md_imports_is_one_start_entry(tmp_path): + got = _by_path(budget(_tree(tmp_path, { + "CLAUDE.md": "@.claude/rules/api.md\n", + ".claude/rules/api.md": "---\npaths: src/**\n---\napi\n", + }), NAMES, rules=True)) + assert got[(".claude/rules/api.md", "rule")]["when"] == "start" + assert (".claude/rules/api.md", "import") not in got + + +def test_an_unreadable_rule_is_counted_as_zero_and_named(tmp_path): + (tmp_path / ".claude/rules").mkdir(parents=True) + (tmp_path / ".claude/rules/bin.md").write_bytes(b"\xff\xfe\x00") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/bin.md", "rule")]["chars"] == 0 + assert payload["warnings"] == [".claude/rules/bin.md: not readable (non-UTF-8 or binary), counted as 0"] + + +def test_rules_are_counted_only_for_a_harness_that_reads_them(tmp_path): + from dokimi.repos.harness_profile import load_profile + + tree = _tree(tmp_path, {"AGENTS.md": "root\n", ".claude/rules/r.md": "rule\n"}) + for name in ("codex", "copilot"): + profile = load_profile(name) + assert not profile.reads_claude_rules + got = budget(tree, profile.guidance_names, profile.import_max_depth, profile.reads_claude_rules) + assert not [e for e in got["entries"] if e["kind"] == "rule"], name + assert load_profile("claude-code").reads_claude_rules diff --git a/dokimi/tests/test_repo_budget_schema.py b/dokimi/tests/test_repo_budget_schema.py index 2108ff392..3d1e978c0 100644 --- a/dokimi/tests/test_repo_budget_schema.py +++ b/dokimi/tests/test_repo_budget_schema.py @@ -18,12 +18,13 @@ def _populated_tree(root): - """Guidance, an import, two near-duplicate skills, a hidden skill, an MCP server and a warning.""" + """Guidance, an import, a rule, two near-duplicate skills, a hidden skill, an MCP server and a warning.""" files = { "CLAUDE.md": "See @docs/style.md for the style.\n", "AGENTS.md": "Agents read this.\n", # turned off by CLAUDE.md: a warning "docs/style.md": "Use four spaces.\n", "sub/CLAUDE.md": "Nested guidance.\n", + ".claude/rules/testing.md": "Run the tests.\n", ".claude/skills/review/SKILL.md": SKILL.format(name="review", description=DESCRIPTION), ".claude/skills/audit/SKILL.md": SKILL.format(name="audit", description=DESCRIPTION), ".claude/skills/hidden/SKILL.md": "---\nname: hidden\ndisable-model-invocation: true\n---\nBody.\n", @@ -47,7 +48,7 @@ def test_json_is_a_stamped_repo_budget_artifact(tmp_path, capsys): assert data["schema_version"] == "repo_budget.v1" validate_artifact("repo_budget", data) # every field is populated, so the schema was checked against more than empty lists - assert {e["kind"] for e in data["entries"]} == {"guidance", "import", "skill listing", "skill body"} + assert {e["kind"] for e in data["entries"]} == {"guidance", "import", "rule", "skill listing", "skill body"} assert {e["when"] for e in data["entries"]} == {"start", "lazy", "on demand", "off"} assert data["skills"] == 3 assert [(d["a"], d["b"]) for d in data["duplicate_skills"]] == [("audit", "review")] From d4d63e8ec27178ed91a3cf87bed90aeeebde3c87 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 30 Oct 2026 22:37:20 -0700 Subject: [PATCH 02/19] fix(repo): rules: paths that names no pattern starts, warn on a linked rules dir, claude_rules key Signed-off-by: Nikolay Petrov --- ...030_180000_napetrov_budget-claude-rules.md | 20 +++++----- dokimi/docs/architecture.md | 10 +++-- dokimi/docs/repo-evaluation-contract.md | 11 +++--- dokimi/dokimi/repos/budget.py | 35 ++++++++++++++---- dokimi/dokimi/repos/harness_profile.py | 4 +- dokimi/dokimi/repos/profiles/claude-code.yaml | 2 +- dokimi/dokimi/schemas/harness_profile.v1.json | 2 +- dokimi/dokimi/schemas/repo_budget.v1.json | 2 +- dokimi/tests/test_repo_budget_rules.py | 37 +++++++++++++++++++ 9 files changed, 92 insertions(+), 31 deletions(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md index 839de418b..03c931c35 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md @@ -1,11 +1,13 @@ ### Added -- **`repo budget` counts `.claude/rules`** — every `.claude/rules/**/*.md` file is a `rule` entry. - A rule in the root `.claude/rules/` with no `paths` frontmatter is `start`; one with `paths`, and - every rule of a subdirectory's `.claude/rules/`, is `lazy`. The frontmatter is not counted, since - Claude Code removes it before loading the rule, and an `@` path in a rule is not followed as an - import. Measured on Claude Code 2.1.304: a root rule raised the first prompt by its size, a scoped - or nested one did not, and a rule's `@extra.md` was not expanded. `start` was too low by - every root rule before; `repo_budget.v1` widens `kind` with `rule`. Rules are counted for a - harness profile with the new optional `guidance.rules: true` (`claude-code` only): Codex and - Copilot do not read `.claude/rules`. +- **`repo budget` counts `.claude/rules`** — a `.claude/rules/**/*.md` file is a `rule` entry. + A rule in the root `.claude/rules/` whose `paths` frontmatter names no pattern is `start`; one + whose `paths` does, and every rule of a subdirectory's `.claude/rules/`, is `lazy`. The + frontmatter is not counted, since Claude Code removes it before loading the rule, and an `@` + path in a rule is not followed as an import. A link to a directory under `.claude/rules` is a + warning: the rules behind it are not counted. Measured on Claude Code 2.1.304: a root rule + raised the first prompt by its size, `paths: []` included; a scoped or nested one loaded only + once the agent read a file it covers; a rule's `@extra.md` was not expanded. `start` was too + low by every root rule before; `repo_budget.v1` widens `kind` with `rule`. Rules are counted + for a harness profile with the new optional `guidance.claude_rules: true` (`claude-code` + only); the Codex and Copilot profiles leave it unset. diff --git a/dokimi/docs/architecture.md b/dokimi/docs/architecture.md index 65094f9c5..90a4f10b1 100644 --- a/dokimi/docs/architecture.md +++ b/dokimi/docs/architecture.md @@ -300,12 +300,14 @@ next release. after stop-word removal when both have 3+ words left), on AGENTS.md files turned off, on unreadable files (non-UTF-8, dangling or out-of-tree link), and on imports past the harness's hop limit, each of which also trips - `--fail-on-warning`. For a profile with `guidance.rules` (Claude Code), `.claude/rules/**/*.md` - files are `rule` entries: `start` in the root `.claude/rules/` without `paths` frontmatter, `lazy` - with it or in a subdirectory's, the frontmatter not counted. `start` is not an upper bound: it is too low by what it does not count + `--fail-on-warning`. For a profile with `guidance.claude_rules` (Claude Code), + `.claude/rules/**/*.md` files are `rule` entries: `start` in the root `.claude/rules/` when + `paths` names no pattern, `lazy` when it does or in a subdirectory's `.claude/rules/`, the + frontmatter not counted. `start` is not an upper bound: it is too low by what it does not count (only `@path.md` imports are followed, so `@notes.txt` adds nothing; skills are read only from real `.claude/skills/*/SKILL.md` directories at the root, so a skill - behind a linked directory is not seen, and a nested `/.claude/skills/` skill, which Claude + behind a linked directory is not seen, nor are the rules behind one (a warning names it), and a + nested `/.claude/skills/` skill, which Claude Code loads on the first read in that subdirectory, is not counted; and imports past the profile's `guidance.import_max_depth`, four hops for Claude Code, which warn naming what was skipped). It is too high by whatever the harness cuts from the skill listing: the listing is charged in full, and the harness cuts it past diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index 44ede2c1c..b08431d85 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -46,11 +46,12 @@ SPDX-License-Identifier: Apache-2.0 > no LLM call): root CLAUDE.md files and their `@` imports plus the skill listing at > start (AGENTS.md files and their imports too, unless a root CLAUDE*, `.claude/CLAUDE.md` or > `CLAUDE.local.md` turns them off), nested guidance lazily, skill bodies on demand, and AGENTS.md -> files a root CLAUDE* turns off. For a profile with `guidance.rules` (`claude-code`), `.claude/rules/**/*.md` files are `rule` entries: at start in the root -> `.claude/rules/` unless their frontmatter has `paths`, lazily otherwise and in a subdirectory's -> `.claude/rules/`; an `@` path in a rule is not followed. The `start` total is not an upper bound: it is too low by what it does -> not count (non-`.md` imports, skills behind a linked directory, nested -> `.claude/skills/` skills, and +> files a root CLAUDE* turns off. For a profile with `guidance.claude_rules` (`claude-code`), +> `.claude/rules/**/*.md` files are `rule` entries: at start in the root `.claude/rules/` unless +> their frontmatter's `paths` names a pattern, lazily then and in a subdirectory's `.claude/rules/`; +> an `@` path in a rule is not followed. The `start` total is not an upper bound: it is too low by +> what it does not count (non-`.md` imports, skills behind a linked directory, rules behind one, +> which warns, nested `.claude/skills/` skills, and > imports past the profile's `guidance.import_max_depth`, four hops for Claude Code, which warns > naming what was skipped), and too high by whatever the harness cuts from the skill listing. > Usage: diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index 411e1b3ea..4b4c8240b 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -42,8 +42,10 @@ SKILL_GLOB = re.compile(r"^\.claude/skills/[^/]+/SKILL\.md$") # Rules are discovered recursively, in the root .claude/rules/ and in a subdirectory's (docs: Memory). RULE_GLOB = re.compile(r"^(?:.+/)?\.claude/rules/(?:.+/)?[^/]+\.md$") +_RULES_ENTRY = re.compile(r"^(?:.+/)?\.claude/rules(?:/.+)?$") _FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\Z)", re.S | re.M) -_PATHS_KEY = re.compile(r"^paths[ \t]*:", re.M) +# `paths:` and what follows it: an inline value, or the `- item` lines under it. +_PATHS_KEY = re.compile(r"^paths[ \t]*:(?P[^\n]*)(?P(?:\n[ \t]*-[^\n]*)*)", re.M) MCP_FILE = ".mcp.json" # Claude Code truncates description + when_to_use at 1,536 characters in the listing (docs: Skills). LISTING_TEXT_CAP = 1536 @@ -142,9 +144,9 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in totals = {w: sum(e["chars"] for e in entries if e["when"] == w) for w in ("start", "lazy", "on demand", "off")} warnings = unreadable_warnings[:] - # A guidance file is an entry of its own, so a cut import of it is already loaded (not an import). + # A guidance file or a rule is an entry of its own, so a cut import of it is already loaded (not an import). loaded = {_target(tree, p) for p in at_start | lazy_all} | { - _target(tree, e["path"]) for e in entries if e["kind"] == "guidance" and e["when"] != "off"} + _target(tree, e["path"]) for e in entries if e["kind"] in ("guidance", "rule") and e["when"] != "off"} for f, target in sorted(set(cut_start + cut_lazy)): if _target(tree, target) in loaded: continue # another chain did load it, possibly through a link @@ -230,9 +232,16 @@ def _rules(tree: RepoTree, files: list[str], counted: set[str], start_t: set[str warnings: list[str]) -> list[dict[str, Any]]: """The ``rule`` entries of ``files``, skipping files already ``counted`` (and adding theirs). - ``budget`` passes no files for a harness that does not read ``.claude/rules`` (``guidance.rules``). + ``budget`` passes no files for a harness that does not read ``.claude/rules`` (``guidance.claude_rules``). """ out = [] + for f in sorted(f for f in files if _RULES_ENTRY.match(f) and not RULE_GLOB.match(f)): + try: + linked_dir = tree.is_dir(tree.resolve(f, dirs=True)) + except (OSError, ValueError): # dangling, looping or out of the tree + linked_dir = False + if linked_dir: # a tree lists a link to a directory as one entry, not the files behind it + warnings.append(f"{f}: a link to a directory; the rules behind it are not counted") for f in sorted(f for f in files if RULE_GLOB.match(f)): if _target(tree, f) in counted: continue # a link to a file already counted, or a rule that is also a guidance file @@ -250,9 +259,10 @@ def _rule(tree: RepoTree, path: str) -> tuple[int, bool, str | None]: """Return (chars in context, has ``paths``, warning) for a ``.claude/rules`` file. Claude Code removes the frontmatter before loading a rule, so it is not counted. A rule with - ``paths`` in its frontmatter loads only when the agent reads a matching file; measured on - Claude Code 2.1.304, that holds even when the YAML does not parse, which the docs say - loads the rule unconditionally. + a non-empty ``paths`` loads only when the agent reads a matching file. Measured on Claude Code + 2.1.304, the key is read line by line: it holds even when the rest of the YAML does not parse + (the docs say such a rule loads unconditionally), while ``paths: []`` loads at start. A rule + whose only pattern is invalid (``[unclosed``) never loads; it is counted lazy. """ try: text = tree.read_text(path) @@ -261,7 +271,16 @@ def _rule(tree: RepoTree, path: str) -> tuple[int, bool, str | None]: m = _FRONTMATTER.match(text) if not m: return len(text), False, None - return len(text) - m.end(), bool(_PATHS_KEY.search(m.group(1))), None + return len(text) - m.end(), _has_paths(m.group(1)), None + + +def _has_paths(frontmatter: str) -> bool: + """True if ``paths`` names at least one pattern: a list item or an inline value not ``[]`` or ``""``.""" + m = _PATHS_KEY.search(frontmatter) + if not m: + return False + values = [m["inline"]] + [line.split("-", 1)[1] for line in m["items"].splitlines() if line.strip()] + return any(v.strip().strip("[]").strip(" \t\"'") for v in values) def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None) -> tuple[set[str], list[tuple[str, str]]]: diff --git a/dokimi/dokimi/repos/harness_profile.py b/dokimi/dokimi/repos/harness_profile.py index c59d6b4e9..0d1407595 100644 --- a/dokimi/dokimi/repos/harness_profile.py +++ b/dokimi/dokimi/repos/harness_profile.py @@ -78,8 +78,8 @@ def import_max_depth(self) -> int | None: @property def reads_claude_rules(self) -> bool: - """Whether the harness reads ``.claude/rules/**/*.md`` (``guidance.rules``).""" - return bool(self.guidance.get("rules")) + """Whether the harness reads ``.claude/rules/**/*.md`` (``guidance.claude_rules``).""" + return bool(self.guidance.get("claude_rules")) def applied_args(self, tools: str, present: frozenset[str]) -> list[str]: """The ``args_if_present`` names :meth:`argv` adds: those in ``present``, in the ``all`` tool mode only.""" diff --git a/dokimi/dokimi/repos/profiles/claude-code.yaml b/dokimi/dokimi/repos/profiles/claude-code.yaml index a180674a7..96c5909a9 100644 --- a/dokimi/dokimi/repos/profiles/claude-code.yaml +++ b/dokimi/dokimi/repos/profiles/claude-code.yaml @@ -85,7 +85,7 @@ guidance: # Memory docs (code.claude.com/docs/en/memory): imports recurse "with a maximum depth of four hops". import_max_depth: 4 # .claude/rules/**/*.md: measured on CLI 2.1.304, a root rule with no `paths` loads at start. - rules: true + claude_rules: true notes: >- Measured on CLI 2.1.286: it reads AGENTS.md only when no CLAUDE.md is in the directory chain it starts in, and one CLAUDE.md diff --git a/dokimi/dokimi/schemas/harness_profile.v1.json b/dokimi/dokimi/schemas/harness_profile.v1.json index 3cb26b746..7a49514a7 100644 --- a/dokimi/dokimi/schemas/harness_profile.v1.json +++ b/dokimi/dokimi/schemas/harness_profile.v1.json @@ -52,7 +52,7 @@ "resolution": {"type": "string"}, "imports": {"type": ["string", "null"]}, "import_max_depth": {"type": "integer", "minimum": 1, "description": "How many hops of @imports the harness follows from a guidance file; absent: unlimited."}, - "rules": {"type": "boolean", "description": "The harness reads .claude/rules/**/*.md (repo budget counts them); absent: false."}, + "claude_rules": {"type": "boolean", "description": "The harness reads .claude/rules/**/*.md (repo budget counts them); absent: false."}, "notes": {"type": "string"} } }, diff --git a/dokimi/dokimi/schemas/repo_budget.v1.json b/dokimi/dokimi/schemas/repo_budget.v1.json index 27640a5b4..309a1c0fd 100644 --- a/dokimi/dokimi/schemas/repo_budget.v1.json +++ b/dokimi/dokimi/schemas/repo_budget.v1.json @@ -17,7 +17,7 @@ "additionalProperties": false, "properties": { "path": {"type": "string"}, - "kind": {"enum": ["guidance", "import", "rule", "skill listing", "skill body"], "description": "`import` is a file a CLAUDE.md or AGENTS.md `@import`s; `rule` is a `.claude/rules/**/*.md` file, `start` at the root unless its frontmatter has `paths`; a skill has a `skill listing` entry unless it is hidden from the model, and always a `skill body` entry."}, + "kind": {"enum": ["guidance", "import", "rule", "skill listing", "skill body"], "description": "`import` is a file a CLAUDE.md or AGENTS.md `@import`s; `rule` is a `.claude/rules/**/*.md` file, `start` at the root unless its frontmatter's `paths` names a pattern; a skill has a `skill listing` entry unless it is hidden from the model, and always a `skill body` entry."}, "when": {"enum": ["start", "lazy", "on demand", "off"]}, "chars": {"type": "integer", "minimum": 0, "description": "Characters in context; 0 for an unreadable file, which is also named in `warnings`."} } diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index 295d95589..50abe4524 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -89,3 +89,40 @@ def test_rules_are_counted_only_for_a_harness_that_reads_them(tmp_path): got = budget(tree, profile.guidance_names, profile.import_max_depth, profile.reads_claude_rules) assert not [e for e in got["entries"] if e["kind"] == "rule"], name assert load_profile("claude-code").reads_claude_rules + + +@pytest.mark.parametrize(("front", "when"), [ + ('paths:\n - "src/**"\n', "lazy"), + ("paths: src/**, lib/**\n", "lazy"), + ('paths: "src/**"\nfoo: [unclosed\n', "lazy"), # measured: the key is read line by line + ("foo: [unclosed\n", "start"), # measured: no paths, so it loads at start + ("paths: []\n", "start"), # measured: an empty list scopes nothing + ('paths: ""\n', "start"), + ("paths:\n", "start"), +]) +def test_only_a_paths_value_that_names_a_pattern_scopes_a_rule(tmp_path, front, when): + payload = budget(_tree(tmp_path, {".claude/rules/r.md": f"---\n{front}---\nbody\n"}), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")] == { + "path": ".claude/rules/r.md", "kind": "rule", "when": when, "chars": len("body\n")} + + +def test_a_cut_import_of_a_counted_rule_does_not_warn(tmp_path): + files = {"CLAUDE.md": "@c1.md\n", "c1.md": "@c2.md\n", "c2.md": "@c3.md\n", "c3.md": "@c4.md\n", + "c4.md": "@.claude/rules/r.md\n", ".claude/rules/r.md": "rule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=4, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" + assert payload["warnings"] == [] + + +def test_a_linked_rules_directory_is_named_not_silently_skipped(tmp_path): + (tmp_path / "shared").mkdir() + (tmp_path / "shared/style.md").write_text("style\n", encoding="utf-8") + (tmp_path / ".claude/rules").mkdir(parents=True) + (tmp_path / ".claude/rules/own.md").write_text("own\n", encoding="utf-8") + try: + (tmp_path / ".claude/rules/shared").symlink_to(tmp_path / "shared", target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert [e["path"] for e in payload["entries"] if e["kind"] == "rule"] == [".claude/rules/own.md"] + assert payload["warnings"] == [".claude/rules/shared: a link to a directory; the rules behind it are not counted"] From 1b7ccad5cb15486b0aad39899e2cc3259e1c0ff6 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 30 Oct 2026 22:47:14 -0700 Subject: [PATCH 03/19] fix(repo): warn on a rules link out of the tree; read paths as YAML first Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/budget.py | 30 +++++++++++++++++--------- dokimi/tests/test_repo_budget_rules.py | 20 +++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index 4b4c8240b..1dea50109 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -8,14 +8,14 @@ - **start**: guidance files at the root, the files a root ``CLAUDE*`` or ``AGENTS.md`` file ``@import``s (``AGENTS.md`` only when it is not turned off), the root ``.claude/rules/**/*.md`` - files with no ``paths`` frontmatter when the harness reads rules (their own ``@`` paths are not + files whose ``paths`` frontmatter names no pattern, when the harness reads rules (their own ``@`` paths are not imports), and the skill listing (each project skill's ``name``, plus its ``description`` and ``when_to_use`` up to 1,536 characters together; a skill with ``disable-model-invocation: true`` is not listed). Every model call pays for these. - **lazy**: guidance files in subdirectories, what a nested CLAUDE.md or AGENTS.md imports, and the rules of a subdirectory's ``.claude/rules/``, loaded when the agent works in that subtree; rules - with ``paths``, loaded when the agent reads a matching file. + whose ``paths`` names a pattern, loaded when the agent reads a matching file. - **on demand**: skill bodies, loaded when the agent invokes the skill. - **off**: AGENTS.md files the harness will not read: a root CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md is present and no CLAUDE.md imports them (measured on Claude Code 2.1.286; the reach probe re-measures). @@ -24,10 +24,11 @@ and differs between model families. The exact cost is the first-call prompt difference between a run with the file and one without. -Five checks ride along: skills with no description and no body text, skills whose descriptions are near-duplicates +Six checks ride along: skills with no description and no body text, skills whose descriptions are near-duplicates (the agent has to choose between them from the listing alone), AGENTS.md files turned off by a -CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md at the root, unreadable files (non-UTF-8, dangling link), and imports past the -harness's hop limit. +CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md at the root, unreadable files (non-UTF-8, dangling link), imports past the +harness's hop limit, and links under ``.claude/rules`` to a directory or out of the tree, whose rules +are not counted. """ from __future__ import annotations @@ -37,6 +38,8 @@ from itertools import combinations from typing import Any +import yaml + from dokimi.repos.source import RepoTree SKILL_GLOB = re.compile(r"^\.claude/skills/[^/]+/SKILL\.md$") @@ -237,11 +240,10 @@ def _rules(tree: RepoTree, files: list[str], counted: set[str], start_t: set[str out = [] for f in sorted(f for f in files if _RULES_ENTRY.match(f) and not RULE_GLOB.match(f)): try: - linked_dir = tree.is_dir(tree.resolve(f, dirs=True)) - except (OSError, ValueError): # dangling, looping or out of the tree - linked_dir = False - if linked_dir: # a tree lists a link to a directory as one entry, not the files behind it - warnings.append(f"{f}: a link to a directory; the rules behind it are not counted") + if tree.is_dir(tree.resolve(f, dirs=True)): # a tree lists a link to a directory as one entry + warnings.append(f"{f}: a link to a directory; the rules behind it are not counted") + except (OSError, ValueError, RuntimeError): # out of the tree (a shared rules dir), dangling or a loop + warnings.append(f"{f}: a link that does not resolve in the tree; any rules behind it are not counted") for f in sorted(f for f in files if RULE_GLOB.match(f)): if _target(tree, f) in counted: continue # a link to a file already counted, or a rule that is also a guidance file @@ -276,6 +278,14 @@ def _rule(tree: RepoTree, path: str) -> tuple[int, bool, str | None]: def _has_paths(frontmatter: str) -> bool: """True if ``paths`` names at least one pattern: a list item or an inline value not ``[]`` or ``""``.""" + try: + meta = yaml.safe_load(frontmatter) + except yaml.YAMLError: + meta = None # Claude Code still reads a `paths:` line of YAML that does not parse + if isinstance(meta, dict): + value = meta.get("paths") + items = value if isinstance(value, list) else str(value or "").split(",") + return any(str(v).strip() for v in items if v is not None) m = _PATHS_KEY.search(frontmatter) if not m: return False diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index 50abe4524..3d87f74c5 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -99,6 +99,10 @@ def test_rules_are_counted_only_for_a_harness_that_reads_them(tmp_path): ("paths: []\n", "start"), # measured: an empty list scopes nothing ('paths: ""\n', "start"), ("paths:\n", "start"), + ("paths: null\n", "start"), + ("paths: # none yet\n", "start"), + ('paths: [\n "src/**"]\n', "lazy"), + ('paths:\n # the api\n - "src/**"\n', "lazy"), ]) def test_only_a_paths_value_that_names_a_pattern_scopes_a_rule(tmp_path, front, when): payload = budget(_tree(tmp_path, {".claude/rules/r.md": f"---\n{front}---\nbody\n"}), NAMES, rules=True) @@ -126,3 +130,19 @@ def test_a_linked_rules_directory_is_named_not_silently_skipped(tmp_path): payload = budget(WorkTree(tmp_path), NAMES, rules=True) assert [e["path"] for e in payload["entries"] if e["kind"] == "rule"] == [".claude/rules/own.md"] assert payload["warnings"] == [".claude/rules/shared: a link to a directory; the rules behind it are not counted"] + + +def test_a_rules_link_out_of_the_tree_is_named(tmp_path): + shared = tmp_path / "shared" # the docs' `ln -s ~/shared-claude-rules .claude/rules/shared` + shared.mkdir() + (shared / "style.md").write_text("style\n", encoding="utf-8") + repo = tmp_path / "repo" + (repo / ".claude/rules").mkdir(parents=True) + try: + (repo / ".claude/rules/shared").symlink_to(shared, target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(repo), NAMES, rules=True) + assert not [e for e in payload["entries"] if e["kind"] == "rule"] + assert payload["warnings"] == [ + ".claude/rules/shared: a link that does not resolve in the tree; any rules behind it are not counted"] From 82fd9bf05401dd4d6ff0457e6f5bc22c512edad8 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 09:57:25 -0700 Subject: [PATCH 04/19] fix(repo): a rule frontmatter too deep for YAML no longer crashes budget Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/budget.py | 2 +- dokimi/tests/test_repo_budget_rules.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index 1dea50109..8b9dc0fdc 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -280,7 +280,7 @@ def _has_paths(frontmatter: str) -> bool: """True if ``paths`` names at least one pattern: a list item or an inline value not ``[]`` or ``""``.""" try: meta = yaml.safe_load(frontmatter) - except yaml.YAMLError: + except (yaml.YAMLError, RecursionError): # RecursionError: nesting too deep for the parser meta = None # Claude Code still reads a `paths:` line of YAML that does not parse if isinstance(meta, dict): value = meta.get("paths") diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index 3d87f74c5..9e539a57e 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -103,6 +103,7 @@ def test_rules_are_counted_only_for_a_harness_that_reads_them(tmp_path): ("paths: # none yet\n", "start"), ('paths: [\n "src/**"]\n', "lazy"), ('paths:\n # the api\n - "src/**"\n', "lazy"), + pytest.param("paths: " + "[" * 3000 + "]" * 3000 + "\n", "start", id="too-deep-for-yaml"), ]) def test_only_a_paths_value_that_names_a_pattern_scopes_a_rule(tmp_path, front, when): payload = budget(_tree(tmp_path, {".claude/rules/r.md": f"---\n{front}---\nbody\n"}), NAMES, rules=True) From f1718b77c2f8a09bb055b535ae2dadef2e92c973 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 13:18:58 -0700 Subject: [PATCH 05/19] fix(repo): budget counts a rule linked to guidance once, at its loading time A root rule that links to a guidance file (or to a file a root CLAUDE.md turns off) is the same bytes: the guidance entry now takes the earlier of the two loading conditions instead of the rule being dropped, so the file is counted at start and no longer warned as unread. Frontmatter that fails to parse no longer crashes budget on an oversized integer (ValueError), and the fallback that reads the paths key ignores YAML comments and blank lines between items. Quoted # is kept. Thread: PRRT_kwDOVBg7g86ySxKH Thread: PRRT_kwDOVBg7g86ySxKK Thread: PRRT_kwDOVBg7g86ySxKP Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/budget.py | 74 +++++++++++++++++++++----- dokimi/tests/test_repo_budget_rules.py | 36 +++++++++++++ 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index 8b9dc0fdc..a090c1f5d 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -47,8 +47,8 @@ RULE_GLOB = re.compile(r"^(?:.+/)?\.claude/rules/(?:.+/)?[^/]+\.md$") _RULES_ENTRY = re.compile(r"^(?:.+/)?\.claude/rules(?:/.+)?$") _FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\Z)", re.S | re.M) -# `paths:` and what follows it: an inline value, or the `- item` lines under it. -_PATHS_KEY = re.compile(r"^paths[ \t]*:(?P[^\n]*)(?P(?:\n[ \t]*-[^\n]*)*)", re.M) +# The `paths:` key of frontmatter that does not parse: its line, read on its own (see _fallback_paths). +_PATHS_LINE = re.compile(r"^paths[ \t]*:(.*)$") MCP_FILE = ".mcp.json" # Claude Code truncates description + when_to_use at 1,536 characters in the listing (docs: Skills). LISTING_TEXT_CAP = 1536 @@ -129,7 +129,7 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in unreadable_warnings.append(warning) entries.append({"path": f, "kind": "guidance", "when": when, "chars": chars}) counted = {_target(tree, f) for f in guidance} - entries += _rules(tree, files if rules else [], counted, start_t, unreadable_warnings) + entries += _rules(tree, files if rules else [], counted, start_t, unreadable_warnings, entries) for f in sorted((at_start | lazily) - set(guidance), key=lambda p: (p not in at_start, p)): if _is_resolvable(tree, f) and _target(tree, f) not in counted: counted.add(_target(tree, f)) @@ -232,11 +232,17 @@ def _chars(tree: RepoTree, path: str) -> tuple[int, str | None]: def _rules(tree: RepoTree, files: list[str], counted: set[str], start_t: set[str], - warnings: list[str]) -> list[dict[str, Any]]: + warnings: list[str], guidance: list[dict[str, Any]]) -> list[dict[str, Any]]: """The ``rule`` entries of ``files``, skipping files already ``counted`` (and adding theirs). + A rule that is the same file as a guidance entry (in ``guidance``) is not counted twice: that entry + takes the earlier of the two loading conditions, so a root rule linked to an AGENTS.md that a root + CLAUDE.md turns off loads it at start. + ``budget`` passes no files for a harness that does not read ``.claude/rules`` (``guidance.claude_rules``). """ + rank = {"off": 0, "lazy": 1, "start": 2} + by_target = {_target(tree, e["path"]): e for e in guidance} out = [] for f in sorted(f for f in files if _RULES_ENTRY.match(f) and not RULE_GLOB.match(f)): try: @@ -245,14 +251,18 @@ def _rules(tree: RepoTree, files: list[str], counted: set[str], start_t: set[str except (OSError, ValueError, RuntimeError): # out of the tree (a shared rules dir), dangling or a loop warnings.append(f"{f}: a link that does not resolve in the tree; any rules behind it are not counted") for f in sorted(f for f in files if RULE_GLOB.match(f)): - if _target(tree, f) in counted: - continue # a link to a file already counted, or a rule that is also a guidance file - counted.add(_target(tree, f)) + target = _target(tree, f) chars, scoped, warning = _rule(tree, f) + # a CLAUDE.md that imports a rule at start loads it then, whatever its frontmatter says + when = "start" if (f.startswith(".claude/rules/") and not scoped) or target in start_t else "lazy" + if target in counted: + # a link to a file already counted (a guidance file, or a rule): the same bytes, loaded once + if target in by_target and rank[when] > rank[by_target[target]["when"]]: + by_target[target]["when"] = when + continue + counted.add(target) if warning: warnings.append(warning) - # a CLAUDE.md that imports a rule at start loads it then, whatever its frontmatter says - when = "start" if (f.startswith(".claude/rules/") and not scoped) or _target(tree, f) in start_t else "lazy" out.append({"path": f, "kind": "rule", "when": when, "chars": chars}) return out @@ -280,19 +290,57 @@ def _has_paths(frontmatter: str) -> bool: """True if ``paths`` names at least one pattern: a list item or an inline value not ``[]`` or ``""``.""" try: meta = yaml.safe_load(frontmatter) - except (yaml.YAMLError, RecursionError): # RecursionError: nesting too deep for the parser + except (yaml.YAMLError, RecursionError, ValueError): # RecursionError: nesting too deep; ValueError: an integer over the digit limit meta = None # Claude Code still reads a `paths:` line of YAML that does not parse if isinstance(meta, dict): value = meta.get("paths") items = value if isinstance(value, list) else str(value or "").split(",") return any(str(v).strip() for v in items if v is not None) - m = _PATHS_KEY.search(frontmatter) - if not m: + values = _fallback_paths(frontmatter) + if values is None: return False - values = [m["inline"]] + [line.split("-", 1)[1] for line in m["items"].splitlines() if line.strip()] return any(v.strip().strip("[]").strip(" \t\"'") for v in values) +def _fallback_paths(frontmatter: str) -> list[str] | None: + """The values of the ``paths`` key in frontmatter that does not parse: its inline value and its ``- item`` lines. + + Comment lines and blank lines between the items are skipped, and a YAML comment is cut from each value + (``_without_comment``). None when there is no ``paths`` key. + """ + lines = frontmatter.splitlines() + for i, line in enumerate(lines): + m = _PATHS_LINE.match(line) + if not m: + continue + values = [_without_comment(m.group(1))] + for item in lines[i + 1:]: + s = item.strip() + if not s or s.startswith("#"): + continue + if not s.startswith("-"): + break + values.append(_without_comment(s[1:])) + return values + return None + + +def _without_comment(text: str) -> str: + """``text`` up to a YAML comment: a `#` that starts a word, outside a quoted scalar (``"src/#x"`` keeps it).""" + quote = None + prev = " " + for i, ch in enumerate(text): + if quote: + if ch == quote: + quote = None + elif ch in "\"'" and prev in " \t[,": + quote = ch + elif ch == "#" and prev in " \t": + return text[:i] + prev = ch + return text + + def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None) -> tuple[set[str], list[tuple[str, str]]]: """Files the CLAUDE.md ``roots`` ``@import``, transitively (relative to the importing file). diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index 9e539a57e..d57f95377 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -71,6 +71,34 @@ def test_a_rule_a_root_claude_md_imports_is_one_start_entry(tmp_path): assert (".claude/rules/api.md", "import") not in got +def test_a_root_rule_linked_to_an_off_agents_md_loads_it_at_start(tmp_path): + # a root CLAUDE.md turns AGENTS.md off, but the rule links to the same bytes and loads them at start + _tree(tmp_path, {"CLAUDE.md": "claude\n", "AGENTS.md": "agents\n"}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/agents.md").symlink_to(tmp_path / "AGENTS.md") + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert _by_path(payload)[("AGENTS.md", "guidance")]["when"] == "start" + assert not [e for e in payload["entries"] if e["when"] == "off"] + assert payload["warnings"] == [] + assert payload["totals"]["start"] == len("claude\n") + len("agents\n") + + +def test_a_root_rule_linked_to_a_nested_guidance_file_loads_it_at_start(tmp_path): + _tree(tmp_path, {"CLAUDE.md": "root\n", "sub/CLAUDE.md": "nested\n"}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/nested.md").symlink_to(tmp_path / "sub/CLAUDE.md") + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert _by_path(payload)[("sub/CLAUDE.md", "guidance")]["when"] == "start" + assert payload["totals"]["start"] == len("root\n") + len("nested\n") + assert payload["totals"]["lazy"] == 0 + + def test_an_unreadable_rule_is_counted_as_zero_and_named(tmp_path): (tmp_path / ".claude/rules").mkdir(parents=True) (tmp_path / ".claude/rules/bin.md").write_bytes(b"\xff\xfe\x00") @@ -104,6 +132,14 @@ def test_rules_are_counted_only_for_a_harness_that_reads_them(tmp_path): ('paths: [\n "src/**"]\n', "lazy"), ('paths:\n # the api\n - "src/**"\n', "lazy"), pytest.param("paths: " + "[" * 3000 + "]" * 3000 + "\n", "start", id="too-deep-for-yaml"), + pytest.param("unused: " + "9" * 5000 + "\n", "start", id="oversized-integer-unrelated-to-paths"), + # the same for YAML that does not parse: the fallback reads the key line by line + pytest.param("paths: # none yet\nfoo: [unclosed\n", "start", id="unparsed-comment-only-value"), + pytest.param("paths: [] # none\nfoo: [unclosed\n", "start", id="unparsed-empty-list-and-comment"), + pytest.param('paths:\n # the api\n - "src/**"\nfoo: [unclosed\n', "lazy", + id="unparsed-comment-line-before-item"), + pytest.param('paths:\n\n - "src/**"\nfoo: [unclosed\n', "lazy", id="unparsed-blank-line-before-item"), + pytest.param('paths: "src/#x" # the api\nfoo: [unclosed\n', "lazy", id="unparsed-quoted-hash-kept"), ]) def test_only_a_paths_value_that_names_a_pattern_scopes_a_rule(tmp_path, front, when): payload = budget(_tree(tmp_path, {".claude/rules/r.md": f"---\n{front}---\nbody\n"}), NAMES, rules=True) From cc17f0df1d0542a21ac0d670ae5391120536724f Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 18:03:00 -0700 Subject: [PATCH 06/19] fix(repo): budget reads rule frontmatter, links and promoted files as the harness does - A rule with a UTF-8 BOM has its frontmatter read, as a SKILL.md does. - `paths` is a pattern only when it is a string or a list of strings; the value is never str()-ed, so a YAML alias chain cannot expand into a hang. Non-string values (true, 5, a mapping) name no pattern. - The unparsed-YAML fallback treats null, ~ and empty items as no pattern, so unrelated malformed metadata does not change the loading condition. - A rule linked to a file already counted takes the earlier loading condition and that route's count (a rule's frontmatter is removed before it loads). - A rule entry joins the target map, so a root alias that sorts after a nested real file still promotes it to start. - A dangling link to a suffixed non-Markdown file under .claude/rules is not warned; a name without a suffix still is (it may be a directory link). - architecture.md lists the rules-link warning among the checks that trip --fail-on-warning. Thread: PRRT_kwDOVBg7g86yUV4N Thread: PRRT_kwDOVBg7g86yUV4R Thread: PRRT_kwDOVBg7g86yUV4U Thread: PRRT_kwDOVBg7g86yVf4V Thread: PRRT_kwDOVBg7g86yVf4Y Thread: PRRT_kwDOVBg7g86yVf4b Thread: PRRT_kwDOVBg7g86yVf4c Signed-off-by: Nikolay Petrov --- dokimi/docs/architecture.md | 6 +- dokimi/dokimi/repos/budget.py | 45 ++++++++++++--- dokimi/tests/test_repo_budget_rules.py | 79 ++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 12 deletions(-) diff --git a/dokimi/docs/architecture.md b/dokimi/docs/architecture.md index 5eade7781..f21f8dbfe 100644 --- a/dokimi/docs/architecture.md +++ b/dokimi/docs/architecture.md @@ -298,9 +298,9 @@ next release. non-empty body line, as Claude Code does. It warns on skills with no description or body text, skills with near-duplicate descriptions (identical descriptions, or Jaccard index >= 0.5 after stop-word removal when both have 3+ words left), on AGENTS.md files turned off, on - unreadable files (non-UTF-8, dangling or out-of-tree link), and on imports past the harness's - hop limit, each of which also trips - `--fail-on-warning`. For a profile with `guidance.claude_rules` (Claude Code), + unreadable files (non-UTF-8, dangling or out-of-tree link), on imports past the harness's + hop limit, and on a link under `.claude/rules` to a directory or out of the tree (the rules behind + it are not counted), each of which also trips `--fail-on-warning`. For a profile with `guidance.claude_rules` (Claude Code), `.claude/rules/**/*.md` files are `rule` entries: `start` in the root `.claude/rules/` when `paths` names no pattern, `lazy` when it does or in a subdirectory's `.claude/rules/`, the frontmatter not counted. `start` is not an upper bound: it is too low by what it does not count diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index a090c1f5d..3c1ab26aa 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -36,6 +36,7 @@ import json import re from itertools import combinations +from pathlib import PurePosixPath from typing import Any import yaml @@ -249,21 +250,27 @@ def _rules(tree: RepoTree, files: list[str], counted: set[str], start_t: set[str if tree.is_dir(tree.resolve(f, dirs=True)): # a tree lists a link to a directory as one entry warnings.append(f"{f}: a link to a directory; the rules behind it are not counted") except (OSError, ValueError, RuntimeError): # out of the tree (a shared rules dir), dangling or a loop - warnings.append(f"{f}: a link that does not resolve in the tree; any rules behind it are not counted") + if not PurePosixPath(f).suffix: # a file with a suffix (notes.txt) is never a rule; a name without one may be a directory + warnings.append(f"{f}: a link that does not resolve in the tree; any rules behind it are not counted") for f in sorted(f for f in files if RULE_GLOB.match(f)): target = _target(tree, f) chars, scoped, warning = _rule(tree, f) # a CLAUDE.md that imports a rule at start loads it then, whatever its frontmatter says when = "start" if (f.startswith(".claude/rules/") and not scoped) or target in start_t else "lazy" if target in counted: - # a link to a file already counted (a guidance file, or a rule): the same bytes, loaded once - if target in by_target and rank[when] > rank[by_target[target]["when"]]: - by_target[target]["when"] = when + # a link to a file already counted (a guidance file, or another rule): the same bytes, loaded once. + # The entry takes the earlier loading condition, and that route's count: a rule has its frontmatter removed. + entry = by_target.get(target) + if entry is not None and rank[when] > rank[entry["when"]]: + entry["when"] = when + entry["chars"] = chars continue counted.add(target) if warning: warnings.append(warning) - out.append({"path": f, "kind": "rule", "when": when, "chars": chars}) + entry = {"path": f, "kind": "rule", "when": when, "chars": chars} + out.append(entry) + by_target[target] = entry return out @@ -280,6 +287,7 @@ def _rule(tree: RepoTree, path: str) -> tuple[int, bool, str | None]: text = tree.read_text(path) except (OSError, ValueError, RuntimeError) as err: # UnicodeDecodeError is a ValueError; RuntimeError a loop return 0, False, f"{path}: not readable ({_unreadable_reason(err)}), counted as 0" + text = text.removeprefix("\ufeff") # a UTF-8 BOM from an editor is not part of the frontmatter (as in skills) m = _FRONTMATTER.match(text) if not m: return len(text), False, None @@ -287,19 +295,38 @@ def _rule(tree: RepoTree, path: str) -> tuple[int, bool, str | None]: def _has_paths(frontmatter: str) -> bool: - """True if ``paths`` names at least one pattern: a list item or an inline value not ``[]`` or ``""``.""" + """True if ``paths`` names at least one pattern: a string, or a list item that is a string, not blank. + + A value that is not a string (a number, a boolean, a mapping) names no pattern, and a container is never + converted to text: a YAML alias chain expands on ``str()``. + """ try: meta = yaml.safe_load(frontmatter) except (yaml.YAMLError, RecursionError, ValueError): # RecursionError: nesting too deep; ValueError: an integer over the digit limit meta = None # Claude Code still reads a `paths:` line of YAML that does not parse if isinstance(meta, dict): value = meta.get("paths") - items = value if isinstance(value, list) else str(value or "").split(",") - return any(str(v).strip() for v in items if v is not None) + items = value if isinstance(value, list) else value.split(",") if isinstance(value, str) else [] + return any(isinstance(v, str) and v.strip() for v in items) values = _fallback_paths(frontmatter) if values is None: return False - return any(v.strip().strip("[]").strip(" \t\"'") for v in values) + return any(_names_pattern(v) for v in values) + + +def _names_pattern(value: str) -> bool: + """True if one unparsed ``paths`` value (the inline value or a list item) names a pattern. + + Brackets and commas are split as YAML would; an empty item, ``null`` and ``~`` name nothing, and quotes + around an item are not part of it (``"null"`` is the pattern ``null``). + """ + for part in value.strip().strip("[]").split(","): + part = part.strip() + if part in ("", "null", "~"): + continue + if part.strip("\"'").strip(): + return True + return False def _fallback_paths(frontmatter: str) -> list[str] | None: diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index d57f95377..e6cd6fc63 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -140,6 +140,14 @@ def test_rules_are_counted_only_for_a_harness_that_reads_them(tmp_path): id="unparsed-comment-line-before-item"), pytest.param('paths:\n\n - "src/**"\nfoo: [unclosed\n', "lazy", id="unparsed-blank-line-before-item"), pytest.param('paths: "src/#x" # the api\nfoo: [unclosed\n', "lazy", id="unparsed-quoted-hash-kept"), + # null and empty items name no pattern, with or without unrelated malformed YAML + pytest.param("paths: null\nunused: [unclosed\n", "start", id="unparsed-null-unrelated-malformed"), + pytest.param('paths: ["", ""]\nunused: [unclosed\n', "start", id="unparsed-empty-items-unrelated-malformed"), + pytest.param('paths: ["", ""]\n', "start", id="parsed-empty-items"), + # a value that is not a string names no pattern (not measured either way) + pytest.param("paths: true\n", "start", id="paths-boolean"), + pytest.param("paths: 5\n", "start", id="paths-integer"), + pytest.param("paths: {a: 1}\n", "start", id="paths-mapping"), ]) def test_only_a_paths_value_that_names_a_pattern_scopes_a_rule(tmp_path, front, when): payload = budget(_tree(tmp_path, {".claude/rules/r.md": f"---\n{front}---\nbody\n"}), NAMES, rules=True) @@ -183,3 +191,74 @@ def test_a_rules_link_out_of_the_tree_is_named(tmp_path): assert not [e for e in payload["entries"] if e["kind"] == "rule"] assert payload["warnings"] == [ ".claude/rules/shared: a link that does not resolve in the tree; any rules behind it are not counted"] + + +def test_a_rule_with_a_utf8_bom_reads_its_frontmatter(tmp_path): + payload = budget(_tree(tmp_path, {".claude/rules/r.md": "\ufeff---\npaths: src/**\n---\nbody\n"}), + NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")] == { + "path": ".claude/rules/r.md", "kind": "rule", "when": "lazy", "chars": len("body\n")} + + +def test_a_paths_value_is_never_stringified(monkeypatch): + # a YAML alias chain expands on str(); only strings are patterns, so a container is never converted + from dokimi.repos import budget as module + + class Bomb: + def __str__(self): + raise AssertionError("a paths value was stringified") + + monkeypatch.setattr(module.yaml, "safe_load", lambda text: {"paths": [Bomb()]}) + assert module._has_paths("paths: x\n") is False + + +def test_a_root_rule_linked_to_an_off_agents_md_is_charged_its_body(tmp_path): + # the rule route strips the frontmatter, so the body is what loads at start + _tree(tmp_path, {"CLAUDE.md": "claude\n", "AGENTS.md": "---\npaths: []\n---\nagents\n"}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/agents.md").symlink_to(tmp_path / "AGENTS.md") + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert _by_path(payload)[("AGENTS.md", "guidance")] == { + "path": "AGENTS.md", "kind": "guidance", "when": "start", "chars": len("agents\n")} + assert payload["totals"]["start"] == len("claude\n") + len("agents\n") + + +def test_a_root_rule_linked_to_a_nested_rule_that_sorts_first_still_loads_at_start(tmp_path): + # "!" sorts before "." : the nested real file is listed first, its root alias must still promote it + _tree(tmp_path, {"!sub/.claude/rules/r.md": "rule body\n"}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/r.md").symlink_to(tmp_path / "!sub/.claude/rules/r.md") + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert _by_path(payload)[("!sub/.claude/rules/r.md", "rule")]["when"] == "start" + assert payload["totals"]["start"] == len("rule body\n") + assert payload["totals"]["lazy"] == 0 + + +def test_a_dangling_link_to_a_non_markdown_file_under_rules_is_not_warned(tmp_path): + # such a file can never be a rule, so no rule is behind it + (tmp_path / ".claude/rules").mkdir(parents=True) + (tmp_path / ".claude/rules/own.md").write_text("own\n", encoding="utf-8") + try: + (tmp_path / ".claude/rules/notes.txt").symlink_to(tmp_path / "nowhere.txt") + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert payload["warnings"] == [] + + +def test_a_dangling_link_to_a_markdown_rule_is_named_and_counted_as_zero(tmp_path): + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/api.md").symlink_to(tmp_path / "missing.md") + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/api.md", "rule")]["chars"] == 0 + assert payload["warnings"] == [".claude/rules/api.md: not readable (not found, broken link, or outside tree), " + "counted as 0"] From f2da52fe63a0bb572b9ecb02d073f04ce343e0cf Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 22:25:46 -0700 Subject: [PATCH 07/19] fix(repo): budget reads a paths key on its own, keeps rules out of the import walk, names unresolved rule links - A `paths` key that the rest of the frontmatter fails to parse is read as its own block, with its flow-list continuation lines, so `paths: true` and `paths: [` over several lines classify the same as the parsed key (Thread: PRRT_kwDOVBg7g86yWM2k). - An @ path in a rule is not followed, also when a CLAUDE.md imports the rule, and a file named CLAUDE.md under .claude/rules is a rule, not guidance (Thread: PRRT_kwDOVBg7g86yWNjN). - A link under .claude/rules that does not resolve is named whatever its suffix, since the tree cannot tell a dangling file from an external directory (Thread: PRRT_kwDOVBg7g86yWNjn). - An entry that resolves to a directory is named as a linked directory before it is classified as a rule, also for a name ending in .md (Thread: PRRT_kwDOVBg7g86yWNj0). Thread: PRRT_kwDOVBg7g86yWM2k Thread: PRRT_kwDOVBg7g86yWNjN Thread: PRRT_kwDOVBg7g86yWNjn Thread: PRRT_kwDOVBg7g86yWNj0 Signed-off-by: Nikolay Petrov --- ...030_180000_napetrov_budget-claude-rules.md | 4 +- dokimi/dokimi/repos/budget.py | 75 ++++++++++++------- dokimi/tests/test_repo_budget_rules.py | 42 ++++++++++- 3 files changed, 88 insertions(+), 33 deletions(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md index 03c931c35..2ad94aa42 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md @@ -5,7 +5,9 @@ whose `paths` does, and every rule of a subdirectory's `.claude/rules/`, is `lazy`. The frontmatter is not counted, since Claude Code removes it before loading the rule, and an `@` path in a rule is not followed as an import. A link to a directory under `.claude/rules` is a - warning: the rules behind it are not counted. Measured on Claude Code 2.1.304: a root rule + warning: the rules behind it are not counted. A link under `.claude/rules` that does not resolve in + the tree is named too, whatever its suffix, since the tree cannot tell a dangling file from a directory; a + `CLAUDE.md` under `.claude/rules` is a rule, not guidance. Measured on Claude Code 2.1.304: a root rule raised the first prompt by its size, `paths: []` included; a scoped or nested one loaded only once the agent read a file it covers; a rule's `@extra.md` was not expanded. `start` was too low by every root rule before; `repo_budget.v1` widens `kind` with `rule`. Rules are counted diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index 3c1ab26aa..df6a18d9a 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -36,7 +36,6 @@ import json import re from itertools import combinations -from pathlib import PurePosixPath from typing import Any import yaml @@ -48,7 +47,7 @@ RULE_GLOB = re.compile(r"^(?:.+/)?\.claude/rules/(?:.+/)?[^/]+\.md$") _RULES_ENTRY = re.compile(r"^(?:.+/)?\.claude/rules(?:/.+)?$") _FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\Z)", re.S | re.M) -# The `paths:` key of frontmatter that does not parse: its line, read on its own (see _fallback_paths). +# The `paths:` key of frontmatter that does not parse: its line, read on its own (see _paths_block). _PATHS_LINE = re.compile(r"^paths[ \t]*:(.*)$") MCP_FILE = ".mcp.json" # Claude Code truncates description + when_to_use at 1,536 characters in the listing (docs: Skills). @@ -75,7 +74,8 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in rules: bool = False) -> dict[str, Any]: files = tree.files() names = set(guidance_names) - every = [f for f in files if f.rsplit("/", 1)[-1] in names] + # a file under .claude/rules is a rule whatever its name (a rules/CLAUDE.md is not guidance) + every = [f for f in files if f.rsplit("/", 1)[-1] in names and not RULE_GLOB.match(f)] guidance = _unaliased(tree, every) groups: dict[str, list[str]] = {} for f in every: @@ -245,14 +245,18 @@ def _rules(tree: RepoTree, files: list[str], counted: set[str], start_t: set[str rank = {"off": 0, "lazy": 1, "start": 2} by_target = {_target(tree, e["path"]): e for e in guidance} out = [] - for f in sorted(f for f in files if _RULES_ENTRY.match(f) and not RULE_GLOB.match(f)): + dirs: set[str] = set() + for f in sorted(f for f in files if _RULES_ENTRY.match(f)): try: if tree.is_dir(tree.resolve(f, dirs=True)): # a tree lists a link to a directory as one entry warnings.append(f"{f}: a link to a directory; the rules behind it are not counted") + dirs.add(f) except (OSError, ValueError, RuntimeError): # out of the tree (a shared rules dir), dangling or a loop - if not PurePosixPath(f).suffix: # a file with a suffix (notes.txt) is never a rule; a name without one may be a directory + # A link that does not resolve may be a directory whatever its name, and the tree cannot tell which: + # any such entry is named, except a Markdown rule, which _rule names as unreadable and counts as 0. + if not RULE_GLOB.match(f): warnings.append(f"{f}: a link that does not resolve in the tree; any rules behind it are not counted") - for f in sorted(f for f in files if RULE_GLOB.match(f)): + for f in sorted(f for f in files if RULE_GLOB.match(f) and f not in dirs): target = _target(tree, f) chars, scoped, warning = _rule(tree, f) # a CLAUDE.md that imports a rule at start loads it then, whatever its frontmatter says @@ -298,22 +302,39 @@ def _has_paths(frontmatter: str) -> bool: """True if ``paths`` names at least one pattern: a string, or a list item that is a string, not blank. A value that is not a string (a number, a boolean, a mapping) names no pattern, and a container is never - converted to text: a YAML alias chain expands on ``str()``. + converted to text: a YAML alias chain expands on ``str()``. When the frontmatter does not parse, the + ``paths`` key is read on its own (``_paths_block``), as Claude Code reads it. """ try: meta = yaml.safe_load(frontmatter) except (yaml.YAMLError, RecursionError, ValueError): # RecursionError: nesting too deep; ValueError: an integer over the digit limit meta = None # Claude Code still reads a `paths:` line of YAML that does not parse if isinstance(meta, dict): - value = meta.get("paths") - items = value if isinstance(value, list) else value.split(",") if isinstance(value, str) else [] - return any(isinstance(v, str) and v.strip() for v in items) - values = _fallback_paths(frontmatter) - if values is None: + return _value_names(meta.get("paths")) + block = _paths_block(frontmatter) + if block is None: return False + try: + own = yaml.safe_load("\n".join(block)) + except (yaml.YAMLError, RecursionError, ValueError): + own = None + if isinstance(own, dict): # the key parses on its own, whatever the rest of the frontmatter does + return _value_names(own.get("paths")) + # the key itself does not parse (`[unclosed`): its text names a pattern unless every part is empty + values = [_without_comment(block[0].split(":", 1)[1])] + for line in block[1:]: + s = line.strip() + if s and not s.startswith("#"): + values.append(_without_comment(s[1:] if s.startswith("-") else s)) return any(_names_pattern(v) for v in values) +def _value_names(value: object) -> bool: + """True if a parsed ``paths`` value names a pattern: a string (split on commas) or a list with a non-blank string.""" + items = value if isinstance(value, list) else value.split(",") if isinstance(value, str) else [] + return any(isinstance(v, str) and v.strip() for v in items) + + def _names_pattern(value: str) -> bool: """True if one unparsed ``paths`` value (the inline value or a list item) names a pattern. @@ -329,26 +350,22 @@ def _names_pattern(value: str) -> bool: return False -def _fallback_paths(frontmatter: str) -> list[str] | None: - """The values of the ``paths`` key in frontmatter that does not parse: its inline value and its ``- item`` lines. +def _paths_block(frontmatter: str) -> list[str] | None: + """The lines of the ``paths`` key: its line, then the lines that continue it (indented, ``- item``, blank or comment). - Comment lines and blank lines between the items are skipped, and a YAML comment is cut from each value - (``_without_comment``). None when there is no ``paths`` key. + None when there is no ``paths`` key. A flow list that runs over several lines (``paths: [`` then ``"src/**"]``) + is one block, so its continuation lines are read with the key. """ lines = frontmatter.splitlines() for i, line in enumerate(lines): - m = _PATHS_LINE.match(line) - if not m: - continue - values = [_without_comment(m.group(1))] - for item in lines[i + 1:]: - s = item.strip() - if not s or s.startswith("#"): - continue - if not s.startswith("-"): - break - values.append(_without_comment(s[1:])) - return values + if _PATHS_LINE.match(line): + block = [line] + for item in lines[i + 1:]: + if not item.strip() or item[:1] in (" ", "\t", "-", "#"): + block.append(item) + else: + break + return block return None @@ -385,6 +402,8 @@ def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None) -> while level: nxt: list[str] = [] for f in level: + if RULE_GLOB.match(f): + continue # an @ path in a rule is not followed (docs/repo-evaluation-contract.md) try: text = tree.read_text(f) except (OSError, ValueError, RuntimeError): # RuntimeError: a link loop before Python 3.13 diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index e6cd6fc63..2af0def04 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -148,6 +148,11 @@ def test_rules_are_counted_only_for_a_harness_that_reads_them(tmp_path): pytest.param("paths: true\n", "start", id="paths-boolean"), pytest.param("paths: 5\n", "start", id="paths-integer"), pytest.param("paths: {a: 1}\n", "start", id="paths-mapping"), + # the key is read on its own when the rest of the frontmatter does not parse + pytest.param("paths: true\nunused: [unclosed\n", "start", id="unparsed-boolean-unrelated-malformed"), + pytest.param("paths: 5\nunused: [unclosed\n", "start", id="unparsed-integer-unrelated-malformed"), + pytest.param('paths: [\n "src/**"]\nunused: [unclosed\n', "lazy", id="unparsed-flow-list-over-lines"), + pytest.param('paths: [\n ""]\nunused: [unclosed\n', "start", id="unparsed-flow-list-empty-over-lines"), ]) def test_only_a_paths_value_that_names_a_pattern_scopes_a_rule(tmp_path, front, when): payload = budget(_tree(tmp_path, {".claude/rules/r.md": f"---\n{front}---\nbody\n"}), NAMES, rules=True) @@ -240,16 +245,45 @@ def test_a_root_rule_linked_to_a_nested_rule_that_sorts_first_still_loads_at_sta assert payload["totals"]["lazy"] == 0 -def test_a_dangling_link_to_a_non_markdown_file_under_rules_is_not_warned(tmp_path): - # such a file can never be a rule, so no rule is behind it +def test_a_dangling_link_under_rules_that_is_not_markdown_is_named(tmp_path): + # a link that does not resolve may be a directory of rules (`shared.rules`), which the tree cannot tell apart (tmp_path / ".claude/rules").mkdir(parents=True) (tmp_path / ".claude/rules/own.md").write_text("own\n", encoding="utf-8") try: - (tmp_path / ".claude/rules/notes.txt").symlink_to(tmp_path / "nowhere.txt") + (tmp_path / ".claude/rules/shared.rules").symlink_to(tmp_path / "nowhere") except OSError: pytest.skip("symlinks unavailable") payload = budget(WorkTree(tmp_path), NAMES, rules=True) - assert payload["warnings"] == [] + assert payload["warnings"] == [ + ".claude/rules/shared.rules: a link that does not resolve in the tree; any rules behind it are not counted"] + + +def test_a_rule_does_not_expand_its_at_imports_even_when_claude_md_imports_it(tmp_path): + files = {"CLAUDE.md": "@.claude/rules/r.md\n", ".claude/rules/r.md": "rule @../../extra.md\n", + "extra.md": "extra\n"} + payload = budget(_tree(tmp_path, files), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" + assert [e["path"] for e in payload["entries"] if e["path"] == "extra.md"] == [] + + +def test_a_file_named_claude_md_under_rules_is_a_rule_not_guidance(tmp_path): + files = {"CLAUDE.md": "root\n", ".claude/rules/CLAUDE.md": "@extra.md\n", ".claude/rules/extra.md": "extra\n"} + payload = budget(_tree(tmp_path, files), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/CLAUDE.md", "rule")]["when"] == "start" + assert not [e for e in payload["entries"] if e["kind"] == "guidance" and e["path"].startswith(".claude/rules/")] + + +def test_a_linked_directory_named_like_a_rule_is_named_not_counted_as_a_rule(tmp_path): + (tmp_path / "shared").mkdir() + (tmp_path / "shared/style.md").write_text("style\n", encoding="utf-8") + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/shared.md").symlink_to(tmp_path / "shared", target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + payload = budget(WorkTree(tmp_path), NAMES, rules=True) + assert not [e for e in payload["entries"] if e["kind"] == "rule"] + assert payload["warnings"] == [".claude/rules/shared.md: a link to a directory; the rules behind it are not counted"] def test_a_dangling_link_to_a_markdown_rule_is_named_and_counted_as_zero(tmp_path): From 6cc0717e5d8fb9060948bd0caf33c557f6c5f160 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 23:35:46 -0700 Subject: [PATCH 08/19] fix(repo): rule exclusions follow the harness flag; an open flow list keeps its continuation A profile that does not read .claude/rules (codex) still counts a file there as guidance and follows its @imports. An unindented continuation line of a flow list that the paths key opens is part of the key's block. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/budget.py | 22 +++++++++++++--------- dokimi/tests/test_repo_budget_rules.py | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index df6a18d9a..dba308cf6 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -74,8 +74,9 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in rules: bool = False) -> dict[str, Any]: files = tree.files() names = set(guidance_names) - # a file under .claude/rules is a rule whatever its name (a rules/CLAUDE.md is not guidance) - every = [f for f in files if f.rsplit("/", 1)[-1] in names and not RULE_GLOB.match(f)] + # under .claude/rules a file is a rule whatever its name (a rules/CLAUDE.md is not guidance), when + # the harness reads rules; otherwise it is an ordinary file of the profile's guidance names + every = [f for f in files if f.rsplit("/", 1)[-1] in names and not (rules and RULE_GLOB.match(f))] guidance = _unaliased(tree, every) groups: dict[str, list[str]] = {} for f in every: @@ -88,16 +89,16 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in # resolve (not dangling/out-of-tree) count; unreadable UTF-8 still counts. root_claude_for_off = [f for f in root_claude if _is_resolvable(tree, f)] # A root CLAUDE.md's imports load at start; a nested one's when the agent reaches its subtree. - at_start, cut_start = _imports(tree, root_claude_for_off, import_max_depth) - lazy_all, cut_lazy = _imports(tree, [f for f in claude if f not in root_claude], import_max_depth) + at_start, cut_start = _imports(tree, root_claude_for_off, import_max_depth, rules) + lazy_all, cut_lazy = _imports(tree, [f for f in claude if f not in root_claude], import_max_depth, rules) # An AGENTS.md Claude reads on its own expands its @imports from hop 0: root ones at start, nested # ones when the agent reaches their subtree. One a root CLAUDE* displaces is read only through an # import, whose chain already follows it at its real hop count, unless it is a root CLAUDE* alias. agents = [f for f in every if f.rsplit("/", 1)[-1] == "AGENTS.md" and _is_resolvable(tree, f) and (not root_claude_for_off or any(a in root_claude for a in groups.get(_target(tree, f), [f])))] - agents_at_start, cut_agents_start = _imports(tree, [f for f in agents if _is_root(f)], import_max_depth) - agents_lazy, cut_agents_lazy = _imports(tree, [f for f in agents if not _is_root(f)], import_max_depth) + agents_at_start, cut_agents_start = _imports(tree, [f for f in agents if _is_root(f)], import_max_depth, rules) + agents_lazy, cut_agents_lazy = _imports(tree, [f for f in agents if not _is_root(f)], import_max_depth, rules) at_start |= agents_at_start lazy_all |= agents_lazy cut_start = cut_start + cut_agents_start @@ -360,9 +361,11 @@ def _paths_block(frontmatter: str) -> list[str] | None: for i, line in enumerate(lines): if _PATHS_LINE.match(line): block = [line] + depth = line.count("[") - line.count("]") # a flow list open on the key runs on, unindented or not for item in lines[i + 1:]: - if not item.strip() or item[:1] in (" ", "\t", "-", "#"): + if depth > 0 or not item.strip() or item[:1] in (" ", "\t", "-", "#"): block.append(item) + depth += item.count("[") - item.count("]") else: break return block @@ -385,7 +388,8 @@ def _without_comment(text: str) -> str: return text -def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None) -> tuple[set[str], list[tuple[str, str]]]: +def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None, + rules: bool = False) -> tuple[set[str], list[tuple[str, str]]]: """Files the CLAUDE.md ``roots`` ``@import``, transitively (relative to the importing file). Breadth first, so a file is placed at its shortest hop count. With ``max_depth``, imports of @@ -402,7 +406,7 @@ def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None) -> while level: nxt: list[str] = [] for f in level: - if RULE_GLOB.match(f): + if rules and RULE_GLOB.match(f): continue # an @ path in a rule is not followed (docs/repo-evaluation-contract.md) try: text = tree.read_text(f) diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index 2af0def04..aad870f93 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -119,10 +119,29 @@ def test_rules_are_counted_only_for_a_harness_that_reads_them(tmp_path): assert load_profile("claude-code").reads_claude_rules +def test_a_profile_that_does_not_read_rules_still_counts_a_rules_file_as_guidance_and_imports(tmp_path): + # Without rules, .claude/rules/AGENTS.md is an ordinary guidance file, and an @import through a + # .claude/rules file is followed (the rule exclusions apply only to a harness that reads rules). + from dokimi.repos.harness_profile import load_profile + + profile = load_profile("codex") + tree = _tree(tmp_path, { + "AGENTS.md": "root @.claude/rules/mid.md\n", + ".claude/rules/AGENTS.md": "nested guidance\n", + ".claude/rules/mid.md": "mid @deep.md\n", + ".claude/rules/deep.md": "deep body\n", + }) + got = _by_path(budget(tree, profile.guidance_names, profile.import_max_depth, False)) + assert ((".claude/rules/AGENTS.md", "guidance") in got) + assert ((".claude/rules/mid.md", "import") in got) + assert ((".claude/rules/deep.md", "import") in got) + + @pytest.mark.parametrize(("front", "when"), [ ('paths:\n - "src/**"\n', "lazy"), ("paths: src/**, lib/**\n", "lazy"), ('paths: "src/**"\nfoo: [unclosed\n', "lazy"), # measured: the key is read line by line + ('paths: [\n"src/**"]\nunused: [unclosed\n', "lazy"), # an unindented continuation of an open flow list ("foo: [unclosed\n", "start"), # measured: no paths, so it loads at start ("paths: []\n", "start"), # measured: an empty list scopes nothing ('paths: ""\n', "start"), From b1f26246a33fc593817d5e6f14d727e5417fe1ef Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sun, 1 Nov 2026 14:02:38 +0000 Subject: [PATCH 09/19] Docs: a rule a root guidance file imports is start whatever its paths Signed-off-by: Anton Volkov --- .../20261030_180000_napetrov_budget-claude-rules.md | 2 +- dokimi/docs/architecture.md | 4 ++-- dokimi/docs/repo-evaluation-contract.md | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md index 2ad94aa42..25c1986a4 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md @@ -2,7 +2,7 @@ - **`repo budget` counts `.claude/rules`** — a `.claude/rules/**/*.md` file is a `rule` entry. A rule in the root `.claude/rules/` whose `paths` frontmatter names no pattern is `start`; one - whose `paths` does, and every rule of a subdirectory's `.claude/rules/`, is `lazy`. The + whose `paths` does, and every rule of a subdirectory's `.claude/rules/`, is `lazy`, unless a root `CLAUDE.md` or `AGENTS.md` imports it: then it is `start` whatever its `paths`. The frontmatter is not counted, since Claude Code removes it before loading the rule, and an `@` path in a rule is not followed as an import. A link to a directory under `.claude/rules` is a warning: the rules behind it are not counted. A link under `.claude/rules` that does not resolve in diff --git a/dokimi/docs/architecture.md b/dokimi/docs/architecture.md index f21f8dbfe..84108effd 100644 --- a/dokimi/docs/architecture.md +++ b/dokimi/docs/architecture.md @@ -302,8 +302,8 @@ next release. hop limit, and on a link under `.claude/rules` to a directory or out of the tree (the rules behind it are not counted), each of which also trips `--fail-on-warning`. For a profile with `guidance.claude_rules` (Claude Code), `.claude/rules/**/*.md` files are `rule` entries: `start` in the root `.claude/rules/` when - `paths` names no pattern, `lazy` when it does or in a subdirectory's `.claude/rules/`, the - frontmatter not counted. `start` is not an upper bound: it is too low by what it does not count + `paths` names no pattern, `lazy` when it does or in a subdirectory's `.claude/rules/`, but `start` whatever its `paths` when a root CLAUDE.md or + AGENTS.md imports it, the frontmatter not counted. `start` is not an upper bound: it is too low by what it does not count (only `@path.md` imports are followed, so `@notes.txt` adds nothing; skills are read only from real `.claude/skills/*/SKILL.md` directories at the root, so a skill behind a linked directory is not seen, nor are the rules behind one (a warning names it), and a diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index 31927114b..7170cd59b 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -48,7 +48,8 @@ SPDX-License-Identifier: Apache-2.0 > `CLAUDE.local.md` turns them off), nested guidance lazily, skill bodies on demand, and AGENTS.md > files a root CLAUDE* turns off. For a profile with `guidance.claude_rules` (`claude-code`), > `.claude/rules/**/*.md` files are `rule` entries: at start in the root `.claude/rules/` unless -> their frontmatter's `paths` names a pattern, lazily then and in a subdirectory's `.claude/rules/`; +> their frontmatter's `paths` names a pattern, lazily then and in a subdirectory's `.claude/rules/`; a rule a root CLAUDE.md or AGENTS.md +> imports is `start` whatever its `paths`; > an `@` path in a rule is not followed. The `start` total is not an upper bound: it is too low by > what it does not count (non-`.md` imports, skills behind a linked directory, rules behind one, > which warns, nested `.claude/skills/` skills, and From cd427e9d16b52ecf227f36129c507abdfbb12bc1 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 09:59:21 -0800 Subject: [PATCH 10/19] fix(budget): comments and alias links do not change how a rule's paths or imports read A bracket inside a YAML comment on the paths line (or on a comment line under it) counted toward the flow-list depth, so an unrelated malformed key below was pulled into the paths block and an empty paths was read as scoped. Comments are now left out of the depth. An @ import of an alias that links to a rule was still followed, though an @ path inside a rule is not. The rule check now also looks at the file the import resolves to, as the rule dedup already does. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/budget.py | 14 ++++++++++---- dokimi/tests/test_repo_budget_rules.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index dba308cf6..876b732c1 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -361,17 +361,23 @@ def _paths_block(frontmatter: str) -> list[str] | None: for i, line in enumerate(lines): if _PATHS_LINE.match(line): block = [line] - depth = line.count("[") - line.count("]") # a flow list open on the key runs on, unindented or not + depth = _flow_depth(line) # a flow list open on the key runs on, unindented or not for item in lines[i + 1:]: if depth > 0 or not item.strip() or item[:1] in (" ", "\t", "-", "#"): block.append(item) - depth += item.count("[") - item.count("]") + depth += _flow_depth(item) else: break return block return None +def _flow_depth(line: str) -> int: + """Open minus closed brackets on a line, a YAML comment left out (``paths: [] # [x`` is closed).""" + text = _without_comment(line) + return text.count("[") - text.count("]") + + def _without_comment(text: str) -> str: """``text`` up to a YAML comment: a `#` that starts a word, outside a quoted scalar (``"src/#x"`` keeps it).""" quote = None @@ -406,8 +412,8 @@ def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None, while level: nxt: list[str] = [] for f in level: - if rules and RULE_GLOB.match(f): - continue # an @ path in a rule is not followed (docs/repo-evaluation-contract.md) + if rules and (RULE_GLOB.match(f) or RULE_GLOB.match(_target(tree, f))): + continue # an @ path in a rule, or in an alias that links to one, is not followed (docs/repo-evaluation-contract.md) try: text = tree.read_text(f) except (OSError, ValueError, RuntimeError): # RuntimeError: a link loop before Python 3.13 diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index aad870f93..224a345fd 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -172,6 +172,9 @@ def test_a_profile_that_does_not_read_rules_still_counts_a_rules_file_as_guidanc pytest.param("paths: 5\nunused: [unclosed\n", "start", id="unparsed-integer-unrelated-malformed"), pytest.param('paths: [\n "src/**"]\nunused: [unclosed\n', "lazy", id="unparsed-flow-list-over-lines"), pytest.param('paths: [\n ""]\nunused: [unclosed\n', "start", id="unparsed-flow-list-empty-over-lines"), + # a bracket in a comment is not part of the flow list, so the next key is not pulled into the paths block + pytest.param("paths: [] # [optional\nunused: [unclosed\n", "start", id="unparsed-bracket-in-comment"), + pytest.param("paths: []\n# see [docs\nunused: [unclosed\n", "start", id="unparsed-comment-line-with-bracket"), ]) def test_only_a_paths_value_that_names_a_pattern_scopes_a_rule(tmp_path, front, when): payload = budget(_tree(tmp_path, {".claude/rules/r.md": f"---\n{front}---\nbody\n"}), NAMES, rules=True) @@ -315,3 +318,19 @@ def test_a_dangling_link_to_a_markdown_rule_is_named_and_counted_as_zero(tmp_pat assert _by_path(payload)[(".claude/rules/api.md", "rule")]["chars"] == 0 assert payload["warnings"] == [".claude/rules/api.md: not readable (not found, broken link, or outside tree), " "counted as 0"] + + +def test_an_alias_of_a_rule_is_not_followed_as_an_import(tmp_path): + # CLAUDE.md imports an alias that links to a rule. The rule's @ imports are not followed, so extra.md is not counted + tree = _tree(tmp_path, {"CLAUDE.md": "@docs/memory/alias.md\n", "extra.md": "extra\n", + ".claude/rules/r.md": "rule @../../extra.md\n"}) + (tmp_path / "docs/memory").mkdir(parents=True) + try: + (tmp_path / "docs/memory/alias.md").symlink_to(tmp_path / ".claude/rules/r.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + assert all(e["path"] != "extra.md" for e in payload["entries"]) + assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" From adbfd73972fc39f6b77efb642a07247e79c5c35b Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 11:11:02 -0800 Subject: [PATCH 11/19] fix(budget): an indented field of another key ends the paths block When the frontmatter does not parse, the paths key was read with its indented continuation lines, so ` unused: [unclosed` under `paths: []` counted as a pattern and the rule was reported as lazy. A line that is an indented mapping key now ends the block, outside an open flow list. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/budget.py | 3 +++ dokimi/tests/test_repo_budget_rules.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index 876b732c1..087022128 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -49,6 +49,7 @@ _FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\Z)", re.S | re.M) # The `paths:` key of frontmatter that does not parse: its line, read on its own (see _paths_block). _PATHS_LINE = re.compile(r"^paths[ \t]*:(.*)$") +_INDENTED_KEY = re.compile(r"^[ \t]+[A-Za-z0-9_][^:#]*:(?:[ \t]|$)") # ` unused: ...`: a key, not a paths continuation MCP_FILE = ".mcp.json" # Claude Code truncates description + when_to_use at 1,536 characters in the listing (docs: Skills). LISTING_TEXT_CAP = 1536 @@ -363,6 +364,8 @@ def _paths_block(frontmatter: str) -> list[str] | None: block = [line] depth = _flow_depth(line) # a flow list open on the key runs on, unindented or not for item in lines[i + 1:]: + if depth == 0 and _INDENTED_KEY.match(item): # an indented field of another key ends the block + break if depth > 0 or not item.strip() or item[:1] in (" ", "\t", "-", "#"): block.append(item) depth += _flow_depth(item) diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index 224a345fd..1a4f6dad3 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -175,6 +175,11 @@ def test_a_profile_that_does_not_read_rules_still_counts_a_rules_file_as_guidanc # a bracket in a comment is not part of the flow list, so the next key is not pulled into the paths block pytest.param("paths: [] # [optional\nunused: [unclosed\n", "start", id="unparsed-bracket-in-comment"), pytest.param("paths: []\n# see [docs\nunused: [unclosed\n", "start", id="unparsed-comment-line-with-bracket"), + # an indented field of another key is not a continuation of the paths value, after a complete value or a list + pytest.param("paths: []\n unused: [unclosed\n", "start", id="unparsed-indented-field-after-empty-value"), + pytest.param("paths: null\n unused: [unclosed\n", "start", id="unparsed-indented-field-after-null"), + pytest.param("paths: src/**\n unused: [unclosed\n", "lazy", id="unparsed-indented-field-after-pattern"), + pytest.param('paths:\n - "src/**"\n unused: [unclosed\n', "lazy", id="unparsed-indented-field-after-list"), ]) def test_only_a_paths_value_that_names_a_pattern_scopes_a_rule(tmp_path, front, when): payload = budget(_tree(tmp_path, {".claude/rules/r.md": f"---\n{front}---\nbody\n"}), NAMES, rules=True) From 6d372ac27473ddf5cd500e2010b110a098cd77e1 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 11:29:02 -0800 Subject: [PATCH 12/19] fix(budget): a scoped rule does not cover a cut import at start A cut import of a rule that loads only when a matching file is read was suppressed as already loaded, so the hop-limit warning was lost. Only a rule that loads at start now counts as loaded for that check. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/budget.py | 4 +++- dokimi/tests/test_repo_budget_rules.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index 087022128..c6e773cfe 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -151,8 +151,10 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in totals = {w: sum(e["chars"] for e in entries if e["when"] == w) for w in ("start", "lazy", "on demand", "off")} warnings = unreadable_warnings[:] # A guidance file or a rule is an entry of its own, so a cut import of it is already loaded (not an import). + # A scoped rule (lazy) loads only when a matching file is read, so it does not cover a cut import at start. loaded = {_target(tree, p) for p in at_start | lazy_all} | { - _target(tree, e["path"]) for e in entries if e["kind"] in ("guidance", "rule") and e["when"] != "off"} + _target(tree, e["path"]) for e in entries + if (e["kind"] == "guidance" and e["when"] != "off") or (e["kind"] == "rule" and e["when"] == "start")} for f, target in sorted(set(cut_start + cut_lazy)): if _target(tree, target) in loaded: continue # another chain did load it, possibly through a link diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index 1a4f6dad3..96c5e9115 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -195,6 +195,16 @@ def test_a_cut_import_of_a_counted_rule_does_not_warn(tmp_path): assert payload["warnings"] == [] +def test_a_cut_import_of_a_scoped_rule_warns(tmp_path): + # a scoped rule loads only when a matching file is read, so the cut import is still named + files = {"CLAUDE.md": "@c1.md\n", "c1.md": "@c2.md\n", "c2.md": "@c3.md\n", "c3.md": "@c4.md\n", + "c4.md": "@.claude/rules/r.md\n", ".claude/rules/r.md": "---\npaths: src/**\n---\nrule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=4, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "lazy" + assert len(payload["warnings"]) == 1 + assert "c4.md: import of .claude/rules/r.md" in payload["warnings"][0] + + def test_a_linked_rules_directory_is_named_not_silently_skipped(tmp_path): (tmp_path / "shared").mkdir() (tmp_path / "shared/style.md").write_text("style\n", encoding="utf-8") From b484dc822defc155e31f4ca4692c84933bde0ac8 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sun, 1 Nov 2026 21:03:01 +0000 Subject: [PATCH 13/19] Recognise a quoted key after empty paths as another field Signed-off-by: Anton Volkov --- dokimi/dokimi/repos/budget.py | 2 +- dokimi/tests/test_repo_budget_rules.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index c6e773cfe..5847e8ed4 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -49,7 +49,7 @@ _FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\Z)", re.S | re.M) # The `paths:` key of frontmatter that does not parse: its line, read on its own (see _paths_block). _PATHS_LINE = re.compile(r"^paths[ \t]*:(.*)$") -_INDENTED_KEY = re.compile(r"^[ \t]+[A-Za-z0-9_][^:#]*:(?:[ \t]|$)") # ` unused: ...`: a key, not a paths continuation +_INDENTED_KEY = re.compile(r"^[ \t]+(?:[A-Za-z0-9_][^:#]*|\"[^\"]*\"|'[^']*'):(?:[ \t]|$)") # ` unused: ...`: a key, not a paths continuation MCP_FILE = ".mcp.json" # Claude Code truncates description + when_to_use at 1,536 characters in the listing (docs: Skills). LISTING_TEXT_CAP = 1536 diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index 96c5e9115..7b4344b3a 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -178,6 +178,8 @@ def test_a_profile_that_does_not_read_rules_still_counts_a_rules_file_as_guidanc # an indented field of another key is not a continuation of the paths value, after a complete value or a list pytest.param("paths: []\n unused: [unclosed\n", "start", id="unparsed-indented-field-after-empty-value"), pytest.param("paths: null\n unused: [unclosed\n", "start", id="unparsed-indented-field-after-null"), + pytest.param('paths: []\n "unused": [unclosed\n', "start", id="unparsed-double-quoted-field-after-empty-value"), + pytest.param("paths: []\n 'unused': [unclosed\n", "start", id="unparsed-single-quoted-field-after-empty-value"), pytest.param("paths: src/**\n unused: [unclosed\n", "lazy", id="unparsed-indented-field-after-pattern"), pytest.param('paths:\n - "src/**"\n unused: [unclosed\n', "lazy", id="unparsed-indented-field-after-list"), ]) From c09d9e47571c24b3359bc56812c4622a3e5ff44f Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 16:57:56 -0800 Subject: [PATCH 14/19] fix(repo): budget keeps a root CLAUDE.md's imports and loads rule aliases by their own route A root CLAUDE.md linked to a rule still expands its @ imports: only an import that reaches a rule is not followed. A rule that promotes a guidance entry (a scoped rule linked to an off AGENTS.md) no longer counts that file as loaded, so a cut import of it is still named. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/budget.py | 13 +++++++---- dokimi/tests/test_repo_budget_rules.py | 32 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index 5847e8ed4..59db51665 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -131,6 +131,8 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in if warning: unreadable_warnings.append(warning) entries.append({"path": f, "kind": "guidance", "when": when, "chars": chars}) + # What loads without a rule: a rule promotes a guidance entry below, but that does not make it independently loaded + guidance_loaded = {_target(tree, e["path"]) for e in entries if e["when"] != "off"} counted = {_target(tree, f) for f in guidance} entries += _rules(tree, files if rules else [], counted, start_t, unreadable_warnings, entries) for f in sorted((at_start | lazily) - set(guidance), key=lambda p: (p not in at_start, p)): @@ -152,9 +154,8 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in warnings = unreadable_warnings[:] # A guidance file or a rule is an entry of its own, so a cut import of it is already loaded (not an import). # A scoped rule (lazy) loads only when a matching file is read, so it does not cover a cut import at start. - loaded = {_target(tree, p) for p in at_start | lazy_all} | { - _target(tree, e["path"]) for e in entries - if (e["kind"] == "guidance" and e["when"] != "off") or (e["kind"] == "rule" and e["when"] == "start")} + loaded = {_target(tree, p) for p in at_start | lazy_all} | guidance_loaded | { + _target(tree, e["path"]) for e in entries if e["when"] == "start" and e["kind"] in ("guidance", "rule")} for f, target in sorted(set(cut_start + cut_lazy)): if _target(tree, target) in loaded: continue # another chain did load it, possibly through a link @@ -417,8 +418,10 @@ def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None, while level: nxt: list[str] = [] for f in level: - if rules and (RULE_GLOB.match(f) or RULE_GLOB.match(_target(tree, f))): - continue # an @ path in a rule, or in an alias that links to one, is not followed (docs/repo-evaluation-contract.md) + # an @ path in a rule, or in an import that links to one, is not followed (docs/repo-evaluation-contract.md). + # A root (or nested CLAUDE.md, AGENTS.md) is memory whatever it links to, so it keeps expanding. + if rules and (RULE_GLOB.match(f) or (f not in roots and RULE_GLOB.match(_target(tree, f)))): + continue try: text = tree.read_text(f) except (OSError, ValueError, RuntimeError): # RuntimeError: a link loop before Python 3.13 diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index 7b4344b3a..d7186d3b4 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -351,3 +351,35 @@ def test_an_alias_of_a_rule_is_not_followed_as_an_import(tmp_path): assert all(e["path"] != "extra.md" for e in payload["entries"]) assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" + + +def test_a_root_claude_md_linked_to_a_rule_still_expands_its_imports(tmp_path): + # the root is memory, not a rule: Claude Code expands its @ imports whatever file it links to + tree = _tree(tmp_path, {"extra.md": "x" * 500 + "\n", ".claude/rules/shared.md": "shared @extra.md\n"}) + try: + (tmp_path / "CLAUDE.md").symlink_to(tmp_path / ".claude/rules/shared.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + assert _by_path(payload)[("extra.md", "import")]["when"] == "start" + assert _by_path(payload)[("extra.md", "import")]["chars"] == 501 + + +def test_a_scoped_rule_alias_of_agents_md_does_not_suppress_a_cut_import(tmp_path): + # AGENTS.md is off (a root CLAUDE.md displaces it) and is reached only through an import cut at hop 4. + # The rule link to it is scoped, so the file still does not load at start: the cut import is named. + files = {"CLAUDE.md": "@c1.md\n", "c1.md": "@c2.md\n", "c2.md": "@c3.md\n", "c3.md": "@c4.md\n", + "c4.md": "@AGENTS.md\n", "AGENTS.md": "---\npaths: src/**\n---\nagents\n"} + tree = _tree(tmp_path, files) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/agents.md").symlink_to(tmp_path / "AGENTS.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, import_max_depth=4, rules=True) + + assert _by_path(payload)[("AGENTS.md", "guidance")]["when"] == "lazy" + assert any("c4.md: import of AGENTS.md is past" in w for w in payload["warnings"]) From f4b58f8295bc83fb4f224ab24749579d14278204 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 18:39:53 -0800 Subject: [PATCH 15/19] fix(budget): follow an @ path of a rule a CLAUDE.md imports; cut-warning and walk order fixes A rule's @ paths are imports only when a CLAUDE.md imports the rule: then they are followed transitively, within the hop limit (maintainer decision on #326). A rule that no CLAUDE.md imports is still not read for imports. - _imports: a file is expanded once per kind of route (a .claude/rules path, or any other), so an import reached through a rule link does not hide the same file reached by its own path, in either order. - A rule's frontmatter is not scanned for imports (it is not counted). - A cut import of an unscoped nested rule from guidance in that rule's subtree is not warned: the rule loads with that guidance. Scoped rules and imports from outside the subtree still warn. - Docs (repo-evaluation-contract, architecture, changelog) state the rule. Signed-off-by: Nikolay Petrov --- ...030_180000_napetrov_budget-claude-rules.md | 4 +- dokimi/docs/architecture.md | 8 +- dokimi/docs/repo-evaluation-contract.md | 7 +- dokimi/dokimi/repos/budget.py | 72 +++++++++++----- dokimi/tests/test_repo_budget_rules.py | 84 +++++++++++++++++-- 5 files changed, 142 insertions(+), 33 deletions(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md index 25c1986a4..b46eb144b 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md @@ -4,12 +4,12 @@ A rule in the root `.claude/rules/` whose `paths` frontmatter names no pattern is `start`; one whose `paths` does, and every rule of a subdirectory's `.claude/rules/`, is `lazy`, unless a root `CLAUDE.md` or `AGENTS.md` imports it: then it is `start` whatever its `paths`. The frontmatter is not counted, since Claude Code removes it before loading the rule, and an `@` - path in a rule is not followed as an import. A link to a directory under `.claude/rules` is a + path in a rule is not followed, unless a CLAUDE.md imports the rule: then the rule loads at start and its `@` imports are followed, transitively and within the hop limit. A link to a directory under `.claude/rules` is a warning: the rules behind it are not counted. A link under `.claude/rules` that does not resolve in the tree is named too, whatever its suffix, since the tree cannot tell a dangling file from a directory; a `CLAUDE.md` under `.claude/rules` is a rule, not guidance. Measured on Claude Code 2.1.304: a root rule raised the first prompt by its size, `paths: []` included; a scoped or nested one loaded only once the agent read a file it covers; a rule's `@extra.md` was not expanded. `start` was too - low by every root rule before; `repo_budget.v1` widens `kind` with `rule`. Rules are counted + low by unscoped root rules before; `repo_budget.v1` widens `kind` with `rule`. Rules are counted for a harness profile with the new optional `guidance.claude_rules: true` (`claude-code` only); the Codex and Copilot profiles leave it unset. diff --git a/dokimi/docs/architecture.md b/dokimi/docs/architecture.md index 84108effd..1c7ce31c8 100644 --- a/dokimi/docs/architecture.md +++ b/dokimi/docs/architecture.md @@ -299,14 +299,16 @@ next release. skills with near-duplicate descriptions (identical descriptions, or Jaccard index >= 0.5 after stop-word removal when both have 3+ words left), on AGENTS.md files turned off, on unreadable files (non-UTF-8, dangling or out-of-tree link), on imports past the harness's - hop limit, and on a link under `.claude/rules` to a directory or out of the tree (the rules behind + hop limit, and on a link at or below `.claude/rules` to a directory or out of the tree (the rules behind it are not counted), each of which also trips `--fail-on-warning`. For a profile with `guidance.claude_rules` (Claude Code), `.claude/rules/**/*.md` files are `rule` entries: `start` in the root `.claude/rules/` when `paths` names no pattern, `lazy` when it does or in a subdirectory's `.claude/rules/`, but `start` whatever its `paths` when a root CLAUDE.md or - AGENTS.md imports it, the frontmatter not counted. `start` is not an upper bound: it is too low by what it does not count + AGENTS.md imports it, the frontmatter not counted. An `@` path in a rule is not followed, unless a CLAUDE.md imports + the rule: then the rule loads at start and its `@` imports are followed, transitively and within the hop limit. `start` is not an upper bound: it is too low by what it does not count (only `@path.md` imports are followed, so `@notes.txt` adds nothing; skills are read only from real `.claude/skills/*/SKILL.md` directories at the root, so a skill - behind a linked directory is not seen, nor are the rules behind one (a warning names it), and a + behind a linked directory is not seen, nor are the rules behind a link at or below `.claude/rules` (a warning + names it; a `.claude` link itself is neither counted nor named), and a nested `/.claude/skills/` skill, which Claude Code loads on the first read in that subdirectory, is not counted; and imports past the profile's `guidance.import_max_depth`, four hops for Claude Code, which warn naming what was skipped). It is too high by whatever the diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index 7f4105c14..046cb29f3 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -50,9 +50,10 @@ SPDX-License-Identifier: Apache-2.0 > `.claude/rules/**/*.md` files are `rule` entries: at start in the root `.claude/rules/` unless > their frontmatter's `paths` names a pattern, lazily then and in a subdirectory's `.claude/rules/`; a rule a root CLAUDE.md or AGENTS.md > imports is `start` whatever its `paths`; -> an `@` path in a rule is not followed. The `start` total is not an upper bound: it is too low by -> what it does not count (non-`.md` imports, skills behind a linked directory, rules behind one, -> which warns, nested `.claude/skills/` skills, and +> an `@` path in a rule is not followed, unless a CLAUDE.md imports the rule: then the rule loads at start and +> its `@` imports are followed, transitively and within the hop limit. The `start` total is not an upper bound: +> it is too low by what it does not count (non-`.md` imports, skills behind a linked directory, rules behind a +> link at or below `.claude/rules` to a directory, which warns, nested `.claude/skills/` skills, and > imports past the profile's `guidance.import_max_depth`, four hops for Claude Code, which warns > naming what was skipped), and too high by whatever the harness cuts from the skill listing. > Usage: diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index 59db51665..ecc8b2e54 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -9,7 +9,7 @@ - **start**: guidance files at the root, the files a root ``CLAUDE*`` or ``AGENTS.md`` file ``@import``s (``AGENTS.md`` only when it is not turned off), the root ``.claude/rules/**/*.md`` files whose ``paths`` frontmatter names no pattern, when the harness reads rules (their own ``@`` paths are not - imports), and the skill + imports unless a CLAUDE.md imports the rule; then they are, transitively), and the skill listing (each project skill's ``name``, plus its ``description`` and ``when_to_use`` up to 1,536 characters together; a skill with ``disable-model-invocation: true`` is not listed). Every model call pays for these. @@ -71,6 +71,21 @@ _STOP = frozenset("a an and the of to for in on with use when or is are be this that it its from by as at".split()) +def _nested_unscoped(tree: RepoTree, files) -> set: + """(rule target, directory above its .claude/rules) for each unscoped rule in a nested .claude/rules.""" + return {(_target(tree, f), f.split(".claude/rules/", 1)[0]) for f in files + if RULE_GLOB.match(f) and not f.startswith(".claude/rules/") and not _rule(tree, f)[1]} + + +def _cut_is_quiet(tree: RepoTree, f: str, target: str, loaded, cut_lazy_set: set, nested_unscoped: set) -> bool: + """True when a cut import needs no warning: another chain loaded its target, or it is a cut lazy import of an + unscoped nested rule from guidance in that rule's subtree (the rule loads with that guidance anyway).""" + if _target(tree, target) in loaded: + return True # another chain did load it, possibly through a link + return (f, target) in cut_lazy_set and any( + _target(tree, target) == t and f.startswith(sub) for t, sub in nested_unscoped) + + def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: int | None = None, rules: bool = False) -> dict[str, Any]: files = tree.files() @@ -90,16 +105,16 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in # resolve (not dangling/out-of-tree) count; unreadable UTF-8 still counts. root_claude_for_off = [f for f in root_claude if _is_resolvable(tree, f)] # A root CLAUDE.md's imports load at start; a nested one's when the agent reaches its subtree. - at_start, cut_start = _imports(tree, root_claude_for_off, import_max_depth, rules) - lazy_all, cut_lazy = _imports(tree, [f for f in claude if f not in root_claude], import_max_depth, rules) + at_start, cut_start = _imports(tree, root_claude_for_off, import_max_depth) + lazy_all, cut_lazy = _imports(tree, [f for f in claude if f not in root_claude], import_max_depth) # An AGENTS.md Claude reads on its own expands its @imports from hop 0: root ones at start, nested # ones when the agent reaches their subtree. One a root CLAUDE* displaces is read only through an # import, whose chain already follows it at its real hop count, unless it is a root CLAUDE* alias. agents = [f for f in every if f.rsplit("/", 1)[-1] == "AGENTS.md" and _is_resolvable(tree, f) and (not root_claude_for_off or any(a in root_claude for a in groups.get(_target(tree, f), [f])))] - agents_at_start, cut_agents_start = _imports(tree, [f for f in agents if _is_root(f)], import_max_depth, rules) - agents_lazy, cut_agents_lazy = _imports(tree, [f for f in agents if not _is_root(f)], import_max_depth, rules) + agents_at_start, cut_agents_start = _imports(tree, [f for f in agents if _is_root(f)], import_max_depth) + agents_lazy, cut_agents_lazy = _imports(tree, [f for f in agents if not _is_root(f)], import_max_depth) at_start |= agents_at_start lazy_all |= agents_lazy cut_start = cut_start + cut_agents_start @@ -156,9 +171,14 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in # A scoped rule (lazy) loads only when a matching file is read, so it does not cover a cut import at start. loaded = {_target(tree, p) for p in at_start | lazy_all} | guidance_loaded | { _target(tree, e["path"]) for e in entries if e["when"] == "start" and e["kind"] in ("guidance", "rule")} + # A nested rule with no paths loads whenever the agent works in its subtree (the directory above its + # .claude/rules), so a cut import of it from guidance in that subtree skips nothing. A path-scoped one loads on + # a matching read, and one imported from outside its subtree is not loaded with the importer: both still named. + nested_unscoped = _nested_unscoped(tree, files) if rules else set() + cut_lazy_set = set(cut_lazy) for f, target in sorted(set(cut_start + cut_lazy)): - if _target(tree, target) in loaded: - continue # another chain did load it, possibly through a link + if _cut_is_quiet(tree, f, target, loaded, cut_lazy_set, nested_unscoped): + continue if not _is_resolvable(tree, target): continue # nothing to load, as with a missing import within the limit warnings.append(f"{f}: import of {target} is past the harness's {import_max_depth}-hop import limit, " @@ -303,6 +323,12 @@ def _rule(tree: RepoTree, path: str) -> tuple[int, bool, str | None]: return len(text) - m.end(), _has_paths(m.group(1)), None +def _without_frontmatter(text: str) -> str: + text = text.removeprefix("\ufeff") + m = _FRONTMATTER.match(text) + return text[m.end():] if m else text + + def _has_paths(frontmatter: str) -> bool: """True if ``paths`` names at least one pattern: a string, or a list item that is a string, not blank. @@ -400,44 +426,50 @@ def _without_comment(text: str) -> str: return text -def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None, - rules: bool = False) -> tuple[set[str], list[tuple[str, str]]]: +def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None) -> tuple[set[str], list[tuple[str, str]]]: """Files the CLAUDE.md ``roots`` ``@import``, transitively (relative to the importing file). - Breadth first, so a file is placed at its shortest hop count. With ``max_depth``, imports of - files already ``max_depth`` hops from a root are not followed; those left unreached come back - as ``(importing file, target)`` pairs. + Breadth first, so a file is placed at its shortest hop count. A rule an import reaches is followed like any + other file (its @ paths are imports only because a CLAUDE.md imports the rule: a rule that no CLAUDE.md imports + is never read here). With ``max_depth``, imports of files already ``max_depth`` hops from a root are not + followed; those left unreached come back as ``(importing file, target)`` pairs. """ seen: set[str] = set() - # Also by the file a path resolves to: an alias (CLAUDE.md -> .) names the same file under - # ever longer paths, which a path-string check never recognises as a cycle. + # Also by the file a path resolves to: an alias (CLAUDE.md -> .) names the same file under ever longer + # paths, which a path-string check never recognises as a cycle. A file is expanded once per kind of route: + # by a `.claude/rules` path, and by any other path. So a file a rule link reaches first is still expanded + # for an import that names it by its own path, whichever import comes first, and each file is expanded at most + # twice (once per kind), so the walk stays linear. Two non-rule paths to one file still expand it once only. reached = {_target(tree, r) for r in roots} + reached_by_rule: set[str] = set() level = list(roots) cut: list[tuple[str, str]] = [] depth = 0 while level: nxt: list[str] = [] for f in level: - # an @ path in a rule, or in an import that links to one, is not followed (docs/repo-evaluation-contract.md). - # A root (or nested CLAUDE.md, AGENTS.md) is memory whatever it links to, so it keeps expanding. - if rules and (RULE_GLOB.match(f) or (f not in roots and RULE_GLOB.match(_target(tree, f)))): - continue try: text = tree.read_text(f) except (OSError, ValueError, RuntimeError): # RuntimeError: a link loop before Python 3.13 continue + if f not in roots and (RULE_GLOB.match(f) or RULE_GLOB.match(_target(tree, f))): + text = _without_frontmatter(text) # a rule's frontmatter is removed before it loads (as _rule counts) here = f.rsplit("/", 1)[0] + "/" if "/" in f else "" for target in IMPORT.findall(_strip_code_spans(_CODE_BLOCK.sub("", text))): if target.startswith("/"): continue # an absolute path names the host filesystem, not a file of the tree path = _normalize(here + target) - if not path or path in seen or _target(tree, path) in reached: + if not path or path in seen: + continue + t = _target(tree, path) + done = reached_by_rule if RULE_GLOB.match(path) else reached + if t in done: continue if max_depth is not None and depth >= max_depth: cut.append((f, path)) continue seen.add(path) - reached.add(_target(tree, path)) + done.add(t) nxt.append(path) level = nxt depth += 1 diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index d7186d3b4..844ee1489 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -297,14 +297,46 @@ def test_a_dangling_link_under_rules_that_is_not_markdown_is_named(tmp_path): ".claude/rules/shared.rules: a link that does not resolve in the tree; any rules behind it are not counted"] -def test_a_rule_does_not_expand_its_at_imports_even_when_claude_md_imports_it(tmp_path): +def test_a_rule_a_claude_md_imports_expands_its_at_imports_transitively(tmp_path): + # maintainer decision (intel/dokimi #326): a CLAUDE.md that imports a rule loads it at start, and the @ paths + # in it are followed like any import, hop by hop files = {"CLAUDE.md": "@.claude/rules/r.md\n", ".claude/rules/r.md": "rule @../../extra.md\n", - "extra.md": "extra\n"} + "extra.md": "extra @extra2.md\n", "extra2.md": "extra2\n"} + payload = budget(_tree(tmp_path, files), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" + assert _by_path(payload)[("extra.md", "import")]["when"] == "start" + assert _by_path(payload)[("extra2.md", "import")]["when"] == "start" + assert payload["warnings"] == [] + + +def test_a_rule_discovered_as_a_rule_does_not_expand_its_at_imports(tmp_path): + # no CLAUDE.md imports the rule: it loads as a rule, and its @ paths are not followed + files = {".claude/rules/r.md": "rule @../../extra.md\n", "extra.md": "extra\n"} payload = budget(_tree(tmp_path, files), NAMES, rules=True) assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" assert [e["path"] for e in payload["entries"] if e["path"] == "extra.md"] == [] +def test_an_at_path_in_a_rules_frontmatter_is_not_an_import(tmp_path): + # the frontmatter is not counted (Claude Code removes it before loading the rule), so an @ path in it is no import + files = {"CLAUDE.md": "@.claude/rules/r.md\n", + ".claude/rules/r.md": "---\ndescription: see @../../team.md\n---\nrule\n", "team.md": "team\n"} + payload = budget(_tree(tmp_path, files), NAMES, rules=True) + assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" + assert [e["path"] for e in payload["entries"] if e["path"] == "team.md"] == [] + + +def test_an_import_chain_through_a_rule_counts_toward_the_hop_limit(tmp_path): + files = {"CLAUDE.md": "@.claude/rules/r.md\n", ".claude/rules/r.md": "rule @../../extra.md\n", + "extra.md": "extra @extra2.md\n", "extra2.md": "extra2\n"} + # CLAUDE.md -> rule (hop 1) -> extra.md (hop 2) -> extra2.md (hop 3): a 2-hop limit cuts extra2.md + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=2, rules=True) + assert _by_path(payload)[("extra.md", "import")]["when"] == "start" + assert [e["path"] for e in payload["entries"] if e["path"] == "extra2.md"] == [] + assert payload["warnings"] == [ + "extra.md: import of extra2.md is past the harness's 2-hop import limit, not followed"] + + def test_a_file_named_claude_md_under_rules_is_a_rule_not_guidance(tmp_path): files = {"CLAUDE.md": "root\n", ".claude/rules/CLAUDE.md": "@extra.md\n", ".claude/rules/extra.md": "extra\n"} payload = budget(_tree(tmp_path, files), NAMES, rules=True) @@ -337,8 +369,8 @@ def test_a_dangling_link_to_a_markdown_rule_is_named_and_counted_as_zero(tmp_pat "counted as 0"] -def test_an_alias_of_a_rule_is_not_followed_as_an_import(tmp_path): - # CLAUDE.md imports an alias that links to a rule. The rule's @ imports are not followed, so extra.md is not counted +def test_an_alias_of_a_rule_is_followed_as_an_import(tmp_path): + # CLAUDE.md imports an alias that links to a rule: the rule is imported, so its @ imports are followed tree = _tree(tmp_path, {"CLAUDE.md": "@docs/memory/alias.md\n", "extra.md": "extra\n", ".claude/rules/r.md": "rule @../../extra.md\n"}) (tmp_path / "docs/memory").mkdir(parents=True) @@ -349,10 +381,52 @@ def test_an_alias_of_a_rule_is_not_followed_as_an_import(tmp_path): payload = budget(tree, NAMES, rules=True) - assert all(e["path"] != "extra.md" for e in payload["entries"]) + assert _by_path(payload)[("extra.md", "import")]["when"] == "start" assert _by_path(payload)[(".claude/rules/r.md", "rule")]["when"] == "start" +@pytest.mark.parametrize("claude_md", ["@.claude/rules/r.md\n@shared.md\n", "@shared.md\n@.claude/rules/r.md\n"]) +def test_the_order_of_imports_does_not_hide_what_a_rule_link_leads_to(tmp_path, claude_md): + # .claude/rules/r.md links to shared.md, which imports extra.md. Whichever of the two imports comes first, + # shared.md's own route (at the root) still expands extra.md + tree = _tree(tmp_path, {"CLAUDE.md": claude_md, "shared.md": "shared @extra.md\n", "extra.md": "extra\n"}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/r.md").symlink_to(tmp_path / "shared.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + assert _by_path(payload)[("extra.md", "import")]["when"] == "start" + assert payload["warnings"] == [] + + +def test_a_cut_import_of_an_unscoped_nested_rule_does_not_warn(tmp_path): + # a nested rule with no paths loads whenever the agent works in sub/, as the cut import in sub/CLAUDE.md would + files = {"sub/CLAUDE.md": "@.claude/rules/r.md\n", "sub/.claude/rules/r.md": "rule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=0, rules=True) + assert _by_path(payload)[("sub/.claude/rules/r.md", "rule")]["when"] == "lazy" + assert payload["warnings"] == [] + + +def test_a_cut_import_of_an_unscoped_nested_rule_from_outside_its_subtree_still_warns(tmp_path): + # other/ does not load sub/'s rule: only the agent working in sub/ does, so the cut import from other/ is named + files = {"other/CLAUDE.md": "@../sub/.claude/rules/r.md\n", "sub/.claude/rules/r.md": "rule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=0, rules=True) + assert payload["warnings"] == [ + "other/CLAUDE.md: import of sub/.claude/rules/r.md is past the harness's 0-hop import limit, not followed"] + + +def test_a_cut_import_of_a_scoped_nested_rule_still_warns(tmp_path): + # a scoped nested rule loads only when the agent reads a matching file, so the cut import is still named + files = {"sub/CLAUDE.md": "@.claude/rules/r.md\n", "sub/.claude/rules/r.md": "---\npaths: src/**\n---\nrule\n"} + payload = budget(_tree(tmp_path, files), NAMES, import_max_depth=0, rules=True) + assert _by_path(payload)[("sub/.claude/rules/r.md", "rule")]["when"] == "lazy" + assert payload["warnings"] == [ + "sub/CLAUDE.md: import of sub/.claude/rules/r.md is past the harness's 0-hop import limit, not followed"] + + def test_a_root_claude_md_linked_to_a_rule_still_expands_its_imports(tmp_path): # the root is memory, not a rule: Claude Code expands its @ imports whatever file it links to tree = _tree(tmp_path, {"extra.md": "x" * 500 + "\n", ".claude/rules/shared.md": "shared @extra.md\n"}) From a68dc876526c27ecd8607aa7dc5056ce9f878ac9 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 20:22:02 -0800 Subject: [PATCH 16/19] fix(budget): count an import's frontmatter, and keep it for a harness that does not read rules Signed-off-by: Nikolay Petrov --- ...030_180000_napetrov_budget-claude-rules.md | 2 +- dokimi/docs/architecture.md | 2 +- dokimi/docs/repo-evaluation-contract.md | 4 +- dokimi/dokimi/repos/budget.py | 46 +++++++++++-------- dokimi/tests/test_repo_budget_rules.py | 25 +++++++++- 5 files changed, 56 insertions(+), 23 deletions(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md index b46eb144b..4fef6193a 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md @@ -4,7 +4,7 @@ A rule in the root `.claude/rules/` whose `paths` frontmatter names no pattern is `start`; one whose `paths` does, and every rule of a subdirectory's `.claude/rules/`, is `lazy`, unless a root `CLAUDE.md` or `AGENTS.md` imports it: then it is `start` whatever its `paths`. The frontmatter is not counted, since Claude Code removes it before loading the rule, and an `@` - path in a rule is not followed, unless a CLAUDE.md imports the rule: then the rule loads at start and its `@` imports are followed, transitively and within the hop limit. A link to a directory under `.claude/rules` is a + path in a rule is not followed, unless a CLAUDE.md imports the rule: then the rule loads at start (lazily, when a nested CLAUDE.md imports it) and its `@` imports are followed, transitively and within the hop limit. A link to a directory under `.claude/rules` is a warning: the rules behind it are not counted. A link under `.claude/rules` that does not resolve in the tree is named too, whatever its suffix, since the tree cannot tell a dangling file from a directory; a `CLAUDE.md` under `.claude/rules` is a rule, not guidance. Measured on Claude Code 2.1.304: a root rule diff --git a/dokimi/docs/architecture.md b/dokimi/docs/architecture.md index 1c7ce31c8..218910af9 100644 --- a/dokimi/docs/architecture.md +++ b/dokimi/docs/architecture.md @@ -304,7 +304,7 @@ next release. `.claude/rules/**/*.md` files are `rule` entries: `start` in the root `.claude/rules/` when `paths` names no pattern, `lazy` when it does or in a subdirectory's `.claude/rules/`, but `start` whatever its `paths` when a root CLAUDE.md or AGENTS.md imports it, the frontmatter not counted. An `@` path in a rule is not followed, unless a CLAUDE.md imports - the rule: then the rule loads at start and its `@` imports are followed, transitively and within the hop limit. `start` is not an upper bound: it is too low by what it does not count + the rule: then the rule loads at start (lazily, when a nested CLAUDE.md imports it) and its `@` imports are followed, transitively and within the hop limit. `start` is not an upper bound: it is too low by what it does not count (only `@path.md` imports are followed, so `@notes.txt` adds nothing; skills are read only from real `.claude/skills/*/SKILL.md` directories at the root, so a skill behind a linked directory is not seen, nor are the rules behind a link at or below `.claude/rules` (a warning diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index 046cb29f3..e1f8111ff 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -50,8 +50,8 @@ SPDX-License-Identifier: Apache-2.0 > `.claude/rules/**/*.md` files are `rule` entries: at start in the root `.claude/rules/` unless > their frontmatter's `paths` names a pattern, lazily then and in a subdirectory's `.claude/rules/`; a rule a root CLAUDE.md or AGENTS.md > imports is `start` whatever its `paths`; -> an `@` path in a rule is not followed, unless a CLAUDE.md imports the rule: then the rule loads at start and -> its `@` imports are followed, transitively and within the hop limit. The `start` total is not an upper bound: +> an `@` path in a rule is not followed, unless a CLAUDE.md imports the rule: then the rule loads at start (lazily, +> when a nested CLAUDE.md imports it) and its `@` imports are followed, transitively and within the hop limit. The `start` total is not an upper bound: > it is too low by what it does not count (non-`.md` imports, skills behind a linked directory, rules behind a > link at or below `.claude/rules` to a directory, which warns, nested `.claude/skills/` skills, and > imports past the profile's `guidance.import_max_depth`, four hops for Claude Code, which warns diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index ecc8b2e54..705e8361c 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -105,16 +105,16 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in # resolve (not dangling/out-of-tree) count; unreadable UTF-8 still counts. root_claude_for_off = [f for f in root_claude if _is_resolvable(tree, f)] # A root CLAUDE.md's imports load at start; a nested one's when the agent reaches its subtree. - at_start, cut_start = _imports(tree, root_claude_for_off, import_max_depth) - lazy_all, cut_lazy = _imports(tree, [f for f in claude if f not in root_claude], import_max_depth) + at_start, cut_start = _imports(tree, root_claude_for_off, import_max_depth, rules) + lazy_all, cut_lazy = _imports(tree, [f for f in claude if f not in root_claude], import_max_depth, rules) # An AGENTS.md Claude reads on its own expands its @imports from hop 0: root ones at start, nested # ones when the agent reaches their subtree. One a root CLAUDE* displaces is read only through an # import, whose chain already follows it at its real hop count, unless it is a root CLAUDE* alias. agents = [f for f in every if f.rsplit("/", 1)[-1] == "AGENTS.md" and _is_resolvable(tree, f) and (not root_claude_for_off or any(a in root_claude for a in groups.get(_target(tree, f), [f])))] - agents_at_start, cut_agents_start = _imports(tree, [f for f in agents if _is_root(f)], import_max_depth) - agents_lazy, cut_agents_lazy = _imports(tree, [f for f in agents if not _is_root(f)], import_max_depth) + agents_at_start, cut_agents_start = _imports(tree, [f for f in agents if _is_root(f)], import_max_depth, rules) + agents_lazy, cut_agents_lazy = _imports(tree, [f for f in agents if not _is_root(f)], import_max_depth, rules) at_start |= agents_at_start lazy_all |= agents_lazy cut_start = cut_start + cut_agents_start @@ -150,14 +150,28 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in guidance_loaded = {_target(tree, e["path"]) for e in entries if e["when"] != "off"} counted = {_target(tree, f) for f in guidance} entries += _rules(tree, files if rules else [], counted, start_t, unreadable_warnings, entries) + rule_entries = {_target(tree, e["path"]): e for e in entries if e["kind"] == "rule"} for f in sorted((at_start | lazily) - set(guidance), key=lambda p: (p not in at_start, p)): - if _is_resolvable(tree, f) and _target(tree, f) not in counted: - counted.add(_target(tree, f)) + if not _is_resolvable(tree, f): + continue + t = _target(tree, f) + when = "start" if f in at_start else "lazy" + if t in counted: + # A rule link counted this file with its frontmatter removed. An ordinary @import keeps the frontmatter, + # so its count replaces the rule's when it loads at the same time or earlier (a rule file is left alone). + rule = rule_entries.get(t) + if rule is None or RULE_GLOB.match(f) or RULE_GLOB.match(t) or (when == "lazy" and rule["when"] == "start"): + continue chars, warning = _chars(tree, f) if warning: unreadable_warnings.append(warning) - entries.append({"path": f, "kind": "import", "when": "start" if f in at_start else "lazy", - "chars": chars}) + rule.update(path=f, kind="import", when=when, chars=chars) + continue + counted.add(t) + chars, warning = _chars(tree, f) + if warning: + unreadable_warnings.append(warning) + entries.append({"path": f, "kind": "import", "when": when, "chars": chars}) skills = _skills(tree, [f for f in files if SKILL_GLOB.match(f)]) for s in skills: @@ -171,9 +185,8 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in # A scoped rule (lazy) loads only when a matching file is read, so it does not cover a cut import at start. loaded = {_target(tree, p) for p in at_start | lazy_all} | guidance_loaded | { _target(tree, e["path"]) for e in entries if e["when"] == "start" and e["kind"] in ("guidance", "rule")} - # A nested rule with no paths loads whenever the agent works in its subtree (the directory above its - # .claude/rules), so a cut import of it from guidance in that subtree skips nothing. A path-scoped one loads on - # a matching read, and one imported from outside its subtree is not loaded with the importer: both still named. + # An unscoped nested rule loads for any work in its subtree, so a cut import of it there skips nothing. + # A path-scoped rule, or one imported from outside its subtree, does not load with its importer: still named. nested_unscoped = _nested_unscoped(tree, files) if rules else set() cut_lazy_set = set(cut_lazy) for f, target in sorted(set(cut_start + cut_lazy)): @@ -426,7 +439,7 @@ def _without_comment(text: str) -> str: return text -def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None) -> tuple[set[str], list[tuple[str, str]]]: +def _imports(tree: RepoTree, roots: list[str], max_depth: int | None, rules: bool) -> tuple[set[str], list[tuple[str, str]]]: """Files the CLAUDE.md ``roots`` ``@import``, transitively (relative to the importing file). Breadth first, so a file is placed at its shortest hop count. A rule an import reaches is followed like any @@ -435,11 +448,8 @@ def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None) -> followed; those left unreached come back as ``(importing file, target)`` pairs. """ seen: set[str] = set() - # Also by the file a path resolves to: an alias (CLAUDE.md -> .) names the same file under ever longer - # paths, which a path-string check never recognises as a cycle. A file is expanded once per kind of route: - # by a `.claude/rules` path, and by any other path. So a file a rule link reaches first is still expanded - # for an import that names it by its own path, whichever import comes first, and each file is expanded at most - # twice (once per kind), so the walk stays linear. Two non-rule paths to one file still expand it once only. + # Also by the file a path resolves to, so an alias (CLAUDE.md -> .) is not walked again under a longer path. + # Expanded once per route kind (rule path or not): a rule link reached first must not hide a later import. reached = {_target(tree, r) for r in roots} reached_by_rule: set[str] = set() level = list(roots) @@ -452,7 +462,7 @@ def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None) -> text = tree.read_text(f) except (OSError, ValueError, RuntimeError): # RuntimeError: a link loop before Python 3.13 continue - if f not in roots and (RULE_GLOB.match(f) or RULE_GLOB.match(_target(tree, f))): + if rules and f not in roots and (RULE_GLOB.match(f) or RULE_GLOB.match(_target(tree, f))): text = _without_frontmatter(text) # a rule's frontmatter is removed before it loads (as _rule counts) here = f.rsplit("/", 1)[0] + "/" if "/" in f else "" for target in IMPORT.findall(_strip_code_spans(_CODE_BLOCK.sub("", text))): diff --git a/dokimi/tests/test_repo_budget_rules.py b/dokimi/tests/test_repo_budget_rules.py index 844ee1489..b91b9cfe8 100644 --- a/dokimi/tests/test_repo_budget_rules.py +++ b/dokimi/tests/test_repo_budget_rules.py @@ -128,13 +128,36 @@ def test_a_profile_that_does_not_read_rules_still_counts_a_rules_file_as_guidanc tree = _tree(tmp_path, { "AGENTS.md": "root @.claude/rules/mid.md\n", ".claude/rules/AGENTS.md": "nested guidance\n", - ".claude/rules/mid.md": "mid @deep.md\n", + ".claude/rules/mid.md": "---\ndescription: see @../../extra.md\n---\nmid @deep.md\n", ".claude/rules/deep.md": "deep body\n", + "extra.md": "extra body\n", }) got = _by_path(budget(tree, profile.guidance_names, profile.import_max_depth, False)) assert ((".claude/rules/AGENTS.md", "guidance") in got) assert ((".claude/rules/mid.md", "import") in got) assert ((".claude/rules/deep.md", "import") in got) + # the frontmatter is not removed for a harness that does not read rules, so its @path is an import too + assert ("extra.md", "import") in got + + +@pytest.mark.parametrize("claude_md", ["@shared.md\n@.claude/rules/r.md\n", "@.claude/rules/r.md\n@shared.md\n"]) +def test_an_ordinary_import_keeps_the_frontmatter_that_a_rule_link_to_the_same_file_removes(tmp_path, claude_md): + # CLAUDE.md imports shared.md whole, frontmatter included; the rule link to it loads only the body. + # Whichever comes first, the file loads at start as an import, so it is counted in full. + text = "---\ndescription: example\n---\nbody\n" + tree = _tree(tmp_path, {"CLAUDE.md": claude_md, "shared.md": text}) + (tmp_path / ".claude/rules").mkdir(parents=True) + try: + (tmp_path / ".claude/rules/r.md").symlink_to(tmp_path / "shared.md") + except OSError: + pytest.skip("symlinks unavailable") + + payload = budget(tree, NAMES, rules=True) + + got = _by_path(payload) + assert got[("shared.md", "import")]["when"] == "start" + assert got[("shared.md", "import")]["chars"] == len(text) + assert payload["warnings"] == [] @pytest.mark.parametrize(("front", "when"), [ From 0edef646bcfd76a4cc6818634bf05c9c4ea4257b Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 20:26:54 -0800 Subject: [PATCH 17/19] docs(budget): changelog for frontmatter on ordinary imports Signed-off-by: Nikolay Petrov --- .../20261030_180000_napetrov_budget-claude-rules.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md index 4fef6193a..3f4330f69 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md @@ -12,4 +12,5 @@ once the agent read a file it covers; a rule's `@extra.md` was not expanded. `start` was too low by unscoped root rules before; `repo_budget.v1` widens `kind` with `rule`. Rules are counted for a harness profile with the new optional `guidance.claude_rules: true` (`claude-code` - only); the Codex and Copilot profiles leave it unset. + only); the Codex and Copilot profiles leave it unset. An `@` import of a file keeps its frontmatter + (it is counted in full), and a profile that does not read rules strips none from an imported rule file. From ae1285310bf77f21398fa129d1f37ba10bee9126 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 20:30:42 -0800 Subject: [PATCH 18/19] docs(budget): a nested CLAUDE.md import loads a rule lazily, not at start Signed-off-by: Nikolay Petrov --- .../20261030_180000_napetrov_budget-claude-rules.md | 2 +- dokimi/docs/architecture.md | 2 +- dokimi/docs/repo-evaluation-contract.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md index 3f4330f69..89979d7cf 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_budget-claude-rules.md @@ -4,7 +4,7 @@ A rule in the root `.claude/rules/` whose `paths` frontmatter names no pattern is `start`; one whose `paths` does, and every rule of a subdirectory's `.claude/rules/`, is `lazy`, unless a root `CLAUDE.md` or `AGENTS.md` imports it: then it is `start` whatever its `paths`. The frontmatter is not counted, since Claude Code removes it before loading the rule, and an `@` - path in a rule is not followed, unless a CLAUDE.md imports the rule: then the rule loads at start (lazily, when a nested CLAUDE.md imports it) and its `@` imports are followed, transitively and within the hop limit. A link to a directory under `.claude/rules` is a + path in a rule is not followed, unless a CLAUDE.md imports the rule: then its `@` imports are followed, transitively and within the hop limit. A root import loads the rule at start; a nested CLAUDE.md import loads it lazily, when that nested file is read. A link to a directory under `.claude/rules` is a warning: the rules behind it are not counted. A link under `.claude/rules` that does not resolve in the tree is named too, whatever its suffix, since the tree cannot tell a dangling file from a directory; a `CLAUDE.md` under `.claude/rules` is a rule, not guidance. Measured on Claude Code 2.1.304: a root rule diff --git a/dokimi/docs/architecture.md b/dokimi/docs/architecture.md index 218910af9..4d64f981e 100644 --- a/dokimi/docs/architecture.md +++ b/dokimi/docs/architecture.md @@ -304,7 +304,7 @@ next release. `.claude/rules/**/*.md` files are `rule` entries: `start` in the root `.claude/rules/` when `paths` names no pattern, `lazy` when it does or in a subdirectory's `.claude/rules/`, but `start` whatever its `paths` when a root CLAUDE.md or AGENTS.md imports it, the frontmatter not counted. An `@` path in a rule is not followed, unless a CLAUDE.md imports - the rule: then the rule loads at start (lazily, when a nested CLAUDE.md imports it) and its `@` imports are followed, transitively and within the hop limit. `start` is not an upper bound: it is too low by what it does not count + the rule: then its `@` imports are followed, transitively and within the hop limit. A root import loads the rule at start; a nested CLAUDE.md import loads it lazily, when that nested file is read. `start` is not an upper bound: it is too low by what it does not count (only `@path.md` imports are followed, so `@notes.txt` adds nothing; skills are read only from real `.claude/skills/*/SKILL.md` directories at the root, so a skill behind a linked directory is not seen, nor are the rules behind a link at or below `.claude/rules` (a warning diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index 0bc7c7da0..e8e6bbabc 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -50,8 +50,8 @@ SPDX-License-Identifier: Apache-2.0 > `.claude/rules/**/*.md` files are `rule` entries: at start in the root `.claude/rules/` unless > their frontmatter's `paths` names a pattern, lazily then and in a subdirectory's `.claude/rules/`; a rule a root CLAUDE.md or AGENTS.md > imports is `start` whatever its `paths`; -> an `@` path in a rule is not followed, unless a CLAUDE.md imports the rule: then the rule loads at start (lazily, -> when a nested CLAUDE.md imports it) and its `@` imports are followed, transitively and within the hop limit. The `start` total is not an upper bound: +> an `@` path in a rule is not followed, unless a CLAUDE.md imports the rule: then its `@` imports are followed, transitively and within the hop limit. +> A root import loads the rule at start; a nested CLAUDE.md import loads it lazily, when that nested file is read. The `start` total is not an upper bound: > it is too low by what it does not count (non-`.md` imports, skills behind a linked directory, rules behind a > link at or below `.claude/rules` to a directory, which warns, nested `.claude/skills/` skills, and > imports past the profile's `guidance.import_max_depth`, four hops for Claude Code, which warns From c0c911e1196559d1bc39c1759febcfacd97c56df Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 21:44:38 -0800 Subject: [PATCH 19/19] fix(repo): _imports defaults for receipt; extract the import entries of budget Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/budget.py | 53 ++++++++++++++++------------- dokimi/scripts/growth_baseline.json | 2 +- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/dokimi/dokimi/repos/budget.py b/dokimi/dokimi/repos/budget.py index 705e8361c..3b11c519a 100644 --- a/dokimi/dokimi/repos/budget.py +++ b/dokimi/dokimi/repos/budget.py @@ -150,28 +150,7 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in guidance_loaded = {_target(tree, e["path"]) for e in entries if e["when"] != "off"} counted = {_target(tree, f) for f in guidance} entries += _rules(tree, files if rules else [], counted, start_t, unreadable_warnings, entries) - rule_entries = {_target(tree, e["path"]): e for e in entries if e["kind"] == "rule"} - for f in sorted((at_start | lazily) - set(guidance), key=lambda p: (p not in at_start, p)): - if not _is_resolvable(tree, f): - continue - t = _target(tree, f) - when = "start" if f in at_start else "lazy" - if t in counted: - # A rule link counted this file with its frontmatter removed. An ordinary @import keeps the frontmatter, - # so its count replaces the rule's when it loads at the same time or earlier (a rule file is left alone). - rule = rule_entries.get(t) - if rule is None or RULE_GLOB.match(f) or RULE_GLOB.match(t) or (when == "lazy" and rule["when"] == "start"): - continue - chars, warning = _chars(tree, f) - if warning: - unreadable_warnings.append(warning) - rule.update(path=f, kind="import", when=when, chars=chars) - continue - counted.add(t) - chars, warning = _chars(tree, f) - if warning: - unreadable_warnings.append(warning) - entries.append({"path": f, "kind": "import", "when": when, "chars": chars}) + _import_entries(tree, at_start, lazily, guidance, counted, entries, unreadable_warnings) skills = _skills(tree, [f for f in files if SKILL_GLOB.match(f)]) for s in skills: @@ -222,6 +201,33 @@ def budget(tree: RepoTree, guidance_names: tuple[str, ...], import_max_depth: in } +def _import_entries(tree: RepoTree, at_start: set[str], lazily: set[str], guidance: list[str], counted: set[str], + entries: list[dict[str, Any]], unreadable_warnings: list[str]) -> None: + """Add an entry for each import that is neither guidance nor a rule of its own (``counted`` grows with them).""" + rule_entries = {_target(tree, e["path"]): e for e in entries if e["kind"] == "rule"} + for f in sorted((at_start | lazily) - set(guidance), key=lambda p: (p not in at_start, p)): + if not _is_resolvable(tree, f): + continue + t = _target(tree, f) + when = "start" if f in at_start else "lazy" + if t in counted: + # A rule link counted this file with its frontmatter removed. An ordinary @import keeps the frontmatter, + # so its count replaces the rule's when it loads at the same time or earlier (a rule file is left alone). + rule = rule_entries.get(t) + if rule is None or RULE_GLOB.match(f) or RULE_GLOB.match(t) or (when == "lazy" and rule["when"] == "start"): + continue + chars, warning = _chars(tree, f) + if warning: + unreadable_warnings.append(warning) + rule.update(path=f, kind="import", when=when, chars=chars) + continue + counted.add(t) + chars, warning = _chars(tree, f) + if warning: + unreadable_warnings.append(warning) + entries.append({"path": f, "kind": "import", "when": when, "chars": chars}) + + def _is_root(path: str) -> bool: """Guidance Claude Code reads at start: at the root, or the project memory in `.claude/`.""" return "/" not in path or path in (".claude/CLAUDE.md", ".claude/AGENTS.md") @@ -439,7 +445,8 @@ def _without_comment(text: str) -> str: return text -def _imports(tree: RepoTree, roots: list[str], max_depth: int | None, rules: bool) -> tuple[set[str], list[tuple[str, str]]]: +def _imports(tree: RepoTree, roots: list[str], max_depth: int | None = None, + rules: bool = False) -> tuple[set[str], list[tuple[str, str]]]: """Files the CLAUDE.md ``roots`` ``@import``, transitively (relative to the importing file). Breadth first, so a file is placed at its shortest hop count. A rule an import reaches is followed like any diff --git a/dokimi/scripts/growth_baseline.json b/dokimi/scripts/growth_baseline.json index b0b75b387..81ab95772 100644 --- a/dokimi/scripts/growth_baseline.json +++ b/dokimi/scripts/growth_baseline.json @@ -70,7 +70,7 @@ "dokimi/report/model_compare.py::extract_scores": {"complexity": 16}, "dokimi/report/subject_report.py::render_subject_scorecard": {"complexity": 12}, "dokimi/report/task_runs_report.py::render_task_runs_report": {"complexity": 22}, - "dokimi/repos/budget.py::budget": {"complexity": 21, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/repos/budget.py::budget": {"complexity": 18, "rationale": "pre-existing when the gate was introduced"}, "dokimi/repos/environment/__init__.py::inspect_repo": {"complexity": 25, "rationale": "pre-existing when the gate was introduced"}, "dokimi/repos/environment/checks.py::_python": {"complexity": 11, "rationale": "pre-existing when the gate was introduced"}, "dokimi/repos/environment/risks.py::_runs_repository_code": {"complexity": 12, "rationale": "pre-existing when the gate was introduced"},