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
5 changes: 3 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ jobs:
with:
python-version: "3.12"

# pytest lives in the `dev` extra; `mcp` is needed for the MCP server tests.
# pytest lives in the `dev` extra; `mcp` is needed for the MCP server tests;
# `kb` (cryptography) for the knowledge service's signing.
- name: Install
run: pip install -e ".[dev,mcp]"
run: pip install -e ".[dev,mcp,kb]"

- name: Unit tests
run: python -m pytest -q
Expand Down
10 changes: 10 additions & 0 deletions adapters/afd_plugin/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,13 @@ push:
default_remote: origin
protected_branches: [main, master]
allowed: false

# Knowledge service: declared but disabled until afd-plugin has its own
# calibration set and has run through a shadow period (rollout stage P3b).
knowledge_lifecycle:
enabled: false
mode: shadow
knowledge_dir: repos/afd-plugin
release:
trigger: none
upstream_visibility: public
23 changes: 23 additions & 0 deletions adapters/vllm_omni/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -399,3 +399,26 @@ push:
rebase_branch: dev/vllm-align
# rebase-commit author identity (parent config SIGNOFF_NAME/EMAIL)
signoff: {name: tzhouam, email: tzhouam@connect.ust.hk}

# Knowledge service (doc/features/knowledge-service.md). Human-only section:
# switching `mode` to auto_merge is a reviewed code change, never an agent write.
knowledge_lifecycle:
enabled: true
mode: shadow # shadow | auto_merge
knowledge_dir: repos/vllm-omni
intake:
merged_prs: true
copilot_runs: true
human_prs: true
daily_model_budget_usd: 50
release:
trigger: github_release
auditor: release_audit.py # adapters/vllm_omni/release_audit.py
sweep:
fallback_interval_days: 30
upstream_visibility: public
circuit_breaker:
retire_ratio: 0.10
max_files: 50
protected_rules: []
model_dir: repos/vllm-omni/models
3 changes: 3 additions & 0 deletions doc/architecture/SPEC/adapters/base.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,6 @@

## 2026-09-28 退役规则不再提供
`render_briefing_docs` 经 `visible_text` 去掉退役规则。

## 2026-09-28 knowledge_lifecycle
`knowledge_lifecycle` 加入 `HIGH_RISK_SECTIONS`:知识服务的开关与 `auto_merge` 切换只能经人工审阅修改。
5 changes: 4 additions & 1 deletion doc/architecture/SPEC/cli.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# cli/ —— 规范

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

`LOC ~1420(6 个文件) · 接口 + 编排门面 · refactor-status: ok`

Expand Down Expand Up @@ -90,3 +90,6 @@ flag CLI 与 `Copilot` 门面:解析 → 过门 → 执行;并持有 run 目
拆分**已完成**(它曾是内聚拆分候选)。`Copilot` 类完整留在 `copilot.py`,
好让 resolve→execute 的流程能在一个文件里读完;只有 argparse/REPL 前端(`entry.py`)
和两个纯格式化器(`utils.py`)搬了出去。K6(`_gate_and_confirm`)已完成,挂在类上。

## 2026-09-28 kb 子命令
`infermatrix-copilot kb …` 在 argparse 之前分派到 `kb_service.cli.main`(知识服务运维命令),不启动 Copilot/LLM。
29 changes: 29 additions & 0 deletions doc/architecture/SPEC/kb_service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# kb_service/ —— 规范

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

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

## 职责
- `config`:解析 adapter `knowledge_lifecycle`(未知键、非法模式/触发器、缺失审计插件、
知识目录与 adapter 不一致、私有上游配 `auto_merge`、`auto_merge` 无校准集均抛
`LifecycleConfigError`);`load_registry` 返回按仓库名的注册表,`general` 由服务配置。
- `ledger`:单个 SQLite,所有表按 `repo` 分区;心跳租约保证单实例;模式变化即代际 +1;
`bump_generation` 使该仓库(或 `*`)之前签发的 outbox 项全部失效,可同时暂停。
- `outbox`:服务端签发 outbox 项、控制记录与暂停清单,读取发布器签名的回执;
发布器端 `check_item`(签名、控制记录新鲜度、过期、代际、暂停、私有上游;
只有 auto_merge 仓库才有非停止类写动作,shadow 与 disabled 一律不发布;
`pause`/`close` 总可执行);
`verify_holds` 供 kb-gate 使用(过期即失败)。
- `cli`:`infermatrix-copilot kb keygen|status|pause|resume|control`。

## 不变量
- 模块不出现任何仓库名;仓库差异只来自 adapter 配置。
- bot 主机无 GitHub 写凭据:服务只写签名的 outbox 项。
- `knowledge_lifecycle` 属于 adapter 的高风险段,agent 写入被拒。

## 依赖(允许)
stdlib + PyYAML + `cryptography`(`kb` extra)+ `.adapters` + `.knowledge_service.signing`。

## 测试
`test_kb_service_core.py`。
5 changes: 5 additions & 0 deletions doc/architecture/SPEC/knowledge_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ from the package, so the kb-gate verifier bundle can vendor them.
directory and runs it with a baseline generated for the audited SHA pair:
knowledge-document issues are enforced, adapter-baseline maintenance is
reported as reconciliation.

