@event4u/agent-config 1.36.0 → 1.37.0

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.
@@ -0,0 +1,13 @@
1
+ """MCP server for agent-config — Phase 1 MVP.
2
+
3
+ Exposes a hand-picked subset of `.agent-src/skills/` as MCP `prompts`
4
+ over stdio. Read-only and instructional per the A0 execution-safety
5
+ boundary in `agents/roadmaps/road-to-mcp-server.md`. No `tools`
6
+ primitive, no engine spawn, no shell execution.
7
+
8
+ Stability: experimental. Contract: `docs/contracts/mcp-phase-1-scope.md`.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.1.0"
13
+ SERVER_NAME = "agent-config"
@@ -0,0 +1,12 @@
1
+ """Entrypoint — `python -m scripts.mcp_server`.
2
+
3
+ Required by Claude Desktop / Zed / Continue stdio-server config.
4
+ The wrapper forwards to `server.main()`; keep this file flat so
5
+ crash tracebacks point at server.py, not the bootstrap.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .server import main
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -0,0 +1,75 @@
1
+ """Phase-6 F1 — server identity metadata.
2
+
3
+ Three values surfaced at boot via stderr (`run_stdio` boot log):
4
+
5
+ - **server version** — wire-surface SemVer in `__init__.py::__version__`.
6
+ Hand-bumped when the MCP-side surface (prompts/resources/tools shape,
7
+ protocol semantics) changes.
8
+ - **package version** — read from `package.json::version` at boot.
9
+ Build-ID semantics; bumps with every release of the agent-config bundle.
10
+ - **skill-set signature** — SHA-256 hex (first 12 chars) over the joined
11
+ `PromptCache._signature` + `ResourceCache._signature` tuples
12
+ (`(uri, mtime)` pairs, already sorted). Content fingerprint, not a
13
+ version — auto-derived, never hand-edited.
14
+
15
+ Wire-surface caveat: the MCP SDK constructs `serverInfo.Implementation`
16
+ internally with a fixed field set (`name`, `version`, `websiteUrl`,
17
+ `icons`), so the package version and skill-set signature cannot be
18
+ attached to `serverInfo._meta` without subclassing the session.
19
+ Stderr is the canonical surface in Phase 6; a wire-surface lift can
20
+ follow once the SDK supports it.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ from pathlib import Path
27
+ from typing import Sequence
28
+
29
+ Signature = Sequence[tuple[str, float]]
30
+
31
+
32
+ def read_package_version(root: Path) -> str:
33
+ """Return `package.json::version`, or `"unknown"` if unreadable."""
34
+ path = root / "package.json"
35
+ try:
36
+ data = json.loads(path.read_text(encoding="utf-8"))
37
+ except (OSError, ValueError):
38
+ return "unknown"
39
+ version = data.get("version")
40
+ if not isinstance(version, str) or not version:
41
+ return "unknown"
42
+ return version
43
+
44
+
45
+ def compute_skill_set_signature(*signatures: Signature) -> str:
46
+ """SHA-256 hex (12 chars) over the concatenated `(uri, mtime)` tuples.
47
+
48
+ Deterministic across processes for identical inputs. Changes when
49
+ any tracked file's path-set or mtime changes. Inputs are taken as-is
50
+ (callers pass already-sorted cache signatures); the hash is taken
51
+ over the joined repr to keep the framing unambiguous.
52
+ """
53
+ hasher = hashlib.sha256()
54
+ for sig in signatures:
55
+ for uri, mtime in sig:
56
+ hasher.update(uri.encode("utf-8"))
57
+ hasher.update(b"\x00")
58
+ hasher.update(f"{mtime:.6f}".encode("ascii"))
59
+ hasher.update(b"\x1e") # record separator
60
+ hasher.update(b"\x1d") # group separator between caches
61
+ return hasher.hexdigest()[:12]
62
+
63
+
64
+ def boot_log_line(
65
+ *,
66
+ server_version: str,
67
+ package_version: str,
68
+ skill_set_signature: str,
69
+ ) -> str:
70
+ """Single stderr line surfacing all three identity values at boot."""
71
+ return (
72
+ f"mcp-server: identity serverVersion={server_version} "
73
+ f"packageVersion={package_version} "
74
+ f"skillSetSignature={skill_set_signature}"
75
+ )
@@ -0,0 +1,305 @@
1
+ """Prompt loader — reads `.agent-src/skills/` and `.agent-src/commands/`.
2
+
3
+ Phase 1 (A4) exposed 5 hand-picked, stack-agnostic skills. Phase 2
4
+ (B1–B3) extends to the full set: every `SKILL.md` under
5
+ `.agent-src/skills/` plus every `*.md` under `.agent-src/commands/`.
6
+
7
+ Frontmatter `name` + `description` map to MCP prompt metadata; the
8
+ body (frontmatter stripped) is the prompt content. Frontmatter
9
+ `source:` is forwarded verbatim into the MCP `_meta` field so clients
10
+ can filter package-vs-project entries on the wire.
11
+
12
+ Project-overrides resolution: `.agent-src/` is the already-merged
13
+ view at sync time; the runtime loader does not re-merge.
14
+
15
+ Frontmatter validation (B3): entries missing `name` or `description`
16
+ are skipped and surfaced in the second tuple element of `scan_*`
17
+ helpers (caller decides whether to log).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any, Literal
24
+
25
+ # Phase 1 hand-picked skills — kept for the Phase-1 entrypoint
26
+ # (`load_phase_1_prompts`) and as the contract-test fixture set. The
27
+ # roadmap originally listed `verify-before-complete`, which lives as
28
+ # a rule, not a skill; its skill counterpart is
29
+ # `verify-completion-evidence` (same evidence-gate obligation).
30
+ PHASE_1_SKILLS: tuple[str, ...] = (
31
+ "verify-completion-evidence",
32
+ "systematic-debugging",
33
+ "test-driven-development",
34
+ "refine-ticket",
35
+ "conventional-commits-writing",
36
+ )
37
+
38
+ PromptKind = Literal["skill", "command"]
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class SkillPrompt:
43
+ """Resolved Markdown prompt ready for MCP exposure.
44
+
45
+ `kind` distinguishes the two Phase-2 source families. The name
46
+ field is the frontmatter `name:` value verbatim (e.g.
47
+ `test-driven-development` or `research:report`); MCP wire names
48
+ are derived in `to_mcp_prompt_meta` with `kind`-aware prefixing.
49
+ """
50
+
51
+ name: str
52
+ description: str
53
+ body: str
54
+ source: str
55
+ kind: PromptKind = "skill"
56
+
57
+
58
+ def _project_root() -> Path:
59
+ """Walk up from this file to the repo root (parent of `scripts/`)."""
60
+ return Path(__file__).resolve().parent.parent.parent
61
+
62
+
63
+ def _strip_frontmatter(text: str) -> tuple[dict[str, str], str]:
64
+ """Split a Markdown file with `---` frontmatter into (meta, body).
65
+
66
+ Tiny YAML-ish parser sufficient for our flat key/value frontmatter.
67
+ Avoids a `pyyaml` dependency for Phase 1; the frontmatter shape is
68
+ enforced by `task lint-skills` upstream.
69
+ """
70
+ if not text.startswith("---\n"):
71
+ return {}, text
72
+ try:
73
+ _, fm, body = text.split("---\n", 2)
74
+ except ValueError:
75
+ return {}, text
76
+ meta: dict[str, str] = {}
77
+ for line in fm.splitlines():
78
+ if not line.strip() or line.startswith("#"):
79
+ continue
80
+ if ":" not in line:
81
+ continue
82
+ key, _, value = line.partition(":")
83
+ meta[key.strip()] = value.strip().strip('"').strip("'")
84
+ return meta, body.lstrip("\n")
85
+
86
+
87
+ def load_skill(name: str, root: Path | None = None) -> SkillPrompt:
88
+ """Load a single skill by name. Raises FileNotFoundError if missing."""
89
+ base = root or _project_root()
90
+ path = base / ".agent-src" / "skills" / name / "SKILL.md"
91
+ if not path.exists():
92
+ raise FileNotFoundError(f"SKILL.md not found: {path}")
93
+ return _load_file(path, kind="skill", fallback_name=name)
94
+
95
+
96
+ def _load_file(
97
+ path: Path,
98
+ *,
99
+ kind: PromptKind,
100
+ fallback_name: str,
101
+ ) -> SkillPrompt:
102
+ text = path.read_text(encoding="utf-8")
103
+ meta, body = _strip_frontmatter(text)
104
+ return SkillPrompt(
105
+ name=meta.get("name", fallback_name),
106
+ description=meta.get("description", "").strip(),
107
+ body=body.rstrip() + "\n",
108
+ source=meta.get("source", "package"),
109
+ kind=kind,
110
+ )
111
+
112
+
113
+ def load_phase_1_prompts(root: Path | None = None) -> list[SkillPrompt]:
114
+ """Load every skill listed in PHASE_1_SKILLS.
115
+
116
+ Kept for backward compatibility with Phase-1 tests and as a
117
+ minimal smoke path. Production entrypoint is `load_all_prompts`.
118
+ """
119
+ prompts: list[SkillPrompt] = []
120
+ errors: list[str] = []
121
+ for name in PHASE_1_SKILLS:
122
+ try:
123
+ prompts.append(load_skill(name, root=root))
124
+ except FileNotFoundError as exc:
125
+ errors.append(str(exc))
126
+ if errors and not prompts:
127
+ raise RuntimeError(
128
+ "No Phase 1 skills loaded. Errors:\n - "
129
+ + "\n - ".join(errors)
130
+ )
131
+ return prompts
132
+
133
+
134
+ def scan_skills(
135
+ root: Path | None = None,
136
+ ) -> tuple[list[SkillPrompt], list[str]]:
137
+ """Enumerate every `.agent-src/skills/*/SKILL.md`.
138
+
139
+ Returns `(prompts, errors)`. Files missing `name` or
140
+ `description` frontmatter are skipped with a one-line reason in
141
+ `errors`. Files that fail to read are surfaced the same way.
142
+ """
143
+ base = root or _project_root()
144
+ skills_root = base / ".agent-src" / "skills"
145
+ prompts: list[SkillPrompt] = []
146
+ errors: list[str] = []
147
+ if not skills_root.is_dir():
148
+ return prompts, errors
149
+ for skill_dir in sorted(skills_root.iterdir()):
150
+ path = skill_dir / "SKILL.md"
151
+ if not path.is_file():
152
+ continue
153
+ try:
154
+ prompt = _load_file(path, kind="skill", fallback_name=skill_dir.name)
155
+ except OSError as exc:
156
+ errors.append(f"{path}: read failed ({exc})")
157
+ continue
158
+ if not prompt.description:
159
+ errors.append(f"{path}: missing frontmatter description")
160
+ continue
161
+ prompts.append(prompt)
162
+ return prompts, errors
163
+
164
+
165
+ def scan_commands(
166
+ root: Path | None = None,
167
+ ) -> tuple[list[SkillPrompt], list[str]]:
168
+ """Enumerate every `.agent-src/commands/**/*.md`.
169
+
170
+ Same return contract as `scan_skills`. Command frontmatter `name:`
171
+ values use `:` as cluster/sub separator (e.g. `research:report`);
172
+ the value is preserved verbatim and translated to MCP wire form
173
+ in `to_mcp_prompt_meta`.
174
+ """
175
+ base = root or _project_root()
176
+ cmd_root = base / ".agent-src" / "commands"
177
+ prompts: list[SkillPrompt] = []
178
+ errors: list[str] = []
179
+ if not cmd_root.is_dir():
180
+ return prompts, errors
181
+ for path in sorted(cmd_root.rglob("*.md")):
182
+ if not path.is_file():
183
+ continue
184
+ rel = path.relative_to(cmd_root).with_suffix("")
185
+ fallback = str(rel).replace("/", ":")
186
+ try:
187
+ prompt = _load_file(path, kind="command", fallback_name=fallback)
188
+ except OSError as exc:
189
+ errors.append(f"{path}: read failed ({exc})")
190
+ continue
191
+ if not prompt.description:
192
+ errors.append(f"{path}: missing frontmatter description")
193
+ continue
194
+ prompts.append(prompt)
195
+ return prompts, errors
196
+
197
+
198
+ def load_all_prompts(
199
+ root: Path | None = None,
200
+ ) -> tuple[list[SkillPrompt], list[str]]:
201
+ """Phase 2 entrypoint — all skills + all commands.
202
+
203
+ Result is sorted by MCP wire name (deterministic across boots)
204
+ and de-duplicated: if the same wire name appears in both lists
205
+ (should not happen in a clean tree) the skill copy wins and the
206
+ duplicate is reported in `errors`.
207
+ """
208
+ skills, skill_errors = scan_skills(root)
209
+ commands, command_errors = scan_commands(root)
210
+ errors = list(skill_errors) + list(command_errors)
211
+ seen: dict[str, SkillPrompt] = {}
212
+ for prompt in skills + commands:
213
+ wire = to_mcp_prompt_meta(prompt)["name"]
214
+ if wire in seen:
215
+ errors.append(
216
+ f"duplicate MCP name {wire!r}: keeping {seen[wire].kind}, "
217
+ f"skipping {prompt.kind}"
218
+ )
219
+ continue
220
+ seen[wire] = prompt
221
+ merged = sorted(seen.values(), key=lambda p: to_mcp_prompt_meta(p)["name"])
222
+ return merged, errors
223
+
224
+
225
+ def to_mcp_prompt_meta(prompt: SkillPrompt) -> dict[str, Any]:
226
+ """Project a SkillPrompt into MCP `Prompt` constructor kwargs.
227
+
228
+ Wire-name shape:
229
+ skill.<frontmatter-name> (skills)
230
+ command.<frontmatter-name with : → .> (commands)
231
+ Colons in command names (e.g. `research:report`) become `.` so
232
+ the wire identifier is a single-segment dotted path that survives
233
+ every MCP client we have tested.
234
+ """
235
+ if prompt.kind == "command":
236
+ wire = f"command.{prompt.name.replace(':', '.')}"
237
+ else:
238
+ wire = f"skill.{prompt.name}"
239
+ return {
240
+ "name": wire,
241
+ "title": prompt.name,
242
+ "description": prompt.description,
243
+ "arguments": [],
244
+ "_meta": {"source": prompt.source, "kind": prompt.kind},
245
+ }
246
+
247
+
248
+ class PromptCache:
249
+ """In-memory cache with mtime-based invalidation (B5 hot-reload).
250
+
251
+ `get()` re-scans `.agent-src/skills/` and `.agent-src/commands/`
252
+ when any tracked SKILL.md / command file has changed mtime since
253
+ the previous scan. New / removed files also trigger a refresh
254
+ (the set of tracked paths is part of the staleness key).
255
+
256
+ The cache is intentionally simple: no inotify, no debounce, no
257
+ background thread. The server calls `get()` once per
258
+ `prompts/list` request, which is the natural rate-limiter.
259
+ """
260
+
261
+ def __init__(self, root: Path | None = None) -> None:
262
+ self._root = root or _project_root()
263
+ self._prompts: list[SkillPrompt] = []
264
+ self._errors: list[str] = []
265
+ self._signature: tuple[tuple[str, float], ...] = ()
266
+ self._index: dict[str, SkillPrompt] = {}
267
+
268
+ def _current_signature(self) -> tuple[tuple[str, float], ...]:
269
+ entries: list[tuple[str, float]] = []
270
+ skills_root = self._root / ".agent-src" / "skills"
271
+ if skills_root.is_dir():
272
+ for skill_dir in sorted(skills_root.iterdir()):
273
+ path = skill_dir / "SKILL.md"
274
+ if path.is_file():
275
+ entries.append((str(path), path.stat().st_mtime))
276
+ cmd_root = self._root / ".agent-src" / "commands"
277
+ if cmd_root.is_dir():
278
+ for path in sorted(cmd_root.rglob("*.md")):
279
+ if path.is_file():
280
+ entries.append((str(path), path.stat().st_mtime))
281
+ return tuple(entries)
282
+
283
+ def _refresh(self) -> None:
284
+ prompts, errors = load_all_prompts(self._root)
285
+ self._prompts = prompts
286
+ self._errors = errors
287
+ self._index = {to_mcp_prompt_meta(p)["name"]: p for p in prompts}
288
+
289
+ def get(self) -> tuple[list[SkillPrompt], list[str]]:
290
+ """Return cached prompts + errors, refreshing on mtime change."""
291
+ signature = self._current_signature()
292
+ if signature != self._signature:
293
+ self._signature = signature
294
+ self._refresh()
295
+ return self._prompts, self._errors
296
+
297
+ @property
298
+ def signature(self) -> tuple[tuple[str, float], ...]:
299
+ """Cached `(path, mtime)` tuples (Phase-6 F1 input). Call `get()` first."""
300
+ return self._signature
301
+
302
+ def lookup(self, wire_name: str) -> SkillPrompt | None:
303
+ """Resolve an MCP wire name to its SkillPrompt, refreshing first."""
304
+ self.get()
305
+ return self._index.get(wire_name)
@@ -0,0 +1,4 @@
1
+ # MCP server runtime — pinned to the versions the test suite ran against.
2
+ # Mirror upstream when bumping; keep this lean (no dev/test deps).
3
+ mcp==1.27.1
4
+ PyYAML==6.0.3
@@ -0,0 +1,201 @@
1
+ """Resource loader — exposes rules, guidelines, contexts as MCP resources.
2
+
3
+ Phase 3 (C1–C4) extends the read-only MCP surface from prompts (skills
4
+ + commands) to read-only **resources** for the governance layer:
5
+
6
+ - `rule://<basename>` — `.agent-src/rules/*.md`
7
+ - `guideline://<relpath-no-ext>` — `docs/guidelines/**/*.md`
8
+ - `context://<relpath-no-ext>` — `.agent-src/contexts/**/*.md`
9
+
10
+ All three are served with `mimeType=text/markdown`. The merge-at-sync
11
+ contract is the same as for prompts: `.agent-src/` is already the
12
+ package + project merged view; this loader does not re-merge.
13
+
14
+ Description resolution: frontmatter `description:` wins, else the
15
+ first H1 line (`# Title`) is used as a title-style fallback, else the
16
+ filename-derived stem.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Literal
24
+
25
+ from .prompts import _project_root, _strip_frontmatter
26
+
27
+ ResourceKind = Literal["rule", "guideline", "context"]
28
+ MIME_MARKDOWN = "text/markdown"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Resource:
33
+ """Resolved Markdown asset ready for MCP exposure."""
34
+
35
+ uri: str
36
+ name: str
37
+ description: str
38
+ body: str
39
+ source: str = "package"
40
+ mime_type: str = MIME_MARKDOWN
41
+ kind: ResourceKind = "rule"
42
+
43
+
44
+ _H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
45
+
46
+
47
+ def _derive_description(meta: dict[str, str], body: str, fallback: str) -> str:
48
+ desc = meta.get("description", "").strip()
49
+ if desc:
50
+ return desc
51
+ match = _H1_RE.search(body)
52
+ if match:
53
+ return match.group(1).strip()
54
+ return fallback
55
+
56
+
57
+ def _load(path: Path, *, uri: str, fallback_name: str, kind: ResourceKind) -> Resource:
58
+ text = path.read_text(encoding="utf-8")
59
+ meta, body = _strip_frontmatter(text)
60
+ name = meta.get("name", fallback_name).strip() or fallback_name
61
+ description = _derive_description(meta, body, fallback_name)
62
+ return Resource(
63
+ uri=uri,
64
+ name=name,
65
+ description=description,
66
+ body=text.rstrip() + "\n",
67
+ source=meta.get("source", "package"),
68
+ kind=kind,
69
+ )
70
+
71
+
72
+ def scan_rules(root: Path | None = None) -> tuple[list[Resource], list[str]]:
73
+ base = root or _project_root()
74
+ rules_root = base / ".agent-src" / "rules"
75
+ out: list[Resource] = []
76
+ errors: list[str] = []
77
+ if not rules_root.is_dir():
78
+ return out, errors
79
+ for path in sorted(rules_root.glob("*.md")):
80
+ if not path.is_file():
81
+ continue
82
+ stem = path.stem
83
+ try:
84
+ out.append(_load(path, uri=f"rule://{stem}", fallback_name=stem, kind="rule"))
85
+ except OSError as exc:
86
+ errors.append(f"{path}: read failed ({exc})")
87
+ return out, errors
88
+
89
+
90
+ def _scan_tree(
91
+ root: Path,
92
+ *,
93
+ scheme: str,
94
+ kind: ResourceKind,
95
+ ) -> tuple[list[Resource], list[str]]:
96
+ out: list[Resource] = []
97
+ errors: list[str] = []
98
+ if not root.is_dir():
99
+ return out, errors
100
+ for path in sorted(root.rglob("*.md")):
101
+ if not path.is_file():
102
+ continue
103
+ rel = path.relative_to(root).with_suffix("")
104
+ slug = str(rel).replace("\\", "/")
105
+ try:
106
+ out.append(
107
+ _load(path, uri=f"{scheme}://{slug}", fallback_name=slug, kind=kind)
108
+ )
109
+ except OSError as exc:
110
+ errors.append(f"{path}: read failed ({exc})")
111
+ return out, errors
112
+
113
+
114
+ def scan_guidelines(root: Path | None = None) -> tuple[list[Resource], list[str]]:
115
+ base = root or _project_root()
116
+ return _scan_tree(base / "docs" / "guidelines", scheme="guideline", kind="guideline")
117
+
118
+
119
+ def scan_contexts(root: Path | None = None) -> tuple[list[Resource], list[str]]:
120
+ base = root or _project_root()
121
+ return _scan_tree(base / ".agent-src" / "contexts", scheme="context", kind="context")
122
+
123
+
124
+ def load_all_resources(
125
+ root: Path | None = None,
126
+ ) -> tuple[list[Resource], list[str]]:
127
+ """Phase 3 entrypoint — every rule, guideline, context."""
128
+ rules, e1 = scan_rules(root)
129
+ guidelines, e2 = scan_guidelines(root)
130
+ contexts, e3 = scan_contexts(root)
131
+ errors = list(e1) + list(e2) + list(e3)
132
+ seen: dict[str, Resource] = {}
133
+ for r in rules + guidelines + contexts:
134
+ if r.uri in seen:
135
+ errors.append(f"duplicate URI {r.uri!r}: keeping first")
136
+ continue
137
+ seen[r.uri] = r
138
+ merged = sorted(seen.values(), key=lambda r: r.uri)
139
+ return merged, errors
140
+
141
+
142
+ def to_mcp_resource_meta(resource: Resource) -> dict[str, object]:
143
+ """Project a Resource into MCP `Resource` constructor kwargs."""
144
+ return {
145
+ "uri": resource.uri,
146
+ "name": resource.name,
147
+ "description": resource.description,
148
+ "mimeType": resource.mime_type,
149
+ "_meta": {"source": resource.source, "kind": resource.kind},
150
+ }
151
+
152
+
153
+ class ResourceCache:
154
+ """In-memory cache with mtime-based invalidation (mirrors `PromptCache`).
155
+
156
+ Re-scans rules / guidelines / contexts on each `get()` when the set
157
+ of tracked files or any mtime has changed. No watcher dependency.
158
+ """
159
+
160
+ def __init__(self, root: Path | None = None) -> None:
161
+ self._root = root or _project_root()
162
+ self._resources: list[Resource] = []
163
+ self._errors: list[str] = []
164
+ self._signature: tuple[tuple[str, float], ...] = ()
165
+ self._index: dict[str, Resource] = {}
166
+
167
+ def _current_signature(self) -> tuple[tuple[str, float], ...]:
168
+ entries: list[tuple[str, float]] = []
169
+ for sub in (
170
+ self._root / ".agent-src" / "rules",
171
+ self._root / "docs" / "guidelines",
172
+ self._root / ".agent-src" / "contexts",
173
+ ):
174
+ if not sub.is_dir():
175
+ continue
176
+ for path in sorted(sub.rglob("*.md")):
177
+ if path.is_file():
178
+ entries.append((str(path), path.stat().st_mtime))
179
+ return tuple(entries)
180
+
181
+ def _refresh(self) -> None:
182
+ resources, errors = load_all_resources(self._root)
183
+ self._resources = resources
184
+ self._errors = errors
185
+ self._index = {r.uri: r for r in resources}
186
+
187
+ def get(self) -> tuple[list[Resource], list[str]]:
188
+ signature = self._current_signature()
189
+ if signature != self._signature:
190
+ self._signature = signature
191
+ self._refresh()
192
+ return self._resources, self._errors
193
+
194
+ @property
195
+ def signature(self) -> tuple[tuple[str, float], ...]:
196
+ """Cached `(path, mtime)` tuples (Phase-6 F1 input). Call `get()` first."""
197
+ return self._signature
198
+
199
+ def lookup(self, uri: str) -> Resource | None:
200
+ self.get()
201
+ return self._index.get(uri)