diff --git a/doc/architecture/SPEC/kb_service.md b/doc/architecture/SPEC/kb_service.md index 2df97413..1c66b065 100644 --- a/doc/architecture/SPEC/kb_service.md +++ b/doc/architecture/SPEC/kb_service.md @@ -181,3 +181,9 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝 否则运行服务实际生效的 `run_root`(环境变量或其支持的 `.env` 文件)。切换激活快照时记录旧快照的停用时间; 停用不足 1 小时的快照不删除——在切换前刚解析到它的预留会在此窗口内写下钉住记录(运行服务在其他进程中,无法共享 激活锁,因此以宽限期协调)。测试通过自动 fixture 把 `KB_RUN_ROOTS` 指向空目录,从不读取真实运行目录。 + + +## 2026-09-29 激活前的格式检查 +`verify_snapshot` 在其他检查之前核对快照声明的知识格式:未声明或不在本 Copilot 支持的 `SUPPORTED_FORMATS` 中 → +`ActivationError`,`active` 保持不变(先升级 Copilot 再激活新格式的知识)。快照随顶层 Markdown 一起携带 +`_format.yaml`。 diff --git a/doc/architecture/SPEC/knowledge_view.md b/doc/architecture/SPEC/knowledge_view.md index df4d98e1..e94ffb38 100644 --- a/doc/architecture/SPEC/knowledge_view.md +++ b/doc/architecture/SPEC/knowledge_view.md @@ -1,6 +1,6 @@ # knowledge_view.py —— 规范 - + `LOC ~170 · 每请求的知识树视图(打包知识或激活快照) · refactor-status: stable` @@ -35,3 +35,8 @@ stdlib + `.sdk._resources`。叶子模块。 ## 2026-09-28 可记录的快照标识 `public_snapshot`:激活快照 ID、`packaged` 或 `unverified`(开发树的绝对路径不外泄);`tree_sha256`:已验证快照 按清单重算的树哈希。`direct_routing` 的 `diagnostics.knowledge_snapshot` 与 SDK 计划都使用它们。 + +## 2026-09-29 知识格式版本 +知识树在顶层 `knowledge/_format.yaml` 声明其格式(`format_version`;当前为 2:规则生命周期尾注、类型化操作、 +`_routes.yaml`、`_tombstones.yaml`)。`knowledge_format(root)` 读取它,`build_manifest` 把它记入 `MANIFEST.json` 的 +`knowledge_format`。该文件位于自动合并白名单之外,改动它需要人工。 diff --git a/knowledge/_format.yaml b/knowledge/_format.yaml new file mode 100644 index 00000000..942774a3 --- /dev/null +++ b/knowledge/_format.yaml @@ -0,0 +1 @@ +format_version: 2 diff --git a/src/infermatrix_copilot/kb_service/activate.py b/src/infermatrix_copilot/kb_service/activate.py index 48b1540a..a36d0002 100644 --- a/src/infermatrix_copilot/kb_service/activate.py +++ b/src/infermatrix_copilot/kb_service/activate.py @@ -98,6 +98,13 @@ def verify_snapshot(snapshot: Path) -> KnowledgeView: view.path(rel) except (KnowledgeViewError, FileNotFoundError, ValueError) as exc: raise ActivationError(f"snapshot content does not match its manifest: {exc}") from exc + from ..knowledge_view import SUPPORTED_FORMATS, knowledge_format + + declared = knowledge_format(view.root) + if declared not in SUPPORTED_FORMATS: + raise ActivationError( + f"knowledge format {declared if declared is not None else '(undeclared)'} is not one this Copilot " + f"reads ({sorted(SUPPORTED_FORMATS)}); upgrade the Copilot before activating this knowledge") files = {p.relative_to(view.root).as_posix(): p.read_text(encoding="utf-8") for p in view.root.rglob("*") if p.is_file() and p.suffix in (".md", ".yaml")} issues = check_tree({k: v for k, v in files.items() if k.startswith(("repos/", "general/"))}) diff --git a/src/infermatrix_copilot/kb_service/sources.py b/src/infermatrix_copilot/kb_service/sources.py index 0155e61e..92f14173 100644 --- a/src/infermatrix_copilot/kb_service/sources.py +++ b/src/infermatrix_copilot/kb_service/sources.py @@ -159,9 +159,10 @@ def knowledge_files(self, rev: str) -> dict[str, str]: return {name.removeprefix("knowledge/"): text for name, text in files.items()} def top_level_knowledge(self, rev: str) -> dict[str, str]: - """knowledge/*.md at the top of the tree (AGENTS.md, README.md, ...): - not governed by the gate, but served with every snapshot.""" - files = self._texts(rev, ("knowledge/",), (".md",)) + """knowledge/*.md at the top of the tree (AGENTS.md, README.md, ...) and + the format declaration: not governed by the gate, but served with every + snapshot.""" + files = self._texts(rev, ("knowledge/",), (".md", "_format.yaml")) return {name.removeprefix("knowledge/"): text for name, text in files.items() if "/" not in name.removeprefix("knowledge/")} diff --git a/src/infermatrix_copilot/knowledge_view.py b/src/infermatrix_copilot/knowledge_view.py index bec8f462..d7e75594 100644 --- a/src/infermatrix_copilot/knowledge_view.py +++ b/src/infermatrix_copilot/knowledge_view.py @@ -33,6 +33,25 @@ KNOWLEDGE_ROOT_ENV = "KNOWLEDGE_ROOT" MANIFEST_NAME = "MANIFEST.json" MANIFEST_SCHEMA_VERSION = 1 +# The knowledge tree declares the format it is written in (rule lifecycle +# footers, typed operations, _routes.yaml, _tombstones.yaml). A Copilot only +# activates snapshots in a format it reads. The file sits at the top of +# knowledge/, outside the auto-merge whitelist: changing it takes a person. +FORMAT_FILE = "_format.yaml" +SUPPORTED_FORMATS = frozenset({2}) + + +def knowledge_format(root: Path) -> int | None: + """The declared format of the knowledge tree at ``root`` (None: undeclared or unreadable).""" + import yaml + + try: + data = yaml.safe_load((Path(root) / FORMAT_FILE).read_text(encoding="utf-8")) + value = data["format_version"] + except (OSError, TypeError, KeyError, yaml.YAMLError): + return None + # exactly an integer: 2.9, "2", true or .inf are malformed, never coerced + return value if type(value) is int else None class KnowledgeViewError(RuntimeError): @@ -191,6 +210,7 @@ def build_manifest(root: Path, snapshot: str) -> dict: return { "schema_version": MANIFEST_SCHEMA_VERSION, "snapshot": snapshot, + "knowledge_format": knowledge_format(root), "files": files, "tree_sha256": tree.hexdigest(), } diff --git a/test/test_kb_flow.py b/test/test_kb_flow.py index 695adcf6..9607d50a 100644 --- a/test/test_kb_flow.py +++ b/test/test_kb_flow.py @@ -781,3 +781,41 @@ def test_pin_discovery_reads_each_run_status_once(tmp_path, monkeypatch): answers = iter([{"state": rs.INTERRUPTED, "child_pid": None}, {"state": rs.QUEUED}]) monkeypatch.setattr(rs, "read_status", lambda _d: next(answers)) # a reclaim lands between reads assert pinned_snapshots([tmp_path / "runs"]) == {(tmp_path / "s").resolve()} + + +def test_activation_refuses_a_knowledge_format_this_copilot_does_not_read(tmp_path): + import json + + rt, lifecycle = _flow_runtime(tmp_path) + _add_agents(rt) + good = rt.knowledge.fetch() + snapshot = activate(rt, good) + assert json.loads((snapshot / "MANIFEST.json").read_text())["knowledge_format"] == 2 + newer = _commit_change(rt, "_format.yaml", "format_version: 3\n") + with pytest.raises(ActivationError, match="knowledge format 3"): + activate(rt, newer) + origin = Path(rt.knowledge.path).parent / "origin" + (origin / "knowledge" / "_format.yaml").unlink() + _git(origin, "add", "-A") + _git(origin, "-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "-m", "undeclared") + with pytest.raises(ActivationError, match="undeclared"): + activate(rt, rt.knowledge.fetch()) + assert Path(active_link(rt.state_dir)).resolve().name == good # still the good one + + +def test_the_packaged_knowledge_declares_a_supported_format(): + from infermatrix_copilot.knowledge_view import SUPPORTED_FORMATS, KnowledgeView, knowledge_format + + assert knowledge_format(KnowledgeView.current().root) in SUPPORTED_FORMATS + + +@pytest.mark.parametrize("declared", ["2.9", "'2'", "true", ".inf", "[2]", "{}"]) +def test_a_malformed_format_declaration_is_refused_not_coerced(tmp_path, declared): + rt, lifecycle = _flow_runtime(tmp_path) + _add_agents(rt) + good = rt.knowledge.fetch() + activate(rt, good) + bad = _commit_change(rt, "_format.yaml", f"format_version: {declared}\n") + with pytest.raises(ActivationError, match="undeclared"): + activate(rt, bad) + assert Path(active_link(rt.state_dir)).resolve().name == good diff --git a/test/test_kb_intake_gate.py b/test/test_kb_intake_gate.py index 24add7bb..76942c59 100644 --- a/test/test_kb_intake_gate.py +++ b/test/test_kb_intake_gate.py @@ -195,6 +195,7 @@ def _knowledge_remote(tmp_path: Path) -> Path: target = origin / "knowledge" / rel target.parent.mkdir(parents=True, exist_ok=True) target.write_text(text, encoding="utf-8") + (origin / "knowledge" / "_format.yaml").write_text("format_version: 2\n", encoding="utf-8") (origin / "skills").mkdir() (origin / "skills" / "x.md").write_text("see DEMO-1a\n", encoding="utf-8") subprocess.run(["git", "init", "-q", "-b", "main", str(origin)], check=True)