Skip to content

Commit 8083c91

Browse files
authored
Merge pull request #266 from JiusiServe/kb/init-manifest-routed
kb init: respect a repository routed by its adapter manifest
2 parents 30f351f + e6204ae commit 8083c91

6 files changed

Lines changed: 692 additions & 70 deletions

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# kb_service/ —— 规范
22

3-
<!-- verified-against: 2026-09-30 -->
3+
<!-- verified-against: 2026-10-01 -->
44

55
`知识服务核心:仓库配置、账本、outbox、CLI · refactor-status: new`
66

@@ -477,3 +477,26 @@ x 必须为正。因阈值停下的调用(`stop_reason="max_budget"`)→ `Mo
477477

478478
## 2026-09-30 新索引每页只登记一次(kb init)
479479
`init_stages.unlink_listed(text, listed)`:新建 `_index.md` 时,生成的导语里指向目录清单已列出页面的行内链接改成纯文本标签(支持尖括号目标与标题;锚点、`./` 忽略;图片与其他目标不动)。改写后再用 `l1.link_targets`(CommonMark 解析器)核验:若仍有指向清单页面的链接(改写不认识的写法,如引用式链接),整段可选导语丢弃并记入清单,绝不登记两次。目录清单是每个页面唯一的登记,知识树校验拒绝同一页面登记两次。回归来自 jiuwenswarm 试点。测试:`test_kb_init_skeleton.py`。
480+
481+
## 2026-10-01 kb init:由 adapter manifest 路由的仓库(回归:afd-plugin 首次真实运行,PR #265)
482+
Direct 的路由优先级是:知识侧 `repos/<repo>/_routes.yaml` 存在则用它,否则回退到 adapter manifest 的
483+
`review_routes`(`{prefix, owner, doc}`)。kb init 不得改变这一优先级下的路由结果,也不得把规则写进 briefing 文档:
484+
- `init_coverage.owners_from_review_routes(manifest)`:把 `review_routes` 按 (owner, doc) 归组成 `Owner`(path = doc,
485+
前缀按声明顺序;同名 owner 的第二个 doc 以 doc 的 stem 作后缀);`owner_table(routes_text, manifest)` →
486+
`("routes_file" | "manifest" | "none", owners)`,与 Direct 同一优先级:routes 文件有 owner 才算 `routes_file`;文件存在但 `owners: []` 时和 Direct 一样退回 manifest(且该文件保持不动——填入 owner 会覆盖 manifest 路由),只有 manifest 也没有路由时空文件才是 init 填写的知识侧表(损坏的 routes 文件抛错,绝不静默换成 manifest)。
487+
每个阶段在 `_inputs` 里算出 `route_source` / `owners`,覆盖率、吸收、deepen 分组一律用它。
488+
- **manifest 路由的仓库**:任何阶段都不写 `_routes.yaml`(写了会覆盖 manifest,把 `ready` 变成 `scope_fallback`、
489+
改变金样路由)。skeleton 把生成器的 owner 提案里 manifest 尚未路由的前缀、modules 阶段本要追加的前缀 / 新 owner,
490+
一律变成清单项 `review_routes (adapter PR): {prefix, owner, doc}`(`review_route_line`;adapter 是人工把关的,
491+
由 adapter PR 落地);modules 阶段仍写地图卡与分组页,覆盖率的 after 按"这些建议已合入"计算并在 PR 正文说明来源;
492+
deepen 阶段把规则写到 manifest 所指的 owner 页(`_rule_page_for`:owner 页是规则页则就地追加,manifest 指向尚不存在的
493+
`rules*.md` 则创建它,否则旁边的 `rules.md` / `rules-code.md`)。文档不变量规则的落点:由 `_paths_named`(证据行 +
494+
规则正文里反引号命名的路径,`facts.claims_in`)经 `most_specific` 找到最具体覆盖最多路径的 owner(`_owner_for`),
495+
无 owner 覆盖的留在规则页。三个阶段都在记录里标 `coverage.routes_source`。
496+
- **briefing 文档只读**:`briefing_docs(manifest)` = `knowledge.briefing_docs` ∪ `briefing_docs_extra` ∪
497+
`performance_briefing_docs`;`adapters.base` 按硬上限渲染它们、超出部分静默截断(afd-plugin 的 16 条规则把索引挤出了
498+
briefing)。init 永不向这些页面追加规则或正文(`_writable_page` 改写到同目录的 `rules-init.md` 并记入清单;仓库规则页是
499+
briefing 文档时 skeleton 的规则页就是 `rules-init.md`);索引登记行不在此限(知识树校验要求新页面登记)。
500+
- 没有 routes 文件也没有 `review_routes` 的仓库:modules / deepen `blocked`(消息指明两种来源都没有)。
501+
测试:`test_kb_init_manifest_routed.py`(manifest 路由的 toy:三阶段 dry run 不写 `_routes.yaml`、规则落在 owner 页、
502+
briefing 文档不动、清单含建议项、覆盖率来源;知识侧路由的 toy 行为不变)。

‎src/infermatrix_copilot/kb_service/init_coverage.py‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
2020
An empty population counts as fully covered (ratio 1.0): there is nothing a
2121
route could miss.
22+
23+
The owner table comes from ``owner_table``: a knowledge-side ``_routes.yaml``
24+
when the repository has one, else the adapter manifest's ``review_routes``
25+
(the precedence ``direct_routing`` applies), else nothing. A repository routed
26+
by its manifest is measured exactly like one routed by a table; what differs
27+
is that init never writes its routes (see ``init_stages``).
2228
"""
2329

2430
from __future__ import annotations
@@ -82,9 +88,71 @@ def load_owners(routes_yaml_text: str) -> list[Owner]:
8288
return out
8389

8490

91+
def owners_from_review_routes(manifest: Mapping | None) -> list[Owner]:
92+
"""The owner table an adapter manifest's ``review_routes`` declares
93+
(``{prefix, owner, doc}`` items, the form ``direct_routing`` falls back to
94+
when a repository has no knowledge-side ``_routes.yaml``): one owner per
95+
(owner, doc) pair in declaration order, its prefixes in declaration order.
96+
An owner name that recurs with a second doc is suffixed with that doc's
97+
stem so names stay unique. Items without a prefix or a doc are skipped."""
98+
routes = (manifest or {}).get("review_routes") or []
99+
if not isinstance(routes, list):
100+
return []
101+
grouped: dict[tuple[str, str], list[str]] = {}
102+
for item in routes:
103+
if not isinstance(item, dict):
104+
continue
105+
prefix = str(item.get("prefix") or "").replace("\\", "/").strip()
106+
doc = str(item.get("doc") or "").strip()
107+
if not prefix or not doc:
108+
continue
109+
owner = str(item.get("owner") or prefix.rstrip("/")).strip()
110+
grouped.setdefault((owner, doc), [])
111+
if prefix not in grouped[(owner, doc)]:
112+
grouped[(owner, doc)].append(prefix)
113+
taken: set[str] = set()
114+
out = []
115+
for (owner, doc), prefixes in grouped.items():
116+
name = owner if owner not in taken else f"{owner}/{PurePosixPath(doc).stem}"
117+
n = 1
118+
while name in taken: # two docs with the same stem (components/a/rules.md, components/b/rules.md)
119+
n += 1
120+
name = f"{owner}/{PurePosixPath(doc).stem}-{n}"
121+
taken.add(name)
122+
out.append(Owner(name, doc, tuple(prefixes)))
123+
return out
124+
125+
126+
def owner_table(routes_text: str | None, manifest: Mapping | None) -> tuple[str, list[Owner]]:
127+
"""Where a repository's routes come from and the owners they define, with
128+
the precedence ``direct_routing`` applies: ``("routes_file", ...)`` for a
129+
knowledge-side ``_routes.yaml`` that names owners, else ``("manifest", ...)``
130+
for adapter ``review_routes``, else ``("none", [])``. Direct falls back to
131+
the manifest when the routes file has NO owners, so a valid but empty file
132+
defers to manifest routes too (and must stay untouched: filling it would
133+
override the manifest); only without manifest routes is an empty file the
134+
knowledge-side table init fills. A malformed routes file raises
135+
(``load_owners``); it is never silently replaced by the manifest."""
136+
file_owners = load_owners(routes_text) if routes_text is not None else None
137+
if file_owners:
138+
return "routes_file", file_owners
139+
manifest_owners = owners_from_review_routes(manifest)
140+
if manifest_owners:
141+
return "manifest", manifest_owners
142+
return ("routes_file", []) if file_owners is not None else ("none", [])
143+
144+
145+
def reaches(path: str, prefix: str) -> bool:
146+
"""``prefix`` covers ``path``. A directory prefix ``pkg/`` also covers the
147+
directory itself named without its slash (``facts.claims_in`` strips it
148+
from a rule's backticked ``pkg/``), so a rule about a directory reaches
149+
the owner of that directory."""
150+
return path.startswith(prefix) or (prefix.endswith("/") and path == prefix[:-1])
151+
152+
85153
def routes_file(path: str, owners: Sequence[Owner]) -> list[Owner]:
86154
"""Every owner whose scope prefixes reach ``path``, in routing order."""
87-
return [o for o in owners if any(path.startswith(p) for p in o.prefixes)]
155+
return [o for o in owners if any(reaches(path, p) for p in o.prefixes)]
88156

89157

90158
def _page_depth(owner: Owner) -> int:
@@ -100,7 +168,7 @@ def most_specific(path: str, owners: Sequence[Owner]) -> list[Owner]:
100168
best: tuple[int, int] | None = None
101169
out: list[Owner] = []
102170
for owner in owners:
103-
length = max((len(p) for p in owner.prefixes if path.startswith(p)), default=-1)
171+
length = max((len(p) for p in owner.prefixes if reaches(path, p)), default=-1)
104172
if length < 0:
105173
continue
106174
key = (length, _page_depth(owner))

‎src/infermatrix_copilot/kb_service/init_deepen.py‎

Lines changed: 15 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
screening — the docs redundancy filter (D5), evidence inside the group's
1616
files, pinned claims, placement, the advisory judge — and are appended to the
1717
owner's rule page (never editing an existing rule; a full page spills to a
18-
sibling). The pass stops when the rule-bearing PR-weighted coverage reaches
18+
sibling; a briefing doc is never written to). Owners come from the
19+
repository's ``_routes.yaml`` or, when it has none, from the adapter
20+
manifest's ``review_routes`` (``init_coverage.owner_table``): a repository
21+
routed by its manifest is deepened onto the pages the manifest routes to.
22+
The pass stops when the rule-bearing PR-weighted coverage reaches
1923
``init.coverage_target``, the budget runs out, or no hot group is left, and it
2024
always says which in the record's notes (and so the PR body): a pass never
2125
ends below the target without the reason and the hottest files still
@@ -37,9 +41,8 @@
3741

3842
import yaml
3943

40-
from ..knowledge_service.lifecycle import LifecycleError, Page
4144
from .init_budget import BudgetExhausted
42-
from .init_coverage import Owner, load_owners, make_include, most_specific, pr_weighted_coverage
45+
from .init_coverage import Owner, make_include, most_specific, pr_weighted_coverage
4346
from .init_modules import scan_modules
4447
from .init_stages import (
4548
ROUTES_NAME, _Candidate, _fence, _numbered, _one_line, _Stage, _title_of, adapter_missing,
@@ -119,13 +122,10 @@ def _build(self, tree: Path) -> InitRecord:
119122

120123
init = self.lifecycle.init
121124
routes_path = f"{self.repo_dir}/{ROUTES_NAME}"
122-
text = self.base.get(routes_path)
123-
if text is None:
124-
return self._blocked([f"{routes_path} does not exist: run (and merge) the earlier stages first"])
125-
try:
126-
owners = load_owners(text)
127-
except (ValueError, yaml.YAMLError) as exc:
128-
return self._blocked([f"{routes_path} is not a valid route table: {exc}"])
125+
if self.route_source == "none":
126+
return self._blocked([f"{routes_path} does not exist and the adapter declares no review_routes: "
127+
"run (and merge) the earlier stages first"])
128+
owners = self.owners # the routes file, or the adapter manifest's review_routes (same precedence as Direct)
129129
flip, problems = self._flip()
130130
if problems:
131131
return self._blocked(problems)
@@ -183,6 +183,7 @@ def _build(self, tree: Path) -> InitRecord:
183183
"uncovered_hot": [list(item) for item in self._without_rules(prs, owners, include)[:MAX_HOT_LIST]],
184184
"unrouted_hot": [list(item) for item in current.uncovered_hot[:MAX_HOT_LIST]],
185185
"target": init.coverage_target,
186+
"routes_source": self.route_source,
186187
}
187188
from ..knowledge_service.pinned_claims import Evidence
188189

@@ -234,15 +235,6 @@ def _without_rules(self, prs, owners: list[Owner], include) -> list[tuple[str, i
234235
return sorted(missed.items(), key=lambda item: (-item[1], item[0]))
235236

236237
# -- rule pages ------------------------------------------------------------------
237-
def _active_rules(self, path: str) -> int:
238-
text = self.head.get(path)
239-
if text is None or not path.endswith(".md"):
240-
return 0
241-
try:
242-
return sum(1 for s in Page.parse(text).rules() if s.footer.status == "active")
243-
except (LifecycleError, yaml.YAMLError):
244-
return 0
245-
246238
def _rule_pages(self, owners: list[Owner]) -> set[str]:
247239
"""Owner paths that bear rules: the page itself holds an active rule,
248240
or (an entry or prose page) a page in the SAME directory does — never a
@@ -253,33 +245,17 @@ def _rule_pages(self, owners: list[Owner]) -> set[str]:
253245
if self._active_rules(owner.path):
254246
bearing.add(owner.path)
255247
continue
248+
redirected = self._redirect_of(owner.path)
249+
if redirected != owner.path and self._active_rules(redirected):
250+
bearing.add(owner.path) # a briefing doc's rules live beside it
251+
continue
256252
if owner.path in self.head and self._is_rule_page(owner.path):
257253
continue # a rule page owns its rules itself: an empty one bears none
258254
directory = PurePosixPath(owner.path).parent
259255
if any(PurePosixPath(p).parent == directory and self._active_rules(p) for p in self.head):
260256
bearing.add(owner.path)
261257
return bearing
262258

263-
def _is_rule_page(self, path: str) -> bool:
264-
try:
265-
page = Page.parse(self.head[path])
266-
return page.frontmatter_data().get("type") == "rule" or bool(page.rules())
267-
except (KeyError, LifecycleError, yaml.YAMLError):
268-
return False
269-
270-
def _rule_page_for(self, owner: Owner) -> str:
271-
"""Where an owner's new rules go: its own page when that is a rule
272-
page, else ``rules.md`` beside it (``rules-code.md`` when a prose
273-
``rules.md`` is in the way)."""
274-
if owner.path in self.head and self._is_rule_page(owner.path):
275-
return owner.path
276-
directory = str(PurePosixPath(owner.path).parent)
277-
for name in ("rules.md", "rules-code.md"):
278-
path = f"{directory}/{name}"
279-
if path not in self.head or self._is_rule_page(path):
280-
return path
281-
raise InitError(f"no rule page can be placed beside {owner.path}")
282-
283259
# -- one module --------------------------------------------------------------------
284260
def _code_payload(self, tree: Path, files: list[str]) -> list[dict]:
285261
out, used = [], 0

‎src/infermatrix_copilot/kb_service/init_modules.py‎

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from .init_budget import BudgetExhausted
3737
from .init_coverage import ROOT_MODULE, Owner, load_owners, module_coverage, most_specific, routes_file
3838
from .init_stages import (
39-
_SLUG, ROUTES_NAME, _fence, _one_line, _page_frontmatter, _slug, _Stage, _title_of,
39+
_SLUG, ROUTES_NAME, _fence, _one_line, _page_frontmatter, _slug, _Stage, _title_of, review_route_line,
4040
)
4141
from .init_support import InitError, InitRecord, generate
4242

@@ -249,13 +249,23 @@ class _Modules(_Stage):
249249
def _build(self, tree: Path) -> InitRecord:
250250
routes_path = f"{self.repo_dir}/{ROUTES_NAME}"
251251
text = self.base.get(routes_path)
252-
if text is None:
253-
return self._blocked([f"{routes_path} does not exist: run (and merge) the skeleton stage first"])
254-
try:
255-
doc = yaml.safe_load(text) or {}
256-
load_owners(text)
257-
except (ValueError, yaml.YAMLError) as exc:
258-
return self._blocked([f"{routes_path} is not a valid route table: {exc}"])
252+
if self.route_source == "routes_file":
253+
try:
254+
doc = yaml.safe_load(text) or {}
255+
load_owners(text)
256+
except (ValueError, yaml.YAMLError) as exc:
257+
return self._blocked([f"{routes_path} is not a valid route table: {exc}"])
258+
elif self.route_source == "manifest":
259+
# the adapter manifest routes this repository: the same table, kept
260+
# in memory only — what this stage would append becomes suggested
261+
# review_routes entries, never a routes file (it would take
262+
# precedence over the manifest and change every route)
263+
doc = {"schema_version": 1, "owners": [
264+
{"owner": o.owner, "path": o.path, "signals": [], "scope_prefixes": list(o.prefixes)}
265+
for o in self.owners]}
266+
else:
267+
return self._blocked([f"{routes_path} does not exist and the adapter declares no review_routes: "
268+
"run (and merge) the skeleton stage first"])
259269
self.routes_path, self.routes_before = routes_path, doc
260270
self.routes = copy.deepcopy(doc)
261271
if not isinstance(self.routes.get("owners"), list):
@@ -289,6 +299,7 @@ def _build(self, tree: Path) -> InitRecord:
289299
"modules": {"before": round(before.ratio, 4), "after": round(after.ratio, 4), "total": len(modules)},
290300
"unrouted": after.uncovered,
291301
"cards": {c.module: c.page for c in cards},
302+
"routes_source": self.route_source,
292303
}
293304
problems = routes_append_only(self.routes_before, self.routes)
294305
if problems:
@@ -523,8 +534,28 @@ def _append_links(self, index: str, links: list[tuple[str, str]]) -> None:
523534
text += f"- [{title}]({link})\n"
524535
self.head[index] = text
525536

537+
def _suggest_routes(self) -> None:
538+
"""Every owner or prefix this stage would have appended to a routes
539+
file, as the exact ``review_routes`` entries the adapter manifest
540+
lacks (adapters are human-gated: an adapter PR, never a route file)."""
541+
before = {str(o["owner"]): o for o in self.routes_before.get("owners") or []}
542+
count = 0
543+
for owner in self.routes.get("owners") or []:
544+
known = before.get(str(owner["owner"]))
545+
had = list((known or {}).get("scope_prefixes") or [])
546+
for prefix in owner.get("scope_prefixes") or []:
547+
if prefix in had:
548+
continue
549+
self.record.checklist.append(review_route_line(prefix, str(owner["owner"]), str(owner["path"])))
550+
count += 1
551+
self.record.notes.append("routes come from the adapter manifest (review_routes): kb init writes no "
552+
f"{ROUTES_NAME}; {count} review_routes entr{'y is' if count == 1 else 'ies are'} "
553+
"suggested on the checklist")
554+
526555
def _conclude(self, rules, evidence, other=None, check_other=None) -> InitRecord:
527-
if self.routes != self.routes_before:
556+
if self.route_source == "manifest":
557+
self._suggest_routes()
558+
elif self.routes != self.routes_before:
528559
header = []
529560
for line in self.base[self.routes_path].splitlines(keepends=True):
530561
if not line.startswith("#"):

0 commit comments

Comments
 (0)