`signing` provides Ed25519 envelopes whose signature covers a purpose tag plus
the canonical JSON payload, so a signature made for one purpose (gate verdict,
outbox item, control record, hold list, publisher ack) never verifies for
another. It needs only `cryptography` and is vendored by the kb-gate verifier.
41 changes: 41 additions & 0 deletions doc/features/knowledge-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 知识服务 —— Copilot 全权负责知识库

> **一览**
> | | |
> |---|---|
> | **状态** | 🚧 分阶段落地。已合并:Direct 路由多仓库化与按请求知识视图(#203)、Knowledge Ops API 2.0 与 L1(#204)、generated-baseline 发版审计(#205);本页随后续 PR 更新 |
> | **做什么** | 知识的创建、维护、退役、删除与发版巡检全部在 Copilot;每次变更经质量门,通过即经合并队列自动合并;ReviewBot 只读消费 |
> | **怎么开关** | 每个仓库在 `adapters/<repo>/manifest.yaml` 的 `knowledge_lifecycle`(人工审阅的高风险段)中设置 `enabled` 与 `mode: shadow\|auto_merge` |
> | **设计** | 经 GPT-6 sol 评审批准的设计文档(知识库管理重构设计,多仓库,v7) |
> | **硬边界** | bot 主机不持有任何 GitHub 写凭据;所有 GitHub 写动作由 GPU 盒上的发布器以 owner 账号执行,且只执行服务签名、未过期、代际有效的 outbox 项;私有上游仓库只能 shadow,不产生任何公开产物 |

## 组成

| 部分 | 位置 | 职责 |
|---|---|---|
| 仓库配置与注册表 | `kb_service/config.py` | 解析每个 adapter 的 `knowledge_lifecycle`;`general/` 由服务配置 |
| 账本 | `kb_service/ledger.py` | 单个 SQLite(`$KB_STATE_DIR/kb.db`),所有表按 `repo` 分区;单实例租约;代际号 |
| 签名 | `knowledge_service/signing.py` | Ed25519 信封,按用途(判定、outbox、控制记录、暂停清单、回执)隔离签名 |
| outbox | `kb_service/outbox.py` | 服务写签名项、控制记录与暂停清单;发布器执行前用 `check_item` 复核 |
| CLI | `infermatrix-copilot kb …` | `keygen`、`status`、`pause`、`resume`、`control` |

## 代际与暂停

任何暂停、熔断、回滚或切回 `shadow` 都会使该仓库(或全局 `*`)代际 +1,并立即重签
控制记录与暂停清单。发布器只执行代际与当前一致、未过期(`enqueue`/`post_verdict`
30 分钟,其余 24 小时)且控制记录签发不超过 10 分钟的项;`pause` 与 `close` 总是可执行。
只有 `auto_merge` 仓库会产生非停止类写动作:`shadow` 只记录“将要做什么”,不开 PR、
不发评论;模式变化本身也会使代际 +1。
暂停清单发布在服务的公开 HTTP 端点,kb-gate 在合并队列中读取它,过期或不可达即失败。

## 运维

```bash
infermatrix-copilot kb keygen --out /etc/infermatrix-kb/service.pem # 公钥提交到 .github/kb-gate.pub
export KB_SIGNING_KEY=/etc/infermatrix-kb/service.pem KB_STATE_DIR=/var/lib/infermatrix-kb
infermatrix-copilot kb status
infermatrix-copilot kb pause --repo vllm-omni --reason "rollback drill"
infermatrix-copilot kb resume --repo vllm-omni
```

依赖:`pip install "infermatrix-copilot[kb]"`(cryptography)。
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ dev = ["pytest>=8.0"]
# MCP server (Claude Code / Codex integration) — kept optional so a standalone
# CLI install stays dependency-free.
mcp = ["mcp>=1.8,<2"]
# Knowledge service (signing, verdict envelopes); the kb-gate verifier pins it by hash.
kb = ["cryptography>=42"]

[project.scripts]
infermatrix-copilot = "infermatrix_copilot.cli:main"
Expand Down
2 changes: 1 addition & 1 deletion src/infermatrix_copilot/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from ..knowledge_service.lifecycle import visible_text

HIGH_RISK_SECTIONS = ("push", "repo", "upstream", "rebase")
HIGH_RISK_SECTIONS = ("push", "repo", "upstream", "rebase", "knowledge_lifecycle")


def expand_path(value: str, extra: dict | None = None) -> str:
Expand Down
6 changes: 6 additions & 0 deletions src/infermatrix_copilot/cli/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import argparse
import sys

from ..intent import parse_intents
from .copilot import Copilot
Expand Down Expand Up @@ -136,6 +137,11 @@ def main(argv: list[str] | None = None) -> int:
help=argparse.SUPPRESS)
parser.add_argument("--execute-strict-reserved", metavar="RUN_ID",
help=argparse.SUPPRESS)
raw = list(sys.argv[1:] if argv is None else argv)
if raw and raw[0] == "kb": # the knowledge service has its own subcommands
from ..kb_service.cli import main as kb_main

