Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions doc/architecture/SPEC/engine/steps/knowledge.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# engine/steps/knowledge.py —— 规范

<!-- verified-against: 2026-09-28 -->
<!-- verified-against: 2026-09-30 -->

`知识服务的 knowledge.* 步骤 · refactor-status: new`

Expand All @@ -12,10 +12,17 @@ auto_merge 仓库写 outbox 的 `open_pr` 项,shadow 只记录)。逻辑全
## 不变量
- 只处理 `params.repo` 指定的一个仓库;未知或未启用的仓库 → BLOCKED。
- 步骤之间只经 `state_updates` 传递纯数据(`kb_repo`、`kb_changeset`、`kb_changeset_status`)。
- 从不直接写 GitHub;发布只经签名 outbox 由 GPU 盒发布器执行。
- 从不直接写 GitHub;发布只经签名 outbox 由 GPU 盒发布器执行(唯一例外:`knowledge.init`,见下)。

## 测试
`test_kb_intake_gate.py`。

## 2026-09-28 合并、巡检、激活步骤
新增 `knowledge.advance_merges`、`knowledge.sweep`、`knowledge.activate`;`kb run` 路径自行持有租约,调度器路径复用其租约。

## 2026-09-30 `knowledge.init`(kb init)
参数 `repo`、`stage`、`dry_run`(缺省 true)、`pin`。用自己的 `InitRuntime`(`kb_service/init_support.py`),**不**经
`_runtime`/`_lifecycle`:服务运行时会打开 `kb.db`,且 `_lifecycle` 拒绝未启用的仓库,而为未启用的仓库建库正是 init 的目的。
调 `init_stages.run_stage`;`InitError`/`NotImplementedError` 与状态 `blocked` 的记录都返回 BLOCKED。`state_updates`:
`kb_init_stage`、`kb_init_status`、`kb_init_pr`。它是本模块里唯一直接写 GitHub 的步骤:经仓库主人的 `gh` 开一个由人合并的
PR,且只在 `ALLOW_PUSH=1` 与 `ALLOW_POST=1` 同时成立、非 dry run、上游非私有时。测试:`test_kb_init_skeleton.py`。
56 changes: 56 additions & 0 deletions doc/architecture/SPEC/kb_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,59 @@ knowledge_root)` 是运行时检查:种子必须是知识树中已存在的文
中,`uncovered_hot` 按次数降序、路径升序;`churn_by_module(prs, modules)` 按最长模块前缀归属(已折叠目录归到折入的
祖先),降序。空集合的比例记为 1.0(没有可漏的路由)。`ROOT_MODULE` 与 `profiles.establish.ROOT_MODULE` 相同,
由测试钉住(本包不依赖 `profiles`)。源根与变更路径按与 `profiles.establish.normalize_root` 相同的规则规范化(`.`、`./` 表示仓库根,接纳一切路径)。测试:`test_kb_init_coverage.py`。


## 2026-09-30 单次调用花费阈值(kb init 预算硬上限的一块积木)
`ModelGateway.call_json(..., max_budget_usd=x)`:x 是**停止阈值**,不是硬上限 —— 花费达到 x 后不再发起新请求,但越过 x 的
那个请求照样计费。硬上限由调用方负责:调用前预留 x + 单个请求的最坏情况。transport 的 `stops_at_spend` 为 False 时在发出调用
**之前**抛 `ModelUnavailable("<provider> cannot stop a call at a spend threshold")`(fail-closed,绝不用 `max_tokens` 近似);
x 必须为正。因阈值停下的调用(`stop_reason="max_budget"`)→ `ModelUnavailable`,并作为失败记录(不会成为训练样本)。
`ModelReply.cost_usd` 取自 `usage["cost_usd"]`(未知为 None),trace 记录同时写入 `cost_usd` 与 `max_budget_usd`。
不传上限的现有调用方行为不变:该参数根本不会发给 transport。
`runtime.trace_recorder` 把阈值与花费持久化到 `model_call` 记录的 `result.max_budget_usd` / `result.cost_usd`
(未设或未知为 null),包括 transport 抛错的失败记录。

## 2026-09-30 kb init:阶段 1(skeleton)、运行时、预算与发布
设计见 kb-init 设计 v3。`kb init REPO --stage S [--dry-run] [--pin SHA]` 经 playbook `kb-init`(单步
`knowledge.init`)运行;`kb init REPO --suggest-seeds` 只打印候选种子(不调模型)。CLI 在打开任何账本**之前**分派
`init`:kb init 从不碰服务的 `kb.db`、outbox、`kb serve`/`kb publish`。

- `init_budget`:`Budget(limit)` 的 `reserve(amount)` 在调用**之前**预留,放不下抛 `BudgetExhausted`;块内
`charge(cost)` 记实际花费,未知/非法花费或块内异常按整笔预留计。生成调用预留 = 阈值 `generator_call_usd` +
单个请求最坏值 `input_bytes × 1.25 × in_price + max_output_tokens × out_price`(claude 的 `--max-budget-usd`
只拦下一个请求)。价格表 `DEFAULT_PRICES`(claude-opus-5-5 = $4/$20 每百万 token、128000 输出上限,2026-09-25
标价)可由 `KB_INIT_PRICES` JSON 覆盖;没有价格的生成模型在任何调用之前被拒(`PriceError`)。判定调用按
`judge_call_usd` 固定记账 —— 这是记账约定,不是花费上限。
- `config.InitConfig` 增加 `generator_call_usd`(>0,缺省 2.0)与 `harness_overhead_bytes`(≥0,缺省 200000)。
- `init_support`:`InitRuntime`(自己的运行时:registry、`ModelGateway`(trace 写到 `<state>/init/traces`)、
知识库克隆 `KB_INIT_KNOWLEDGE_CLONE` 或 `<state>/init/knowledge-repo`、价格表、环境);`InitRecord`(每阶段一个
`<state>/init/<repo>/<stage>.json`:钉点、知识库基点、输入摘要、花费、种子来源、逐规则证据、判定、丢弃原因、清单、
问题、未完成项、PR);`UpstreamPin`(上游 bare 镜像、`resolve`、只读 `export`(拒绝链接与越界成员)、
`PinnedObserver`);`collect_docs`;`generate`/`judge`(先预留);`claim_problems`(设计 §9.1);
`other_path_problems`(`knowledge/` 之外的路径必须在 `INIT_PATHS` 内且有专门检查);`run_knowledge_validators`
(在基点树叠加改动后跑 CI 同款的 `check_knowledge_tree.py` 与 `check_wiki_lint.py`);`InitPublisher`
(临时索引在确切基点上构建一个确定性提交,推到**必须不存在**的分支 —— `--force-with-lease=<ref>:`,经 `gh`
开 PR 并确认 head;dry run 把树与 PR 正文写到本地)。只在 `ALLOW_PUSH=1` 且 `ALLOW_POST=1` 时发布;私有上游
永远 dry run。与 kb 发布器一样不经 `push.guard_push`:它推的是全新分支、从不强推、只写 `INIT_PATHS`。
- `init_stages`(唯一允许调用 `check_changeset(bootstrap=True)` 的模块,由 L1 测试钉住):`run_stage` 与
`validate_change`(设计 §9.3:L1 changeset(bootstrap)、`check_tree`、`check_index_links`、钉点声明与证据、
`knowledge/` 之外的路径)。skeleton:文档语料 → 地图调用(入口页、架构页、owner 路由、general 链接)→ 文档不变量
调用 → 每个 `repos/<other>/` 种子规则页一次改写调用 → 逐条筛查:D5 去掉与文档重复的行、证据在钉点可取且哈希入记录、
钉点声明成立、在一棵逐条累积的树上落位(页满则转到兄弟页 `rules-doc-invariants.md` / `<stem>-<n>.md`,**先落位再
判定**,判定看到的就是规则最终所在的页)→ 咨询性判定(fail 剔除,unsure/unjudged 标出)→ 经 `ops.apply_operations`
写入(新规则页先建壳并登记到目录索引;已有规则只追加,从不修改)→ 新目录建 `_index.md`、已有目录只追加链接、
仓库未登记时登记到 `repos/_index.md`(`INIT_PATHS` 允许这一个共享文件);`_routes.yaml` 只在不存在时生成:先由
已有子目录入口页**确定性**地得出 owner(页中反引号里、在钉点存在、落在 `source_roots` 内的代码路径;`pkg/**` 即目录
`pkg/`),再补模型提出的 owner(scope 前缀须在钉点存在)→ 已有页面的问题进清单(钉点下不成立的声明、与文档重复的行、
没有路由到达的页、表格索引的样式)→ 检查 → dry run 或 PR。钉点缺省取上游镜像的 `HEAD`(远端默认分支,不假设
`main`;每次同步都按 `ls-remote --symref` 刷新镜像的 `HEAD`,默认分支改名或删除后也跟得上)。没有可用 owner 时的
兜底路由用规范化后的 `source_roots`(`./pkg/` → `pkg/`),源根是仓库本身或未设置时用钉点下的全部顶层目录。发布时 `KB_INIT_GIT_AUTHOR` 缺失在任何模型调用之前拒绝。推送之前先把这次发布(基点、分支、文件、标题、
正文、作者、时间)写成 `<stage>-publish.json`(`save_prepared`),记录状态为 `publishing`;推送/开 PR 失败记为
`blocked` 并保留它,重跑时直接完成**同一个**发布(同输入重建出同一提交,已推送的分支与已开的 PR 都复用,不再调模型)。
dry run 每次整体替换输出目录,不残留上一次的页面;有待完成的发布时拒绝 dry run(不覆盖它)。init 写的非规则页
(入口页简介、架构页、目录标题)经 `neutral_headings`:代码之外的 ATX 标题改为粗体段落,其余以 `#` 开头的行转义为
`\#`,代码块里的此类行缩进一个空格 —— 没有任何行以 `#` 开头,`RULE_HEADING`/`ANY_RULE_HEADING` 永远匹配不到,
模型写的 `## Overview` 或 `## SERV-1 — x` 不会变成规则 ID。模型提出的 owner 页必须
是本仓库目录下已有或本阶段创建的页面,指向其他仓库的页面一律丢弃并记入 notes。仓库标签不在 `doc/knowledge/SCHEMA.md` 分类法里、种子不存在、生成模型不可用或无价格 → 记录为 `blocked`,
不产生 PR。同输入摘要重跑直接返回记录;已发布记录的输入变了则拒绝。`modules`/`deepen`/`harvest-calibration` 抛
`NotImplementedError`。测试:`test_kb_init_skeleton.py`、`test_kb_init_config.py`。
6 changes: 5 additions & 1 deletion doc/architecture/SPEC/playbooks/PLAYBOOKS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# playbooks/*.yaml —— 规范

<!-- verified-against: 2026-09-29 -->
<!-- verified-against: 2026-09-30 -->

`9 个文件 · 声明式编排数据 · refactor-status: ok`

Expand Down Expand Up @@ -57,3 +57,7 @@ success, steps[]`。

## kb-merge / kb-sweep / kb-activate(candidate)
知识服务的合并推进、发版巡检与快照激活;candidate 状态,由 `kb serve` 调度或 `kb run` 手动运行。

## kb-init(candidate)
为一个仓库建知识库:单步 `knowledge.init`,每次运行一个阶段(skeleton → modules → deepen + 生命周期开关 →
harvest-calibration),每阶段一个由人合并的 PR。只由 `kb init REPO --stage S` 运行,不经 `kb serve`,不写服务账本。
7 changes: 5 additions & 2 deletions doc/architecture/SPEC/profiles/establish.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

## 公开契约
`fact_id`、`build_doc_corpus`、`is_redundant`、`extract_directives`、
`scan_modules`、`scan_modules_at_depth`、`normalize_root`、`ROOT_MODULE`、`HUMAN_DOC_NAMES`。
`scan_modules`、`scan_modules_at_depth`、`normalize_root`、`ROOT_MODULE`、`HUMAN_DOC_NAMES`、`doc_files`。

## 不变量
- `is_redundant`(对 README+docs 做 6 词 shingle)会丢弃任何**仓库自己的文档已经写过**
Expand All @@ -19,6 +19,9 @@
- `scan_modules_at_depth`(kb init 的模块定义):源根以下至多 `depth` 层目录为模块,更深的文件归最深的那层祖先;
非空行少于 `min_loc` 的模块由深到浅折入父目录模块,直到稳定;源根永不折叠(`normalize_root` 先把 `.`、`./pkg`、`/pkg/` 等写法规范化,仓库根为 `""` 且包含其他根,保证折叠必然终止);嵌套源根各自拥有自己的文件;
每一层都跳过隐藏目录与非代码目录;`exclude` 按仓库相对路径做 fnmatch;未知语言返回 `{}`;结果确定、有序。
- `build_doc_corpus(..., globs=None)`:不给 `globs` 时语料与原来完全相同(README* + docs/**/*.md);给出时读
`doc_files(repo, globs)` —— 按 glob 顺序、再按路径排序,每个文件只读一次,只收普通文件,解析后逃出仓库的
符号链接一律跳过。kb init 用它按 adapter 的 `doc_globs` 建 D5 语料,归一化与 briefing 路径是同一份。

