ctxfire 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ctxfire/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Explain and budget static context graphs for coding agents."""
2
+
3
+ __version__ = "0.1.0"
ctxfire/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Support ``python -m ctxfire``."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
ctxfire/adapters.py ADDED
@@ -0,0 +1,185 @@
1
+ """Versioned loading-semantics adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from functools import lru_cache
9
+ from pathlib import PurePosixPath
10
+
11
+ from .config import AgentConfig
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Inclusion:
16
+ pattern: str
17
+ activation: str
18
+ reason: str
19
+
20
+
21
+ @lru_cache(maxsize=512)
22
+ def _glob_regex(pattern: str) -> re.Pattern[str]:
23
+ normalized = pattern.removeprefix("./")
24
+ index = 0
25
+ translated = ""
26
+ while index < len(normalized):
27
+ character = normalized[index]
28
+ if character == "*":
29
+ if index + 1 < len(normalized) and normalized[index + 1] == "*":
30
+ index += 2
31
+ if index < len(normalized) and normalized[index] == "/":
32
+ translated += "(?:.*/)?"
33
+ index += 1
34
+ else:
35
+ translated += ".*"
36
+ continue
37
+ translated += "[^/]*"
38
+ elif character == "?":
39
+ translated += "[^/]"
40
+ elif character == "[":
41
+ closing = normalized.find("]", index + 1)
42
+ if closing == -1:
43
+ translated += r"\["
44
+ else:
45
+ content = normalized[index + 1 : closing]
46
+ if content.startswith("!"):
47
+ content = "^" + content[1:]
48
+ translated += "[" + content.replace("\\", r"\\") + "]"
49
+ index = closing
50
+ else:
51
+ translated += re.escape(character)
52
+ index += 1
53
+ return re.compile(f"^{translated}$")
54
+
55
+
56
+ def matches(path: str, pattern: str) -> bool:
57
+ """Match a root-relative POSIX path with ``*`` and recursive ``**`` globs."""
58
+
59
+ return _glob_regex(pattern).fullmatch(path) is not None
60
+
61
+
62
+ def _chain_directories(working_directory: str) -> tuple[PurePosixPath, ...]:
63
+ current = PurePosixPath() if working_directory == "." else PurePosixPath(working_directory)
64
+ return tuple(PurePosixPath(*current.parts[:depth]) for depth in range(len(current.parts) + 1))
65
+
66
+
67
+ def _under(directory: PurePosixPath, relative: str) -> str:
68
+ return (directory / relative).as_posix()
69
+
70
+
71
+ def inclusions(
72
+ agent: AgentConfig, available: Mapping[str, int] | None = None
73
+ ) -> tuple[Inclusion, ...]:
74
+ """Expand an adapter to ordered, explainable include rules."""
75
+
76
+ available = available or {}
77
+ result: list[Inclusion] = []
78
+ directories = _chain_directories(agent.working_directory)
79
+ if agent.adapter == "agents-md@1":
80
+ result.extend(
81
+ Inclusion(_under(directory, "AGENTS.md"), "always", "agents-md@1 chain")
82
+ for directory in directories
83
+ )
84
+ if agent.adapter == "codex@1":
85
+ for directory in directories:
86
+ candidates = (
87
+ "AGENTS.override.md",
88
+ "AGENTS.md",
89
+ *agent.instruction_fallback_filenames,
90
+ )
91
+ for filename in candidates:
92
+ path = _under(directory, filename)
93
+ if available.get(path, 0) > 0:
94
+ result.append(Inclusion(path, "always", "codex@1 instruction chain precedence"))
95
+ break
96
+ result.append(
97
+ Inclusion(
98
+ _under(directory, ".agents/skills/*/SKILL.md"),
99
+ "conditional",
100
+ "codex@1 skill body; loaded only when selected",
101
+ )
102
+ )
103
+ if agent.adapter == "claude-code@1":
104
+ for directory in directories:
105
+ for filename in ("CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md"):
106
+ result.append(
107
+ Inclusion(
108
+ _under(directory, filename),
109
+ "always",
110
+ "claude-code@1 project memory ancestor chain",
111
+ )
112
+ )
113
+ result.append(
114
+ Inclusion(
115
+ _under(directory, ".claude/rules/**/*.md"),
116
+ agent.claude_rules_activation,
117
+ "claude-code@1 project rule; metadata-only mode cannot parse paths frontmatter",
118
+ )
119
+ )
120
+ result.append(
121
+ Inclusion(
122
+ _under(directory, ".claude/skills/*/SKILL.md"),
123
+ "conditional",
124
+ "claude-code@1 skill body; loaded only when selected",
125
+ )
126
+ )
127
+ working_prefix = "" if agent.working_directory == "." else f"{agent.working_directory}/"
128
+ result.extend(
129
+ [
130
+ Inclusion(
131
+ f"{working_prefix}**/CLAUDE.md",
132
+ "conditional",
133
+ "claude-code@1 descendant memory; loaded when that subtree is read",
134
+ ),
135
+ Inclusion(
136
+ f"{working_prefix}**/CLAUDE.local.md",
137
+ "conditional",
138
+ "claude-code@1 descendant local memory; loaded when that subtree is read",
139
+ ),
140
+ Inclusion(
141
+ f"{working_prefix}**/.claude/skills/*/SKILL.md",
142
+ "conditional",
143
+ "claude-code@1 descendant skill body; available when that subtree is read",
144
+ ),
145
+ ]
146
+ )
147
+ result.extend(Inclusion(pattern, "always", "explicit include") for pattern in agent.include)
148
+ result.extend(
149
+ Inclusion(pattern, "conditional", "explicit conditional include")
150
+ for pattern in agent.conditional
151
+ )
152
+ return tuple(result)
153
+
154
+
155
+ def exact_probe_paths(agent: AgentConfig) -> tuple[str, ...]:
156
+ """Return known exact paths that engines load even when Git ignores them."""
157
+
158
+ result: list[str] = []
159
+ directories = _chain_directories(agent.working_directory)
160
+ if agent.adapter == "agents-md@1":
161
+ result.extend(_under(directory, "AGENTS.md") for directory in directories)
162
+ elif agent.adapter == "codex@1":
163
+ for directory in directories:
164
+ result.extend(
165
+ _under(directory, filename)
166
+ for filename in (
167
+ "AGENTS.override.md",
168
+ "AGENTS.md",
169
+ *agent.instruction_fallback_filenames,
170
+ )
171
+ )
172
+ elif agent.adapter == "claude-code@1":
173
+ for directory in directories:
174
+ result.extend(
175
+ _under(directory, filename)
176
+ for filename in ("CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md")
177
+ )
178
+ for pattern in (*agent.include, *agent.conditional):
179
+ if not any(character in pattern for character in "*?["):
180
+ result.append(pattern)
181
+ return tuple(dict.fromkeys(result))
182
+
183
+
184
+ def excluded(path: str, patterns: tuple[str, ...]) -> bool:
185
+ return any(matches(path, pattern) for pattern in patterns)
ctxfire/cli.py ADDED
@@ -0,0 +1,297 @@
1
+ """Command-line interface for ctxfire."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, cast
10
+
11
+ from . import __version__
12
+ from .config import load_config
13
+ from .render import explain_text, json_text, sarif, scan_text
14
+ from .scanner import scan
15
+
16
+ EXIT_OK = 0
17
+ EXIT_ERROR = 1
18
+ EXIT_BUDGET_EXCEEDED = 2
19
+
20
+
21
+ def _write(text: str, output: Path | None) -> None:
22
+ if output is None:
23
+ print(text, end="")
24
+ else:
25
+ output.write_text(text, encoding="utf-8")
26
+
27
+
28
+ def _report(args: argparse.Namespace): # type: ignore[no-untyped-def]
29
+ return scan(load_config(args.config))
30
+
31
+
32
+ def command_scan(args: argparse.Namespace) -> int:
33
+ report = _report(args)
34
+ if args.format == "json":
35
+ rendered = json_text(report.as_dict())
36
+ elif args.format == "sarif":
37
+ findings = [
38
+ {
39
+ "rule_id": "ctxfire/context-surface",
40
+ "title": "Agent context surface",
41
+ "help": (
42
+ "Review the versioned adapter assumptions and use ctxfire explain "
43
+ "for attribution."
44
+ ),
45
+ "level": "note",
46
+ "message": (
47
+ f"{agent.name}: estimated {agent.estimated_tokens_per_day} input tokens/day."
48
+ ),
49
+ }
50
+ for agent in report.agents
51
+ ]
52
+ rendered = json_text(
53
+ sarif(findings, report.tool["version"], report.as_dict()["assumptions"])
54
+ )
55
+ else:
56
+ rendered = scan_text(report)
57
+ _write(rendered, args.output)
58
+ return EXIT_OK
59
+
60
+
61
+ def command_explain(args: argparse.Namespace) -> int:
62
+ report = _report(args)
63
+ if args.format == "json":
64
+ agents = []
65
+ for agent in report.as_dict()["agents"]:
66
+ if args.agent and agent["name"] != args.agent:
67
+ continue
68
+ agent["files"] = [
69
+ item for item in agent["files"] if not args.file or item["path"] == args.file
70
+ ]
71
+ agents.append(agent)
72
+ rendered = json_text(
73
+ {
74
+ "schema_version": report.schema_version,
75
+ "tool": report.tool,
76
+ "assumptions": report.as_dict()["assumptions"],
77
+ "agents": agents,
78
+ }
79
+ )
80
+ else:
81
+ rendered = explain_text(report, args.agent, args.file)
82
+ _write(rendered, args.output)
83
+ return EXIT_OK
84
+
85
+
86
+ def _load_snapshot(path: Path) -> dict[str, Any]:
87
+ payload = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8")))
88
+ if payload.get("schema_version") != "1.0" or not isinstance(payload.get("agents"), list):
89
+ raise ValueError(f"not a ctxfire report schema 1.0: {path}")
90
+ return payload
91
+
92
+
93
+ def command_diff(args: argparse.Namespace) -> int:
94
+ before = _load_snapshot(args.before)
95
+ after = _load_snapshot(args.after)
96
+ old = {agent["name"]: agent for agent in before["agents"]}
97
+ new = {agent["name"]: agent for agent in after["agents"]}
98
+ changes = []
99
+ for name in sorted(old.keys() | new.keys()):
100
+ old_tokens = int(old.get(name, {}).get("estimated_tokens_per_day", 0))
101
+ new_tokens = int(new.get(name, {}).get("estimated_tokens_per_day", 0))
102
+ old_paths = {item["path"] for item in old.get(name, {}).get("files", [])}
103
+ new_paths = {item["path"] for item in new.get(name, {}).get("files", [])}
104
+ old_files = {item["path"]: item for item in old.get(name, {}).get("files", [])}
105
+ new_files = {item["path"]: item for item in new.get(name, {}).get("files", [])}
106
+ changed_files = []
107
+ for path in sorted(old_paths & new_paths):
108
+ old_file = old_files[path]
109
+ new_file = new_files[path]
110
+ compared = ("exact_bytes", "counted_bytes", "estimated_tokens", "activation_rate")
111
+ if any(old_file.get(field) != new_file.get(field) for field in compared):
112
+ changed_files.append(
113
+ {
114
+ "path": path,
115
+ "before": {field: old_file.get(field) for field in compared},
116
+ "after": {field: new_file.get(field) for field in compared},
117
+ }
118
+ )
119
+ changes.append(
120
+ {
121
+ "agent": name,
122
+ "fires_per_day_before": old.get(name, {}).get("fires_per_day", 0),
123
+ "fires_per_day_after": new.get(name, {}).get("fires_per_day", 0),
124
+ "estimated_tokens_per_day_before": old_tokens,
125
+ "estimated_tokens_per_day_after": new_tokens,
126
+ "estimated_tokens_per_day_delta": new_tokens - old_tokens,
127
+ "added_files": sorted(new_paths - old_paths),
128
+ "removed_files": sorted(old_paths - new_paths),
129
+ "changed_files": changed_files,
130
+ }
131
+ )
132
+ payload = {
133
+ "schema_version": "1.0",
134
+ "kind": "ctxfire-diff",
135
+ "assumptions_before": before.get("assumptions"),
136
+ "assumptions_after": after.get("assumptions"),
137
+ "assumptions_changed": before.get("assumptions") != after.get("assumptions"),
138
+ "changes": changes,
139
+ }
140
+ if args.format == "json":
141
+ rendered = json_text(payload)
142
+ else:
143
+ lines = [
144
+ "ctxfire diff",
145
+ "Estimated token deltas use each snapshot's recorded assumptions.",
146
+ "",
147
+ ]
148
+ if payload["assumptions_changed"]:
149
+ lines.append("WARNING: estimation assumptions changed between snapshots.")
150
+ for item in changes:
151
+ lines.append(
152
+ f"{item['agent']}: {item['estimated_tokens_per_day_delta']:+d} estimated tokens/day"
153
+ )
154
+ lines.extend(f" + {path}" for path in item["added_files"])
155
+ lines.extend(f" - {path}" for path in item["removed_files"])
156
+ for changed in item["changed_files"]:
157
+ lines.append(
158
+ f" ~ {changed['path']}: {changed['before']['exact_bytes']} -> "
159
+ f"{changed['after']['exact_bytes']} exact bytes; "
160
+ f"~{changed['before']['estimated_tokens']} -> "
161
+ f"~{changed['after']['estimated_tokens']} tokens"
162
+ )
163
+ if item["fires_per_day_before"] != item["fires_per_day_after"]:
164
+ lines.append(
165
+ f" schedule: {item['fires_per_day_before']:g} -> "
166
+ f"{item['fires_per_day_after']:g} fires/day"
167
+ )
168
+ rendered = "\n".join(lines) + "\n"
169
+ _write(rendered, args.output)
170
+ return EXIT_OK
171
+
172
+
173
+ def command_check(args: argparse.Namespace) -> int:
174
+ report = _report(args)
175
+ for option, value in (
176
+ ("--max-tokens-per-fire", args.max_tokens_per_fire),
177
+ ("--max-tokens-per-day", args.max_tokens_per_day),
178
+ ("--max-usd-per-day", args.max_usd_per_day),
179
+ ):
180
+ if value is not None and value < 0:
181
+ raise ValueError(f"{option} cannot be negative")
182
+ findings: list[dict[str, str]] = []
183
+ checks = [
184
+ (
185
+ "tokens-per-fire",
186
+ args.max_tokens_per_fire,
187
+ max((item.estimated_tokens_per_fire for item in report.agents), default=0),
188
+ ),
189
+ ("tokens-per-day", args.max_tokens_per_day, int(report.totals["estimated_tokens_per_day"])),
190
+ ]
191
+ for name, limit, actual in checks:
192
+ if limit is not None and actual > limit:
193
+ findings.append(
194
+ {
195
+ "rule_id": f"ctxfire/{name}",
196
+ "title": f"Context budget exceeded: {name}",
197
+ "help": (
198
+ "Run ctxfire explain to attribute context files, then adjust "
199
+ "context or the explicit budget."
200
+ ),
201
+ "level": "error",
202
+ "message": f"Estimated {name} is {actual}, above configured CLI limit {limit}.",
203
+ }
204
+ )
205
+ if args.max_usd_per_day is not None:
206
+ cost = report.totals["estimated_usd_per_day"]
207
+ if cost is None:
208
+ raise ValueError("--max-usd-per-day requires usd_per_million_input_tokens in config")
209
+ if float(cost) > args.max_usd_per_day:
210
+ findings.append(
211
+ {
212
+ "rule_id": "ctxfire/usd-per-day",
213
+ "title": "API-equivalent cost budget exceeded",
214
+ "help": (
215
+ "Review the dated price and cache assumptions before changing the budget."
216
+ ),
217
+ "level": "error",
218
+ "message": (
219
+ "Estimated API-equivalent input cost/day is "
220
+ f"${float(cost):.6f}, above ${args.max_usd_per_day:.6f}."
221
+ ),
222
+ }
223
+ )
224
+ if args.format == "sarif":
225
+ rendered = json_text(
226
+ sarif(findings, report.tool["version"], report.as_dict()["assumptions"])
227
+ )
228
+ elif args.format == "json":
229
+ rendered = json_text(
230
+ {
231
+ "schema_version": "1.0",
232
+ "passed": not findings,
233
+ "findings": findings,
234
+ "report": report.as_dict(),
235
+ }
236
+ )
237
+ else:
238
+ assumptions = report.assumptions
239
+ rendered = (
240
+ ("ctxfire check: PASS\n" if not findings else "ctxfire check: FAIL\n")
241
+ + (
242
+ f"Estimates: {assumptions.tokenizer} ({assumptions.tokenizer_version}), "
243
+ f"{assumptions.bytes_per_token:g} bytes/token, model {assumptions.model}, "
244
+ f"price date {assumptions.price_date}, cache {assumptions.cache_assumption}.\n"
245
+ )
246
+ + "\n".join(f" - {item['message']}" for item in findings)
247
+ + ("\n" if findings else "")
248
+ )
249
+ _write(rendered, args.output)
250
+ return EXIT_OK if not findings else EXIT_BUDGET_EXCEEDED
251
+
252
+
253
+ def _common(parser: argparse.ArgumentParser, formats: tuple[str, ...]) -> None:
254
+ parser.add_argument("--config", type=Path, default=Path("ctxfire.toml"))
255
+ parser.add_argument("--format", choices=formats, default=formats[0])
256
+ parser.add_argument("--output", type=Path)
257
+
258
+
259
+ def build_parser() -> argparse.ArgumentParser:
260
+ parser = argparse.ArgumentParser(
261
+ prog="ctxfire",
262
+ description="Explain and budget the static context graph of coding agents.",
263
+ )
264
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
265
+ subparsers = parser.add_subparsers(dest="command", required=True)
266
+ scan_parser = subparsers.add_parser(
267
+ "scan", help="build a context graph and estimate its daily cost"
268
+ )
269
+ _common(scan_parser, ("text", "json", "sarif"))
270
+ scan_parser.set_defaults(handler=command_scan)
271
+ explain_parser = subparsers.add_parser("explain", help="show why each file is included")
272
+ _common(explain_parser, ("text", "json"))
273
+ explain_parser.add_argument("--agent")
274
+ explain_parser.add_argument("--file")
275
+ explain_parser.set_defaults(handler=command_explain)
276
+ diff_parser = subparsers.add_parser("diff", help="compare two JSON scan snapshots")
277
+ diff_parser.add_argument("before", type=Path)
278
+ diff_parser.add_argument("after", type=Path)
279
+ diff_parser.add_argument("--format", choices=("text", "json"), default="text")
280
+ diff_parser.add_argument("--output", type=Path)
281
+ diff_parser.set_defaults(handler=command_diff)
282
+ check_parser = subparsers.add_parser("check", help="enforce stable CI budgets")
283
+ _common(check_parser, ("text", "json", "sarif"))
284
+ check_parser.add_argument("--max-tokens-per-fire", type=int)
285
+ check_parser.add_argument("--max-tokens-per-day", type=int)
286
+ check_parser.add_argument("--max-usd-per-day", type=float)
287
+ check_parser.set_defaults(handler=command_check)
288
+ return parser
289
+
290
+
291
+ def main(argv: list[str] | None = None) -> int:
292
+ try:
293
+ args = build_parser().parse_args(argv)
294
+ return int(args.handler(args))
295
+ except (OSError, ValueError, json.JSONDecodeError) as error:
296
+ print(f"ctxfire: error: {error}", file=sys.stderr)
297
+ return EXIT_ERROR
ctxfire/config.py ADDED
@@ -0,0 +1,149 @@
1
+ """Configuration parsing and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from importlib.metadata import PackageNotFoundError, version
8
+ from pathlib import Path, PurePosixPath
9
+ from typing import Any
10
+
11
+ from .model import CONFIG_SCHEMA_VERSION, Assumptions
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class AgentConfig:
16
+ name: str
17
+ adapter: str
18
+ engine: str
19
+ working_directory: str
20
+ fires_per_day: float
21
+ include: tuple[str, ...]
22
+ conditional: tuple[str, ...]
23
+ exclude: tuple[str, ...]
24
+ instruction_fallback_filenames: tuple[str, ...]
25
+ instruction_max_bytes: int | None
26
+ claude_rules_activation: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Config:
31
+ path: Path
32
+ root: Path
33
+ project_name: str
34
+ assumptions: Assumptions
35
+ agents: tuple[AgentConfig, ...]
36
+
37
+
38
+ def _patterns(raw: dict[str, Any], key: str) -> tuple[str, ...]:
39
+ value = raw.get(key, [])
40
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
41
+ raise ValueError(f"{key} must be an array of strings")
42
+ for pattern in value:
43
+ pure = PurePosixPath(pattern)
44
+ if pure.is_absolute() or ".." in pure.parts:
45
+ raise ValueError(f"{key} pattern must stay inside project root: {pattern}")
46
+ return tuple(value)
47
+
48
+
49
+ def load_config(path: Path) -> Config:
50
+ """Load a ctxfire v1 TOML file without reading repository content."""
51
+
52
+ resolved = path.resolve()
53
+ data = tomllib.loads(resolved.read_text(encoding="utf-8"))
54
+ if str(data.get("schema_version", "")) != CONFIG_SCHEMA_VERSION:
55
+ raise ValueError(f'schema_version must be "{CONFIG_SCHEMA_VERSION}"')
56
+ project = data.get("project", {})
57
+ if not isinstance(project, dict):
58
+ raise ValueError("[project] must be a table")
59
+ root_value = Path(str(project.get("root", ".")))
60
+ root = root_value if root_value.is_absolute() else resolved.parent / root_value
61
+ root = root.resolve()
62
+ if not root.is_dir():
63
+ raise ValueError(f"project root is not a directory: {root}")
64
+
65
+ bytes_per_token = float(project.get("bytes_per_token", 4.0))
66
+ conditional_rate = float(project.get("conditional_activation_rate", 0.0))
67
+ if bytes_per_token <= 0:
68
+ raise ValueError("bytes_per_token must be greater than zero")
69
+ if not 0 <= conditional_rate <= 1:
70
+ raise ValueError("conditional_activation_rate must be between 0 and 1")
71
+ price_raw = project.get("usd_per_million_input_tokens")
72
+ price = None if price_raw is None else float(price_raw)
73
+ if price is not None and price < 0:
74
+ raise ValueError("usd_per_million_input_tokens cannot be negative")
75
+ tokenizer = str(project.get("tokenizer", "byte-estimate"))
76
+ if tokenizer == "byte-estimate":
77
+ tokenizer_version = "approximation-v1"
78
+ elif tokenizer.startswith("tiktoken:") and tokenizer.partition(":")[2]:
79
+ try:
80
+ tokenizer_version = version("tiktoken")
81
+ except PackageNotFoundError as error:
82
+ raise ValueError("tiktoken tokenizer requested; install ctxfire[tokenizers]") from error
83
+ else:
84
+ raise ValueError("tokenizer must be byte-estimate or tiktoken:<encoding>")
85
+ assumptions = Assumptions(
86
+ tokenizer=tokenizer,
87
+ tokenizer_version=tokenizer_version,
88
+ bytes_per_token=bytes_per_token,
89
+ model=str(project.get("model", "unspecified")),
90
+ price_date=str(project.get("price_date", "unspecified")),
91
+ usd_per_million_input_tokens=price,
92
+ cache_assumption=str(project.get("cache_assumption", "no-cache-credit")),
93
+ conditional_activation_rate=conditional_rate,
94
+ )
95
+
96
+ raw_agents = data.get("agents", [])
97
+ if not isinstance(raw_agents, list) or not raw_agents:
98
+ raise ValueError("configure at least one [[agents]] table")
99
+ agents: list[AgentConfig] = []
100
+ names: set[str] = set()
101
+ for raw in raw_agents:
102
+ if not isinstance(raw, dict) or not str(raw.get("name", "")).strip():
103
+ raise ValueError("every [[agents]] table needs a non-empty name")
104
+ name = str(raw["name"])
105
+ if name in names:
106
+ raise ValueError(f"duplicate agent name: {name}")
107
+ names.add(name)
108
+ fires = float(raw.get("fires_per_day", 1.0))
109
+ if fires < 0:
110
+ raise ValueError(f"fires_per_day cannot be negative for {name}")
111
+ working_directory = str(raw.get("working_directory", ".")).strip("/") or "."
112
+ work_path = PurePosixPath(working_directory)
113
+ if work_path.is_absolute() or ".." in work_path.parts:
114
+ raise ValueError(f"working_directory must stay inside project root: {name}")
115
+ adapter = str(raw.get("adapter", "explicit@1"))
116
+ if adapter not in {"explicit@1", "agents-md@1", "codex@1", "claude-code@1"}:
117
+ raise ValueError(f"unsupported adapter {adapter!r} for {name}")
118
+ fallback_names = _patterns(raw, "instruction_fallback_filenames")
119
+ if any("/" in item or "\\" in item for item in fallback_names):
120
+ raise ValueError(f"instruction fallback names must be filenames for {name}")
121
+ max_bytes_raw = raw.get("instruction_max_bytes", 32768 if adapter == "codex@1" else None)
122
+ instruction_max_bytes = None if max_bytes_raw is None else int(max_bytes_raw)
123
+ if instruction_max_bytes is not None and instruction_max_bytes <= 0:
124
+ raise ValueError(f"instruction_max_bytes must be positive for {name}")
125
+ claude_rules_activation = str(raw.get("claude_rules_activation", "always"))
126
+ if claude_rules_activation not in {"always", "conditional"}:
127
+ raise ValueError(f"claude_rules_activation must be always or conditional for {name}")
128
+ agents.append(
129
+ AgentConfig(
130
+ name=name,
131
+ adapter=adapter,
132
+ engine=str(raw.get("engine", adapter.partition("@")[0])),
133
+ working_directory=working_directory,
134
+ fires_per_day=fires,
135
+ include=_patterns(raw, "include"),
136
+ conditional=_patterns(raw, "conditional"),
137
+ exclude=_patterns(raw, "exclude"),
138
+ instruction_fallback_filenames=fallback_names,
139
+ instruction_max_bytes=instruction_max_bytes,
140
+ claude_rules_activation=claude_rules_activation,
141
+ )
142
+ )
143
+ return Config(
144
+ path=resolved,
145
+ root=root,
146
+ project_name=str(project.get("name", root.name)),
147
+ assumptions=assumptions,
148
+ agents=tuple(agents),
149
+ )