return kb_main(raw[1:])
args = parser.parse_args(argv)

if args.command == "doctor": # diagnostics only — no Copilot/LLM spin-up
Expand Down
1 change: 1 addition & 0 deletions src/infermatrix_copilot/kb_service/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""The knowledge service: owns every change to the knowledge base (see doc/features/knowledge-service.md)."""
145 changes: 145 additions & 0 deletions src/infermatrix_copilot/kb_service/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""``infermatrix-copilot kb`` — operate the knowledge service.

kb keygen --out PATH create a signing key (0600) and print its public key
kb status repositories, modes, generations, queues
kb pause (--repo R | --all) --reason TEXT
kb resume (--repo R | --all)
kb control refresh the signed control record and hold list now

Pause/resume bump the repository's generation, so every outbox item issued
before it is void; they then re-sign the control record and hold list at once.
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import time
from pathlib import Path

DEFAULT_STATE_DIR = Path.home() / ".infermatrix-copilot" / "kb"


def _state_dir(value: str | None) -> Path:
return Path(value or os.environ.get("KB_STATE_DIR") or DEFAULT_STATE_DIR).expanduser()


def _ledger(state_dir: Path):
from .ledger import Ledger

return Ledger(state_dir / "kb.db")


def _registry():
from ..sdk._resources import adapters_root
from .config import general_lifecycle, load_registry

general = general_lifecycle(
enabled=os.environ.get("KB_GENERAL_ENABLED", "0") == "1",
mode=os.environ.get("KB_GENERAL_MODE", "shadow"),
)
return load_registry(Path(os.environ.get("ADAPTERS_DIR") or adapters_root()), general=general)


def _signing_key():
from ..knowledge_service.signing import load_private_key

path = os.environ.get("KB_SIGNING_KEY")
if not path:
raise SystemExit("KB_SIGNING_KEY is not set (path to the service's Ed25519 private key)")
return load_private_key(path)


def _sync_repos(ledger, registry) -> bool:
"""Record configured modes; True when any mode changed (and so a
generation was bumped and the signed control record must be re-issued)."""
changed = False
for lifecycle in registry.values():
changed |= ledger.ensure_repo(lifecycle.repo, lifecycle.mode if lifecycle.enabled else "disabled")
return changed


def _outbox(state_dir: Path, ledger):
from .outbox import Outbox

return Outbox(state_dir, _signing_key(), ledger, clock=time.time)


def _public(registry) -> set[str]:
return {name for name, lifecycle in registry.items() if lifecycle.publishes}


def _refresh(state_dir: Path, ledger, registry) -> None:
_outbox(state_dir, ledger).transition(lambda: None, public_repos=_public(registry))


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")
sub = parser.add_subparsers(dest="command", required=True)
keygen = sub.add_parser("keygen")
keygen.add_argument("--out", required=True)
sub.add_parser("status")
for name in ("pause", "resume"):
cmd = sub.add_parser(name)
target = cmd.add_mutually_exclusive_group(required=True)
target.add_argument("--repo")
target.add_argument("--all", action="store_true")
if name == "pause":
cmd.add_argument("--reason", required=True)
sub.add_parser("control")
args = parser.parse_args(argv)

if args.command == "keygen":
from ..knowledge_service.signing import generate_private_key, public_key_text

key = generate_private_key(args.out)
print(public_key_text(key.public_key()))
return 0

state_dir = _state_dir(args.state_dir)
ledger = _ledger(state_dir)
try:
registry = _registry()
if _sync_repos(ledger, registry) and os.environ.get("KB_SIGNING_KEY"):
_refresh(state_dir, ledger, registry)
if args.command == "status":
report = {
"state_dir": str(state_dir),
"active_snapshot": ledger.active_snapshot(),
"repos": [
{**row, "configured": row["repo"] in registry or row["repo"] == "*",
"open_changesets": len(ledger.changesets(row["repo"], ("open", "pr_open", "signed", "queued")))
if row["repo"] != "*" else None,
"human_queue": len(ledger.human_queue(row["repo"])) if row["repo"] != "*" else None}
for row in ledger.all_repo_states()
],
}
json.dump(report, sys.stdout, indent=2, sort_keys=True)
print()
return 0
if args.command in {"pause", "resume"}:
repo = "*" if args.all else args.repo
if repo != "*" and repo not in registry:
print(f"unknown repository: {repo}", file=sys.stderr)
return 2
# the state change and its signed publication happen under one
# interprocess lock, so no concurrent refresh can publish a
# pre-pause control record after the pause
if args.command == "pause":
generation = _outbox(state_dir, ledger).transition(
lambda: ledger.bump_generation(repo, pause=True, reason=args.reason),
public_repos=_public(registry))
else:
generation = _outbox(state_dir, ledger).transition(
lambda: ledger.resume(repo), public_repos=_public(registry))
print(f"{args.command}d {repo}: generation {generation}")
return 0
if args.command == "control":
_refresh(state_dir, ledger, registry)
return 0
finally:
ledger.close()
return 2
Loading
Loading