## 边界 —— 不属于这里
纯确定性 helper —— 不含 LLM、不写 store、不含 step 逻辑。
Expand All @@ -27,7 +30,7 @@
仅 stdlib。

## 测试
`test_profile_steps.py`(冗余过滤、模块扫描、指令抽取);`test_kb_init_coverage.py`(按深度的模块扫描)。
`test_profile_steps.py`(冗余过滤、模块扫描、指令抽取);`test_kb_init_coverage.py`(按深度的模块扫描);`test_kb_init_skeleton.py`(`doc_files` 与带 `globs` 的语料)。

## 重构备注
纯函数 —— 易测、易复用。冗余过滤器是承载 ETH 研究结论的关键防线;**保持它确定性**。
Expand Down
21 changes: 21 additions & 0 deletions playbooks/kb-init.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# kb init for ONE repository: one stage per run (skeleton, then modules, then
# deepen + lifecycle flip, then harvest-calibration), each opened as a
# human-merged PR (design kb-init v3). Runs outside the knowledge service: it
# never writes the service ledger. Candidate.
name: kb-init
version: 1
status: candidate
provenance:
created_by: human
source: kb init design v3 (bootstrap a repository's knowledge base)
task_kinds: [knowledge_maintenance]
repos: []
requires: []
params:
repo: "knowledge repository name"
stage: "skeleton | modules | deepen | harvest-calibration"
dry_run: "true (default): write the tree and PR body locally; false needs ALLOW_PUSH=1 and ALLOW_POST=1"
pin: "upstream commit to pin (default: the default branch head)"
steps:
- {id: init, step: knowledge.init}
success: the stage's PR is open (or its dry run is written), or the record says why it is blocked
37 changes: 37 additions & 0 deletions src/infermatrix_copilot/engine/steps/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,40 @@ async def activate_snapshot(ctx: StepContext) -> StepResult:
return StepResult(False, FailureKind.ESCALATE, f"activation refused: {exc}")
return StepResult(True, summary=f"active snapshot {sha}",
outputs={"state_updates": {"kb_active_snapshot": sha, "kb_snapshot_path": str(snapshot)}})


def _init_runtime(ctx: StepContext):
"""kb init's own runtime: never the service's (that opens kb.db), and it
serves disabled repositories — bootstrapping one is the point."""
from pathlib import Path

from ...kb_service.cli import DEFAULT_STATE_DIR
from ...kb_service.init_support import InitRuntime

return InitRuntime.from_env(ctx.settings, state_dir=Path(_STATE_DIR or DEFAULT_STATE_DIR))


@step("knowledge.init", kind="agent", risk="knowledge",
description="kb init: bootstrap one repository's knowledge base, one human-merged stage at a time")
async def init_stage(ctx: StepContext) -> StepResult:
from ...kb_service.init_stages import run_stage
from ...kb_service.init_support import InitError

repo = str(ctx.params.get("repo") or ctx.state.get("kb_repo") or "")
stage = str(ctx.params.get("stage") or "")
dry_run = str(ctx.params.get("dry_run", "true")).lower() not in ("0", "false", "no")
pin = str(ctx.params.get("pin") or "") or None
try:
rt = _init_runtime(ctx)
lifecycle = rt.registry.get(repo)
if lifecycle is None:
return StepResult(False, FailureKind.BLOCKED, f"no adapter declares knowledge repo {repo!r}")
record = run_stage(rt, lifecycle, stage, dry_run=dry_run, pin=pin)
except (InitError, NotImplementedError) as exc:
return StepResult(False, FailureKind.BLOCKED, str(exc))
updates = {"kb_init_stage": stage, "kb_init_status": record.status, "kb_init_pr": dict(record.pr)}
if record.status == "blocked":
return StepResult(False, FailureKind.BLOCKED, "; ".join(record.problems)[:2000],
outputs={"state_updates": updates})
return StepResult(True, summary=f"kb init {stage} for {repo}: {record.status}",
outputs={"state_updates": updates})
45 changes: 45 additions & 0 deletions src/infermatrix_copilot/kb_service/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
kb traces [--kind K] [--changeset ID] [--rule ID] [--limit N] query trace/1 records
kb replay --record ID --model PROVIDER:MODEL[:EFFORT] re-ask a recorded call
kb export --out FILE [--role judge|generator] dataset (calibration-safe)
kb init REPO --stage STAGE [--dry-run] [--pin SHA]
bootstrap a repository's knowledge base, one
human-merged stage at a time (skeleton, modules,
deepen, harvest-calibration); never touches kb.db
kb init REPO --suggest-seeds rank existing knowledge pages worth seeding from (no model call)
kb publish (--remote HOST:/STATE_DIR | --local DIR) [--once]
the publisher (GPU box, owner's gh login): perform
the signed outbox items; writes need ALLOW_POST=1
Expand Down Expand Up @@ -103,6 +108,36 @@ def _refresh(state_dir: Path, ledger, registry) -> None:
_outbox(state_dir, ledger).transition(lambda: None)


def _init_command(args, state_dir: Path) -> int:
from ..config import Settings

if args.suggest_seeds == bool(args.stage):
print("kb init: pass exactly one of --stage or --suggest-seeds", file=sys.stderr)
return 2
if args.suggest_seeds:
from .init_stages import suggest_seeds
from .init_support import InitError, InitRuntime

try:
rt = InitRuntime.from_env(Settings(), state_dir=state_dir)
lifecycle = rt.registry.get(args.repo)
if lifecycle is None:
print(f"no adapter declares knowledge repo {args.repo!r}", file=sys.stderr)
return 2
for path, score, shared in suggest_seeds(rt, lifecycle):
print(f"{score:6.3f} {path} ({', '.join(shared[:8])})")
except InitError as exc:
print(str(exc), file=sys.stderr)
return 1
return 0
from .runner import run_playbook

params = {"stage": args.stage, "dry_run": "true" if args.dry_run else "false", "pin": args.pin or ""}
outcome, run_dir = run_playbook(Settings(), "kb-init", args.repo, state_dir=state_dir, params=params)
print(f"kb init {args.repo} {args.stage}: {outcome.status} ({run_dir})")
return 0 if outcome.status == "done" else 1


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="infermatrix-copilot kb")
parser.add_argument("--state-dir", help="default: $KB_STATE_DIR or ~/.infermatrix-copilot/kb")
Expand Down Expand Up @@ -142,6 +177,13 @@ def main(argv: list[str] | None = None) -> int:
export = sub.add_parser("export")
export.add_argument("--out", required=True)
export.add_argument("--role", default="judge", choices=("judge", "generator"))
init = sub.add_parser("init")
init.add_argument("repo")
init.add_argument("--stage", choices=("skeleton", "modules", "deepen", "harvest-calibration"))
init.add_argument("--dry-run", action="store_true",
help="write the tree and PR body under the state directory instead of opening a PR")
init.add_argument("--pin", help="upstream commit to pin (default: the default branch head)")
init.add_argument("--suggest-seeds", action="store_true")
publish = sub.add_parser("publish")
where = publish.add_mutually_exclusive_group(required=True)
where.add_argument("--remote", help="host:/absolute/path of the service state directory (over ssh)")
Expand All @@ -163,6 +205,9 @@ def main(argv: list[str] | None = None) -> int:
return _publish(args)
if args.command in {"traces", "replay", "export"}:
return _traces_command(args, _state_dir(args.state_dir))
if args.command == "init":
# before any ledger is opened: kb init never touches the service's kb.db
return _init_command(args, _state_dir(args.state_dir))

state_dir = _state_dir(args.state_dir)
ledger = _ledger(state_dir)
Expand Down
Loading
Loading