From f09b7d1e9192f0317d9ad04a4e977d15108e89b8 Mon Sep 17 00:00:00 2001 From: mambo-wang Date: Sun, 6 Sep 2026 15:51:15 +0800 Subject: [PATCH 01/23] =?UTF-8?q?refactor(mcp):=20output=5Fdir=20=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E4=B8=BA=20repo=5Fpath=20=E5=8D=95=E9=94=9A=EF=BC=8C?= =?UTF-8?q?=E7=9F=A5=E8=AF=86=E5=BA=93=E5=9B=BA=E5=AE=9A=E8=90=BD=E5=9C=A8?= =?UTF-8?q?=20/repowiki?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 所有知识库工具的 inputSchema 移除 output_dir 参数,统一以 repo_path 为唯一 目标锚,库路径由 default_output_dir(repo_path) 推导;调用方仍传 output_dir 时被静默忽略以保兼容 - registry / prompts 及各 tool handler(ingest、query、lint、stats、capture、 distill、consolidate、workspace 一族)同步收敛;缺失锚点时注入服务端启动 CWD,并在解析失败时返回带 fix 提示的可执行错误 - 文档、schema.yaml 与 workspace 模板同步更新 - 附带本轮重构的演进计划、收敛检查清单与批量替换脚本 --- ...03\345\261\200\346\216\250\345\257\274.md" | 131 ++++++++++ .output_dir-convergence-checklist.txt | 60 +++++ AGENTS.md | 4 +- codewiki/agents/wiki-recall.md | 6 +- codewiki/cli/commands/query.py | 15 +- codewiki/mcp/cache.py | 14 +- codewiki/mcp/prompts.py | 107 +++----- codewiki/mcp/registry.py | 236 +++--------------- codewiki/mcp/tools/analysis.py | 26 +- codewiki/mcp/tools/batch_ingest.py | 15 +- codewiki/mcp/tools/capture_conversation.py | 5 +- codewiki/mcp/tools/close_session.py | 21 +- codewiki/mcp/tools/cross_service.py | 32 ++- codewiki/mcp/tools/distill_conversation.py | 8 +- codewiki/mcp/tools/doc_writer.py | 36 ++- codewiki/mcp/tools/doctrine.py | 16 +- codewiki/mcp/tools/evidence.py | 13 +- codewiki/mcp/tools/init_wiki.py | 20 +- codewiki/mcp/tools/issue_tracker.py | 22 +- codewiki/mcp/tools/legacy_tools.py | 20 +- codewiki/mcp/tools/module_tree.py | 21 +- codewiki/mcp/tools/note_ingest.py | 22 +- codewiki/mcp/tools/note_lifecycle.py | 41 ++- codewiki/mcp/tools/note_query.py | 7 +- codewiki/mcp/tools/prompt_server.py | 15 +- codewiki/mcp/tools/review_checklist.py | 4 +- codewiki/mcp/tools/store_bridge.py | 52 +++- codewiki/mcp/tools/wiki_lint.py | 8 +- codewiki/mcp/tools/wiki_search.py | 35 ++- codewiki/mcp/tools/wiki_stats.py | 11 +- codewiki/mcp/tools/workspace_analyzer.py | 20 +- codewiki/mcp/tools/workspace_bootstrap.py | 26 +- codewiki/mcp/tools/workspace_layout.py | 24 ++ codewiki/templates/schema.yaml | 4 +- .../workspace/agents-md-workspace.md.tpl | 4 +- codewiki/templates/workspace/readme.md.tpl | 2 +- codewiki/templates/workspace/repo-map.md.tpl | 2 +- ...344\270\216MCP\345\267\245\345\205\267.md" | 9 +- ...76\350\256\241\346\226\271\346\241\210.md" | 6 +- repowiki/.meta/page_manifest.json | 18 ++ .../.meta/telemetry/iamwangbao-163-com.jsonl | 72 ++++++ .../memories/iamwangbao-163-com.md" | 8 + repowiki/wiki/log-2026-09.md | 7 + repowiki/wiki/reading-guide.md | 2 +- scripts/_strip_output_dir.py | 100 ++++++++ 45 files changed, 755 insertions(+), 572 deletions(-) create mode 100644 ".codebuddy/plans/output_dir-\346\224\266\346\225\233\344\270\272repo_path\345\270\203\345\261\200\346\216\250\345\257\274.md" create mode 100644 .output_dir-convergence-checklist.txt create mode 100644 scripts/_strip_output_dir.py diff --git "a/.codebuddy/plans/output_dir-\346\224\266\346\225\233\344\270\272repo_path\345\270\203\345\261\200\346\216\250\345\257\274.md" "b/.codebuddy/plans/output_dir-\346\224\266\346\225\233\344\270\272repo_path\345\270\203\345\261\200\346\216\250\345\257\274.md" new file mode 100644 index 0000000..983a830 --- /dev/null +++ "b/.codebuddy/plans/output_dir-\346\224\266\346\225\233\344\270\272repo_path\345\270\203\345\261\200\346\216\250\345\257\274.md" @@ -0,0 +1,131 @@ +--- +name: output_dir-收敛为repo_path布局推导 +overview: 激进收敛 CodeWiki 的 output_dir 机制:跨进程持久化彻底移除、session 恢复与写路径一律改用 repo_path 的布局感知推导(default_output_dir)、全工具(含只读检索)统一移除 output_dir 参数、模板与工具描述同步改写,输出实施清单。 +todos: + - id: inventory-callers + content: 用 [subagent:code-explorer] 盘点 output_dir 全消费点并固化收敛清单 + status: completed + - id: core-converge + content: 退役 cache.set/get_output_dir,session 创建与恢复统一改 default_output_dir 推导 + status: completed + dependencies: + - inventory-callers + - id: schema-handlers + content: registry/schema 全工具(含只读)移除 output_dir 参数,handler 统一 repo_path 推导 + status: completed + dependencies: + - core-converge + - id: templates-docs + content: 同步 workspace 模板/AGENTS 注入/prompts/hook/docs 文案,output_dir= 全部改 repo_path= + status: in_progress + dependencies: + - schema-handlers + - id: tests-regression + content: 适配布局与会话测试,新增外来目录污染回归,用 [skill:verify-and-stop] 全量跑绿 + status: pending + dependencies: + - templates-docs +--- + +## 需求概述 + +上一轮事故(smoke 进程把临时 output_dir 持久化进 repo 根 DB,导致后续 session 恢复时索引被清空/错位)暴露了 `output_dir` 参数面的设计冗余。用户经盘点讨论后选定激进收敛方案(方案 B),核心诉求: + +1. **机制收敛一步到位**:`output_dir` 本质是 `repo_path` 的(布局感知)纯函数,不应成为可跨进程持久化、可自由指向仓库外的全局状态。 +2. **接口收敛**:除只读检索工具外,移除各工具 schema 上的 `output_dir` 参数;输出目录由 `repo_path` 经布局自动推导(普通单仓 → `repo根/repowiki`,centralized 成员 → workspace 根 repowiki),不落库、不可被外部进程改写。 +3. **全工具收敛**:包括 `query_wiki`/`note_query` 等只读检索工具在内的所有工具统一移除显式 `output_dir` 参数;输出目录一律由 `repo_path` 经 `default_output_dir` 布局推导(跨仓显式寻址改用 `repo_path=<业务仓目录>` + `repo=` 过滤,孤立 wiki 直接以仓库根为 repo_path)。 +4. **文档/模板同步**:workspace 模板、AGENTS.md 注入、prompts、hook、README/docs 中所有 `output_dir=` 书写同步收敛,避免旧示例引导调用方继续传参。 +5. **回归保护**:确保 centralized/colocated 布局路由语义不被破坏,并新增"外来 output_dir 不得污染 session/索引"的防护回归测试。 + +方案保持既有布局机制(`workspace_layout.default_output_dir` 已实现布局感知推导),不改写布局语义本身。 + +## 技术栈 + +- Python(现有 codewiki MCP server,无新增依赖) +- 复用现有布局路由原语:`workspace_layout.default_output_dir / resolve_workspace / routing_for_write / is_centralized_corpus`(codewiki/mcp/tools/workspace_layout.py) +- 复用现有事故防护原语:`cache._foreign_output_dir`(codewiki/mcp/cache.py:359,判定"repo 外路径 + repo 拥有 repowiki/") + +## 核心设计洞察(收敛的关键) + +事故根因链中,"污染"的唯一入口是 **session 创建/恢复时从 cache 读取持久化的 output_dir**: + +- `SessionStore.find_or_restore`(codewiki/mcp/session.py:156-211)第 190-196 行 `output_dir = cache.get_output_dir()` 优先于 `/repowiki` fallback; +- `analysis.py:276/518` 两处 analyze 成功后 `cache.set_output_dir(...)` 落库; +- `cache.py` 的 `set_output_dir`(402 行)已加"repo 外且 repo 有 repowiki 则忽略"的防护,`get_output_dir`(386 行)也已部分加固——但持久化键仍存在、仍被恢复路径消费。 + +**因此优雅收敛点是:让 session.output_dir 恒等于 `default_output_dir(repo_path)`**。改两条路即可令所有 handler 内 `session.output_dir` 引用(doc_writer/crosslink/evidence/batch_ingest 等数十处)无需逐一修改即自动正确: + +1. session 创建(analyze 成功后 create session 传入的 output_dir)与恢复(find_or_restore)一律改由 `default_output_dir(repo_path)` 推导; +2. `cache.set_output_dir / get_output_dir` 退役(删除方法与全部调用点),`repo_meta` 不再承载 output_dir 键——外来进程在机制上无法再改写任何后续会话的目录指向。 + +## 解析链(收敛后目标态) + +```mermaid +flowchart TD + A[MCP 调用] --> B{工具类别?} + B -->|写路径/生命周期/分析| C[repo_path 必填] + C --> D[default_output_dir repo_path] + D --> E[布局感知推导] + E -->|普通单仓| F[repo根/repowiki] + E -->|centralized 成员| G[workspace 根 repowiki] + B -->|只读检索 query_wiki 等| D + F --> K[KnowledgeStore] + G --> K +``` + +## 关键技术决策与取舍 + +1. **全工具移除参数**(用户最终确认,推翻了此前的"只读保留")。所有工具(含 `query_wiki`/note_query 等只读检索)schema 不再暴露 `output_dir`;输出目录一律由 `repo_path` 经 `default_output_dir` 布局推导,跨仓显式寻址改用 `repo_path=<业务仓目录>` + `repo=` 过滤。 +2. **handler 容忍层(平滑过渡)**:为兼容既有调用方(AGENTS.md 约定、skill、hook、旧脚本)在过渡期仍携带的 `output_dir` 参数,handler 内部解析保留"显式值仅用于**校验告警**,不改变推导结果"的降级路径:若显式值与 `default_output_dir(repo_path)` 不一致且落在 repo 外,按 `_foreign_output_dir` 同款判定拒绝/忽略并告警日志;MCP server 对未知/多余参数的宽容度决定是否可直接硬删——先核查 registry/server 参数校验实现(registry.py 已存在 repo_path 默认注入机制,见 2945 行注释)。 +3. **兼容层不做长期保留**:收敛后 `resolve_output_dir`(store_bridge.py:34)命中路径收敛为"session(恒为推导值)> repo_path 推导";显式 `output_dir` 仅告警忽略(`_warn_ignored_output_dir`),不改变推导结果;不传 session 且无 repo_path 时维持原 ValueError 契约。 +4. **不做大范围 handler 改写**:`session.output_dir` 字段保留(内存态),仅保证其赋值来源恒为布局推导,从而全部内部引用自动正确——避免 53 个文件的无谓改动(最小爆破面)。 + +## 风险与性能 + +- 性能无新增热点(default_output_dir 走 workspace_layout 进程级缓存 `_cache`,见 workspace_layout.py:46)。 +- 风险点:centralized 语义(业务仓分析落 workspace 共享 corpus、repo= 过滤、routing_for_write 分区)绝不可破坏——所有改动必须复用现有布局原语,禁止手写 `/repowiki` 拼接路径。 +- 风险点:MCP schema 移除参数后,registry 的工具说明、prompts.py 的 usage 文本、AGENTS.md 注入模板若残留 output_dir= 示例,会引导 agent 传已被忽略的参数——文案同步与代码收敛必须同批完成。 +- 爆破面控制:不动布局语义、不动 task-memory/团队协作层、不动 close_session 的索引重建逻辑本身;仅收敛"目录从哪来"。 + +## 目录结构(改动文件) + +``` +codewiki/mcp/cache.py [MODIFY] 退役 set_output_dir/get_output_dir 与 repo_meta 中 output_dir 键的写入/读取;保留 _foreign_output_dir 或上移到统一收敛 helper 供 handler 校验复用 +codewiki/mcp/session.py [MODIFY] find_or_restore(约 190-196 行)改 default_output_dir(repo_path) 推导;不再调用 cache.get_output_dir +codewiki/mcp/tools/analysis.py [MODIFY] 移除两处 cache.set_output_dir 调用(约 276、518 行);session 创建时 output_dir 由 default_output_dir 推导 +codewiki/mcp/tools/store_bridge.py [MODIFY] resolve_output_dir/store_for 收敛注释与降级校验路径(session 恒为推导值;显式 output_dir 仅读工具) +codewiki/mcp/registry.py [MODIFY] 写路径工具 schema 移除 output_dir 参数描述;核对/兼容未知参数策略 +codewiki/mcp/server.py [MODIFY] 核查参数校验宽容度(如需要) +codewiki/mcp/tools/note_query.py [MODIFY] handle_query_wiki(778 行)保留 output_dir 寻址;文档串更新为"第二跳优先 repo_path" +codewiki/mcp/tools/workspace_layout.py [MODIFY](如需要)抽出统一收敛 helper,集中 repo 外 output_dir 判定 +codewiki/templates/workspace/agents-md-workspace.md.tpl [MODIFY] 第二跳 query_wiki(output_dir=业务仓repowiki) → repo_path=<业务仓目录>;第一跳去掉显式 output_dir +codewiki/templates/workspace/repo-map.md.tpl [MODIFY] 新增业务仓示例注释(29 行)同步改为 repo_path +codewiki/templates/workspace/readme.md.tpl [MODIFY] 27 行 output_dir= 示例同步 +codewiki/templates/workspace/agents-md-workspace-centralized.md.tpl [MODIFY] 核对一跳文案(应为无 output_dir 写法) +codewiki/mcp/prompts.py / codewiki/mcp/tools/doctrine.py / distill_conversation.py / review_changes.py / note_ingest.py 等 [MODIFY] 文案与内部透传校对(以盘点清单为准) +codewiki/hooks/task_session_start.py / codewiki/agents/wiki-recall.md / codewiki/cli/commands/query.py [MODIFY] output_dir= 示例收敛 +tests/test_layout_routing.py / test_runtime_layout.py / test_centralized_layout_fixes.py / test_workspace_layout.py / test_team_layout.py / test_workspace_analyzer_layout.py / test_query_repo_filter.py [MODIFY] 断言适配 +tests/test_output_dir_convergence.py [NEW] 新增回归:外来目录 set 被拒/不落库、restore 走布局推导、centralized 路由不回归 +docs/(多仓 Harness 工作区两份设计文档等)与 AGENTS.md/README [MODIFY] 文案同步(output_dir= 收敛为 repo_path) +``` + +## 实现要点 + +1. 先盘点后动手:用 code-explorer 精确列出 `set_output_dir`/`get_output_dir`/`arguments.get("output_dir")`/`session.output_dir` 的全部消费点,生成收敛清单,避免漏改或误删 centralized 相关调用。 +2. 复用而非新造:repo 外 output_dir 的拒绝判定直接沿用 `cache._foreign_output_dir` 的语义(必要时上移为共享函数),三态回退(无 workspace/非成员/colocated → `/repowiki`)已由 `default_output_dir` 保证。 +3. 文案与代码同批:模板/AGENTS/prompts/README 的任何 output_dir= 残留都会让收敛"名存实亡"——todo 中把文案面与 schema 面绑在同一里程碑。 +4. 日志纪律:repo 外 output_dir 被忽略/拒绝时输出 warning(复用现有 logger),不带路径大 payload、不刷屏(每调用一次即可)。 + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- Purpose: 在收敛改动前精确盘点 `output_dir`/`session.output_dir` 的全部消费点、MCP schema 参数定义位置、模板与 docs 中所有 `output_dir=` 文案面,产出收敛清单,避免大范围重构漏改。 +- Expected outcome: 输出按"数据/恢复层、handler 解析层、schema 定义层、模板/prompts/hook/docs 文案层"分组的完整文件与行号清单,作为后续改动与回归的依据。 + +### Skill + +- **verify-and-stop** +- Purpose: 收敛与文案同步完成后,跑布局/会话/centralized 全套测试并核对回归验收条件(外来目录不落库、restore 走布局推导、centralized 路由不回归、既有测试全绿),只验证不扩范围。 +- Expected outcome: 给出回归测试通过证明与残留输出_dir 消费点为零的验收结论。 \ No newline at end of file diff --git a/.output_dir-convergence-checklist.txt b/.output_dir-convergence-checklist.txt new file mode 100644 index 0000000..f15efed --- /dev/null +++ b/.output_dir-convergence-checklist.txt @@ -0,0 +1,60 @@ +# output_dir 收敛清单(todo1 产物,实施后删除) + +目标态:output_dir 是 repo_path 的布局感知纯函数;跨进程持久化退役;写路径工具 schema 不暴露 output_dir(handler 容忍层忽略/告警 repo 外值);只读检索保留显式 output_dir;session.output_dir 恒 = default_output_dir(repo_path)。 + +## L1 数据/恢复层(持久化与 session 恢复) +- codewiki/mcp/cache.py:359 `_foreign_output_dir(od)` — repo 外判定(保留/上移共享) +- codewiki/mcp/cache.py:386-400 `get_output_dir` — 读 repo_meta["output_dir"](退役) +- codewiki/mcp/cache.py:402-411 `set_output_dir` — 写 repo_meta(退役;现有"repo 外+有 repowiki 则忽略"防护可并入共享 helper) +- codewiki/mcp/session.py:156-211 `find_or_restore` — 190-196 行消费 cache.get_output_dir(),需改 default_output_dir(repo_path);注意 174-181 行 cache.is_fresh() 无缓存返回 None 的契约不可破坏 +- codewiki/mcp/tools/analysis.py:276、518 — 两处 cache.set_output_dir 调用(退役) +- codewiki/mcp/tools/analysis.py:254-260、507-513 — store.create(output_dir=...) 两处,output_dir 源自 60-66 行解析(保留"显式→default_output_dir"已近目标,但显式分支应收敛) +- codewiki/mcp/tools/analysis.py:280-315 — project.json 写入(output_dir 字段+相对 cache_db;消费方 wiki_search._resolve_db_path 只读 cache_db 字段,output_dir 字段无读取方 → project.json 保留不动) +- codewiki/mcp/tools/wiki_search.py:101 `_resolve_db_path` — 读 project.json cache_db(事故修复涉及,勿回退) + +## L2 handler 解析层(arguments.get("output_dir") 出现点;括号内为当前角色) +- codewiki/mcp/tools/analysis.py:60 — analyze_repo 主流程(写路径:收显式 → 应收敛) +- codewiki/mcp/tools/workspace_analyzer.py:452 — analyze_workspace(写/分析) +- codewiki/mcp/tools/workspace_bootstrap.py:669 — init_workspace/add_workspace_repo 族 +- codewiki/mcp/tools/doc_writer.py:1308、1616 — write_doc_file 两个入口(写) +- codewiki/mcp/tools/note_ingest.py:219 — ingest_note(写) +- codewiki/mcp/tools/note_lifecycle.py:66、99、182 — 三个函数(如 init_wiki?/close?) +- codewiki/mcp/tools/batch_ingest.py:65 — batch_ingest(写) +- codewiki/mcp/tools/close_session.py:174 — close_session(生命周期,写侧) +- codewiki/mcp/tools/wiki_lint.py:231 — lint_wiki(校验,兼读写) +- codewiki/mcp/tools/evidence.py:85 — evidence 族(查询+写,仅读结果?待核) +- codewiki/mcp/tools/module_tree.py:239 — save_module_tree(写) +- codewiki/mcp/tools/wiki_stats.py:52 — wiki_stats(只读统计) +- codewiki/mcp/tools/doctrine.py:181 — doctrine(refresh/init 等) +- codewiki/mcp/tools/note_query.py:792 — handle_query_wiki(**只读,保留 output_dir 寻址**) +- codewiki/mcp/tools/cross_service.py:53 — query_cross_service(**只读跨仓,保留**) +- codewiki/mcp/tools/issue_tracker.py:81 — issue 工具(写 issue 到 wiki?) +- codewiki/mcp/tools/prompt_server.py:378 — prompt 获取(只读) +- codewiki/mcp/tools/store_bridge.py:41 — resolve_output_dir 公共解析(session > output_dir > repo_path→default) +- codewiki/mcp/tools/workspace_result.py:29-67 — resolve_session(repo_path/session_id→store.find_or_restore;无 store/cache 时返回 None) + +## L3 schema 定义层(registry.py 37 处 + 模块内 Tool) +- 注册:registry.py:64 `_register(schema, handler_path, mode, takes_store)` → REGISTRY +- dispatch:registry.py:2971 `dispatch()` 不做参数过滤 → 未知/多余参数直接透传 handler(MCP 层宽容;schema 移除无执行风险) +- 兜底:registry.py:2956 `_inject_repo_path_default`(2988 在 dispatch 内调用)— 零锚点时注入 server 启动 CWD;注释"resolution order session > output_dir > repo_path" +- schema 定义位置待 todo3 逐工具处理时按 name= 定位;其中仓库内已有模块自带 schema:codewiki/mcp/tools/close_session.py:31 TOOLS=[Tool(name="close_session",...)] +- 注意 registry 中 readme:analyze_repo 在 registry 的 schema 位于 ~104-108 行(output_dir 描述待收敛) + +## L4 文案/模板/测试面(待 todo4/5 逐一同步;已知锚点) +- 模板:codewiki/templates/workspace/agents-md-workspace.md.tpl(第 7-21 行两跳 query_wiki(output_dir=));agents-md-workspace-centralized.md.tpl(一跳,需核);repo-map.md.tpl:29;readme.md.tpl:27 +- prompts:codewiki/mcp/prompts.py:404 `ingest_source(output_dir="{output_dir}")` +- hooks:codewiki/hooks/task_session_start.py(含 query_wiki,需核 output_dir 用法) +- agents:codewiki/agents/wiki-recall.md(含 query_wiki) +- cli:codewiki/cli/commands/query.py +- tests:布局/会话/centralized 相关文件清单见 plan「目录结构」;新增 tests/test_output_dir_convergence.py +- docs/README/AGENTS.md 文案同步(git 上已改的 wiki_search/cache 勿动) + +## centralized 不可动(语义红线) +- workspace_layout.py 原语:default_output_dir / resolve_workspace / routing_for_write / is_centralized_corpus +- centralized:成员仓 cache 与 anchor 落在 workspace 根 .codewiki//(analysis.py:293-300 project.json 注释);repo= 过滤;共享 corpus +- 禁止手写 `/repowiki` 拼接;一律复用 default_output_dir + +## 关键行为契约(改动时保持) +- resolve_session/find_or_restore 返回 None 而非抛错(session 可选语义) +- 写工具无 session 且无 repo_path 时保持 ValueError(含 fix 提示) +- MCP 层不校验未知参数 → 容忍层以 handler 内判定实现,不依赖 schema diff --git a/AGENTS.md b/AGENTS.md index a5c5d50..dab86cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,7 +153,7 @@ Single-context layout: root `CONTEXT.md` + `docs/adr/`. See `docs/agents/domain. - `repowiki/raw/` 是**暂存区,不进 `query_wiki` 检索**,蒸馏完成后由 `distill_conversation` 删除(除非 `keep_raw`);未蒸馏的 raw 会一直保留(无自动过期);不膨胀、不影响查询性能。 - 蒸馏产出 `status=draft` 的 note,须 `confirm_note` 确认后才成正式知识。 - 触发形态:**both** —— 手动命令(主) + IDE hook(可选)。 -- **Mode C submit 走 `distilled_file` 文件侧通道(勿内联大 JSON)**:多条大对话蒸馏时,`distilled` 内联参数可能超出 MCP 传输限制导致失败。正确做法:先用 `write_to_file` 把蒸馏 JSON(形状 `{conversation_id: {notes, memories}}`,或单条裸 `{notes, memories}` 配合 `conversation_id` 参数)写入 `repowiki/raw/.distill-*.json`,再 `distill_conversation(mode="submit", distilled_file=<路径>)` 只传小路径;相对路径按 `output_dir` 再按 CWD 解析。小载荷仍可内联 `distilled`(两者可合并,内联优先)。**不要再写临时 Python 脚本调用 handler 绕过**。 +- **Mode C submit 走 `distilled_file` 文件侧通道(勿内联大 JSON)**:多条大对话蒸馏时,`distilled` 内联参数可能超出 MCP 传输限制导致失败。正确做法:先用 `write_to_file` 把蒸馏 JSON(形状 `{conversation_id: {notes, memories}}`,或单条裸 `{notes, memories}` 配合 `conversation_id` 参数)写入 `repowiki/raw/.distill-*.json`,再 `distill_conversation(mode="submit", distilled_file=<路径>)` 只传小路径;相对路径先按推导出的 repowiki 目录再按 CWD 解析。小载荷仍可内联 `distilled`(两者可合并,内联优先)。**不要再写临时 Python 脚本调用 handler 绕过**。 ## Task memory (任务记忆) @@ -200,7 +200,7 @@ IDE 式磁盘 transcript;任务记忆的注入与捕获改由 Agent 执行: 本会话 chatId → `qwenwork_task_get_detail(chatId, offset 分页)` 拉全部轮次 2. 按轮次做要点级压缩:保留决策脉络、关键事实、结论与提交号;丢弃寒暄、 过程噪音与工具调用细节 - 3. 调 `capture_conversation(output_dir=/repowiki, + 3. 调 `capture_conversation(repo_path=, conversation=[{"role": ..., "content": ...}...], task_id=<绑定的任务id>, source_session_id="qwenwork-")` 走标准管线落盘 (frontmatter/content_hash/supersede 全套),**勿手写 raw/*.md** diff --git a/codewiki/agents/wiki-recall.md b/codewiki/agents/wiki-recall.md index 7c0689b..52bf749 100644 --- a/codewiki/agents/wiki-recall.md +++ b/codewiki/agents/wiki-recall.md @@ -10,7 +10,7 @@ description: 任务开始前检索 CodeWiki 团队知识库(本仓库 repowiki ### 1. 相关性预检(必做,先花小钱) ```bash -codewiki query "<任务关键词>" --check --output-dir /repowiki +codewiki query "<任务关键词>" --check --repo-path ``` - `relevant: false` 或 top_score < 2 → 判定"知识库无相关内容",直接返回一行结论:`wiki-recall: 无相关知识(precheck top_score=)`,结束 @@ -20,10 +20,10 @@ codewiki query "<任务关键词>" --check --output-dir /repowiki ```bash # 常规(默认):snippet 级,足够判断方向 -codewiki query "<完整任务描述关键词>" --output-dir /repowiki --top 5 +codewiki query "<完整任务描述关键词>" --repo-path --top 5 # 深读单页:需要完整内容时(改核心模块/排查复杂问题) -codewiki query "<查询>" --output-dir /repowiki --expand 5000 +codewiki query "<查询>" --repo-path --expand 5000 ``` ### 3. 压缩返回(保护主上下文) diff --git a/codewiki/cli/commands/query.py b/codewiki/cli/commands/query.py index 645b714..1073dc1 100644 --- a/codewiki/cli/commands/query.py +++ b/codewiki/cli/commands/query.py @@ -94,7 +94,7 @@ def _render_result_block(payload: dict) -> str: @click.command(name="query") @click.argument("query") @click.option( - "--output-dir", "-o", default=None, help="repowiki directory (default: /repowiki)" + "--repo-path", "-r", default=None, help="Repository root (default: current directory); repowiki is derived from it" ) @click.option("--top", type=int, default=10, show_default=True, help="Max results (1-20)") @click.option( @@ -121,7 +121,7 @@ def _render_result_block(payload: dict) -> str: default=None, help="Include full page content (optional value: char budget 500-20000)", ) -def query_command(query, output_dir, top, check_mode, scope, type_filter, expand): +def query_command(query, repo_path, top, check_mode, scope, type_filter, expand): """Search the wiki from the command line (agent-friendly delimited output). Same search engine as the query_wiki MCP tool — BM25 + usage heat + @@ -129,16 +129,19 @@ def query_command(query, output_dir, top, check_mode, scope, type_filter, expand """ from pathlib import Path - od = Path(output_dir).expanduser().resolve() if output_dir else Path.cwd() / "repowiki" + from codewiki.mcp.tools.workspace_layout import default_output_dir + + rp = Path(repo_path).expanduser().resolve() if repo_path else Path.cwd() + od = default_output_dir(rp) if not od.is_dir(): click.echo( - f"error: output dir not found: {od}\n" - "Pass --output-dir or run from a repo with a generated repowiki/.", + f"error: repowiki dir not found: {od}\n" + "Pass --repo-path or run from a repo with a generated repowiki/.", err=True, ) sys.exit(2) - arguments = {"output_dir": str(od), "query": query, "max_results": max(1, min(20, top))} + arguments = {"repo_path": str(rp), "query": query, "max_results": max(1, min(20, top))} if check_mode: arguments["mode"] = "check" if scope: diff --git a/codewiki/mcp/cache.py b/codewiki/mcp/cache.py index 6bc94d3..ef8f9fc 100644 --- a/codewiki/mcp/cache.py +++ b/codewiki/mcp/cache.py @@ -356,13 +356,13 @@ def get_last_commit_id(self) -> Optional[str]: def set_last_commit_id(self, cid: str): self._mset("last_commit_id", cid) - def get_output_dir(self) -> Optional[str]: - """Return the output_dir recorded by the last analyze_repo, if any.""" - od = self._mget("output_dir") - return self._abs_path(od) if od else None - - def set_output_dir(self, od: str): - self._mset("output_dir", self._rel_path(od)) + # NOTE (output_dir convergence): set_output_dir / get_output_dir are + # retired — output_dir is a pure function of repo_path under the active + # layout (workspace_layout.default_output_dir) and must never be persisted + # or inherited across processes. The repo_meta "output_dir" key written by + # older versions is now dead data; stale foreign rows are simply ignored + # because no code path reads them anymore. "Foreign path" validation lives + # in workspace_layout.is_foreign_output_dir for handler-side write guards. def get_component_count(self) -> int: r = self.conn.execute("SELECT COUNT(*) as c FROM components").fetchone() diff --git a/codewiki/mcp/prompts.py b/codewiki/mcp/prompts.py index ee41f02..e8f4ed8 100644 --- a/codewiki/mcp/prompts.py +++ b/codewiki/mcp/prompts.py @@ -81,7 +81,7 @@ def _resolve_path(raw: str) -> str: 本会话 chatId → `qwenwork_task_get_detail(chatId, offset 分页)` 拉全部轮次 2. 按轮次做要点级压缩:保留决策脉络、关键事实、结论与提交号;丢弃寒暄、 过程噪音与工具调用细节 - 3. 调 `capture_conversation(output_dir=/repowiki, + 3. 调 `capture_conversation(repo_path=, conversation=[{{"role": ..., "content": ...}}...], task_id=<绑定的任务id>, source_session_id="qwenwork-")` 走标准管线落盘 (frontmatter/content_hash/supersede 全套),**勿手写 raw/*.md** @@ -93,8 +93,6 @@ def _resolve_path(raw: str) -> str: def _prompt_init_wiki(args: dict[str, str]) -> str: repo_path = _resolve_path(args.get("repo_path", "")) - output_dir = args.get("output_dir", "") - od_note = f',output_dir="{output_dir}"' if output_dir else "" # T6: 可选启用任务管理(跨会话任务记忆 + 对话采集 Hook) enable_task_management = args.get("enable_task_management", "").strip().lower() if enable_task_management in ("1", "true", "yes", "on"): @@ -176,13 +174,13 @@ def _prompt_init_wiki(args: dict[str, str]) -> str: return f"""请为项目初始化 Wiki 工作区。按以下步骤执行: ## 步骤 1: 初始化 -调用 init_wiki(repo_path="{repo_path}"{od_note}) +调用 init_wiki(repo_path="{repo_path}") - 自动创建目录结构:wiki/modules, wiki/entities, wiki/concepts, wiki/sources, wiki/comparisons, wiki/queries, notes/ - 拷贝带注释的 schema.yaml 模板到输出目录(保留所有注释,方便阅读和自定义) - 在仓库根目录写入/更新 AGENTS.md(含使用建议、自我反思协议、知识沉淀规则) {hook_block} ## 步骤 {2 + step_shift}: 自定义 schema.yaml -读取 `{output_dir or repo_path + "/repowiki"}/schema.yaml`,根据项目特点修改: +读取 `{repo_path}/repowiki/schema.yaml`,根据项目特点修改: - **purpose**(重要):用一两句话描述项目定位,会注入到所有文档生成 prompt 中 - **doc_types**:选择适合项目的文档风格(api/architecture/design/business 等) - **conventions**:调整命名规范、最小行数、是否需要 Mermaid 图等 @@ -230,7 +228,7 @@ def _prompt_init_workspace(args: dict[str, str]) -> str: ## 步骤 4: 登记业务仓(仅新工作区需要) - 对用户提到的每个业务仓,用 add_workspace_repo(url=<克隆URL>) 逐个登记(目录名自动取仓库名);用户没给 URL 就先询问,不要凭记忆猜测 - 登记完成后**不要自动生成 wiki**:不调用 init_wiki / analyze_repo / analyze_workspace,等用户显式要求时再生成 -- 生成时按布局选工具:**centralized** 下 `init_wiki` 不适用于仓库级(知识统一汇入工作区 repowiki,无独立仓库 wiki)——单仓代码知识用 `analyze_repo()`(不传 output_dir 自动路由到 `wiki/modules/<名>/` 分区),跨仓拓扑与工作区总览用 `analyze_workspace(workspace_path=<工作区根>)`;**colocated** 下按既有流程 `init_wiki` + `analyze_repo`(各仓 wiki 位于 `/repowiki/`) +- 生成时按布局选工具:**centralized** 下 `init_wiki` 不适用于仓库级(知识统一汇入工作区 repowiki,无独立仓库 wiki)——单仓代码知识用 `analyze_repo()`(自动推导输出目录并路由到 `wiki/modules/<名>/` 分区),跨仓拓扑与工作区总览用 `analyze_workspace(workspace_path=<工作区根>)`;**colocated** 下按既有流程 `init_wiki` + `analyze_repo`(各仓 wiki 位于 `/repowiki/`) ## 注意事项 - 首次初始化必须显式选择布局:不传 layout 时工具返回 needs_layout_decision 且不写任何产物;重跑自动沿用 `repowiki/.meta/workspace.json` 中持久化的布局(显式传冲突值才报错) @@ -301,12 +299,10 @@ def _prompt_remove_workspace_repo(args: dict[str, str]) -> str: def _prompt_generate_wiki(args: dict[str, str]) -> str: repo_path = _resolve_path(args.get("repo_path", "")) - output_dir = args.get("output_dir", "") - od_note = f',output_dir="{output_dir}"' if output_dir else "" return f"""请为代码仓库生成完整的 Wiki 文档。按以下步骤执行: ## 步骤 1: 分析仓库 -调用 analyze_repo(repo_path="{repo_path}"{od_note}) +调用 analyze_repo(repo_path="{repo_path}") - 返回组件数量、语言统计等分析结果 - 大文件结果写入 workspace 文件,通过返回的 file_path 读取 @@ -352,7 +348,7 @@ def _prompt_incremental_update(args: dict[str, str]) -> str: ## 步骤 1: 检测变更 调用 analyze_repo(repo_path="{repo_path}") -- 如果 output_dir 已有 .meta/metadata.json,返回 changes 字段 +- 如果 repo_path 对应的 repowiki 已有 .meta/metadata.json,返回 changes 字段 - changes 包含: added_files, modified_files, deleted_files, affected_modules ## 步骤 2: 评估影响范围 @@ -389,8 +385,8 @@ def _prompt_extract_knowledge(args: dict[str, str]) -> str: from pathlib import Path as _Path source_name = _Path(source_path).stem - # output_dir defaults to cwd/repowiki (not next to the source file) - output_dir = args.get("output_dir", "") or str(_Path(_resolve_path("")) / "repowiki") + # 知识库定位由 repo_path 经布局推导(default_output_dir),不再接受显式 output_dir + repo_path = _resolve_path(args.get("repo_path", "")) granularity = args.get("granularity", "").strip() gran_call = f', variables={{"granularity": "{granularity}"}}' if granularity else "" gran_default_note = ( @@ -401,9 +397,9 @@ def _prompt_extract_knowledge(args: dict[str, str]) -> str: return f"""请导入外部文档并从中抽取结构化知识。采用「骨架提取 → 去重检查 → 证据校验 → 页面撰写」两阶段流程,按以下步骤执行: ## 步骤 1: 导入文档 -调用 ingest_source(output_dir="{output_dir}", source_ref="{source_path}") -- 文档会被复制到 {output_dir}/raw/sources/ 并注册到 source_registry.json -- 此步骤直接传入 output_dir,无需 session +调用 ingest_source(repo_path="{repo_path}", source_ref="{source_path}") +- 文档会被复制到 {repo_path}/repowiki/raw/sources/ 并注册到 source_registry.json +- 此步骤直接传入 repo_path,无需 session ## 步骤 2: 骨架提取(Pass 0 — 只识别,不撰写) 1. 通读源文档 "{source_path}"(使用 Read 工具直接读取原始文件,无需 view_repo_file),标记关键实体和抽象概念 @@ -414,7 +410,7 @@ def _prompt_extract_knowledge(args: dict[str, str]) -> str: ## 步骤 3: 去重检查(语义去重) 对每个骨架项: -1. 调用 query_wiki(output_dir="{output_dir}", query="") 搜索已有相似页面 +1. 调用 query_wiki(repo_path="{repo_path}", query="<title 及 aliases>") 搜索已有相似页面 2. 调用 get_prompt(prompt_type="extraction_dedup") 获取去重判定规则 3. 按规则将每项三分类:**create**(新建页面)/ **merge**(合并进已有页面)/ **drop**(丢弃) - 核心原则:**related ≠ same**——相关不等于相同,拿不准就不合并 @@ -425,12 +421,12 @@ def _prompt_extract_knowledge(args: dict[str, str]) -> str: - 一次性提及按当前 granularity 处理:focused/standard 直接丢弃;exhaustive 可保留但页面中须注明仅为提及 - 无法给出真实引用的项应丢弃——**无引用不成立** -## 步骤 5: 撰写知识页面(使用 output_dir="{output_dir}") -1. 源文档摘要: write_doc_file(output_dir="{output_dir}", filename="{source_name}.md", page_type="source", content=...) +## 步骤 5: 撰写知识页面 +1. 源文档摘要: write_doc_file(repo_path="{repo_path}", filename="{source_name}.md", page_type="source", content=...) - 调用 get_prompt(prompt_type="source_summary") 获取模板 -2. 实体页面(action=create): write_doc_file(output_dir="{output_dir}", filename="<实体名>.md", page_type="entity", content=...) +2. 实体页面(action=create): write_doc_file(repo_path="{repo_path}", filename="<实体名>.md", page_type="entity", content=...) - 调用 get_prompt(prompt_type="entity_page") 获取模板(含编译器写作纪律) -3. 概念页面(action=create): write_doc_file(output_dir="{output_dir}", filename="<概念名>.md", page_type="concept", content=...) +3. 概念页面(action=create): write_doc_file(repo_path="{repo_path}", filename="<概念名>.md", page_type="concept", content=...) - 调用 get_prompt(prompt_type="concept_page") 获取模板(含编译器写作纪律) 4. action=merge 的项: 先用 view_repo_file 读取已有页面,再用 edit_doc_file 追加新事实 - 新事实必须附 `[^src:{source_name}:<line_range>]` 引用;补充 aliases;不得覆盖或删除已有内容 @@ -438,8 +434,8 @@ def _prompt_extract_knowledge(args: dict[str, str]) -> str: ## 步骤 6: 构建知识图谱并验证 - 页面间使用 [[wikilink]] 互相引用(如 [[认证服务]]、[[OAuth2]]) - build_search_index 会自动解析 wikilink 为图谱边 -- 调用 query_wiki(output_dir="{output_dir}", query="<实体/概念名>") 验证新页面可被检索 -- 之后可通过 query_wiki(output_dir="{output_dir}", query, hop=1) 进行多跳关联搜索 +- 调用 query_wiki(repo_path="{repo_path}", query="<实体/概念名>") 验证新页面可被检索 +- 之后可通过 query_wiki(repo_path="{repo_path}", query, hop=1) 进行多跳关联搜索 ## 写作纪律(所有页面通用) - **编译器模式**:事实性陈述直接引用源文档原句并标注 `[^src:{source_name}:<a-b>]`,可轻排序、去重、连接,但不得为风格改写或扩写 @@ -448,8 +444,8 @@ def _prompt_extract_knowledge(args: dict[str, str]) -> str: - 使用 frontmatter_extra 添加 aliases(搜索加权 3x)和 source_refs ## 注意事项 -- 整个流程直接使用 output_dir,无需 analyze_repo -- write_doc_file 直接传 output_dir 参数 +- 整个流程直接使用 repo_path(自动推导 output_dir),无需 analyze_repo +- write_doc_file 直接传 repo_path 参数 - ingest_source 只负责存储,不会自动生成 entity/concept 页面 - 每个页面应包含:定义、关键属性、与其他实体的关系、来源引用""" @@ -488,12 +484,11 @@ def _prompt_search_wiki(args: dict[str, str]) -> str: def _prompt_quality_check(args: dict[str, str]) -> str: - output_dir = args.get("output_dir", "") - od_param = f'output_dir="{output_dir}"' if output_dir else "repo_path=<repo_path>" + repo_path = _resolve_path(args.get("repo_path", "")) return f"""请对 Wiki 文档执行全面质量审计。按以下步骤执行: ## 步骤 1: 运行全量检查 -调用 lint_wiki({od_param}, checks=["all"]) +调用 lint_wiki(repo_path="{repo_path}", checks=["all"]) - stale_refs: 文档引用了已不存在的代码组件 - broken_links: Markdown 链接指向不存在的页面 - undocumented: 高影响组件缺少文档 @@ -904,7 +899,7 @@ def _prompt_cross_service_trace(args: dict[str, str]) -> str: def _prompt_ingest_note(args: dict[str, str]) -> str: - output_dir = args.get("output_dir", "") + repo_path = _resolve_path(args.get("repo_path", "")) note_type = args.get("note_type", "general") return f"""请将知识经验归档到 Wiki 知识库。按以下步骤执行: @@ -943,7 +938,7 @@ def _prompt_ingest_note(args: dict[str, str]) -> str: ```json {{ - "output_dir": "{output_dir or "<repo>/repowiki"}", + "repo_path": "{repo_path or "<repo>"}", "note_type": "{note_type}", "title": "<简洁描述核心知识的标题>", "content": "## 背景\\n...\\n## 核心内容\\n...\\n## 原因\\n...", @@ -953,7 +948,7 @@ def _prompt_ingest_note(args: dict[str, str]) -> str: ``` ### 参数说明 -- **output_dir**(必填):Wiki 输出目录路径 +- **repo_path**(必填):仓库根目录路径(自动推导 Wiki 输出目录) - **note_type**:笔记类型,默认 general - **title**(必填):简洁的标题,概括核心知识 - **content**(必填):Markdown 格式的笔记正文 @@ -968,14 +963,14 @@ def _prompt_ingest_note(args: dict[str, str]) -> str: 调用 query_wiki 确认笔记可被检索: ``` -query_wiki(output_dir="{output_dir or "<repo>/repowiki"}", query="<笔记标题关键词>") +query_wiki(repo_path="{repo_path or "<repo>"}", query="<笔记标题关键词>") ``` ## 高质量笔记示例 ```json {{ - "output_dir": "{output_dir or "<repo>/repowiki"}", + "repo_path": "{repo_path or "<repo>"}", "note_type": "lesson", "title": "OrderService.process() 只做参数校验不做业务处理", "content": "## 背景\\n\\nAgent 误以为 OrderService.process() 包含完整业务逻辑,基于方法名做了错误的设计假设。\\n\\n## 正确做法\\n\\nprocess() 仅做入参校验和格式化,实际业务处理在 OrderService.execute() 中。老项目方法名与实际行为不一致是常见情况,应优先阅读实现而非信任方法名。\\n\\n## 根因\\n\\n十几年老项目,方法经过多次重构但名称未更新。", @@ -1269,12 +1264,12 @@ def _prompt_consolidate_knowledge(args: dict[str, str]) -> str: def _prompt_promote_note(args: dict[str, str]) -> str: note_file = (args.get("note_file") or "").strip() - output_dir = (args.get("output_dir") or "").strip() or "<repo>/repowiki" + repo_path = _resolve_path(args.get("repo_path", "")) target = note_file or "<候选笔记相对路径,如 notes/2026-08-01-port-conflict.md>" return f"""笔记晋升工作流(P1 C 线,docs/知识飞轮增强设计方案-P1三项.md §4)。当 wiki_stats 返回的 `promotion_candidates` 出现候选笔记(status=stable、被 Agent 声明采纳达到门槛、树龄足够),或用户要求"把某条笔记晋升为正式 wiki 页面"时,使用本流程把反复被采纳的笔记 AI 重写为正式 wiki 页面,打通 notes → wiki 的断层。 ## 前置:确定晋升对象 -- 未指定笔记时:调用 `wiki_stats(output_dir="{output_dir}")`,读取 `promotion_candidates` 列表,向用户展示候选(file/title/type/adopted_count/age_days/suggested_page_type),由用户选定要晋升哪一条 +- 未指定笔记时:调用 `wiki_stats(repo_path="{repo_path}")`,读取 `promotion_candidates` 列表,向用户展示候选(file/title/type/adopted_count/age_days/suggested_page_type),由用户选定要晋升哪一条 - 已指定笔记(本次目标:`{target}`)时:直接进入执行步骤 ## 类型路由(笔记 → 目标页面类型) @@ -1295,11 +1290,11 @@ def _prompt_promote_note(args: dict[str, str]) -> str: - **原笔记的 aliases 并入新页面 frontmatter** 的 aliases 字段,保持检索连续性 ## 执行步骤 -1. 读取原笔记全文(`view_repo_file` 或直接读 `{output_dir}/{target}`),确认其类型并按映射表选定 page_type +1. 读取原笔记全文(`view_repo_file` 或直接读 `{repo_path}/repowiki/{target}`),确认其类型并按映射表选定 page_type 2. 按重写规则完成去个人化重写,调用 `write_doc_file`(page_type 按映射表取): ```json {{ - "output_dir": "{output_dir}", + "repo_path": "{repo_path}", "page_type": "<query|concept>", "file_name": "<新页面文件名>", "title": "<去个人化后的标题>", @@ -1314,13 +1309,12 @@ def _prompt_promote_note(args: dict[str, str]) -> str: metadata: promoted_to: wiki/queries/<新页面文件名> ``` - (值为新页面相对 output_dir 的路径。**⚠ promoted_to 必须写在 `metadata:` 嵌套段下(缩进两格),不能写成顶层键**——OKF v0.2 顶层键白名单不含 promoted_to,顶层直写会触发 okf_conformance lint 告警) + (值为新页面相对 Wiki 根目录的路径,如 `wiki/queries/xxx.md`;Wiki 根目录由 `repo_path` 自动推导。**⚠ promoted_to 必须写在 `metadata:` 嵌套段下(缩进两格),不能写成顶层键**——OKF v0.2 顶层键白名单不含 promoted_to,顶层直写会触发 okf_conformance lint 告警) 4. **原笔记不删除、不降级状态**——它是审计轨迹与 source_ref 链路的锚点,仅加 promoted_to 标记 ## 参数说明 - **note_file**(可选):要晋升的笔记相对路径(如 notes/xxx.md) -- **output_dir**(可选):Wiki 输出目录,默认 `<repo>/repowiki` -- **repo_path**(可选):仓库根目录,用于推导 output_dir""" +- **repo_path**(必填):仓库根目录,用于自动推导 Wiki 输出目录""" def register(server): @@ -1345,11 +1339,6 @@ async def list_prompts() -> list: description="仓库根目录路径(相对路径基于当前工作目录,默认当前目录)", required=False, ), - PromptArgument( - name="output_dir", - description="Wiki 输出目录(默认: <repo>/repowiki)", - required=False, - ), PromptArgument( name="enable_task_management", description="是否启用任务管理(跨会话任务记忆):true/1 会在初始化指引中追加任务管理启用说明(注册 SessionEnd 采集 Hook + 向 AGENTS.md 写入任务引导段,新建会话时提示用户关联/新建任务);留空或 false 则跳过。默认关闭。", @@ -1368,13 +1357,7 @@ async def list_prompts() -> list: "为 clone-only 接管(只补缺业务仓克隆,不触碰骨架与 AGENTS.md),骨架有" "缺失才补齐产物并强制刷新约定块。业务仓登记走 add_workspace_repo。" ), - arguments=[ - PromptArgument( - name="output_dir", - description="产品级 repowiki 目录(默认: <workspace>/repowiki)", - required=False, - ), - ], + arguments=[], ), Prompt( name="add-workspace-repo", @@ -1432,11 +1415,6 @@ async def list_prompts() -> list: description="要分析的代码仓库路径(相对路径基于当前工作目录,默认当前目录)", required=False, ), - PromptArgument( - name="output_dir", - description="Wiki 输出目录(默认: <repo>/repowiki)", - required=False, - ), ], ), Prompt( @@ -1522,8 +1500,8 @@ async def list_prompts() -> list: description="对已生成的 Wiki 执行全面质量检查:过时引用、断链、覆盖率、循环依赖", arguments=[ PromptArgument( - name="output_dir", - description="Wiki 输出目录", + name="repo_path", + description="仓库根目录路径(相对路径基于当前工作目录,默认当前目录)", required=False, ), ], @@ -1599,8 +1577,8 @@ async def list_prompts() -> list: required=False, ), PromptArgument( - name="output_dir", - description="Wiki 输出目录(默认: <cwd>/repowiki)", + name="repo_path", + description="仓库根目录路径(相对路径基于当前工作目录,默认当前目录)", required=False, ), PromptArgument( @@ -1619,8 +1597,8 @@ async def list_prompts() -> list: ), arguments=[ PromptArgument( - name="output_dir", - description="Wiki 输出目录(默认: <repo>/repowiki)", + name="repo_path", + description="仓库根目录路径(相对路径基于当前工作目录,默认当前目录)", required=False, ), PromptArgument( @@ -1716,11 +1694,6 @@ async def list_prompts() -> list: description="要晋升的笔记相对路径(如 notes/xxx.md);留空则从 wiki_stats 的 promotion_candidates 中由用户选定", required=False, ), - PromptArgument( - name="output_dir", - description="Wiki 输出目录(默认: <repo>/repowiki)", - required=False, - ), PromptArgument( name="repo_path", description="仓库根目录路径(相对路径基于当前工作目录,默认当前目录)", diff --git a/codewiki/mcp/registry.py b/codewiki/mcp/registry.py index dfcdc67..8fc3cf9 100644 --- a/codewiki/mcp/registry.py +++ b/codewiki/mcp/registry.py @@ -105,10 +105,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "string", "description": "Absolute path to the repository to analyze", }, - "output_dir": { - "type": "string", - "description": "Output directory for generated docs (default: <repo>/repowiki)", - }, "include_patterns": { "type": "string", "description": "Comma-separated file patterns to include (e.g., '*.py,*.js')", @@ -175,7 +171,7 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "Use [[wikilinks]] in content to reference other pages — these are automatically " "parsed into a graph for multi-hop search (query_wiki with hop parameter). " "For large docs (>200 lines), use content_file instead of inline content. " - "Provide output_dir or derive it from repo_path. " + "Output directory is derived from repo_path. " "MANDATORY FINAL STEP: after writing the LAST module doc, you MUST call " "close_session(repo_path=...) to build the BM25 search index + wikilink graph. " "query_wiki returns NOTHING until close_session runs — skipping it leaves the wiki unsearchable." @@ -183,10 +179,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages", - }, "repo_path": { "type": "string", "description": "Repository path. Derives output_dir = repo_path/repowiki.", @@ -280,10 +272,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages", - }, "repo_path": { "type": "string", "description": "Repository path. Derives output_dir = repo_path/repowiki.", @@ -359,10 +347,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "string", "description": "Repository path. Derives output_dir = repo_path/repowiki.", }, - "output_dir": { - "type": "string", - "description": "Output directory for the module tree (default: repo_path/repowiki). Overrides repo_path-based default.", - }, "module_tree": { "type": "object", "description": ( @@ -468,10 +452,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "object", "description": "Optional template variables to fill in", }, - "output_dir": { - "type": "string", - "description": "Optional bundle directory (contains schema.yaml) — most direct way to enable schema-constraint injection (incl. the OKF v0.2 block)", - }, "repo_path": { "type": "string", "description": "Optional repository path — derives <repo>/repowiki and enables writing large prompts to workspace files", @@ -505,11 +485,7 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "properties": { "repo_path": { "type": "string", - "description": "Repository path. output_dir is resolved from the session or cache, falling back to repo_path/repowiki.", - }, - "output_dir": { - "type": "string", - "description": "Optional. Documentation output directory; overrides the session/cache-resolved value.", + "description": "Repository path. Output directory is derived from it (repo_path/repowiki).", }, "force": { "type": "boolean", @@ -819,13 +795,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages", - }, "repo_path": { "type": "string", - "description": "Repository path. Auto-derives output_dir = repo_path/repowiki when not provided.", + "description": "Repository path. output_dir is auto-derived via the workspace layout (default_output_dir).", }, "checks": { "type": "array", @@ -914,10 +886,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr }, "description": "Code regions this page's facts are grounded in.", }, - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages.", - }, "repo_path": { "type": "string", "description": "Repository path; evidence resources resolve against this root.", @@ -943,7 +911,7 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "architecture (system design rationale), bug_fix (how we fixed Y), " "pitfall (gotcha with root cause), known_issue (tracked problem), " "workaround (temporary solution), general (free-form knowledge). " - "Can be used with or without an active session — just provide output_dir. " + "Can be used with or without an active session — just provide repo_path. " "CONFLICT AWARENESS: before writing it scans notes/ for existing notes that look " "like the same knowledge and returns them in 'similar_notes' with a hint " "(set detect_conflicts=false to skip). It never overwrites or auto-merges — if the " @@ -954,13 +922,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages", - }, "repo_path": { "type": "string", - "description": "Repository path. Auto-derives output_dir = repo_path/repowiki when not provided.", + "description": "Repository path. Derives output_dir = repo_path/repowiki.", }, "scope": { "description": ( @@ -1071,16 +1035,11 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages", - }, "repo_path": { "type": "string", "description": ( - "Repository root used to locate the knowledge base when output_dir " - "is absent (derives <repo_path>/repowiki; layout-aware in " - "centralized workspaces). Pass repo_path OR output_dir." + "Repository root used to locate the knowledge base " + "(derives <repo_path>/repowiki; layout-aware in centralized workspaces)." ), }, "query": { @@ -1111,7 +1070,7 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "applicable to one business repo = its wiki/modules/<repo>/ partition " "+ shared-pool pages tagged with it + untagged product-line (global) " "pages. Omit for a one-hop search across the whole workspace. " - "Combined with output_dir, the filter applies within that corpus. " + "The filter applies within the corpus derived from repo_path. " "Ignored outside centralized workspaces." ), }, @@ -1238,13 +1197,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages", - }, "repo_path": { "type": "string", - "description": "Repository path. Auto-derives output_dir = repo_path/repowiki when not provided.", + "description": "Repository path. Derives output_dir = repo_path/repowiki.", }, "note_file": { "type": "string", @@ -1281,13 +1236,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages", - }, "repo_path": { "type": "string", - "description": "Repository path. Auto-derives output_dir = repo_path/repowiki when not provided.", + "description": "Repository path. Derives output_dir = repo_path/repowiki.", }, "status": { "type": "string", @@ -1341,13 +1292,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages", - }, "repo_path": { "type": "string", - "description": "Repository path. Auto-derives output_dir = repo_path/repowiki when not provided.", + "description": "Repository path. Derives output_dir = repo_path/repowiki.", }, "note_file": { "type": "string", @@ -1413,13 +1360,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages", - }, "repo_path": { "type": "string", - "description": "Repository path. Auto-derives output_dir = repo_path/repowiki when not provided.", + "description": "Repository path. Derives output_dir = repo_path/repowiki.", }, "source_ref": { "type": "string", @@ -1491,13 +1434,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages", - }, "repo_path": { "type": "string", - "description": "Repository path. Auto-derives output_dir = repo_path/repowiki when not provided.", + "description": "Repository path. Derives output_dir = repo_path/repowiki.", }, "name": { "type": "string", @@ -1538,13 +1477,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "string", "description": "Optional active session id (resolves output_dir).", }, - "output_dir": { - "type": "string", - "description": "Wiki output directory (default: <repo_path>/repowiki).", - }, "repo_path": { "type": "string", - "description": "Repository path used to derive output_dir when output_dir is absent.", + "description": "Repository path used to derive the output directory.", }, "conversation": { "type": "array", @@ -1616,13 +1551,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "string", "description": "Optional active session id (resolves output_dir).", }, - "output_dir": { - "type": "string", - "description": "Wiki output directory (default: <repo_path>/repowiki).", - }, "repo_path": { "type": "string", - "description": "Repository path used to derive output_dir when output_dir is absent.", + "description": "Repository path used to derive the output directory.", }, "raw_path": { "type": "string", @@ -1748,13 +1679,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "string", "description": "Optional active session id (resolves output_dir).", }, - "output_dir": { - "type": "string", - "description": "Wiki output directory (default: <repo_path>/repowiki).", - }, "repo_path": { "type": "string", - "description": "Repository path used to derive output_dir when output_dir is absent.", + "description": "Repository path used to derive the output directory.", }, "mode": { "type": "string", @@ -1815,13 +1742,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "string", "description": "Optional active session id (resolves output_dir).", }, - "output_dir": { - "type": "string", - "description": "Wiki output directory (default: <repo_path>/repowiki).", - }, "repo_path": { "type": "string", - "description": "Repository path used to derive output_dir when output_dir is absent.", + "description": "Repository path used to derive the output directory.", }, "mode": { "type": "string", @@ -1869,16 +1792,10 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages", - }, "repo_path": { "type": "string", "description": ( - "Repository root used to derive the output directory when " - "output_dir is absent (repo_path/repowiki). " - "Pass repo_path OR output_dir." + "Repository root used to derive the output directory (repo_path/repowiki)." ), }, "items": { @@ -1913,13 +1830,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Output directory for wiki pages", - }, "repo_path": { "type": "string", - "description": "Repository path. Auto-derives output_dir = repo_path/repowiki when not provided.", + "description": "Repository path. Derives output_dir = repo_path/repowiki.", }, "issue_type": { "type": "string", @@ -1995,10 +1908,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "string", "description": "Absolute path to the parent directory containing git repos", }, - "output_dir": { - "type": "string", - "description": "Output directory for workspace overview (default: <workspace>/repowiki)", - }, "exclude_dirs": { "type": "string", "description": "Comma-separated directory names to skip (default: node_modules,.venv,__pycache__)", @@ -2133,10 +2042,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "string", "description": "Absolute path to the workspace root (for analyze_workspace) or repo root (for analyze_repo monorepo mode). Auto-derives output_dir when omitted.", }, - "output_dir": { - "type": "string", - "description": "Output directory for workspace analysis .meta/ files. Overrides auto-derived path from workspace_path.", - }, "filter_type": { "type": "string", "enum": ["all", "by_service", "by_method", "by_path", "trace"], @@ -2175,11 +2080,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "string", "description": "Absolute path to the repository to document", }, - "output_dir": { - "type": "string", - "description": "Output directory for generated docs (default: ./repowiki)", - "default": "repowiki", - }, "doc_type": { "type": "string", "description": "Type of documentation to generate. Valid values defined in schema.yaml doc_types.types (default: design)", @@ -2218,11 +2118,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "string", "description": "Absolute path to the repository", }, - "output_dir": { - "type": "string", - "description": "Directory containing generated docs (default: ./repowiki)", - "default": "repowiki", - }, }, "required": ["repo_path"], }, @@ -2255,10 +2150,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "string", "description": "Repository root path. AGENTS.md is written here. Default: current working directory.", }, - "output_dir": { - "type": "string", - "description": "Wiki output directory (default: <repo_path>/repowiki). Created if it does not exist.", - }, }, "required": [], }, @@ -2303,10 +2194,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr inputSchema={ "type": "object", "properties": { - "output_dir": { - "type": "string", - "description": "Product-level repowiki directory (default: <workspace>/repowiki).", - }, "layout": { "type": "string", "enum": list(VALID_LAYOUTS), @@ -2432,11 +2319,7 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "properties": { "repo_path": { "type": "string", - "description": "Repository root path. If output_dir is not given, stats are read from <repo_path>/repowiki/.meta/retrieval_stats.db.", - }, - "output_dir": { - "type": "string", - "description": "Wiki output directory (default: <repo_path>/repowiki). Use this if the wiki was generated to a custom location.", + "description": "Repository root path. Stats are read from <repo_path>/repowiki/.meta/retrieval_stats.db.", }, "sort_by": { "type": "string", @@ -2506,13 +2389,9 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "string", "description": "Optional active session id (resolves output_dir).", }, - "output_dir": { - "type": "string", - "description": "Wiki output directory (default: <repo_path>/repowiki).", - }, "repo_path": { "type": "string", - "description": "Repository path used to derive output_dir when output_dir is absent.", + "description": "Repository path used to derive the output directory.", }, }, "required": ["title"], @@ -2540,7 +2419,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "description": "Optional status filter: 'active' or 'completed'.", }, "session_id": {"type": "string", "description": "Optional active session id."}, - "output_dir": {"type": "string", "description": "Wiki output directory."}, "repo_path": {"type": "string", "description": "Repository path."}, }, }, @@ -2571,7 +2449,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr ), }, "session_id": {"type": "string", "description": "Optional active session id."}, - "output_dir": {"type": "string", "description": "Wiki output directory."}, "repo_path": {"type": "string", "description": "Repository path."}, }, "required": ["task_id"], @@ -2593,7 +2470,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "properties": { "task_id": {"type": "string", "description": "Task id."}, "session_id": {"type": "string", "description": "Optional active session id."}, - "output_dir": {"type": "string", "description": "Wiki output directory."}, "repo_path": {"type": "string", "description": "Repository path."}, }, "required": ["task_id"], @@ -2615,7 +2491,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "properties": { "task_id": {"type": "string", "description": "Task id."}, "session_id": {"type": "string", "description": "Optional active session id."}, - "output_dir": {"type": "string", "description": "Wiki output directory."}, "repo_path": {"type": "string", "description": "Repository path."}, }, "required": ["task_id"], @@ -2642,7 +2517,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr }, "task_id": {"type": "string", "description": "Task id to bind to."}, "session_id": {"type": "string", "description": "Optional active session id."}, - "output_dir": {"type": "string", "description": "Wiki output directory."}, "repo_path": {"type": "string", "description": "Repository path."}, }, "required": ["source_session_id", "task_id"], @@ -2667,7 +2541,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "task_id": {"type": "string", "description": "Task id."}, "content": {"type": "string", "description": "Memory text (markdown)."}, "session_id": {"type": "string", "description": "Optional active session id."}, - "output_dir": {"type": "string", "description": "Wiki output directory."}, "repo_path": {"type": "string", "description": "Repository path."}, }, "required": ["task_id", "content"], @@ -2711,7 +2584,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr ), }, "session_id": {"type": "string", "description": "Optional active session id."}, - "output_dir": {"type": "string", "description": "Wiki output directory."}, "repo_path": {"type": "string", "description": "Repository path."}, }, "required": ["task_id"], @@ -2762,7 +2634,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr ), }, "session_id": {"type": "string", "description": "Optional active session id."}, - "output_dir": {"type": "string", "description": "Wiki output directory."}, "repo_path": {"type": "string", "description": "Repository path."}, }, "required": ["task_id"], @@ -2773,43 +2644,6 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr ) -# ------------------------------------------------------------------- -# Schema-level target-anchor guard (A: anyOf output_dir | repo_path) -# ------------------------------------------------------------------- -# Knowledge-base tools expose output_dir and repo_path as ALTERNATIVE target -# anchors — neither is required by the business payload alone, yet at least -# one must be present for the call to resolve. Post-processing every -# registered schema here (single point, Doctrine) makes one of them -# explicitly required at the schema level, so clients/LLMs see the contract -# instead of discovering it from a runtime error. Explicit anchor > -# derivable > session cache: tools that already require either path are -# skipped; session_id stays out of anyOf (explicit paths beat stale -# sessions, per Doctrine). - - -def _apply_target_anchor_anyof() -> None: - for td in REGISTRY.values(): - schema = td.schema.inputSchema - if not isinstance(schema, dict): - continue - props = schema.get("properties") - if not isinstance(props, dict): - continue - if "output_dir" not in props or "repo_path" not in props: - continue - required = set(schema.get("required") or []) - if "output_dir" in required or "repo_path" in required: - continue - if "anyOf" not in schema: - schema["anyOf"] = [ - {"required": ["output_dir"]}, - {"required": ["repo_path"]}, - ] - - -_apply_target_anchor_anyof() - - # =================================================================== # Public API # =================================================================== @@ -2941,12 +2775,12 @@ async def _try_cbm_enrichment( # Last-resort repo_path default (B): server start CWD # ------------------------------------------------------------------- # MCP stdio processes are launched with the host's project/workspace root as -# the CWD. When a knowledge-base call omits BOTH output_dir and repo_path, -# injecting repo_path=<server start CWD> lets resolution proceed through the -# normal layout-aware path instead of failing with "output_dir or repo_path -# is required". Explicit arguments are never overwritten; the injected -# default only fills complete absence and never outranks session/output_dir -# downstream (resolution order stays session > output_dir > repo_path). +# the CWD. When a knowledge-base call omits repo_path, injecting +# repo_path=<server start CWD> lets resolution proceed through the normal +# layout-aware path instead of failing with "repo_path is required". +# Explicit arguments are never overwritten; the injected default only fills +# complete absence and never outranks the session downstream (resolution order +# stays session > repo_path). try: _SERVER_START_CWD = os.getcwd() except Exception: # pragma: no cover - cwd always readable in practice @@ -2955,10 +2789,10 @@ async def _try_cbm_enrichment( def _inject_repo_path_default(arguments: dict[str, Any]) -> None: """Fill ``repo_path`` from the server start CWD when the call has no - explicit target anchor (output_dir/repo_path). In place; no-op otherwise.""" + explicit target anchor. In place; no-op otherwise.""" if _SERVER_START_CWD is None: return - if arguments.get("output_dir") or arguments.get("repo_path"): + if arguments.get("repo_path"): return try: if not Path(_SERVER_START_CWD).is_dir(): @@ -2984,7 +2818,7 @@ async def dispatch(name: str, arguments: dict[str, Any], store: Any) -> list[Tex matching the behavior of the original call_tool in server.py. """ try: - # B: fallback target anchor for calls that omit output_dir/repo_path + # B: fallback target anchor for calls that omit repo_path _inject_repo_path_default(arguments) tool_def = REGISTRY.get(name) @@ -3042,7 +2876,7 @@ async def dispatch(name: str, arguments: dict[str, Any], store: Any) -> list[Tex except Exception as e: logger.error("Tool %s failed: %s", name, e, exc_info=True) msg = str(e) - if isinstance(e, ValueError) and ("output_dir" in msg or "repo_path" in msg): + if isinstance(e, ValueError) and "repo_path" in msg: # C: actionable error — tell the caller exactly how to fix the call return [ TextContent( @@ -3050,13 +2884,7 @@ async def dispatch(name: str, arguments: dict[str, Any], store: Any) -> list[Tex text=json.dumps( { "error": msg, - "fix": ( - "Retry with repo_path=<repo root> or " - "output_dir=<repowiki directory> to locate the " - "knowledge base. Passing either explicitly is " - "preferred; the server only falls back to its " - "start directory when both are absent." - ), + "fix": "Retry with repo_path=<repo root> to locate the knowledge base.", }, ensure_ascii=False, ), diff --git a/codewiki/mcp/tools/analysis.py b/codewiki/mcp/tools/analysis.py index 26b2e81..8878a21 100644 --- a/codewiki/mcp/tools/analysis.py +++ b/codewiki/mcp/tools/analysis.py @@ -54,16 +54,14 @@ def handle_analyze_repo(arguments: Dict[str, Any], store: SessionStore) -> str: if not repo_path.exists(): return json.dumps({"error": f"Repository not found: {repo_path}"}) - # Layout-aware default output_dir (ticket 04): an explicit argument always - # wins; otherwise a centralized-workspace member repo analyses into the - # workspace knowledge base, everything else keeps <repo>/repowiki. - _od_arg = (arguments.get("output_dir") or "").strip() - if _od_arg: - output_dir = Path(_od_arg).expanduser().resolve() - else: - from codewiki.mcp.tools.workspace_layout import default_output_dir - - output_dir = default_output_dir(repo_path) + # output_dir is a pure function of repo_path under the active layout + # (convergence: no explicit output_dir override on write paths — an + # external process must not steer where this repo's knowledge lands). + # Centralized member repos analyse into the workspace knowledge base; + # everything else keeps <repo>/repowiki. + from codewiki.mcp.tools.workspace_layout import default_output_dir + + output_dir = default_output_dir(repo_path) output_dir.mkdir(parents=True, exist_ok=True) import tempfile @@ -272,10 +270,6 @@ def handle_analyze_repo(arguments: Dict[str, Any], store: SessionStore) -> str: session.analyzed_commit = get_git_commit_hash(repo_path) or None if session.analyzed_commit: cache.set_last_commit_id(session.analyzed_commit) - try: - cache.set_output_dir(str(output_dir)) - except Exception as e: - logger.warning("Failed to persist output_dir to cache: %s", e) # Persist repo_path ↔ output_dir mapping (enables session-free SQLite access) try: @@ -514,10 +508,6 @@ def _build_no_change_response( from codewiki.cli.utils.repo_validator import get_git_commit_hash session.analyzed_commit = get_git_commit_hash(repo_path) or None - try: - cache.set_output_dir(str(output_dir)) - except Exception as e: - logger.warning("Failed to persist output_dir to cache: %s", e) workspace = SessionWorkspace(repo_path, session.session_id) session.workspace = workspace diff --git a/codewiki/mcp/tools/batch_ingest.py b/codewiki/mcp/tools/batch_ingest.py index c413be6..5c9210f 100644 --- a/codewiki/mcp/tools/batch_ingest.py +++ b/codewiki/mcp/tools/batch_ingest.py @@ -60,13 +60,14 @@ def handle_batch_ingest( item["session_id"] = session_id # Inject output_dir into each item if not already set. - # When output_dir is absent, derive it from repo_path (repo_path/repowiki) - # to stay consistent with handle_ingest_note / handle_ingest_source. - top_output_dir = arguments.get("output_dir") - if not top_output_dir: - rp = arguments.get("repo_path") - if rp: - top_output_dir = str(Path(rp).expanduser().resolve() / "repowiki") + # Derived from session/repo_path under the active layout (a caller-supplied + # output_dir is ignored on the write path). + from codewiki.mcp.tools.store_bridge import resolve_output_dir + + try: + top_output_dir = str(resolve_output_dir(session, arguments)) + except ValueError: + top_output_dir = None if top_output_dir: for item in items: if "output_dir" not in item and "session_id" not in item: diff --git a/codewiki/mcp/tools/capture_conversation.py b/codewiki/mcp/tools/capture_conversation.py index 8db9190..2339b6e 100644 --- a/codewiki/mcp/tools/capture_conversation.py +++ b/codewiki/mcp/tools/capture_conversation.py @@ -157,8 +157,7 @@ def _resolve_output_dir( Resolution order: 1. An active session's ``output_dir`` (a fully-resolved repowiki path). - 2. An explicit ``output_dir`` argument. - 3. ``repo_path``/repowiki fallback. + 2. ``repo_path``-derived output directory (layout-aware ``default_output_dir``). Thin re-export of the unified bridge — kept here for the modules (task_manager) and tests that import it from this module. @@ -334,7 +333,7 @@ def handle_capture_conversation( Arguments: - session_id (optional): active session id. - - output_dir / repo_path (optional): repowiki resolution fallback. + - repo_path (optional): repository root; repowiki is derived from it. - conversation (required): list of turns or {"turns": [...]} object. - link_to (optional): wiki object id/title this conversation relates to. - source_session_id (optional): the IDE-side session id (e.g. CodeBuddy's diff --git a/codewiki/mcp/tools/close_session.py b/codewiki/mcp/tools/close_session.py index 02816d5..fd26bb7 100644 --- a/codewiki/mcp/tools/close_session.py +++ b/codewiki/mcp/tools/close_session.py @@ -52,11 +52,7 @@ "properties": { "repo_path": { "type": "string", - "description": "Repository path. output_dir is resolved from the session or cache, falling back to repo_path/repowiki.", - }, - "output_dir": { - "type": "string", - "description": "Optional. Documentation output directory; overrides the session/cache-resolved value.", + "description": "Repository path. Output directory is derived from it (repo_path/repowiki).", }, "force": { "type": "boolean", @@ -170,18 +166,11 @@ def handle_close_session(arguments: dict, store: "SessionStore") -> str: # already-closed session this restores it from cache so the rebuild can run. session = store.find_or_restore(rp) - # Resolve output_dir: explicit arg > session > convention - od_arg = arguments.get("output_dir") - if od_arg: - output_dir = str(Path(_resolve_path(od_arg))) - elif session is not None and session.output_dir: - output_dir = session.output_dir - else: - # Layout-aware (ticket 07): centralized members close into the - # workspace knowledge base; everything else keeps <repo>/repowiki. - from codewiki.mcp.tools.workspace_layout import default_output_dir + # Resolve output_dir: a pure function of repo_path under the active layout; + # a caller-supplied output_dir is ignored on the write path (retired param). + from codewiki.mcp.tools.store_bridge import resolve_output_dir - output_dir = str(default_output_dir(rp)) + output_dir = str(resolve_output_dir(session, arguments)) # Determine if docs were written docs_generated = False diff --git a/codewiki/mcp/tools/cross_service.py b/codewiki/mcp/tools/cross_service.py index 9cf8444..03ad7f0 100644 --- a/codewiki/mcp/tools/cross_service.py +++ b/codewiki/mcp/tools/cross_service.py @@ -49,24 +49,20 @@ def handle_query_cross_service( ensure_ascii=False, ) - # Resolve meta directory: explicit output_dir first, then auto-derive - explicit_od = arguments.get("output_dir") - if explicit_od: - meta_dir = Path(explicit_od).expanduser().resolve() / ".meta" - else: - meta_dir = workspace_path / "repowiki" / ".meta" - if not meta_dir.exists(): - # Legacy: pre-unification analyze_workspace output location - meta_dir = workspace_path / "workspace-wiki" / ".meta" - if not meta_dir.exists(): - # Broader search: try common workspace subdirs - for candidate in workspace_path.iterdir(): - if not candidate.is_dir() or candidate.name.startswith("."): - continue - test_dir = candidate / ".meta" - if test_dir.exists() and (test_dir / "cross_service_links.json").exists(): - meta_dir = test_dir - break + # Resolve meta directory from workspace_path (output_dir retired) + meta_dir = workspace_path / "repowiki" / ".meta" + if not meta_dir.exists(): + # Legacy: pre-unification analyze_workspace output location + meta_dir = workspace_path / "workspace-wiki" / ".meta" + if not meta_dir.exists(): + # Broader search: try common workspace subdirs + for candidate in workspace_path.iterdir(): + if not candidate.is_dir() or candidate.name.startswith("."): + continue + test_dir = candidate / ".meta" + if test_dir.exists() and (test_dir / "cross_service_links.json").exists(): + meta_dir = test_dir + break links_path = meta_dir / "cross_service_links.json" routes_path = meta_dir / "workspace_routes.json" diff --git a/codewiki/mcp/tools/distill_conversation.py b/codewiki/mcp/tools/distill_conversation.py index 789b074..c7cf8ef 100644 --- a/codewiki/mcp/tools/distill_conversation.py +++ b/codewiki/mcp/tools/distill_conversation.py @@ -1100,8 +1100,14 @@ def _process_llm_output( ) continue + # Write delegation: ingest_note is a write tool and no longer accepts + # the retired output_dir parameter — it derives the KB from repo_path. + # resolve_output_dir returned output_dir as default_output_dir(x) where + # x is either the repo itself (colocated: x/repowiki) or the centralized + # workspace root; in both cases output_dir.parent is a root that + # re-derives the same directory, so it is a faithful repo_path. ingest_args = { - "output_dir": str(output_dir), + "repo_path": str(output_dir.parent), "title": title, "note_type": note_type, "content": content, diff --git a/codewiki/mcp/tools/doc_writer.py b/codewiki/mcp/tools/doc_writer.py index c4a4e2c..a609a7e 100644 --- a/codewiki/mcp/tools/doc_writer.py +++ b/codewiki/mcp/tools/doc_writer.py @@ -1305,7 +1305,6 @@ async def handle_write_doc_file( ) -> str: """Create a new documentation file in the output directory.""" # Resolve output directory from output_dir or repo_path (schema contract) - od = arguments.get("output_dir") rp = arguments.get("repo_path") repo_path = None if rp: @@ -1320,17 +1319,14 @@ async def handle_write_doc_file( session = resolve_session(arguments, store) - if od: - output_dir = Path(od).expanduser().resolve() - elif session: - # Prefer the session's output_dir (honours custom output_dir from analyze_repo) - output_dir = Path(session.output_dir).expanduser().resolve() - elif repo_path: - from codewiki.mcp.tools.workspace_layout import default_output_dir + # output_dir is a pure function of repo_path under the active layout; a + # caller-supplied output_dir is ignored on the write path (retired param). + from codewiki.mcp.tools.store_bridge import resolve_output_dir - output_dir = default_output_dir(repo_path) - else: - return json.dumps({"error": "output_dir or repo_path is required."}) + try: + output_dir = resolve_output_dir(session, arguments) + except ValueError as e: + return json.dumps({"error": str(e)}) if session and repo_path is None: repo_path = session.repo_path @@ -1613,7 +1609,6 @@ async def handle_edit_doc_file( ) -> str: """Edit an existing documentation file (str_replace, insert, or undo).""" # Resolve output directory from output_dir or repo_path - od = arguments.get("output_dir") rp = arguments.get("repo_path") repo_path = None if rp: @@ -1628,15 +1623,14 @@ async def handle_edit_doc_file( session = resolve_session(arguments, store) - if od: - output_dir = str(Path(od).expanduser().resolve()) - elif session: - # Prefer the session's output_dir (honours custom output_dir from analyze_repo) - output_dir = str(Path(session.output_dir).expanduser().resolve()) - elif repo_path: - output_dir = str(Path(repo_path) / "repowiki") - else: - return json.dumps({"error": "output_dir or repo_path is required."}) + # output_dir is a pure function of repo_path under the active layout; a + # caller-supplied output_dir is ignored on the write path (retired param). + from codewiki.mcp.tools.store_bridge import resolve_output_dir + + try: + output_dir = str(resolve_output_dir(session, arguments)) + except ValueError as e: + return json.dumps({"error": str(e)}) if session and repo_path is None: repo_path = session.repo_path diff --git a/codewiki/mcp/tools/doctrine.py b/codewiki/mcp/tools/doctrine.py index ca3594f..82edb6c 100644 --- a/codewiki/mcp/tools/doctrine.py +++ b/codewiki/mcp/tools/doctrine.py @@ -176,16 +176,12 @@ def handle_refresh_doctrine(arguments: Dict[str, Any], store: Any) -> str: if session is None and session_id: return json.dumps({"error": f"Session {session_id} not found or expired."}) - if session: - output_dir = Path(session.output_dir).expanduser().resolve() - elif arguments.get("output_dir"): - output_dir = Path(arguments["output_dir"]).expanduser().resolve() - elif arguments.get("repo_path"): - output_dir = Path(arguments["repo_path"]).expanduser().resolve() / "repowiki" - else: - return json.dumps( - {"error": "output_dir or repo_path is required (or pass an active session)."} - ) + from codewiki.mcp.tools.store_bridge import resolve_output_dir + + try: + output_dir = resolve_output_dir(session, arguments) + except ValueError as e: + return json.dumps({"error": str(e)}) mode = str(arguments.get("mode") or "prepare").lower() if mode not in ("prepare", "submit"): diff --git a/codewiki/mcp/tools/evidence.py b/codewiki/mcp/tools/evidence.py index a58840a..12417a0 100644 --- a/codewiki/mcp/tools/evidence.py +++ b/codewiki/mcp/tools/evidence.py @@ -82,7 +82,6 @@ def _resolve_targets( """Resolve (output_dir, repo_root) following the write_doc_file convention.""" from codewiki.mcp.tools.workspace_result import resolve_session - od = arguments.get("output_dir") rp = arguments.get("repo_path") repo_path: Optional[Path] = None @@ -92,15 +91,11 @@ def _resolve_targets( session = resolve_session(arguments, store) - if od: - output_dir = Path(od).expanduser().resolve() - elif session: - output_dir = Path(session.output_dir).expanduser().resolve() - elif repo_path: - from codewiki.mcp.tools.workspace_layout import default_output_dir + from codewiki.mcp.tools.store_bridge import resolve_output_dir - output_dir = default_output_dir(repo_path) - else: + try: + output_dir = resolve_output_dir(session, arguments) + except ValueError: return None, None if repo_path is None and session is not None and session.repo_path: diff --git a/codewiki/mcp/tools/init_wiki.py b/codewiki/mcp/tools/init_wiki.py index 14c96db..101182e 100644 --- a/codewiki/mcp/tools/init_wiki.py +++ b/codewiki/mcp/tools/init_wiki.py @@ -130,7 +130,6 @@ def handle_init_wiki(arguments: dict) -> str: JSON string with created paths and status. """ repo_path = arguments.get("repo_path", "").strip() - output_dir = arguments.get("output_dir", "").strip() # Resolve repo_path if not repo_path: @@ -172,20 +171,11 @@ def handle_init_wiki(arguments: dict) -> str: except Exception as e: # pragma: no cover - routing must never break init logger.warning("centralized-layout routing skipped: %s", e) - # Resolve output_dir — the default routes to the workspace corpus under - # centralized; an explicit output_dir is the caller's directory-level - # choice and is never hijacked (same semantics as routing_for_write). - if not output_dir: - if _centralized: - from codewiki.mcp.tools.workspace_layout import default_output_dir + # output_dir is a pure function of repo_path under the active layout; a + # caller-supplied output_dir is ignored on the write path (retired param). + from codewiki.mcp.tools.workspace_layout import default_output_dir - output_dir_p = default_output_dir(repo_path_p) - else: - output_dir_p = repo_path_p / "repowiki" - elif os.path.isabs(output_dir): - output_dir_p = Path(output_dir).resolve() - else: - output_dir_p = (repo_path_p / output_dir).resolve() + output_dir_p = default_output_dir(repo_path_p) results: dict = { "repo_path": str(repo_path_p), @@ -291,7 +281,7 @@ def handle_init_wiki(arguments: dict) -> str: "left untouched; no AGENTS.md block was injected into the business " "repo. Next: run analyze_repo on this repo (modules route to " "wiki/modules/<repo>/), or use ingest_note/query_wiki with " - "output_dir=<workspace repowiki> and repo=<name> filtering." + "repo_path=<workspace root> and repo=<name> filtering." ) else: results["next_steps"] = ( diff --git a/codewiki/mcp/tools/issue_tracker.py b/codewiki/mcp/tools/issue_tracker.py index 1a07296..4a0702a 100644 --- a/codewiki/mcp/tools/issue_tracker.py +++ b/codewiki/mcp/tools/issue_tracker.py @@ -73,24 +73,22 @@ def handle_flag_issue( by a stable hash of (issue_type, page_path) so duplicate flags are idempotent (the timestamp is updated but the ID stays the same). """ - # Resolve output directory. Location is unified on ``output_dir``; when - # it is omitted we fall back to the active session directory so the tool - # still works inside a live MCP session. The ``session_id`` parameter was - # removed: it only duplicated output_dir resolution and was never stored - # on the issue record. - output_dir = arguments.get("output_dir") - if not output_dir and store is not None: + # Resolve output directory from repo_path under the active layout, with a + # fallback to the most-recently-accessed active session. A caller-supplied + # ``output_dir`` is ignored on the write path (retired param). + rp = arguments.get("repo_path") + if rp: + from codewiki.mcp.tools.workspace_layout import default_output_dir + + output_dir = default_output_dir(Path(rp).expanduser().resolve()) + else: # Fall back to the most recently accessed active session's output_dir sessions = getattr(store, "_sessions", None) or {} if sessions: latest = max(sessions.values(), key=lambda s: s.last_accessed) output_dir = latest.output_dir - if not output_dir: - rp = arguments.get("repo_path") - if rp: - output_dir = str(Path(rp).expanduser().resolve() / "repowiki") else: - return json.dumps({"error": "output_dir is required (or pass repo_path to derive it)."}) + return json.dumps({"error": "repo_path is required (or pass an active session)."}) output_dir = Path(output_dir).expanduser().resolve() # Validate inputs diff --git a/codewiki/mcp/tools/legacy_tools.py b/codewiki/mcp/tools/legacy_tools.py index 15a653b..3846de4 100644 --- a/codewiki/mcp/tools/legacy_tools.py +++ b/codewiki/mcp/tools/legacy_tools.py @@ -36,11 +36,6 @@ "type": "string", "description": "Absolute path to the repository to document", }, - "output_dir": { - "type": "string", - "description": "Output directory for generated docs (default: ./repowiki)", - "default": "repowiki", - }, "doc_type": { "type": "string", "description": "Type of documentation to generate. Valid values defined in schema.yaml doc_types.types (default: design)", @@ -73,11 +68,6 @@ "type": "string", "description": "Absolute path to the repository", }, - "output_dir": { - "type": "string", - "description": "Directory containing generated docs (default: ./repowiki)", - "default": "repowiki", - }, }, "required": ["repo_path"], }, @@ -108,8 +98,9 @@ def _load_config(): async def handle_generate_docs(arguments: dict) -> str: """Legacy generate_docs — requires CodeWiki LLM configuration.""" repo_path = Path(arguments["repo_path"]).expanduser().resolve() - raw_od = Path(arguments.get("output_dir", "repowiki")).expanduser() - output_dir = raw_od.resolve() if raw_od.is_absolute() else (repo_path / raw_od).resolve() + from codewiki.mcp.tools.workspace_layout import default_output_dir + + output_dir = default_output_dir(repo_path).resolve() if not repo_path.exists(): return json.dumps({"error": f"Repository not found: {repo_path}"}) @@ -181,8 +172,9 @@ async def handle_generate_docs(arguments: dict) -> str: async def handle_get_module_tree(arguments: dict, store=None) -> str: """Legacy get_module_tree.""" repo_path = Path(arguments["repo_path"]).expanduser().resolve() - raw_od = Path(arguments.get("output_dir", "repowiki")).expanduser() - output_dir = raw_od.resolve() if raw_od.is_absolute() else (repo_path / raw_od).resolve() + from codewiki.mcp.tools.workspace_layout import default_output_dir + + output_dir = default_output_dir(repo_path).resolve() from codewiki.src.config import MODULE_TREE_FILENAME, meta_resolve diff --git a/codewiki/mcp/tools/module_tree.py b/codewiki/mcp/tools/module_tree.py index a4f0b67..a6baf24 100644 --- a/codewiki/mcp/tools/module_tree.py +++ b/codewiki/mcp/tools/module_tree.py @@ -235,18 +235,11 @@ def handle_save_module_tree( # Try to reuse active session for workspace/caching, fall back to standalone session = resolve_session(arguments, store) - # Respect explicit output_dir from arguments, then session, then default - explicit_od = arguments.get("output_dir") - if explicit_od: - output_dir = ( - str(_Path(explicit_od).expanduser().resolve()) - if _Path(explicit_od).is_absolute() - else str((_Path(rp) / explicit_od).expanduser().resolve()) - ) - elif session is not None and session.output_dir: - output_dir = session.output_dir - else: - output_dir = str(_Path(rp) / "repowiki") + # output_dir is a pure function of repo_path under the active layout; a + # caller-supplied output_dir is ignored on the write path (retired param). + from codewiki.mcp.tools.store_bridge import resolve_output_dir + + output_dir = str(resolve_output_dir(session, arguments)) workspace = ( session.workspace if session is not None else SessionWorkspace(_Path(rp), "standalone") @@ -277,10 +270,12 @@ def handle_get_processing_order( ) session = resolve_session(arguments, store) + from codewiki.mcp.tools.workspace_layout import default_output_dir + output_dir = ( session.output_dir if session is not None and session.output_dir - else str(_Path(rp) / "repowiki") + else str(default_output_dir(_Path(rp).expanduser().resolve())) ) workspace = ( session.workspace if session is not None else SessionWorkspace(_Path(rp), "standalone") diff --git a/codewiki/mcp/tools/note_ingest.py b/codewiki/mcp/tools/note_ingest.py index 57e0544..f18103e 100644 --- a/codewiki/mcp/tools/note_ingest.py +++ b/codewiki/mcp/tools/note_ingest.py @@ -215,20 +215,14 @@ def handle_ingest_note( session = resolve_session(arguments, store) - # Resolve output directory - od = arguments.get("output_dir") - if od: - output_dir = Path(od).expanduser().resolve() - elif session: - output_dir = Path(session.output_dir).expanduser().resolve() - else: - rp = arguments.get("repo_path") - if rp: - from codewiki.mcp.tools.workspace_layout import default_output_dir - - output_dir = default_output_dir(rp) - else: - return json.dumps({"error": "output_dir is required (or pass repo_path to derive it)."}) + # Resolve output directory: a pure function of repo_path under the active + # layout; a caller-supplied output_dir is ignored on the write path. + from codewiki.mcp.tools.store_bridge import resolve_output_dir + + try: + output_dir = resolve_output_dir(session, arguments) + except ValueError as e: + return json.dumps({"error": str(e)}) # Layout-aware provenance (ticket 04): notes ingested from a centralized # member repo are shared-pool knowledge and carry a repo: source tag. diff --git a/codewiki/mcp/tools/note_lifecycle.py b/codewiki/mcp/tools/note_lifecycle.py index e50c26d..1bf1990 100644 --- a/codewiki/mcp/tools/note_lifecycle.py +++ b/codewiki/mcp/tools/note_lifecycle.py @@ -63,19 +63,18 @@ def handle_confirm_note(arguments: Dict[str, Any], store: SessionStore) -> str: from codewiki.mcp.tools.workspace_result import resolve_session session = resolve_session(arguments, store) - od = arguments.get("output_dir") rp = arguments.get("repo_path") - if od: - output_dir = Path(od).expanduser().resolve() - elif rp: + if rp: # Prefer repo_path derivation over the restored session's cached - # output_dir: find_or_restore() may return a stale/incorrect path that - # does not match where notes were actually written. - output_dir = Path(rp).expanduser().resolve() / "repowiki" + # output_dir (layout-aware; keeps cross-repo confirms correct). + # A caller-supplied output_dir is ignored on the write path. + from codewiki.mcp.tools.workspace_layout import default_output_dir + + output_dir = default_output_dir(Path(rp).expanduser().resolve()) elif session: output_dir = Path(session.output_dir).expanduser().resolve() else: - return json.dumps({"error": "output_dir is required (or pass repo_path to derive it)."}) + return json.dumps({"error": "repo_path is required (or pass an active session)."}) note_file = arguments.get("note_file", "") if not note_file: @@ -96,19 +95,18 @@ def handle_reject_note(arguments: Dict[str, Any], store: SessionStore) -> str: from codewiki.mcp.tools.workspace_result import resolve_session session = resolve_session(arguments, store) - od = arguments.get("output_dir") rp = arguments.get("repo_path") - if od: - output_dir = Path(od).expanduser().resolve() - elif rp: + if rp: # Prefer repo_path derivation over the restored session's cached - # output_dir: find_or_restore() may return a stale/incorrect path that - # does not match where notes were actually written. - output_dir = Path(rp).expanduser().resolve() / "repowiki" + # output_dir (layout-aware; keeps cross-repo confirms correct). + # A caller-supplied output_dir is ignored on the write path. + from codewiki.mcp.tools.workspace_layout import default_output_dir + + output_dir = default_output_dir(Path(rp).expanduser().resolve()) elif session: output_dir = Path(session.output_dir).expanduser().resolve() else: - return json.dumps({"error": "output_dir is required (or pass repo_path to derive it)."}) + return json.dumps({"error": "repo_path is required (or pass an active session)."}) note_file = arguments.get("note_file", "") if not note_file: @@ -179,16 +177,15 @@ def handle_batch_set_status(arguments: Dict[str, Any], store: SessionStore) -> s from codewiki.mcp.tools.workspace_result import resolve_session session = resolve_session(arguments, store) - od = arguments.get("output_dir") rp = arguments.get("repo_path") - if od: - output_dir = Path(od).expanduser().resolve() - elif rp: - output_dir = Path(rp).expanduser().resolve() / "repowiki" + if rp: + from codewiki.mcp.tools.workspace_layout import default_output_dir + + output_dir = default_output_dir(Path(rp).expanduser().resolve()) elif session: output_dir = Path(session.output_dir).expanduser().resolve() else: - return json.dumps({"error": "output_dir is required (or pass repo_path to derive it)."}) + return json.dumps({"error": "repo_path is required (or pass an active session)."}) target = arguments.get("status", "stable") or "stable" scope = (arguments.get("scope", "all") or "all").lower() # all | wiki | notes diff --git a/codewiki/mcp/tools/note_query.py b/codewiki/mcp/tools/note_query.py index def47f2..e0bd4bf 100644 --- a/codewiki/mcp/tools/note_query.py +++ b/codewiki/mcp/tools/note_query.py @@ -788,11 +788,8 @@ def handle_query_wiki( session = resolve_session(arguments, store) - # Resolve output directory - od = arguments.get("output_dir") - if od: - output_dir = Path(od).expanduser().resolve() - elif session: + # Resolve output directory from session or repo_path + if session: output_dir = Path(session.output_dir).expanduser().resolve() else: # Fallback: derive from repo_path if available. Layout-aware diff --git a/codewiki/mcp/tools/prompt_server.py b/codewiki/mcp/tools/prompt_server.py index 6f74365..2e07c85 100644 --- a/codewiki/mcp/tools/prompt_server.py +++ b/codewiki/mcp/tools/prompt_server.py @@ -372,26 +372,21 @@ def handle_get_prompt( prompt_type = arguments["prompt_type"] variables = arguments.get("variables", {}) # Bundle locators, most-direct first: - # output_dir → points straight at the bundle holding schema.yaml - # repo_path → derives <repo>/repowiki (also enables workspace writes) + # repo_path → derives the bundle dir under the active layout (also + # enables workspace writes) # session_id → resolves via the active session's output_dir - output_dir_arg = arguments.get("output_dir") repo_path = arguments.get("repo_path") - if output_dir_arg: + if repo_path: from pathlib import Path - output_dir = str(Path(output_dir_arg).expanduser().resolve()) - # No session/workspace here; large prompts stay inline. - session = None - elif repo_path: - from pathlib import Path + from codewiki.mcp.tools.workspace_layout import default_output_dir rp = ( str(Path(repo_path).expanduser().resolve()) if Path(repo_path).is_absolute() else str((Path.cwd() / repo_path).expanduser().resolve()) ) - output_dir = str(Path(rp) / "repowiki") + output_dir = str(default_output_dir(Path(rp).expanduser().resolve())) # Try to find active session for workspace access session = store.find_or_restore(rp) # Create a lightweight workspace for large prompt writing if no active session diff --git a/codewiki/mcp/tools/review_checklist.py b/codewiki/mcp/tools/review_checklist.py index 9f6c6a3..c34e958 100644 --- a/codewiki/mcp/tools/review_checklist.py +++ b/codewiki/mcp/tools/review_checklist.py @@ -186,7 +186,9 @@ def load_project_checklist(repo_path: Optional[str]) -> Optional[Dict[str, List[ """ if not repo_path: return None - p = Path(repo_path) / "repowiki" / "review_checklist.yaml" + from codewiki.mcp.tools.workspace_layout import default_output_dir + + p = default_output_dir(Path(repo_path)) / "review_checklist.yaml" if not p.exists(): return None try: diff --git a/codewiki/mcp/tools/store_bridge.py b/codewiki/mcp/tools/store_bridge.py index 49cf3b0..d7d8dca 100644 --- a/codewiki/mcp/tools/store_bridge.py +++ b/codewiki/mcp/tools/store_bridge.py @@ -13,48 +13,74 @@ 1. An active session's ``output_dir`` (already fully resolved at session creation time, including centralized-workspace routing). -2. An explicit ``output_dir`` argument. -3. ``repo_path`` → ``workspace_layout.default_output_dir`` (layout-aware: +2. ``repo_path`` → ``workspace_layout.default_output_dir`` (layout-aware: centralized members route to the workspace-root shared corpus, everything else keeps ``<repo>/repowiki``). -Raises ``ValueError`` when none of the three is available — same contract the -old per-tool copies had, so handler error paths behave identically. +Raises ``ValueError`` when neither a session nor ``repo_path`` is available — +the retired ``output_dir`` parameter is ignored (with a warning) on all paths. """ from __future__ import annotations +import logging from pathlib import Path from typing import Any, Dict, List, Optional from codewiki.mcp.session import SessionState from codewiki.src.store import KnowledgeStore +logger = logging.getLogger(__name__) + def resolve_output_dir( session: Optional[SessionState], arguments: Dict[str, Any], ) -> Path: - """Resolve the repowiki output directory for this invocation.""" + """Resolve the knowledge-base directory for this invocation. + + output_dir is a pure function of repo_path under the active layout + (:func:`workspace_layout.default_output_dir`) and is never persisted — an + external invocation cannot steer where a repo's knowledge lands. + + An active session's ``output_dir`` (already layout-derived at session + creation) wins; otherwise ``repo_path`` derives the layout-aware directory. + A caller-supplied explicit ``output_dir`` that differs from the derivation + is ignored with a warning — a compat shim for callers that still send the + retired parameter. + """ if session is not None: return Path(session.output_dir).expanduser().resolve() od = arguments.get("output_dir") - if od: - return Path(od).expanduser().resolve() rp = arguments.get("repo_path") if rp: - # Layout-aware: centralized members write into the workspace-root - # shared corpus; single repos keep <repo>/repowiki. from codewiki.mcp.tools.workspace_layout import default_output_dir - return default_output_dir(Path(rp).expanduser().resolve()) + derived = default_output_dir(Path(rp).expanduser().resolve()) + if od: + _warn_ignored_output_dir(rp, od, derived) + return derived raise ValueError( - "output_dir or repo_path is required (or pass an active session). " - "Provide repo_path=<repo root> or output_dir=<repowiki directory> to " - "locate the knowledge base." + "repo_path is required (or pass an active session). " + "Provide repo_path=<repo root> to locate the knowledge base." ) +def _warn_ignored_output_dir(rp: str, od: Any, derived: Path) -> None: + """Warn once per invocation when a write call still sends output_dir.""" + from codewiki.mcp.tools.workspace_layout import is_foreign_output_dir + + if is_foreign_output_dir(rp, od) is not None: + logger.warning( + "resolve_output_dir: ignoring explicit output_dir=%r on a write " + "path; layout derives %s from repo_path=%r (output_dir retired " + "on write tools)", + od, + derived, + rp, + ) + + def store_for( session: Optional[SessionState], arguments: Dict[str, Any], diff --git a/codewiki/mcp/tools/wiki_lint.py b/codewiki/mcp/tools/wiki_lint.py index 38ebc17..fac74e2 100644 --- a/codewiki/mcp/tools/wiki_lint.py +++ b/codewiki/mcp/tools/wiki_lint.py @@ -225,14 +225,10 @@ def _collect_linked_targets( def _get_output_dir(session: Optional[SessionState], arguments: Dict) -> Optional[Path]: - """Resolve the output directory from session or arguments.""" + """Resolve the output directory from session or repo_path (a + caller-supplied output_dir is ignored on the write path).""" if session: return Path(session.output_dir).expanduser().resolve() - output_dir = arguments.get("output_dir") - if output_dir: - p = Path(output_dir).expanduser().resolve() - p.mkdir(parents=True, exist_ok=True) - return p # Fallback: derive from repo_path (layout-aware, ticket 09: centralized # members lint the workspace knowledge base). rp = arguments.get("repo_path") diff --git a/codewiki/mcp/tools/wiki_search.py b/codewiki/mcp/tools/wiki_search.py index dbedffd..3418cfe 100644 --- a/codewiki/mcp/tools/wiki_search.py +++ b/codewiki/mcp/tools/wiki_search.py @@ -303,6 +303,29 @@ def _extract_title(ct): return None +def _cache_serves_output_dir(session, output_dir: Path) -> bool: + """True when the session's shared cache DB is the one *output_dir* maps to. + + Cross-repo pollution guard: a session cache is bound to its repo root DB + (``<repo>/.codewiki/analysis_cache.db``). Routing an output_dir that maps + to a DIFFERENT DB through that cache would rebuild/update the repo index + with the foreign wiki's content (a smoke/harness run pointing output_dir + at a temp directory does exactly that). Callers fall through to the + standalone path when this returns False — there the DB is resolved from + the output_dir itself, so the repo cache is never touched. + """ + cache = getattr(session, "cache", None) + if cache is None: + return False + expected = _resolve_db_path(output_dir) + if expected is None: + return False + try: + return Path(cache.db_path).resolve() == Path(expected).resolve() + except (ValueError, OSError): + return False + + # ---- Public API ---- @@ -318,8 +341,8 @@ def build_full_index(output_dir, session=None): return {"docs_indexed": 0, "notes_indexed": 0, "total_tokens": 0} with _build_lock: - # Try SQLite cache first (active session) - if session is not None and getattr(session, "cache", None) is not None: + # Try SQLite cache first (active session owning this output_dir) + if session is not None and _cache_serves_output_dir(session, od): try: return session.cache.build_search_index(od) except Exception as e: @@ -426,7 +449,7 @@ def update_file(output_dir, filepath, session=None): """Incrementally update search index for a single file.""" od = Path(output_dir) fp = Path(filepath) - if session is not None and getattr(session, "cache", None) is not None: + if session is not None and _cache_serves_output_dir(session, od): try: session.cache.update_search_doc(od, fp) return @@ -575,8 +598,8 @@ def search( # reuses its shared AnalysisCache connection). See _ensure_index. _ensure_index(od, session=session) - # Try SQLite cache first (active session) - if session is not None and getattr(session, "cache", None) is not None: + # Try SQLite cache first (active session owning this output_dir) + if session is not None and _cache_serves_output_dir(session, od): try: return session.cache.search( query, @@ -769,7 +792,7 @@ def _df_sqlite(tok, conn): conn = None _standalone = None - if session is not None and getattr(session, "cache", None) is not None: + if session is not None and _cache_serves_output_dir(session, od): try: conn = session.cache.conn except Exception: diff --git a/codewiki/mcp/tools/wiki_stats.py b/codewiki/mcp/tools/wiki_stats.py index 9df1ffc..32b9695 100644 --- a/codewiki/mcp/tools/wiki_stats.py +++ b/codewiki/mcp/tools/wiki_stats.py @@ -49,17 +49,16 @@ def handle_wiki_stats( session = resolve_session(arguments, store) - od = arguments.get("output_dir") - if od: - output_dir = Path(od).expanduser().resolve() - elif session: + if session: output_dir = Path(session.output_dir).expanduser().resolve() else: rp = arguments.get("repo_path") if rp: - output_dir = Path(rp).expanduser().resolve() / "repowiki" + from codewiki.mcp.tools.workspace_layout import default_output_dir + + output_dir = default_output_dir(Path(rp).expanduser().resolve()) else: - return json.dumps({"error": "output_dir is required (or pass repo_path to derive it)."}) + return json.dumps({"error": "repo_path is required (or pass an active session)."}) from codewiki.mcp.tools import telemetry diff --git a/codewiki/mcp/tools/workspace_analyzer.py b/codewiki/mcp/tools/workspace_analyzer.py index 0289826..db13db3 100644 --- a/codewiki/mcp/tools/workspace_analyzer.py +++ b/codewiki/mcp/tools/workspace_analyzer.py @@ -448,19 +448,20 @@ def handle_analyze_workspace( if exclude_str: exclude_dirs.update(d.strip() for d in exclude_str.split(",") if d.strip()) - # Output dir for the workspace-level overview (product-level repowiki) - output_dir_arg = arguments.get("output_dir") - if output_dir_arg: - output_dir = Path(output_dir_arg).resolve() - else: - output_dir = workspace_path / "repowiki" + # Output dir for the workspace-level overview (product-level repowiki) is + # a fixed workspace-root convention; output_dir is retired on writes. + output_dir = workspace_path / "repowiki" output_dir.mkdir(parents=True, exist_ok=True) # Layout detection (ticket 08): centralized workspaces keep all knowledge # in the workspace repowiki, so per-repo analysis targets it instead of # the (nonexistent) in-repo repowiki. generate_repo_wikis gates the heavy # per-repo analysis under centralized (default off — topology still runs). - from codewiki.mcp.tools.workspace_layout import LAYOUT_CENTRALIZED, read_layout + from codewiki.mcp.tools.workspace_layout import ( + LAYOUT_CENTRALIZED, + default_output_dir, + read_layout, + ) layout = read_layout(workspace_path) centralized = layout == LAYOUT_CENTRALIZED @@ -490,10 +491,7 @@ def handle_analyze_workspace( for repo_path in repos: # Layout-aware per-repo target (ticket 08): centralized keeps all # knowledge in the workspace repowiki; colocated keeps <repo>/repowiki. - if centralized: - repo_output_dir = output_dir - else: - repo_output_dir = repo_path / "repowiki" + repo_output_dir = default_output_dir(repo_path) # ── Incremental three-tier dispatch ────────────────────────────── # (docs/多仓Harness工作区-Wiki增量更新设计方案.md) diff --git a/codewiki/mcp/tools/workspace_bootstrap.py b/codewiki/mcp/tools/workspace_bootstrap.py index 70596d8..f1639cf 100644 --- a/codewiki/mcp/tools/workspace_bootstrap.py +++ b/codewiki/mcp/tools/workspace_bootstrap.py @@ -321,7 +321,7 @@ def _repo_map_section(name: str, layout: str = LAYOUT_COLOCATED) -> str: "**检索方式**\n" "\n" "```\n" - f"query_wiki(query=<问题>, output_dir=<harness根>/{name}/repowiki)\n" + f"query_wiki(query=<问题>, repo_path=<harness根>/{name})\n" "```\n" ) @@ -510,12 +510,9 @@ def _run_full_skeleton_flow( else: layout = layout_arg or LAYOUT_COLOCATED results["layout"] = layout - if layout == LAYOUT_CENTRALIZED and output_dir_p != workspace_p / "repowiki": - return None, ( - "centralized layout requires the default output_dir <workspace>/repowiki: " - "workspace discovery is anchored at <workspace>/repowiki/.meta/workspace.json, " - "so a custom output_dir would make the layout config invisible to routing." - ) + # output_dir is a pure function of workspace_path (<workspace>/repowiki) + # — a caller-supplied custom directory is retired, so the layout config + # stays anchored at <workspace>/repowiki/.meta/workspace.json by construction. config_path.parent.mkdir(parents=True, exist_ok=True) _write_text( config_path, @@ -636,13 +633,12 @@ def handle_init_workspace(arguments: dict) -> str: later). Advertised parameters (from arguments dict): - output_dir: Product-level repowiki directory - (default: <workspace>/repowiki). layout: ``colocated`` | ``centralized`` — required on FIRST init (ask the user; without it the gate returns ``needs_layout_decision`` and writes nothing); on re-runs the persisted layout wins and a conflicting value is an error (clone-only adoption included). + (output_dir is retired: it always derives to <workspace>/repowiki.) Tolerated but unadvertised: workspace_path: Workspace root (default: current working directory). @@ -666,13 +662,7 @@ def handle_init_workspace(arguments: dict) -> str: return _err(f"invalid layout {layout_arg!r}: expected one of {list(VALID_LAYOUTS)}") name = workspace_p.name - output_dir = (arguments.get("output_dir") or "").strip() - if not output_dir: - output_dir_p = workspace_p / "repowiki" - elif os.path.isabs(output_dir): - output_dir_p = Path(output_dir).resolve() - else: - output_dir_p = (workspace_p / output_dir).resolve() + output_dir_p = workspace_p / "repowiki" results: dict = { "workspace_path": str(workspace_p), @@ -781,7 +771,7 @@ def handle_init_workspace(arguments: dict) -> str: "Workspace initialized. Next: " "1) Register business repos with add_workspace_repo(url=<clone URL>); " "2) For each business repo run init_wiki / analyze_repo with " - "output_dir=<workspace>/<repo>/repowiki to build its repo-level wiki; " + "repo_path=<workspace>/<repo> to build its repo-level wiki; " "3) Run analyze_workspace(workspace_path=<workspace root>) for cross-repo analysis; " "4) On POSIX run: chmod +x bootstrap.sh" ) @@ -919,7 +909,7 @@ def handle_add_workspace_repo(arguments: dict) -> str: else: results["next_steps"] = ( f"Repo {name!r} registered. Next: run init_wiki / analyze_repo with " - f"output_dir=<workspace>/{name}/repowiki, then fill its 业务概述 section in " + f"repo_path=<workspace>/{name}, then fill its 业务概述 section in " "repowiki/wiki/repo-map.md. On POSIX run: chmod +x bootstrap.sh" ) return json.dumps(results, ensure_ascii=False, indent=2) diff --git a/codewiki/mcp/tools/workspace_layout.py b/codewiki/mcp/tools/workspace_layout.py index 4c1a6e0..31b512c 100644 --- a/codewiki/mcp/tools/workspace_layout.py +++ b/codewiki/mcp/tools/workspace_layout.py @@ -160,6 +160,30 @@ def default_output_dir(repo_path: Union[str, Path]) -> Path: return rp / REPOWIKI_DIRNAME +def is_foreign_output_dir( + repo_path: Union[str, Path], output_dir: Union[str, Path, None] +) -> Path | None: + """Resolve a caller-supplied *output_dir* against the layout-derived KB. + + output_dir is a pure function of repo_path (see :func:`default_output_dir`): + write paths derive it and no longer accept overrides. A supplied value that + resolves differently is *foreign* — an external smoke/harness run must not + be able to steer where a repo's knowledge lands — so handlers should ignore + it (and warn) rather than honour it. Returns the foreign absolute path when + it differs from the derived directory, else ``None``. + """ + if not output_dir: + return None + try: + supplied = Path(output_dir).expanduser().resolve() + except OSError: + supplied = None + expected = default_output_dir(repo_path).resolve() + if supplied is not None and supplied != expected: + return supplied + return None + + def is_centralized_corpus(output_dir: Union[str, Path]) -> bool: """True when *output_dir* lies within a centralized workspace's corpus. diff --git a/codewiki/templates/schema.yaml b/codewiki/templates/schema.yaml index a637798..3510bb2 100644 --- a/codewiki/templates/schema.yaml +++ b/codewiki/templates/schema.yaml @@ -1,7 +1,7 @@ # CodeWiki 默认 schema 模板 # 此文件定义与目标仓库语言无关的文档生成默认值。 -# 使用方式:直接拷贝到项目的 output_dir(如 repowiki/schema.yaml),然后按需修改。 -# 首次 analyze_repo 时,本文件的配置会作为初始值写入 output_dir/schema.yaml。 +# 使用方式:直接拷贝到项目的 repowiki 目录(如 repowiki/schema.yaml),然后按需修改。 +# 首次 analyze_repo 时,本文件的配置会作为初始值写入 repowiki/schema.yaml。 # 增量更新时,用户已自定义的字段会被保留,不会被覆盖。 # # 自动推断的字段(不在本文件中): diff --git a/codewiki/templates/workspace/agents-md-workspace.md.tpl b/codewiki/templates/workspace/agents-md-workspace.md.tpl index 0de9970..f5c826b 100644 --- a/codewiki/templates/workspace/agents-md-workspace.md.tpl +++ b/codewiki/templates/workspace/agents-md-workspace.md.tpl @@ -9,7 +9,7 @@ **第一跳(产品级)**:先查本仓 repowiki,获取产品概述、业务仓导航、跨仓约定。 ``` -query_wiki(query=..., output_dir=<harness根>/repowiki) +query_wiki(query=..., repo_path=<harness根>) ``` 导航入口页:`repowiki/wiki/repo-map.md`(各业务仓职责、目录、repowiki 路径一览)。 @@ -17,7 +17,7 @@ query_wiki(query=..., output_dir=<harness根>/repowiki) **第二跳(仓库级)**:命中某个业务仓后,下钻到该业务仓自己的 repowiki 获取模块/实体/笔记等深度知识。 ``` -query_wiki(query=..., output_dir=<harness根>/<业务仓目录>/repowiki) +query_wiki(query=..., repo_path=<harness根>/<业务仓目录>) ``` **跨服务调用关系**:直接对工作区根做多仓分析检索。 diff --git a/codewiki/templates/workspace/readme.md.tpl b/codewiki/templates/workspace/readme.md.tpl index a0ada14..fa2bdf5 100644 --- a/codewiki/templates/workspace/readme.md.tpl +++ b/codewiki/templates/workspace/readme.md.tpl @@ -24,7 +24,7 @@ bootstrap 会把所有业务仓克隆到本仓的子目录。已存在的目录 1. **harness 不入业务仓**:跨仓约定、产品级 repowiki 只存在于本仓;业务仓内部资产归业务仓自己。 2. **提交不打架**:业务仓带自己的 `.git`,本仓 `.gitignore` 显式排除业务目录,业务代码物理上无法被提交进本仓。 3. **分支松耦合**:各业务仓自由选择主线分支或个人开发分支,互不感知、无需同步。 -4. **知识分层检索**:本仓 repowiki 存产品概述、各业务仓业务概述与导航;深度模块知识在各业务仓自己的 repowiki。Agent 检索先查本仓,命中业务仓后用 `query_wiki(output_dir=<业务仓>/repowiki)` 下钻;跨服务调用关系用 `query_cross_service(workspace_path=<本仓根目录>)`。 +4. **知识分层检索**:本仓 repowiki 存产品概述、各业务仓业务概述与导航;深度模块知识在各业务仓自己的 repowiki。Agent 检索先查本仓,命中业务仓后用 `query_wiki(repo_path=<业务仓>)` 下钻;跨服务调用关系用 `query_cross_service(workspace_path=<本仓根目录>)`。 ## 维护约定 diff --git a/codewiki/templates/workspace/repo-map.md.tpl b/codewiki/templates/workspace/repo-map.md.tpl index a11e804..22f4b3e 100644 --- a/codewiki/templates/workspace/repo-map.md.tpl +++ b/codewiki/templates/workspace/repo-map.md.tpl @@ -26,5 +26,5 @@ status: stable **业务概述**:<一段话> -**检索方式**:query_wiki(query=..., output_dir=<harness根>/<目录>/repowiki) +**检索方式**:query_wiki(query=..., repo_path=<harness根>/<目录>) --> diff --git "a/docs/\345\244\232\344\273\223Harness\345\267\245\344\275\234\345\214\272-\347\256\241\347\220\206\346\250\241\345\236\213\344\270\216MCP\345\267\245\345\205\267.md" "b/docs/\345\244\232\344\273\223Harness\345\267\245\344\275\234\345\214\272-\347\256\241\347\220\206\346\250\241\345\236\213\344\270\216MCP\345\267\245\345\205\267.md" index 1c2ec53..70de170 100644 --- "a/docs/\345\244\232\344\273\223Harness\345\267\245\344\275\234\345\214\272-\347\256\241\347\220\206\346\250\241\345\236\213\344\270\216MCP\345\267\245\345\205\267.md" +++ "b/docs/\345\244\232\344\273\223Harness\345\267\245\344\275\234\345\214\272-\347\256\241\347\220\206\346\250\241\345\236\213\344\270\216MCP\345\267\245\345\205\267.md" @@ -108,7 +108,6 @@ CodeWiki v5.5.0 为这个模型提供了三个开箱即用的 MCP 工具与配 | 参数 | 必填 | 默认 | 说明 | |------|------|------|------| -| `output_dir` | 否 | `<workspace>/repowiki` | 产品级 repowiki 目录 | | `layout` | 首次初始化必传 | — | `colocated`(各业务仓自带 repowiki,两跳检索)或 `centralized`(知识集中于工作区 repowiki,一跳检索)。首次初始化前须征询用户;重跑可省略(自动沿用持久化布局),传冲突值报错 | **产物**: @@ -180,10 +179,10 @@ MCP Server 内置三个 Prompt(IDE Prompt 面板可直接触发): 3. 对每个业务仓调用 add_workspace_repo(url=...) # 登记 + 克隆 4. 对每个业务仓调用 init_wiki / analyze_repo # 建仓库级 Wiki 5. 调用 analyze_workspace(workspace_path=...) # 跨仓分析 → repowiki/overview.md -6. 日常检索: - - query_wiki(output_dir=<harness根>/repowiki) # 第一跳:产品级 - - query_wiki(output_dir=<harness根>/<业务仓>/repowiki) # 第二跳:仓库级 - - query_cross_service(workspace_path=<harness根>) # 跨服务调用 +6. 日常检索(输出目录由 `repo_path` 经布局自动推导,不再传 `output_dir`): + - query_wiki(repo_path=<harness根>) # 第一跳:产品级(colocated:harness 根自带 repowiki) + - query_wiki(repo_path=<harness根>/<业务仓>) # 第二跳:仓库级(colocated:各仓自有 repowiki;centralized:注册成员自动路由到工作区 repowiki) + - query_cross_service(workspace_path=<harness根>) # 跨服务调用 7. 移除业务仓时调用 remove_workspace_repo(name=...) 8. 增量同步(代码变更后):直接重跑 analyze_workspace,按返回的 per-repo `mode` 分派—— - `skipped`:未变更仓,不碰; diff --git "a/docs/\345\244\232\344\273\223Harness\345\267\245\344\275\234\345\214\272-\351\233\206\344\270\255\345\274\217Wiki\345\270\203\345\261\200\350\256\276\350\256\241\346\226\271\346\241\210.md" "b/docs/\345\244\232\344\273\223Harness\345\267\245\344\275\234\345\214\272-\351\233\206\344\270\255\345\274\217Wiki\345\270\203\345\261\200\350\256\276\350\256\241\346\226\271\346\241\210.md" index 5d97395..586499b 100644 --- "a/docs/\345\244\232\344\273\223Harness\345\267\245\344\275\234\345\214\272-\351\233\206\344\270\255\345\274\217Wiki\345\270\203\345\261\200\350\256\276\350\256\241\346\226\271\346\241\210.md" +++ "b/docs/\345\244\232\344\273\223Harness\345\267\245\344\275\234\345\214\272-\351\233\206\344\270\255\345\274\217Wiki\345\270\203\345\261\200\350\256\276\350\256\241\346\226\271\346\241\210.md" @@ -130,7 +130,7 @@ CodeWiki-Plus-Harness/ ← harness 主仓库(独立 git) | `query_wiki` | 一跳检索整个 `repowiki/`;新增可选 `repo=<名>` 过滤,收敛到"适用于该仓的知识"=该仓分区 + 带该仓标的共享项 + 全局项(§7.1) | | `query_cross_service` | 仍读 `.meta/`,不变 | | `ingest_note` / `write_doc_file` | 按页型路由:`module` → 仓分区,其余 → 共享池(带来源标) | -| `capture_conversation` / `distill_conversation` / `task_manager` | `output_dir` 解析到工作区根,`tasks`/`raw`/`conversations` 共享(§8) | +| `capture_conversation` / `distill_conversation` / `task_manager` | `repo_path` 经布局推导到工作区根,`tasks`/`raw`/`conversations` 共享(§8) | | `lint_wiki` | 新增 layout-violation 检查(§12.6) | ## 7. 检索路由 @@ -138,7 +138,9 @@ CodeWiki-Plus-Harness/ ← harness 主仓库(独立 git) - **`colocated`**:两跳,完全同《管理模型》现状——第一跳查父仓 `repowiki`,命中后下钻业务仓 `repowiki`。 - **`centralized`**:**一跳**。`query_wiki` 直接检索工作区唯一 `repowiki/`,覆盖产品级 + 全部业务仓;传 `repo=<名>` 时收敛到"**适用于该仓的知识**"——`wiki/modules/<名>/` + 带该仓标的共享项 + 全局项(§7.1)。`repo-map.md` 仍是导航页,但角色从"第二跳入口"变为"仓清单与分区索引"。 -**`output_dir` 与 `repo=` 的分工(二者不冗余)**:`output_dir` 是**目录级定位**——指向 `repowiki/wiki/modules/<名>/` 即只查该仓 modules 分区(沿用 `colocated` 第二跳的既有机制,轻量场景用它即可)。但 `output_dir` 是单一路径,无法同时覆盖"该仓 modules + 适用于该仓的共享池知识"(分散在不同目录)。`repo=` 补的正是这个缺口:按**仓身份**聚合 `wiki/modules/<名>/` 与适用于该仓的共享池页——frontmatter `repo:`/`repos:` 含 `<名>`,**或无范围标的全局页**(§7.1)。这恰是 `colocated` 模式"下钻某仓 `repowiki/` 拿全部知识"在集中模式下的等价物——集中模式把一仓的适用知识拆进了"分区 + 共享池"多处,单靠 `output_dir` 聚不拢。因此:只要 modules 用 `output_dir`,要"适用于该仓的全部"用 `repo=`。 +**`repo_path` 与 `repo=` 的分工(二者不冗余)**:`repo_path` 是**目录级定位**——经 `default_output_dir` 布局推导后指向检索的 corpus(colocated:`<业务仓>/repowiki` 即该仓全部知识;centralized:工作区唯一 `repowiki/`)。corpus 是单一路径,无法单独覆盖"该仓 modules + 适用于该仓的共享池知识"(分散在不同目录)。`repo=` 补的正是这个缺口(集中模式):按**仓身份**聚合 `wiki/modules/<名>/` 与适用于该仓的共享池页——frontmatter `repo:`/`repos:` 含 `<名>`,**或无范围标的全局页**(§7.1)。这恰是 `colocated` 模式"下钻某仓 `repowiki/` 拿全部知识"在集中模式下的等价物——集中模式把一仓的适用知识拆进了"分区 + 共享池"多处,单靠目录定位聚不拢。因此:定位整个 corpus 用 `repo_path`(集中模式即全库一跳);要"适用于该仓的全部"用 `repo=`。 + +> **收敛更新(output_dir 退役)**:早期设计稿以显式 `output_dir` 作为目录级定位参数;收敛后**所有工具(含只读检索)不再暴露 `output_dir`**,输出目录一律由 `repo_path` 经 `default_output_dir` 布局推导(普通单仓 → `repo根/repowiki`,centralized 成员 → 工作区根 `repowiki`),跨仓显式寻址改用 `repo_path=<业务仓目录>` + `repo=` 过滤。本节按 `output_dir` 叙述的部分仅作设计史参考,不代表当前契约。 ### 7.1 范围模型:改某个仓时该查什么 diff --git a/repowiki/.meta/page_manifest.json b/repowiki/.meta/page_manifest.json index 23b3e9a..1dc4fd7 100644 --- a/repowiki/.meta/page_manifest.json +++ b/repowiki/.meta/page_manifest.json @@ -99,6 +99,24 @@ "repo": null, "producer": "codewiki/5.6.0", "written_at": "2026-09-05T12:00:39Z" + }, + "wiki/comparisons/自动生成SKILL可行性-wikiskill闭环-vs-CodeWiki编译复用.md": { + "git_head": null, + "components": [], + "files": [], + "source_fingerprint": null, + "repo": null, + "producer": "codewiki/5.6.0", + "written_at": "2026-09-05T15:20:04Z" + }, + "wiki/queries/skill-creator设计方案.md": { + "git_head": null, + "components": [], + "files": [], + "source_fingerprint": null, + "repo": null, + "producer": "codewiki/5.6.0", + "written_at": "2026-09-05T15:39:25Z" } } } \ No newline at end of file diff --git a/repowiki/.meta/telemetry/iamwangbao-163-com.jsonl b/repowiki/.meta/telemetry/iamwangbao-163-com.jsonl index 8a7befd..136af39 100644 --- a/repowiki/.meta/telemetry/iamwangbao-163-com.jsonl +++ b/repowiki/.meta/telemetry/iamwangbao-163-com.jsonl @@ -203,3 +203,75 @@ {"t": "hit", "doc": "wiki/modules/MCP_Tools_DocWriter.md", "at": "2026-09-05", "n": 4} {"t": "hit", "doc": "wiki/modules/MCP_Tools_Analysis.md", "at": "2026-09-05", "n": 2} {"t": "hit", "doc": "wiki/modules/test_doc.md", "at": "2026-09-05", "n": 1} +{"t": "hit", "doc": "notes/2026-08-29-生成的-ps1-必须带-utf-8-bom否则-powershell-51-按-gbk-误读.md", "at": "2026-09-06", "n": 20} +{"t": "hit", "doc": "notes/2026-09-05-github-release-正文乱码不可逆utf-8-字节被按-gbk-解码写入只能基于事实重写.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-08-03-mcp-工具-schema-不声明-session-idhandler-隐式读取.md", "at": "2026-09-06", "n": 45} +{"t": "hit", "doc": "wiki/modules/KnowledgeStore.md", "at": "2026-09-06", "n": 26} +{"t": "hit", "doc": "wiki/scenarios/MCP-Server薄壳架构与参数约定.md", "at": "2026-09-06", "n": 66} +{"t": "hit", "doc": "wiki/modules/CLI_Adapter.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "notes/2026-08-26-load-project-checklist-对-yaml-损坏静默回退-none-无日志难排查.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "wiki/modules/CLI_Utils.md", "at": "2026-09-06", "n": 14} +{"t": "hit", "doc": "wiki/scenarios/Wiki页面生成约定与数据结构.md", "at": "2026-09-06", "n": 33} +{"t": "hit", "doc": "notes/2026-08-29-工作区痕迹齐备时跳过-init-workspace直接跑-bootstrap-脚本补-clone.md", "at": "2026-09-06", "n": 25} +{"t": "hit", "doc": "wiki/modules/CLI_Commands.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "wiki/modules/CLI.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "wiki/modules/CLI_Config.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "notes/2026-09-04-在-codebuddy-使用跨-agent-技能纯-skillmd-直接装-codebuddyskillshooks-需.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-08-29-subagent-定义的-frontmatter-按宿主家族分发同名文件不同-schema.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "notes/2026-08-23-distill-worker-subagent-定义随包发布hook-启用时自动拷贝到项目-codebuddyagent.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "wiki/modules/MCP_Prompts.md", "at": "2026-09-06", "n": 80} +{"t": "hit", "doc": "notes/2026-08-25-mcp-参数长度受限时蒸馏-submit-走文件侧通道python-脚本直接调-handle-distill-conve.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "wiki/modules/MCP_Core.md", "at": "2026-09-06", "n": 140} +{"t": "hit", "doc": "wiki/modules/MCP_Server.md", "at": "2026-09-06", "n": 65} +{"t": "hit", "doc": "notes/2026-08-25-doctrine-不会自动注入-agent-上下文唯一通道是-query-wikimodeoverview.md", "at": "2026-09-06", "n": 25} +{"t": "hit", "doc": "notes/2026-09-05-codewiki-plus-发布是手动流程三处版本引用-lightweight-tag-ci-不发布.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-09-05-analyze-repo-超时根因探测教训venv-早已在默认排除且生效真正拖累是-agent-临时目录caveman.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-08-29-analyze-workspace-增量模式内部自动判断做主路径锚点复用-metadatajson.md", "at": "2026-09-06", "n": 30} +{"t": "hit", "doc": "notes/2026-08-28-telemetry-原子写入崩溃会残留孤儿-tmppid-文件且无自动清理机制.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Workspace.md", "at": "2026-09-06", "n": 30} +{"t": "hit", "doc": "notes/2026-08-26-ruff-format-panic-的根因是误提交的一次性诊断脚本检查步骤勿因工具崩溃轻率移除.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "wiki/scenarios/发布与依赖治理方法.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-09-04-agent-hook-注入的工程化防御与防漂移可复用模式caveman-提炼.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "notes/2026-09-05-query-wiki-p0-改进四项定案rev2-评审定稿est-tokens-by-file-v1-仅-notes-新.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "notes/2026-08-26-handle-query-wiki-在-session-存在时每次查询都全量重建检索索引.md", "at": "2026-09-06", "n": 52} +{"t": "hit", "doc": "wiki/modules/MCP_Cache.md", "at": "2026-09-06", "n": 30} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Analysis.md", "at": "2026-09-06", "n": 83} +{"t": "hit", "doc": "wiki/queries/skill-creator设计方案.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Quality.md", "at": "2026-09-06", "n": 74} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Knowledge.md", "at": "2026-09-06", "n": 81} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Dependency.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "wiki/modules/AnalysisPipeline.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "wiki/modules/LLM_Backend.md", "at": "2026-09-06", "n": 26} +{"t": "hit", "doc": "notes/2026-08-25-untracked-新文件不在分析图谱内review-changes-changed-sources-切片为空.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-08-25-知识摄入到自动检索链路ingest-note-自动写索引close-session-兜底终态.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "notes/2026-08-29-登记业务仓后不自动生成-wiki必须等用户显式要求.md", "at": "2026-09-06", "n": 36} +{"t": "hit", "doc": "wiki/scenarios/多仓工作区初始化与增量分析.md", "at": "2026-09-06", "n": 30} +{"t": "hit", "doc": "notes/2026-08-29-clone-only-短路路径必须仍写-workspacejson-并询问用户模式.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "notes/2026-09-05-confirmreject-生命周期已从-knowledge-looppy-拆到-note-lifecyclepy202.md", "at": "2026-09-06", "n": 30} +{"t": "hit", "doc": "wiki/modules/RouteExtractors.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-08-26-record-hit-同日聚合只查最后一行交错写入下退化为纯追加n-恒为-1.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_DocWriter.md", "at": "2026-09-06", "n": 50} +{"t": "hit", "doc": "notes/2026-08-26-analyze-repo-增量与依赖图谱的测试并行执行存在时序竞态.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "notes/2026-08-26-analyze-changes-的-changed-components-行区间定位是近似跨函数边界会误报组件.md", "at": "2026-09-06", "n": 8} +{"t": "hit", "doc": "wiki/modules/SharedConfig.md", "at": "2026-09-06", "n": 30} +{"t": "hit", "doc": "notes/2026-09-05-测试污染真实-meta-的-module-treefixture-把-module-treejson-写成-test-使.md", "at": "2026-09-06", "n": 20} +{"t": "hit", "doc": "wiki/scenarios/对话蒸馏管线与raw暂存区.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "wiki/modules/WebApp.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "notes/2026-09-05-stale-evidence-只驱动复核提醒仅处理带-content-hash-的条目报-warning-且不自动改写.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-08-25-聚合doctrine-阈值等运行参数通过-repowikischemayaml-conventionsaggregati.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "wiki/modules/GraphAndSort.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-09-04-d19knowledgestore-跨进程锁文件集中到-wiki-rootmetalockssha256目标绝对路径20.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "notes/2026-09-05-schemayaml-模板双源收敛为包内单源删根副本守卫测试只验包内模板清理-init-wikischema-gener.md", "at": "2026-09-06", "n": 9} +{"t": "hit", "doc": "notes/2026-08-23-会话启动时的-query-wiki蒸馏等重操作委托-subagent-执行避免阻塞用户正常使用.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "notes/2026-08-23-hook-采集机制仅正式接线-codebuddyreadme-措辞用仅接线支持.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "wiki/scenarios/任务记忆系统设计方法.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-09-04-file-lock-的锁文件可能是数据文件本身释放即删只能加在-storelocked-不能下沉到-file-lock.md", "at": "2026-09-06", "n": 8} +{"t": "hit", "doc": "notes/2026-09-04-锁文件清理采用仅-windows-释放即删unix-一律保留不删.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "wiki/reading-guide.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-08-25-review-changes-全轴-prepare-单次调用易挂起mcp-通道卡住.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-09-05-检索预算口径打架前门-1200-字符-snippet-300-10-条只-4-条带内容expand-后门-1020000.md", "at": "2026-09-06", "n": 7} +{"t": "hit", "doc": "notes/2026-08-26-type-filter-是单值精确匹配设计要优先-5-类需多次查询合并.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-08-29-引用已有笔记前须检查其-statusdeprecated-笔记不应被采纳.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "wiki/modules/LanguageAnalyzers.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-09-05-doc-similaritypy同源判定用正文-shingle-的-minhash-bottom-k-sketch-ja.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-09-05-code-routing-代码注入分档的真实规则纯-boilerplate-文件仅签名businessinfra混合全量.md", "at": "2026-09-06", "n": 5} diff --git "a/repowiki/tasks/\344\273\226\345\261\261\344\271\213\347\237\263/memories/iamwangbao-163-com.md" "b/repowiki/tasks/\344\273\226\345\261\261\344\271\213\347\237\263/memories/iamwangbao-163-com.md" index a6a2a8d..7b03280 100644 --- "a/repowiki/tasks/\344\273\226\345\261\261\344\271\213\347\237\263/memories/iamwangbao-163-com.md" +++ "b/repowiki/tasks/\344\273\226\345\261\261\344\271\213\347\237\263/memories/iamwangbao-163-com.md" @@ -33,3 +33,11 @@ grill 拷问「ponytail/caveman 融合 CodeWiki MCP」第一轮 Q1–Q4 用户 ### 2026-09-05 19:20 下一步待办:hook-probe 探索代理核实 CodeWiki 现有 hook/注入设施事实(_ide_hook.py 采集方向 vs 注入方向需新增反向通道;CodeBuddy 是否支持 stdout 隐藏注入)返回后开第二轮拷问;融合方案尚未落地实现。 + +### 2026-09-05 23:20 + +2026-09-05:wikiskill(arXiv:2608.27454 开源实现)+ 论文解读调研完成,产出 comparison 页「自动生成SKILL可行性-wikiskill闭环-vs-CodeWiki编译复用」(wiki/comparisons/)。定档结论:能实现,推荐方案 C 半闭环(MVP=单向编译器 B:confirmed notes/scenarios → SKILL.md draft → 确认闸门 → .codebuddy/skills/);不建自动评分门控(CodeWiki 无 held-out 基准);素材源=notes/scenarios;生成走 Mode C;先仓库内闭环再家族分发。grill Q1-Q4 用户均按推荐处理。下一步待用户拍板:是否进入 skill-creator 设计/实现。 + +### 2026-09-05 23:39 + +2026-09-05(续):grill 第二轮 Q6-Q9 用户仍全按推荐,skill-creator 设计定档并落 wiki/queries/skill-creator设计方案.md。定档:①产物粒度=scenario 直译为主 + 未吸收的高价值 pitfall/lesson/decision 单条补充;②选材=prepare 列候选 + 显式 topic/sources 指定,禁止全自动触发(Doctrine 显式触发);③落盘=两区制,草稿 repowiki/skills/ 不生效 → 确认后 install 到 .codebuddy/skills/ 生效(单区 draft 标记方案否决:草稿期技能已被 IDE 触发,闸门形同虚设);④更新=整份重写 + metadata.revisions 变更记录段(不做 unified diff);⑤工具形态 skill_creator 四 mode:prepare/submit/install/retire,容量硬顶 12,批次最多新建 1 份。分工:consolidate_notes 产知识层 scenario,skill_creator 产行为层 SKILL,单向下游。未决:Q10 回流验证判定、Q11 lint 规则与容量取值。 diff --git a/repowiki/wiki/log-2026-09.md b/repowiki/wiki/log-2026-09.md index 10b6c65..817ea0f 100644 --- a/repowiki/wiki/log-2026-09.md +++ b/repowiki/wiki/log-2026-09.md @@ -76,3 +76,10 @@ * **lint_wiki**: 检查完成: 155 个问题 * **close_session**: 会话关闭 * **lint_wiki**: 检查完成: 0 个问题 +* **write_doc_file**: 创建 自动生成SKILL可行性-wikiskill闭环-vs-CodeWiki编译复用.md +* **close_session**: 会话关闭 +* **write_doc_file**: 创建 skill-creator设计方案.md +* **close_session**: 会话关闭 + +## 2026-09-06 +* **close_session**: 会话关闭 diff --git a/repowiki/wiki/reading-guide.md b/repowiki/wiki/reading-guide.md index 3b2c903..b3dc24d 100644 --- a/repowiki/wiki/reading-guide.md +++ b/repowiki/wiki/reading-guide.md @@ -1,7 +1,7 @@ --- type: Concept title: "阅读指南" -generated: { by: codewiki/reading_guide.py, at: 2026-09-05T12:15:49Z } +generated: { by: codewiki/reading_guide.py, at: 2026-09-05T16:15:58Z } stale_after: 2099-12-31 description: "> 基于 PageRank 依赖分析自动生成。排名越靠前的组件被越多模块依赖,建议优先阅读。" --- diff --git a/scripts/_strip_output_dir.py b/scripts/_strip_output_dir.py new file mode 100644 index 0000000..3f58974 --- /dev/null +++ b/scripts/_strip_output_dir.py @@ -0,0 +1,100 @@ +"""One-shot: remove the retired `output_dir` inputSchema property from +write-path/lifecycle/management tools in codewiki/mcp/registry.py. + +Read-only tools keep `output_dir` (explicit addressing for cross-repo / +isolated-kb search is still legitimate). + +Usage: + uv run python scripts/_strip_output_dir.py # dry run (no writes) + uv run python scripts/_strip_output_dir.py --apply # rewrite registry.py +""" +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path + +REGISTRY = Path(__file__).resolve().parent.parent / "codewiki/mcp/registry.py" + +NAME_RE = re.compile(r'name="([a-z_0-9]+)"') + +# Write-path / lifecycle / analysis / management tools whose output_dir +# parameter is retired (output_dir is derived from repo_path by layout). +DELETE = { + "analyze_repo", "write_doc_file", "edit_doc_file", "save_module_tree", + "close_session", "stamp_evidence", "ingest_note", "confirm_note", + "batch_set_status", "reject_note", "ingest_source", "retract_source", + "capture_conversation", "distill_conversation", "consolidate_notes", + "refresh_doctrine", "batch_ingest", "flag_issue", "analyze_workspace", + "generate_docs", "init_wiki", "init_workspace", "create_task", + "list_tasks", "get_task", "complete_task", "delete_task", + "set_session_task", "add_task_memory", "get_task_context", + "compact_task_memories", +} + + +def owner_tool(lines: list[str], key_idx: int) -> str | None: + """Nearest preceding top-level `name=` belonging to this property.""" + for j in range(key_idx - 1, -1, -1): + m = NAME_RE.search(lines[j]) + if m: + return m.group(1) + return None + + +def property_end(lines: list[str], start: int) -> int: + """Index of the line that closes the `{` opened on *start* (line-local).""" + depth = 0 + for i in range(start, len(lines)): + depth += lines[i].count("{") - lines[i].count("}") + if depth == 0: + return i + raise ValueError(f"unbalanced braces at line {start + 1}") + + +def main() -> int: + apply = "--apply" in sys.argv + text = REGISTRY.read_text(encoding="utf-8") + lines = text.splitlines(keepends=True) + + drop_lines: set[int] = set() + actions: list[str] = [] + seen: set[str] = set() + for idx, line in enumerate(lines): + if '"output_dir": {' not in line: + continue + owner = owner_tool(lines, idx) + if owner is None or owner not in DELETE: + continue + if owner in seen: + # Defensive: at most one output_dir property per tool. + continue + seen.add(owner) + end = property_end(lines, idx) + drop = set(range(idx, end + 1)) + drop_lines |= drop + actions.append(f"{owner}: dropping lines {idx + 1}-{end + 1}") + + new_text = "".join(l for i, l in enumerate(lines) if i not in drop_lines) + try: + ast.parse(new_text) + except SyntaxError as exc: + print(f"SYNTAX ERROR after edit: {exc}") + return 1 + + print(f"Tools touched: {len(actions)}; lines dropped: {len(drop_lines)}") + print("\n".join(actions)) + untouched = sorted(t for t in DELETE if t not in seen) + if untouched: + print(f"NOT FOUND (check manually): {untouched}") + if apply: + REGISTRY.write_text(new_text, encoding="utf-8") + print("Applied.") + else: + print("Dry run — pass --apply to rewrite.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e6df9f74a07cbe99bfc942c131c854878e9ae43f Mon Sep 17 00:00:00 2001 From: mambo-wang <iamwangbao@163.com> Date: Sun, 6 Sep 2026 15:51:25 +0800 Subject: [PATCH 02/23] =?UTF-8?q?test:=20=E9=80=82=E9=85=8D=20output=5Fdir?= =?UTF-8?q?=20=E9=80=80=E4=BC=91=EF=BC=8C=E6=B5=8B=E8=AF=95=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=94=B9=E7=94=A8=20repo=5Fpath=20=E9=94=9A=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 各测试 helper 由传 output_dir=<repo>/repowiki 改为 repo_path=<repo>(推导 结果相同);adoption 的 capture 集成用例改为在 <root>/repowiki 下建库 - target_anchor_guards 重写为单锚不变量:断言无工具再广告 output_dir,并保留 注入/报错(C 部分)的回归覆盖 - promotion 的 promote-note prompt 断言同步为 repo_path(arguments 不再含 output_dir;插值后的路径分隔符不参与断言) - 非 handler 场景(cache.search、_resolve_output_dir、output_dir 退休兼容 验证用例)保留原有 output_dir 传参 --- tests/conftest.py | 1 - tests/test_adoption.py | 11 +- tests/test_centralized_layout_fixes.py | 37 ++-- tests/test_claude_mem_borrowings.py | 4 +- tests/test_consolidation_p2.py | 16 +- tests/test_distill_cleanup.py | 6 +- tests/test_distill_p1.py | 2 +- tests/test_doctrine_p3.py | 12 +- tests/test_evidence.py | 2 +- tests/test_freshness.py | 10 +- tests/test_friction.py | 11 +- tests/test_hook_registry.py | 12 +- tests/test_ide_hook_capture.py | 8 +- tests/test_index_freshness.py | 2 +- tests/test_ingest_conflict_advisory.py | 4 +- tests/test_l0_archive.py | 10 +- tests/test_lint_fix.py | 2 +- tests/test_lint_layout_violations.py | 4 +- tests/test_low_adoption.py | 2 +- tests/test_phase2_concurrency.py | 6 +- tests/test_phase3_4_first_slice.py | 2 +- tests/test_promotion.py | 11 +- tests/test_query_repo_filter.py | 28 +-- tests/test_query_transparency.py | 4 +- tests/test_remove_repo_cleanup.py | 2 +- tests/test_scope_writes.py | 2 +- tests/test_target_anchor_guards.py | 74 ++----- tests/test_task_manager.py | 260 ++++++++++++------------- tests/test_team_layout.py | 2 +- tests/test_workspace_bootstrap.py | 3 +- tests/test_workspace_layout.py | 16 +- 31 files changed, 274 insertions(+), 292 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b0826f0..ec5bbb6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,7 +46,6 @@ def analyzed_repo(tmp_path): handle_analyze_repo( { "repo_path": str(tmp_path), - "output_dir": str(tmp_path / "repowiki"), "incremental": False, }, store, diff --git a/tests/test_adoption.py b/tests/test_adoption.py index b803b2b..a8ffa87 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -191,8 +191,9 @@ def test_no_hits_ignores_adoption(self): # capture integration (end-to-end through handle_capture_conversation) # --------------------------------------------------------------------------- # def _capture_args(tmp_path, turns, session_id=""): + # output_dir is retired on write tools: capture writes into + # default_output_dir(repo_path) = <repo_path>/repowiki. return { - "output_dir": str(tmp_path), "repo_path": str(tmp_path), "conversation": turns, "source_session_id": session_id, @@ -200,7 +201,7 @@ def _capture_args(tmp_path, turns, session_id=""): def _make_doc(tmp_path, rel): - p = tmp_path / rel + p = tmp_path / "repowiki" / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_text("---\ntype: pitfall\ntitle: t\nstatus: stable\n---\nbody", encoding="utf-8") @@ -224,7 +225,7 @@ def test_declared_docs_persisted(self, tmp_path): ) assert result["adopted_docs"] == ["notes/pitfall-a.md", "wiki/modules/m.md"] assert result["adoption_inserted"] == 2 - counts = load_adoption_counts(tmp_path) + counts = load_adoption_counts(tmp_path / "repowiki") assert counts == {"notes/pitfall-a.md": 1, "wiki/modules/m.md": 1} def test_supersede_no_double_count(self, tmp_path): @@ -243,7 +244,7 @@ def test_supersede_no_double_count(self, tmp_path): ] handle_capture_conversation(_capture_args(tmp_path, turns1, "s1"), SessionStore()) handle_capture_conversation(_capture_args(tmp_path, turns2, "s1"), SessionStore()) - assert load_adoption_counts(tmp_path) == {"notes/pitfall-a.md": 1} + assert load_adoption_counts(tmp_path / "repowiki") == {"notes/pitfall-a.md": 1} def test_missing_path_dropped(self, tmp_path): _make_doc(tmp_path, "notes/exists.md") @@ -258,7 +259,7 @@ def test_missing_path_dropped(self, tmp_path): handle_capture_conversation(_capture_args(tmp_path, turns, "s1"), SessionStore()) ) assert result["adopted_docs"] == ["notes/exists.md"] - assert load_adoption_counts(tmp_path) == {"notes/exists.md": 1} + assert load_adoption_counts(tmp_path / "repowiki") == {"notes/exists.md": 1} def test_nudge_when_search_traces_without_declaration(self, tmp_path): turns = [ diff --git a/tests/test_centralized_layout_fixes.py b/tests/test_centralized_layout_fixes.py index 4c04ae4..ee22d2a 100644 --- a/tests/test_centralized_layout_fixes.py +++ b/tests/test_centralized_layout_fixes.py @@ -89,7 +89,9 @@ def test_routes_centralized_business_repo_into_corpus(self, tmp_path): assert marker in schema.read_text(encoding="utf-8") assert (tmp_path / "repowiki" / "notes").is_dir() - def test_explicit_output_dir_not_hijacked(self, tmp_path): + def test_explicit_output_dir_ignored_on_member(self, tmp_path): + """output_dir is a pure function of repo_path under the active layout: + a caller-supplied value is retired and ignored, never honoured.""" _init(tmp_path) _register(tmp_path) repo = tmp_path / "a" @@ -98,12 +100,12 @@ def test_explicit_output_dir_not_hijacked(self, tmp_path): res = json.loads(handle_init_wiki({"repo_path": str(repo), "output_dir": str(custom)})) - # Explicit output_dir is the caller's directory-level choice (§7): - # never hijacked, but centralized safety defaults still apply. assert res["status"] == "ok" - assert Path(res["output_dir"]) == custom.resolve() - assert (custom / "schema.yaml").is_file() + # routing to the workspace corpus wins; the explicit dir is not created + assert Path(res["output_dir"]) == (tmp_path / "repowiki").resolve() + assert not custom.exists() assert not (repo / "AGENTS.md").exists() + assert (tmp_path / "repowiki" / "wiki" / "modules" / "a").is_dir() def test_still_allows_workspace_root(self, tmp_path): _init(tmp_path) @@ -146,18 +148,19 @@ def test_reads_namespaced_tree(self, tmp_path): def test_falls_back_to_corpus_meta(self, tmp_path): """Pre-namespacing layout: <corpus>/.meta/module_tree.json still loads.""" + _init(tmp_path) + _register(tmp_path) + repo = tmp_path / "a" + repo.mkdir(exist_ok=True) corpus = tmp_path / "repowiki" - (corpus / ".meta").mkdir(parents=True) + (corpus / ".meta").mkdir(parents=True, exist_ok=True) tree = {"legacy": {"components": [], "children": {}}} (corpus / ".meta" / "module_tree.json").write_text(json.dumps(tree), encoding="utf-8") - repo = tmp_path / "a" - repo.mkdir() - res = json.loads( - asyncio.run( - handle_get_module_tree({"repo_path": str(repo), "output_dir": str(corpus)}, None) - ) - ) + # The corpus derives from the registered member's repo_path (no + # output_dir param); with no namespaced tree under <ws>/.codewiki/a, + # the legacy corpus .meta file is the fallback that loads. + res = json.loads(asyncio.run(handle_get_module_tree({"repo_path": str(repo)}, None))) assert res["status"] == "success" assert res["total_modules"] == 1 @@ -168,11 +171,13 @@ def test_falls_back_to_corpus_meta(self, tmp_path): # --------------------------------------------------------------------------- class TestIngestNoteProvenance: def test_warns_when_writing_repo_unknown(self, tmp_path): + """Inside a centralized corpus, a write whose repo_path is not a + registered member cannot be provenance-stamped → warning surfaced.""" _init(tmp_path) + # The workspace root is not a member repo: routing_for_write returns + # None, so the silent-global guard must surface a provenance warning. res = json.loads( - handle_ingest_note( - {"output_dir": str(tmp_path / "repowiki"), "title": "T1", "content": "body"}, None - ) + handle_ingest_note({"repo_path": str(tmp_path), "title": "T1", "content": "body"}, None) ) assert res["status"] == "ingested" assert "provenance_warning" in res diff --git a/tests/test_claude_mem_borrowings.py b/tests/test_claude_mem_borrowings.py index 5a7fd85..a7ea2a9 100644 --- a/tests/test_claude_mem_borrowings.py +++ b/tests/test_claude_mem_borrowings.py @@ -111,7 +111,9 @@ def _mk_wiki(tmp_path) -> Path: def _query(od: Path, **kw) -> dict: - args = {"output_dir": str(od)} + # output_dir is retired on query tools; the KB lives at + # default_output_dir(repo_path), and od is always <root>/repowiki here. + args = {"repo_path": str(od.parent)} args.update(kw) return json.loads(handle_query_wiki(args, SessionStore())) diff --git a/tests/test_consolidation_p2.py b/tests/test_consolidation_p2.py index b6a39fb..197c3bb 100644 --- a/tests/test_consolidation_p2.py +++ b/tests/test_consolidation_p2.py @@ -53,7 +53,7 @@ def _ingest( r = json.loads( handle_ingest_note( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "title": title, "note_type": note_type, "content": content, @@ -71,7 +71,7 @@ def _confirm(repo: str, note_file: str) -> dict: return json.loads( handle_confirm_note( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "note_file": note_file, }, store, @@ -109,7 +109,7 @@ def _fm(repo: str, rel: str) -> dict: def _consolidate(repo: str, args: dict) -> dict: store = SessionStore() - payload = {"output_dir": f"{repo}/repowiki", **args} + payload = {"repo_path": repo, **args} return json.loads(cons.handle_consolidate_notes(payload, store)) @@ -146,7 +146,7 @@ def test_wiki_stats_exposes_aggregation_section(tmp_path): nf = _ingest(repo, "Stats visibility note") _confirm(repo, nf) store = SessionStore() - resp = json.loads(handle_wiki_stats({"output_dir": f"{repo}/repowiki"}, store)) + resp = json.loads(handle_wiki_stats({"repo_path": repo}, store)) # no retrieval stats DB yet → early return path must still carry counters assert "aggregation" in resp assert resp["aggregation"]["notes_since_last_consolidation"] == 1 @@ -160,11 +160,11 @@ def test_get_task_context_exposes_aggregation(tmp_path): store = SessionStore() r = json.loads( - handle_create_task({"output_dir": f"{repo}/repowiki", "title": "P2 smoke task"}, store) + handle_create_task({"repo_path": repo, "title": "P2 smoke task"}, store) ) task_id = r["task"]["id"] resp = json.loads( - handle_get_task_context({"output_dir": f"{repo}/repowiki", "task_id": task_id}, store) + handle_get_task_context({"repo_path": repo, "task_id": task_id}, store) ) assert resp["ok"] is True assert "aggregation" in resp @@ -183,7 +183,7 @@ def test_prepare_lists_only_pending_confirmed_notes(tmp_path): rejected = _ingest(repo, "Rejected candidate note") store = SessionStore() handle_reject_note( - {"output_dir": f"{repo}/repowiki", "note_file": rejected, "reason": "noise"}, store + {"repo_path": repo, "note_file": rejected, "reason": "noise"}, store ) resp = _consolidate(repo, {"mode": "prepare"}) @@ -356,7 +356,7 @@ def test_lint_scenario_capacity_and_orphan(tmp_path): store = SessionStore() resp = json.loads( handle_lint_wiki( - {"output_dir": f"{repo}/repowiki", "checks": ["scenario_capacity", "scenario_orphan"]}, + {"repo_path": repo, "checks": ["scenario_capacity", "scenario_orphan"]}, store, ) ) diff --git a/tests/test_distill_cleanup.py b/tests/test_distill_cleanup.py index a3300c0..eda8294 100644 --- a/tests/test_distill_cleanup.py +++ b/tests/test_distill_cleanup.py @@ -38,7 +38,7 @@ def _submit(repo: str, distilled: dict): store = SessionStore() out = distill.handle_distill_conversation( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "mode": "submit", "distilled": distilled, }, @@ -169,7 +169,7 @@ def _write_raw_index(repo: str, entries: list) -> None: def _prepare(repo: str, task_id: str | None) -> dict: store = SessionStore() - args = {"output_dir": f"{repo}/repowiki", "mode": "prepare"} + args = {"repo_path": repo, "mode": "prepare"} if task_id is not None: args["task_id"] = task_id return json.loads(distill.handle_distill_conversation(args, store)) @@ -269,7 +269,7 @@ def test_submit_ignores_other_tasks_extractions(self, tmp_path): store = SessionStore() out = distill.handle_distill_conversation( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "mode": "submit", "task_id": "task-one", # Extraction for conv-b (another task) must be ignored; conv-a has diff --git a/tests/test_distill_p1.py b/tests/test_distill_p1.py index ec63f29..6993f76 100644 --- a/tests/test_distill_p1.py +++ b/tests/test_distill_p1.py @@ -56,7 +56,7 @@ def _submit(repo: str, distilled: dict): store = SessionStore() out = distill.handle_distill_conversation( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "mode": "submit", "distilled": distilled, }, diff --git a/tests/test_doctrine_p3.py b/tests/test_doctrine_p3.py index df035cd..ef36624 100644 --- a/tests/test_doctrine_p3.py +++ b/tests/test_doctrine_p3.py @@ -52,7 +52,7 @@ def _ingest_and_confirm(repo: str, title: str) -> str: r = json.loads( handle_ingest_note( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "title": title, "note_type": "decision", "content": "## Background\nbody", @@ -62,7 +62,7 @@ def _ingest_and_confirm(repo: str, title: str) -> str: ) ) nf = Path(r["note_path"]).name - handle_confirm_note({"output_dir": f"{repo}/repowiki", "note_file": nf}, store) + handle_confirm_note({"repo_path": repo, "note_file": nf}, store) return nf @@ -86,7 +86,7 @@ def _write_scenario(repo: str, name: str) -> str: def _refresh(repo: str, args: dict) -> dict: store = SessionStore() - payload = {"output_dir": f"{repo}/repowiki", **args} + payload = {"repo_path": repo, **args} return json.loads(doc_tool.handle_refresh_doctrine(payload, store)) @@ -192,7 +192,7 @@ def test_query_wiki_overview_injects_doctrine_and_navigation(tmp_path): resp = json.loads( handle_query_wiki( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "mode": "overview", "query": "", }, @@ -219,7 +219,7 @@ def test_consolidate_submit_cascades_doctrine_hint(tmp_path): resp = json.loads( cons.handle_consolidate_notes( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "mode": "submit", "report": { "scenarios": [ @@ -253,7 +253,7 @@ def test_consolidate_no_doctrine_hint_below_threshold(tmp_path): resp = json.loads( cons.handle_consolidate_notes( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "mode": "submit", "report": { "scenarios": [ diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 0c0f025..3964891 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -221,7 +221,7 @@ def test_lint_wiki_dispatches_stale_evidence(tmp_path): handle_stamp_evidence(args, store) (repo / "src" / "calc.py").write_text("changed\n", encoding="utf-8") - res = json.loads(handle_lint_wiki({"output_dir": str(od), "checks": ["stale_evidence"]}, store)) + res = json.loads(handle_lint_wiki({"repo_path": str(od.parent), "checks": ["stale_evidence"]}, store)) assert res["checks_run"] == ["stale_evidence"] assert res["total_issues"] == 1 assert res["issues"][0]["check"] == "stale_evidence" diff --git a/tests/test_freshness.py b/tests/test_freshness.py index 187220a..655d1d8 100644 --- a/tests/test_freshness.py +++ b/tests/test_freshness.py @@ -189,7 +189,7 @@ def test_ingest_writes_type_window(tmp_path): r = json.loads( handle_ingest_note( { - "output_dir": str(od), + "repo_path": str(od.parent), "title": f"n-{ntype}", "note_type": ntype, "content": "body", @@ -211,7 +211,7 @@ def test_confirm_renews_by_type_window(tmp_path): # Old workaround note whose stale_after has lapsed _write_note(od, "w.md", ntype="workaround", status="draft", stale_after=PAST) store = SessionStore() - r = json.loads(handle_confirm_note({"output_dir": str(od), "note_file": "w.md"}, store)) + r = json.loads(handle_confirm_note({"repo_path": str(od.parent), "note_file": "w.md"}, store)) assert "error" not in r, r fm = _read_fm(od, "w.md") assert fm["status"] == "stable" @@ -262,7 +262,7 @@ def test_lint_dispatch_reads_schema_config(tmp_path): stale_after=(TODAY - timedelta(days=5)).strftime("%Y-%m-%d"), ) store = SessionStore() - resp = json.loads(handle_lint_wiki({"output_dir": str(od), "checks": ["stale_notes"]}, store)) + resp = json.loads(handle_lint_wiki({"repo_path": str(od.parent), "checks": ["stale_notes"]}, store)) files = [i["file"] for i in resp.get("issues", []) if i["check"] == "stale_notes"] assert files == ["notes/wa.md"] @@ -273,7 +273,7 @@ def test_lint_no_double_report_with_okf(tmp_path): store = SessionStore() resp = json.loads( handle_lint_wiki( - {"output_dir": str(od), "checks": ["stale_notes", "okf_conformance"]}, store + {"repo_path": str(od.parent), "checks": ["stale_notes", "okf_conformance"]}, store ) ) lapsed_issues = [i for i in resp.get("issues", []) if i["file"] == "notes/lapsed.md"] @@ -368,6 +368,6 @@ def test_wiki_stats_freshness_distribution(tmp_path): assert dist["due_notes"] == ["notes/lapsed.md"] store = SessionStore() - resp = json.loads(handle_wiki_stats({"output_dir": str(od)}, store)) + resp = json.loads(handle_wiki_stats({"repo_path": str(od.parent)}, store)) # No retrieval stats db yet -> error path still carries freshness assert resp.get("freshness", {}).get("due") == 1 diff --git a/tests/test_friction.py b/tests/test_friction.py index c0fc314..db1d2cd 100644 --- a/tests/test_friction.py +++ b/tests/test_friction.py @@ -243,7 +243,7 @@ def test_format_friction_signals_line(): def _capture(repo: Path, conversation, **kwargs) -> dict: - args = {"output_dir": str(repo / "repowiki"), "conversation": conversation} + args = {"repo_path": str(repo), "conversation": conversation} args.update(kwargs) return json.loads(capture.handle_capture_conversation(args, SessionStore())) @@ -358,7 +358,7 @@ def test_prepare_lists_captures_by_friction_desc(tmp_path): out = json.loads( distill.handle_distill_conversation( { - "output_dir": str(tmp_path / "repowiki"), + "repo_path": str(tmp_path), "mode": "prepare", }, SessionStore(), @@ -383,7 +383,7 @@ def test_prepare_no_hint_when_all_calm(tmp_path): out = json.loads( distill.handle_distill_conversation( { - "output_dir": str(tmp_path / "repowiki"), + "repo_path": str(tmp_path), "mode": "prepare", }, SessionStore(), @@ -396,10 +396,9 @@ def test_prepare_no_hint_when_all_calm(tmp_path): def test_get_task_context_pending_raws_carry_friction(tmp_path): repo = tmp_path - od = str(repo / "repowiki") store = SessionStore() - r = json.loads(tm.handle_create_task({"output_dir": od, "title": "摩擦信号机制"}, store)) + r = json.loads(tm.handle_create_task({"repo_path": str(repo), "title": "摩擦信号机制"}, store)) assert r["ok"] is True task_id = r["task"]["id"] @@ -408,7 +407,7 @@ def test_get_task_context_pending_raws_carry_friction(tmp_path): _capture(repo, calm, task_id=task_id) _capture(repo, _correction_conversation(), task_id=task_id) - ctx = json.loads(tm.handle_get_task_context({"output_dir": od, "task_id": task_id}, store)) + ctx = json.loads(tm.handle_get_task_context({"repo_path": str(repo), "task_id": task_id}, store)) assert ctx["ok"] is True assert ctx["pending_raw_count"] == 2 entries = ctx["pending_raws"] diff --git a/tests/test_hook_registry.py b/tests/test_hook_registry.py index f9d1c8d..12d02b9 100644 --- a/tests/test_hook_registry.py +++ b/tests/test_hook_registry.py @@ -124,7 +124,7 @@ def test_delimited_block_output(self, tmp_path): from codewiki.cli.commands.query import query_command od = _mk_wiki(tmp_path) - res = CliRunner().invoke(query_command, ["端口冲突", "--output-dir", str(od)]) + res = CliRunner().invoke(query_command, ["端口冲突", "--repo-path", str(tmp_path)]) assert res.exit_code == 0, res.output out = res.output assert out.startswith("--- codewiki:query:start ---") @@ -138,7 +138,7 @@ def test_missing_terms_noted(self, tmp_path): from codewiki.cli.commands.query import query_command od = _mk_wiki(tmp_path) - res = CliRunner().invoke(query_command, ["端口冲突 量子", "--output-dir", str(od)]) + res = CliRunner().invoke(query_command, ["端口冲突 量子", "--repo-path", str(tmp_path)]) assert res.exit_code == 0 assert "missing_terms: 量子" in res.output assert "topically adjacent" in res.output @@ -147,7 +147,7 @@ def test_check_mode_lightweight(self, tmp_path): from codewiki.cli.commands.query import query_command od = _mk_wiki(tmp_path) - res = CliRunner().invoke(query_command, ["端口冲突", "--check", "--output-dir", str(od)]) + res = CliRunner().invoke(query_command, ["端口冲突", "--check", "--repo-path", str(tmp_path)]) assert res.exit_code == 0 assert "relevant: true" in res.output assert "top_score:" in res.output @@ -161,7 +161,7 @@ def test_check_mode_lightweight(self, tmp_path): def test_missing_output_dir_errors(self, tmp_path): from codewiki.cli.commands.query import query_command - res = CliRunner().invoke(query_command, ["x", "--output-dir", str(tmp_path / "nope")]) + res = CliRunner().invoke(query_command, ["x", "--repo-path", str(tmp_path / "nope")]) assert res.exit_code == 2 def test_full_search_records_telemetry(self, tmp_path): @@ -169,7 +169,7 @@ def test_full_search_records_telemetry(self, tmp_path): from codewiki.cli.commands.query import query_command od = _mk_wiki(tmp_path) - CliRunner().invoke(query_command, ["端口冲突", "--output-dir", str(od)]) + CliRunner().invoke(query_command, ["端口冲突", "--repo-path", str(tmp_path)]) from codewiki.mcp.tools import telemetry agg = telemetry.aggregate_usage(od) @@ -179,7 +179,7 @@ def test_expand_flag(self, tmp_path): from codewiki.cli.commands.query import query_command od = _mk_wiki(tmp_path) - res = CliRunner().invoke(query_command, ["端口冲突", "--output-dir", str(od), "--expand"]) + res = CliRunner().invoke(query_command, ["端口冲突", "--repo-path", str(tmp_path), "--expand"]) assert res.exit_code == 0 assert "lsof" in res.output # full page content included diff --git a/tests/test_ide_hook_capture.py b/tests/test_ide_hook_capture.py index bf88853..bb98828 100644 --- a/tests/test_ide_hook_capture.py +++ b/tests/test_ide_hook_capture.py @@ -577,7 +577,7 @@ def get(self, sid): result = _json.loads( _cap.handle_capture_conversation( { - "output_dir": str(out), + "repo_path": str(out.parent), "conversation": [ {"role": "user", "content": "review 最近一次提交"}, {"role": "assistant", "content": "好的,我来审查"}, @@ -611,7 +611,7 @@ def get(self, sid): result = _json.loads( _cap.handle_capture_conversation( { - "output_dir": str(out), + "repo_path": str(out.parent), "conversation": [ {"role": "assistant", "content": "only assistant text"}, ], @@ -641,10 +641,10 @@ def get(self, sid): conv = [{"role": "user", "content": "重复的开场白"}, {"role": "assistant", "content": "回答"}] _json.loads( - _cap.handle_capture_conversation({"output_dir": str(out), "conversation": conv}, _Store()) + _cap.handle_capture_conversation({"repo_path": str(out.parent), "conversation": conv}, _Store()) ) _json.loads( - _cap.handle_capture_conversation({"output_dir": str(out), "conversation": conv}, _Store()) + _cap.handle_capture_conversation({"repo_path": str(out.parent), "conversation": conv}, _Store()) ) # Second capture supersedes the first (same source_session empty) — but here # neither has source_session_id, so they are both written. Ensure distinct. diff --git a/tests/test_index_freshness.py b/tests/test_index_freshness.py index 7f16fb2..6e0c227 100644 --- a/tests/test_index_freshness.py +++ b/tests/test_index_freshness.py @@ -139,7 +139,7 @@ def test_handle_query_wiki_end_to_end_pull(self, tmp_path): encoding="utf-8", ) out = json.loads( - handle_query_wiki({"output_dir": str(od), "query": "供应链追溯"}, SessionStore()) + handle_query_wiki({"repo_path": str(od.parent), "query": "供应链追溯"}, SessionStore()) ) assert any("newmod" in r["file"] for r in out["results"]) diff --git a/tests/test_ingest_conflict_advisory.py b/tests/test_ingest_conflict_advisory.py index 313919d..d67c455 100644 --- a/tests/test_ingest_conflict_advisory.py +++ b/tests/test_ingest_conflict_advisory.py @@ -39,8 +39,10 @@ def _write_note(notes_dir: Path, filename: str, title: str, status: str = "stabl def _ingest(output_dir: Path, title: str, **extra): + # output_dir is retired on write tools; the KB lives at <repo_path>/repowiki + # and output_dir (the repowiki root) is always its child. args = { - "output_dir": str(output_dir), + "repo_path": str(output_dir.parent), "title": title, "content": "写入后删除 key,读多写少场景走 TTL。", "note_type": "general", diff --git a/tests/test_l0_archive.py b/tests/test_l0_archive.py index b26fb39..6e3830e 100644 --- a/tests/test_l0_archive.py +++ b/tests/test_l0_archive.py @@ -42,7 +42,7 @@ def _submit(repo: str, distilled: dict, **extra_args): store = SessionStore() out = distill.handle_distill_conversation( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "mode": "submit", "distilled": distilled, **extra_args, @@ -161,7 +161,7 @@ def test_source_ref_repointed_across_conflict_rounds(tmp_path): r0 = json.loads( handle_ingest_note( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "title": "alpha beta gamma delta epsilon", "note_type": "pitfall", "content": "existing", @@ -172,7 +172,7 @@ def test_source_ref_repointed_across_conflict_rounds(tmp_path): ) handle_confirm_note( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "note_file": Path(r0["note_path"]).name, }, store, @@ -281,7 +281,7 @@ def test_query_wiki_surfaces_source_ref(tmp_path): resp = json.loads( handle_query_wiki( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "query": "缓存击穿", }, store, @@ -300,7 +300,7 @@ def test_conflict_pending_keeps_raw_unarchived(tmp_path): handle_ingest_note( { - "output_dir": f"{repo}/repowiki", + "repo_path": repo, "title": "alpha beta gamma delta epsilon", "note_type": "pitfall", "content": "existing", diff --git a/tests/test_lint_fix.py b/tests/test_lint_fix.py index aef9cc7..0154cbf 100644 --- a/tests/test_lint_fix.py +++ b/tests/test_lint_fix.py @@ -11,7 +11,7 @@ def _run_lint(output_dir: Path, **kwargs) -> dict: - args = {"output_dir": str(output_dir), "checks": ["stale_refs"], **kwargs} + args = {"repo_path": str(output_dir.parent), "checks": ["stale_refs"], **kwargs} out = handle_lint_wiki(args, None) return json.loads(out) if isinstance(out, str) else out diff --git a/tests/test_lint_layout_violations.py b/tests/test_lint_layout_violations.py index 35b0835..78c0fe0 100644 --- a/tests/test_lint_layout_violations.py +++ b/tests/test_lint_layout_violations.py @@ -48,7 +48,7 @@ def _init_centralized(tmp_path): def _lint(ws): res = json.loads( handle_lint_wiki( - {"output_dir": str(ws / "repowiki"), "checks": ["layout_violations"]}, + {"repo_path": str(ws), "checks": ["layout_violations"]}, _StubStore(), ) ) @@ -108,7 +108,7 @@ def test_inert_outside_centralized_corpus(self, tmp_path): ) res = json.loads( handle_lint_wiki( - {"output_dir": str(repo / "repowiki"), "checks": ["layout_violations"]}, + {"repo_path": str(repo), "checks": ["layout_violations"]}, _StubStore(), ) ) diff --git a/tests/test_low_adoption.py b/tests/test_low_adoption.py index 11bb14b..007e239 100644 --- a/tests/test_low_adoption.py +++ b/tests/test_low_adoption.py @@ -227,7 +227,7 @@ def test_dispatch_all_surfaces_low_adoption(tmp_path): _adopt(od, "notes/other.md") store = SessionStore() - resp = json.loads(handle_lint_wiki({"output_dir": str(od), "checks": ["all"]}, store)) + resp = json.loads(handle_lint_wiki({"repo_path": str(od.parent), "checks": ["all"]}, store)) issues = _adopted(resp.get("issues", [])) assert len(issues) == 1 assert issues[0]["severity"] == "warning" diff --git a/tests/test_phase2_concurrency.py b/tests/test_phase2_concurrency.py index 2f543a6..0b6608a 100644 --- a/tests/test_phase2_concurrency.py +++ b/tests/test_phase2_concurrency.py @@ -114,9 +114,9 @@ def test_gc_bindings_noop_without_dir(tmp_path): def test_create_task_duplicate_still_rejected(tmp_path): out = str(tmp_path) - r1 = json.loads(handle_create_task({"output_dir": out, "title": "Alpha Task"}, _StubStore())) + r1 = json.loads(handle_create_task({"repo_path": out, "title": "Alpha Task"}, _StubStore())) assert r1.get("ok") is True - r2 = json.loads(handle_create_task({"output_dir": out, "title": "Alpha Task"}, _StubStore())) + r2 = json.loads(handle_create_task({"repo_path": out, "title": "Alpha Task"}, _StubStore())) assert "error" in r2 and "already exists" in r2["error"] @@ -384,7 +384,7 @@ def test_create_task_concurrent_same_title_one_wins(tmp_path): def create(): results.append( - json.loads(handle_create_task({"output_dir": out, "title": "Race"}, _StubStore())) + json.loads(handle_create_task({"repo_path": out, "title": "Race"}, _StubStore())) ) t1 = threading.Thread(target=create) diff --git a/tests/test_phase3_4_first_slice.py b/tests/test_phase3_4_first_slice.py index 062314c..a8bdaec 100644 --- a/tests/test_phase3_4_first_slice.py +++ b/tests/test_phase3_4_first_slice.py @@ -43,7 +43,7 @@ def test_ingest_note_stamps_author(tmp_path, monkeypatch): git_sync._checked_repos.clear() # isolate sync_check once-per-process state res = json.loads( handle_ingest_note( - {"output_dir": str(tmp_path), "title": "作者标注", "content": "正文"}, + {"repo_path": str(tmp_path), "title": "作者标注", "content": "正文"}, _StubStore(), ) ) diff --git a/tests/test_promotion.py b/tests/test_promotion.py index 2f4a4f9..b8bfe97 100644 --- a/tests/test_promotion.py +++ b/tests/test_promotion.py @@ -257,7 +257,7 @@ def test_wiki_stats_surfaces_promotion_candidates(self, tmp_path): _write_note(od, "note-a.md", date=_days_ago(15)) _seed_stats_table(od, "notes/note-a.md") _seed_adoption(od, "notes/note-a.md", 3) - out = json.loads(handle_wiki_stats({"output_dir": str(od)}, SessionStore())) + out = json.loads(handle_wiki_stats({"repo_path": str(od.parent)}, SessionStore())) assert "promotion_candidates" in out cands = out["promotion_candidates"] assert len(cands) == 1 @@ -270,7 +270,7 @@ def test_wiki_stats_omits_when_no_candidates(self, tmp_path): _write_note(od, "note-a.md", date=_days_ago(15)) _seed_stats_table(od, "notes/note-a.md") _seed_adoption(od, "notes/note-a.md", 1) - out = json.loads(handle_wiki_stats({"output_dir": str(od)}, SessionStore())) + out = json.loads(handle_wiki_stats({"repo_path": str(od.parent)}, SessionStore())) assert "promotion_candidates" not in out def test_ranked_by_adopted_count(self, tmp_path): @@ -337,11 +337,11 @@ def test_handler_interpolates_arguments(self): text = _prompt_promote_note( { "note_file": "notes/2026-08-01-port-conflict.md", - "output_dir": "D:/repo/repowiki", + "repo_path": "D:/repo", } ) assert "notes/2026-08-01-port-conflict.md" in text - assert "D:/repo/repowiki" in text + assert "D:/repo" in text.replace("\\", "/") or "D:\\repo" in text def test_registered_in_prompt_list(self): srv = _FakeServer() @@ -351,7 +351,8 @@ def test_registered_in_prompt_list(self): assert "promote-note" in names entry = next(p for p in prompts if p.name == "promote-note") arg_names = {a.name for a in entry.arguments} - assert {"note_file", "output_dir", "repo_path"} <= arg_names + assert {"note_file", "repo_path"} <= arg_names + assert "output_dir" not in arg_names def test_get_prompt_dispatches_to_handler(self): srv = _FakeServer() diff --git a/tests/test_query_repo_filter.py b/tests/test_query_repo_filter.py index 1068207..7a8ba8e 100644 --- a/tests/test_query_repo_filter.py +++ b/tests/test_query_repo_filter.py @@ -107,7 +107,7 @@ def _files(res): class TestQueryRepoFilter: def test_one_hop_default_covers_all_repos(self, tmp_path): ws = _setup_workspace(tmp_path) - res = _query(ws, output_dir=ws / "repowiki") + res = _query(ws, repo_path=ws) files = _files(res) assert "wiki/modules/a/autha.md" in files assert "wiki/modules/b/authb.md" in files @@ -115,7 +115,7 @@ def test_one_hop_default_covers_all_repos(self, tmp_path): def test_repo_filter_returns_applicable_knowledge_only(self, tmp_path): ws = _setup_workspace(tmp_path) - res = _query(ws, repo="a", output_dir=ws / "repowiki") + res = _query(ws, repo="a", repo_path=ws) assert res.get("repo_filter") == "a" files = _files(res) # a's partition + shared page tagged a + global page + a-tagged note @@ -129,7 +129,7 @@ def test_repo_filter_returns_applicable_knowledge_only(self, tmp_path): def test_repo_filter_other_repo(self, tmp_path): ws = _setup_workspace(tmp_path) - res = _query(ws, repo="b", output_dir=ws / "repowiki") + res = _query(ws, repo="b", repo_path=ws) files = _files(res) assert "wiki/modules/b/authb.md" in files assert "wiki/entities/OnlyBZebra.md" in files @@ -152,18 +152,24 @@ def test_repo_path_fallback_with_filter(self, tmp_path): assert "wiki/modules/a/autha.md" in files assert "wiki/modules/b/authb.md" not in files - def test_output_dir_corpus_with_repo_filter(self, tmp_path): - """output_dir picks the corpus; repo= narrows within it.""" + def test_explicit_output_dir_ignored_on_query(self, tmp_path): + """The retired output_dir parameter cannot narrow the corpus: the + layout-derived corpus (workspace repowiki) always wins.""" ws = _setup_workspace(tmp_path) - # Corpus limited to a's partition: b's pages are not in the corpus at all. - res = _query(ws, repo="a", output_dir=ws / "repowiki" / "wiki" / "modules" / "a") + # output_dir points into a's partition, but the derived corpus from + # repo_path covers the whole workspace repowiki — b's partition and + # shared pools stay reachable, proving the explicit value was ignored. + res = _query( + ws, repo_path=ws / "a", output_dir=ws / "repowiki" / "wiki" / "modules" / "a" + ) files = _files(res) - assert any("autha.md" in f for f in files) - assert not any("authb.md" in f for f in files) + assert "wiki/modules/b/authb.md" in files + assert "wiki/entities/SharedZebra.md" in files + assert "wiki/concepts/GlobalZebraConvention.md" in files def test_repo_filter_unknown_repo_returns_only_globals(self, tmp_path): ws = _setup_workspace(tmp_path) - res = _query(ws, repo="ghost", output_dir=ws / "repowiki") + res = _query(ws, repo="ghost", repo_path=ws) files = _files(res) # no partition and no tagged pages for "ghost" — only global pages match assert "wiki/concepts/GlobalZebraConvention.md" in files @@ -178,7 +184,7 @@ def test_repo_filter_inert_outside_centralized_corpus(self, tmp_path): (repowiki / "wiki" / "modules" / "solo.md").write_text( "# Solo zebra\n\nsolo zebra module\n", encoding="utf-8" ) - res = _query(tmp_path, repo="anything", output_dir=repowiki) + res = _query(tmp_path, repo="anything", repo_path=repo) # Filter inert: the page is found even though it is not under an # "anything/" partition, and no repo_filter is reported. assert any("solo.md" in f for f in _files(res)) diff --git a/tests/test_query_transparency.py b/tests/test_query_transparency.py index 6b70b10..82cb4b3 100644 --- a/tests/test_query_transparency.py +++ b/tests/test_query_transparency.py @@ -46,7 +46,7 @@ def _mk_wiki(tmp_path) -> Path: def _query(od: Path, **kw) -> dict: - args = {"output_dir": str(od), "query": kw.pop("query", "认证")} + args = {"repo_path": str(od.parent), "query": kw.pop("query", "认证")} args.update(kw) return json.loads(handle_query_wiki(args, SessionStore())) @@ -171,7 +171,7 @@ def test_wiki_stats_surfaces_cold(self, tmp_path): od = _mk_wiki(tmp_path) old = (datetime.now() - timedelta(days=220)).strftime("%Y-%m-%d") _seed_stats(od, [("wiki/modules/auth.md", 5, old)]) - out = json.loads(handle_wiki_stats({"output_dir": str(od)}, SessionStore())) + out = json.loads(handle_wiki_stats({"repo_path": str(od.parent)}, SessionStore())) assert "cold_candidates" in out assert out["cold_candidates"][0]["file_path"] == "wiki/modules/auth.md" diff --git a/tests/test_remove_repo_cleanup.py b/tests/test_remove_repo_cleanup.py index 9589232..4e6f715 100644 --- a/tests/test_remove_repo_cleanup.py +++ b/tests/test_remove_repo_cleanup.py @@ -111,7 +111,7 @@ def test_orphan_surfaced_by_lint(self, tmp_path): _remove(ws, "a") res = json.loads( handle_lint_wiki( - {"output_dir": str(ws / "repowiki"), "checks": ["layout_violations"]}, + {"repo_path": str(ws), "checks": ["layout_violations"]}, _StubStore(), ) ) diff --git a/tests/test_scope_writes.py b/tests/test_scope_writes.py index 54a3a2b..c2530d6 100644 --- a/tests/test_scope_writes.py +++ b/tests/test_scope_writes.py @@ -210,7 +210,7 @@ def test_global_and_multi_repo_filter_hits(self, tmp_path): def query(repo): return json.loads( handle_query_wiki( - {"query": "quokka", "repo": repo, "output_dir": str(ws / "repowiki")}, + {"query": "quokka", "repo": repo, "repo_path": str(ws)}, _StubStore(), ) ) diff --git a/tests/test_target_anchor_guards.py b/tests/test_target_anchor_guards.py index 4d5e579..79113b1 100644 --- a/tests/test_target_anchor_guards.py +++ b/tests/test_target_anchor_guards.py @@ -1,11 +1,12 @@ """Schema-level target-anchor guards (A+B+C). -Regression coverage for the "agents omit output_dir/repo_path" problem: -- A: anyOf(output_dir | repo_path) is post-processed into every dual-anchor - knowledge-base tool schema that requires neither path. +Regression coverage for the "agents omit output_dir/repo_path" problem, +updated for the output_dir-retirement world: +- A: no registered tool advertises the retired output_dir; knowledge-base + tools expose repo_path as the single anchor. - B: dispatch injects repo_path=<server start CWD> when a call carries no - explicit anchor (output_dir/repo_path), so resolution succeeds instead - of raising. Explicit arguments are never overwritten. + explicit repo_path, so resolution succeeds instead of raising. Explicit + repo_path is never overwritten (output_dir is NOT an anchor anymore). - C: resolve_output_dir raises an actionable error and dispatch wraps it in a JSON payload with a "fix" field. """ @@ -23,46 +24,27 @@ # --------------------------------------------------------------------------- # -# A: schema-level anyOf guard +# A: single-anchor schema invariant (output_dir retired) # --------------------------------------------------------------------------- # -@pytest.mark.parametrize( - "tool_name", - [ - "capture_conversation", - "query_wiki", - "ingest_note", - "ingest_source", - "distill_conversation", - "lint_wiki", - "confirm_note", - "reject_note", - "wiki_stats", - "write_doc_file", - ], -) -def test_dual_anchor_tools_gain_anyof(tool_name: str) -> None: - """Tools exposing both output_dir and repo_path (neither required) must - advertise anyOf so clients/LLMs treat one of them as required.""" - schema = REGISTRY[tool_name].schema.inputSchema - assert schema.get("anyOf") == [ - {"required": ["output_dir"]}, - {"required": ["repo_path"]}, - ] +def test_no_tool_advertises_output_dir() -> None: + """Exhaustive invariant: output_dir is retired — it must not appear in any + registered tool schema. repo_path is the single target anchor.""" + for name, td in REGISTRY.items(): + props = td.schema.inputSchema.get("properties") or {} + assert "output_dir" not in props, f"{name} still advertises output_dir" def test_tool_already_requiring_anchor_is_untouched() -> None: - # analyze_repo already requires repo_path — an anyOf would be redundant. + # analyze_repo already requires repo_path — no anchor fallback needed. schema = REGISTRY["analyze_repo"].schema.inputSchema assert "repo_path" in schema["required"] - assert "anyOf" not in schema def test_query_wiki_schema_declares_repo_path() -> None: - """query_wiki's handler derives output_dir from repo_path, but the schema - used to omit the parameter — the main source of runtime "output_dir is - required" errors. It must now expose repo_path.""" + """query_wiki's handler derives output_dir from repo_path; the schema must + expose repo_path so clients never hit a runtime anchor error.""" props = REGISTRY["query_wiki"].schema.inputSchema["properties"] assert "repo_path" in props @@ -72,24 +54,6 @@ def test_batch_ingest_schema_declares_repo_path() -> None: assert "repo_path" in props -def test_all_dual_anchor_tools_get_anyof() -> None: - """Exhaustive invariant: every registered tool whose properties include - both anchors — and requires neither — must carry the anyOf guard.""" - from codewiki.mcp.registry import _apply_target_anchor_anyof - - _apply_target_anchor_anyof() # idempotent re-apply - for name, td in REGISTRY.items(): - schema = td.schema.inputSchema - props = schema.get("properties") or {} - if "output_dir" not in props or "repo_path" not in props: - continue - required = set(schema.get("required") or []) - if "output_dir" in required or "repo_path" in required: - assert "anyOf" not in schema, f"{name} already requires an anchor" - continue - assert schema.get("anyOf"), f"{name} missing anyOf guard" - - # --------------------------------------------------------------------------- # # B: server-CWD fallback injection # --------------------------------------------------------------------------- # @@ -103,10 +67,12 @@ def test_inject_fills_only_when_fully_absent(monkeypatch, tmp_path) -> None: _inject_repo_path_default(a) assert a == {"repo_path": cwd} - # Explicit anchors are never overwritten. + # A legacy output_dir arg is NOT an anchor in the retired world: the call + # still needs repo_path and gets the CWD fallback. Explicit repo_path is + # never overwritten. b = {"output_dir": "x"} _inject_repo_path_default(b) - assert b == {"output_dir": "x"} + assert b == {"output_dir": "x", "repo_path": cwd} c = {"repo_path": "y"} _inject_repo_path_default(c) diff --git a/tests/test_task_manager.py b/tests/test_task_manager.py index 34ad8b1..787f23f 100644 --- a/tests/test_task_manager.py +++ b/tests/test_task_manager.py @@ -19,10 +19,6 @@ # --------------------------------------------------------------------------- # -def _od(repo: str) -> str: - return f"{repo}/repowiki" - - def _store() -> SessionStore: return SessionStore() @@ -40,7 +36,7 @@ def test_create_and_list_tasks(tmp_path): repo = str(tmp_path) r = _call( tm.handle_create_task, - output_dir=_od(repo), + repo_path=repo, title="实现登录鉴权", description="做一个 OAuth2 登录", ) @@ -49,68 +45,68 @@ def test_create_and_list_tasks(tmp_path): assert task_id assert r["task"]["status"] == "active" - lst = _call(tm.handle_list_tasks, output_dir=_od(repo)) + lst = _call(tm.handle_list_tasks, repo_path=repo) assert lst["ok"] is True ids = [t["id"] for t in lst["tasks"]] assert task_id in ids # Status filter: only active tasks, and none completed yet. - active = _call(tm.handle_list_tasks, output_dir=_od(repo), status="active") + active = _call(tm.handle_list_tasks, repo_path=repo, status="active") assert any(t["id"] == task_id for t in active["tasks"]) - done = _call(tm.handle_list_tasks, output_dir=_od(repo), status="completed") + done = _call(tm.handle_list_tasks, repo_path=repo, status="completed") assert all(t["id"] != task_id for t in done["tasks"]) def test_duplicate_title_rejected(tmp_path): repo = str(tmp_path) - first = _call(tm.handle_create_task, output_dir=_od(repo), title="重构订单模块") + first = _call(tm.handle_create_task, repo_path=repo, title="重构订单模块") assert first["ok"] is True - dup = _call(tm.handle_create_task, output_dir=_od(repo), title="重构订单模块") + dup = _call(tm.handle_create_task, repo_path=repo, title="重构订单模块") assert "error" in dup assert "already exists" in dup["error"] def test_complete_task(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="写单元测试") + r = _call(tm.handle_create_task, repo_path=repo, title="写单元测试") task_id = r["task"]["id"] - c = _call(tm.handle_complete_task, output_dir=_od(repo), task_id=task_id) + c = _call(tm.handle_complete_task, repo_path=repo, task_id=task_id) assert c["ok"] is True assert c["task"]["status"] == "completed" assert c["task"].get("completed_at") # Completed tasks disappear from active listing. - active = _call(tm.handle_list_tasks, output_dir=_od(repo), status="active") + active = _call(tm.handle_list_tasks, repo_path=repo, status="active") assert all(t["id"] != task_id for t in active["tasks"]) def test_add_and_get_task_memory(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="迁移数据库") + r = _call(tm.handle_create_task, repo_path=repo, title="迁移数据库") task_id = r["task"]["id"] m1 = _call( tm.handle_add_task_memory, - output_dir=_od(repo), + repo_path=repo, task_id=task_id, content="完成 schema 迁移脚本", ) assert m1["ok"] is True m2 = _call( - tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content="验证数据一致性" + tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content="验证数据一致性" ) assert m2["ok"] is True - got = _call(tm.handle_get_task, output_dir=_od(repo), task_id=task_id) + got = _call(tm.handle_get_task, repo_path=repo, task_id=task_id) assert "完成 schema 迁移脚本" in got["memories"] assert "验证数据一致性" in got["memories"] def test_add_memory_to_missing_task(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_add_task_memory, output_dir=_od(repo), task_id="ghost-task", content="x") + r = _call(tm.handle_add_task_memory, repo_path=repo, task_id="ghost-task", content="x") assert "error" in r assert "does not exist" in r["error"] @@ -122,12 +118,12 @@ def test_add_memory_to_missing_task(tmp_path): def test_set_session_task_binding(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="修复登录 bug") + r = _call(tm.handle_create_task, repo_path=repo, title="修复登录 bug") task_id = r["task"]["id"] b = _call( tm.handle_set_session_task, - output_dir=_od(repo), + repo_path=repo, source_session_id="session-abc", task_id=task_id, ) @@ -144,7 +140,7 @@ def test_set_session_task_binding(tmp_path): def test_set_session_task_rejects_missing_task(tmp_path): repo = str(tmp_path) r = _call( - tm.handle_set_session_task, output_dir=_od(repo), source_session_id="s1", task_id="nope" + tm.handle_set_session_task, repo_path=repo, source_session_id="s1", task_id="nope" ) assert "error" in r @@ -152,12 +148,12 @@ def test_set_session_task_rejects_missing_task(tmp_path): def test_capture_resolves_task_from_session_binding(tmp_path): """正向:set_session_task 建绑定后,capture 仅凭 source_session_id 就能盖章 task_id。""" repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="绑定回退任务") + r = _call(tm.handle_create_task, repo_path=repo, title="绑定回退任务") task_id = r["task"]["id"] _call( tm.handle_set_session_task, - output_dir=_od(repo), + repo_path=repo, source_session_id="session-bound", task_id=task_id, ) @@ -165,7 +161,7 @@ def test_capture_resolves_task_from_session_binding(tmp_path): # 不传 task_id,只传 source_session_id —— 修复后应从绑定文件反查并盖章。 cap = _call( capture.handle_capture_conversation, - output_dir=_od(repo), + repo_path=repo, source_session_id="session-bound", conversation=[ {"role": "user", "content": "绑定回退任务的对话内容"}, @@ -189,7 +185,7 @@ def test_capture_without_binding_keeps_taskless(tmp_path): repo = str(tmp_path) cap = _call( capture.handle_capture_conversation, - output_dir=_od(repo), + repo_path=repo, source_session_id="session-nobody", conversation=[ {"role": "user", "content": "无任务关联的对话"}, @@ -204,12 +200,12 @@ def test_capture_without_binding_keeps_taskless(tmp_path): def test_capture_deletes_binding_after_successful_write(tmp_path): """绑定是一次性消费凭证:捕获成功落盘后绑定文件应被自动删除。""" repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="消费即删任务") + r = _call(tm.handle_create_task, repo_path=repo, title="消费即删任务") task_id = r["task"]["id"] _call( tm.handle_set_session_task, - output_dir=_od(repo), + repo_path=repo, source_session_id="session-once", task_id=task_id, ) @@ -220,7 +216,7 @@ def test_capture_deletes_binding_after_successful_write(tmp_path): cap = _call( capture.handle_capture_conversation, - output_dir=_od(repo), + repo_path=repo, source_session_id="session-once", conversation=[ {"role": "user", "content": "本次会话的对话"}, @@ -236,7 +232,7 @@ def test_capture_deletes_binding_after_successful_write(tmp_path): # 无绑定后再次捕获同一会话 → supersede 应继承原 task_id,归属不丢。 cap2 = _call( capture.handle_capture_conversation, - output_dir=_od(repo), + repo_path=repo, source_session_id="session-once", conversation=[ {"role": "user", "content": "本次会话的对话"}, @@ -258,12 +254,12 @@ def test_capture_deletes_binding_after_successful_write(tmp_path): def test_explicit_task_id_does_not_consume_binding(tmp_path): """显式传 task_id 时不消费绑定:绑定文件保留,供后续无显式 task_id 的捕获使用。""" repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="显式任务") + r = _call(tm.handle_create_task, repo_path=repo, title="显式任务") task_id = r["task"]["id"] _call( tm.handle_set_session_task, - output_dir=_od(repo), + repo_path=repo, source_session_id="session-explicit", task_id=task_id, ) @@ -275,7 +271,7 @@ def test_explicit_task_id_does_not_consume_binding(tmp_path): # 显式传 task_id(task_source="argument")→ 不触发绑定消费。 cap = _call( capture.handle_capture_conversation, - output_dir=_od(repo), + repo_path=repo, source_session_id="session-explicit", task_id=task_id, conversation=[ @@ -290,22 +286,22 @@ def test_explicit_task_id_does_not_consume_binding(tmp_path): def test_delete_task_cascades_binding(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="要删除的任务") + r = _call(tm.handle_create_task, repo_path=repo, title="要删除的任务") task_id = r["task"]["id"] _call( tm.handle_set_session_task, - output_dir=_od(repo), + repo_path=repo, source_session_id="session-xyz", task_id=task_id, ) - d = _call(tm.handle_delete_task, output_dir=_od(repo), task_id=task_id) + d = _call(tm.handle_delete_task, repo_path=repo, task_id=task_id) assert d["ok"] is True assert d["cleared_bindings"] == 1 # Task gone from index and disk. - lst = _call(tm.handle_list_tasks, output_dir=_od(repo)) + lst = _call(tm.handle_list_tasks, repo_path=repo) assert all(t["id"] != task_id for t in lst["tasks"]) from pathlib import Path @@ -322,16 +318,16 @@ def test_delete_task_cascades_binding(tmp_path): def test_get_task_context_aggregates_related_notes(tmp_path): repo = str(tmp_path) r = _call( - tm.handle_create_task, output_dir=_od(repo), title="聚合任务", description="任务描述体" + tm.handle_create_task, repo_path=repo, title="聚合任务", description="任务描述体" ) task_id = r["task"]["id"] - _call(tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content="记忆条目") + _call(tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content="记忆条目") # A related note stamped with task_id (via ingest_note). nr = _call( kl.handle_ingest_note, - output_dir=_od(repo), + repo_path=repo, title="关联经验", note_type="lesson", content="## 背景\n\n关联内容", @@ -339,7 +335,7 @@ def test_get_task_context_aggregates_related_notes(tmp_path): ) assert "error" not in nr - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) assert ctx["ok"] is True assert ctx["task"]["id"] == task_id assert "任务描述体" in ctx["description"] @@ -357,13 +353,13 @@ def test_end_to_end_task_memory_flow(tmp_path): repo = str(tmp_path) # 1. Create a task. - r = _call(tm.handle_create_task, output_dir=_od(repo), title="端到端任务") + r = _call(tm.handle_create_task, repo_path=repo, title="端到端任务") task_id = r["task"]["id"] # 2. Capture a conversation bound to the task. cap = _call( capture.handle_capture_conversation, - output_dir=_od(repo), + repo_path=repo, conversation=[ {"role": "user", "content": "帮我实现端到端任务的登录功能"}, {"role": "assistant", "content": "已实现登录,采用 JWT 方案"}, @@ -378,7 +374,7 @@ def test_end_to_end_task_memory_flow(tmp_path): # 3. Distill: produce both a wiki note and a task memory. sub = _call( distill.handle_distill_conversation, - output_dir=_od(repo), + repo_path=repo, mode="submit", distilled={ cid: { @@ -403,19 +399,19 @@ def test_end_to_end_task_memory_flow(tmp_path): # 4. Distilled memories are written DIRECTLY (ADR-0002, no confirm gate): # timestamp-headed entry already in memories.md. - got = _call(tm.handle_get_task, output_dir=_od(repo), task_id=task_id) + got = _call(tm.handle_get_task, repo_path=repo, task_id=task_id) assert "JWT 实现" in got["memories"] assert got["memories_total"] == 1 assert got["memories"].startswith("### ") # 5. The note is stamped with task_id and retrievable via query_wiki. # Draft notes surface with an "[unconfirmed]" prefix, so match on substring. - q = _call(kl.handle_query_wiki, output_dir=_od(repo), query="JWT", task_id=task_id) + q = _call(kl.handle_query_wiki, repo_path=repo, query="JWT", task_id=task_id) titles = [res.get("title") for res in q.get("results", [])] assert any("登录采用 JWT 方案" in t for t in titles) # 6. query_wiki with a different task_id returns no matching note. - q2 = _call(kl.handle_query_wiki, output_dir=_od(repo), query="JWT", task_id="some-other-task") + q2 = _call(kl.handle_query_wiki, repo_path=repo, query="JWT", task_id="some-other-task") titles2 = [res.get("title") for res in q2.get("results", [])] assert not any("登录采用 JWT 方案" in t for t in titles2) @@ -425,11 +421,11 @@ def test_end_to_end_task_memory_flow(tmp_path): def test_delete_task_cascades_task_dir(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="级联删除") + r = _call(tm.handle_create_task, repo_path=repo, title="级联删除") task_id = r["task"]["id"] - _call(tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content="已落盘记忆") + _call(tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content="已落盘记忆") - d = _call(tm.handle_delete_task, output_dir=_od(repo), task_id=task_id) + d = _call(tm.handle_delete_task, repo_path=repo, task_id=task_id) assert d["ok"] is True from pathlib import Path @@ -463,14 +459,14 @@ def _write_raw_capture( def test_task_context_pending_raw_count_and_listing(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="注入任务") + r = _call(tm.handle_create_task, repo_path=repo, title="注入任务") task_id = r["task"]["id"] _write_raw_capture(tmp_path, "conv-a.md", task_id, "2026-08-16T01:00:00Z") _write_raw_capture(tmp_path, "conv-b.md", task_id, "2026-08-16T02:00:00Z") _write_raw_capture(tmp_path, "conv-c.md", "other-task", "2026-08-16T03:00:00Z") - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) assert ctx["pending_raw_count"] == 2 relpaths = {e["relpath"] for e in ctx["pending_raws"]} assert relpaths == {"conv-a.md", "conv-b.md"} @@ -481,19 +477,19 @@ def test_task_context_pending_raw_count_and_listing(tmp_path): def test_task_context_distilled_raws_not_counted(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="注入任务") + r = _call(tm.handle_create_task, repo_path=repo, title="注入任务") task_id = r["task"]["id"] _write_raw_capture(tmp_path, "conv-a.md", task_id, "2026-08-16T01:00:00Z", status="distilled") - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) assert ctx["pending_raw_count"] == 0 assert ctx["pending_raws"] == [] def test_task_context_related_notes_carry_status(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="注入任务") + r = _call(tm.handle_create_task, repo_path=repo, title="注入任务") task_id = r["task"]["id"] from pathlib import Path @@ -507,7 +503,7 @@ def test_task_context_related_notes_carry_status(tmp_path): f'---\ntitle: "稳定笔记"\ntask_id: "{task_id}"\nstatus: stable\n---\n\nx', encoding="utf-8" ) - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) statuses = {n["relpath"]: n["status"] for n in ctx["related_notes"]} assert statuses == {"draft-note.md": "draft", "stable-note.md": "stable"} @@ -521,11 +517,11 @@ def test_task_context_related_notes_carry_status(tmp_path): def test_add_task_memory_stamps_timestamp_heading(tmp_path, monkeypatch): monkeypatch.setenv("CODEWIKI_USER", "alice") repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="打头任务") + r = _call(tm.handle_create_task, repo_path=repo, title="打头任务") task_id = r["task"]["id"] - _call(tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content="第一条记忆") - _call(tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content="第二条记忆") + _call(tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content="第一条记忆") + _call(tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content="第二条记忆") from pathlib import Path @@ -547,11 +543,11 @@ def test_append_direct_stamps_heading_and_tolerates_ghost(tmp_path, monkeypatch) monkeypatch.setenv("CODEWIKI_USER", "alice") repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="直写打头") + r = _call(tm.handle_create_task, repo_path=repo, title="直写打头") task_id = r["task"]["id"] # Direct write (ADR-0002): timestamp-headed entries land immediately. - written = tm.append_task_memories_direct(Path(_od(repo)), task_id, ["蒸馏出的任务进度", ""]) + written = tm.append_task_memories_direct(Path(repo) / "repowiki", task_id, ["蒸馏出的任务进度", ""]) assert written == 1 text = (Path(repo) / "repowiki" / "tasks" / task_id / "memories" / "alice.md").read_text( encoding="utf-8" @@ -560,7 +556,7 @@ def test_append_direct_stamps_heading_and_tolerates_ghost(tmp_path, monkeypatch) assert "蒸馏出的任务进度" in text # Ghost task_id tolerated: no write, no crash. - assert tm.append_task_memories_direct(Path(_od(repo)), "ghost-task", ["x"]) == 0 + assert tm.append_task_memories_direct(Path(repo) / "repowiki", "ghost-task", ["x"]) == 0 def test_split_memories_three_formats(): @@ -580,21 +576,21 @@ def test_split_memories_three_formats(): def test_get_task_context_bounded_memories(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="截断任务") + r = _call(tm.handle_create_task, repo_path=repo, title="截断任务") task_id = r["task"]["id"] for i in range(5): - _call(tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content=f"记忆{i}") + _call(tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content=f"记忆{i}") # Default (20) keeps everything. - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) assert ctx["memories_total"] == 5 assert ctx["memories_truncated"] is False assert ctx["compaction_due"] is False assert "记忆4" in ctx["memories"] # max_memories=2 keeps only the most recent two entries. - ctx2 = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id, max_memories=2) + ctx2 = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id, max_memories=2) assert ctx2["memories_total"] == 5 assert ctx2["memories_truncated"] is True assert "记忆4" in ctx2["memories"] and "记忆3" in ctx2["memories"] @@ -602,33 +598,33 @@ def test_get_task_context_bounded_memories(tmp_path): # Invalid max_memories means no limit. ctx3 = _call( - tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id, max_memories="bogus" + tm.handle_get_task_context, repo_path=repo, task_id=task_id, max_memories="bogus" ) assert ctx3["memories_truncated"] is False def test_get_task_bounded_memories_default_five(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="详情截断") + r = _call(tm.handle_create_task, repo_path=repo, title="详情截断") task_id = r["task"]["id"] for i in range(7): - _call(tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content=f"记忆{i}") + _call(tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content=f"记忆{i}") - t = _call(tm.handle_get_task, output_dir=_od(repo), task_id=task_id) + t = _call(tm.handle_get_task, repo_path=repo, task_id=task_id) assert t["memories_total"] == 7 assert t["memories_truncated"] is True assert "记忆6" in t["memories"] and "记忆2" in t["memories"] assert "记忆0" not in t["memories"] # Explicit larger value pages back through older entries. - t2 = _call(tm.handle_get_task, output_dir=_od(repo), task_id=task_id, max_memories=50) + t2 = _call(tm.handle_get_task, repo_path=repo, task_id=task_id, max_memories=50) assert t2["memories_truncated"] is False and t2["memories_total"] == 7 def test_legacy_memories_file_counts_by_paragraph(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="存量回退") + r = _call(tm.handle_create_task, repo_path=repo, title="存量回退") task_id = r["task"]["id"] from pathlib import Path @@ -637,7 +633,7 @@ def test_legacy_memories_file_counts_by_paragraph(tmp_path): mem.parent.mkdir(parents=True, exist_ok=True) mem.write_text("旧一\n\n旧二\n\n旧三\n\n旧四", encoding="utf-8") - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id, max_memories=2) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id, max_memories=2) assert ctx["memories_total"] == 4 assert ctx["memories_truncated"] is True assert "旧四" in ctx["memories"] and "旧一" not in ctx["memories"] @@ -645,7 +641,7 @@ def test_legacy_memories_file_counts_by_paragraph(tmp_path): def test_compaction_due_signal(tmp_path): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="压缩信号") + r = _call(tm.handle_create_task, repo_path=repo, title="压缩信号") task_id = r["task"]["id"] from pathlib import Path @@ -655,23 +651,23 @@ def test_compaction_due_signal(tmp_path): # Below thresholds: 40 entries exactly, small payload. mem.write_text("\n\n".join(f"条目{i}" for i in range(40)), encoding="utf-8") - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id, max_memories=1) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id, max_memories=1) assert ctx["compaction_due"] is False # Count threshold: 41 entries. mem.write_text("\n\n".join(f"条目{i}" for i in range(41)), encoding="utf-8") - ctx2 = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id, max_memories=1) + ctx2 = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id, max_memories=1) assert ctx2["compaction_due"] is True # Byte threshold: 21 entries (beyond keep window) with oversized total payload. mem.write_text("\n\n".join(f"条目{i} " + "x" * 1200 for i in range(21)), encoding="utf-8") - ctx3 = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx3 = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) assert ctx3["compaction_due"] is True # Oversized payload but entries within the keep window: compaction cannot # help (nothing to compress), so the signal stays off. mem.write_text("### 2026-08-24 10:00\n\n" + "x" * (24 * 1024 + 1), encoding="utf-8") - ctx4 = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx4 = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) assert ctx4["compaction_due"] is False @@ -683,7 +679,7 @@ def test_compaction_due_signal(tmp_path): def _make_task_with_entries(tmp_path, title, n_entries, legacy=False): repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title=title) + r = _call(tm.handle_create_task, repo_path=repo, title=title) task_id = r["task"]["id"] if legacy: from pathlib import Path @@ -694,7 +690,7 @@ def _make_task_with_entries(tmp_path, title, n_entries, legacy=False): else: for i in range(n_entries): _call( - tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content=f"记忆{i}" + tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content=f"记忆{i}" ) return repo, task_id @@ -702,7 +698,7 @@ def _make_task_with_entries(tmp_path, title, n_entries, legacy=False): def test_compact_prepare_returns_entries_and_instruction(tmp_path): repo, task_id = _make_task_with_entries(tmp_path, "压缩准备", 41) - p = _call(tm.handle_compact_task_memories, output_dir=_od(repo), task_id=task_id) + p = _call(tm.handle_compact_task_memories, repo_path=repo, task_id=task_id) assert p["ok"] is True and p["compaction_needed"] is True assert len(p["entries_to_compress"]) == 21 # 41 - keep 20 assert all("记忆" in e for e in p["entries_to_compress"]) @@ -714,7 +710,7 @@ def test_compact_prepare_returns_entries_and_instruction(tmp_path): def test_compact_prepare_noop_below_thresholds(tmp_path): repo, task_id = _make_task_with_entries(tmp_path, "压缩空转", 15) - p = _call(tm.handle_compact_task_memories, output_dir=_od(repo), task_id=task_id) + p = _call(tm.handle_compact_task_memories, repo_path=repo, task_id=task_id) assert p["ok"] is True and p["compaction_needed"] is False assert "entries_to_compress" not in p @@ -725,15 +721,15 @@ def test_compact_prepare_noop_when_within_keep_window(tmp_path): from pathlib import Path repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="窗口内空转") + r = _call(tm.handle_create_task, repo_path=repo, title="窗口内空转") task_id = r["task"]["id"] mem = Path(repo) / "repowiki" / "tasks" / task_id / "memories.md" mem.parent.mkdir(parents=True, exist_ok=True) mem.write_text("\n\n".join("x" * 3000 for _ in range(10)), encoding="utf-8") - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id, max_memories=1) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id, max_memories=1) assert ctx["compaction_due"] is False - p = _call(tm.handle_compact_task_memories, output_dir=_od(repo), task_id=task_id) + p = _call(tm.handle_compact_task_memories, repo_path=repo, task_id=task_id) assert p["compaction_needed"] is False @@ -741,12 +737,12 @@ def test_compact_submit_rewrites_and_archives(tmp_path, monkeypatch): monkeypatch.setenv("CODEWIKI_USER", "alice") repo, task_id = _make_task_with_entries(tmp_path, "压缩落盘", 45) - p = _call(tm.handle_compact_task_memories, output_dir=_od(repo), task_id=task_id) + p = _call(tm.handle_compact_task_memories, repo_path=repo, task_id=task_id) assert len(p["entries_to_compress"]) == 25 s = _call( tm.handle_compact_task_memories, - output_dir=_od(repo), + repo_path=repo, task_id=task_id, mode="submit", summary="早期记忆摘要:完成了 A、B,遗留 C 待办。", @@ -776,7 +772,7 @@ def test_compact_submit_rewrites_and_archives(tmp_path, monkeypatch): assert f"记忆{i}" in archive # Bounded read after compaction: summary always kept + recent entries only. - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id, max_memories=3) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id, max_memories=3) assert ctx["memories_total"] == 20 assert ctx["memories_truncated"] is True assert tm._SUMMARY_HEADING in ctx["memories"] @@ -789,7 +785,7 @@ def test_compact_submit_legacy_entries_get_synthetic_heading(tmp_path, monkeypat s = _call( tm.handle_compact_task_memories, - output_dir=_od(repo), + repo_path=repo, task_id=task_id, mode="submit", summary="存量压缩摘要。", @@ -821,7 +817,7 @@ def test_compact_second_round_appends_archive_and_carries_summary(tmp_path, monk _call( tm.handle_compact_task_memories, - output_dir=_od(repo), + repo_path=repo, task_id=task_id, mode="submit", summary="第一轮摘要。", @@ -829,17 +825,17 @@ def test_compact_second_round_appends_archive_and_carries_summary(tmp_path, monk # 20 kept; add 25 more headed entries -> 45 again. for i in range(100, 125): _call( - tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content=f"新记忆{i}" + tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content=f"新记忆{i}" ) - p2 = _call(tm.handle_compact_task_memories, output_dir=_od(repo), task_id=task_id) + p2 = _call(tm.handle_compact_task_memories, repo_path=repo, task_id=task_id) assert p2["compaction_needed"] is True assert "第一轮摘要" in p2["existing_summary"] # caller folds it into the new summary assert len(p2["entries_to_compress"]) == 25 s2 = _call( tm.handle_compact_task_memories, - output_dir=_od(repo), + repo_path=repo, task_id=task_id, mode="submit", summary="第二轮摘要(含第一轮内容)。", @@ -865,22 +861,22 @@ def test_compact_submit_validations(tmp_path): repo, task_id = _make_task_with_entries(tmp_path, "压缩校验", 41) # Missing summary. - r = _call(tm.handle_compact_task_memories, output_dir=_od(repo), task_id=task_id, mode="submit") + r = _call(tm.handle_compact_task_memories, repo_path=repo, task_id=task_id, mode="submit") assert "summary is required" in r["error"] # Oversized summary. r2 = _call( tm.handle_compact_task_memories, - output_dir=_od(repo), + repo_path=repo, task_id=task_id, mode="submit", summary="x" * 2049, ) assert "exceeds" in r2["error"] # Invalid mode. - r3 = _call(tm.handle_compact_task_memories, output_dir=_od(repo), task_id=task_id, mode="bogus") + r3 = _call(tm.handle_compact_task_memories, repo_path=repo, task_id=task_id, mode="bogus") assert "mode must be" in r3["error"] # Unknown task. - r4 = _call(tm.handle_compact_task_memories, output_dir=_od(repo), task_id="ghost-task") + r4 = _call(tm.handle_compact_task_memories, repo_path=repo, task_id="ghost-task") assert "does not exist" in r4["error"] @@ -890,13 +886,13 @@ def test_compact_idempotent_after_compaction(tmp_path, monkeypatch): _call( tm.handle_compact_task_memories, - output_dir=_od(repo), + repo_path=repo, task_id=task_id, mode="submit", summary="一次摘要。", ) # After compaction: 20 kept entries -> below keep window + thresholds -> no-op. - again = _call(tm.handle_compact_task_memories, output_dir=_od(repo), task_id=task_id) + again = _call(tm.handle_compact_task_memories, repo_path=repo, task_id=task_id) assert again["compaction_needed"] is False from pathlib import Path @@ -927,12 +923,12 @@ def _write_user_mem(repo, task_id, owner, content): def test_layered_loading_own_full_others_summary_plus_two(tmp_path, monkeypatch): monkeypatch.setenv("CODEWIKI_USER", "alice") repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="分层加载") + r = _call(tm.handle_create_task, repo_path=repo, title="分层加载") task_id = r["task"]["id"] for i in range(3): _call( - tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content=f"我的记忆{i}" + tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content=f"我的记忆{i}" ) bob_text = ( @@ -943,7 +939,7 @@ def test_layered_loading_own_full_others_summary_plus_two(tmp_path, monkeypatch) ) _write_user_mem(repo, task_id, "bob", bob_text) - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) # Hot layer: all 3 of alice's entries. assert "我的记忆0" in ctx["memories"] and "我的记忆2" in ctx["memories"] # Warm layer: bob's summary + only the 2 most recent entries (Q9/Q11). @@ -959,10 +955,10 @@ def test_layered_loading_own_full_others_summary_plus_two(tmp_path, monkeypatch) def test_get_task_warm_layer_summaries_only(tmp_path, monkeypatch): monkeypatch.setenv("CODEWIKI_USER", "alice") repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="详情分层") + r = _call(tm.handle_create_task, repo_path=repo, title="详情分层") task_id = r["task"]["id"] - _call(tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content="我的记忆") + _call(tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content="我的记忆") _write_user_mem( repo, task_id, @@ -970,7 +966,7 @@ def test_get_task_warm_layer_summaries_only(tmp_path, monkeypatch): f"{tm._SUMMARY_HEADING}\n\nbob 摘要。\n\n### 2026-08-22 11:00\n\nbob 条目", ) - t = _call(tm.handle_get_task, output_dir=_od(repo), task_id=task_id) + t = _call(tm.handle_get_task, repo_path=repo, task_id=task_id) assert "我的记忆" in t["memories"] assert "bob 摘要" in t["memories"] # Summary view: other users' entries are NOT injected (Q9), no hints either. @@ -981,15 +977,15 @@ def test_get_task_warm_layer_summaries_only(tmp_path, monkeypatch): def test_warm_layer_budget_degrades_to_hints(tmp_path, monkeypatch): monkeypatch.setenv("CODEWIKI_USER", "alice") repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="预算降级") + r = _call(tm.handle_create_task, repo_path=repo, title="预算降级") task_id = r["task"]["id"] - _call(tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content="我的记忆") + _call(tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content="我的记忆") big = "y" * (tm._WARM_ENTRY_BUDGET + 10) older = "### 2026-08-20 09:00\n\n" + big + "\n\n### 2026-08-22 11:00\n\nbob 最新内容" _write_user_mem(repo, task_id, "bob", older) - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) # Newest warm entry stays full; the oversized older one degrades to a # one-line hint (Q12): clue kept, bulk dropped, pointer to the live file. assert "bob 最新内容" in ctx["memories"] @@ -1005,7 +1001,7 @@ def test_legacy_file_is_hot_layer_single_user_zero_regression(tmp_path, monkeypa monkeypatch.setenv("CODEWIKI_USER", "alice") repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="存量零回归") + r = _call(tm.handle_create_task, repo_path=repo, title="存量零回归") task_id = r["task"]["id"] raw = "\n\n".join(f"### 2026-08-2{i} 10:00\n\n旧记忆{i}" for i in range(1, 4)) @@ -1013,7 +1009,7 @@ def test_legacy_file_is_hot_layer_single_user_zero_regression(tmp_path, monkeypa mem.parent.mkdir(parents=True, exist_ok=True) mem.write_text(raw, encoding="utf-8") - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) assert ctx["memories"] == raw assert tm._WARM_SECTION_HEADING not in ctx["memories"] @@ -1021,18 +1017,18 @@ def test_legacy_file_is_hot_layer_single_user_zero_regression(tmp_path, monkeypa def test_hot_merge_own_and_legacy_chronological(tmp_path, monkeypatch): monkeypatch.setenv("CODEWIKI_USER", "alice") repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="热层合并") + r = _call(tm.handle_create_task, repo_path=repo, title="热层合并") task_id = r["task"]["id"] # Alice's own file holds the NEWEST entry; legacy holds an older one — # union-merged output must be chronological regardless of file order. - _call(tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content="新记忆") + _call(tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content="新记忆") from pathlib import Path mem = Path(repo) / "repowiki" / "tasks" / task_id / "memories.md" mem.write_text("### 2026-08-01 08:00\n\n旧记忆", encoding="utf-8") - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) assert ctx["memories"].index("旧记忆") < ctx["memories"].index("新记忆") assert ctx["memories_total"] == 2 @@ -1042,11 +1038,11 @@ def test_user_id_change_old_file_becomes_warm_layer(tmp_path, monkeypatch): # information is not sunk; deep history stays reachable via the summary. monkeypatch.setenv("CODEWIKI_USER", "alice") repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="身份变更") + r = _call(tm.handle_create_task, repo_path=repo, title="身份变更") task_id = r["task"]["id"] _call( - tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content="alice 时的记忆" + tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content="alice 时的记忆" ) _write_user_mem( repo, @@ -1056,7 +1052,7 @@ def test_user_id_change_old_file_becomes_warm_layer(tmp_path, monkeypatch): ) monkeypatch.setenv("CODEWIKI_USER", "carol") - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) # alice's file is now warm: her entries enter as the 2 most recent, carol # has no own file so the hot layer is empty. assert "alice 旧条目" in ctx["memories"] @@ -1067,29 +1063,29 @@ def test_compaction_never_touches_other_users_files(tmp_path, monkeypatch): # Q6 invariant: the compactor may only rewrite its own file (+ legacy). monkeypatch.setenv("CODEWIKI_USER", "alice") repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="压缩不动他人") + r = _call(tm.handle_create_task, repo_path=repo, title="压缩不动他人") task_id = r["task"]["id"] for i in range(41): - _call(tm.handle_add_task_memory, output_dir=_od(repo), task_id=task_id, content=f"记忆{i}") + _call(tm.handle_add_task_memory, repo_path=repo, task_id=task_id, content=f"记忆{i}") bob_file = _write_user_mem(repo, task_id, "bob", "### 2026-08-20 09:00\n\nbob 条目一") bob_before = bob_file.read_text(encoding="utf-8") - p = _call(tm.handle_compact_task_memories, output_dir=_od(repo), task_id=task_id) + p = _call(tm.handle_compact_task_memories, repo_path=repo, task_id=task_id) # Other users' entries never enter the compaction unit. assert all("bob" not in e for e in p["entries_to_compress"]) _call( tm.handle_compact_task_memories, - output_dir=_od(repo), + repo_path=repo, task_id=task_id, mode="submit", summary="摘要。", ) assert bob_file.read_text(encoding="utf-8") == bob_before # bob's file is untouched AND stays out of alice's hot layer post-merge. - ctx = _call(tm.handle_get_task_context, output_dir=_od(repo), task_id=task_id) + ctx = _call(tm.handle_get_task_context, repo_path=repo, task_id=task_id) assert ctx["memories_total"] == 21 # 20 kept + bob's 1 @@ -1104,13 +1100,13 @@ def test_index_rebuilt_when_teammate_task_merged_in(tmp_path): from pathlib import Path repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="索引丢失") + r = _call(tm.handle_create_task, repo_path=repo, title="索引丢失") task_id = r["task"]["id"] index = Path(repo) / "repowiki" / "tasks" / ".index.json" index.unlink() # simulate the merge losing the index entirely - lst = _call(tm.handle_list_tasks, output_dir=_od(repo)) + lst = _call(tm.handle_list_tasks, repo_path=repo) ids = [t["id"] for t in lst["tasks"]] assert task_id in ids # Index file rewritten with the rebuilt entry (title/status recovered). @@ -1122,13 +1118,13 @@ def test_index_rebuilt_on_corrupt_json(tmp_path): from pathlib import Path repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="索引损坏") + r = _call(tm.handle_create_task, repo_path=repo, title="索引损坏") task_id = r["task"]["id"] index = Path(repo) / "repowiki" / "tasks" / ".index.json" index.write_text("{not valid json", encoding="utf-8") - got = _call(tm.handle_get_task, output_dir=_od(repo), task_id=task_id) + got = _call(tm.handle_get_task, repo_path=repo, task_id=task_id) assert got["ok"] is True # corrupt index did not sink the task data = json.loads(index.read_text(encoding="utf-8")) assert any(t["id"] == task_id for t in data["tasks"]) @@ -1141,13 +1137,13 @@ def test_index_drops_entries_whose_dir_is_gone(tmp_path): from pathlib import Path repo = str(tmp_path) - r1 = _call(tm.handle_create_task, output_dir=_od(repo), title="保留任务") - r2 = _call(tm.handle_create_task, output_dir=_od(repo), title="他人删除") + r1 = _call(tm.handle_create_task, repo_path=repo, title="保留任务") + r2 = _call(tm.handle_create_task, repo_path=repo, title="他人删除") keep_id, gone_id = r1["task"]["id"], r2["task"]["id"] shutil.rmtree(Path(repo) / "repowiki" / "tasks" / gone_id) - lst = _call(tm.handle_list_tasks, output_dir=_od(repo)) + lst = _call(tm.handle_list_tasks, repo_path=repo) ids = [t["id"] for t in lst["tasks"]] assert keep_id in ids and gone_id not in ids data = json.loads( @@ -1160,19 +1156,19 @@ def test_index_rebuild_idempotent_and_status_preserved(tmp_path): from pathlib import Path repo = str(tmp_path) - r = _call(tm.handle_create_task, output_dir=_od(repo), title="重建幂等") + r = _call(tm.handle_create_task, repo_path=repo, title="重建幂等") task_id = r["task"]["id"] - _call(tm.handle_complete_task, output_dir=_od(repo), task_id=task_id) + _call(tm.handle_complete_task, repo_path=repo, task_id=task_id) - lst1 = _call(tm.handle_list_tasks, output_dir=_od(repo)) - lst2 = _call(tm.handle_list_tasks, output_dir=_od(repo)) + lst1 = _call(tm.handle_list_tasks, repo_path=repo) + lst2 = _call(tm.handle_list_tasks, repo_path=repo) assert lst1["tasks"] == lst2["tasks"] # steady state: no rebuild churn entry = [t for t in lst1["tasks"] if t["id"] == task_id][0] assert entry["status"] == "completed" # completed_at carried through rebuild paths # Force a rebuild (delete index) — status recovered from task.md frontmatter. (Path(repo) / "repowiki" / "tasks" / ".index.json").unlink() - lst3 = _call(tm.handle_list_tasks, output_dir=_od(repo)) + lst3 = _call(tm.handle_list_tasks, repo_path=repo) entry3 = [t for t in lst3["tasks"] if t["id"] == task_id][0] assert entry3["status"] == "completed" assert entry3.get("completed_at") diff --git a/tests/test_team_layout.py b/tests/test_team_layout.py index 3e2f7d5..47dbb34 100644 --- a/tests/test_team_layout.py +++ b/tests/test_team_layout.py @@ -195,7 +195,7 @@ def test_lint_team_layout_gitignore_reports_tracked(git_repo): res = json.loads( handle_lint_wiki( - {"output_dir": str(git_repo / "repowiki"), "checks": ["team_layout_gitignore"]}, + {"repo_path": str(git_repo), "checks": ["team_layout_gitignore"]}, _StubStore(), ) ) diff --git a/tests/test_workspace_bootstrap.py b/tests/test_workspace_bootstrap.py index 4ff289c..09bc754 100644 --- a/tests/test_workspace_bootstrap.py +++ b/tests/test_workspace_bootstrap.py @@ -686,8 +686,9 @@ def test_init_workspace_schema_is_minimal(self): tool_def = registry.REGISTRY["init_workspace"] props = tool_def.schema.inputSchema["properties"] assert tool_def.schema.inputSchema["required"] == [] - assert set(props) == {"output_dir", "layout"} + assert set(props) == {"layout"} assert props["layout"]["enum"] == ["colocated", "centralized"] + assert "output_dir" not in props assert "workspace_path" not in props assert "with_readme" not in props assert "repos" not in props diff --git a/tests/test_workspace_layout.py b/tests/test_workspace_layout.py index f306bc4..2de0992 100644 --- a/tests/test_workspace_layout.py +++ b/tests/test_workspace_layout.py @@ -215,13 +215,17 @@ def test_centralized_reinit_switches_variant(self, tmp_path): agents = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") assert "一跳" in agents - def test_centralized_rejects_custom_output_dir(self, tmp_path): - """Discovery is anchored at <workspace>/repowiki — a custom output_dir - would make the layout config invisible to routing.""" + def test_centralized_ignores_stale_output_dir_arg(self, tmp_path): + """output_dir is retired: init always derives <workspace>/repowiki, so + discovery stays anchored at <workspace>/repowiki/.meta/workspace.json + and a caller-supplied custom directory is never honoured.""" res = _init(tmp_path, layout="centralized", output_dir="custom-wiki") - assert "error" in res - assert "output_dir" in res["error"] - assert not (tmp_path / "custom-wiki" / ".meta" / "workspace.json").exists() + assert res["status"] == "ok" + assert res["output_dir"] == str((tmp_path / "repowiki").resolve()) + assert not (tmp_path / "custom-wiki").exists() + assert json.loads(_config_path(tmp_path).read_text(encoding="utf-8")) == { + "wiki_layout": "centralized" + } # --------------------------------------------------------------------------- From 319f10d2a8bb750125666516d645eb9acd601b87 Mon Sep 17 00:00:00 2001 From: mambo-wang <iamwangbao@163.com> Date: Sun, 6 Sep 2026 15:51:34 +0800 Subject: [PATCH 03/23] =?UTF-8?q?fix(mcp):=20find=5For=5Frestore=20?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E5=B7=B2=E5=88=A0=E9=99=A4=E7=9A=84=20=5FP()?= =?UTF-8?q?=20=E5=AF=BC=E8=87=B4=20NameError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session.py 在收敛改动后遗留 SessionWorkspace(_P(rp), ...),而 _P 已随 output_dir 退休被移除,任何走 find_or_restore 的路径都会抛 NameError (review_changes.prepare 端到端用例复现)。SessionWorkspace 内部已做 Path(repo_path).resolve(),直接传入 rp 即可。 另:.gitignore 忽略 pytest basetemp(.pytest-tmp/,本机瞬态、每次全量跑会 产生上千个文件);新增「发版本」任务记录。 --- .gitignore | 29 +++++++++++++++++++ codewiki/mcp/session.py | 24 +++++++-------- .../task.md" | 9 ++++++ 3 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 "repowiki/tasks/\345\217\221\347\211\210\346\234\254/task.md" diff --git a/.gitignore b/.gitignore index 4e14716..92eef25 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,8 @@ MANIFEST # Tests .pytest_cache/ +# pytest basetemp (configured in pyproject): local-only, never commit +.pytest-tmp/ .coverage htmlcov/ .tox/ @@ -108,3 +110,30 @@ repowiki/.meta/locks/ # Qoder IDE plugin skills (not part of this repo) .qoder/skills/ +# Team-layout Phase 1: 可重建派生物不入库(D1,详见 docs/团队化文件冲突治理与同步策略设计方案.md) +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/wiki/index.md +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/.meta/edit_history.json +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/.meta/metadata.json +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/.meta/module_tree.json +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/.meta/symbol_map.json +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/.meta/project.json +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/.meta/overview_refs.json +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/.meta/aggregate_state.json +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/.meta/source_registry.json +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/.meta/task_bindings/ +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/.meta/locks/ +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/tasks/.index.json +.pytest-tmp/test_still_allows_workspace_ro0/repowiki/distill-jobs.json +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/wiki/index.md +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/.meta/edit_history.json +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/.meta/metadata.json +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/.meta/module_tree.json +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/.meta/symbol_map.json +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/.meta/project.json +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/.meta/overview_refs.json +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/.meta/aggregate_state.json +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/.meta/source_registry.json +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/.meta/task_bindings/ +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/.meta/locks/ +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/tasks/.index.json +.pytest-tmp/test_still_allows_standalone_r0/standalone/repowiki/distill-jobs.json diff --git a/codewiki/mcp/session.py b/codewiki/mcp/session.py index 70e5cb4..116694d 100644 --- a/codewiki/mcp/session.py +++ b/codewiki/mcp/session.py @@ -183,17 +183,14 @@ def find_or_restore(self, repo_path: str) -> Optional[SessionState]: leaf_nodes = cache.get_leaf_nodes() lazy_store = LazyComponentStore(cache, metas) - # Determine output_dir: prefer the dir recorded by the last - # analyze_repo (honours custom output_dir), else repo convention - from pathlib import Path as _P - - output_dir = None - try: - output_dir = cache.get_output_dir() - except Exception: - pass - if not output_dir: - output_dir = str(_P(rp) / "repowiki") + # output_dir is a pure function of repo_path under the active layout — + # never persisted, never inheritable across processes (an external run + # must not re-point later sessions at a foreign directory). Centralized + # member repos derive the workspace-root shared corpus; everything else + # keeps <repo>/repowiki. + from codewiki.mcp.tools.workspace_layout import default_output_dir + + output_dir = str(default_output_dir(rp)) session = self.create( repo_path=rp, @@ -203,10 +200,11 @@ def find_or_restore(self, repo_path: str) -> Optional[SessionState]: cache=cache, ) - # Create a lightweight workspace so write_result works + # Create a lightweight workspace so write_result works. + # SessionWorkspace normalizes the path itself (Path.resolve()). from codewiki.mcp.workspace import SessionWorkspace - workspace = SessionWorkspace(_P(rp), session.session_id) + workspace = SessionWorkspace(rp, session.session_id) session.workspace = workspace return session diff --git "a/repowiki/tasks/\345\217\221\347\211\210\346\234\254/task.md" "b/repowiki/tasks/\345\217\221\347\211\210\346\234\254/task.md" new file mode 100644 index 0000000..e96f738 --- /dev/null +++ "b/repowiki/tasks/\345\217\221\347\211\210\346\234\254/task.md" @@ -0,0 +1,9 @@ +--- +type: task +task_id: 发版本 +title: 发版本 +status: active +created_at: 2026-09-05T13:09:04.705810+00:00 +--- + +发布 PyPI 版本 + 创建 Git Release From 784e2855a78ae51dd73d35d53a9fcb889a9444c6 Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 16:07:51 +0800 Subject: [PATCH 04/23] =?UTF-8?q?=EF=BB=BFfeat(mcp):=20stale=5Fevidence=20?= =?UTF-8?q?=E6=BC=82=E7=A7=BB=E4=BF=A1=E5=8F=B7=E5=9B=9E=E7=81=8C=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=E5=86=B3=E7=AD=96=EF=BC=88B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collect_evidence_drift 作为 lint 与增量的共享采集点(单点收敛), _enrich_stale_evidence 挂变更路径输出 stale_evidence_pages 第三信号源 (与 affected_modules/stale_pages 并列);no_changes 路径显式静默(ADR-0005)。 hint 引导复核+re-stamp 而非重写,守"证据只驱动提醒"红线。 --- codewiki/mcp/tools/analysis.py | 56 +++++ codewiki/mcp/tools/evidence.py | 79 ++++++ codewiki/mcp/tools/wiki_lint.py | 116 +++------ ...005-evidence-drift-silent-on-no-changes.md | 49 ++++ tests/test_stale_evidence_signal.py | 224 ++++++++++++++++++ 5 files changed, 440 insertions(+), 84 deletions(-) create mode 100644 docs/adr/0005-evidence-drift-silent-on-no-changes.md create mode 100644 tests/test_stale_evidence_signal.py diff --git a/codewiki/mcp/tools/analysis.py b/codewiki/mcp/tools/analysis.py index 8878a21..bc5b967 100644 --- a/codewiki/mcp/tools/analysis.py +++ b/codewiki/mcp/tools/analysis.py @@ -330,6 +330,7 @@ def handle_analyze_repo(arguments: Dict[str, Any], store: SessionStore) -> str: changes_info = _detect_doc_changes(repo_path, output_dir, components=metas) if changes_info is not None: changes_info = _enrich_stale_pages(changes_info, output_dir) + changes_info = _enrich_stale_evidence(changes_info, output_dir) workspace.write_json("changes.json", changes_info) # 5. Summary @@ -519,6 +520,10 @@ def _build_no_change_response( if not lang or lang.lower() in ("null", "none", "unknown"): lang = "unknown" langs[lang] = langs.get(lang, 0) + 1 + # D2 stale_pages enriches here too (fingerprint drift may exist with no + # code change), but the D1 evidence signal stays SILENT on this path + # (ADR-0005): no_changes means "code didn't move" — evidence drift then + # waits for an explicit lint_wiki(checks=["stale_evidence"]). changes_info = _enrich_stale_pages(changes_info, output_dir) workspace.write_json("changes.json", changes_info) @@ -787,6 +792,57 @@ def _enrich_stale_pages( return changes_info +def _enrich_stale_evidence( + changes_info: Optional[Dict[str, Any]], output_dir: Path +) -> Optional[Dict[str, Any]]: + """Merge D1 evidence-drift signal into *changes_info* in place (B6). + + Third drift signal alongside ``affected_modules`` (git-diff driven) and + ``stale_pages`` (D2 manifest driven): pages whose ``repo://`` evidence no + longer verifies get a page-level status summary in + ``changes_info["stale_evidence_pages"]``. Semantics differ from + ``stale_pages`` (file-level change) — the two fields coexist without + dedup; a page hit by both signals is honestly double-reported. + + Only the changed-code path enriches. The ``no_changes`` short-circuit + stays silent by design (ADR-0005): drift there waits for an explicit + ``lint_wiki(checks=["stale_evidence"])``. + + The signal drives review, never rewriting: the hint guides the agent to + re-verify claims and re-stamp evidence that still holds, not to blind- + regenerate pages (evidence drift may be relocation, not semantic change). + """ + if not isinstance(changes_info, dict): + return changes_info + if changes_info.get("no_changes"): + return changes_info + from codewiki.mcp.tools.evidence import collect_evidence_drift + + drift = collect_evidence_drift(output_dir) + if not drift: + return changes_info + + pages: Dict[str, Dict[str, int]] = {} + for record in drift: + counts = pages.setdefault( + record["file"], {"stale": 0, "missing": 0, "unresolvable": 0} + ) + counts[record["status"]] += 1 + changes_info["stale_evidence_pages"] = { + page: {k: v for k, v in counts.items() if v} + for page, counts in sorted(pages.items()) + } + total = sum(sum(c.values()) for c in changes_info["stale_evidence_pages"].values()) + changes_info["hint"] = ( + changes_info.get("hint", "") + + f" {total} evidence entr{'y' if total == 1 else 'ies'} drifted across " + f"{len(changes_info['stale_evidence_pages'])} page(s) — review whether the " + "claims still hold: re-stamp evidence that does (stamp_evidence), correct " + "pages that do not. Detail: lint_wiki(checks=['stale_evidence'])." + ) + return changes_info + + def _detect_doc_changes( repo_path: Path, output_dir: Path, diff --git a/codewiki/mcp/tools/evidence.py b/codewiki/mcp/tools/evidence.py index 12417a0..61fd1d0 100644 --- a/codewiki/mcp/tools/evidence.py +++ b/codewiki/mcp/tools/evidence.py @@ -76,6 +76,85 @@ def _add(candidate: Path) -> None: return roots +def collect_evidence_drift(output_dir: Path) -> List[Dict[str, str]]: + """Scan the corpus for drifted code evidence, entry-level detail. + + Shared collection point for both consumers of the D1 evidence signal: + the lint check ``stale_evidence`` (uses the detail records directly) and + the ``analyze_repo`` incremental post-step (aggregates them per page into + ``changes_info.stale_evidence_pages`` — B6). + + Returns a list of ``{"file": <page relpath>, "resource": <repo:// URI>, + "status": "stale"|"missing"|"unresolvable"}`` for every evidence entry + that no longer verifies. Entries without ``content_hash`` are skipped + (D1 progressive-enable semantics: legacy pages never trigger drift). + Evidence drives review only — this function never rewrites content. + """ + from codewiki.src.evidence import verify_entry + + output_dir = Path(output_dir) + base_roots = evidence_roots(output_dir) + drift: List[Dict[str, str]] = [] + + for md_file in output_dir.rglob("*.md"): + if not md_file.is_file(): + continue + parts = set(md_file.relative_to(output_dir).parts) + if parts & {".trash", ".hook-debug", ".meta"} or "raw" in parts: + continue + try: + content = md_file.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + if not content.startswith("---"): + continue + end = content.find("---", 3) + if end < 0: + continue + try: + import yaml + + data = yaml.safe_load(content[3:end]) or {} + except Exception: # noqa: BLE001 - malformed FM is other checks' concern + continue + if not isinstance(data, dict): + continue + sources = data.get("sources") + if isinstance(sources, dict): + sources = [sources] + if not isinstance(sources, list): + continue + + rel_path = str(md_file.relative_to(output_dir)).replace("\\", "/") + for entry in sources: + if not isinstance(entry, dict) or "content_hash" not in entry: + continue + roots = ( + evidence_roots(output_dir, entry.get("repo")) + if entry.get("repo") + else base_roots + ) + statuses = [verify_entry(entry, root) for root in roots] + if "ok" in statuses: + continue + # Same priority as the lint check: drift > gone > broken URI. + if "stale" in statuses: + status = "stale" + elif "missing" in statuses: + status = "missing" + else: + status = "unresolvable" + drift.append( + { + "file": rel_path, + "resource": str(entry.get("resource", "<unknown>")), + "status": status, + } + ) + + return drift + + def _resolve_targets( arguments: Dict[str, Any], store: SessionStore ) -> Tuple[Optional[Path], Optional[Path]]: diff --git a/codewiki/mcp/tools/wiki_lint.py b/codewiki/mcp/tools/wiki_lint.py index fac74e2..3b5c785 100644 --- a/codewiki/mcp/tools/wiki_lint.py +++ b/codewiki/mcp/tools/wiki_lint.py @@ -1000,94 +1000,42 @@ def _check_unsupported_claims( def _check_stale_evidence(output_dir: Path) -> List[Dict[str, Any]]: """Flag pages whose ``repo://`` code evidence no longer matches source. - Reads each page's ``sources`` list for entries carrying a ``content_hash`` - (stamped by ``stamp_evidence``), re-reads the referenced region, and reports - ``stale`` (code drifted) or ``missing`` (file gone) entries. Evidence - drives review only — this check never rewrites content. + Thin wrapper over :func:`collect_evidence_drift` + (``codewiki.mcp.tools.evidence``) — the shared collection point also + feeds the ``analyze_repo`` incremental post-step (B6). Reports ``stale`` + (code drifted) or ``missing`` (file gone) entries. Evidence drives + review only — this check never rewrites content. """ - from codewiki.mcp.tools.evidence import evidence_roots - from codewiki.src.evidence import verify_entry - - # Centralized workspaces keep the code in <ws>/<repo>/ while the corpus is - # <ws>/repowiki, so output_dir.parent (the status-quo repo root) resolves - # nothing — try every plausible root instead. An entry's own `repo` field - # (recorded by stamp_evidence) narrows it to the owning repo. - base_roots = evidence_roots(output_dir) - issues: List[Dict[str, Any]] = [] - - for md_file in output_dir.rglob("*.md"): - if not md_file.is_file(): - continue - parts = set(md_file.relative_to(output_dir).parts) - if parts & _SCRATCH_DIR_NAMES or "raw" in parts: - continue - try: - content = md_file.read_text(encoding="utf-8", errors="replace") - except OSError: - continue - if not content.startswith("---"): - continue - end = content.find("---", 3) - if end < 0: - continue - try: - import yaml - - data = yaml.safe_load(content[3:end]) or {} - except Exception: # noqa: BLE001 - malformed FM is other checks' concern - continue - if not isinstance(data, dict): - continue - sources = data.get("sources") - if isinstance(sources, dict): - sources = [sources] - if not isinstance(sources, list): - continue + from codewiki.mcp.tools.evidence import collect_evidence_drift - rel_path = str(md_file.relative_to(output_dir)).replace("\\", "/") - for entry in sources: - if not isinstance(entry, dict) or "content_hash" not in entry: - continue - roots = ( - evidence_roots(output_dir, entry.get("repo")) - if entry.get("repo") - else base_roots + issues: List[Dict[str, Any]] = [] + for record in collect_evidence_drift(output_dir): + status = record["status"] + resource = record["resource"] + if status == "stale": + message = f"code evidence drifted: {resource}" + suggestion = ( + "Source changed since this page was grounded. Re-verify the " + "claim, then re-stamp via stamp_evidence or edit_doc_file." ) - statuses = [verify_entry(entry, root) for root in roots] - if "ok" in statuses: - continue - # Only report the most actionable verdict: drift > gone > broken URI. - if "stale" in statuses: - status = "stale" - elif "missing" in statuses: - status = "missing" - else: - status = "unresolvable" - resource = str(entry.get("resource", "<unknown>")) - if status == "stale": - message = f"code evidence drifted: {resource}" - suggestion = ( - "Source changed since this page was grounded. Re-verify the " - "claim, then re-stamp via stamp_evidence or edit_doc_file." - ) - elif status == "missing": - message = f"evidence file disappeared: {resource}" - suggestion = ( - "Referenced source no longer exists under the repo root. " - "Re-check the page and re-stamp or remove the entry." - ) - else: - message = f"unresolvable evidence resource: {resource}" - suggestion = "Malformed repo:// resource; re-stamp with a valid URI." - issues.append( - { - "check": "stale_evidence", - "severity": "warning", - "message": message, - "file": rel_path, - "suggestion": suggestion, - } + elif status == "missing": + message = f"evidence file disappeared: {resource}" + suggestion = ( + "Referenced source no longer exists under the repo root. " + "Re-check the page and re-stamp or remove the entry." ) + else: + message = f"unresolvable evidence resource: {resource}" + suggestion = "Malformed repo:// resource; re-stamp with a valid URI." + issues.append( + { + "check": "stale_evidence", + "severity": "warning", + "message": message, + "file": record["file"], + "suggestion": suggestion, + } + ) return issues diff --git a/docs/adr/0005-evidence-drift-silent-on-no-changes.md b/docs/adr/0005-evidence-drift-silent-on-no-changes.md new file mode 100644 index 0000000..449b82f --- /dev/null +++ b/docs/adr/0005-evidence-drift-silent-on-no-changes.md @@ -0,0 +1,49 @@ +# 0005. 证据漂移信号仅在代码变更路径参与增量决策,no_changes 路径保持静默 + +日期:2026-09-06 +状态:已接受 + +## 背景 + +B6(stale_evidence 回灌增量决策)把 D1 证据哈希检查的输出接入 `analyze_repo` 的 +增量信号,成为与 `affected_modules`(git diff 驱动)、`stale_pages`(D2 manifest +驱动)并列的第三信号源。设计拷问中暴露一个路径决策问题:`analyze_repo` 的 +`no_changes` 短路路径(代码无变更、直接复用缓存返回)是否也跑证据漂移扫描? + +注意该路径并非"完全无事可做"——D2 的 `stale_pages` enrich 就挂在 no_changes 路径 +上(`analysis.py` `_build_no_change_response`),因为 manifest 指纹漂移可能在代码 +零变更时发生(sources 被外部编辑)。证据漂移理论上同理:代码哈希没变不会漂,但 +sources 里的 `content_hash` 被手改、或上轮分析后发生过又被回滚的变更,都可能让 +`verify_entry` 失败。 + +## 决策 + +**`no_changes` 路径不跑证据漂移检查,hint 保持 "Documentation is up to date"。** +漂移信号只挂 `handle_analyze_repo` 的变更路径(`_enrich_stale_evidence` 在 +`_enrich_stale_pages` 之后调用)。实现上 `_enrich_stale_evidence` 内部带 +`no_changes` 守卫,且在 `_build_no_change_response` 中显式不调用。 + +## 理由 + +1. **语义对称性换执行对称性是可接受的取舍**:no_changes 意味着"代码没动",证据 + 漂移在该状态下是小概率事件(sources 手改属异常操作);为之付出每次短路都全库 + 扫描的代价不成比例。 +2. **出口仍然存在**:`lint_wiki(checks=["stale_evidence"])` 随时可显式触发全量 + 证据校验;B6 hint 文案里明确引导这个出口。 +3. **"up to date" 文案的可信度**:接受该文案在极端场景下的不精确(有漂移但代码 + 没变),换取短路路径的简洁。若未来实测发现该场景高频,可重新评估。 + +## 后果 + +- 两路漂移信号(D2/D1)在 no_changes 路径行为不对称:stale_pages 参与、 + stale_evidence 静默。这是显式决策而非遗漏,代码注释已标注本 ADR。 +- `_enrich_stale_evidence` 的 no_changes 守卫是防御性的(当前唯一调用点在变更 + 路径),留着以防未来误挂。 + +## 关联 + +- 设计拷问记录:2026-09-06 会话(grill-with-docs),Q3 用户拍板选 (b) 静默 +- D1 设计:`docs/OpenWiki-借鉴详细设计方案.md` §1 +- 实现:`codewiki/mcp/tools/analysis.py::_enrich_stale_evidence`、 + `codewiki/mcp/tools/evidence.py::collect_evidence_drift` +- 测试:`tests/test_stale_evidence_signal.py::test_enrich_silent_on_no_changes_path` diff --git a/tests/test_stale_evidence_signal.py b/tests/test_stale_evidence_signal.py new file mode 100644 index 0000000..4a3a437 --- /dev/null +++ b/tests/test_stale_evidence_signal.py @@ -0,0 +1,224 @@ +"""Tests for B6 — evidence-drift signal feeding incremental decisions. + +Covers: + - ``collect_evidence_drift`` (shared collection point in tools/evidence.py): + fresh / stale / missing / unresolvable matrix, legacy-page skip + - ``_enrich_stale_evidence`` post-step: changed-code path enrichment with + page-level aggregation + review-guiding hint; ``no_changes`` silence + - ``_check_stale_evidence`` lint regression: thin wrapper keeps behavior + - coexistence semantics: ``stale_pages`` (D2) and ``stale_evidence_pages`` + (B6) report the same page independently, no dedup +""" + +from __future__ import annotations + +from pathlib import Path + +from codewiki.mcp.session import SessionStore +from codewiki.mcp.tools.analysis import _enrich_stale_evidence +from codewiki.mcp.tools.evidence import collect_evidence_drift, handle_stamp_evidence +from codewiki.mcp.tools.wiki_lint import _check_stale_evidence + +_CALC = "def add(a, b):\n return a + b\n\ndef sub(a, b):\n return a - b\n" + + +def _mk_repo(tmp_path: Path) -> tuple[Path, Path]: + repo = tmp_path / "repo" + (repo / "src").mkdir(parents=True) + (repo / "src" / "calc.py").write_text(_CALC, encoding="utf-8") + od = repo / "repowiki" + (od / "wiki" / "modules").mkdir(parents=True) + return repo, od + + +def _write_page(od: Path, name: str = "Calc.md") -> Path: + p = od / "wiki" / "modules" / name + p.write_text( + "---\ntype: Architecture\ntitle: Calc\nstatus: stable\n---\n\nbody\n", + encoding="utf-8", + ) + return p + + +def _stamp(od: Path, repo: Path, page: str, resource: str) -> None: + handle_stamp_evidence( + { + "page": page, + "evidence": [{"resource": resource}], + "output_dir": str(od), + "repo_path": str(repo), + }, + SessionStore(), + ) + + +# --------------------------------------------------------------------------- # +# collect_evidence_drift matrix +# --------------------------------------------------------------------------- # +def test_drift_matrix_fresh_stale_missing_unresolvable(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_page(od, "Fresh.md") + _write_page(od, "Drift.md") + _write_page(od, "Gone.md") + _write_page(od, "Broken.md") + (repo / "src" / "fresh.py").write_text(_CALC, encoding="utf-8") + _stamp(od, repo, "wiki/modules/Fresh.md", "repo://src/fresh.py#L1-L2") + _stamp(od, repo, "wiki/modules/Drift.md", "repo://src/calc.py#L1-L2") + (repo / "src" / "gone.py").write_text("x = 1\n", encoding="utf-8") + _stamp(od, repo, "wiki/modules/Gone.md", "repo://src/gone.py") + _stamp(od, repo, "wiki/modules/Broken.md", "repo://src/calc.py#L1-L2") + + # fresh baseline + assert collect_evidence_drift(od) == [] + + # drift: source line region content changes + (repo / "src" / "calc.py").write_text( + "def add(a, b):\n return a * b\n\ndef sub(a, b):\n return a - b\n", + encoding="utf-8", + ) + # gone: referenced file removed + (repo / "src" / "gone.py").unlink() + # broken: rewrite one entry's resource to a malformed URI (keep hash) + broken = od / "wiki" / "modules" / "Broken.md" + text = broken.read_text(encoding="utf-8").replace( + "resource: repo://src/calc.py#L1-L2", "resource: repo:/\\/\\/bad" + ) + broken.write_text(text, encoding="utf-8") + + drift = {r["file"]: r for r in collect_evidence_drift(od)} + assert set(drift) == {"wiki/modules/Drift.md", "wiki/modules/Gone.md", "wiki/modules/Broken.md"} + assert drift["wiki/modules/Drift.md"]["status"] == "stale" + assert drift["wiki/modules/Gone.md"]["status"] == "missing" + assert drift["wiki/modules/Broken.md"]["status"] == "unresolvable" + assert drift["wiki/modules/Drift.md"]["resource"] == "repo://src/calc.py#L1-L2" + + +def test_drift_skips_legacy_pages_without_content_hash(tmp_path): + repo, od = _mk_repo(tmp_path) + # Legacy page: sources WITHOUT content_hash must never trigger drift + # (D1 progressive-enable semantics). + p = od / "wiki" / "modules" / "Legacy.md" + p.write_text( + "---\ntype: Architecture\ntitle: Legacy\nsources:\n - resource: repo://src/calc.py\n---\n\nbody\n", + encoding="utf-8", + ) + (repo / "src" / "calc.py").write_text("changed entirely\n", encoding="utf-8") + assert collect_evidence_drift(od) == [] + + +def test_drift_skips_scratch_and_raw_dirs(tmp_path): + repo, od = _mk_repo(tmp_path) + raw = od / "raw" + raw.mkdir() + (raw / "conv.md").write_text("---\ntitle: x\n---\n", encoding="utf-8") + assert collect_evidence_drift(od) == [] + + +# --------------------------------------------------------------------------- # +# _enrich_stale_evidence post-step +# --------------------------------------------------------------------------- # +def test_enrich_changed_path_aggregates_and_guides_review(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_page(od, "Calc.md") + _stamp(od, repo, "wiki/modules/Calc.md", "repo://src/calc.py#L1-L2") + (repo / "src" / "calc.py").write_text( + "def add(a, b):\n return a * b\n\ndef sub(a, b):\n return a - b\n", + encoding="utf-8", + ) + + changes = {"no_changes": False, "changed_files": ["src/calc.py"], "hint": "Only 1 module(s) need updating."} + result = _enrich_stale_evidence(changes, od) + + assert result["stale_evidence_pages"] == {"wiki/modules/Calc.md": {"stale": 1}} + assert "review" in result["hint"] + assert "stamp_evidence" in result["hint"] # guides re-stamp, not rewrite + assert "lint_wiki(checks=['stale_evidence'])" in result["hint"] + + +def test_enrich_noop_when_evidence_fresh(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_page(od, "Calc.md") + _stamp(od, repo, "wiki/modules/Calc.md", "repo://src/calc.py#L1-L2") + + changes = {"no_changes": False, "changed_files": ["README.md"]} + result = _enrich_stale_evidence(changes, od) + assert "stale_evidence_pages" not in result + assert "hint" not in result # unchanged hint + + +def test_enrich_silent_on_no_changes_path(tmp_path): + """ADR-0005: the no_changes short-circuit stays silent by design.""" + repo, od = _mk_repo(tmp_path) + _write_page(od, "Calc.md") + _stamp(od, repo, "wiki/modules/Calc.md", "repo://src/calc.py#L1-L2") + (repo / "src" / "calc.py").write_text("totally different\n", encoding="utf-8") + + changes = {"no_changes": True, "changed_files": []} + result = _enrich_stale_evidence(changes, od) + assert "stale_evidence_pages" not in result + assert "hint" not in result + + +def test_enrich_handles_none_changes_info(tmp_path): + repo, od = _mk_repo(tmp_path) + assert _enrich_stale_evidence(None, od) is None + + +def test_enrich_coexists_with_stale_pages_no_dedup(tmp_path): + """Same page may appear in both D2 stale_pages and B6 stale_evidence_pages.""" + repo, od = _mk_repo(tmp_path) + _write_page(od, "Calc.md") + _stamp(od, repo, "wiki/modules/Calc.md", "repo://src/calc.py#L1-L2") + (repo / "src" / "calc.py").write_text( + "def add(a, b):\n return a * b\n\ndef sub(a, b):\n return a - b\n", + encoding="utf-8", + ) + + changes = { + "no_changes": False, + "changed_files": ["src/calc.py"], + "stale_pages": ["wiki/modules/Calc.md"], # D2 already flagged it + "hint": "", + } + result = _enrich_stale_evidence(changes, od) + # Both signals present, independently reported + assert result["stale_pages"] == ["wiki/modules/Calc.md"] + assert result["stale_evidence_pages"] == {"wiki/modules/Calc.md": {"stale": 1}} + + +# --------------------------------------------------------------------------- # +# lint thin-wrapper regression +# --------------------------------------------------------------------------- # +def test_lint_wrapper_keeps_issue_shape(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_page(od, "Calc.md") + _stamp(od, repo, "wiki/modules/Calc.md", "repo://src/calc.py#L1-L2") + + assert _check_stale_evidence(od) == [] + + (repo / "src" / "calc.py").write_text( + "def add(a, b):\n return a * b\n\ndef sub(a, b):\n return a - b\n", + encoding="utf-8", + ) + issues = _check_stale_evidence(od) + assert len(issues) == 1 + assert issues[0]["check"] == "stale_evidence" + assert issues[0]["severity"] == "warning" + assert "drifted" in issues[0]["message"] + assert issues[0]["file"] == "wiki/modules/Calc.md" + assert "stamp_evidence" in issues[0]["suggestion"] + + +# --------------------------------------------------------------------------- # +# colocated smoke (multi-root resolution) +# --------------------------------------------------------------------------- # +def test_colocated_workspace_resolves_via_fallback_root(tmp_path): + """Colocated layout: code at output_dir.parent — evidence_roots fallback path.""" + repo, od = _mk_repo(tmp_path) + _write_page(od, "Calc.md") + _stamp(od, repo, "wiki/modules/Calc.md", "repo://src/calc.py#L1-L2") + (repo / "src" / "calc.py").write_text("changed\n", encoding="utf-8") + + drift = collect_evidence_drift(od) + assert len(drift) == 1 + assert drift[0]["status"] == "stale" From 626d68494b9bdac46a386c4edf56369c203d01f8 Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 16:08:00 +0800 Subject: [PATCH 05/23] =?UTF-8?q?=EF=BB=BFdocs:=20DeepWiki=20=E7=B1=BB?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=AF=B9=E6=AF=94=E8=B0=83=E7=A0=94=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=EF=BC=88=E5=90=AB=20B=20=E7=B3=BB=E5=88=97=E5=8B=98?= =?UTF-8?q?=E8=AF=AF=E6=AE=B5=EF=BC=89=E4=B8=8E=E7=B3=BB=E5=88=97=2012=20?= =?UTF-8?q?=E5=85=AC=E4=BC=97=E5=8F=B7=E6=96=87=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四项目源码横评(openwiki/deepwiki-open/OpenDeepWiki/deepwiki-rs), 勘误段记录实现期代码核对发现的 B2/B1 表述偏差与 B6 落地事实。 --- ...40\224\346\212\245\345\221\212-2026-09.md" | 202 ++++++++++++++++++ ...50\260\201\345\234\250\345\201\232Demo.md" | 151 +++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 "docs/DeepWiki\347\261\273\345\274\200\346\272\220\351\241\271\347\233\256\345\257\271\346\257\224\350\260\203\347\240\224\346\212\245\345\221\212-2026-09.md" create mode 100644 "docs/articles/CodeWiki-Plus\347\263\273\345\210\22712\357\274\232\345\233\233\344\270\252DeepWiki\345\244\215\345\210\273\347\232\204\346\272\220\347\240\201\346\250\252\350\257\204\342\200\224\342\200\224\350\260\201\345\234\250\345\201\232\345\267\245\347\250\213\350\260\201\345\234\250\345\201\232Demo.md" diff --git "a/docs/DeepWiki\347\261\273\345\274\200\346\272\220\351\241\271\347\233\256\345\257\271\346\257\224\350\260\203\347\240\224\346\212\245\345\221\212-2026-09.md" "b/docs/DeepWiki\347\261\273\345\274\200\346\272\220\351\241\271\347\233\256\345\257\271\346\257\224\350\260\203\347\240\224\346\212\245\345\221\212-2026-09.md" new file mode 100644 index 0000000..4e0ba2d --- /dev/null +++ "b/docs/DeepWiki\347\261\273\345\274\200\346\272\220\351\241\271\347\233\256\345\257\271\346\257\224\350\260\203\347\240\224\346\212\245\345\221\212-2026-09.md" @@ -0,0 +1,202 @@ +# DeepWiki 类开源项目对比调研报告 + +> 调研日期:2026-09-06 | 调研对象:langchain-ai/openwiki、AsyncFuncAI/deepwiki-open、AIDotNet/OpenDeepWiki、sopaco/deepwiki-rs(Litho) | 视角:与 CodeWiki-CN(mambo-wang/CodeWiki-Plus)的对比与借鉴 +> +> **方法论**:四个项目均克隆源码通读实现(commit 时点 2026-08-15 ~ 2026-09-04),所有代码事实标注 `文件:行号`;GitHub 元数据取自 api.github.com(2026-09-06)。已遵循本仓既有教训——"竞品调研必须克隆源码读代码,文档站可能系统性滞后",本次调研再次验证了该教训(多个项目的 README 与代码存在实质性出入,见 §1)。 +> +> 详细源码调研笔记存档于 `.research-competitors/notes/`(openwiki-notes.md、deepwiki-open-notes.md、OpenDeepWiki-notes.md、deepwiki-rs-notes.md)。 + +--- + +## 一、五项目全景定位 + +| 项目 | Stars | 技术栈 | 产品形态 | 一句话定位 | +|---|---|---|---|---| +| **CodeWiki-CN** | (本仓) | Python + Vue | MCP 服务端(47 工具)+ CLI | 本地优先的 LLM Wiki 生成与知识飞轮,嵌入 IDE Agent 工作流 | +| **openwiki** | 16.2k | TypeScript + LangChain/deepagents | npm CLI(Ink UI)+ 内部 MCP + 宿主集成 | "自维护 wiki":planner→逐页 worker 的 agentic 生成 + 事实治理(Claims) | +| **deepwiki-open** | 17.9k | Next.js 前端 + **Python FastAPI 后端**(AdalFlow) | Web 服务(单 Docker 容器双进程) | Devin DeepWiki 的开源复刻:输入仓库 URL → 生成 wiki + 网页问答 | +| **OpenDeepWiki** | 3.6k | ASP.NET Core(.NET 10)+ Next.js | 自托管多仓库 SaaS 平台 | 多仓/多分支/多语言托管的知识库平台,组织权限 + Admin 后台 + MCP 商业化接口 | +| **deepwiki-rs (Litho)** | 1.7k | Rust(tokio + rig-core) | 纯 CLI | 一次性 C4 架构文档生成器,四阶段声明式 agent 流水线 | + +**代码规模与活跃度**(2026-09-06): + +| 项目 | 首次创建 | 最近推送 | 开放 Issue | 语言 | 协议 | +|---|---|---|---|---|---| +| openwiki | 2026-06-22 | 2026-09-05 | 145 | TypeScript | MIT | +| deepwiki-open | 2025-04-30 | 2026-09-03 | 267 | Python* | MIT | +| OpenDeepWiki | 2025-04-27 | 2026-08-27 | 22 | C# | MIT | +| deepwiki-rs | 2025-09-05 | 2026-08-14 | 2 | Rust | MIT | + +*deepwiki-open 的 GitHub 主语言标记为 Python,但 Next.js 前端 + FastAPI 后端的组合常被误认为纯 TS 项目——核心逻辑全在 Python 侧(`api/main.py:50-72`)。 + +**关键背景注记**:deepwiki-rs 已宣布演进为后继项目 Terrain(README.md:31-40),Litho 收缩为维护态 C4 文档生成器,主理人开发重心已转移;借鉴其机制需考虑该项目的生命周期风险。 + +### README 与代码不符的"宣传水分"(调研发现) + +源码核对发现以下宣称与实现存在实质出入,提示我们任何竞品借鉴前必须读代码: + +| 项目 | 宣称 | 实际 | +|---|---|---| +| deepwiki-rs | 支持 Go(README.md:102) | 语言处理器仅 12 个,无 Go(`src/generator/preprocess/extractors/language_processors/mod.rs:44-57`) | +| deepwiki-rs | "research agents in parallel"(README.md:417) | `do_parallel_with_limit` 仅用于两处,agent 间与目录总结均为串行 for 循环(`src/generator/preprocess/mod.rs:146`) | +| deepwiki-rs | `--skip-*` 旗标可跳过阶段(README.md:495) | 三个旗标是死代码,全库无消费点(`src/cli.rs:36-44`) | +| deepwiki-rs | 缓存省 token | `CacheEntry` 存了 `model_name` 但 `get()` 不比对(`cache/mod.rs:88-109`),换模型命中旧答案 | +| OpenDeepWiki | "多语言代码分析" | 无 AST/语言解析器,代码理解全靠 LLM + ReadFile/ListFiles/Grep(`Agents/Tools/GitTool.cs:770-781`);"多语言"实为输出文档多语言 | +| deepwiki-open | LiteLLM 支持 | LiteLLM 只是自研 10 客户端注册表中的一个可选 provider(`api/clients/litellm.py:8-49`),并非核心抽象 | + +--- + +## 二、实现逻辑对比(六维度) + +### 2.1 架构定位与代码分析方式 + +| 维度 | CodeWiki-CN | openwiki | deepwiki-open | OpenDeepWiki | deepwiki-rs | +|---|---|---|---|---|---| +| 代码分析 | 多语言 AST(Python)+ tree-sitter,调用图 + 服务边界检测 + 跨服务路由匹配(`AnalysisPipeline`) | **零静态分析**,LLM agent 用 ls/glob/grep/read_file 读代码 | **零静态分析**,纯文本文件树 + README + 向量检索 | **零静态分析**,LLM + ReadFile/ListFiles/Grep 三件套 | **正则/关键词匹配冒充 AST**,12 语言处理器用 `content.matches("fn ")` 计数复杂度 | +| 依赖结构 | 组件依赖图 + 拓扑排序驱动生成顺序 | planner prompt 要求按 "owned systems / cross-system workflows" 组织,无机械提取 | 无 | 无 | import 语句正则匹配 | +| 分析产物 | SQLite 缓存(components + relationships + module_tree) | 无持久化分析产物,planner 探索结果即弃 | FAISS 向量库 + LocalDB pickle | 目录树 JSON(LLM 写出) | 进程内 HashMap Memory(作用域隔离) | + +**这是五个项目最根本的分水岭**:CodeWiki 是唯一做精确代码分析(AST/tree-sitter 调用图)的项目。四个竞品全部走"LLM 直接读文件"路线——openwiki 和 OpenDeepWiki 是有意的架构选择(相信 agent 的泛化能力),deepwiki-open 用向量检索替代,deepwiki-rs 的正则匹配介于两者之间但精度最低。四家的代码事实正确性完全依赖 LLM,重构后行号引用易漂移;CodeWiki 的分析图谱是天然更强的重定位预言机(`docs/OpenWiki-借鉴详细设计方案.md` §1.3 的判断依然成立)。 + +### 2.2 文档生成流程 + +| 维度 | CodeWiki-CN | openwiki | deepwiki-open | OpenDeepWiki | deepwiki-rs | +|---|---|---|---|---|---| +| 结构生成 | 依赖图聚类模块树 → 拓扑排序处理顺序 | LLM planner 出 plan(pages[] + deletePages),有界 agent 只能 `submit_plan` | LLM 生成 XML 结构(4-6 页或 8-12 页) | LLM agent 用 `WriteCatalog` 写 JSON 目录 | C4 层级:Preprocess → Research(7 agent 按 C1-C4 组织)→ Compose(6 editor)→ Output | +| 逐页生成 | LLM 按模块树逐模块生成,`[[...]]` 交叉链接 | 每页独立 worker agent,docsOnly 沙箱只能写自己那页 | 复用 RAG 聊天管线逐页生成(向量检索驱动) | `Parallel.ForEachAsync` 并行 + 每篇硬超时 + 断点续跑 | 6 editor 顺序执行,KeyModulesInsight 与 Deep Dive 两处并行 | +| 生成可靠性 | lint_wiki 20 项检查兜底 | 提交校验失败以"可纠正的 tool 错误"回给 worker 重试;崩溃则回滚快照跳页 | 每页 2 次重试 + 失败占位页 | 单篇失败不中断 + 已落盘 path 跳过 | ReAct 迭代耗尽后 summary reasoning 降级兜底 | +| 收尾 | frontmatter 注入 + 交叉链接 + schema 校验 | **确定性收尾(非 LLM)**:mermaid 校验→索引→内链→Claims 投影→provenance 盖章 | 空括号引用后处理成真实带行锚链接 | Skill 包打包(SKILL.md + 文档 zip) | 删除整个输出目录全量重写(`src/generator/outlet/mod.rs:79-82`) | + +**观察**:openwiki 的"确定性收尾"与 CodeWiki 的 lint 后置校验理念一致但更彻底——它把 mermaid 校验、索引同步、内链校验全部做成代码而非 LLM 判断,`index.md` 由代码确定性生成、禁止模型手写(`src/agent/prompts/code.ts:31`)。CodeWiki 的对应收敛点是 lint_wiki,但索引与内链目前部分依赖生成时注入而非收尾时机械统一。 + +### 2.3 检索与问答 + +| 维度 | CodeWiki-CN | openwiki | deepwiki-open | OpenDeepWiki | deepwiki-rs | +|---|---|---|---|---|---| +| 检索方式 | BM25×authority×heat 排序(`cache.py::_doc_authority`) | **无检索**,agent wiki-first 翻文件 | FAISS 向量单路召回 top_k=20,无 rerank | **无 RAG**,目录注入工具描述 + agent 翻文档 | **无检索**(问答在外部姊妹项目) | +| 问答链路 | IDE Agent 经 MCP 调 query_wiki | agent 聊天(wiki-first prompt) | WebSocket 流式 + SSE 回退 | 网页聊天(SSE 流式含 tool_call 事件) | 无 | +| 会话管理 | 任务记忆(分片追加式,多用户 git 隔离) | SQLite checkpointer 持久化 + 裁剪防膨胀 | 前端带全量历史,后端内存重建,重启即失 | 前端带全量历史,服务端仅审计日志 | 无 | +| 多语言 | — | 确定性翻译 pass + 模型只写新增 | prompt 指令 + 每语言独立缓存全量重生成 | LLM 翻译已有目录与文档(不重新生成) | 8 种语言 prompt 指令 | + +**有趣的反差**:star 数最高的两个项目(deepwiki-open 17.9k、openwiki 16.2k)在检索上走了完全相反的极端——deepwiki-open 是五家中唯一有向量 RAG 的,openwiki 干脆放弃检索靠 agent 翻文件。而检索质量上限:deepwiki-open 的纯 FAISS 单路召回(无 rerank、按词数切分跨块语义割裂)其实在工程上是五家中最弱的检索实现,只是"有"而已。CodeWiki 的 BM25×authority×heat 是中间路线,且唯一带采纳反馈闭环(adopted_count 反哺排序)。 + +### 2.4 MCP / Agent 集成 + +| 维度 | CodeWiki-CN | openwiki | deepwiki-open | OpenDeepWiki | deepwiki-rs | +|---|---|---|---|---|---| +| MCP | 核心形态:47 工具,覆盖分析/文档/知识/质量/任务/工作区 | 内部 MCP:6 个生命周期工具(begin/submit_plan/next_page/inspect_claims/submit_page/finish) | **无 MCP** | HTTP MCP:全局 + 仓库级两端点,7 工具,**API Key + OAuth 2.1 + 用量统计** | **无 MCP** | +| 分发方式 | stdio MCP 接入 IDE | npm 包 + 四宿主安装器(Codex/Claude Code/OpenCode/Cursor) | Docker 镜像 | Docker Compose 自托管 | cargo / Smithery 分发 skill | +| 面向 Agent 的输出 | wiki 即 Agent 记忆(query_wiki 按需取用) | wiki 首先写给 Agent 当记忆用 | 无 Agent 出口 | SKILL.md + 文档 zip 打包下载 | `.ai-context/` 分层知识库(Tier0-3 按稳定性) + 纯 Agent 平行实现 skill | + +**关键差异**:OpenDeepWiki 的 MCP 是五家中唯一做了**商业化基建**的——OAuth 2.1 完整流程 + Protected Resource Metadata + 用量统计中间件(`Program.cs:388-392,383`),把"知识库对外服务"当产品能力做。CodeWiki 的 MCP 是面向自有 IDE 工作流的内部工具,openwiki 的 MCP 则是把生成队列开放给外部宿主的"协议化流水线"——三种 MCP 哲学完全不同。 + +### 2.5 增量更新与知识维护 + +| 维度 | CodeWiki-CN | openwiki | deepwiki-open | OpenDeepWiki | deepwiki-rs | +|---|---|---|---|---|---| +| 增量更新 | git diff → affected/cascade 模块 + 页面级 manifest(`page_manifest.py`,D2 已落地) | **四层机制**:git HEAD no-op 检测 + sha256 源指纹 + 页级 checkpoint fast-forward + Claims preflight | **无**(缓存键无 commit hash,上游更新即全 stale) | 仓库级定时检查远程 HEAD,changed files 走 git diff,增量 prompt 禁用 WriteCatalog 防整树重写 | **无**(删目录全量重写) | +| 事实新鲜度 | `stale_after` 时间窗 + `stale_evidence` 证据哈希(D1 已落地) | Grounded Claims:`repo://path#L20-L48` 证据 URI + 行范围重锚 + stale/unresolved preflight | 无 | 无 | 无 | +| 质量治理 | lint_wiki 20 项 + 确认闸门(draft→confirm) + Doctrine 共识 | Claims 账本稀疏 reconciliation(confirm/revise/retract) | 无 | 无 | 无 | +| 评测 | 无(lint 仅结构检查) | **LEDGER 纵向漂移 eval**(supported/stale/invented/unverified 四态 + judge 元评估 ≥0.90)+ DeepSWE 配对实验 | 无 | 无 | 无 | + +**这一维度是 CodeWiki 与 openwiki 的"双雄对峙"**:两者在增量与事实治理上工程化程度远超其余三家,且走了不同的路线——openwiki 用"证据 URI + 哈希 + 重锚"把文档过时变成可机械验证的状态机;CodeWiki 用"分析图谱 + 确认闸门 + 采纳计数"把知识质量变成有人负责的资产。CodeWiki 已在 2026-08-31 借鉴了 openwiki 的 D1-D5(evidence.py、page_manifest.py、stale_evidence 均已落地),本次对比确认那轮借鉴选点准确。 + +### 2.6 工程底座 + +| 维度 | CodeWiki-CN | openwiki | deepwiki-open | OpenDeepWiki | deepwiki-rs | +|---|---|---|---|---|---| +| 并发控制 | 生成走 LLM 后端并发配置 | 页队列严格串行(成本/一致性取舍) | 三层信号量(RAG 索引 4 / 任务池 CPU/2 / 页级 1) | **DB 租约 + 槽位 + 心跳 + 崩溃恢复**(集群级,单体内实现分布式调度) | tokio Semaphore 限流,仅两处使用 | +| 任务恢复 | 增量锚点(commit_id)复用 | 持久化队列 `.run.json`,CI 挂了重跑即续 | get-or-create 幂等提交 + 终端态 TTL 300s | DB 轮询 + 已落盘 path 跳过(断点续跑) | 无(无增量即无恢复需求) | +| LLM 提供商抽象 | llm_services.py 单层 | LangChain 生态 | 自研 10 客户端注册表 | 预设目录 + DB 覆盖,模型按"目录模型/内容模型"分角色绑定 | rig-core 8 provider + **双模型 efficient/powerful fallover** | +| 缓存 | SQLite 分析缓存 | — | FAISS pickle + wiki JSON(无版本键) | 内存缓存抽象框架 | **MD5(prompt) 文件缓存带 token 统计 + 命中率监控** | + +--- + +## 三、综合评估:各项目的真正差异化 + +用一句话概括五家的"护城河": + +1. **CodeWiki-CN**:唯一做精确代码分析 + 唯一有知识飞轮(对话→蒸馏→确认→采纳反哺)的项目。牺牲了"开箱即用 Web 服务"的传播性,换取知识资产的可信治理与 IDE 深度集成。 +2. **openwiki**:事实治理工程化最彻底(Claims + 多层增量 + LEDGER eval),且是唯一认真做"wiki 作为 Agent 记忆"叙事的——OKF frontmatter 明确 description "optimized for search & retrieval"(`src/agent/prompts/code.ts:63-88`)。弱在检索与静态分析全空白。 +3. **deepwiki-open**:唯一有向量 RAG 与 Web 问答体验的(这是它 star 最高的直接原因——Demo 效应),但工程上缓存无版本、无增量、无 MCP,是"演示优先"的形态。 +4. **OpenDeepWiki**:唯一做"平台化"——多租户、组织权限、Admin 后台、MCP 商业化(OAuth+用量统计)、集群调度。它解决的是"企业内 wiki 服务怎么运营"的问题,与 CodeWiki 解决的"个人/团队知识怎么可信"是不同问题域。 +5. **deepwiki-rs (Litho)**:声明式 agent 流水线框架(StepForwardAgent trait)是漂亮的抽象,prompt 压缩/双模型 fallover/缓存监控等细节扎实,但无检索、无增量、无 MCP,且项目已转向后继 Terrain。 + +**CodeWiki-CN 的相对位置**:在"事实正确性"(AST 分析)与"知识治理"(确认闸门+飞轮)两个维度上是五家最强;在"传播性"(无 Web Demo)、"平台化"(无多租户)、"评测体系"(无 LEDGER 类纵向 eval)三个维度上存在结构性缺位——前两者是定位取舍,第三者是真实短板。 + +--- + +## 四、可借鉴点(按优先级) + +> 排除已落地的 D1-D5(证据哈希/页面 manifest/Mermaid 降级/no-op 防扰/评测框架——2026-08-31 那轮借鉴,`codewiki/src/evidence.py`、`mcp/tools/page_manifest.py`、`stale_evidence` 检查均已存在)。以下按「价值 × 落地成本」排序,每项给出对标机制与 CodeWiki 落点。 + +### P0(高价值、与现有架构同构、可直接落地) + +**B1. 确定性收尾管线(对标 openwiki `wiki-finalizer.ts:248-285`)** +openwiki 在每次生成/更新后跑一段纯代码收尾:mermaid 校验 → 索引同步 → 内链校验 → provenance 盖章,`index.md` 禁止模型手写。CodeWiki 现状是生成时注入交叉链接 + lint 后置检查,但索引与内链缺少"生成后机械统一"这一步。落点:`analyze_repo` 生成流程末尾增加 `finalize_wiki_artifacts` 阶段,复用 lint_wiki 已有的内链/mermaid 检查逻辑做"检查+修复"而非仅"检查"。成本约 2-3 人日。 + +**B2. LLM 调用文件缓存带 token 统计(对标 deepwiki-rs `cache/mod.rs:43-67,127-180`)** +MD5(prompt) 为 key 的 JSON 文件缓存,带 token 用量统计与命中率监控(`cache/performance_monitor.rs`)。CodeWiki 的全量 `analyze_repo` 重跑(幂等重跑自动沿用)目前每模块都重新调 LLM;加 prompt 哈希缓存后,未变模块的生成可零成本命中。注意 deepwiki-rs 的教训:**缓存键必须校验 model_name**(它没做,`cache/mod.rs:88-109`,换模型命中旧答案)。落点:`llm_services.py` 加可选缓存层,key = hash(prompt + model + model 参数)。 + +**B3. 生成并发三层闸门(对标 deepwiki-open `api/rag/rag.py:27-34`、`api/services/wiki/tasks.py:62-66`)** +RAG 索引并发、任务池并发、页级并发三个独立信号量,各自可配置。CodeWiki 的生成并发目前是单一配置;分析(CPU 密集 AST)与生成(IO 密集 LLM 调用)性质不同应分开限流。成本约 1 人日。 + +### P1(高价值、需要一定设计工作) + +**B4. 双模型 efficient/powerful + 错误注入 fallover(对标 deepwiki-rs `llm/client/mod.rs:106-118`)** +主模型失败时把错误信息注入 prompt 换 fallback 模型重试 + 指数退避。对 CodeWiki 的价值:结构生成(目录/模块树描述)用便宜模型、逐页内容用强模型的分角色绑定(OpenDeepWiki 也有类似设计:ResolveCatalogModelAsync/ResolveContentModelAsync)。本机 GitHub 网络双通道不稳定(已知环境事实),LLM 端点同样可能间歇失败,fallover 提升无人值守批处理(cron 定时重建)的鲁棒性。 + +**B5. 持久化可恢复页队列(对标 openwiki `.run.json` + CI 挂了重跑即续)** +把"逐模块生成"从一次性循环改为持久化队列:每模块推进前状态落盘,中断后 resume 从断点继续。与 B2 缓存互补(缓存省重复调用,队列保进度不丢)。对大仓库全量生成(26+ 模块)的容错价值明显。落点参考 openwiki 的 begin → submit_plan → next_page → submit_page → finish 生命周期(`src/agent/repository-runner.ts:373-397`)。 + +**B6. `stale_evidence` 检查结果回灌增量决策(对标 openwiki Claims preflight 输出喂给 planner)** +openwiki 的 update 流程会把 Claims preflight 的 stale/unresolved 清单直接作为 planner 的输入,决定哪些页真正需要重写(`src/agent/repository-prompts.ts:215-231`)。CodeWiki 已有 `stale_evidence` 检查(D1 落地),但结果目前只进 lint 报告;把它接入 `_detect_doc_changes` 的输出(与 affected_modules / stale_pages 并列),成为增量重写范围的第三信号源,闭环就完整了。这是对已落地 D1/D2 的"最后一公里"集成。 + +### P2(有启发性、按需取用) + +**B7. `.ai-context/` 按稳定性分层(对标 deepwiki-rs Tier0-3)** +知识按变更频率分层:Tier0 PROJECT-ESSENCE(几乎不变)→ Tier3 DYNAMICS(当前问题/TODO)。与 CodeWiki 的 wiki/modules(结构层,随代码变)vs notes(经验层,随认知变)天然同构,启发点是**在 schema.yaml 显式声明各页面类型的"稳定性档位"**,让 stale_after 窗口的设定有理论依据而非拍脑袋。 + +**B8. SSE 心跳 + shield 防代理超时(对标 deepwiki-open `api/routers/repo.py:20-57`)** +每 10s 心跳注释帧躲 undici 300s headers timeout;`asyncio.shield` 保证心跳超时不误杀索引任务。对 CodeWiki 的 MCP 长任务(analyze_repo 全量)在代理/网关环境下的可用性有参考价值。 + +**B9. init 备份事务含信号回滚(对标 openwiki `wiki-replacement.ts:42-80`)** +重新 init 会备份旧 wiki,失败或 SIGINT/SIGTERM 均回滚。CodeWiki 的 `init_wiki` 幂等重跑已有保护,但缺少"中断即回滚"的事务语义。 + +**B10. DB 租约式集群并发(对标 OpenDeepWiki `WikiGenerationConcurrencyService.cs:37-64`)** +DB 租约 + 槽位 + 心跳 + 崩溃恢复,单体内实现"每仓库单写者、集群总并发上限"。仅当 CodeWiki 未来走向多实例部署(如团队共享 wiki 服务)时才有必要,当前单机 stdio MCP 形态用不上,记录备用。 + +### 不建议借鉴(与既有判断一致) + +| 项 | 理由 | +|---|---| +| deepwiki-open 的 FAISS 向量检索 | 纯单路召回无 rerank、词数切分跨块割裂,工程上是四家最弱检索实现;CodeWiki 的 BM25×authority×heat + 采纳反馈已更优,且向量库引入嵌入模型依赖,违背"工具无状态、跨 IDE 可移植"原则 | +| openwiki 的 personal 模式 + 9 连接器 | 前轮已明确不借鉴(偏离代码库原生定位) | +| OpenDeepWiki 的多租户平台化 | 与 CodeWiki"本地优先、嵌入 IDE"定位相反,是不同问题域 | +| deepwiki-rs 的正则"AST" | 解析精度反面教材 | +| 各家的 LLM 直读代码(无静态分析)路线 | CodeWiki 的 AST/tree-sitter 调用图是核心差异化资产,不可放弃 | + +--- + +## 五、结论 + +1. **CodeWiki-CN 的差异化定位在五家中依然成立且稀缺**:精确代码分析(唯一)+ 知识飞轮与确认闸门(唯一)+ 增量更新(与 openwiki 并列第一梯队)。四个竞品全部走 LLM 直读路线,证明了"做 AST 调用图"这条路没有直接竞争者。 +2. **openwiki 仍是头号对标对象**,但两轮借鉴后剩余增量集中在工程可靠性(B1 确定性收尾、B5 可恢复队列、B9 事务回滚),其 LEDGER eval 体系(D5)框架已借鉴但尚未实际跑起来——**建议优先把 D5 从设计稿推进到实测**,这是对"文档质量"最硬核的度量。 +3. **deepwiki-open 的高 star 验证了"Web Demo 传播效应"**:17.9k star 的项目工程上无增量、无 MCP、检索最弱——如果 CodeWiki 需要传播性,一个只读 Web 可视化(复用现有 Vue 前端)比补齐 RAG 更划算。这是产品决策而非技术决策。 +4. **最值得马上动手的三件事**:B2(LLM 调用缓存带 token 统计,1-2 人日、直接降本)→ B6(stale_evidence 回灌增量决策,打通 D1/D2 最后一公里)→ B1(确定性收尾管线,2-3 人日)。 + +--- + +## 勘误(2026-09-06,实现期代码核对) + +B 系列建议进入实现拷问时(grill-with-docs),逐项核对代码发现三处陈述偏差,勘误如下: + +1. **B2 的痛点表述过重**:"幂等重跑每个模块都重新调 LLM" 不准确——`analyze_repo` 已有 `no_changes` 短路(`analysis.py:110`)与 `affected_modules` 限定范围两层省钱机制。且模块生成是 agentic 多轮调用(`run_module_agent` 带 tools 走完整轨迹),deepwiki-rs 式 MD5(prompt)→response 缓存不适用,B2 若做只能是模块级指纹缓存(命中跳过整个 agent run)。实现优先级据此下调。 +2. **B1 的一半已存在**:"索引与内链缺少生成后机械统一" 不准确——`analyze_repo` 末尾已调 `rebuild_index + append_log`(`analysis.py:380-385`),`write_doc_file` 每次写后也调(`doc_writer.py:1577-1585`)。真实缺口仅在 `generate_docs`(`legacy_tools.py:108-179`)跑完 `doc_gen.run()` 后无任何收尾。B1 范围据此收窄为补单点缺口。 +3. **B6 已于 2026-09-06 落地**:`collect_evidence_drift`(`evidence.py`)作为 lint 与增量决策的共享采集点,`_enrich_stale_evidence`(`analysis.py`)挂变更路径输出 `stale_evidence_pages` 第三信号源;no_changes 路径静默是显式决策(ADR-0005)。测试 `tests/test_stale_evidence_signal.py` 10 项,定向回归 119 过,ruff 过。 + +(勘误不改动正文——原表述与代码的差距本身有价值,与 §1 的宣传水分清单互为镜鉴:调研报告也要接受自己提出的"读代码才算数"标准的检验。) + +--- + +*本报告代码事实来源:`.research-competitors/notes/` 下四份源码调研笔记(各含完整行号依据),调研基于 2026-08-15 ~ 2026-09-04 的 commit 时点。CodeWiki-CN 事实来源:`repowiki/wiki/overview.md`、`repowiki/wiki/doctrine.md`、`docs/OpenWiki-借鉴详细设计方案.md` 及本次代码核对。* diff --git "a/docs/articles/CodeWiki-Plus\347\263\273\345\210\22712\357\274\232\345\233\233\344\270\252DeepWiki\345\244\215\345\210\273\347\232\204\346\272\220\347\240\201\346\250\252\350\257\204\342\200\224\342\200\224\350\260\201\345\234\250\345\201\232\345\267\245\347\250\213\350\260\201\345\234\250\345\201\232Demo.md" "b/docs/articles/CodeWiki-Plus\347\263\273\345\210\22712\357\274\232\345\233\233\344\270\252DeepWiki\345\244\215\345\210\273\347\232\204\346\272\220\347\240\201\346\250\252\350\257\204\342\200\224\342\200\224\350\260\201\345\234\250\345\201\232\345\267\245\347\250\213\350\260\201\345\234\250\345\201\232Demo.md" new file mode 100644 index 0000000..02aa279 --- /dev/null +++ "b/docs/articles/CodeWiki-Plus\347\263\273\345\210\22712\357\274\232\345\233\233\344\270\252DeepWiki\345\244\215\345\210\273\347\232\204\346\272\220\347\240\201\346\250\252\350\257\204\342\200\224\342\200\224\350\260\201\345\234\250\345\201\232\345\267\245\347\250\213\350\260\201\345\234\250\345\201\232Demo.md" @@ -0,0 +1,151 @@ +# CodeWiki-Plus 系列 12:四个 DeepWiki 复刻的源码横评——谁在做工程,谁在做 Demo? + +> 上一篇讲了 CodeWiki-Plus 自己怎么回答"机器写的 Wiki 凭什么可信"。这一篇把镜头转向外面。DeepWiki 火了之后,开源圈冒出来一批复刻和同类项目,star 都不低:deepwiki-open 17.9k,openwiki 16.2k,OpenDeepWiki 3.6k,deepwiki-rs 1.7k。我最近做 CodeWiki-Plus 的竞品调研,把这四个项目的源码全部克隆下来通读了一遍,加上 CodeWiki-Plus 自己,五个项目摆在一起对比。先说这篇的立场,也是整次调研最大的收获:**star 数和技术含量是两回事,README 和代码是两回事,"有 Demo"和"有工程"更是两回事。** 下面按源码说话。 + +--- + +## 引子:为什么非得读源码 + +调研方法先交代清楚。四个仓库全部浅克隆,commit 时点从 2026-08-15 到 2026-09-04,四个子代理并行读源码,每一个论断都要求落到`文件:行号`。为什么这么较真?因为这已经是我们踩过的坑:之前调研 claude-mem,文档站说它有 4 个工具,代码里实际 19 个。 + +这次的教训更生动。下面这张表,左边是 README 的宣称,右边是源码的真相: + +| 项目 | README 宣称 | 源码实际 | +|------|------------|----------| +| deepwiki-rs | 支持 Go | 语言处理器就 12 个(rust/js/ts/php/react/vue/svelte/kotlin/python/java/csharp/swift),没有 Go(`language_processors/mod.rs:44-57`) | +| deepwiki-rs | "research agents in parallel" | 并行工具函数全库只用了两处,agent 之间和逐目录总结全是串行 for 循环(`preprocess/mod.rs:146`) | +| deepwiki-rs | `--skip-*` 旗标可跳过阶段 | 三个旗标是死代码,声明了全库没有消费点(`cli.rs:36-44`) | +| deepwiki-rs | 缓存省 token | 缓存条目存了 model_name 字段,读取时却不比对,换模型照样命中旧答案(`cache/mod.rs:88-109`) | +| OpenDeepWiki | "多语言代码分析" | 全库没有任何 AST 或语言解析器,代码理解全靠 LLM 加 ReadFile/ListFiles/Grep 三个工具(`GitTool.cs:770-781`);所谓多语言,指的是输出文档的多语言 | +| deepwiki-open | LiteLLM 支持 | LiteLLM 只是它自研的 10 个客户端里的一个可选项(`api/clients/litellm.py:8-49`),不是核心抽象 | + +一个项目占四行,因为 deepwiki-rs 是重灾区。这不是要踩谁,而是想说一个判断:**这一批项目处在"DeepWiki 概念验证"的窗口期,README 的营销属性大于文档属性。** 你要是想从中借鉴点什么,读代码是唯一可靠的方式。 + +--- + +## 一、五个项目,五条路线 + +先把五个项目的底子摆出来(star 数据取自 2026-09-06 的 GitHub API): + +| 项目 | Stars | 技术栈 | 形态 | 一句话定位 | +|---|---|---|---|---| +| CodeWiki-Plus | — | Python + Vue | MCP 服务端(47 工具) | 本地优先的 LLM Wiki 生成与知识飞轮,嵌在 IDE Agent 工作流里 | +| openwiki | 16.2k | TypeScript + LangChain | npm CLI + 内部 MCP | "自维护 wiki":planner 到逐页 worker 的 agentic 生成,外加一套事实治理 | +| deepwiki-open | 17.9k | Next.js + Python FastAPI | Web 服务 | Devin DeepWiki 的开源复刻:输个仓库 URL,生成 wiki 加网页问答 | +| OpenDeepWiki | 3.6k | ASP.NET Core + Next.js | 自托管平台 | 多仓库多分支多语言的知识库托管平台,带组织权限和 Admin 后台 | +| deepwiki-rs (Litho) | 1.7k | Rust | 纯 CLI | 一次性的 C4 架构文档生成器 | + +几个容易被外表骗到的点,展开说说。 + +**deepwiki-open 不是 TypeScript 项目。** GitHub 把它的主语言标成 Python,但很多人的印象里它是"那个 Next.js 的 DeepWiki 复刻"。读代码才知道,Next.js 那层只是个反向代理,`src/app/api/*/route.ts` 把请求原样转发给后端;真正的心脏是 Python FastAPI(`api/main.py:50-72`),重度绑定 AdalFlow 框架。一个 Docker 容器里跑两个进程,这是"Demo 长得快"的典型架构:前端抄 DeepWiki 的皮,后端拼 RAG 的架子。 + +**deepwiki-rs 已经在收缩。** 它的产品名改叫 Litho,README 里明确宣布演进为后继项目 Terrain,Litho 收缩为"快速聚焦的 C4 文档生成器"(README.md:31-40)。主理人的开发重心已经转移。借鉴它的机制之前,得先掂量这个项目还能活多久。 + +**OpenDeepWiki 是五家里唯一做"平台"的。** ASP.NET Core 单体 + EF Core 双数据库 + 组织/部门/角色权限 + Admin 后台 + IM 接入(飞书/QQ/微信/Slack)。它解决的问题不是"怎么生成好文档",而是"企业内部的知识库服务怎么运营"——这是另一个问题域。 + +--- + +## 二、分水岭之一:谁在做真正的代码分析 + +这是五个项目最根本的差异,也是我读完之后心里最有底的一件事。 + +四个竞品,无一例外,全部走"LLM 直接读文件"的路线: + +- **openwiki** 是有意为之的架构选择:planner agent 拿着 ls/glob/grep/read_file 四个只读工具在仓库里逛,每页 worker 加上写权限但被限制只能写自己那一页。零 AST,零依赖图,零嵌入向量。它的态度是相信 agent 的泛化能力。 +- **OpenDeepWiki** 更极简:LLM 加 ReadFile/ListFiles/Grep 三件套,没了。 +- **deepwiki-rs** 表面上有 12 个"语言处理器",读进去发现是正则和关键词匹配,复杂度统计用 `content.matches("fn ")` 这种字符串计数(`mod.rs:112-143`)。介于"有分析"和"没分析"之间,但更靠近没分析那头。 +- **deepwiki-open** 用向量检索替代代码理解:文件按 350 词一块、100 词重叠切开,扔进 FAISS。 + +而 CodeWiki-Plus 的底座是 Python `ast` 加 tree-sitter 的静态分析管线:组件清单、函数签名、调用图、服务边界检测、跨服务路由匹配,全部来自真实的语法分析。这个差异的后果很直接:**四家竞品的代码事实正确性完全押注在 LLM 身上,代码一重构,文档里的行号引用就漂移;CodeWiki-Plus 的分析图谱本身就是重定位的预言机**——组件搬家了,图谱重分析会给出新位置。 + +有意思的是,没有一家复刻选择做静态分析。原因不难猜:LLM 读文件这条路,两周就能出 Demo,star 涨得飞快;AST 管线要适配十种语言,半年都不一定讨好。但两条路的长期差距,会随着文档被使用的时间慢慢拉开。 + +--- + +## 三、分水岭之二:增量更新,只有一家及格 + +"代码变了,文档怎么办",这是所有 wiki 生成器的生死题。答案分布很有意思: + +**deepwiki-open:没有。** 这是 star 最高项目最刺眼的短板。它的 wiki 缓存键只有仓库名,不含 commit、不含分支(`pipeline.py:163-167`);已经克隆过的仓库直接复用旧克隆(`pipeline.py:366-371`)。上游仓库更新之后,它手里的索引和 wiki 全部悄悄过期,唯一的解决办法是手动删缓存重建。更糟的是用户根本不知道过期了。 + +**deepwiki-rs:也没有。** 每次运行直接删掉整个输出目录全量重写(`outlet/mod.rs:79-82`)。它的 prompt 缓存能省点 LLM 调用费,但流程本身是全量的。 + +**OpenDeepWiki:有,仓库级。** 定时检查远程 HEAD commit,有差异就建任务,changed files 从 git diff 拿,增量 prompt 里明确禁用整树重写的工具(`WikiGenerator.cs:704-707`)。粒度粗但方向对。 + +**openwiki:这一题的满分答卷,四层机制。** 第一层,git HEAD 没变就直接 no-op 返回;第二层,对全部文件内容做 sha256 源指纹,运行中源码漂移就不推进 checkpoint;第三层,页级 manifest 记录每页覆盖的文件指纹,没受影响的页直接 fast-forward;第四层,上一篇讲过的 Grounded Claims,每条事实带证据 URI 和内容哈希,update 前先跑 preflight 逐条比对,产出 stale 清单喂给 planner 决定哪些页真正要重写。 + +四层机制环环相扣,全是为了回答一个问题:**这次更新,到底哪些页需要动?** 相比之下,"每次全量重写"的做法在十页 wiki 上没感觉,在一百页 wiki 上就是灾难。 + +CodeWiki-Plus 在这一题上的位置,坦白说属于第二梯队上半段:模块页有 commit_id 锚点加 git diff 的受影响模块检测,共享池笔记有页面级 manifest(这两块部分机制正是 8 月底从 openwiki 借鉴落地的)。和 openwiki 的差距在于:我们的 `stale_evidence` 检查结果目前只进 lint 报告,还没有回灌到增量决策里。这个后面说。 + +--- + +## 四、分水岭之三:检索与问答的三个极端 + +这一维度的分布像个光谱,两端都是几万 star 的项目。 + +**deepwiki-open 站在"有 RAG"的一端**,而且是五家里唯一的向量检索:FAISS,top_k=20,嵌入模型默认 OpenAI text-embedding-3-small。但读细节就会发现这套检索工程上是五家里最弱的:纯单路召回,没有 rerank,没有混合检索,没有查询改写;分块按词数硬切,跨块语义割裂;每次聊天请求都从 pickle 反序列化整个 FAISS 库,大仓库热路径开销可观(`research.py:33-57`)。还有个挺萌的设计:用户输入超过 7500 token 就直接跳过 RAG 裸答。**它有检索,但检索本身不是它的护城河,网页问答的 Demo 体验才是。** + +**openwiki 站在"无检索"的另一端。** 全库 grep 不到任何 embedding 或向量代码。它的问答策略是给 agent 一句 prompt:"先去 /openwiki 目录里翻 wiki,用 grep 和 glob",纯靠 agent 拿文件系统工具翻。诚实说,这条路在 wiki 小的时候完全够用,而且省掉了嵌入模型的依赖;wiki 大了之后命中率和 token 成本都会退化。它甚至在 frontmatter 规范里给 description 字段注明"为检索工具优化",翻译过来就是:**检索这活儿,我留给别人做。** + +**OpenDeepWiki 居中但更糙**:没有向量,聊天时把整棵 wiki 目录塞进工具描述里,agent 按行区间去读文档;跨仓库搜索靠关键词打分(`McpGlobalTools.cs:456-464`),不是语义检索。 + +CodeWiki-Plus 走的是中间路线:BM25 关键词检索,排序里乘了文档权威度和热度,外加一个独有的反馈闭环——**被 Agent 检索后实际采纳的文档,采纳计数会反哺排序权重**。检索质量这个话题,五个项目谁都不敢说做完了,但"检索结果能不能反过来教育检索器"这个钩子,目前只有我们挂上了。 + +--- + +## 五、分水岭之四:三种 MCP 哲学 + +MCP 这维度上,五个项目倒是各自选了三种完全不同的哲学。 + +**CodeWiki-Plus:MCP 就是本体。** 47 个工具覆盖分析、文档、知识、质量、任务、工作区,整个产品以 stdio MCP 服务端的形态嵌进 IDE Agent 的工作流。这是"工具供 Agent 调用"的哲学。 + +**openwiki:MCP 是流水线的协议化。** 它的 MCP server 只暴露 6 个工具:begin、submit_plan、next_page、inspect_claims、submit_page、finish。仔细看,这不是能力接口,是把"逐页生成队列"这套流程本身开放给外部宿主——宿主 coding agent 用自己的模型和原生仓库工具做研究和写页,openwiki 管队列、管校验、管收尾。**它把"怎么保证质量"握在自己手里,把"干活"外包给宿主。** 这个分工思路值得细品。 + +**OpenDeepWiki:MCP 是商业化接口。** 全局和仓库级两个 HTTP 端点,7 个工具,配了 API Key、完整 OAuth 2.1 流程、Protected Resource Metadata、用量统计中间件(`Program.cs:388-392`)。这是把知识库当对外服务来运营的姿势,五家里唯一认真做了"计费基建"的。 + +deepwiki-open 和 deepwiki-rs:无 MCP。 + +--- + +## 六、每家压箱底的工程亮点 + +横评不能光挑刺。四个项目各有几处设计,读的时候让我停下来记了笔记。 + +**openwiki 的确定性收尾。** 每次生成或更新结束,跑一段纯代码的收尾流水线:mermaid 校验、索引同步、内链校验、Claims 投影、provenance 盖章,一步 LLM 都不掺和;目录的 index.md 由代码生成,明令禁止模型手写(`wiki-finalizer.ts:248-285`)。这个分寸感很高级:**LLM 负责写内容,机械的活儿交给机械。** 顺便,它的页队列持久化在 `.run.json` 里,CI 上跑挂了,重跑就从断点继续。 + +**deepwiki-open 的防御性解析和并发闸门。** 它对 LLM 输出的解析健壮得有点夸张:XML 被截断就用手写状态机从残缺 JSON 里抢救出已完整的对象,零额外 LLM 调用(`structure.py:179-250`)——明显是被小本地模型折磨出来的。并发控制是三层信号量:RAG 索引 4 并发、任务池 CPU 核数一半、页级默认 1,各自独立可配。还有 SSE 心跳的细节:每 10 秒发一个注释帧,就为了躲 undici 默认 300 秒的 headers 超时,索引任务用 `asyncio.shield` 罩着,心跳超时也不会误杀(`repo.py:20-57`)。工程上全是脏活,但没有这些脏活 Demo 就会随机挂。 + +**OpenDeepWiki 的单体内分布式调度。** 这是我觉得它最值得抄的一处:DB 租约表加全局槽位加心跳加崩溃恢复,在单体应用里实现了"每仓库单写者、集群总并发上限"(`WikiGenerationConcurrencyService.cs:37-64`)。没有引入消息队列,成本控制得很好。它的断点续跑也实在:已落盘的文档路径直接跳过,单篇失败不中断批次。 + +**deepwiki-rs 的声明式流水线和双模型。** 它的 StepForwardAgent trait 是个漂亮抽象:每个 agent 声明自己需要哪些数据源(内存键、上游结果、外部知识)、prompt 模板、调用模式,框架自动完成数据校验、prompt 组装、结果写回,新 agent 只写配置不写流程(`step_forward_agent.rs:581-726`)。双模型设计也聪明:主力模型失败时,把错误信息注入 prompt 换备用模型重试,外层套指数退避(`llm/client/mod.rs:106-118`)。另外它的 prompt 哈希文件缓存带 token 用量统计和命中率监控,是省钱的实在功夫。 + +--- + +## 七、CodeWiki-Plus 学到了什么 + +调研的落点不是写文章,是改进自己。排除 8 月底已经从 openwiki 借鉴落地的五项(证据哈希、页面 manifest、mermaid 降级、no-op 防扰、评测框架),这次从四个项目里提炼出的清单,按优先级排三件: + +**第一件,LLM 调用缓存加 token 统计。** 学 deepwiki-rs 的 MD5(prompt) 文件缓存,但要避开它的坑:缓存键必须校验模型名,否则换模型命中旧答案。CodeWiki-Plus 幂等重跑时每个模块都重新调 LLM,加这层缓存后,没变的模块零成本命中。一到两个人日的活,直接降本。 + +**第二件,stale_evidence 回灌增量决策。** 这是对已落地机制的"最后一公里"。openwiki 的做法是 Claims preflight 的 stale 清单直接喂给 planner,成为"哪些页要重写"的决策输入;我们的 stale_evidence 检查结果目前只躺在 lint 报告里。把它接进增量检测的输出,和受影响模块、过期页面并列成第三信号源,增量闭环才算真正闭上。 + +**第三件,确定性收尾管线。** 学 openwiki 的 finalizer:生成流程末尾加一段纯代码收尾,把索引同步和内链校验从"检查"升级成"检查加修复"。索引和内链这种机械活,不该指望 LLM 每次都写对,也不该只靠事后 lint 点名。 + +还有一条产品层面的观察,不是技术决策:deepwiki-open 用五家里最粗糙的工程拿了最高的 star,17.9k。它证明了**网页 Demo 的传播力可以碾压工程质量**。CodeWiki-Plus 目前只有 IDE 侧的消费入口,如果哪天需要传播性,把现有 Vue 前端包装成只读可视化站点,比补齐任何技术短板都划算。这是留给未来的选择题。 + +--- + +## 结尾:自己的路线,扒完别人家的源码才敢确认 + +把五个项目读完整理完,最大的收获不是那三件待办,而是一次定位确认: + +**精确代码分析这条路,五家里只有 CodeWiki-Plus 在走。** 四个竞品全部押注 LLM 直读,说明这条路的短期成本优势确实明显;但也说明,AST 调用图这个我们从第一天起就当作理所当然的底座,在同类项目里居然是稀缺资产。 + +**知识治理这条线,五家里只有 CodeWiki-Plus 和 openwiki 在认真做。** openwiki 用证据哈希把"文档过时"变成可机械验证的状态机;CodeWiki-Plus 在此之外还多压了一层确认闸门和采纳反馈,让知识质量变成有人负责的资产。而剩下三家,连增量更新都没有,谈治理为时尚早。 + +star 会涨会跌,README 会更新会过时,源码不会说谎。这次调研把四个项目的源码摊开之后,CodeWiki-Plus 该坚持什么、该补什么、该忽略什么,答案都比之前清晰了一层。 + +--- + +*(本文基于 docs/DeepWiki类开源项目对比调研报告-2026-09.md 及四份源码调研笔记(.research-competitors/notes/)撰写,全部代码事实经二次核查与源码一致;star 数据取自 2026-09-06 GitHub API。系列前文见 docs/articles/。)* From b12e98cde449ddb48aa003ef5e145058f1c84fa2 Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 16:34:57 +0800 Subject: [PATCH 06/23] =?UTF-8?q?feat(mcp):=20skill=20=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E8=AF=8D=E6=B1=87=E5=B1=82=E5=9C=B0=E5=9F=BA?= =?UTF-8?q?=EF=BC=88T1,=20#24=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两区制布局的词汇与基建(ADR-0004):草稿区 skills/ 落 repowiki 根(与 notes/ 同层的知识资产),生效区 .codebuddy/skills/ 在仓库根、repowiki 不扫描。 - config: SKILLS_DIR / SKILL_EFFECT_DIR 两区常量(PAGE_TYPE_DIRS 保持 wiki/-relative 语义不动,skill 走 page_router 专用分支) - schema: page_types.skill 三处声明(模板/仓内实例/生成器默认表), 五段必填章节与 scenario 同构 - 索引: cache.py(SQLite) 与 wiki_search.py(legacy JSON) 双路径扫 skills/<name>/SKILL.md,source="skill"(召回隔离是 T5 #28 的入口职责) - lint: 新增 skill_sections 检查(schema 驱动必填章节,缺章节 error), registry checks 枚举与 README 同步 - 测试: tests/test_skill_pages.py 12 例(路由/章节校验/双路径索引/ 生效区隔离不变量) Closes #24 --- README.md | 1 + codewiki/mcp/cache.py | 45 ++++- codewiki/mcp/registry.py | 6 +- codewiki/mcp/tools/page_router.py | 13 ++ codewiki/mcp/tools/schema_generator.py | 15 ++ codewiki/mcp/tools/wiki_lint.py | 86 ++++++++++ codewiki/mcp/tools/wiki_search.py | 24 ++- codewiki/src/config.py | 11 ++ codewiki/templates/schema.yaml | 13 ++ repowiki/schema.yaml | 15 ++ tests/test_skill_pages.py | 220 +++++++++++++++++++++++++ 11 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 tests/test_skill_pages.py diff --git a/README.md b/README.md index 47dc522..cc394f4 100644 --- a/README.md +++ b/README.md @@ -563,6 +563,7 @@ CodeWiki-Plus 采用 **SQLite 主存储 + JSON 兼容副本** 的双层架构: | `okf_conformance` | OKF v0.2 合规审计:缺失 type/frontmatter、旧版状态词、verified 格式错误、stale_after 过期、缺 okf_version | | `scenario_capacity` | L2 场景块数量达到/超过容量上限(error/warning 分级),需先 MERGE 腾位再新增 | | `scenario_orphan` | 无来源标注(metadata.source_notes)且长期未被检索的孤儿场景块,可能冗余或过时 | +| `skill_sections` | 草稿区技能页(skills/<name>/SKILL.md)缺失 schema `page_types.skill` 五段骨架(工作场景/适用条件/核心 SOP/判断逻辑/禁忌与反模式)——缺章节的指令资产不可执行(error) | `lint_wiki` 返回 **health_score**(0-100),计算方式为 `100 - Σ(error×10 + warning×3 + info×1)`。 diff --git a/codewiki/mcp/cache.py b/codewiki/mcp/cache.py index ef8f9fc..22ac521 100644 --- a/codewiki/mcp/cache.py +++ b/codewiki/mcp/cache.py @@ -1051,7 +1051,7 @@ def _build_search_index_locked(self, output_dir: Path) -> Dict[str, Any]: c.execute("DELETE FROM search_stats") from codewiki.src.config import WIKI_SYSTEM_FILES, WIKI_DIR - dc = nc = sc = 0 + dc = nc = sc = skc = 0 # Scan wiki/ subdirectories recursively for doc pages wiki_dir = od / WIKI_DIR @@ -1156,6 +1156,46 @@ def _build_search_index_locked(self, output_dir: Path) -> Dict[str, Any]: c.execute("INSERT OR IGNORE INTO search_token_index VALUES(?,?,?)", (t, fk, f)) nc += 1 + # skill-creator (issue #24): draft-zone skill pages, one SKILL.md per + # nested directory (skills/<name>/SKILL.md). Indexed with + # source="skill" — ADR-0004: indexed and linted, but recall-side + # isolation (query_wiki never returns them) is enforced separately + # at the search entry point (T5, issue #28). + from codewiki.src.config import SKILLS_DIR + + sk_dir = od / SKILLS_DIR + if sk_dir.is_dir(): + for sf in sorted(sk_dir.rglob("SKILL.md")): + if not sf.is_file(): + continue + try: + ct = sf.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + if not ct.strip(): + continue + title = _extract_frontmatter(ct, "name") or sf.parent.name + tokens = tokenize(build_indexable_text(ct)) + if not tokens: + continue + tf = {} + [tf.update({t: tf.get(t, 0) + 1}) for t in tokens] + fk = str(sf.relative_to(od)).replace("\\", "/") + c.execute( + "INSERT OR REPLACE INTO search_index(doc_key,title,source,doc_len,term_freq,authority) VALUES(?,?,?,?,?,?)", + ( + fk, + title, + "skill", + len(tokens), + json.dumps(tf), + doc_authority(fk, "skill", ct), + ), + ) + for t, f in tf.items(): + c.execute("INSERT OR IGNORE INTO search_token_index VALUES(?,?,?)", (t, fk, f)) + skc += 1 + # Scan raw/sources/ for third-party document text raw_dir = od / "raw" / "sources" if raw_dir.is_dir(): @@ -1192,7 +1232,7 @@ def _build_search_index_locked(self, output_dir: Path) -> Dict[str, Any]: c.execute("INSERT OR IGNORE INTO search_token_index VALUES(?,?,?)", (t, fk, f)) sc += 1 - td = dc + nc + sc + td = dc + nc + sc + skc if td: avg = (c.execute("SELECT SUM(doc_len) FROM search_index").fetchone()[0] or 0) / td c.execute("INSERT INTO search_stats VALUES('total_docs',?)", (str(td),)) @@ -1218,6 +1258,7 @@ def _build_search_index_locked(self, output_dir: Path) -> Dict[str, Any]: "docs_indexed": dc, "notes_indexed": nc, "sources_indexed": sc, + "skills_indexed": skc, "total_docs": td, "graph_edges": graph_info.get("edges", 0), } diff --git a/codewiki/mcp/registry.py b/codewiki/mcp/registry.py index 8fc3cf9..5a5f95f 100644 --- a/codewiki/mcp/registry.py +++ b/codewiki/mcp/registry.py @@ -785,7 +785,10 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "scenario_capacity (L2 scene blocks at/over the consolidation cap — " "merge similar scenes before adding more), " "scenario_orphan (scene blocks with no source_notes provenance and no " - "recent retrieval — possibly redundant or outdated). " + "recent retrieval — possibly redundant or outdated), " + "skill_sections (draft-zone SKILL.md pages missing the schema " + "page_types.skill five-section skeleton — an instruction asset without " + "its sections is not actionable). " "Run checks=['all'] for a comprehensive audit. " "After fixing issues, use flag_issue to track remaining problems. " "MANDATORY FINAL STEP: after lint passes (or issues are tracked), you MUST call " @@ -825,6 +828,7 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "okf_conformance", "scenario_capacity", "scenario_orphan", + "skill_sections", "layout_violations", "team_layout_gitignore", ], diff --git a/codewiki/mcp/tools/page_router.py b/codewiki/mcp/tools/page_router.py index d6db52f..2f13570 100644 --- a/codewiki/mcp/tools/page_router.py +++ b/codewiki/mcp/tools/page_router.py @@ -28,6 +28,7 @@ WIKI_DIR, RAW_SOURCES_DIR, NOTES_DIR, + SKILLS_DIR, INDEX_FILENAME, LOG_FILENAME, OVERVIEW_FILENAME, @@ -110,6 +111,10 @@ def resolve_wiki_paths(output_dir: str | Path, schema: dict | None = None) -> di "queries": wiki / PAGE_TYPE_DIRS["query"], "notes": od / NOTES_DIR, "raw_sources": od / RAW_SOURCES_DIR, + # skill-creator (issue #24, ADR-0004): draft zone at repowiki root — + # schema.page_types.skill.directory override maps here (values are + # output_dir-relative, so "skills" lands outside wiki/ by design). + "skills": od / SKILLS_DIR, "index": wiki / INDEX_FILENAME, "log": wiki / LOG_FILENAME, "overview": wiki / OVERVIEW_FILENAME, @@ -153,6 +158,13 @@ def get_page_type_dir(page_type: str, output_dir: str | Path, schema: dict | Non if custom_dir: return od / custom_dir + # 1b. skill-creator (issue #24, ADR-0004): skill draft zone lives at the + # repowiki root (sibling of notes/), NOT under wiki/ — PAGE_TYPE_DIRS + # values are wiki/-relative by convention, so skill gets a dedicated + # branch instead of a misleading PAGE_TYPE_DIRS entry. + if page_type == "skill": + return od / SKILLS_DIR + # 2. built-in mapping (keyed by singular page_type) subdir = PAGE_TYPE_DIRS.get(page_type) if subdir: @@ -300,5 +312,6 @@ def ensure_wiki_dirs(output_dir: str | Path, schema: dict | None = None) -> None "queries", "notes", "raw_sources", + "skills", ): paths[key].mkdir(parents=True, exist_ok=True) diff --git a/codewiki/mcp/tools/schema_generator.py b/codewiki/mcp/tools/schema_generator.py index f5e05f5..e35131c 100644 --- a/codewiki/mcp/tools/schema_generator.py +++ b/codewiki/mcp/tools/schema_generator.py @@ -204,6 +204,21 @@ "决策结论", ], }, + # skill-creator (issue #24, ADR-0004): skill draft zone at the repowiki + # root (output_dir-relative "skills", sibling of notes/) — NOT under + # wiki/. Effect zone (.codebuddy/skills/) lives outside repowiki and is + # never scanned. Routing fallback lives in page_router's skill branch. + "skill": { + "directory": "skills", + "description": "从已确认知识编译的 SKILL.md 行为指令草稿(两区制草稿区:进索引进 lint、不生效)", + "required_sections": [ + "工作场景", + "适用条件", + "核心 SOP", + "判断逻辑", + "禁忌与反模式", + ], + }, } # ── installation schema.yaml loading ───────────────────────────────────── diff --git a/codewiki/mcp/tools/wiki_lint.py b/codewiki/mcp/tools/wiki_lint.py index 3b5c785..68159a5 100644 --- a/codewiki/mcp/tools/wiki_lint.py +++ b/codewiki/mcp/tools/wiki_lint.py @@ -44,6 +44,9 @@ # P2 (team-memory fusion): L2 scene block hygiene "scenario_capacity", "scenario_orphan", + # skill-creator (issue #24, ADR-0004): draft-zone SKILL.md section + # conformance against schema.page_types.skill.required_sections + "skill_sections", # P1 B-line: hot-but-never-adopted notes (usage utility dimension) "low_adoption", # Centralized-layout discipline (ticket 09) @@ -1555,6 +1558,84 @@ def _check_scenario_orphan( return issues +def _check_skill_sections(output_dir: Path) -> List[Dict[str, Any]]: + """Validate draft-zone SKILL.md bodies against schema required sections. + + skill-creator (issue #24, ADR-0004): every draft skill lives at + ``skills/<name>/SKILL.md`` and its body must carry the five-section + skeleton declared in ``schema.yaml`` ``page_types.skill`` (same shape as + scenario blocks — When-to-Apply ≈ 适用条件, Instructions ≈ SOP). A skill + without its sections is not an actionable instruction set, so a missing + section is an error, not a warning. + + The section list is read from schema (single source of truth); the check + is scoped to the draft zone only — the effect zone (.codebuddy/skills/) + lives outside repowiki and is never scanned. + """ + issues: List[Dict[str, Any]] = [] + try: + import re + + from codewiki.mcp.tools.page_router import load_schema + from codewiki.src.config import SKILLS_DIR + + schema = load_schema(output_dir) + pt = schema.get("page_types", {}).get("skill", {}) + required = list(pt.get("required_sections", [])) if isinstance(pt, dict) else [] + except Exception: + return issues + if not required: + return issues # nothing declared — nothing to enforce + + sk_dir = output_dir / SKILLS_DIR + if not sk_dir.is_dir(): + return issues + + for sf in sorted(sk_dir.rglob("SKILL.md")): + if not sf.is_file(): + continue + try: + ct = sf.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + try: + rel = str(sf.relative_to(output_dir)).replace("\\", "/") + except ValueError: + rel = f"{SKILLS_DIR}/{sf.parent.name}/SKILL.md" + + # Collect heading titles from the BODY only (frontmatter may mention + # section-ish keys; the skeleton lives in the markdown body). + body = ct + if body.startswith("---"): + end = body.find("\n---", 3) + if end != -1: + body = body[end + 4 :] + headings = set() + for line in body.splitlines(): + m = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*$", line) + if m: + headings.add(m.group(1).strip()) + + for section in required: + if section not in headings: + issues.append( + { + "check": "skill_sections", + "severity": "error", + "message": ( + f"Skill draft '{rel}' is missing required section " + f"'{section}' (schema page_types.skill skeleton)." + ), + "file": rel, + "suggestion": ( + "Re-run skill_creator prepare for the writing-system " + "prompt, then submit with the full five-section body." + ), + } + ) + return issues + + # --------------------------------------------------------------------------- # OKF v0.2 conformance (§11 / §12) # --------------------------------------------------------------------------- @@ -2111,6 +2192,11 @@ def handle_lint_wiki( if "scenario_orphan" in checks and output_dir: all_issues.extend(_check_scenario_orphan(output_dir)) + if "skill_sections" in checks and output_dir: + # skill-creator (issue #24): required-section list is read from + # schema.yaml inside the check; dispatch passes no hardcoded values. + all_issues.extend(_check_skill_sections(output_dir)) + if "okf_conformance" in checks and output_dir: all_issues.extend( _check_okf_conformance( diff --git a/codewiki/mcp/tools/wiki_search.py b/codewiki/mcp/tools/wiki_search.py index 3418cfe..43df7b3 100644 --- a/codewiki/mcp/tools/wiki_search.py +++ b/codewiki/mcp/tools/wiki_search.py @@ -365,7 +365,7 @@ def build_full_index(output_dir, session=None): # Legacy JSON fallback with _build_lock: idx = _IndexData() - dc = nc = sc = 0 + dc = nc = sc = skc = 0 # Scan wiki/ subdirectories recursively from codewiki.src.config import WIKI_DIR, WIKI_SYSTEM_FILES @@ -414,6 +414,27 @@ def build_full_index(output_dir, session=None): idx.upsert(f"{_NOTES_DIR}/{nf.name}", title, "note", ct, batch=True) nc += 1 + # skill-creator (issue #24): draft-zone skill pages (skills/<name>/ + # SKILL.md), source="skill". Recall-side isolation is enforced at the + # search entry point (T5, issue #28) — the index itself carries them. + from codewiki.src.config import SKILLS_DIR + + sk_dir = od / SKILLS_DIR + if sk_dir.is_dir(): + for sf in sorted(sk_dir.rglob("SKILL.md")): + if not sf.is_file(): + continue + try: + ct = sf.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + if not ct.strip(): + continue + title = _extract_fm(ct, "name") or sf.parent.name + fk = str(sf.relative_to(od)).replace("\\", "/") + idx.upsert(fk, title, "skill", ct, batch=True) + skc += 1 + # Scan raw/sources/ raw_dir = od / "raw" / "sources" if raw_dir.is_dir(): @@ -439,6 +460,7 @@ def build_full_index(output_dir, session=None): "docs_indexed": dc, "notes_indexed": nc, "sources_indexed": sc, + "skills_indexed": skc, "total_docs": idx.total_docs, "avg_doc_len": round(idx.avg_doc_len, 1), "vocabulary_size": len(idx.doc_freq), diff --git a/codewiki/src/config.py b/codewiki/src/config.py index 06bc9ad..9c92e12 100644 --- a/codewiki/src/config.py +++ b/codewiki/src/config.py @@ -74,6 +74,17 @@ "scenario": "scenarios", } +# skill-creator (issue #24, ADR-0004): two-zone skill layout. +# Draft zone lives at the repowiki ROOT (sibling of notes/, not under wiki/ — +# skills are behaviour assets, not code-structure docs) and is managed: +# indexed (source="skill"), linted (skill_sections check), never IDE-effective. +# Effect zone lives at the REPO root, outside repowiki entirely: IDE-discovered +# and effective, never scanned by any repowiki tool. PAGE_TYPE_DIRS stays +# untouched because its values are wiki/-relative by convention; the skill +# page type routes through SKILLS_DIR via page_router + schema declaration. +SKILLS_DIR = "skills" # draft zone, relative to output_dir (repowiki root) +SKILL_EFFECT_DIR = ".codebuddy/skills" # effect zone, relative to repo root + # Files excluded from wiki index and search (system files). # Team-layout Phase 1: membership also matches monthly log shards diff --git a/codewiki/templates/schema.yaml b/codewiki/templates/schema.yaml index 3510bb2..526e540 100644 --- a/codewiki/templates/schema.yaml +++ b/codewiki/templates/schema.yaml @@ -217,6 +217,19 @@ page_types: - 调研过程 - 方案权衡 - 决策结论 + # skill-creator (issue #24, ADR-0004):技能草稿区。注意 directory 是 + # output_dir 相对路径——skills/ 在 repowiki 根(与 notes/ 同层的知识资产), + # 不在 wiki/ 代码文档层;生效区在仓库根 .codebuddy/skills/,repowiki 不扫描。 + # 正文五段骨架与 scenario 同构(When to Apply ≈ 适用条件,Instructions ≈ SOP)。 + skill: + directory: skills + description: 从已确认知识编译的 SKILL.md 行为指令草稿(两区制草稿区:进索引进 lint、不生效),由 skill_creator 生成,经确认后 install 到生效区 + required_sections: + - 工作场景 + - 适用条件 + - 核心 SOP + - 判断逻辑 + - 禁忌与反模式 # 提取粒度:控制 extraction_scan 提示词扫描源码时提取实体/概念的密度。 # focused — 3-7 个关键项,适合小型项目或快速概览 diff --git a/repowiki/schema.yaml b/repowiki/schema.yaml index 8ca3b67..251a6b8 100644 --- a/repowiki/schema.yaml +++ b/repowiki/schema.yaml @@ -311,6 +311,21 @@ page_types: - 相关任务与资产 - 演化记录 - 待确认问题 + # skill-creator (issue #24, ADR-0004):技能草稿区。directory 是 output_dir + # 相对路径——skills/ 在 repowiki 根(与 notes/ 同层的知识资产,不在 wiki/ 代码 + # 文档层);生效区在仓库根 .codebuddy/skills/,repowiki 不扫描。代码内置路由在 + # page_router 的 skill 分支(config.SKILLS_DIR),不设此项仍可用;在此声明使 + # schema 自文档化,并让 lint 的 skill_sections 检查感知五段必填章节。 + skill: + directory: skills + description: 从已确认知识编译的 SKILL.md 行为指令草稿(两区制草稿区:进索引进 + lint、不生效),由 skill_creator 生成,经确认后 install 到生效区 + required_sections: + - 工作场景 + - 适用条件 + - 核心 SOP + - 判断逻辑 + - 禁忌与反模式 # 提取粒度:控制 extraction_scan 提示词扫描源码时提取实体/概念的密度。 # focused — 3-7 个关键项,适合小型项目或快速概览 diff --git a/tests/test_skill_pages.py b/tests/test_skill_pages.py new file mode 100644 index 0000000..712da42 --- /dev/null +++ b/tests/test_skill_pages.py @@ -0,0 +1,220 @@ +"""Tests for skill-creator T1 vocabulary foundation (issue #24, ADR-0004). + +Covers the draft-zone groundwork only — page-type routing (skills/ at the +repowiki root, NOT under wiki/), the skill_sections lint check (schema-driven +required sections), draft-zone indexing (source="skill"), and the effect-zone +isolation invariant (.codebuddy/skills/ is never scanned by repowiki tools). + +The four-mode skill_creator tool itself is T2/T3 (issues #25/#26). +""" + +import json +from pathlib import Path + +import yaml + +from codewiki.mcp.session import SessionStore +from codewiki.mcp.tools import wiki_search +from codewiki.mcp.tools.page_router import ( + ensure_wiki_dirs, + get_page_type_dir, + resolve_wiki_paths, +) +from codewiki.mcp.tools.wiki_lint import handle_lint_wiki + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # +_SECTIONS = ["工作场景", "适用条件", "核心 SOP", "判断逻辑", "禁忌与反模式"] + +_SKILL_SCHEMA = { + "page_types": { + "skill": { + "directory": "skills", + "description": "SKILL.md draft zone", + "required_sections": _SECTIONS, + } + } +} + + +def _mk_repo(tmp_path: Path) -> tuple[str, Path]: + repo = tmp_path / "repo" + od = repo / "repowiki" + (od / "notes").mkdir(parents=True) + (od / "schema.yaml").write_text(yaml.safe_dump(_SKILL_SCHEMA, allow_unicode=True), encoding="utf-8") + return str(repo), od + + +def _write_skill( + od: Path, name: str, sections: list[str] | None = None +) -> Path: + """Write a draft skill at skills/<name>/SKILL.md with the given sections.""" + body_sections = _SECTIONS if sections is None else sections + body = "\n".join(f"## {s}\n\ncontent for {s}" for s in body_sections) + sk_dir = od / "skills" / name + sk_dir.mkdir(parents=True, exist_ok=True) + p = sk_dir / "SKILL.md" + p.write_text( + "---\n" + + yaml.safe_dump( + {"name": name, "description": f"当遇到 {name} 场景时使用", "type": "Skill", "status": "draft"}, + allow_unicode=True, + ) + + "---\n\n" + + body + + "\n", + encoding="utf-8", + ) + return p + + +def _lint(repo: str, checks: list[str] | None = None) -> list[dict]: + store = SessionStore() + args = {"repo_path": repo} + if checks: + args["checks"] = checks + r = json.loads(handle_lint_wiki(args, store)) + return r.get("issues", []) + + +# --------------------------------------------------------------------------- # +# 1. Page-type routing: skill draft zone sits at the repowiki root +# --------------------------------------------------------------------------- # +def test_get_page_type_dir_skill_routes_to_repowiki_root(tmp_path): + repo, od = _mk_repo(tmp_path) + # schema override ("skills") resolves output_dir-relative, outside wiki/ + assert get_page_type_dir("skill", od) == od / "skills" + + +def test_get_page_type_dir_skill_without_schema_still_root(tmp_path): + # No schema.yaml at all — code-level fallback (page_router skill branch) + od = tmp_path / "bare" / "repowiki" + od.mkdir(parents=True) + assert get_page_type_dir("skill", od) == od / "skills" + # and it must NOT fall into the wiki/ namespace like other page types + assert get_page_type_dir("skill", od) != od / "wiki" / "skills" + + +def test_resolve_wiki_paths_and_ensure_dirs_include_skills(tmp_path): + repo, od = _mk_repo(tmp_path) + paths = resolve_wiki_paths(od) + assert paths["skills"] == od / "skills" + ensure_wiki_dirs(od) + assert (od / "skills").is_dir() + + +# --------------------------------------------------------------------------- # +# 2. skill_sections lint check (schema-driven, draft zone only) +# --------------------------------------------------------------------------- # +def test_skill_sections_valid_draft_passes(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_skill(od, "demo-skill") # full five-section skeleton + issues = [i for i in _lint(repo, ["skill_sections"]) if i["check"] == "skill_sections"] + assert issues == [] + + +def test_skill_sections_missing_section_is_error(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_skill(od, "broken-skill", sections=_SECTIONS[:3] + _SECTIONS[4:]) # drop 判断逻辑 + issues = [i for i in _lint(repo, ["skill_sections"]) if i["check"] == "skill_sections"] + assert len(issues) == 1 + assert issues[0]["severity"] == "error" + assert "判断逻辑" in issues[0]["message"] + assert issues[0]["file"] == "skills/broken-skill/SKILL.md" + + +def test_skill_sections_included_in_all_checks(tmp_path): + """'all' runs must include the new check — no silent opt-out.""" + from codewiki.mcp.tools.wiki_lint import _ALL_CHECKS + + assert "skill_sections" in _ALL_CHECKS + + +def test_skill_sections_ignores_frontmatter_only_body(tmp_path): + """A body whose only 'headings' are frontmatter keys must fail the check.""" + repo, od = _mk_repo(tmp_path) + sk_dir = od / "skills" / "fm-only" + sk_dir.mkdir(parents=True) + (sk_dir / "SKILL.md").write_text( + "---\ntitle: x\n工作场景: not a heading\n---\nplain body without headings\n", + encoding="utf-8", + ) + issues = [i for i in _lint(repo, ["skill_sections"]) if i["check"] == "skill_sections"] + assert len(issues) == len(_SECTIONS) + + +# --------------------------------------------------------------------------- # +# 3. Draft-zone indexing (source="skill", nested SKILL.md discovered) +# --------------------------------------------------------------------------- # +def test_build_full_index_counts_skills(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_skill(od, "indexed-skill") + (od / "wiki").mkdir(exist_ok=True) + r = wiki_search.build_full_index(od) # no session / no DB -> legacy JSON + assert r["skills_indexed"] == 1 + + +def test_build_full_index_skill_doc_key_and_source(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_skill(od, "keyed-skill") + wiki_search.build_full_index(od) + # legacy JSON index: assert via search that the skill row exists with + # source="skill" and a root-level skills/ key (not wiki/skills/) + idx = wiki_search._load_index(od) + keys = [k for k in idx.docs if k.startswith("skills/")] + assert keys == ["skills/keyed-skill/SKILL.md"] + assert idx.docs["skills/keyed-skill/SKILL.md"]["source"] == "skill" + + +def test_sqlite_index_counts_skills(tmp_path): + """The AnalysisCache (SQLite) build path must index draft skills too.""" + from codewiki.mcp.cache import AnalysisCache + + repo, od = _mk_repo(tmp_path) + _write_skill(od, "sqlite-skill") + _write_skill(od, "sqlite-skill-two") + cache = AnalysisCache(Path(repo), db_path=Path(repo) / ".codewiki" / "analysis_cache.db") + try: + r = cache.build_search_index(od) + assert r["skills_indexed"] == 2 + rows = cache.conn.execute( + "SELECT doc_key, source FROM search_index WHERE source='skill'" + ).fetchall() + assert {row["doc_key"] for row in rows} == { + "skills/sqlite-skill/SKILL.md", + "skills/sqlite-skill-two/SKILL.md", + } + finally: + cache.close() + + +# --------------------------------------------------------------------------- # +# 4. Effect-zone isolation: .codebuddy/skills/ is never scanned +# --------------------------------------------------------------------------- # +def test_effect_zone_files_never_indexed(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_skill(od, "draft-one") + # effect zone lives at the REPO root, one level above the repowiki root + eff = Path(repo) / ".codebuddy" / "skills" / "eff-one" + eff.mkdir(parents=True) + (eff / "SKILL.md").write_text( + "---\nname: eff-one\ndescription: effect zone copy\n---\n\nbody\n", encoding="utf-8" + ) + r = wiki_search.build_full_index(od) + assert r["skills_indexed"] == 1 # only the draft-zone file + idx = wiki_search._load_index(od) + assert not [k for k in idx.docs if ".codebuddy" in k] + + +def test_effect_zone_files_never_linted(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_skill(od, "draft-one") + # a heading-less SKILL.md in the effect zone would fail skill_sections + # if it were scanned — it must never appear in lint output + eff = Path(repo) / ".codebuddy" / "skills" / "eff-broken" + eff.mkdir(parents=True) + (eff / "SKILL.md").write_text("no frontmatter, no headings\n", encoding="utf-8") + issues = [i for i in _lint(repo, ["skill_sections"]) if i["check"] == "skill_sections"] + assert issues == [] From a370624a821ee0e9301e0a4699b670772dbf4bf2 Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 16:35:07 +0800 Subject: [PATCH 07/23] =?UTF-8?q?docs:=20skill-creator=20=E9=9C=80?= =?UTF-8?q?=E6=B1=82=E8=AE=BE=E8=AE=A1=E5=AE=9A=E7=A8=BF=E4=B8=8E=20WikiSk?= =?UTF-8?q?ill=20=E7=B2=BE=E8=AF=BB=EF=BC=88grill=20Q1-Q9=20=E6=94=B6?= =?UTF-8?q?=E6=95=9B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/skill-creator需求与设计方案.md:实施唯一输入(四 mode 工具、 两区制、lint 八项、检索隔离、验收 7 条、实施拆解 8 工单) - docs/adr/0004:技能与场景分轨,两区制守确认闸门 - docs/WikiSkill论文与wikiskill源码精读.md:论文 Table 1-7 全表 + wikiskill v0.1.4 源码精读(research 子代理产出) - CONTEXT.md:skill 领域词条入表 --- CONTEXT.md | 9 + ...20\347\240\201\347\262\276\350\257\273.md" | 377 ++++++++++++++++++ ...0004-skill-scenario-split-two-zone-gate.md | 45 +++ ...76\350\256\241\346\226\271\346\241\210.md" | 281 +++++++++++++ 4 files changed, 712 insertions(+) create mode 100644 "docs/WikiSkill\350\256\272\346\226\207\344\270\216wikiskill\346\272\220\347\240\201\347\262\276\350\257\273.md" create mode 100644 docs/adr/0004-skill-scenario-split-two-zone-gate.md create mode 100644 "docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" diff --git a/CONTEXT.md b/CONTEXT.md index 8460dba..992fa0b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -72,6 +72,15 @@ missing / 三级 stale check,`_ensure_index`)同样收口在 search 入口 output_dir 60s 节流。cache.py 已瘦身为纯 persistence adapter,kernel 私有 import 全仓归零。 +**skill** — 从已确认知识编译出的行为指令资产(SKILL.md,Anthropic Agent Skill +规范):消费方是宿主 IDE 按 description 自动触发改变 agent 行为,与 scenario +(检索知识,agent 主动 query_wiki 按需查阅)分轨互补。两区制生命周期:草稿区 +`repowiki/skills/`(进索引进 lint、不生效)→ 人工确认后 install 到生效区 +`.codebuddy/skills/`(IDE 发现即生效)。修订经 `metadata.revisions` 审计链, +退役 retire 保留正文。有效性反馈收敛到既有 `flag_issue` 通道,不开平行通道。 +_Avoid_: 把 skill 当 scenario 的替身——一个进系统提示改变行为,一个进检索 +供查阅;素材过期联动(stale)走技能自身标注,不改写素材源。 + ## Key decisions - [ADR-0001 — 任务记忆保持 Markdown,不迁移 JSONL](adr/0001-task-memory-stays-markdown.md)(2026-08-24) diff --git "a/docs/WikiSkill\350\256\272\346\226\207\344\270\216wikiskill\346\272\220\347\240\201\347\262\276\350\257\273.md" "b/docs/WikiSkill\350\256\272\346\226\207\344\270\216wikiskill\346\272\220\347\240\201\347\262\276\350\257\273.md" new file mode 100644 index 0000000..043e0ed --- /dev/null +++ "b/docs/WikiSkill\350\256\272\346\226\207\344\270\216wikiskill\346\272\220\347\240\201\347\262\276\350\257\273.md" @@ -0,0 +1,377 @@ +# WikiSkill 论文与 wikiskill 源码精读 + +> 调研对象: +> - 论文:WikiSkill: Compiling Agent Experience into Persistent Knowledge for Skill Evolution(arXiv:2608.27454,Google Research + Virginia Tech) +> - 开源源码:github.com/ashutoshsinghpr7/wikiskill(v0.1.4,commit 02fac2c,2026-09-02),浅克隆于 `.research-competitors/wikiskill/` +> +> 编制日期:2026-09-06 +> 定位:为 skill-creator 设计方案(`repowiki/wiki/queries/skill-creator设计方案.md`)补充一手事实。既有两份文档(可行性对比 + 设计方案)已覆盖架构定档与工具形态;本文只挖它们没写透的细节:实验数字、prompt 原文、门控代码、skill-impact.md 确切格式、SKILL.md 产物结构、退役/冲突/去重机制现状。 + +--- + +## 0. 与既有两份文档的增量 + +既有文档已覆盖:三层架构(raw/wiki/skills)、四角色闭环、`R_val > R_best` 严格门控 + git 回滚、"wiki 不暴露给执行者"消融(63.7%→60.9%)、工程极简(无向量库)、三档方案定档(方案 C 半闭环)。本文新增的一手事实: + +1. **论文 Table 1/2/3/4/5/6/7 全部具体数字**:五模型 × 五基准主结果、跨模型迁移矩阵(含负迁移案例)、四配置消融全表(既有文档只引了 63.7→60.9 一个数字)、门控接受率(创建 ~26-52%、编辑 ~13-36%)、接受时点分布(early/mid/late)、各基准 train/val/test 划分规模、优化器 API 调用复杂度。 +2. **技能目录是双文件结构**:`SKILL.md` + `PURPOSE.md`(溯源文件独立,不塞 frontmatter)——既有文档与 CodeWiki 设计稿均未提及。 +3. **Skill Proposer / Wiki Maintainer 的 prompt 全文**(论文附录 E.2/E.3 + 源码 `prompts.py` 逐字版),含"必须读至少 4 条失败 trace""replace 目标必须是短小片段"等可移植纪律。 +4. **skill-impact.md 条目的确切格式**(harness 程序化追加的 markdown 结构,含内嵌完整被拒提案 JSON)。 +5. **门控代码的工程语义**:`accepted = r_val > prev_best` 的确切位置、patch 三种操作对 target 的 exact-substring 校验、`git reset --hard + clean -fd` 回滚、`no_action` 也是一种合法提案。 +6. **隔离 profile 机制**:wikiskill 用 Hermes 原生技能加载(symlink 重建)而非论文的系统提示全量注入——实现路径与论文不同但语义等价。 +7. **RUNS.md 六次真实运行记录**:至今所有 live 门控全是拒绝/no_action,零次现场接受;每次迭代成本 $0.086-$0.25;框架曾通过自己的 maintainer 诊断出自身 bug。 +8. **退役/去重/冲突机制的真相**:论文与源码都没有技能退役机制;去重全靠 prompt 纪律;论文 Limitations 明确承认"无自动 wiki 修剪"与"严格门控排除中性提案"两个缺口——直接对应 CodeWiki 设计稿的 Q10/Q11。 +9. **demo bench 的"陷阱任务"设计**(生成时 assert 双 bug 不互补、防幻影评分的强制新鲜沙箱),是自建技能验证基准的可借鉴样板。 +10. **XStack18 公众号原文未获取**(见下节),本文全部结论来自论文 HTML 全文与源码。 + +## 0.1 一手资料获取情况 + +| 资料 | 状态 | 说明 | +|---|---|---| +| 论文摘要页 + HTML 全文 | 已获取 | arxiv.org/abs 与 /html/2608.27454,HTML 全文约 95KB 纯文本,含全部表格与附录 A-E | +| wikiskill 源码 | 已获取 | `git clone --depth 1` 一次成功(走全局代理),v0.1.4,全部 Python 约 3000 行 + 8 个 SKILL.md + docs/ | +| XStack18 公众号文章 | **未获取** | WebSearch 两轮("XStack18 WikiSkill")未找到原文或可靠转载;搜到的均为其他作者的第三方解读(CSDN/博客园/头条等),非 XStack18 出品,本文不引用。既有对比文档当时用过该文内容,如需原文需另行人工获取 | + +源码行号以本仓 `.research-competitors/wikiskill/` 下实际读取为准(下文统称 `wikiskill/`,即该克隆内相对路径)。 + +--- + +## 1. 论文实验数据(既有文档未捕获的部分) + +### 1.1 主结果 Table 1(§4.2,五模型 × 五基准) + +全部为三次独立完整演化运行的测试集平均(论文 §4.1): + +| 模型 | 方法 | LiveMath | SealQA | SpreadSheet | OfficeQA | ALFWorld | Avg | +|---|---|---|---|---|---|---|---| +| Qwen-3.5-4B | No skill / Trace2Skill / EvoSkill / SkillOpt / **WikiSkill** | 29.1 / 31.5 / 41.7 / 48.7 / **49.7** | 32.5 / 37.6 / 37.3 / 33.3 / **39.4** | 14.6 / 17.5 / 18.6 / 14.0 / **21.1** | 30.2 / 31.0 / 29.5 / 34.5 / 28.5 | 24.4 / 42.8 / 41.5 / 45.3 / **53.7** | 26.2 / 32.1 / 33.7 / 35.2 / **38.5** | +| Qwen-3.5-9B | 同上序 | 28.2 / 33.1 / 58.1 / 48.7 / **56.3** | 26.3 / 36.9 / 34.5 / 29.4 / **43.1** | 24.3 / 26.5 / 35.4 / 29.0 / **33.6** | 35.9 / 38.4 / 34.9 / 38.0 / **40.5** | 34.7 / 48.8 / 48.5 / 55.7 / **63.4** | 29.9 / 36.7 / 42.3 / 40.2 / **47.4** | +| Qwen-3.6-27B | 同上序 | 33.9 / 36.3 / 57.3 / 51.9 / **61.9** | 27.5 / 37.3 / 32.9 / 34.5 / **41.6** | 40.8 / 53.3 / 59.5 / 53.2 / **81.7** | 42.1 / 54.3 / 52.5 / 54.8 / **53.7** | 52.8 / 55.5 / 64.2 / 59.2 / **77.6** | 39.4 / 47.3 / 53.3 / 50.7 / **63.3** | +| Gemma-4-31B | 同上序 | 33.9 / 32.3 / 29.8 / 40.1 / **56.7** | 30.6 / 37.7 / 38.4 / 36.1 / **41.2** | 48.3 / 58.5 / 56.4 / 63.1 / **68.0** | 43.3 / 43.2 / 39.9 / 44.4 / 44.2 | 50.4 / 57.2 / 52.6 / 61.9 / **64.4** | 41.3 / 45.8 / 43.4 / 49.1 / **54.9** | +| Gemini-3.5-Flash | 同上序 | 33.0 / 41.9 / 44.6 / 49.7 / **72.6** | 29.4 / 44.3 / 43.6 / 28.2 / **44.7** | 50.5 / 56.0 / 55.4 / 66.1 / **76.6** | 48.6 / 50.0 / 51.2 / 49.8 / **60.7** | 85.9(各法相同,见下) | 49.5 / 55.6 / 56.1 / 55.9 / **68.1** | + +关键注记(论文 §4.1/§4.2.1): + +- WikiSkill 平均分对五个模型分别比"各模型最强竞品"高 **3.3 / 5.1 / 10.0 / 5.8 / 12.0 分**。 +- 基线方法不稳定:EvoSkill 在 Qwen-9B LiveMath 大涨(28.2→58.1)却把 Gemma-4-31B 打降(33.9→29.8);SkillOpt 把 Gemini Flash 的 SealQA 打降(29.4→28.2)。WikiSkill 是唯一全模型 Avg 第一且多数模型-数据对不劣化的方法。 +- Gemini-3.5-Flash 在 ALFWorld 上所有方法同分 85.9,因为它在验证集上 S0 就拿了 100%,触发 Algorithm 1 的 `R_best=1.0` 提前终止——**演化从未发生**。这也导致它在跨模型迁移表(Table 2)里作为技能源标注 "−"。 +- 数据集敏感性:LiveMath 全模型受益(+20.6 ~ +39.6);OfficeQA 最难受益——Qwen-3.5-4B 反而微降(30.2→28.5),原因是小模型在长上下文里执行不了多步检索工作流,回退默认读文档行为(§4.2.1)。 + +### 1.2 跨模型迁移 Table 2(§4.2.2)——含负迁移证据 + +技能源 = Qwen-3.5-4B / Qwen-3.6-27B / Gemini-3.5-Flash 三种,被测模型五种的完整矩阵(节选关键数字): + +- **他模型技能优于自进化技能**:Qwen-3.5-9B 用 Qwen-27B 技能在 SpreadSheet 拿 50.5%(无技能 24.3%,自进化 33.6%);Gemma-4-31B 用 Qwen-27B 技能在 LiveMath 拿 73.7%(无技能 33.9%,自进化 56.7%)。 +- **小模型技能也能惠及大模型**:Qwen-3.5-4B 技能把 Gemma-4-31B 的 LiveMath 提到 73.1%、ALFWorld 提到 66.9%。 +- **负迁移实锤**:Qwen-3.5-4B 的 SpreadSheet 技能把 Gemini-3.5-Flash 从 50.5% **打到 18.1%**。论文错误分析归因两点:①小模型技能编码了低层 workaround(单行 Python 命令、字符串转换规则),帮助小模型避免执行失败,却**约束强模型使用端到端完整脚本**;②碎片化诊断流程引入冗余工具调用,耗尽强模型的交互预算。 +- **"发现"与"执行"是两种能力**:Qwen-3.5-4B 的 OfficeQA 技能把自己从 30.2 降到 28.5,却把 Qwen-3.6-27B 从 42.1 提到 52.9(§4.2.2)。这为 CodeWiki 半闭环设计(强模型提炼、宿主执行)提供了论文级依据。 + +### 1.3 消融 Table 3(§5.1)——四配置全表,不止 63.7→60.9 + +用 Gemini-3.5-Flash,独立开关"Inference Agent 训练 rollout 时 wiki 可见"(IA)与"Skill Proposer wiki 可见"(SP)。SP 关闭时连 Wiki Maintainer 一并移除(消除跨迭代知识积累)。四配置 + 无技能基线(Avg over 4 基准): + +| IA wiki | SP wiki | LiveMath | SealQA | SpreadSheet | OfficeQA | Avg | +|---|---|---|---|---|---|---| +| —(No skill) | — | 33.0 | 29.4 | 50.5 | 48.6 | 40.4 | +| 是 | 否 | 43.8 | 42.0 | 44.4 | 51.0 | **45.3** | +| 否 | 否 | 51.3 | 38.4 | 49.9 | 55.2 | **48.7** | +| 是 | 是 | 64.8 | 42.8 | 80.2 | 55.6 | **60.9** | +| 否(默认) | 是 | 72.6 | 44.7 | 76.6 | 60.7 | **63.7** | + +既有文档只引了 63.7→60.9(默认 vs IA 开)。完整表额外揭示: + +- **IA 开 wiki 在两种 SP 配置下都有害**:无 SP 时 48.7→45.3(-3.4),有 SP 时 63.7→60.9(-2.8)。即"执行者看 wiki 抄答案"的代价是独立于提案质量的。 +- **SP 无 wiki(48.7)比无技能基线(40.4)仍高 8.3 分**——即便没有持久知识层,"看 trace 提案 + 验证门控"本身就有收益;wiki 层再叠加 +15.0 分。 +- LiveMath 对该消融最敏感(51.3→72.6)。 + +### 1.4 门控接受率与技能/模式统计 Table 4(§5.2)——skill-creator 最直接的参照数字 + +按模型(全基准平均): + +| 模型 | 技能创建(提案/接受) | 技能编辑(提案/接受) | 技能均长(行) | 模式创建 | 模式编辑 | 模式均长(行) | +|---|---|---|---|---|---|---| +| Qwen-3.5-4B | 3.1 / 1.6 | 4.9 / 1.3 | 126.2 | 8.8 | 18.4 | 48.2 | +| Qwen-3.5-9B | 4.6 / 1.4 | 3.4 / 0.7 | 128.6 | 7.3 | 10.9 | 26.6 | +| Qwen-3.6-27B | 4.4 / 1.5 | 3.6 / 0.8 | 118.9 | 6.5 | 17.9 | 47.7 | +| Gemma-4-31B | 4.8 / 1.3 | 3.2 / 0.8 | 45.1 | 6.3 | 13.7 | 23.7 | +| Gemini-3.5-Flash | 2.3 / 1.2 | 5.7 / 1.1 | 81.2 | 8.9 | 7.0 | 18.1 | + +按基准(全模型平均):LiveMath 技能最短(84.6 行)模式最少(4.4 个);SpreadSheet 技能最长(142.5 行)模式最多(9.8 个)。 + +可换算的**接受率**:创建接受率 26%~52%(Gemini-Flash 2.3→1.2 ≈ 52%;Qwen-4B 3.1→1.6 ≈ 52%;Qwen-9B 4.6→1.4 ≈ 30%);编辑接受率仅 **13%~36%**(Qwen-9B 3.4→0.7 ≈ 21%;OfficeQA 3.3→0.3 ≈ 9%)。注意这是"每次完整演化(含多次迭代)累计"的计数,且分母含被拒提案——即**提案过半被拒是常态**,被拒内容进 wiki 供下轮复用正是论文的复利主张。模式页(wiki patterns)的创建与编辑**全部保留、从不回滚**(Table 4 注)。 + +### 1.5 接受时点分布 Table 5(附录 A.2)——"技能数量演化曲线"的代理 + +按演化阶段(early = 迭代 0-1,mid = 2-4,late = 5-7)统计被接受更新占比: + +| 按模型 | Early | Mid | Late | +|---|---|---|---| +| Qwen-3.5-4B / 9B / 27B | 39% / 52% / 43% | 39% / 30% / 40% | 21% / 19% / 17% | +| Gemma-4-31B / Gemini-Flash | 52% / 50% | 37% / 46% | 11% / 4% | + +| 按基准 | Early | Mid | Late | +|---|---|---|---| +| LiveMath / SealQA / SpreadSheet / OfficeQA / ALFWorld | 44% / 39% / 41% / 58% / 55% | 42% / 33% / 48% / 26% / 34% | 14% / 28% / 11% / 16% / 10% | + +结论:约四到六成接受发生在前两轮,但 mid/late 仍持续有接受(SealQA 最持续:mid 33% + late 28%)——说明**技能提炼不是一轮收敛**,多轮回流的半闭环设计有数据支撑。演化总长为 K≤8 次迭代(late 段定义到迭代 7)。 + +### 1.6 数据划分 Table 6 与实现细节(附录 B/C/D) + +| 基准 | 交互 | Train / Val / Test | 工具 | +|---|---|---|---| +| LiveMath | 单步 | 35 / 18 / 124 | 无(直接推理) | +| SealQA | 多步 | 16 / 10 / 85 | web_search, read_file | +| SpreadSheet | 多步 | 80 / 40 / 280 | bash | +| OfficeQA | 多步 | 50 / 24 / 172 | glob, grep, read | +| ALFWorld | 多步 | 39 / 18 / 134 | 模拟器合法动作 | + +- 验证集极小(10-40 题)是共性(沿袭 SkillOpt/EvoSkill 设置),论文承认这会给门控决策引入噪声,对策是三次完整演化取平均 + paired bootstrap 显著性检验(1000 次,p<0.05,附录 C)。 +- **轨迹分层采样**(附录 C):每迭代最多 8 条 trace(≤5 失败 + ≤3 成功),单条执行日志注入 prompt 前截断到 **15,000 字符**。 +- **优化器调用复杂度**(附录 D,Table 7):WikiSkill 全批处理(B=N_train),每迭代 1(maintainer)+ T_ReAct(proposer ReAct 轮数,实测 10-20)次调用,对训练集大小是 **O(1)**;对比 Trace2Skill O(N_train)、EvoSkill/SkillOpt O(N_train/B)。Proposer 不预采样 trace、按需 read_file 是达成 O(1) 的关键。 + +### 1.7 Limitations(论文自述缺口,直接对应 CodeWiki Q10/Q11) + +1. **全量注入、不评估检索/触发**——"随着技能数量增长,检索与触发会变得重要,本文未覆盖"。 +2. **严格 `>` 门控排除中性提案**——可能存在"当场不涨、后续有用"的提案被拒;更宽松的接受准则是 future work。 +3. **无自动 wiki 修剪机制**——模式页/日志/diff 持续累积,长演化下可能必须修剪。 +4. 未覆盖超长时程任务(数百动作级)与单次 rollout 内的在线技能调整。 + +### 1.8 对 skill-creator 有直接影响的论文事实(既有文档未写) + +- **技能目录 = 双文件**(§3.1 Skills Layer):`SKILL.md`(完整技能内容)+ `PURPOSE.md`("映射回促成其创建/修改的 wiki patterns")。溯源不进 frontmatter,独立成文件。 +- 技能正文约定三段式:**When to Apply + When NOT to Apply + Instructions**(附录 E.3 finish() 格式);PURPOSE.md 三段式:**Origin + Patterns Addressed + Evolution History**。 +- 维护者对 pattern 页的结构约定:**PROBLEM → ROOT CAUSE → FIX**,每页 10-30 行;index.md 每 pattern 一行,格式固定为 `[name](wiki/patterns/name.md): PROBLEM + ROOT CAUSE + FIX in one or two sentences`(附录 E.2)——index 条目质量被论文称为"wiki 最重要部分",因为它决定读者是否点开全文。 +- 维护者**每迭代可建/改 pattern 数量无硬性上限**,"由维护者根据 trace 与 wiki 现状自行判断"(§3.2.2);去重靠 prompt 纪律("Do NOT create duplicate patterns")。 +- Proposer 的 ReAct 工具只有两个:`read_file` 与 `finish(proposal)`(附录 E.3);源码版加了 `write_file` 写提案 JSON。 +- trace 路径别名:workspace 把 `read_file("traces/<task_id>")` 自动映射到 `raw/` 下的执行日志(附录 E.3 末注)。 +- `skill-impact.md` 由**外层 harness 程序化追加**(不是 agent 写的),记录提案元数据、目标技能名、unified diff、验证分、接受结果(§3.2.4)——审计链是机制保证而非 agent 自觉。 + +--- + +## 2. wikiskill 源码工程细节 + +### 2.1 代码地图(全部 ~3000 行 Python) + +| 文件 | 行数 | 职责 | +|---|---|---| +| `wikiskill/harness.py` | 179 | Algorithm 1 编排(evolve 主循环) | +| `wikiskill/gating.py` | 183 | 提案应用/回滚/git 管理/验证 rollout/持久状态 | +| `wikiskill/prompts.py` | 142 | 三角色 prompt 模板(自述"adapted from paper Appendix E, extracted verbatim from the arXiv HTML") | +| `wikiskill/bench.py` | 461 | demo 基准生成(22 任务,含陷阱任务) | +| `wikiskill/cli.py` | 273 | init/status/evolve/gate/compare/transfer/run-task/maintain/propose/reset 十个子命令 | +| `wikiskill/wiki.py` | 61 | wiki 骨架 + 追加写入 + 独立 git 仓库 | +| `wikiskill/scoring.py` | 62 | 四种确定性 grader | +| `wikiskill/tasks.py` | 109 | tasks.json 校验 + 沙箱物化 | +| `wikiskill/traces.py` | 72 | raw 层不可变 trace 存储 | +| `wikiskill/transfer.py` | 79 | 跨工作区技能拷贝 | +| `wikiskill/compare.py` | 168 | 配对精确二项检验(McNemar 类) | +| `wikiskill/backends/*` | ~1100 | Hermes(参考实现)/ Claude Code / Codex / Copilot 四后端 | +| `skills/` | — | 3 个框架技能(proposer/maintainer/evolve)+ 5 个真实演化产物 | +| `docs/RUNS.md` | — | 六次真实运行日志(见 §2.7) | + +### 2.2 Skill Proposer 的 prompt 模板原文(`wikiskill/prompts.py:82-120`) + +逐字结构(变量插值处用 `<>` 标注): + +``` +You are the Skill Proposer in a WikiSkill evolution loop (iteration <it>). + +Load the `wikiskill-proposer` skill and follow it exactly. + +WORKSPACE: <ws> +- Wiki: <ws>/wiki/ (index.md, skill-impact.md, patterns/, log.md) +- Raw traces: <ws>/raw/traces/iter-<NN>/ (full execution logs, read any trace you need via read_file) +- Active skills: <ws>/skills/active/ + +Training rollout summary this iteration: +<按 score 升序排列的任务表:id [split] score=... : title> + +Rules (from the paper's Appendix E.3): +1. Read wiki/index.md FIRST, then wiki/skill-impact.md (contains full content of + rejected proposals — DO NOT repeat rejected approaches). +2. Read relevant pattern pages, then read at least 4 execution traces for failed + tasks to diagnose root causes. Traces can be long — read selectively + (search_files for errors, read tails); do not read every line. +3. Decide: create (new skill), patch (existing skill), or no_action. +4. Write your proposal JSON to: <ws>/runs/proposals/iter-<NN>.json + Create/patch skills under <ws>/skills/active ONLY via the proposal file; + the harness applies it. [...] + WRITE EARLY — leave enough turns to finish the file before the cap. + +Proposal JSON schema (write the file with write_file): +{"action": "create", "name": "snake_case", "skill_md": "full SKILL.md with YAML frontmatter + When to Apply + When NOT to Apply + Instructions", "purpose_md": "Origin + Patterns Addressed + Evolution History"} +{"action": "patch", "name": "existing-skill", "edits": [{"op": "append"|"replace"|"insert_after", "target": "exact text", "content": "..."}]} +{"action": "no_action"} +Prefer patching over creating when a skill is partially correct. Keep skills +concise and actionable. If no change is warranted, write {"action": "no_action"}. +``` + +与论文附录 E.3 的差异:论文版 proposer 只有 `read_file` + `finish(proposal)` 两工具、提案经 finish() 提交;源码版改为 `write_file` 写 JSON 到固定路径(`runs/proposals/iter-NN.json`),由 harness 读取应用(`prompts.py:74-86` 的 `propose_step`,`harness.py:74`)。训练摘要表按分数升序排列(最差的排最前),是源码新增的引导。 + +**提案原子粒度**:一次提案恰好作用于**一个**技能(create 或 patch 三选一),patch 的 edits 列表可含多个操作但都落在同一文件上(`gating.py:67-105`)。`no_action` 是一等合法结果,不触发验证 rollout,也写进 skill-impact.md(`harness.py:140-147`,`prompts.py:128-129`)。 + +配套的框架技能 `skills/wikiskill-proposer/SKILL.md`(装进 proposer agent 的 profile,`harness.py:17` 的 `FRAMEWORK_SKILLS`)把同一规程展开成 SKILL.md 形态,其中 patch 纪律明确:"`replace`/`insert_after` targets must be short, specific text present in the file. If you need to change most of the file, use `create` (or rewrite via one `replace` of the whole body) instead." + +### 2.3 Wiki Maintainer 的 prompt(`wikiskill/prompts.py:48-79`) + +核心约定:读失败 trace 做根因分析、也读成功 trace 保住有效行为;"traces can be very long. Do NOT read every line... Reading selectively leaves budget for writing";**"WRITE EARLY, edit later"**(`prompts.py:73`——源码实测教训:25 轮预算够分析不够写,见 §2.7 Run 2);pattern 页 10-30 行;index.md 整体重写、每 pattern 一行;**"Do not touch the skills directory — that is the Skill Proposer's job"**(`skills/wikiskill-maintainer/SKILL.md`,职责隔离写进技能)。维护者 prompt 只提供采样后的 manifest(≤8 条路径),不像 proposer 可自主探索。 + +### 2.4 门控判定代码(R_val/R_best 的确切计算与比较) + +- **R 的计算**:`gating.py:169-174` `mean_score` = 各任务得分(0.0/1.0)的算术平均,round 到 4 位小数。得分来自 `scoring.py:32-60` 的四种确定性 grader(exact / contains / json_field / code_stdout),异常(缺文件、JSON 解析失败、超时 60s)一律 0.0 不 crash。 +- **比较与接受**:`harness.py:159` `accepted = r_val > prev_best`——严格大于,代码就这一行;接受则 `gating.py:119-122` `accept_commit`(git add -A + commit "accept iter-k R=<r_val>")并更新 `state["r_best"]`;拒绝则 `gating.py:107-110` `rollback` = `git reset --hard -q` + `git clean -fd -q`(在 `skills/active` 仓库内)。 +- **R_best 初值**:首次 evolve 先跑空技能基线验证(`harness.py:115-121`),`baseline` 与 `r_best` 都设为该值;`R_best==1.0` 提前终止(`harness.py:124-126`,`--no-early-stop` 是 dev 旋钮)。 +- **提案应用**:`gating.py:67-105` `apply_proposal`——create 写 `SKILL.md` + 可选 `PURPOSE.md`;patch 的 `replace`/`insert_after` 要求 target 是文件中的**精确子串**,找不到抛 `ValueError`(`gating.py:91,97`)——即提案应用是全有或全无:一个 edit 失败整个 apply 抛异常(evolve 循环未捕获时会终止迭代)。 +- **持久状态**:`runs/state.json`(`gating.py:24-37`)记录 domain/baseline/r_best/next_iter/history;history 每条含 iter/train_mean/r_val/accepted/proposal 描述——**这正是"技能数量演化曲线"的数据源**,`wikiskill status` 直接打印(`cli.py:53-62`)。 +- **防幻影评分**(源码实测教训固化为代码):每次 rollout 前强制重新物化沙箱、删除任务规格之外的所有文件(`gating.py:141-147` 调 `tasks.py:80-105` `materialize(force=True)`);session 首行 `tool_call_count==0` 判定为启动失败并打警告(`gating.py:127-133`)。 + +### 2.5 skill-impact.md 的确切格式(`prompts.py:123-142`) + +文件骨架由 `wiki.py:33-50` `ensure` 创建(初始内容 `# Skill Impact\n\n_No proposals yet._\n`),之后**只追加**(`wiki.py:52-56` `append_skill_impact`)。每条由 `gate_outcome_entry` 生成,确切结构: + +```markdown +### iter-<NN> — ACCEPTED|REJECTED (R_val=<r_val>, R_best=<prev_best|—>) + +Proposal: <create|patch> `<name>` +Proposal file: `runs/proposals/iter-<NN>.json` (re-run collisions possible — full content embedded below) + +```diff +<unified diff,来自 gating.skill_diff()> +``` + +Full proposal content (paper: rejected proposals must remain visible to future proposers): + +```json +<完整提案 JSON,含 skill_md/purpose_md 全文,json.dumps indent=2> +``` + +Validation: <r_val> > R_best → accepted, skills committed. +(或) +Validation: <r_val> ≤ R_best → skills rolled back; wiki retained. +``` + +`no_action` 时只写头两行(`prompts.py:128-129`)。**注意**:完整提案 JSON 内嵌的原因是提案文件按迭代命名,重跑会互相覆盖("re-run collisions possible")——审计链不能依赖可变路径。diff 由 `gating.py:113-116` `git diff` 生成。 + +### 2.6 wiki/ 目录组织与三层落盘 + +workspace 布局(`harness.py:34-48` `init_workspace`): + +``` +workspaces/<domain>/ +├── raw/traces/iter-<NN>/{train,val}/<task>.meta.json + <task>.jsonl # 不可变(traces.py:40-44 已存在则抛 FileExistsError) +├── wiki/ # 独立 git 仓库(wiki.py:22-31:目录内 git init,每次变更 commit,永不回滚) +│ ├── index.md # 模式目录,每 pattern 一行 +│ ├── log.md # 时间线日志(wiki.py:58-61 追加) +│ ├── skill-impact.md +│ └── patterns/*.md +├── skills/active/ # 独立 git 仓库(gating.py:45-53,初始提交 "S0: empty skill set"),受门控回滚 +├── skills/framework/ # wikiskill-maintainer + wikiskill-proposer 两个框架技能的拷贝 +├── bench/tasks/<id>/ # 任务沙箱 +├── runs/ # state.json、proposals/、每次 agent run 的 stdout + session.jsonl +└── .hermes-home/ # 隔离 profile(见 2.8) +``` + +三个 git 仓库的分工是本文最重要的工程事实之一:**skills/active 一个仓(接受=commit,拒绝=reset --hard)、wiki/ 一个仓(只 commit 不回滚,纯审计)、外层 workspace 无仓**。`cmd_reset`(`cli.py:174-182`)清 raw/runs 并回滚技能,但 wiki 不动。 + +### 2.7 RUNS.md 六次真实运行——live 数据(含全部拒绝记录) + +docs/RUNS.md 是作者记录的真实运行(每行对应 workspace 内 runs/ + raw/traces/ 工件,"No fabricated numbers"): + +| Run | 模型 | 基线 S0 | 结果 | +|---|---|---|---| +| 1 | deepseek-v4-flash, 15 轮 | R=1.0 | Algorithm 1 提前停止,正确行为 | +| 2 | 同上, 8 轮(加陷阱任务) | 1.0 → 强制迭代 | 提案 `spec_literal_transform`:R_val=1.0 不大于 R_best → **拒绝**。发现 maintainer 25 轮预算"够分析不够写"的 turn-cap bug | +| 3 | 同上, 60 轮修复版 | 1.0 → 强制 | maintainer 蒸馏出 4 个 pattern 页;提案 `exact-match-sandbox-task`:R_val=0.8889(技能反而伤了 1 题)→ **拒绝** | +| 4 | gemma-3-4b(免费档) | — | **整run作废**:12% session 启动失败(`-m` 路由 bug → HTTP 400),死 session 被旧沙箱残留交付物**幻影评分**。maintainer 的第 5 个 pattern 页 `trace-harness-launch-failure` 自己诊断出了框架 bug | +| 5 | gemini-2.5-flash-lite | 0.6667 | 提案 `find-secret`:R_val=0.4444,两题回归(extract-longest 1.0→0.0、find-biggest 1.0→0.0,"搜索精确字符串"的指引误导了需要**比较**文件的任务)→ **拒绝**。成本 **$0.086/迭代**(31 个真实 session) | +| 6 | 同上, 3 迭代复合实验 | 0.4444 | train 低至 0.2308,6/48 启动失败;仅 iter-03 蒸馏出 1 个 pattern;proposer **no_action**;r_best 保持 0.4444,零技能接受。诚实负结果:**积累需要更强的模型或更健康的 session** | + +要点:**截至 v0.1.4,该实现从未在 live 运行中成功接受过一个提案**(作者自述"acceptance remains test-proven, not live-proven")。论文的接受/复利数字(Table 4/5)依赖更强的 proposer 模型。另:docs/CRON.md 记录每夜 01:00 的 `evolve nightly --iters 1` 定时任务,免费档小模型一次迭代约 1-2 小时。 + +### 2.8 隔离 profile:与论文不同的实现路径 + +论文 §3.2.1 是**把技能全文注入系统提示**(full-injection,消除检索/触发失败作为混淆变量)。源码走的是另一条路:`backends/hermes.py:45-63` `bootstrap_profile` 为每个 workspace 建独立 `HERMES_HOME`(拷贝 config/凭证、清空 sessions/memories、`hermes skills opt-out` 关闭捆绑技能种子);`hermes.py:66-88` `set_active_skills` 在**每次 agent 调用前**把 profile 的 skills/ 重建为 active 集(+ maintainer/proposer 轮次额外加框架技能)的 symlink 集。语义等价(agent 恰好看到 S_k),但机制是宿主原生技能加载而非 prompt 注入——对 CodeWiki 的启示:**"草稿区不生效"用宿主的技能发现机制(profile/symlink 隔离)实现,比改系统提示更干净**。 + +其他后端同理:Claude Code 用隔离 `CLAUDE_CONFIG_DIR`、Codex 用隔离 `CODEX_HOME`、Copilot 把 active 集 symlink 进沙箱的 `.github/skills/`(README "Agent backends" 节)。 + +### 2.9 demo bench 的陷阱任务设计(自建验证基准的样板) + +`bench.py:337-456` `trap_tasks`:四种"可靠地让朴素 agent 在 S0 失败"的任务(数十换算陷阱、隐含字母过滤、双 bug 调试脚本 ×2)。双 bug 任务在**生成时 assert 两个 bug 不会互相抵消**(`bench.py:411` `assert buggy_val != int(expected), "trap debug-boundary: bugs compensate!"`)——防止"错错得对"让陷阱任务失真。bench 总体 22 任务(13 train / 9 val,seed 42 确定性生成),任务族瞄准经典失败模式:不读全规格、硬编码、朴素解析、不验证输出(`bench.py` 模块 docstring)。"每个任务交付物都是文件,评分无歧义"。`compare` 子命令提供配对 win/loss/tie 表 + 双侧精确二项 p 值(McNemar 类,`compare.py:29-46`),回答"这技能到底有没有用"。 + +### 2.10 transfer:跨工作区技能拷贝(Table 2 的工程对应物) + +`transfer.py:26-66`:把 src 的 active 技能整目录拷进 dst 的 active 集;同名默认跳过(`--force` 覆盖,先 rmtree 再 copytree);在 dst 仓库做一次 git commit,之后 dst 可以用自己的模型跑 `gate` 验证——**拒绝即回滚,与普通提案同语义**(模块 docstring)。没有做任何模型适配或冲突合并。 + +--- + +## 3. SKILL.md 产物:frontmatter 与正文结构(vs OKF v0.2) + +### 3.1 提案 schema 约定的最小形态 + +`prompts.py:115` 的提案 schema 只约定:`skill_md` = "full SKILL.md with YAML frontmatter + When to Apply + When NOT to Apply + Instructions"。frontmatter 只要求 `name` + `description`(论文 §2:"a unique name and concise description")。**没有 status/version/lifecycle 字段——生命周期完全外置在 git 提交史 + skill-impact.md 审计链里**。 + +### 3.2 仓库内 5 个真实演化产物的实际结构 + +`skills/` 下 5 个技能是 Run 3-5 期间演化出的真实产物(RUNS.md 逐一对得上:spec-literal-execution、script-exec-blocked、search-miss-binary、verify-output-readback、trace-harness-launch-failure),被作者打包随仓分发(仓库兼作 Hermes skills tap)。实际形态: + +- frontmatter:`name`、`description`(一句触发条件,含具体工具名与行为指令)+ 打包时加的 `version: 1.0.0`、`license: MIT`、`platforms`、`metadata.hermes.tags/homepage`(后四项是 tap 分发包装,非演化产物内生)。 +- 正文骨架实际是 **Problem → Root cause → Fix → Evidence**(不是提案 schema 约定的 When to Apply 三段式——真实产物偏离了 schema,但 Evidence 段保留)。 +- **Evidence 段带量化回链**:如 `spec-literal-execution` 写 "FAIL: spec-format2-1 (0.0). PASS: spec-format1-1, spec-format3-1 (1.0) — same duplicate-shaped data";`script-exec-blocked` 写 "Hit in 3/4 analyzed traces"。任务 ID + 分数 + 对比样本,可追溯到 graded 任务。 +- `description` 的写法值得注意:全是"条件 + 具体行动"式,如 "ripgrep/search_files silently skips binary-detected files — verify empty search results with grep -a / file / xxd before concluding 'not found'"。 + +### 3.3 与 CodeWiki 设计稿 OKF v0.2 frontmatter 的对比 + +| 字段 | wikiskill 演化产物 | CodeWiki 草稿区设计(skill-creator设计方案) | 差异评注 | +|---|---|---|---| +| name / description | 有(description = 触发条件 + 行动) | 有(同语义) | 一致 | +| type / status / generated / stale_after | 无 | 有(`type: Skill`、`status: draft|stable|deprecated`) | CodeWiki 把生命周期写进 frontmatter;wikiskill 外置到 git + skill-impact.md。CodeWiki 多这套是因为**没有自动门控**,需要文件自描述状态 | +| metadata.source_refs | 无独立文件级字段 | 有(scenario/notes 回链) | wikiskill 用**独立 PURPOSE.md 文件**(Origin + Patterns Addressed + Evolution History)承载同一信息 | +| metadata.revisions | 无 | 有(`{at, reason, source}` 列表) | wikiskill 的等价物是 skill-impact.md 的 unified diff 审计链(外部、含拒绝记录) | +| installed_at / installed_to | 无 | 有 | wikiskill 无两区制:active 即生效 | +| 正文骨架 | When to Apply / When NOT to Apply / Instructions(schema 约定);实际产物 Problem/Root cause/Fix/Evidence | 工作场景/适用条件/核心 SOP/判断逻辑/禁忌与反模式 | 语义高度同构:When NOT to Apply ≈ 禁忌与反模式;Instructions ≈ SOP;Evidence ≈ 关键事实依据。CodeWiki 的 scenario 骨架与论文约定天然对齐 | +| 溯源粒度 | PURPOSE.md + Evidence 段(任务 ID + 分数) | source_refs + revisions | wikiskill 的 Evidence 带**量化结果**(分数、命中率),CodeWiki 设计稿目前只有路径引用——可借鉴"证据段带任务/场景 ID + 结果数字" | + +对 CodeWiki 的两个具体启示:① `PURPOSE.md` 独立文件 vs frontmatter `source_refs`——如果 SKILL.md 会被宿主全量读入上下文,溯源元数据放独立文件可省 token(wikiskill 的选择);CodeWiki 草稿区进 lint 进索引、不直接生效,frontmatter 方案可行,但 install 到生效区时可考虑剥离冗余元数据。② description 的"条件+行动"写法(wikiskill 产物全部如此)比抽象概括更利于触发判定。 + +--- + +## 4. 技能退役、冲突、去重机制现状(对应 Q10/Q11) + +### 4.1 退役(retirement):不存在 + +- 源码 grep `retire|deprecat|delete_skill|remove_skill` 零命中(唯一 "Deprecated" 是无关的函数注释,`backends/hermes.py:90`)。 +- 技能唯一退出路径是**提案被拒**(git 回滚)或 `wikiskill reset`(回滚到上次 commit,`cli.py:174-182`)。已接受的技能**永远不会被主动移除**——只能被后续 patch 逐步改写。 +- 论文 Limitations 第 3 条自认:"Wiki Layer continuously accumulates... currently lacks an automated mechanism to prune the wiki",且未提技能修剪。技能侧不膨胀的现实原因是接受率低(Table 4:每次完整演化仅 1.2-1.6 个创建被接受)+ 演化轮数少(≤8)。 +- **对 CodeWiki 的参照**:wikiskill 没有解决退役问题,但它的"永不退役 + 外置审计链"之所以没爆炸,靠的是入口严(严格门控)和寿命短(≤8 轮)。CodeWiki 半闭环没有自动门控,入口松,**retire 模式(设计方案已有)不是可选项而是必需**——这是本次核实的最重要结论之一。 + +### 4.2 冲突(conflict):机制薄弱,有两处隐患 + +- `apply_proposal` 的 create 分支对同名目录 `os.makedirs(exist_ok=True)` 后直接覆写文件(`gating.py:74-84`)——**同名 create 静默覆盖既有技能,无冲突检测**。 +- `transfer` 对同名技能默认 skip、`--force` 才覆盖(`transfer.py:47-49`)——唯一有名字冲突意识的地方。 +- 无语义冲突检测(两个技能给矛盾指引时无任何机制发现)。论文 Limitations 第 1 条承认未评估多技能场景(全量注入 + 技能数少,冲突未成为实验内问题)。 +- **对 CodeWiki 的参照**:设计稿的"name/description Jaccard 相似度冲突预检"在 wikiskill 中完全缺失,属于 CodeWiki 超前项;但 wikiskill 的教训(覆盖无声、审计链靠外置 diff 补救)提示**覆盖/更新必须留 diff 或 revisions 记录**。 + +### 4.3 去重(dedup):prompt 纪律,非代码机制 + +- 维护者 prompt:"Do NOT create duplicate patterns — update existing ones with new evidence"(附录 E.2)/"Keep the wiki compact; merge overlapping patterns"(`skills/wikiskill-maintainer/SKILL.md`)。 +- 提案者 prompt:"Prefer patching over creating when a skill is partially correct"(附录 E.3 / `prompts.py:118-119`)+ "DO NOT repeat rejected approaches"(`prompts.py:102-103`)。 +- 没有任何程序化去重(无相似度计算、无向量检索)。被拒提案防重发的机制是**把完整被拒 JSON 内嵌进 skill-impact.md** 让下一轮 proposer 读到(`prompts.py:136`)——这是 wikiskill 对"重复提案"问题的唯一结构性答案,且是纯信息性的(靠 LLM 遵守)。 +- **对 CodeWiki 的参照**:Q10(技能有效如何判定)在 wikiskill 的对应物是 R_val 数字进 skill-impact.md;CodeWiki 无自动评分,最接近的等价物是把"人工试用反馈/任务结果"结构化回写(设计方案 Q10 的 effectiveness 字段方向),并且 wikiskill 证明**审计链放外部文件、按迭代追加、含完整被拒内容**是可行且被论文要求的形态。 + +--- + +## 5. 结论:本次精读对 skill-creator 设计的五条直接输入 + +1. **产物双文件**(SKILL.md + PURPOSE.md)是论文原生设计;CodeWiki 用 frontmatter source_refs 承载同一信息可行,但 install 时考虑剥离,Evidence/溯源正文带量化结果(任务 ID + 分数/命中率)值得抄。 +2. **"更新必须留痕"有论文级依据**:skill-impact.md 是 harness 程序化追加、含 unified diff + 完整被拒提案 + R_val 数字的外置审计链;CodeWiki 的 revisions 字段 + 拒绝记录应达到同等信息密度。 +3. **退役是 CodeWiki 的真缺口**(wikiskill 未解决且论文自认未解决),半闭环入口松,retire 模式必须落地;冲突预检(Jaccard)是 CodeWiki 超前项,但 create 覆盖必须显式(wikiskill 的静默覆写是反面教材)。 +4. **回流有效性判定可借用 skill-impact 形态**:即使没有自动门控,"每次试用 → 追加一条结构化结果记录(改了什么 / 结果数字 / 接受与否)"就能构成 Q10 需要的判定素材;wikiskill 的 no_action 证明"propose 无变化"也是合法轮次,CodeWiki 的重编译应允许空产出。 +5. **多轮回流有数据支撑**(Table 5:39-58% 接受发生在前两轮,但 mid/late 仍占 42-61%),但**门槛条件是 proposer 模型够强 + 素材里有真实失败**(RUNS.md Run 6 的负结果:弱模型 + 高启动失败率下 wiki 断粮、no_action、零积累)——CodeWiki 半闭环的"提炼用强模型、执行交给宿主"与论文 Table 2 的"发现/执行分离"结论一致。 + +--- + +## 附:本次引用源清单 + +- 论文 HTML 全文:arxiv.org/html/2608.27454(摘要、§1-7、Limitations、附录 A-E、Table 1-7、Figure 1-3) +- 源码:`.research-competitors/wikiskill/`(commit 02fac2c,v0.1.4),重点文件 `wikiskill/prompts.py`、`wikiskill/gating.py`、`wikiskill/harness.py`、`wikiskill/wiki.py`、`wikiskill/scoring.py`、`wikiskill/bench.py`、`wikiskill/tasks.py`、`wikiskill/transfer.py`、`wikiskill/compare.py`、`wikiskill/backends/hermes.py`、`skills/*/SKILL.md`、`docs/RUNS.md`、`docs/CRON.md`、`docs/COMPARING.md`、`README.md` +- 未获取:XStack18《WikiSkill——把 Agent 经验编译成永久知识》公众号原文(WebSearch 两轮未找到原文或可靠转载,本文零引用) diff --git a/docs/adr/0004-skill-scenario-split-two-zone-gate.md b/docs/adr/0004-skill-scenario-split-two-zone-gate.md new file mode 100644 index 0000000..ff94f3e --- /dev/null +++ b/docs/adr/0004-skill-scenario-split-two-zone-gate.md @@ -0,0 +1,45 @@ +# 0004. 技能与场景分轨,两区制守确认闸门 + +日期:2026-09-06 +状态:已接受 + +## 背景 + +为承载「自动生成 SKILL」(把已确认知识编译成 SKILL.md 行为指令资产),需要决定技能 +(skill)与既有场景块(scenario)的关系,以及生成的技能文件如何在不违反「落盘必经 +确认闸门」Doctrine 的前提下安全生效。核心矛盾:SKILL.md 一旦落进 IDE 技能发现目录 +(`.codebuddy/skills/`)即被宿主自动加载并可触发——草稿期文件若直接落生效区,确认 +闸门形同虚设。定档调研见 +`repowiki/wiki/comparisons/自动生成SKILL可行性-wikiskill闭环-vs-CodeWiki编译复用.md` +与 `repowiki/wiki/queries/skill-creator设计方案.md`(权衡三)。 + +## 决策 + +1. **技能与场景分轨**:scenario 是检索知识(agent 主动 `query_wiki` 按需查阅), + skill 是行为指令(宿主 IDE 按 description 自动触发改变 agent 行为)。两者素材同源 + (已确认笔记)、消费链路不同,不合并为一种资产。 +2. **草稿区技能进索引但不进召回**:草稿区 `repowiki/skills/` 参与索引构建与 + lint_wiki 扫描(管理通道),但不进入 query_wiki 的召回排序(避免 agent 把行为 + 指令当检索知识引用,混淆两种语义)。 +3. **两区制**:草稿落 `repowiki/skills/`(进索引进 lint、不生效),经用户确认后 + install 到生效区 `.codebuddy/skills/`(IDE 发现即生效)。install 是用户动作, + 工具不代劳;install 后的修订漂移只提示(lint warning + prepare 提示),不自动 + 覆盖生效区。 + +## 理由 + +1. **单区 + status 标记不可行**:草稿期技能若落在生效区,IDE 即时发现并可触发, + `status: draft` 标记没有任何机制强制力——闸门必须由目录隔离(IDE 不扫描 + repowiki/skills/)来物理保证。 +2. **wikiskill 同构佐证**:其隔离 profile 机制(`backends/hermes.py` symlink 重建 + active 集)同样靠宿主技能发现边界实现"所见即 active 集",语义等价但路径更重; + 两区制用目录边界达到同一效果,无需 profile 编排。 +3. **检索双通道会造成语义混淆**:若技能可被 query_wiki 召回,agent 会把 SKILL 正文 + 当知识引用,与"行为指令只经 IDE 触发"的消费模型冲突;scenario 已承担检索职责。 + +## 后果 + +- 生效区与草稿区可能漂移(草稿修订后生效区仍旧版):这是有意代价,由 lint 的 + content_hash 一致性检查暴露、由用户决定 reinstall。 +- `repowiki/skills/` 是新的受管目录:schema.yaml 需增 `page_types.skill`, + lint/路由/索引相应感知;`.codebuddy/skills/` 不进 repowiki 任何扫描。 diff --git "a/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" "b/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" new file mode 100644 index 0000000..eb6bf2e --- /dev/null +++ "b/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" @@ -0,0 +1,281 @@ +# skill-creator 需求与设计方案 + +> 把已确认知识编译成 SKILL.md 行为指令资产:MVP 单向编译器 + 回流接口设计。 +> +> 编制日期:2026-09-06 · 状态:设计定稿(grill 九问收敛完毕,代码未动) +> +> 前置文档(本方案的输入,本文不重复其论证): +> 1. `repowiki/wiki/comparisons/自动生成SKILL可行性-wikiskill闭环-vs-CodeWiki编译复用.md` — 三档方案定档(方案 C 半闭环,MVP = 单向编译器) +> 2. `repowiki/wiki/queries/skill-creator设计方案.md` — 工具形态初稿(四 mode、两区制、防碎片纪律、Q10/Q11 未决) +> 3. `docs/WikiSkill论文与wikiskill源码精读.md` — 一手事实补充(论文 Table 1-7 全表、源码工程细节、退役/冲突/去重现状) +> 4. `docs/adr/0004-skill-scenario-split-two-zone-gate.md` — 本方案催生的架构决策 +> +> 本文职责:整合上述输入 + 2026-09-06 grill 拷问九问(Q1-Q9)的收敛结论,形成 +> 唯一的**实施输入**——后续 to-spec / 工单拆解以本文为准。 + +## 1. 目标与非目标 + +**目标**:让 CodeWiki 的知识管线长出「自动生成 SKILL」能力——已确认知识 +(scenario + 精选 notes)经宿主 agent(Mode C)编译为 SKILL.md 草稿,人工确认后 +install 到生效区,被宿主 IDE 自动发现并改变 agent 行为;试用反馈经既有通道回流, +支撑技能修订迭代。 + +**非目标**: + +- 不建自动评分门控(无 held-out 基准;且 wikiskill 六次 live 运行零接受、$0.09/迭代 + 的数据表明弱模型下自动门控收益存疑——精读报告 §2.7) +- 不编 Inference Agent 编排(那是重做 wikiskill;CodeWiki 的采集管线天然回流) +- 不做跨工作区技能迁移(wikiskill transfer 的对应物,暂无需求) + +## 2. 范围(Q1/Q2 收敛) + +**本次实施(MVP)**: + +1. `skill_creator` 工具(四 mode:prepare / submit / install / retire) +2. 草稿区 `repowiki/skills/` 目录与词汇层(schema page_types.skill) +3. SKILL.md lint 规则与容量控制 +4. 素材过期联动、漂移检测(lint 检查) +5. 有效性反馈通道接线(flag_issue 复用) + +**本次只设计不实施(回流迭代,Phase 2)**: + +6. 回流接口——用生成的 SKILL 跑真实任务 → 采集管线吸收 → 再提炼改进。MVP 落地时 + 保证回流**数据有落点**(见 §4.4),迭代编排不做。 + +**明确不做**:自动评分、自动触发编译(Doctrine「触发永远显式」)、生效区自动同步。 + +**素材边界(2026-09-06 补)**:任务记忆(task memories)**不作 skill 素材**——它是 +直写落盘不经确认的进度知识(ADR-0002),直取等于给最高风险产物开免检通道;通用 +经验若值得变 skill,蒸馏双轨时本就该落成 note(正道 = 重新蒸馏 raw / 手动 +`ingest_note` 过闸门)。Phase 2 可评估任务记忆作 prepare 的**发现信号**(高频主题 +提示「值得蒸馏」),只借信号不借内容。 + +## 3. 领域词汇 + +见 `CONTEXT.md` 「skill」词条(2026-09-06 入表)。核心分轨一句话:**scenario 是 +检索知识(agent 查阅),skill 是行为指令(IDE 触发)**;与 `consolidate_notes` 的 +分工表沿用设计稿(上游 scenario → 下游 skill,source_refs 回链)。 + +## 4. 机制设计 + +### 4.1 工具形态:`skill_creator` 四 mode + +新文件 `codewiki/mcp/tools/skill_creator.py`,`registry.py` 注册。沿用 +`note_consolidation.py`(Mode C 先例,`codewiki/mcp/tools/note_consolidation.py:344` +起 prepare / `:400` 起 submit)的骨架: + +| mode | 入参 | 行为 | +|---|---|---| +| `prepare` | `topic?`, `sources?`, `limit?` | 返回候选素材(未编译 scenario + 精选 notes)、草稿区与生效区技能索引、冲突预检、容量预警、**该技能名下 open issues**(Q3 回流素材)、正文写作系统提示 | +| `submit` | `report.skills[]` | frontmatter 校验、路径安全、落草稿区、溯源互链、`revisions` 追加、索引重建、计数器归零 | +| `install` | `name` | 草稿 → `.codebuddy/skills/<name>/SKILL.md`,写 `installed_at`,**仅用户确认后调用** | +| `retire` | `name`, `reason` | 标记 deprecated + 从生效区 uninstall,正文保留(审计) | + +相对设计稿初稿的增量: + +- **prepare 吸收 open issues**:候选素材中,对既有技能聚合其名下 flag_issue 的 + open 问题作为修订输入(复用 `codewiki/mcp/tools/issue_tracker.py:66` + handle_flag_issue 的数据,不新开通道——Doctrine「单点收敛」)。 +- **允许空产出**:`no_action` 是合法轮次(wikiskill 先例:`harness.py:140-147`)。 + submit 报告可以为空("素材不足,不值得编译"),不视为失败。 + +### 4.2 目录、frontmatter 与 install 剥离(Q8 收敛) + +``` +repowiki/skills/<name>/SKILL.md # 草稿区:进索引进 lint、不生效 +.codebuddy/skills/<name>/SKILL.md # 生效区:IDE 自动发现 +``` + +**溯源单文件自包含**(Q8 定档 (a),否决 wikiskill 双文件 PURPOSE.md 方案):草稿区 +frontmatter 承载全部管理信息,膨胀由既有 `fold_private_metadata` +(`codewiki/src/frontmatter.py:203`)折叠吸收。理由:草稿区消费者是 lint 与 agent, +自包含优先;drift 比对(§4.5)只算一个文件。 + +```yaml +--- +name: <slug> +description: <触发条件 + 具体行动,一句话> # 写法规范见 §4.3 +type: Skill +status: draft # draft | stable | deprecated +generated: { by: codewiki/<ver>, at: <ts> } +stale_after: <date> +metadata: + summary: <40 字内> + source_refs: [wiki/scenarios/xxx.md, notes/yyy.md] + revisions: [{ at, reason, source }] # 每次修订追加;信息密度对齐 skill-impact.md + installed_at: <ts> + installed_to: .codebuddy/skills/<name>/ + installed_hash: <规范化内容哈希> # install 时写入,drift 检测用(§4.5) +--- +``` + +**install 剥离**:生效区只保留 `name` / `description` / 正文——`type`/`status`/ +`generated`/`stale_after`/`metadata.*` 全部剥离(SKILL.md 会被宿主全量读入上下文, +管理元数据在那里是纯 token 浪费;wikiskill 的 PURPOSE.md 独立文件正是出于同一动机, +精读报告 §3.3 启示①)。生效区文件由 install 程序化生成,**不允许**在生效区直接编辑。 + +### 4.3 写作规范(吸收 wikiskill 产物实测经验) + +1. **description = 条件 + 行动**:wikiskill 五个真实演化产物的 description 全部如此 + (如 "ripgrep/search_files silently skips binary-detected files — verify empty + search results with grep -a / file / xxd before concluding 'not found'"), + 比抽象概括更利于 IDE 触发判定(精读报告 §3.2)。 +2. **正文骨架沿用 scenario 五段**:工作场景 / 适用条件 / 核心 SOP / 判断逻辑 / + 禁忌与反模式——与论文三段式(When to Apply / When NOT to Apply / Instructions) + 天然对齐(精读报告 §3.3 对比表)。 +3. **关键事实依据段带量化回链**:不止 source_refs 路径,正文证据段应带 + note 标题 + 关键结论(wikiskill Evidence 段带任务 ID + 分数 + 命中率的做法值得 + 抄,精读报告 §3.2);CodeWiki 的对应物是「依据:notes/xxx.md 的 Y 结论」。 +4. **正文长度上限 8KB**(Q4 定档;Anthropic 官方技能普遍 1-3KB,8KB 已宽,超限 + 说明该拆分或该引用 scenario 而非复述)。 +5. **禁绝对路径与密钥**(lint 强制)。 + +### 4.4 有效性回流(Q10 收敛) + +**负面反馈 = flag_issue**:试用发现问题 → 对该技能草稿/生效条目 `flag_issue` +(issue_type 建议 `skill-ineffective`);`skill_creator prepare` 聚合该技能名下 +open issues 作为修订素材(§4.1)。修订闭环:flag → prepare(看到 issue)→ +submit(revisions 记录 reason)→ install。 + +**正面反馈 = 沉默即默认**:好用 = 不 flag 不 retire,无额外机制(避免为「点赞」 +再造一条通道;usage 信号无数据源——IDE 不回报技能触发次数)。 + +**素材过期联动(Q5 收敛)**:技能 `source_refs` 指向的素材若被后续 consolidate +更新、retire 或 deprecate,lint_wiki 将该技能标记 `possibly_stale`(复用既有对端 +新鲜度语义:ADR-0003 的"素材变没变"时间线),提示人决定 revise / retire。不自动 +触发修订(Doctrine「触发永远显式」)。 + +### 4.5 lint 规则与容量(Q4/Q5/Q6 收敛) + +草稿区纳入 `lint_wiki` 扫描(进索引就进 lint,不留法外之地)。SKILL.md 专项检查: + +| 规则 | 级别 | 说明 | +|---|---|---| +| name slug 合规 | error | slugify 后与目录名一致 | +| description 非空且含触发条件语义 | error | 「条件 + 行动」结构(§4.3-1) | +| frontmatter 完整 | error | status / source_refs 必填 | +| 正文 ≤ 8KB | error | 超限拒收 | +| 无敏感串 | error | 绝对路径、密钥 pattern | +| 修订必有 revisions | error | updated 条目 revisions 非空 | +| 素材过期联动 | warning | source_refs 素材 deprecated/已删 → possibly_stale(§4.4) | +| 漂移检测 | warning | 草稿区规范化哈希 ≠ `installed_hash` → "草稿已修订,生效区仍旧版,建议 reinstall"(§4.2) | + +**漂移不自动修复**(Q6 收敛):install 后草稿再修订,生效区不自动覆盖——"生效是 +用户动作"(定档第 5 条)。lint 暴露 + prepare 提示,reinstall 由人决定。 + +**规范化哈希**:install 剥离了管理元数据,drift 比对不能比整文件 content_hash—— +草稿区算「name + description + 正文」的规范化哈希存入 `installed_hash`,lint 比较该 +值。 + +**容量硬顶 12 份**(类比 `max_scenarios`=15,`codewiki/mcp/tools/aggregation_state.py:94`): +红(≥12)= 先合并再新建;橙(≥9)= 只 UPDATE。防碎片纪律沿用设计稿:默认 UPDATE、 +每批最多新建 1 份、新建前读 ≥2 份最相似技能、name/description Jaccard 冲突预检 +(wikiskill 的 create 同名静默覆盖是反面教材,精读报告 §4.2;retire + 冲突预检是 +竞品缺失的必需项,精读报告 §4.1)。 + +### 4.6 索引但不可召回(Q7 收敛,显式设计约束) + +草稿区 `repowiki/skills/` **参与索引构建**(lint、drift 检测、prepare 候选扫描、 +容量统计依赖索引),但**不进入 query_wiki 召回排序**。理由:技能消费方是 IDE 自动 +触发;若同时可被检索召回,agent 会把 SKILL 正文当知识引用,混淆「检索知识」与 +「行为指令」两种语义(ADR-0004 决策 2)。实现上:索引构建含 skills 目录,检索 +入口(`wiki_search.search` 的 SearchIndex Protocol 调用侧)按 page_type 过滤排除 +skill——过滤收口在 search 入口,与既有 freshness gate 同位置,避免调用方各自为政。 + +## 5. Q10/Q11 收敛结论(对设计稿未决问题的正式回答) + +**Q10 技能有效如何判定**:负面 = flag_issue(§4.4);正面 = 沉默即默认;素材过期 = +lint possibly_stale。不建 effectiveness 字段(无自动数据源)、不建 skill-impact.md +独立审计文件(revisions + flag_issue 信息量覆盖其子集,Doctrine 单点收敛)。 + +**Q11 lint 与容量**:规则清单见 §4.5(8 项:6 error + 2 warning);正文上限 8KB; +容量硬顶 12 / 橙线 9;草稿区纳入 lint_wiki 扫描。 + +## 6. 验收标准 + +MVP 验收 = 以下场景全部可用且测试覆盖: + +1. `skill_creator(mode="prepare")` 返回候选素材(scenario + 精选 notes + 既有技能 + open issues)、容量与冲突预警,无自动副作用。 +2. `submit` 落草稿区,frontmatter 校验失败(缺 status / 正文超 8KB / 敏感串)时 + 拒收并报具体规则;成功时溯源互链(技能 source_refs ⇄ 素材 compiled_into)。 +3. `install` 产出剥离管理元数据的生效区 SKILL.md,写 installed_at/installed_hash; + 二次调用幂等。 +4. 修订后 `lint_wiki` 报 drift warning;素材 retire 后报 possibly_stale warning。 +5. `retire` 标记 deprecated 且从生效区移除,草稿正文保留。 +6. `query_wiki` 任意查询不召回 skill 类型页面;`lint_wiki` 扫描覆盖草稿区。 +7. 全量 pytest 通过 + ruff(0.16.3)零告警;工具接口七处同步完成(§7-8)。 + +## 7. 实施拆解(建议工单序) + +依赖从下往上,阻塞关系如箭头所示: + +1. **词汇层**:`repowiki/schema.yaml` 增 `page_types.skill`(directory: `skills`, + required_sections 沿用五段骨架);`note_types.py` 同款思路增 skill 资产声明。 + → 阻塞 2/5 +2. **配置与目录**:`codewiki/src/config.py` 增 `SKILLS_DIR`(草稿区)与生效区常量 + (`PAGE_TYPE_DIRS` 同处)。→ 阻塞 3 +3. **skill_creator 工具**:四 mode 实现,复用 `store.locked_rmw` + frontmatter + round-trip(`codewiki/src/frontmatter.py`)+ `aggregation_state` 计数器;含 + install 剥离与规范化哈希。→ 阻塞 4/6 +4. **lint 扩展**:SKILL.md 八项检查(§4.5)+ possibly_stale 联动 + drift 检测 + + 容量阈值。→ 阻塞 6 +5. **检索隔离**:search 入口按 page_type 排除 skill(§4.6)。 +6. **仓内闭环验证**:拿真实素材走一轮 prepare→submit→install→flag→revise 全链。 +7. **同步与测试**:handler / registry / prompts(正文+注册)/ resources / README + 中英 / docs 设计文档 / tests——七处同步 + 全量测试(本仓接口改动同步纪律)。 +8. **回流接线**:prepare 聚合 flag_issue open issues(可与 3 同工单做)。 + +Phase 2(本次不做):真实任务回流编排、技能采纳信号、hooks.yaml 家族分发技能资产。 + +## 8. 风险与已知限制 + +- **CodeBuddy 技能发现对 install 产物格式的实际兼容性**未实测(MVP 验收场景 3/6 + 需真机验证一次)。 +- **容量 12 的取值是类比推定**,无运行数据支撑;上线后按 lint 的容量告警频率调整。 +- **正面反馈通道缺失**是有意取舍:若未来 IDE 提供技能触发回执,可再评估。 +- 半闭环多轮回流的门槛条件(提炼模型够强 + 素材含真实失败,精读报告 §5-5)意味着 + 回流质量依赖宿主模型档位,工具层不解决。 +- **素材保真度受捕获链路信息损耗制约**(见 §9):压缩时丢掉的工具操作细节,蒸馏 + 永远找不回来——这是 notes/scenarios 素材质量的上游杠杆,影响 skill 生成上限。 + +## 9. 素材保真度:捕获链路的信息损耗(Phase 2 改进项,本次不改代码) + +skill 的价值密度取决于素材里「命令-报错-修复对、版本/参数钉子、失败轨迹」的保有量 +(wikiskill 演化产物全是此类;论文 Proposer 纪律要求读 ≥4 条失败 trace 诊断根因)。 +现状是这些信息在捕获链路被系统性丢弃: + +**丢弃触点清单(2026-09-06 实核)**: + +1. `codewiki/mcp/tools/capture_conversation.py:207` — `_NOISE_BLOCK_TYPES` 11 类 + 块(tool_use / tool_result / thinking / system 等)持久化前整体丢弃 +2. `codewiki/mcp/_ide_hook.py:248` — 同名集合,IDE hook 采集侧同样过滤 +3. `AGENTS.md`「QwenWork 捕获协议」— 指令层压缩准则「丢弃寒暄、过程噪音与 + **工具调用细节**」;千问办公链路的压缩由 agent 执行,口径即天花板 +4. `tests/test_ide_hook_capture.py:229,254` — 测试断言 tool 块被过滤(改口径须同步) +5. distill-worker 剧本(`.qoder/.codebuddy/agents/distill-worker.md`)— 蒸馏只读 + raw,raw 缺的提不出 + +既有自认:`docs/知识飞轮增强设计方案-P0三项.md` §设计前提已记录该过滤是刻意设计 +(raw 必须无噪音),且 toolError/toolReject 信号在现有管线下不可得。 + +**改进方向(两档,均为后续工单)**: + +- **方案甲(指令层,零代码)**:改 AGENTS.md 捕获协议与 distill-worker 剧本的压缩 + 准则——丢过程脚印(重复读、失败搜索、确认往返),**保留关键命令原文、报错→修复 + 对、版本/参数钉子**。判断标准:这条工具信息换个会话还能复用吗?能 → 留。覆盖 + QwenWork 链路(agent 压缩本就写要点),成本低,先行。 +- **方案乙(代码层)**:`_NOISE_BLOCK_TYPES` 拆两级——纯噪音(thinking/system) + 仍丢;tool_use 保留压缩形态(toolName + 关键参数首行,如 bash command), + tool_result 仅保留 error 片段(首 ~200 字符);落 raw 时折叠为附录块不污染正文。 + 触点:capture_conversation.py + _ide_hook.py 双处同步 + 测试 + P0 文档更新 + (IDE hook 是 stdlib-only 确定性脚本,无法做价值判断,只能做形态压缩)。 + +**与 MVP 的关系**:不阻塞 MVP(素材源 notes/scenarios 已有存量),但它是素材质量 +的上游杠杆;方案甲可在回流迭代(Phase 2)开工前先做,几乎零成本。 + +--- + +*源设计文档:repowiki/wiki/queries/skill-creator设计方案.md(stable)+ +docs/WikiSkill论文与wikiskill源码精读.md(2026-09-06)+ grill Q1-Q9 收敛记录 +(2026-09-06 会话)。状态:设计定稿待实施。* From 3db4827110fb92d9b6210273d96699e82cb83250 Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 16:49:53 +0800 Subject: [PATCH 08/23] =?UTF-8?q?feat(mcp):=20=E6=8A=80=E8=83=BD=E9=A1=B5?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E9=9A=94=E7=A6=BB=E2=80=94=E2=80=94=E8=BF=9B?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E4=BD=86=E4=B8=8D=E8=BF=9B=E5=8F=AC=E5=9B=9E?= =?UTF-8?q?=EF=BC=88T5,=20#28=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0004 决策 2:草稿区技能页参与索引构建(lint/容量统计/prepare 候选 扫描依赖),但永不进 query_wiki 召回——行为指令的消费方是宿主 IDE 触发, 可检索会造成「检索知识」与「行为指令」语义混淆。 - cache.py search():主评分循环与展开词循环两处排除 source="skill" - wiki_search.py search():legacy JSON 路径主循环同款排除 - 测试 3 例:legacy/SQLite 双路径零召回(带对照笔记证明索引有效、 排除只针对 skill)+ 展开路径不泄漏 Closes #28 --- codewiki/mcp/cache.py | 10 ++++ codewiki/mcp/tools/wiki_search.py | 5 ++ tests/test_skill_pages.py | 87 +++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/codewiki/mcp/cache.py b/codewiki/mcp/cache.py index 22ac521..9e79041 100644 --- a/codewiki/mcp/cache.py +++ b/codewiki/mcp/cache.py @@ -1378,6 +1378,12 @@ def search( continue if not include_notes and doc_row["source"] == "note": continue + # skill-creator T5 (issue #28, ADR-0004): recall isolation — + # draft-zone skills are indexed (lint/capacity/stats see them) + # but NEVER recalled: skills are behaviour instructions consumed + # by the IDE trigger, not retrievable knowledge. + if doc_row["source"] == "skill": + continue # LLM Wiki: type_filter enforcement if allowed_source_types and doc_row["source"] not in allowed_source_types: continue @@ -1481,6 +1487,10 @@ def search( continue if not include_notes and doc_row["source"] == "note": continue + # T5 recall isolation (issue #28): expanded-term path must + # not leak skill pages either — same rule as the main loop. + if doc_row["source"] == "skill": + continue snippet = "" ex_raw_len = 0 if output_dir is not None: diff --git a/codewiki/mcp/tools/wiki_search.py b/codewiki/mcp/tools/wiki_search.py index 43df7b3..022e09b 100644 --- a/codewiki/mcp/tools/wiki_search.py +++ b/codewiki/mcp/tools/wiki_search.py @@ -703,6 +703,11 @@ def search( continue if not include_notes and di.get("source") == "note": continue + # skill-creator T5 (issue #28, ADR-0004): recall isolation — draft + # skills are indexed but never recalled (behaviour instructions are + # consumed via IDE trigger, not query_wiki). Mirrors cache.py search. + if di.get("source") == "skill": + continue s = 0.0 tfm = di.get("term_freq", {}) dl = di.get("doc_len", 1) diff --git a/tests/test_skill_pages.py b/tests/test_skill_pages.py index 712da42..e248a26 100644 --- a/tests/test_skill_pages.py +++ b/tests/test_skill_pages.py @@ -218,3 +218,90 @@ def test_effect_zone_files_never_linted(tmp_path): (eff / "SKILL.md").write_text("no frontmatter, no headings\n", encoding="utf-8") issues = [i for i in _lint(repo, ["skill_sections"]) if i["check"] == "skill_sections"] assert issues == [] + + +# --------------------------------------------------------------------------- # +# 5. T5 recall isolation (issue #28): indexed, but never recalled +# --------------------------------------------------------------------------- # +def _mk_recognizable_skill(od: Path, name: str, marker: str) -> Path: + """A skill whose body carries a unique BM25 marker token.""" + body = "\n".join(f"## {s}\n\n{marker} {s}" for s in _SECTIONS) + sk_dir = od / "skills" / name + sk_dir.mkdir(parents=True, exist_ok=True) + p = sk_dir / "SKILL.md" + p.write_text( + "---\n" + + yaml.safe_dump( + {"name": name, "description": f"当遇到 {name} 场景时使用 {marker}", "type": "Skill", "status": "draft"}, + allow_unicode=True, + ) + + "---\n\n" + + body + + "\n", + encoding="utf-8", + ) + return p + + +def test_search_legacy_never_recalls_skill(tmp_path): + repo, od = _mk_repo(tmp_path) + marker = "zxquniquemarker" + _mk_recognizable_skill(od, "secret-skill", marker) + # a note sharing the marker IS recalled — proving the index works and + # the exclusion is specific to skills, not a broken search + (od / "notes").mkdir(exist_ok=True) + (od / "notes" / f"{marker}-note.md").write_text( + f"---\ntitle: {marker} note\nstatus: stable\n---\n\n{marker} body\n", + encoding="utf-8", + ) + wiki_search.build_full_index(od) + results = wiki_search.search(od, marker) + files = [r["file"] for r in results] + assert not [f for f in files if f.startswith("skills/")], files + assert any(marker in f for f in files), "control note should be recalled" + + +def test_search_sqlite_never_recalls_skill(tmp_path): + from codewiki.mcp.cache import AnalysisCache + + repo, od = _mk_repo(tmp_path) + marker = "zxqsqliteuniquemarker" + _mk_recognizable_skill(od, "sqlite-secret-skill", marker) + (od / "notes").mkdir(exist_ok=True) + (od / "notes" / f"{marker}-note.md").write_text( + f"---\ntitle: {marker} note\nstatus: stable\n---\n\n{marker} body\n", + encoding="utf-8", + ) + cache = AnalysisCache(Path(repo), db_path=Path(repo) / ".codewiki" / "analysis_cache.db") + try: + cache.build_search_index(od) + # indexed but not recallable + rows = cache.conn.execute( + "SELECT COUNT(*) AS c FROM search_index WHERE source='skill'" + ).fetchone() + assert rows["c"] == 1 + results = cache.search(marker, output_dir=od) + files = [r["file"] for r in results] + assert not [f for f in files if f.startswith("skills/")], files + assert any(marker in f for f in files), "control note should be recalled" + finally: + cache.close() + + +def test_search_expanded_terms_do_not_leak_skill(tmp_path): + """The SQLite expanded-terms path must respect the same isolation.""" + from codewiki.mcp.cache import AnalysisCache + + repo, od = _mk_repo(tmp_path) + marker = "zxqexpanduniquemarker" + _mk_recognizable_skill(od, "expand-leak-skill", marker) + cache = AnalysisCache(Path(repo), db_path=Path(repo) / ".codewiki" / "analysis_cache.db") + try: + cache.build_search_index(od) + results = cache.search( + "nearmiss", # main query matches nothing… + output_dir=od, + ) + assert not [r for r in results if str(r["file"]).startswith("skills/")] + finally: + cache.close() From 8fc23b2ac294ecb5b1300d08f663eb1495bb729c Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 17:49:52 +0800 Subject: [PATCH 09/23] =?UTF-8?q?=EF=BB=BFdocs(compare):=20=E7=AB=9E?= =?UTF-8?q?=E5=93=81=E8=B0=83=E7=A0=94=E4=BA=A7=E7=89=A9=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=E8=87=B3=20docs/compare/=EF=BC=8C=E7=B3=BB=E5=88=9712=E5=A2=9E?= =?UTF-8?q?=E8=A1=A5=E4=B8=A4=E4=B8=AA=E5=88=86=E6=B0=B4=E5=B2=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调研报告从 docs/ 根移入 docs/compare/,合集四份源码调研笔记与公众号 系列12副本;系列12新增"对LLM依赖程度""团队经验沉淀与任务记忆"两个 分水岭章节(增补版同步 articles/ 与 compare/ 两处),并修正"学到什么" 章节中 B6 已落地、B2/B1 经代码核对后的真实范围表述。 --- ...50\260\201\345\234\250\345\201\232Demo.md" | 44 ++++- ...50\260\201\345\234\250\345\201\232Demo.md" | 185 ++++++++++++++++++ ...40\224\346\212\245\345\221\212-2026-09.md" | 0 docs/compare/OpenDeepWiki-notes.md | 63 ++++++ docs/compare/deepwiki-open-notes.md | 80 ++++++++ docs/compare/deepwiki-rs-notes.md | 58 ++++++ docs/compare/openwiki-notes.md | 76 +++++++ 7 files changed, 501 insertions(+), 5 deletions(-) create mode 100644 "docs/compare/CodeWiki-Plus\347\263\273\345\210\22712\357\274\232\345\233\233\344\270\252DeepWiki\345\244\215\345\210\273\347\232\204\346\272\220\347\240\201\346\250\252\350\257\204\342\200\224\342\200\224\350\260\201\345\234\250\345\201\232\345\267\245\347\250\213\350\260\201\345\234\250\345\201\232Demo.md" rename "docs/DeepWiki\347\261\273\345\274\200\346\272\220\351\241\271\347\233\256\345\257\271\346\257\224\350\260\203\347\240\224\346\212\245\345\221\212-2026-09.md" => "docs/compare/DeepWiki\347\261\273\345\274\200\346\272\220\351\241\271\347\233\256\345\257\271\346\257\224\350\260\203\347\240\224\346\212\245\345\221\212-2026-09.md" (100%) create mode 100644 docs/compare/OpenDeepWiki-notes.md create mode 100644 docs/compare/deepwiki-open-notes.md create mode 100644 docs/compare/deepwiki-rs-notes.md create mode 100644 docs/compare/openwiki-notes.md diff --git "a/docs/articles/CodeWiki-Plus\347\263\273\345\210\22712\357\274\232\345\233\233\344\270\252DeepWiki\345\244\215\345\210\273\347\232\204\346\272\220\347\240\201\346\250\252\350\257\204\342\200\224\342\200\224\350\260\201\345\234\250\345\201\232\345\267\245\347\250\213\350\260\201\345\234\250\345\201\232Demo.md" "b/docs/articles/CodeWiki-Plus\347\263\273\345\210\22712\357\274\232\345\233\233\344\270\252DeepWiki\345\244\215\345\210\273\347\232\204\346\272\220\347\240\201\346\250\252\350\257\204\342\200\224\342\200\224\350\260\201\345\234\250\345\201\232\345\267\245\347\250\213\350\260\201\345\234\250\345\201\232Demo.md" index 02aa279..50253dc 100644 --- "a/docs/articles/CodeWiki-Plus\347\263\273\345\210\22712\357\274\232\345\233\233\344\270\252DeepWiki\345\244\215\345\210\273\347\232\204\346\272\220\347\240\201\346\250\252\350\257\204\342\200\224\342\200\224\350\260\201\345\234\250\345\201\232\345\267\245\347\250\213\350\260\201\345\234\250\345\201\232Demo.md" +++ "b/docs/articles/CodeWiki-Plus\347\263\273\345\210\22712\357\274\232\345\233\233\344\270\252DeepWiki\345\244\215\345\210\273\347\232\204\346\272\220\347\240\201\346\250\252\350\257\204\342\200\224\342\200\224\350\260\201\345\234\250\345\201\232\345\267\245\347\250\213\350\260\201\345\234\250\345\201\232Demo.md" @@ -108,7 +108,41 @@ deepwiki-open 和 deepwiki-rs:无 MCP。 --- -## 六、每家压箱底的工程亮点 +## 六、分水岭之五:对 LLM 的依赖程度 + +前几条分水岭看的都是"做了什么",这一条看"靠什么做"。整个管线里确定性代码和 LLM 的配比,决定了两个东西:**成本曲线和正确率上限。** + +**OpenDeepWiki 是五家里最重的。** 数一下 LLM 出场的次数:目录生成是一次 agentic 调用(agent 拿着目录树和 README 用 `WriteCatalog` 写 JSON);每篇文档又是独立 agentic 调用,带着完整系统提示词和工具往返;多语言靠 LLM 逐篇翻译已有文档(`TranslateWikiAsync`),不是复用分析结果。整条管线没有一处确定性的代码理解托底,"目录模型"和"内容模型"分开配置已经是它对成本的全部思考。 + +**deepwiki-open 是三次出场加一个影子。** 结构 XML 一次、逐页生成一次、网页问答一次,外加一个常驻的嵌入模型——连"哪段代码和这个问题相关"都要模型说了算。确定性部分只有文件树展示和文本分块。它的省钱努力体现在防御性解析(被截断的输出抢救出来重用)而不是减少调用。 + +**deepwiki-rs 有个确定性骨架。** 目录扫描、文件重要性打分、正则语言处理都不花 token,这是它值得肯定的部分。但从逐目录 LLM 总结开始(`preprocess/mod.rs` 的 DirectoryDossier),7 个 research agent、6 个 compose editor 就全是 LLM 了,骨架之上全是模型。 + +**openwiki 全是 agent,但有分寸感。** planner 和 worker 都是完整 agent,分析零静态底座,按说是最重的一家——但它在收尾处把机械活从 LLM 手里拿了回来:finalizer 纯代码、index.md 禁止模型手写。它对 LLM 的态度是"干活可以,签字不行"。 + +**CodeWiki-Plus 的配比最克制。** 分析阶段零 LLM:组件、签名、调用图、行区间全部来自 AST 和 tree-sitter 的语法分析,这一段一个 token 都不花。生成阶段 LLM 只当翻译官——事实是分析器采出来的,模型只负责说清楚;上一篇讲过的置信度协议再补一道闸:没有代码证据的断言必须挂牌 `[candidate]`。**依赖度低的直接回报是:代码事实的正确性不随模型发挥波动。** + +--- + +## 七、分水岭之六:团队经验沉淀与任务记忆 + +这一条是横评里最一边倒的维度。问题是:**用这个工具一个月之后,团队比一个月之前多了什么?** + +**deepwiki-open:什么都不多。** 对话历史由前端每次全量携带,后端在内存里重建对话对象,服务重启即失(`research.py:107-116`)。连单人使用者的连续性都没有,更谈不上团队。wiki 缓存是一次性产物,没有回流机制。 + +**OpenDeepWiki:有组织,没沉淀。** 它的组织/部门/角色体系做得很全,但那是访问控制,回答的是"谁能看",不是"谁知道"。聊天记录进 ChatLog 是审计日志,不是可检索的知识。多人共用一个 wiki 站,各自的理解并不会因此累积。 + +**openwiki:治理单篇事实,不沉淀团队经验。** Claims 账本管的是"这篇文档里每条断言是否仍有证据",这是文档级事实治理;团队踩过的坑、做过的决策、推翻过的方案,没有对应的容器。它的 personal 模式有 9 类连接器(Slack、Gmail、HackerNews……)能摄取个人数据流,但那是个人知识库方向,与代码 wiki 主线正交。聊天会话有 SQLite checkpointer 持久化,但那是会话状态,关掉就是历史。 + +**deepwiki-rs:最接近的一个,但只做了一半。** 它的 `.ai-context/` 分层知识库(Tier0 项目本质 → Tier3 当前问题)按稳定性组织知识供 coding agent 消费,"AGENTS.md 讲怎么干活,.ai-context 讲项目是什么"这个分工是对的。但内容是一次性生成的,用完即弃,没有从使用中回流的通道——是个静态的说明书,不是飞轮。 + +**CodeWiki-Plus 在这条线上没有对手:对话捕获 → 蒸馏 → 草稿笔记 → 确认闸门 → 采纳计数反哺检索排序,外加 Doctrine 团队共识和跨会话的任务记忆(分片存储、多人隔离)。** 工具越用,库里的确认笔记越多,检索排序越准,团队的决策脉络越完整。这是"Wiki 生成器"和"知识系统"的分界线:前者生成完就结束了,后者从使用中变厚。 + +不过没有对手不等于做完了。沉淀的另一半是治理成本:确认闸门需要人过目,笔记多了需要合并退役,这些成本是真实存在的。四家竞品没做这件事,可能不是没想到,而是不想让用户背这个成本。CodeWiki-Plus 的判断是值得背——但这个判断本身,也需要用飞轮里的使用数据持续检验。 + +--- + +## 八、每家压箱底的工程亮点 横评不能光挑刺。四个项目各有几处设计,读的时候让我停下来记了笔记。 @@ -122,15 +156,15 @@ deepwiki-open 和 deepwiki-rs:无 MCP。 --- -## 七、CodeWiki-Plus 学到了什么 +## 九、CodeWiki-Plus 学到了什么 调研的落点不是写文章,是改进自己。排除 8 月底已经从 openwiki 借鉴落地的五项(证据哈希、页面 manifest、mermaid 降级、no-op 防扰、评测框架),这次从四个项目里提炼出的清单,按优先级排三件: -**第一件,LLM 调用缓存加 token 统计。** 学 deepwiki-rs 的 MD5(prompt) 文件缓存,但要避开它的坑:缓存键必须校验模型名,否则换模型命中旧答案。CodeWiki-Plus 幂等重跑时每个模块都重新调 LLM,加这层缓存后,没变的模块零成本命中。一到两个人日的活,直接降本。 +**第一件,LLM 调用缓存加 token 统计。** 学 deepwiki-rs 的 prompt 文件缓存,但要避开它的坑:缓存键必须校验模型名,否则换模型命中旧答案。而且实现前先核对了自己的代码:CodeWiki-Plus 的模块生成是 agentic 多轮调用(agent 带工具走完整轨迹),单次 prompt→response 的缓存根本套不上,能做的只有模块级指纹缓存——输入没变就跳过整个 agent run。教训:借鉴别家机制前,先确认人家的机制对应自己哪段代码,调研报告说的痛点可能自家早就解决了一半。 -**第二件,stale_evidence 回灌增量决策。** 这是对已落地机制的"最后一公里"。openwiki 的做法是 Claims preflight 的 stale 清单直接喂给 planner,成为"哪些页要重写"的决策输入;我们的 stale_evidence 检查结果目前只躺在 lint 报告里。把它接进增量检测的输出,和受影响模块、过期页面并列成第三信号源,增量闭环才算真正闭上。 +**第二件,stale_evidence 回灌增量决策。** 这是对已落地机制的"最后一公里"。openwiki 的做法是 Claims preflight 的 stale 清单直接喂给 planner,成为"哪些页要重写"的决策输入。这一件在本文写作当天已经落地:漂移信号成为增量决策的第三信号源,与受影响模块、过期页面并列;代码没变的短路路径保持静默,是有意的不对称——代码没动,工具就不该多嘴。 -**第三件,确定性收尾管线。** 学 openwiki 的 finalizer:生成流程末尾加一段纯代码收尾,把索引同步和内链校验从"检查"升级成"检查加修复"。索引和内链这种机械活,不该指望 LLM 每次都写对,也不该只靠事后 lint 点名。 +**第三件,确定性收尾管线。** 学 openwiki 的 finalizer:生成流程末尾加一段纯代码收尾,把索引同步和内链校验从"检查"升级成"检查加修复"。核对代码后范围收窄:真实缺口只剩 generate_docs 这条链路跑完没有任何索引收尾,两处既有调用点已经在了。半天能补完的活。 还有一条产品层面的观察,不是技术决策:deepwiki-open 用五家里最粗糙的工程拿了最高的 star,17.9k。它证明了**网页 Demo 的传播力可以碾压工程质量**。CodeWiki-Plus 目前只有 IDE 侧的消费入口,如果哪天需要传播性,把现有 Vue 前端包装成只读可视化站点,比补齐任何技术短板都划算。这是留给未来的选择题。 diff --git "a/docs/compare/CodeWiki-Plus\347\263\273\345\210\22712\357\274\232\345\233\233\344\270\252DeepWiki\345\244\215\345\210\273\347\232\204\346\272\220\347\240\201\346\250\252\350\257\204\342\200\224\342\200\224\350\260\201\345\234\250\345\201\232\345\267\245\347\250\213\350\260\201\345\234\250\345\201\232Demo.md" "b/docs/compare/CodeWiki-Plus\347\263\273\345\210\22712\357\274\232\345\233\233\344\270\252DeepWiki\345\244\215\345\210\273\347\232\204\346\272\220\347\240\201\346\250\252\350\257\204\342\200\224\342\200\224\350\260\201\345\234\250\345\201\232\345\267\245\347\250\213\350\260\201\345\234\250\345\201\232Demo.md" new file mode 100644 index 0000000..50253dc --- /dev/null +++ "b/docs/compare/CodeWiki-Plus\347\263\273\345\210\22712\357\274\232\345\233\233\344\270\252DeepWiki\345\244\215\345\210\273\347\232\204\346\272\220\347\240\201\346\250\252\350\257\204\342\200\224\342\200\224\350\260\201\345\234\250\345\201\232\345\267\245\347\250\213\350\260\201\345\234\250\345\201\232Demo.md" @@ -0,0 +1,185 @@ +# CodeWiki-Plus 系列 12:四个 DeepWiki 复刻的源码横评——谁在做工程,谁在做 Demo? + +> 上一篇讲了 CodeWiki-Plus 自己怎么回答"机器写的 Wiki 凭什么可信"。这一篇把镜头转向外面。DeepWiki 火了之后,开源圈冒出来一批复刻和同类项目,star 都不低:deepwiki-open 17.9k,openwiki 16.2k,OpenDeepWiki 3.6k,deepwiki-rs 1.7k。我最近做 CodeWiki-Plus 的竞品调研,把这四个项目的源码全部克隆下来通读了一遍,加上 CodeWiki-Plus 自己,五个项目摆在一起对比。先说这篇的立场,也是整次调研最大的收获:**star 数和技术含量是两回事,README 和代码是两回事,"有 Demo"和"有工程"更是两回事。** 下面按源码说话。 + +--- + +## 引子:为什么非得读源码 + +调研方法先交代清楚。四个仓库全部浅克隆,commit 时点从 2026-08-15 到 2026-09-04,四个子代理并行读源码,每一个论断都要求落到`文件:行号`。为什么这么较真?因为这已经是我们踩过的坑:之前调研 claude-mem,文档站说它有 4 个工具,代码里实际 19 个。 + +这次的教训更生动。下面这张表,左边是 README 的宣称,右边是源码的真相: + +| 项目 | README 宣称 | 源码实际 | +|------|------------|----------| +| deepwiki-rs | 支持 Go | 语言处理器就 12 个(rust/js/ts/php/react/vue/svelte/kotlin/python/java/csharp/swift),没有 Go(`language_processors/mod.rs:44-57`) | +| deepwiki-rs | "research agents in parallel" | 并行工具函数全库只用了两处,agent 之间和逐目录总结全是串行 for 循环(`preprocess/mod.rs:146`) | +| deepwiki-rs | `--skip-*` 旗标可跳过阶段 | 三个旗标是死代码,声明了全库没有消费点(`cli.rs:36-44`) | +| deepwiki-rs | 缓存省 token | 缓存条目存了 model_name 字段,读取时却不比对,换模型照样命中旧答案(`cache/mod.rs:88-109`) | +| OpenDeepWiki | "多语言代码分析" | 全库没有任何 AST 或语言解析器,代码理解全靠 LLM 加 ReadFile/ListFiles/Grep 三个工具(`GitTool.cs:770-781`);所谓多语言,指的是输出文档的多语言 | +| deepwiki-open | LiteLLM 支持 | LiteLLM 只是它自研的 10 个客户端里的一个可选项(`api/clients/litellm.py:8-49`),不是核心抽象 | + +一个项目占四行,因为 deepwiki-rs 是重灾区。这不是要踩谁,而是想说一个判断:**这一批项目处在"DeepWiki 概念验证"的窗口期,README 的营销属性大于文档属性。** 你要是想从中借鉴点什么,读代码是唯一可靠的方式。 + +--- + +## 一、五个项目,五条路线 + +先把五个项目的底子摆出来(star 数据取自 2026-09-06 的 GitHub API): + +| 项目 | Stars | 技术栈 | 形态 | 一句话定位 | +|---|---|---|---|---| +| CodeWiki-Plus | — | Python + Vue | MCP 服务端(47 工具) | 本地优先的 LLM Wiki 生成与知识飞轮,嵌在 IDE Agent 工作流里 | +| openwiki | 16.2k | TypeScript + LangChain | npm CLI + 内部 MCP | "自维护 wiki":planner 到逐页 worker 的 agentic 生成,外加一套事实治理 | +| deepwiki-open | 17.9k | Next.js + Python FastAPI | Web 服务 | Devin DeepWiki 的开源复刻:输个仓库 URL,生成 wiki 加网页问答 | +| OpenDeepWiki | 3.6k | ASP.NET Core + Next.js | 自托管平台 | 多仓库多分支多语言的知识库托管平台,带组织权限和 Admin 后台 | +| deepwiki-rs (Litho) | 1.7k | Rust | 纯 CLI | 一次性的 C4 架构文档生成器 | + +几个容易被外表骗到的点,展开说说。 + +**deepwiki-open 不是 TypeScript 项目。** GitHub 把它的主语言标成 Python,但很多人的印象里它是"那个 Next.js 的 DeepWiki 复刻"。读代码才知道,Next.js 那层只是个反向代理,`src/app/api/*/route.ts` 把请求原样转发给后端;真正的心脏是 Python FastAPI(`api/main.py:50-72`),重度绑定 AdalFlow 框架。一个 Docker 容器里跑两个进程,这是"Demo 长得快"的典型架构:前端抄 DeepWiki 的皮,后端拼 RAG 的架子。 + +**deepwiki-rs 已经在收缩。** 它的产品名改叫 Litho,README 里明确宣布演进为后继项目 Terrain,Litho 收缩为"快速聚焦的 C4 文档生成器"(README.md:31-40)。主理人的开发重心已经转移。借鉴它的机制之前,得先掂量这个项目还能活多久。 + +**OpenDeepWiki 是五家里唯一做"平台"的。** ASP.NET Core 单体 + EF Core 双数据库 + 组织/部门/角色权限 + Admin 后台 + IM 接入(飞书/QQ/微信/Slack)。它解决的问题不是"怎么生成好文档",而是"企业内部的知识库服务怎么运营"——这是另一个问题域。 + +--- + +## 二、分水岭之一:谁在做真正的代码分析 + +这是五个项目最根本的差异,也是我读完之后心里最有底的一件事。 + +四个竞品,无一例外,全部走"LLM 直接读文件"的路线: + +- **openwiki** 是有意为之的架构选择:planner agent 拿着 ls/glob/grep/read_file 四个只读工具在仓库里逛,每页 worker 加上写权限但被限制只能写自己那一页。零 AST,零依赖图,零嵌入向量。它的态度是相信 agent 的泛化能力。 +- **OpenDeepWiki** 更极简:LLM 加 ReadFile/ListFiles/Grep 三件套,没了。 +- **deepwiki-rs** 表面上有 12 个"语言处理器",读进去发现是正则和关键词匹配,复杂度统计用 `content.matches("fn ")` 这种字符串计数(`mod.rs:112-143`)。介于"有分析"和"没分析"之间,但更靠近没分析那头。 +- **deepwiki-open** 用向量检索替代代码理解:文件按 350 词一块、100 词重叠切开,扔进 FAISS。 + +而 CodeWiki-Plus 的底座是 Python `ast` 加 tree-sitter 的静态分析管线:组件清单、函数签名、调用图、服务边界检测、跨服务路由匹配,全部来自真实的语法分析。这个差异的后果很直接:**四家竞品的代码事实正确性完全押注在 LLM 身上,代码一重构,文档里的行号引用就漂移;CodeWiki-Plus 的分析图谱本身就是重定位的预言机**——组件搬家了,图谱重分析会给出新位置。 + +有意思的是,没有一家复刻选择做静态分析。原因不难猜:LLM 读文件这条路,两周就能出 Demo,star 涨得飞快;AST 管线要适配十种语言,半年都不一定讨好。但两条路的长期差距,会随着文档被使用的时间慢慢拉开。 + +--- + +## 三、分水岭之二:增量更新,只有一家及格 + +"代码变了,文档怎么办",这是所有 wiki 生成器的生死题。答案分布很有意思: + +**deepwiki-open:没有。** 这是 star 最高项目最刺眼的短板。它的 wiki 缓存键只有仓库名,不含 commit、不含分支(`pipeline.py:163-167`);已经克隆过的仓库直接复用旧克隆(`pipeline.py:366-371`)。上游仓库更新之后,它手里的索引和 wiki 全部悄悄过期,唯一的解决办法是手动删缓存重建。更糟的是用户根本不知道过期了。 + +**deepwiki-rs:也没有。** 每次运行直接删掉整个输出目录全量重写(`outlet/mod.rs:79-82`)。它的 prompt 缓存能省点 LLM 调用费,但流程本身是全量的。 + +**OpenDeepWiki:有,仓库级。** 定时检查远程 HEAD commit,有差异就建任务,changed files 从 git diff 拿,增量 prompt 里明确禁用整树重写的工具(`WikiGenerator.cs:704-707`)。粒度粗但方向对。 + +**openwiki:这一题的满分答卷,四层机制。** 第一层,git HEAD 没变就直接 no-op 返回;第二层,对全部文件内容做 sha256 源指纹,运行中源码漂移就不推进 checkpoint;第三层,页级 manifest 记录每页覆盖的文件指纹,没受影响的页直接 fast-forward;第四层,上一篇讲过的 Grounded Claims,每条事实带证据 URI 和内容哈希,update 前先跑 preflight 逐条比对,产出 stale 清单喂给 planner 决定哪些页真正要重写。 + +四层机制环环相扣,全是为了回答一个问题:**这次更新,到底哪些页需要动?** 相比之下,"每次全量重写"的做法在十页 wiki 上没感觉,在一百页 wiki 上就是灾难。 + +CodeWiki-Plus 在这一题上的位置,坦白说属于第二梯队上半段:模块页有 commit_id 锚点加 git diff 的受影响模块检测,共享池笔记有页面级 manifest(这两块部分机制正是 8 月底从 openwiki 借鉴落地的)。和 openwiki 的差距在于:我们的 `stale_evidence` 检查结果目前只进 lint 报告,还没有回灌到增量决策里。这个后面说。 + +--- + +## 四、分水岭之三:检索与问答的三个极端 + +这一维度的分布像个光谱,两端都是几万 star 的项目。 + +**deepwiki-open 站在"有 RAG"的一端**,而且是五家里唯一的向量检索:FAISS,top_k=20,嵌入模型默认 OpenAI text-embedding-3-small。但读细节就会发现这套检索工程上是五家里最弱的:纯单路召回,没有 rerank,没有混合检索,没有查询改写;分块按词数硬切,跨块语义割裂;每次聊天请求都从 pickle 反序列化整个 FAISS 库,大仓库热路径开销可观(`research.py:33-57`)。还有个挺萌的设计:用户输入超过 7500 token 就直接跳过 RAG 裸答。**它有检索,但检索本身不是它的护城河,网页问答的 Demo 体验才是。** + +**openwiki 站在"无检索"的另一端。** 全库 grep 不到任何 embedding 或向量代码。它的问答策略是给 agent 一句 prompt:"先去 /openwiki 目录里翻 wiki,用 grep 和 glob",纯靠 agent 拿文件系统工具翻。诚实说,这条路在 wiki 小的时候完全够用,而且省掉了嵌入模型的依赖;wiki 大了之后命中率和 token 成本都会退化。它甚至在 frontmatter 规范里给 description 字段注明"为检索工具优化",翻译过来就是:**检索这活儿,我留给别人做。** + +**OpenDeepWiki 居中但更糙**:没有向量,聊天时把整棵 wiki 目录塞进工具描述里,agent 按行区间去读文档;跨仓库搜索靠关键词打分(`McpGlobalTools.cs:456-464`),不是语义检索。 + +CodeWiki-Plus 走的是中间路线:BM25 关键词检索,排序里乘了文档权威度和热度,外加一个独有的反馈闭环——**被 Agent 检索后实际采纳的文档,采纳计数会反哺排序权重**。检索质量这个话题,五个项目谁都不敢说做完了,但"检索结果能不能反过来教育检索器"这个钩子,目前只有我们挂上了。 + +--- + +## 五、分水岭之四:三种 MCP 哲学 + +MCP 这维度上,五个项目倒是各自选了三种完全不同的哲学。 + +**CodeWiki-Plus:MCP 就是本体。** 47 个工具覆盖分析、文档、知识、质量、任务、工作区,整个产品以 stdio MCP 服务端的形态嵌进 IDE Agent 的工作流。这是"工具供 Agent 调用"的哲学。 + +**openwiki:MCP 是流水线的协议化。** 它的 MCP server 只暴露 6 个工具:begin、submit_plan、next_page、inspect_claims、submit_page、finish。仔细看,这不是能力接口,是把"逐页生成队列"这套流程本身开放给外部宿主——宿主 coding agent 用自己的模型和原生仓库工具做研究和写页,openwiki 管队列、管校验、管收尾。**它把"怎么保证质量"握在自己手里,把"干活"外包给宿主。** 这个分工思路值得细品。 + +**OpenDeepWiki:MCP 是商业化接口。** 全局和仓库级两个 HTTP 端点,7 个工具,配了 API Key、完整 OAuth 2.1 流程、Protected Resource Metadata、用量统计中间件(`Program.cs:388-392`)。这是把知识库当对外服务来运营的姿势,五家里唯一认真做了"计费基建"的。 + +deepwiki-open 和 deepwiki-rs:无 MCP。 + +--- + +## 六、分水岭之五:对 LLM 的依赖程度 + +前几条分水岭看的都是"做了什么",这一条看"靠什么做"。整个管线里确定性代码和 LLM 的配比,决定了两个东西:**成本曲线和正确率上限。** + +**OpenDeepWiki 是五家里最重的。** 数一下 LLM 出场的次数:目录生成是一次 agentic 调用(agent 拿着目录树和 README 用 `WriteCatalog` 写 JSON);每篇文档又是独立 agentic 调用,带着完整系统提示词和工具往返;多语言靠 LLM 逐篇翻译已有文档(`TranslateWikiAsync`),不是复用分析结果。整条管线没有一处确定性的代码理解托底,"目录模型"和"内容模型"分开配置已经是它对成本的全部思考。 + +**deepwiki-open 是三次出场加一个影子。** 结构 XML 一次、逐页生成一次、网页问答一次,外加一个常驻的嵌入模型——连"哪段代码和这个问题相关"都要模型说了算。确定性部分只有文件树展示和文本分块。它的省钱努力体现在防御性解析(被截断的输出抢救出来重用)而不是减少调用。 + +**deepwiki-rs 有个确定性骨架。** 目录扫描、文件重要性打分、正则语言处理都不花 token,这是它值得肯定的部分。但从逐目录 LLM 总结开始(`preprocess/mod.rs` 的 DirectoryDossier),7 个 research agent、6 个 compose editor 就全是 LLM 了,骨架之上全是模型。 + +**openwiki 全是 agent,但有分寸感。** planner 和 worker 都是完整 agent,分析零静态底座,按说是最重的一家——但它在收尾处把机械活从 LLM 手里拿了回来:finalizer 纯代码、index.md 禁止模型手写。它对 LLM 的态度是"干活可以,签字不行"。 + +**CodeWiki-Plus 的配比最克制。** 分析阶段零 LLM:组件、签名、调用图、行区间全部来自 AST 和 tree-sitter 的语法分析,这一段一个 token 都不花。生成阶段 LLM 只当翻译官——事实是分析器采出来的,模型只负责说清楚;上一篇讲过的置信度协议再补一道闸:没有代码证据的断言必须挂牌 `[candidate]`。**依赖度低的直接回报是:代码事实的正确性不随模型发挥波动。** + +--- + +## 七、分水岭之六:团队经验沉淀与任务记忆 + +这一条是横评里最一边倒的维度。问题是:**用这个工具一个月之后,团队比一个月之前多了什么?** + +**deepwiki-open:什么都不多。** 对话历史由前端每次全量携带,后端在内存里重建对话对象,服务重启即失(`research.py:107-116`)。连单人使用者的连续性都没有,更谈不上团队。wiki 缓存是一次性产物,没有回流机制。 + +**OpenDeepWiki:有组织,没沉淀。** 它的组织/部门/角色体系做得很全,但那是访问控制,回答的是"谁能看",不是"谁知道"。聊天记录进 ChatLog 是审计日志,不是可检索的知识。多人共用一个 wiki 站,各自的理解并不会因此累积。 + +**openwiki:治理单篇事实,不沉淀团队经验。** Claims 账本管的是"这篇文档里每条断言是否仍有证据",这是文档级事实治理;团队踩过的坑、做过的决策、推翻过的方案,没有对应的容器。它的 personal 模式有 9 类连接器(Slack、Gmail、HackerNews……)能摄取个人数据流,但那是个人知识库方向,与代码 wiki 主线正交。聊天会话有 SQLite checkpointer 持久化,但那是会话状态,关掉就是历史。 + +**deepwiki-rs:最接近的一个,但只做了一半。** 它的 `.ai-context/` 分层知识库(Tier0 项目本质 → Tier3 当前问题)按稳定性组织知识供 coding agent 消费,"AGENTS.md 讲怎么干活,.ai-context 讲项目是什么"这个分工是对的。但内容是一次性生成的,用完即弃,没有从使用中回流的通道——是个静态的说明书,不是飞轮。 + +**CodeWiki-Plus 在这条线上没有对手:对话捕获 → 蒸馏 → 草稿笔记 → 确认闸门 → 采纳计数反哺检索排序,外加 Doctrine 团队共识和跨会话的任务记忆(分片存储、多人隔离)。** 工具越用,库里的确认笔记越多,检索排序越准,团队的决策脉络越完整。这是"Wiki 生成器"和"知识系统"的分界线:前者生成完就结束了,后者从使用中变厚。 + +不过没有对手不等于做完了。沉淀的另一半是治理成本:确认闸门需要人过目,笔记多了需要合并退役,这些成本是真实存在的。四家竞品没做这件事,可能不是没想到,而是不想让用户背这个成本。CodeWiki-Plus 的判断是值得背——但这个判断本身,也需要用飞轮里的使用数据持续检验。 + +--- + +## 八、每家压箱底的工程亮点 + +横评不能光挑刺。四个项目各有几处设计,读的时候让我停下来记了笔记。 + +**openwiki 的确定性收尾。** 每次生成或更新结束,跑一段纯代码的收尾流水线:mermaid 校验、索引同步、内链校验、Claims 投影、provenance 盖章,一步 LLM 都不掺和;目录的 index.md 由代码生成,明令禁止模型手写(`wiki-finalizer.ts:248-285`)。这个分寸感很高级:**LLM 负责写内容,机械的活儿交给机械。** 顺便,它的页队列持久化在 `.run.json` 里,CI 上跑挂了,重跑就从断点继续。 + +**deepwiki-open 的防御性解析和并发闸门。** 它对 LLM 输出的解析健壮得有点夸张:XML 被截断就用手写状态机从残缺 JSON 里抢救出已完整的对象,零额外 LLM 调用(`structure.py:179-250`)——明显是被小本地模型折磨出来的。并发控制是三层信号量:RAG 索引 4 并发、任务池 CPU 核数一半、页级默认 1,各自独立可配。还有 SSE 心跳的细节:每 10 秒发一个注释帧,就为了躲 undici 默认 300 秒的 headers 超时,索引任务用 `asyncio.shield` 罩着,心跳超时也不会误杀(`repo.py:20-57`)。工程上全是脏活,但没有这些脏活 Demo 就会随机挂。 + +**OpenDeepWiki 的单体内分布式调度。** 这是我觉得它最值得抄的一处:DB 租约表加全局槽位加心跳加崩溃恢复,在单体应用里实现了"每仓库单写者、集群总并发上限"(`WikiGenerationConcurrencyService.cs:37-64`)。没有引入消息队列,成本控制得很好。它的断点续跑也实在:已落盘的文档路径直接跳过,单篇失败不中断批次。 + +**deepwiki-rs 的声明式流水线和双模型。** 它的 StepForwardAgent trait 是个漂亮抽象:每个 agent 声明自己需要哪些数据源(内存键、上游结果、外部知识)、prompt 模板、调用模式,框架自动完成数据校验、prompt 组装、结果写回,新 agent 只写配置不写流程(`step_forward_agent.rs:581-726`)。双模型设计也聪明:主力模型失败时,把错误信息注入 prompt 换备用模型重试,外层套指数退避(`llm/client/mod.rs:106-118`)。另外它的 prompt 哈希文件缓存带 token 用量统计和命中率监控,是省钱的实在功夫。 + +--- + +## 九、CodeWiki-Plus 学到了什么 + +调研的落点不是写文章,是改进自己。排除 8 月底已经从 openwiki 借鉴落地的五项(证据哈希、页面 manifest、mermaid 降级、no-op 防扰、评测框架),这次从四个项目里提炼出的清单,按优先级排三件: + +**第一件,LLM 调用缓存加 token 统计。** 学 deepwiki-rs 的 prompt 文件缓存,但要避开它的坑:缓存键必须校验模型名,否则换模型命中旧答案。而且实现前先核对了自己的代码:CodeWiki-Plus 的模块生成是 agentic 多轮调用(agent 带工具走完整轨迹),单次 prompt→response 的缓存根本套不上,能做的只有模块级指纹缓存——输入没变就跳过整个 agent run。教训:借鉴别家机制前,先确认人家的机制对应自己哪段代码,调研报告说的痛点可能自家早就解决了一半。 + +**第二件,stale_evidence 回灌增量决策。** 这是对已落地机制的"最后一公里"。openwiki 的做法是 Claims preflight 的 stale 清单直接喂给 planner,成为"哪些页要重写"的决策输入。这一件在本文写作当天已经落地:漂移信号成为增量决策的第三信号源,与受影响模块、过期页面并列;代码没变的短路路径保持静默,是有意的不对称——代码没动,工具就不该多嘴。 + +**第三件,确定性收尾管线。** 学 openwiki 的 finalizer:生成流程末尾加一段纯代码收尾,把索引同步和内链校验从"检查"升级成"检查加修复"。核对代码后范围收窄:真实缺口只剩 generate_docs 这条链路跑完没有任何索引收尾,两处既有调用点已经在了。半天能补完的活。 + +还有一条产品层面的观察,不是技术决策:deepwiki-open 用五家里最粗糙的工程拿了最高的 star,17.9k。它证明了**网页 Demo 的传播力可以碾压工程质量**。CodeWiki-Plus 目前只有 IDE 侧的消费入口,如果哪天需要传播性,把现有 Vue 前端包装成只读可视化站点,比补齐任何技术短板都划算。这是留给未来的选择题。 + +--- + +## 结尾:自己的路线,扒完别人家的源码才敢确认 + +把五个项目读完整理完,最大的收获不是那三件待办,而是一次定位确认: + +**精确代码分析这条路,五家里只有 CodeWiki-Plus 在走。** 四个竞品全部押注 LLM 直读,说明这条路的短期成本优势确实明显;但也说明,AST 调用图这个我们从第一天起就当作理所当然的底座,在同类项目里居然是稀缺资产。 + +**知识治理这条线,五家里只有 CodeWiki-Plus 和 openwiki 在认真做。** openwiki 用证据哈希把"文档过时"变成可机械验证的状态机;CodeWiki-Plus 在此之外还多压了一层确认闸门和采纳反馈,让知识质量变成有人负责的资产。而剩下三家,连增量更新都没有,谈治理为时尚早。 + +star 会涨会跌,README 会更新会过时,源码不会说谎。这次调研把四个项目的源码摊开之后,CodeWiki-Plus 该坚持什么、该补什么、该忽略什么,答案都比之前清晰了一层。 + +--- + +*(本文基于 docs/DeepWiki类开源项目对比调研报告-2026-09.md 及四份源码调研笔记(.research-competitors/notes/)撰写,全部代码事实经二次核查与源码一致;star 数据取自 2026-09-06 GitHub API。系列前文见 docs/articles/。)* diff --git "a/docs/DeepWiki\347\261\273\345\274\200\346\272\220\351\241\271\347\233\256\345\257\271\346\257\224\350\260\203\347\240\224\346\212\245\345\221\212-2026-09.md" "b/docs/compare/DeepWiki\347\261\273\345\274\200\346\272\220\351\241\271\347\233\256\345\257\271\346\257\224\350\260\203\347\240\224\346\212\245\345\221\212-2026-09.md" similarity index 100% rename from "docs/DeepWiki\347\261\273\345\274\200\346\272\220\351\241\271\347\233\256\345\257\271\346\257\224\350\260\203\347\240\224\346\212\245\345\221\212-2026-09.md" rename to "docs/compare/DeepWiki\347\261\273\345\274\200\346\272\220\351\241\271\347\233\256\345\257\271\346\257\224\350\260\203\347\240\224\346\212\245\345\221\212-2026-09.md" diff --git a/docs/compare/OpenDeepWiki-notes.md b/docs/compare/OpenDeepWiki-notes.md new file mode 100644 index 0000000..b4c8bd3 --- /dev/null +++ b/docs/compare/OpenDeepWiki-notes.md @@ -0,0 +1,63 @@ +# OpenDeepWiki 调研笔记(AIDotNet/OpenDeepWiki) + +调研对象:本地克隆 `D:\repos\CodeWiki-CN\.research-competitors\OpenDeepWiki`。所有论断均核对源码,标注 `文件:行号`(相对于 `src/` 根,均实际读取验证)。README 用于交叉参考,未作为事实来源。 + +## 1. 整体架构与定位 + +定位为**多仓库托管的 SaaS 式代码知识库平台**(自部署版 DeepWiki):后台导入 Git/ZIP/本地目录 → AI 生成 wiki → Next.js 公共站点 `/{owner}/{repo}` 阅读并提供聊天、嵌入对话、MCP 服务。 + +- 后端:ASP.NET Core(.NET 10)单体应用,`src/OpenDeepWiki/Program.cs` 为唯一入口;自研轻量 API 框架 MiniApis(`Program.cs:73` `AddMiniApis`)+ Endpoints 模式(`src/OpenDeepWiki/Endpoints/`,含 Admin/Auth/Organization/Embed 等 17 组)。 +- 数据库:EF Core,SQLite / PostgreSQL 双提供程序(`src/EFCore/OpenDeepWiki.Sqlite`、`src/EFCore/OpenDeepWiki.Postgresql`),`Program.cs:76` `AddDatabase` 按配置切换。 +- 前端:Next.js 16 + React 19 App Router(`web/package.json:44,48`),含 admin 后台、i18n、分享页。 +- 多仓库模型:`Repository`(OrgName/RepoName)→ `RepositoryBranch`(每分支独立 wiki)→ `BranchLanguage`(每语言一套目录+文档),实体在 `src/OpenDeepWiki.Entities/Repositories/`。组织/部门/角色做权限隔离(`Services/Organizations/OrganizationService.cs:9`、`Program.cs:234` AdminDepartmentService),属"企业多租户"雏形而非硬隔离租户。 +- 认证:JWT + 角色(`Program.cs:99-118`,AdminOnly 策略:117)、Google OAuth、API Key、MCP 专用 OAuth(`Program.cs:130-136`)。 +- 额外有 IM 接入层:飞书/QQ/微信/Slack webhook(`src/OpenDeepWiki/Chat/Providers/`)。 + +## 2. 代码分析阶段 + +- 克隆/更新用 **LibGit2Sharp**(`Services/Repositories/RepositoryAnalyzer.cs:4` 引用,`:200-212` clone/pull 分支),工作区固定布局 `{data}/{org}/{repo}/branches/{branch}/tree`(`:352-360`)。ZIP 导入解压(`PrepareArchiveWorkspaceAsync` `:362-381`),本地目录导入有白名单根(`Program.cs:499-501` LOCAL_IMPORT_ROOT)。 +- **无语言特定解析器**(无 tree-sitter/AST/LSP)。对代码的"理解"完全下放给 LLM agent + 通用工具:`Agents/Tools/GitTool.cs:770-781` 只提供 ReadFile/ListFiles/Grep 三个函数。所谓"多语言代码分析"实为 (a) LLM 泛化能力 + (b) **输出文档多语言**(BranchLanguage 每语言独立生成/翻译)。 +- 目录树采集有自适应预算:`RepositoryScanPlan.cs:5-19`(`ResolvedRepositoryScanPlan` 按仓库规模动态决定树深度/节点上限)。 +- 任务队列:**DB 轮询的 BackgroundService**,非消息队列。`RepositoryProcessingWorker.cs:18-51` 每 30s 扫 Pending/Processing 状态的 Repository(`:85-94`),同类 worker 还有 Branch/Translation/MindMap/Graphify(`Program.cs:254-258`)。 + +## 3. 文档生成流程 + +Pipeline(`WikiGenerator.cs`,2884 行核心类):**PrepareWorkspace → GenerateCatalogAsync → GenerateDocumentsAsync(并行)→ TranslateWikiAsync → GenerateMindMapAsync → SkillMarkdown**。 + +- 目录生成:`GenerateCatalogAsync`(`WikiGenerator.cs:245`)预采集目录树(TOON)/README/入口点拼进 user message(`:263,293-319`),agent 用 `CatalogTool.WriteCatalog` 写出 JSON 目录(title/path/order/children)。源码探索工具有**调用预算**(`DocumentSourceToolBudget`,`WikiGenerator.cs:276`、`Agents/Tools/DocumentSourceToolBudget.cs:9-16`,超限返回 BUDGET_REACHED 迫使 agent 立即产出)。 +- 单篇文档:`GenerateDocumentsAsync`(`:366`)只给 leaf 节点生成正文;`Parallel.ForEachAsync` 控并发(`:561-568`),每篇独立硬超时(`:466-468`);单篇失败不中断(部分失败容忍 `:605-608`);已落盘的 path 跳过实现断点续跑(`:414-420`)。首篇先串行"预热 prompt cache"再开并行(`:545-559`)。 +- Prompt 策略:提示词是外置资产 `src/OpenDeepWiki/prompts/{catalog,content,mindmap}-generator.md + incremental-updater.md`,由 `FilePromptPlugin` 加载(`Program.cs:199-211`),系统 prompt 跨仓库固定、运行时上下文进 user message(catalog-generator.md `<context>` 段)。content prompt 硬编码极详细的反幻觉约束(代码块必须带源文件 blockquote 链接)和 Mermaid 语法规则(`WikiGenerator.cs:818-899` + content-generator.md constraints 段)。 +- 多语言:`TranslateWikiAsync`(`WikiGenerator.cs:1757`)创建目标 BranchLanguage 后**翻译已有目录与文档**(LLM 翻译,非从源码重新生成);`TranslationWorker.cs:17` 定时扫描 Completed 仓库按配置语言自动建翻译任务。 +- 增量更新:`IncrementalUpdateWorker.cs:13` 轮询手动任务 + 定时扫描(`CheckScheduledUpdatesAsync` 约 `:318-350`,按仓库级 `UpdateIntervalMinutes` 到期检查远程 HEAD commit,差异则建任务);changed files 由 `RepositoryAnalyzer.GetChangedFilesAsync`(`:297`)从 git diff 得出;增量 prompt 明确禁用 WriteCatalog 防止整树重写(`WikiGenerator.cs:704-707`),agent 用 EditDoc/WriteDoc 精准修改。 + +## 4. 检索/问答能力 + +- **没有向量 RAG**(全库无 embedding/向量代码)。网页聊天 `ChatAssistantService.StreamChatAsync`(`Services/Chat/ChatAssistantService.cs:186,440+`)是 agentic 方案:给 LLM 挂三类工具——GitTool(直接 checkout 到对应分支读源码,`:477-480`)、ChatDocReaderTool(**把整棵 wiki 目录塞进工具描述**,agent 按行区间读文档,`:496-503`)、管理员配置的外部 MCP/Skill 工具(`:506-519`)。SSE 流式输出含 tool_call/tool_result 事件(`:141-149`)。 +- 对话历史由前端每次全量携带(`ChatRequest.Messages` `:130-136`),服务端不维护会话状态;仅 `ChatLogService` 记审计日志。 +- IM 消息链路有真正的异步基础设施:DB 消息队列 + 死信处理(`Chat/Queue/DatabaseMessageQueue.cs`、`Chat/Processing/ChatMessageProcessingWorker.cs`、`DeadLetterProcessor.cs`)。 + +## 5. MCP / Agent 集成 + +- 用官方 Model Context Protocol C# SDK 内嵌 MCP server,HTTP transport,两级端点:全局 `/api/mcp` 与仓库级 `/api/mcp/{owner}/{repo}`(`Program.cs:313-353` 注册、`:385-386` 映射,scope 从 URL 解析注入 `:316-347`)。 +- 全局工具 4 个:ListRepositories / RouteQuestion(跨仓库路由)/ SearchDocs / ReadDoc(`MCP/McpGlobalTools.cs:26,127,163,262`);仓库级 3 个:搜索文档 / 目录结构 / 读源文件(`MCP/McpRepositoryTools.cs:25,164,216`)。路由与检索是 **token 关键词评分**(`McpGlobalTools.cs:146,456-464` ScoreRepository/ScoreDocument),非语义检索。 +- MCP 认证支持 API Key(`MCP/ApiKeyAuthenticationHandler.cs:12`)与完整 OAuth 2.1(`McpOAuthServer.cs` + `Program.cs:388-392` Protected Resource Metadata),且有用量统计中间件与聚合服务(`Program.cs:383,307`)。 +- **没有 AGENTS.md 自动生成**(全源码无该字样)。最接近的是 Skill 包:`RepositorySkillMarkdownBuilder.cs:16-100` 生成 SKILL.md(frontmatter+文档索引)并把全部文档打包 zip 供 Claude Code 等技能系统下载。 + +## 6. 值得注意的工程设计 + +- **集群级生成并发控制**:`WikiGenerationCoordinator`(`WikiGenerationConcurrencyService.cs:37-64`)用 DB 租约表(RepositoryGenerationLock)+ 全局槽位(WikiGenerationSlot)+ 心跳(`WikiGenerationHeartbeat`,`RepositoryProcessingWorker.cs:170-175`)+ 崩溃恢复 `RecoverStaleWorkAsync`(`:82`),实现多实例部署下"每仓库单写者、集群总并发上限"。这是单体应用内做的分布式调度,成本低于引入 MQ。 +- 5 个独立后台 Worker 职责分离(生成/分支/翻译/思维导图/Graphify 图谱产物,`Program.cs:254-258`),全量重生成有清理器(RepositoryFullRegenerationCleaner)。 +- AI 配置完全管理后台化:Provider 预设目录 + DB 系统设置覆盖启动配置(`Program.cs:418-421`),模型可按"目录模型/内容模型"分角色绑定(`WikiGenerator.cs` ResolveCatalogModelAsync/ResolveContentModelAsync)。 +- 缓存有独立抽象框架(`framework/OpenDeepWiki.Cache.*`,默认内存实现,`Program.cs:218`)。 +- 部署:Docker Compose(compose.yaml,SQLite 默认/PG 可选)、Makefile、Sealos 脚本;.env 多路径加载(`Program.cs:439-490`)。 + +## 7. 明显短板/局限 + +1. **检索无语义层**:无 embedding/向量库,聊天靠"目录注入 + agent 翻文档",跨仓库搜索靠关键词打分,大 wiki 下召回质量受限。 +2. **任务队列是 30s DB 轮询**:时效性与吞吐有限,Worker 与 Web 同进程,扩容粒度粗。 +3. **token 成本高**:每篇文档独立 agentic 调用(带完整系统 prompt + 工具往返),多语言靠逐篇翻译而非复用分析结果。 +4. **无代码级精确解析**:无 AST,代码事实正确性完全依赖 LLM,重构后的行号引用易漂移。 +5. 安全默认值偏弱:JWT 密钥有硬编码 fallback(`Program.cs:96`)、CORS 全放开 AllowAll(`Program.cs:146-153`)。 +6. 会话不服务端持久化(前端带全量历史),长对话成本递增。 + +**对 CodeWiki 对比报告的要点**:OpenDeepWiki 的差异化在于"托管平台化"(多仓库/多分支/多语言/组织权限/Admin 后台/MCP 商业化接口)与集群租约式并发调度;弱项是无 RAG、无精确代码分析、成本控制粗放。 diff --git a/docs/compare/deepwiki-open-notes.md b/docs/compare/deepwiki-open-notes.md new file mode 100644 index 0000000..8fbdb54 --- /dev/null +++ b/docs/compare/deepwiki-open-notes.md @@ -0,0 +1,80 @@ +# deepwiki-open(AsyncFuncAI/deepwiki-open)调研笔记 + +> 调研对象:本地克隆 `D:\repos\CodeWiki-CN\.research-competitors\deepwiki-open`,commit 时点代码为准。 +> 定位:Devin DeepWiki 的开源复刻(README 自称 "my own implementation attempt of DeepWiki"),输入 GitHub/GitLab/Bitbucket 仓库 URL,自动生成 wiki + 代码问答。 + +## 1. 整体架构与定位 + +**双进程 Web 服务**:Next.js 15 前端(`src/`,React 18 + tailwind,next-intl 多语言)+ **Python FastAPI 后端**(`api/main.py:50-72`,uvicorn 端口 8001)。注意:这不是纯 TypeScript 项目——核心逻辑(RAG、wiki 生成、聊天流)全在 Python 侧,`api/` 有独立的 `pyproject.toml`/`poetry.lock`。 + +- 前端 `src/app/api/*/route.ts` 只是**反向代理**,把请求转发到 `SERVER_BASE_URL`(默认 `http://localhost:8001`),如 `src/app/api/chat/stream/route.ts:5,18`(逐字节 pipe SSE 流)。 +- 单容器部署:Dockerfile 在一个镜像里同时跑 FastAPI 和 Next.js standalone(`Dockerfile:99-103` start.sh 后台起两个进程);docker-compose 挂载 `~/.adalflow` 持久化仓库克隆和向量库(`docker-compose.yml:17-18`)。 +- 后端路由:system/auth/repo/wiki/chat/codemap 六组(`api/main.py:64-72`)。聊天走 WebSocket `/ws/chat`(`api/routers/chat.py:20-66`),HTTP SSE `/chat/completions/stream` 为回退(chat.py:69-116);codemap 走 `/ws/codemap` NDJSON(`api/routers/codemap.py:14-49`)。 +- 核心依赖 **AdalFlow**(`api/rag/rag.py:7`,adalflow 的 Embedder/LocalDB/FAISSRetriever/TextSplitter 全家桶)。 +- 访问控制极简:可选的静态授权码 `DEEPWIKI_AUTH_MODE`/`DEEPWIKI_AUTH_CODE`(`api/config.py:60-62`,`api/routers/auth.py:17-22`),无用户体系、无数据库。 + +## 2. 代码分析阶段 + +- **抓取方式是本地 git clone,不是 GitHub API**:`Repo.download()` 用 GitPython 浅克隆 `--depth=1 --single-branch`(`api/repository.py:191-216`),支持 github/gitlab/bitbucket 三种 token 认证格式(repository.py:38-118),也支持本地路径仓库(`is_local`,repository.py:170-171)。 +- **不使用 GitHub Embeddings API**(全代码 grep 无 `api.github`/embeddings API 调用)。嵌入自建:读文件 → TextSplitter 分块 → ToEmbeddings 批量嵌入 → adalflow LocalDB pickle 持久化(`api/rag/pipeline.py:248-275`)。 +- 文件筛选:配置化 code/doc 扩展名白名单 + excluded/included dirs/files,RAG 索引与文件树展示共用同一实现 `iterate_files`(`api/config.py:467-516`)。超大文件跳过(token > 8192×10,`api/rag/pipeline.py:21,126-129`)。 +- 无 AST/依赖图等代码理解——纯文本文件树 + README + 向量检索(见 §4)。meta_data 记录 `is_code`/`is_implementation`(pipeline.py:144-154)但只用于展示,检索时不区分。 + +## 3. 文档生成流程 + +异步任务状态机(后端原版是从前端 page.tsx 移植到 Python,见 `api/services/wiki/tasks.py:316` 注释): + +1. **提交任务** `POST /wiki/tasks`(`api/routers/wiki.py:218-228`):get-or-create 语义——活动任务去重 join、已有缓存直接 `from_cache`(`api/services/wiki/tasks.py:161-190`)。 +2. **INDEXING**:仅当 `{repo_name}.pkl` 不存在才建索引(tasks.py:219-222)。 +3. **DETERMINING_STRUCTURE**:读本地克隆的文件树+README(`api/services/wiki/structure.py:20-48`),用 LLM 生成 XML 格式的 wiki 结构(sections + pages,每页含 title/importance/relevant_files/related_pages),`build_structure_prompt`(`api/services/wiki/prompts.py:213-260`)要求 4-6 页(concise)或 8-12 页(comprehensive)。XML 解析非常防御性:剥 markdown fence、修 bare `&`、截断抢救、正则 fallback(structure.py:179-250)。 +4. **GENERATING**:逐页生成,prompt 为 `build_page_prompt`(prompts.py:25-132)——强制首行 `<details>` 溯源文件块、大量 Mermaid 图、表格、`Sources: [path:line]()` 空括号引用格式(≥5 个文件)。每页生成**复用 RAG 聊天管线**(`_generate_page` 构造 ChatCompletionRequest 走 `research_chat`,tasks.py:369-404),即页面内容由向量检索到的代码块驱动。有界并发 + 每页 2 次重试 + 失败占位页不炸整个任务(tasks.py:268-312)。 +5. **缓存**:完成后整包写入 `~/.adalflow/wikicache/deepwiki_cache_{type}_{owner}_{repo}_{lang}.json`(`api/services/wiki/io.py:21-29,52-72`),**缓存键无 commit hash、无过期**。支持导出 Markdown/JSON(`POST /export/wiki`,io.py:146-235)。 +6. 进度推送:SSE `/wiki/tasks/{id}/stream`,1 秒轮询 registry 输出 progress/done/error 事件(`api/routers/wiki.py:271-302`)。 +7. **后处理**:`post_process_wiki_content` 把模型输出的空括号引用 `[path:line]()` 解析成 GitHub/GitLab/Bitbucket 真实带行锚链接(`api/services/wiki/content.py:84-151`)。 + +## 4. 检索/问答能力 + +- **RAG 链路**:adalflow `FAISSRetriever`,top_k=20(`api/config/embedder.json:33-35`),**无 rerank、无混合检索、无 query 改写**。检索结果按文件分组、块标注 `[lines A-B]` 行号注入上下文(`api/services/research.py:157-192`)。 +- **嵌入模型**:默认 OpenAI `text-embedding-3-small`(dimensions=256,embedder.json:2-10),可换 Ollama `nomic-embed-text`、Google `gemini-embedding-001`、Bedrock Titan v2(embedder.json:11-32);类型由 `DEEPWIKI_EMBEDDER_TYPE` 环境变量选择(config.py:65)。分块:word 级 chunk_size=350 / overlap=100(embedder.json:36-40);`LineTrackingTextSplitter` 为每个 chunk 回写起止行号(pipeline.py:174-208)。 +- **向量库**:FAISS + adalflow LocalDB,pickle 文件落盘(`api/rag/rag.py:290-296`,pipeline.py:163-171)。嵌入维度不一致时按多数派过滤(rag.py:183-236)。 +- **流式问答**:WebSocket 优先,断线/不可用回退 HTTP SSE(前端 `src/utils/websocketClient.ts` + `src/app/api/chat/stream/route.ts:16`)。 +- **Follow-up**:无服务端会话——前端每次带全量 messages,后端把历史 user/assistant 对灌进内存 `Memory`(`api/services/research.py:107-116`),以 `<turn>` 文本拼接进 prompt(research.py:252-259,`api/chat/_prompts.py:13-31`)。对话状态每次请求重建,重启即失。 +- **Deep Research 模式**:`mode=deep_research` 时前端自动循环发 "Continue the research"(`src/components/Ask.tsx:392-441`),后端按 `research_iteration` 换三套 prompt(首迭代出研究计划/中间迭代出增量发现/第 5 轮出最终结论,`api/prompts.py:60-151` + `api/services/research.py:214-242`);迭代由**前端驱动**,后端无状态。 +- **Codemap**:两段式 LLM 调用生成"带引用的分步指南"(skeleton JSON → enrich 补 prose+mermaid),JSON 解析带 3 次重试 + 修复(`api/services/codemap.py:39-126`,prompts 见 `api/prompts.py:195-265`)。 +- 输入超 7500 token 时跳过 RAG 直接裸答(research.py:23,63-73,147);超限报错时自动降级为无上下文 prompt 重试(research.py:261-288)。 + +## 5. 多语言 / 多提供商 + +- **LLM 提供商**:自研客户端注册表而非引入 LiteLLM SDK——`api/clients/` 下 10 个客户端(google/openai/openrouter/ollama/bedrock/azure/dashscope/anthropic-bedrock 等,`api/config.py:71-95`),`ChatStreamer` 按 provider 名注册子类(`api/chat/_stream.py:29-48`)。**LiteLLM 是其中一个可选 provider**:`LiteLLMClient` 继承 adalflow OpenAIClient 指向 LiteLLM proxy 的 OpenAI 兼容端点(`api/clients/litellm.py:8-49`,配套 docker-compose-litellm.yml/litellm-config.yml)。默认 provider 为 google gemini-2.5-flash(`api/config/generator.json:2-3`)。 +- **多语言**:UI 层 next-intl 10 种语言(`src/messages/*.json`);wiki 内容靠 **prompt 内嵌语言指令**生成(`api/services/wiki/prompts.py:126` "Generate the content in {language}"),**每种语言独立缓存文件独立生成**(io.py:26-29),即中文 wiki = 重新跑一遍完整 pipeline,无翻译管线。问答侧靠"跟随用户 query 语言"的 prompt 指令(`api/prompts.py:8-11`)。 + +## 6. 值得注意的工程设计 + +- **并发控制三层信号量**:RAG 索引准备 `DEEPWIKI_MAX_CONCURRENT_RAG=4`(`api/rag/rag.py:27-34`,`asyncio.to_thread` 包同步阻塞操作,rag.py:344-354);wiki 任务池 `DEEPWIKI_MAX_CONCURRENT_WIKI_TASKS`(默认 CPU/2,tasks.py:62-64);单任务页级并发 `DEEPWIKI_WIKI_PAGE_CONCURRENCY`(默认 1,tasks.py:66)。 +- **SSE 心跳防代理超时**:`/repo/prepare` 每 10s 发心跳注释帧,注释明说为了躲 undici 300s headers timeout;先 flush 首字节再跑慢索引、`asyncio.shield` 保证心跳超时不误杀索引任务(`api/routers/repo.py:20-57`);配套前端预热器把冷索引从首个聊天请求剥离(`src/utils/prepareRepo.ts:1-9`)。 +- **任务生命周期**:终端态任务 TTL 300s 自动出注册表(tasks.py:70,200-206);get-or-create 幂等提交(tasks.py:161-190)。 +- **健壮的 LLM 输出解析**:XML 截断抢救 + 双正则 fallback(structure.py:179-250)、JSON 平衡括号提取 + 修复 + 重试(codemap.py:47-126)、流式 token 超限降级重试(research.py:261-288)——明显是为小本地模型(qwen3:1.7b 是 ollama 默认,generator.json:117)调校的。 +- 测试 21 个 pytest 文件(tests/backend 分 routers/services/schemas/rag),在同类项目中算少而精。 + +## 7. 明显短板 / 局限 + +1. **无增量更新**:缓存键只有 repo 名不含 commit/branch(`api/rag/pipeline.py:163-167`,io.py:26-29);已克隆仓库直接复用旧克隆(pipeline.py:366-371),上游更新后 wiki/索引全部 stale,只能手动删缓存重建。 +2. **纯 Web 形态,无 MCP/API 优先的消费方式**:全代码无 MCP(grep 零命中);产出只进私有 JSON 缓存 + 前端渲染 + 手动导出 md/json,不回写仓库、无 PR/CI 集成。 +3. **无持久化用户/会话层**:对话记忆在请求内存对象里(research.py:107-116),无多用户隔离;"auth" 只是单个全局授权码(auth.py:17-22)。 +4. **检索质量上限低**:纯 FAISS 向量单路召回 top_k=20,无 rerank/混合检索/结构感知分块(embedder.json:33-40);代码块按词数切分,跨块语义割裂。 +5. **RAG 对象每次请求重建**:`research_chat` 每次聊天请求都重新 `RAG()` + `aprepare_retriever`(research.py:33-57)——从 pickle 反序列化整个 FAISS 库,大仓库热路径开销可观,无进程内 LRU。 +6. **每语言全量重生成**(§5)、**嵌入维度写死过滤逻辑**(rag.py:183-236 是补丁式容错而非根因修复)、单机单实例架构(TaskRegistry 是模块级内存对象 tasks.py:209,无法水平扩展)。 + +## 与对比报告相关的关键事实速查 + +| 维度 | deepwiki-open | +|---|---| +| 形态 | Next.js 前端 + FastAPI 后端,单 Docker 容器,无 MCP | +| 仓库获取 | git 浅克隆(GitPython),非 API | +| 嵌入 | OpenAI text-embedding-3-small(256d) / Ollama / Google / Bedrock 可切换 | +| 向量库 | FAISS + LocalDB pickle | +| 生成管线 | LLM 出 XML 结构 → 逐页 RAG 生成 → 引用后处理 → JSON 缓存 | +| rerank | 无 | +| 增量更新 | 无 | +| LLM 抽象 | 自研 10 客户端注册表,LiteLLM 为可选 provider | +| 多语言 | prompt 指令 + 每语言独立缓存,无翻译管线 | diff --git a/docs/compare/deepwiki-rs-notes.md b/docs/compare/deepwiki-rs-notes.md new file mode 100644 index 0000000..3dbad3a --- /dev/null +++ b/docs/compare/deepwiki-rs-notes.md @@ -0,0 +1,58 @@ +# deepwiki-rs(Litho)竞品调研笔记 + +> 仓库:sopaco/deepwiki-rs(本地克隆于 `.research-competitors/deepwiki-rs`),Rust,v1.5.1(`Cargo.toml:3`),MIT。所有论断均基于源码核对。 + +## 1. 整体架构与定位 + +- **形态:纯 CLI 工具,非 MCP、非服务**。入口 `src/main.rs:16-27`:`clap` 解析参数 → `generator::workflow::launch(config)`,唯一子命令是 `sync-knowledge`(`src/main.rs:31-37`)。自称 "AI-powered documentation generation engine",产出 **C4 模型**架构文档(Context/Container/Component)。 +- **"Litho" 是 deepwiki-rs 的产品名重命名**(README.md:5)。"litho-engine" 指其内置四阶段文档生成引擎(即 `workflow.rs` 的 launch 流水线),配置文件为 `litho.toml`,内部工作目录 `.litho`(`src/cli.rs:174`)。 +- **重要信号:项目已宣布演进为 Terrain**(README.md:31-40),Litho 定位收缩为 "fast, focused C4 doc generator",开发重心已转移。 + +## 2. 代码分析阶段(Preprocess) + +- 驱动入口 `src/generator/preprocess/mod.rs:45-127`:① 提取原始文档(README 等)→ ② `StructureExtractor` 扫描目录结构(`structure_extractor.rs:45-100`,walkdir 递归 + git tracked-only 过滤 + 文件重要性打分 + LLM 目录打分加成)→ ③ **逐目录 LLM 总结生成 DirectoryDossier**(`mod.rs:135-212`,>256KB 按字典序分批,`mod.rs:133`)→ ④ 依赖关系分析。 +- **解析层是正则/关键词匹配,非 AST**:12 个语言处理器(rust/js/ts/php/react/vue/svelte/kotlin/python/java/csharp/swift,`extractors/language_processors/mod.rs:44-57`),复杂度计算用 `content.matches("fn ")` 等字符串计数(`mod.rs:112-143`)。**没有 Go 处理器**(README.md:102 宣称支持 Go,代码不符)。 +- **Agentic 阶段划分**:四阶段 = Preprocess → Research → Compose → Output(`workflow.rs:83-133`)。Research 按 C4 层级组织 7 个 agent:C1 SystemContextResearcher → C2 DomainModulesDetector/ArchitectureResearcher/WorkflowResearcher → C3-C4 KeyModulesInsight → BoundaryAnalyzer → DatabaseOverviewAnalyzer(条件触发)(`research/orchestrator.rs:22-54`)。 +- **工作流引擎核心是 `StepForwardAgent` trait**(`step_forward_agent.rs:581-726`):每个 agent 声明式配置 `AgentDataConfig`(required/optional `DataSource`:Memory 键、上游 agent 结果、外部知识类别)+ `PromptTemplate` + `LLMCallMode`(Extract 结构化抽取 / Prompt / PromptWithTools),默认 `execute()` 自动完成数据可用性校验(`step_forward_agent.rs:624-641`)、标准 prompt 组装、LLM 调用、结果写回 Memory。新 agent 只需实现配置钩子,是典型的声明式流水线框架。 + +## 3. 文档生成流程 + +- Compose 阶段 6 个 editor 顺序执行(Overview/Architecture/Workflow/KeyModulesInsight/Boundary/Database,`compose/mod.rs:24-52`),产出写入固定 DocTree(`outlet/mod.rs:25-47`),`DiskOutlet` **删除整个输出目录后全量重写**(`outlet/mod.rs:79-82`),最后 `SummaryOutlet` 生成详/简双版执行报告(`summary_outlet.rs:21-23`)。 +- **Prompt 策略**(`step_forward_agent.rs:416-577`):标准 user prompt = 开场指令 + 时间占位符 + 数据源格式化输出(项目结构树/代码洞察 top-25/依赖关系/README 截断 16KB)+ 收尾强调指令;目标语言指令追加到 system/user 双端(`step_forward_agent.rs:664-665`),支持 8 种语言(`cli.rs:86`)。超过 64K token 触发 LLM 智能压缩至 50%,保留函数签名/类型定义等 pattern,压缩失败降级为应急截断(`utils/prompt_compressor.rs:44-58`、`step_forward_agent.rs:314-349`),压缩结果本身有缓存(`cache/mod.rs:183-197`)。 +- **并发策略(Rust 优势实际用得很节制)**:`do_parallel_with_limit`(`utils/threads.rs:6-27`,tokio Semaphore 限流 join_all)**仅用于两处**——KeyModulesInsight 按领域模块并行分析(`research/agents/key_modules_insight.rs:110-126`)和 Deep Dive 文档并行生成(`compose/agents/key_modules_insight_editor.rs:49-50`)。research/compose 各 agent 之间、目录总结(`preprocess/mod.rs:146` for 循环)均为**串行**。README.md:417 序列图宣称 "Execute multiple research agents in parallel",与代码不符。整体是 IO 密集(LLM 调用),Rust 并发优势主要体现在 tokio 异步与低开销 CLI 分发。 + +## 4. 检索/问答能力 + +**无问答、无 RAG、无任何检索接口**。`src/` 中没有 query/search/embedding 模块;产物是一次性生成的静态 markdown。README.md:141-149 提到的 AI 问答在**外部姊妹项目 Litho Book**(Rust+Axum markdown 阅读器)中,不在本仓库。 + +## 5. MCP / Agent 集成 + +- **无 MCP server**。面向 AI Agent 的输出走三条路: + 1. `.agents/skills/litho-documents-skill/`(SKILL.md:7-9):Litho 四阶段流水线的**纯 Agent 平行实现**——不依赖二进制,用 agent 工具调用复刻同一流程,含 `.litho-agent/` 中间产物持久化策略对抗上下文遗忘(SKILL.md:110-142),按项目规模分档扫描策略(SKILL.md:40-47)。已上 Smithery 分发(README.md:194)。 + 2. `.agents/skills/ai-context-generator/` + `.ai-context/`:生成**分层 AI 知识库**(Tier0 PROJECT-ESSENCE → Tier3 DYNAMICS,按稳定性分层,`.ai-context/SKILL.md:33-42`),供 coding agent 会话启动时消费,与 AGENTS.md 互补("AGENTS.md 讲怎么干活,.ai-context 讲项目是什么")。 + 3. 生成的 `docs/`(en/zh/zh_se 三语)本身面向人+Agent 阅读。 + +## 6. 值得注意的工程设计 + +- **双模型 + fallover**:efficient/powerful 双模型(`cli.rs:50-56`),主模型失败时把错误信息注入 prompt 换 fallback 模型重试(`llm/client/mod.rs:106-118`)+ 指数退避重试(`mod.rs:47-73`)。 +- **LLM 层基于 rig-core 0.35**(`Cargo.toml:12`),8 个 provider(openai/moonshot/deepseek/mistral/openrouter/anthropic/gemini/ollama,`config.rs:38-47`)。 +- **ReAct + summary reasoning 降级**:带工具 agent 达到最大迭代后,用无工具 agent 对 chat_history 做总结推理兜底(`llm/client/mod.rs:154-184`);工具并发默认 4、max_turns 100(`config.rs:187-193`)。 +- **文件级 prompt 缓存**:MD5(prompt) 为 key、按 category 目录存放 JSON、带 token 用量统计与过期时间(`cache/mod.rs:43-67,127-180`),并有缓存命中率性能监控(`cache/performance_monitor.rs`)——省 token 复跑利器。 +- **进程内 Memory**:`HashMap<"scope:key", serde_json::Value>` 带访问计数/大小统计(`memory/mod.rs:31-84`),作用域隔离(PREPROCESS/STUDIES_RESEARCH/DOCUMENTATION 等)。 +- **安全细节**:ReAct 的 file_explorer 工具做了路径逃逸防护(拒绝绝对路径与 `..` 穿越,`llm/tools/file_explorer.rs:48-71`);LLM 输出全部走宽松反序列化(`preprocess/agents/directory_summary.rs:105-152`)。 +- **外部知识集成**:PDF/MD/SQL/YAML 按语义分块(8000 字符/200 重叠,`integrations/local_docs.rs:65-88`),按 category 定向投递给指定 agent(`step_forward_agent.rs:540-555`)。 + +## 7. 明显短板/局限 + +1. **无增量**:每次运行全量重新生成并删除重写输出目录(`outlet/mod.rs:79-82`);缓存只省 LLM 调用,不省流程。 +2. **串行瓶颈**:阶段间与多数 agent 间串行,大仓库目录总结逐个 for 循环(`preprocess/mod.rs:146`),性能上限受 LLM 延迟串行叠加制约。 +3. **解析精度低**:无 tree-sitter/AST,正则计数定复杂度、依赖提取靠 import 语句匹配;无 Go/C/C++ 处理器。 +4. **无检索/问答/服务化**:纯一次性生成器,不能作为知识库被持续查询;无 MCP、HTTP API、watch 模式。 +5. **强外部依赖**:启动即校验 `mermaid-fixer` 二进制,未安装直接 bail(`workflow.rs:51-53`)——流程性依赖外部 crate 工具。 +6. **死旗标**:`--skip-preprocessing/--skip-research/--skip-documentation` 仅在 `cli.rs:36-44` 声明,全代码库无消费点,README.md:495 宣称可跳过阶段与实现不符。 +7. **缓存不校验模型**:`CacheEntry` 存了 `model_name` 但 `get()` 不比对(`cache/mod.rs:88-109`),换模型后可能命中旧模型答案。 +8. **项目生命周期**:主理人已转向 Terrain(README.md:31-40),本仓库大概率进入维护模式。 + +## 对比报告可用的一句话画像 + +Litho 是"声明式 StepForwardAgent 流水线 + 进程内 Memory + prompt 哈希文件缓存"的 Rust CLI 生成器,工程亮点在 prompt 组装/压缩、双模型 fallover 和 agent skill 生态(纯 Agent 平行实现 + 分层 .ai-context),但无检索问答、无增量、无 MCP,且核心维护已转向后继项目 Terrain。 diff --git a/docs/compare/openwiki-notes.md b/docs/compare/openwiki-notes.md new file mode 100644 index 0000000..538fb05 --- /dev/null +++ b/docs/compare/openwiki-notes.md @@ -0,0 +1,76 @@ +# OpenWiki(langchain-ai/openwiki)调研笔记 + +> 调研对象:本地克隆 `D:\repos\CodeWiki-CN\.research-competitors\openwiki`(TypeScript,v0.5.0)。 +> 所有论断均基于源码核对,标注 `文件:行号`(相对仓库根)。 + +## 1. 整体架构与定位 + +OpenWiki 是一个 **npm CLI**(`package.json` 的 `bin.openwiki` 指向 `dist/cli/cli.js`,description 为 "A CLI that uses a DeepAgents documentation agent to generate and maintain an OpenWiki for a codebase"),基于 **LangChain/LangGraph + deepagents** 构建,交互 UI 用 Ink(React for CLI)。它同时是三合一: + +1. **CLI Agent 工具**:命令解析入口 `src/cli/commands.ts:168`(`parseCommand`),支持 `--init/--update`、`code`/`personal` 双模式(`src/cli/commands.ts:26` 的 `OpenWikiRunMode`)、`ingest`/`cron`/`auth`/`ngrok`/`visualize`/`integrations`/`mcp` 子命令(`src/cli/commands.ts:107-164` 的 `CliCommand` 联合类型)。 +2. **MCP server**(内部):`openwiki mcp --host <id>` 启动一个"rootless"生命周期 MCP 服务器(`src/cli/commands.ts:658` `parseMcpCommand`;`src/integrations/mcp/server.ts:50` `createOpenWikiMcpServer`)。 +3. **Agent 库形态的集成**:把自身作为 skill+MCP 安装进 Codex/Claude Code/OpenCode/Cursor 四个宿主(`src/integrations/install/registry.ts:12,32,46,66`)。 + +核心生成流程入口是 `runNativeRepositoryGeneration`(`src/agent/repository-runner.ts:205`);聊天/agent 组装在 `src/agent/index.ts:481`(`createDeepAgent`),带 SQLite checkpointer(`src/agent/index.ts:9,811`)。定位 slogan 是"self-maintaining wiki, built for agents, explored by humans"——**wiki 首先写给 Agent 当记忆用**,人类用可视化浏览器(`src/visualize/server.ts`)阅读。 + +## 2. 代码分析阶段 + +**完全没有 AST/tree-sitter/ctags/依赖图提取,也没有嵌入向量**(对 src/ 全文 grep `embedding|vector|rag` 仅命中无关文案)。分析 = LLM agent 直接用文件系统工具读代码: + +- 工具集:planner 只有只读 `read_file/ls/glob/grep`(`src/agent/repository-runner.ts:74` `PLANNER_FILESYSTEM_TOOLS`);page worker 加上 `write_file/edit_file`(`src/agent/repository-runner.ts:75-79`),外加 `submit_plan/inspect_claims/submit_page` 三个生命周期工具(`src/agent/repository-runner.ts:80-85`)。 +- 文件系统是**虚拟根沙箱**:`OpenWikiLocalShellBackend`(`src/agent/docs-only-backend.ts:166`)把 `/` 映射到目标仓库,docsOnly 模式下写权限被限制在 `writableWikiPages` 白名单(每页 worker 只能写自己那一页,`src/agent/docs-only-backend.ts:517-527`;`src/agent/repository-runner.ts:415-424` 传入 `[job.path]`);shell `execute` 在 `.openwikiignore` 激活时被整体禁用(`src/agent/docs-only-backend.ts:487-508`),且永远禁止触碰 Claims 内部状态。 +- 依赖结构不显式提取。planner prompt 要求按"owned systems, runtime domains, cross-system workflows"组织信息架构而非镜像目录树(`src/agent/repository-prompts.ts:49-54`),探索策略是"先看 manifests/入口/公共面 → 追端到端流 → 看测试"(`src/agent/repository-prompts.ts:57-64`)。发现类指令偏好 `rg --files` 加排除目录(`src/agent/prompt.ts:161`),允许读 git 历史建立上下文(`src/agent/prompt.ts:152-156`)。 + +## 3. 文档生成流程 + +**明确两阶段**:planner(结构)→ 逐页 worker(内容),且全程持久化可恢复: + +- **Planning**:一个有界 planner agent 只能输出 `submit_plan`,plan schema 为 `pages[]{path,title,purpose,seedPaths,relatedPages,instructions}` + `deletePages`(`src/agent/repository-runner.ts:38-54`);init 必须含 `/openwiki/quickstart.md`,update 禁止删它(`src/agent/repository-prompts.ts:72-75`)。禁止子代理委派(`src/agent/repository-runner.ts:90-97` `NO_DELEGATION_MIDDLEWARE` 过滤掉 deepagents 注入的 task 工具)。 +- **逐页生成**:持久化有序队列,`nextRepositoryPage` 顺序取页(`src/agent/repository-runner.ts:373-397`),每页一个全新 agent(`runPageAgent`,`src/agent/repository-runner.ts:407`),prompt 含该页 purpose/seedPaths/relatedPages + 待决策的 Claims 清单(`src/agent/repository-prompts.ts:114-178`)。提交校验失败会以"可纠正的 tool 错误"回给 worker 重试(`src/agent/repository-runner.ts:113-134` `createSubmissionRejection`);worker 崩溃则恢复页快照并跳过、留待下次 update(`src/agent/repository-runner.ts:505-517`)。 +- **确定性收尾**(非 LLM):mermaid 校验→索引同步→内链校验→Claims sources 投影→generated provenance 盖章(`src/agent/wiki-finalizer.ts:248-285` `finalizeWikiArtifacts`)。目录 `index.md` 由代码确定性生成,禁止模型手写(`src/agent/prompts/code.ts:31`)。坏链不打断流程,原地标记 HTML 注释供下次自愈(`src/agent/prompt.ts:133-139`);mermaid 解析失败降级为 text fence 并留修复注释(`src/agent/prompt.ts:141-149`)。 +- **Prompt 策略亮点**:OKF v0.2(Google Knowledge Catalog 开放知识格式)frontmatter 强制要求 `type/title/description/tags`,description 明确"optimized for search & retrieval"(`src/agent/prompts/code.ts:63-88`);mermaid 图谱纪律按图型分类(sequenceDiagram/stateDiagram/erDiagram/flowchart,`src/agent/prompt.ts:141-149`);秘密文件(.env 等)禁读(`src/agent/prompts/code.ts:56-61`)。 +- **增量更新机制(最重的工程设计)**,多层: + 1. **no-op 检测**:对比 `.last-update.json` 记录的 gitHead 与当前 HEAD/worktree status,只有 openwiki 自身路径变化则跳过(`src/agent/utils.ts:126-195` `getUpdateNoopStatus`); + 2. **源指纹**:sha256 哈希全部 tracked+untracked 文件内容与 porcelain status(`src/agent/utils.ts:331-404` `createRepositorySourceSnapshot`),运行中源漂移会不推进 checkpoint、提示用户再 update(`src/agent/repository-runner.ts:244-253`); + 3. **每页源 checkpoint**:`openwiki/.page-manifest.json` 记录每页覆盖的指纹(`src/generation/page-manifest.ts:35` `RepositoryPageManifestEntry`),update 时未受影响页直接 fast-forward(`src/generation/repository-run.ts:391` `fastForwardUnchangedRepositoryPageCoverage`); + 4. **页级 update window**:按页的已提交 git baseline 分组,连同 changedPaths 喂给 planner 决定哪些页真正需要重写(`src/agent/repository-prompts.ts:215-231` `formatPageUpdateWindows`;`src/generation/repository-run.ts:145-167` 类型定义); + 5. **Grounded Claims**:每页的实质事实命题以 sidecar JSON 存于 `openwiki/.claims`(`src/claims/brains/code/store.ts:84,116`),证据 URI 为 `repo://<path>#L20-L48` 且带内容哈希版本;update 前 preflight 逐条比对证据版本,产出 stale/unresolved 清单(`src/claims/brains/code/preflight.ts:36-96`)。证据版本编码了行范围"重定位锚点"(首/末行哈希+上下文哈希),行号漂移后能自动重锚(`src/claims/evidence/repository/resolver.ts:22-69`)。worker 提交稀疏 reconciliation(confirm/revise/retract),无争议 Claims 自动保留(`src/claims/guidance.ts:18-24`)。 +- **init 安全性**:重新 init 会把旧 wiki 备份到临时目录,失败/ SIGINT/SIGTERM 均回滚,只保留用户手写的 `INSTRUCTIONS.md`(`src/agent/wiki-replacement.ts:42-80`)。运行状态持久化在 `openwiki/.run.json`,中断后 resume(`src/generation/run-state.ts`)。 +- **多语言**:`--language` 切换时先跑一轮确定性翻译 pass 把存量页面翻到目标语言,模型只写新增内容(`src/agent/translation-middleware.ts:187` `translateWiki`;`src/agent/prompt.ts:123-130`)。 + +## 4. 检索/问答能力 + +**无 RAG/嵌入/混合检索**(grep 证实)。问答链路是"agent + 文件系统工具": + +- code 模式聊天 prompt 明确要求 **wiki-first QA**:"inspect the generated wiki under /openwiki first",先 grep/glob wiki 再看源码,用户问"wiki 怎么说"时只用 wiki 页面(`src/agent/prompts/code.ts:25-28`)。 +- 聊天会话经 SQLite checkpointer 持久化(`src/agent/index.ts:416-422,811`),并有 checkpoint 裁剪防膨胀(`src/agent/index.ts:837-897`)。 +- **没有 web UI 问答**。`openwiki visualize` 只是只读的节点图 + Markdown 阅读器本地服务/静态导出(`src/cli/commands.ts:305-402`;`src/visualize/`)。OKF frontmatter 的 description 字段是为"外部检索工具"预留的接口,自身未实现检索。 +- personal 模式有 9 类连接器摄取(`src/connectors/sources/`:mcp、slack、gmail、x、web-search、hackernews、langsmith、git-repo),`openwiki ingest` 触发(`src/cli/commands.ts:404-486`),但这是个人知识库方向,与代码 wiki 主线正交。 + +## 5. MCP / Agent 集成 + +- **MCP server**:`openwiki mcp` 暴露 6 个生命周期工具 `openwiki_begin / submit_plan / next_page / inspect_page_claims / submit_page / finish`(`src/integrations/mcp/server.ts:11-30` 的 INSTRUCTIONS),本质是把第 3 节的持久化队列开放给外部宿主——宿主 coding agent(用自己的模型和原生仓库工具)做研究和写页,OpenWiki 管队列、校验、Claims、收尾(`integrations/openwiki/SKILL.md:8-65` 详述契约)。 +- **`skills/` 目录**:随包分发的 deepagents 技能(`mermaid-diagrams`、`write-connector`),安装时原子同步到 `~/.openwiki`(`src/agent/skills.ts:24-118`,含读-only Nix 场景的权限自愈),运行时以 `skills: ["/skills/"]` 注入 agent(`src/agent/repository-runner.ts:348,484`)。 +- **宿主集成**:`openwiki integrations install codex|claude|opencode|cursor` 安装 SKILL.md+MCP 配置(user 级或 `--project` 仓库级),支持 list/uninstall(`src/cli/commands.ts:539-650`;`src/integrations/install/registry.ts`)。 + +## 6. 值得注意的工程设计 + +- **可恢复页作业生命周期**:`begin → submit_plan → next_page → submit_page → finish`,每页推进前 Markdown+Claims+manifest 均落盘;CI(GitHub Actions/GitLab/Bitbucket 定时 workflow,由 `ensureCodeModeRepoSetup` 自动生成 `.github/workflows/openwiki-update.yml`,`src/ingestion/code-mode.ts:65-93`)跑挂后重跑即续。 +- **Claims 账本 + 证据版本重锚**(见第 3 节):把"文档是否过时"从模糊判断变成可机械验证的状态机,这是与 DeepWiki 类产品拉开差距的核心。 +- **eval 体系非常重**:LEDGER(`evals/ledger/`)——回放 git checkpoint、逐条抽取 wiki 原子事实命题、判定 supported/stale/invented(hallucinated)/unverified 四态等分母分区(`evals/ledger/README.md`),带 BM25 语义 evidence map 路由、金标准一致性 ≥0.90 的 judge 元评估门槛(`evals/ledger/meta/README.md`);另有 DeepSWE 配对实验验证"有 wiki 的 Codex 是否更能修 bug"(`evals/deepswe/README.md`)。 +- **成本/一致性取舍**:页队列**严格串行**(`while(true) nextRepositoryPage`,`src/agent/repository-runner.ts:380-396`),无并行页生成;planner/page worker 均禁止委派子代理。 +- 其他:细粒度 telemetry(仅 init/update 发一条 `openwiki_run` 事件,`src/cli/commands.ts:1062-1068`);技能目录原子安装处理 Windows EPERM/并发竞争(`src/agent/skills.ts:53-118`);Windows ACL 处理(`src/platform/windows-acl.ts`)。 + +## 7. 明显短板/局限 + +1. **无检索层**:问答靠 agent 拿 grep/glob 翻 wiki 文件,wiki 大了以后命中率和 token 成本都会退化;OKF description 字段是"留给别人做检索"的空位。 +2. **无静态分析**:不建 AST/调用图/依赖图,结构质量完全取决于 planner 模型的判断;跨页一致性只靠 plan 里的 relatedPages 和 quickstart 路由,无机械校验(除内链外)。 +3. **串行逐页生成**:大仓库一次 init 可能几十个 page job 顺序跑,时长和费用高,且没有页级并行。 +4. **worker 失败静默降级**:页 worker 崩溃只回滚快照并跳过(`src/agent/repository-runner.ts:505-517`),一次 run 可能留下不完整 wiki,要等下一轮 update 补。 +5. **状态文件入侵仓库**:`openwiki/.run.json`、`.claims/`、`.page-manifest.json`、`.last-update.json` 全部进 git;强 git 依赖(无 git 的目录指纹走 unborn 分支特殊路径)。 +6. **personal 模式 cron 偏 macOS**(launchd 语义,`src/cli/commands.ts:1151-1167` 帮助文本);交互聊天需 TTY,非 TTY 只能 `--print` 单轮(`src/cli/commands.ts:1040-1049`)。 +7. **无 web 问答服务**:visualizer 是只读阅读器,对比 DeepWiki 的"网页问答"形态是明显缺位。 + +## 一句话总结 + +OpenWiki 的差异化不在"生成 wiki"本身,而在**工程化的事实治理**:两阶段 plan→逐页生成、持久化可恢复队列、git 基线 + 源指纹 + 页级 checkpoint 的多层增量、带证据版本重锚的 Grounded Claims、OKF 标准化输出,以及 LEDGER 纵向漂移 eval——但检索/问答和静态分析层面几乎空白,问答完全靠 agent 翻文件。 From 64fb95eadca9b470f3ed6cad937d014a8f3693f3 Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 17:53:15 +0800 Subject: [PATCH 10/23] =?UTF-8?q?feat(mcp):=20skill=5Fcreator=20prepare+su?= =?UTF-8?q?bmit=20=E7=BC=96=E8=AF=91=E9=97=AD=E7=8E=AF=EF=BC=88T2,=20#25?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mode C 前两 mode(沿 note_consolidation 骨架,ADR-0004 两区制): - prepare:候选素材(未吸收 scenario + stable pitfall/lesson/decision 笔记 + 技能名下 open issues 聚合)、Jaccard>0.6 冲突预检、容量分级 (橙 9 只 UPDATE / 红 12 先合并)、防碎片纪律与写作系统提示,零副作用 - submit:全量先校验后落盘(name_slug/description_trigger/body ≤8KB/ source_refs 路径逃逸/敏感串/冲突与批量防碎片,失败报具体规则名); OKF frontmatter + revisions 审计链 + 双向溯源互链(source_refs ⇄ 素材 compiled_into);空产出 no_action 合法;submit 后重建索引 - 修复子代理实现缺陷:_norm_rel 的 lstrip("./") 会把 ../.. 逃逸路径 剥成合法相对路径,绕过路径安全检查——在原始串上先行拒绝 - install/retire mode 显式报错指向 T3(#26);注册同步:registry/ prompts(正文+注册)/resources/README 中英/测试 Closes #25 --- README.md | 6 +- codewiki/mcp/prompts.py | 56 ++ codewiki/mcp/registry.py | 93 +++ codewiki/mcp/resources.py | 3 +- codewiki/mcp/tools/skill_creator.py | 862 ++++++++++++++++++++++++++++ tests/test_skill_creator.py | 488 ++++++++++++++++ 6 files changed, 1505 insertions(+), 3 deletions(-) create mode 100644 codewiki/mcp/tools/skill_creator.py create mode 100644 tests/test_skill_creator.py diff --git a/README.md b/README.md index cc394f4..6f7c72d 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ repowiki/ | `get_prompt` | 获取各阶段的提示词模板(含 16 种 prompt_type) | | `close_session` | 关闭会话释放资源,构建 BM25 索引 + wikilink 图谱,写入生成元数据 | -**知识管理(10 个):** +**知识管理(11 个):** | 工具 | 用途 | |------|------| @@ -241,6 +241,7 @@ repowiki/ | `batch_ingest` | 批量导入:一次调用处理多个笔记/文档 | | `init_wiki` | 初始化 Wiki 工作区目录结构与项目级 schema.yaml | | `wiki_stats` | Wiki 知识库统计(页面数、笔记状态分布、覆盖率概览) | +| `skill_creator` | 把已确认知识(场景块 + stable pitfall/lesson/decision 笔记 + 技能 open issues)编译为 SKILL.md 行为指令草稿(`repowiki/skills/` 草稿区,两区制:进索引进 lint、不生效)。Mode C:prepare 零副作用返回候选素材/冲突预检(Jaccard>0.6)/容量预警(橙 9 只 UPDATE、红 12 先合并)/写作规范提示 → Agent 撰写 → submit 校验(失败报具体规则名)落盘、写双向溯源(source_refs ⇄ compiled_into)、追加 revisions、重建索引;空产出 no_action 合法;每批最多新建 1 份 | **质量保障(2 个):** @@ -928,7 +929,7 @@ All tools require zero LLM config. The IDE Agent invokes them via MCP. The serve | `get_prompt` | Retrieve prompt templates (23 prompt_types) | | `close_session` | Close session, build BM25 index + wikilink graph, write metadata | -**Knowledge Management (10):** +**Knowledge Management (11):** | Tool | Purpose | |------|---------| @@ -942,6 +943,7 @@ All tools require zero LLM config. The IDE Agent invokes them via MCP. The serve | `batch_ingest` | Batch import multiple notes/sources in one call | | `init_wiki` | Initialize Wiki workspace directories and project-level schema.yaml | | `wiki_stats` | Wiki statistics (page counts, note status distribution, coverage overview) | +| `skill_creator` | Compile confirmed knowledge (scenario blocks + stable pitfall/lesson/decision notes + per-skill open issues) into SKILL.md behaviour-instruction drafts in `repowiki/skills/` (two-zone draft area: indexed and linted, never effective). Mode C: prepare (zero side effects) returns candidates, conflict pre-check (Jaccard > 0.6), capacity warning (orange >= 9 update-only, red >= 12 merge-first) and the writing system prompt → the agent writes → submit validates (failures name the exact rule), writes the draft, records bidirectional provenance (source_refs ⇄ compiled_into), appends revisions and rebuilds the index. Empty output (no_action) is legal; at most one new skill per batch | **Quality Assurance (2):** diff --git a/codewiki/mcp/prompts.py b/codewiki/mcp/prompts.py index e8f4ed8..0c54eae 100644 --- a/codewiki/mcp/prompts.py +++ b/codewiki/mcp/prompts.py @@ -1262,6 +1262,43 @@ def _prompt_consolidate_knowledge(args: dict[str, str]) -> str: 用户拒绝则到此为止,不再追问。""" +def _prompt_skill_creator(args: dict[str, str]) -> str: + repo_path = _resolve_path(args.get("repo_path", "")) + return f"""技能编译工作流(skill-creator T2,docs/skill-creator需求与设计方案.md,ADR-0004 两区制)。当用户说"把经验编成技能""生成 SKILL""整理出可复用的行为指令",或希望把已确认知识(场景块 + 精选笔记)升级为 IDE 可触发的 SKILL.md 时,使用本流程。**编译出的技能只落草稿区 `repowiki/skills/`(进索引进 lint、不生效);install 到生效区是后续单独的用户动作(T3)。** + +## ⛔ 行为契约(必须遵守) +- 素材边界:只用**已确认知识**——`wiki/scenarios/` 场景块、stable 状态的 pitfall/lesson/decision 笔记、既有技能名下的 open issues;**任务记忆不是技能素材**(直写落盘无确认闸门,ADR-0002)。 +- 永不自动编译;触发词出现先向用户确认再执行。 + +## 步骤 1:准备(零副作用) +`skill_creator(mode="prepare", repo_path="{repo_path}", topic=<计划中的技能名>)`,关注返回中的: +- `capacity.warning`:orange(≥9 份)= 只准 UPDATE;red(≥12 份)= 先合并/退役再谈新建 +- `conflict_precheck.warnings`:topic 与既有技能 name/description 的 Jaccard 相似度 >0.6 —— 命中就改为 UPDATE 该技能 +- `candidates.scenarios` / `candidates.notes`:未被任何技能 source_refs 吸收的素材(带 est_tokens 阅读成本) +- `open_issues_by_skill`:既有技能名下的 open issues(修订输入) +- `system_prompt` 与 `fragmentation_discipline`:写作规范与防碎片纪律全文 + +## 步骤 2:撰写(防碎片纪律) +1. 默认 UPDATE;每批**最多新建 1 份**;新建前先读 ≥2 份最相似技能确认放不进 +2. description = **触发条件 + 具体行动**(一句话,利于 IDE 触发判定) +3. 正文五段骨架:工作场景 / 适用条件 / 核心 SOP / 判断逻辑 / 禁忌与反模式;正文 ≤8KB +4. 证据段带量化回链:"依据:notes/xxx.md 的 Y 结论",不止 source_refs 路径 +5. **禁绝对路径(/Users/、C:\\…)与密钥**——submit 会拒收 + +## 步骤 3:提交 +`skill_creator(mode="submit", report={{"skills": [{{"name": "<slug>", "action": "created|updated", "description": "...", "body": "<五段正文>", "source_refs": ["wiki/scenarios/...", "notes/..."], "summary": "<40 字内>", "revision_note": "<修订原因,updated 时建议附>"}}]}})` +- 校验失败会返回**具体规则名**(name_slug / description_trigger / body_too_large / sensitive_content / source_refs_required / name_conflict / batch_fragmentation / capacity_orange 等)——按规则修正后重交,失败轮次不落盘 +- 素材不足、不值得编译时:提交**空 report**(no_action 是合法轮次,不算失败) +- 成功后工具自动写双向溯源(技能 source_refs ⇄ 素材 compiled_into)、追加 revisions、重建索引 + +## 步骤 4:验证与收尾 +`lint_wiki(checks=["skill_sections"])` 确认草稿五段骨架齐全;向用户汇报新建/更新的技能清单,并提示:草稿不生效,审阅通过后走 install(T3)才能被 IDE 发现。试用中发现问题用 `flag_issue(page_path="skills/<name>/SKILL.md", issue_type="skill-ineffective")` 回流,下次 prepare 会聚合为修订素材。 + +## 参数说明 +- **repo_path**(必填):仓库根目录 +- **topic**(可选):prepare 时传入计划技能名做冲突预检""" + + def _prompt_promote_note(args: dict[str, str]) -> str: note_file = (args.get("note_file") or "").strip() repo_path = _resolve_path(args.get("repo_path", "")) @@ -1678,6 +1715,24 @@ async def list_prompts() -> list: ), ], ), + Prompt( + name="skill-creator", + title="技能编译(SKILL.md 草稿区)", + description=( + "把已确认知识(场景块 + stable 笔记 + 技能 open issues)编译为" + " SKILL.md 行为指令草稿(repowiki/skills/,两区制草稿区:进索引进" + " lint、不生效):prepare 取候选素材/冲突预检/容量预警/写作规范 → " + "Agent 撰写 → submit 校验落盘并写双向溯源。install/retire 为后续" + "工单。适用于「生成技能」「把经验编成 SKILL」等场景。" + ), + arguments=[ + PromptArgument( + name="repo_path", + description="仓库根目录路径(相对路径基于当前工作目录,默认当前目录)", + required=False, + ), + ], + ), Prompt( name="promote-note", title="笔记晋升为正式 wiki 页面", @@ -1731,6 +1786,7 @@ async def get_prompt(name: str, arguments: dict[str, str] | None) -> Any: "distill-conversations": _prompt_distill_conversations, "task-workflow": _prompt_task_workflow, "consolidate-knowledge": _prompt_consolidate_knowledge, + "skill-creator": _prompt_skill_creator, "promote-note": _prompt_promote_note, } diff --git a/codewiki/mcp/registry.py b/codewiki/mcp/registry.py index 5a5f95f..ba444a0 100644 --- a/codewiki/mcp/registry.py +++ b/codewiki/mcp/registry.py @@ -1778,6 +1778,99 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr mode="thread", ) +_register( + Tool( + name="skill_creator", + description=( + "Compile CONFIRMED knowledge into SKILL.md behaviour-instruction " + "drafts in the draft zone repowiki/skills/ (skill-creator T2, " + "issue #25, ADR-0004 two-zone gate: drafts are indexed and linted " + "but NEVER effective — install to .codebuddy/skills/ is a separate " + "user action, T3). Mode C protocol — the host agent writes, the " + "tool does deterministic bookkeeping. mode='prepare' (zero side " + "effects): candidate materials not yet absorbed by any skill " + "(scenario blocks + stable pitfall/lesson/decision notes), open " + "issues grouped per existing skill, the draft-zone skill index, " + "a name/description conflict pre-check (token Jaccard > 0.6), a " + "graded capacity warning (green / orange=update-only / " + "red=merge-first, cap 12), the anti-fragmentation discipline and " + "the writing system prompt (description = trigger condition + " + "action, five-section skeleton, quantified note backlinks, body " + "<= 8KB, no absolute paths or secrets). mode='submit' with " + "report.skills=[{name, action(created|updated), description, body, " + "source_refs, summary?, revision_note?}]: validates each entry " + "(failures name the exact rule, e.g. name_slug / " + "description_trigger / body_too_large / sensitive_content / " + "source_refs_required / name_conflict), enforces the batch " + "anti-fragmentation rules (at most ONE created per batch, orange/" + "red capacity blocks creation), writes skills/<name>/SKILL.md " + "with OKF frontmatter, appends revisions on update, records " + "bidirectional provenance (skill metadata.source_refs ⇄ material " + "metadata.compiled_into) and rebuilds the search index. An empty " + "report (no_action) is a legal round. Task memories are NOT " + "skill material (ADR-0002). NEVER runs automatically." + ), + inputSchema={ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Optional active session id (resolves output_dir).", + }, + "repo_path": { + "type": "string", + "description": "Repository path used to derive the output directory.", + }, + "mode": { + "type": "string", + "enum": ["prepare", "submit"], + "description": ( + "prepare: return candidates + skill index + conflict " + "pre-check + capacity warning + writing system prompt. " + "submit: validate and record the agent-written skill " + "report (install/retire are a later ticket)." + ), + }, + "topic": { + "type": "string", + "description": ( + "prepare only: the planned skill name/topic, used for " + "the name/description conflict pre-check (Jaccard > 0.6)." + ), + }, + "sources": { + "type": "array", + "items": { + "type": "string", + "enum": ["scenarios", "notes", "issues"], + }, + "description": ( + "prepare only: restrict the returned candidate kinds " + "(default: all three)." + ), + }, + "limit": { + "type": "integer", + "description": "prepare only: max candidates per kind (default 30).", + }, + "report": { + "type": "object", + "description": ( + "submit only: {skills: [{name, action, description, " + "body, source_refs, summary?, revision_note?}]} — " + "action in created|updated, source_refs the absorbed " + "material paths (wiki/scenarios/... or notes/...). An " + "empty skills list is a legal no_action round." + ), + }, + }, + "required": ["mode"], + }, + ), + handler_path="codewiki.mcp.tools.skill_creator:handle_skill_creator", + mode="thread", +) + _register( Tool( name="batch_ingest", diff --git a/codewiki/mcp/resources.py b/codewiki/mcp/resources.py index cca343e..729b030 100644 --- a/codewiki/mcp/resources.py +++ b/codewiki/mcp/resources.py @@ -371,7 +371,7 @@ async def read_resource(uri: Any) -> str: { "server": "CodeWiki-CN MCP Server v5.5.0", # NOTE: keep in sync with the number of _register() calls in registry.py - "tool_count": 47, + "tool_count": 49, "tool_categories": { "代码分析": [ "analyze_repo", @@ -405,6 +405,7 @@ async def read_resource(uri: Any) -> str: "ingest_source", "retract_source", "batch_ingest", + "skill_creator", ], "质量保障": ["lint_wiki", "flag_issue"], "会话管理": ["close_session", "init_wiki"], diff --git a/codewiki/mcp/tools/skill_creator.py b/codewiki/mcp/tools/skill_creator.py new file mode 100644 index 0000000..e55d262 --- /dev/null +++ b/codewiki/mcp/tools/skill_creator.py @@ -0,0 +1,862 @@ +"""MCP tool: skill_creator — compile confirmed knowledge into SKILL.md drafts. + +skill-creator T2 (issue #25, docs/skill-creator需求与设计方案.md §4.1-§4.3, +ADR-0004): a one-way compiler from confirmed knowledge (scenario blocks, +curated stable pitfall/lesson/decision notes, open issues on existing skills) +into behaviour-instruction SKILL.md drafts in the draft zone +(``repowiki/skills/``). Mode C protocol, mirroring note_consolidation (the +tool does deterministic bookkeeping; the host agent does the writing): + + mode='prepare' + Zero side effects. Returns the candidate materials not yet absorbed by + any skill, the open issues grouped per existing skill, the draft-zone + skill index, a name/description conflict pre-check (token Jaccard + > 0.6), the graded capacity warning (green / orange=update-only / + red=merge-first), the anti-fragmentation discipline text and the + writing system prompt (description = condition + action, five-section + skeleton, quantified note backlinks, body <= 8KB, no absolute paths + or secrets). + + mode='submit' + Takes ``report.skills[]`` = [{name, action(created|updated), + description, body, source_refs[], summary?, revision_note?}] — + validates each entry (failures name the exact rule), writes + ``skills/<name>/SKILL.md`` with OKF frontmatter (render_frontmatter + round-trip), appends revisions on update, records bidirectional + provenance (skill ``metadata.source_refs`` ⇄ material + ``metadata.compiled_into``) and rebuilds the search index. An empty + report (``no_action``) is a legal round: "material not worth + compiling" is not an error. + +Out of scope here (later tickets): install/retire (#26), the eight SKILL.md +lint checks (#27), recall-side isolation (#28). Draft-zone skills are +indexed but never effective — the two-zone gate (ADR-0004) is physical: +the IDE never scans repowiki/skills/. +""" + +from __future__ import annotations + +import json +import logging +import re +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +from codewiki.mcp.tools.note_consolidation import ( + _append_meta_list, + _norm_rel, + _read_body, + _read_frontmatter, + _SOFT_DELETE_MARKER, +) + +logger = logging.getLogger(__name__) + +_VALID_ACTIONS = ("created", "updated") +# Design §4.3-4 / Q4: 8KB body cap (Anthropic skills are typically 1-3KB; +# over-budget means the skill should split or reference the scenario). +_BODY_LIMIT_BYTES = 8 * 1024 +# Design §4.5: capacity hard cap 12, orange line 9 (analogous to +# max_scenarios=15 in aggregation_state). +_MAX_SKILLS = 12 +_ORANGE_SKILLS = 9 +_STALE_DAYS = 90 +_SUMMARY_CHARS = 300 +_CANDIDATE_LIMIT = 30 +_DESCRIPTION_MIN_CHARS = 10 +# Design §4.5: name/description conflict pre-check threshold. +_CONFLICT_JACCARD = 0.6 +# Only high-value note types are skill material (design §2/§4.1); task +# memories are explicitly excluded (ADR-0002 direct-write, no gate). +_SKILL_NOTE_TYPES = ("pitfall", "lesson", "decision") + +# Design §4.3-5 / lint 无敏感串: absolute paths and secret keys are rejected. +_ABS_PATH_RE = re.compile(r"(/Users/|/home/|[A-Za-z]:\\)") +_SECRET_KEY_RE = re.compile(r"\bsk-[A-Za-z0-9]{20,}") +_SENSITIVE_PATTERNS: Tuple[Tuple[str, Any], ...] = ( + ("absolute_path", _ABS_PATH_RE), + ("secret_key", _SECRET_KEY_RE), +) + +_CJK_RE = re.compile(r"[\u4e00-\u9fff]") +_WORD_RE = re.compile(r"[a-z0-9]+") + +_KIND_ALIASES = { + "scenario": "scenarios", + "scenarios": "scenarios", + "note": "notes", + "notes": "notes", + "issue": "issues", + "issues": "issues", +} + +_FRAGMENTATION_DISCIPLINE = ( + "Anti-fragmentation discipline (design §4.5): DEFAULT to UPDATE, not " + "CREATE. At most ONE new skill per submit batch. Before creating a new " + "skill, read at least 2 most-similar existing skills and confirm the new " + "knowledge fits nowhere. ORANGE capacity (9-11 live skills) means UPDATE " + "only; RED (>=12) means merge/retire first. A name/description Jaccard " + "similarity > 0.6 with an existing skill is a conflict — update that " + "skill instead of creating a near-duplicate." +) + +_SKILL_WRITING_SYSTEM = ( + "You are the SKILL Compiler (skill-creator, Mode C).\n" + "Compile CONFIRMED knowledge (scenario blocks, stable pitfall/lesson/" + "decision notes, open issues on existing skills) into behaviour-" + "instruction SKILL.md drafts. A skill changes how the IDE agent BEHAVES " + "when its description matches — it is not retrievable knowledge.\n\n" + "WRITING RULES (mandatory, design §4.3):\n" + "1. description = TRIGGER CONDITION + CONCRETE ACTION in one sentence, " + "e.g. 'ripgrep silently skips binary-detected files — verify empty " + "search results with grep -a before concluding not found'. Abstract " + "summaries fail IDE trigger matching.\n" + "2. Body skeleton — five sections, same as scenario blocks: " + "工作场景 / 适用条件 / 核心 SOP / 判断逻辑 / 禁忌与反模式.\n" + "3. Evidence with quantified backlinks: cite in the body " + "'依据: notes/<file>.md 的 <关键结论>' (note title + key conclusion), " + "not just source_refs paths.\n" + "4. Body <= 8KB. Over-budget means split the skill or reference the " + "scenario instead of restating it.\n" + "5. NO absolute paths (/Users/..., C:\\..., D:\\repos) and NO secrets or " + "API keys — submit rejects them.\n" + "6. Material boundary: scenarios + stable pitfall/lesson/decision notes " + "+ open issues only. Task memories are NOT skill material (ADR-0002: " + "direct-write, no confirmation gate).\n\n" + "STRATEGY (anti-fragmentation):\n" + "1. Default is UPDATE; at most ONE new skill per batch.\n" + "2. Before CREATE, read >= 2 most-similar existing skills; the conflict " + "pre-check in prepare flags Jaccard > 0.6.\n" + "3. ORANGE capacity means UPDATE only; RED means merge/retire first.\n\n" + "WORKFLOW:\n" + "(1) Read the candidate materials listed in prepare (view_repo_file).\n" + "(2) For revisions of existing skills, fold in their open issues " + "(flag_issue feedback, grouped in prepare).\n" + "(3) Call skill_creator(mode='submit', report={skills: [{name, action, " + "description, body, source_refs, summary?, revision_note?}]}) — the " + "tool writes the SKILL.md draft (status=draft). Nothing is effective " + "until install, which is a separate user action (T3).\n" + "Empty output is legal: when the material is not worth compiling, " + "submit an empty report (no_action)." +) + + +# --------------------------------------------------------------------------- # +# Small helpers +# --------------------------------------------------------------------------- # +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _stale_after(output_dir: Path) -> str: + """``YYYY-MM-DD`` = today + default_stale_days (schema convention, 90).""" + days = _STALE_DAYS + try: + from codewiki.mcp.tools.page_router import load_schema + + raw = (load_schema(output_dir).get("conventions") or {}).get("default_stale_days") + if raw is not None: + days = max(1, int(raw)) + except Exception: + pass + return (date.today() + timedelta(days=days)).isoformat() + + +def _est_tokens(text: str) -> int: + """Rough reading-cost estimate: 1 token per CJK char + 1 per 4 others.""" + cjk = len(_CJK_RE.findall(text)) + return cjk + (len(text) - cjk) // 4 + + +def _tokens(text: str) -> Set[str]: + """Token set for Jaccard: lowercase word runs + individual CJK chars.""" + toks = set(_WORD_RE.findall((text or "").lower())) + toks.update(_CJK_RE.findall(text or "")) + return toks + + +def _jaccard(a: Set[str], b: Set[str]) -> float: + if not a or not b: + return 0.0 + return len(a & b) / len(a | b) + + +def _skills_dir(output_dir: Path) -> Path: + from codewiki.mcp.tools.page_router import get_page_type_dir + + return get_page_type_dir("skill", output_dir) + + +# --------------------------------------------------------------------------- # +# Draft-zone scan +# --------------------------------------------------------------------------- # +def _scan_skills(output_dir: Path) -> List[Dict[str, Any]]: + """Draft-zone index: skills/<name>/SKILL.md → name/status/summary/refs.""" + sdir = _skills_dir(output_dir) + out: List[Dict[str, Any]] = [] + if not sdir.is_dir(): + return out + for p in sorted(sdir.rglob("SKILL.md")): + if not p.is_file(): + continue + fm = _read_frontmatter(p) or {} + meta = fm.get("metadata") if isinstance(fm.get("metadata"), dict) else {} + refs = meta.get("source_refs") + if isinstance(refs, str): + refs = [refs] + if not isinstance(refs, list): + refs = [] + out.append( + { + "name": str(fm.get("name") or p.parent.name), + "file": _norm_rel(str(p.relative_to(output_dir)), output_dir), + "status": str(fm.get("status") or "draft"), + "description": str(fm.get("description") or ""), + "summary": str(meta.get("summary") or "")[:_SUMMARY_CHARS], + "source_refs": [ + _norm_rel(str(r), output_dir) for r in refs if str(r).strip() + ], + } + ) + return out + + +def _absorbed_paths(skills: List[Dict[str, Any]]) -> Set[str]: + """Material rel-paths already absorbed by some skill's source_refs.""" + absorbed: Set[str] = set() + for s in skills: + absorbed.update(s["source_refs"]) + return absorbed + + +# --------------------------------------------------------------------------- # +# Candidate materials +# --------------------------------------------------------------------------- # +def _candidate_scenarios( + output_dir: Path, absorbed: Set[str], limit: int +) -> List[Dict[str, Any]]: + """Live scenario blocks not yet absorbed by any skill.""" + from codewiki.mcp.tools.page_router import get_page_type_dir + + sdir = get_page_type_dir("scenario", output_dir) + out: List[Dict[str, Any]] = [] + if not sdir.is_dir(): + return out + for p in sorted(sdir.glob("*.md")): + if not p.is_file(): + continue + if _read_body(p) == _SOFT_DELETE_MARKER: + continue # pending soft-delete + rel = _norm_rel(str(p.relative_to(output_dir)), output_dir) + if rel in absorbed: + continue + fm = _read_frontmatter(p) or {} + meta = fm.get("metadata") if isinstance(fm.get("metadata"), dict) else {} + body = _read_body(p) + out.append( + { + "file": rel, + "title": str(fm.get("title") or p.stem), + "summary": str(meta.get("summary") or "")[:_SUMMARY_CHARS], + "est_tokens": _est_tokens(body), + } + ) + if len(out) >= limit: + break + return out + + +def _candidate_notes( + output_dir: Path, absorbed: Set[str], limit: int +) -> List[Dict[str, Any]]: + """Stable pitfall/lesson/decision notes not yet absorbed by any skill.""" + from codewiki.src.config import NOTES_DIR + + ndir = Path(output_dir) / NOTES_DIR + out: List[Dict[str, Any]] = [] + if not ndir.is_dir(): + return out + for p in sorted(ndir.glob("*.md")): + if not p.is_file(): + continue + fm = _read_frontmatter(p) or {} + status = str(fm.get("status", "")).lower() + if status not in ("stable", "confirmed"): + continue + if str(fm.get("type", "")).lower() not in _SKILL_NOTE_TYPES: + continue + rel = _norm_rel(str(p.relative_to(output_dir)), output_dir) + if rel in absorbed: + continue + body = _read_body(p) + out.append( + { + "file": rel, + "title": str(fm.get("title") or p.stem), + "note_type": str(fm.get("type") or ""), + "preview": body[:_SUMMARY_CHARS], + "est_tokens": _est_tokens(body), + } + ) + if len(out) >= limit: + break + return out + + +def _open_issues_per_skill(output_dir: Path) -> List[Dict[str, Any]]: + """Open flag_issue entries grouped by draft-skill name (design §4.4).""" + from codewiki.mcp.tools.issue_tracker import _load_issues + from codewiki.src.config import SKILLS_DIR + + groups: Dict[str, List[Dict[str, Any]]] = {} + for issue in (_load_issues(output_dir).get("issues") or {}).values(): + if not isinstance(issue, dict): + continue + if str(issue.get("status") or "open") != "open": + continue + parts = str(issue.get("page_path") or "").replace("\\", "/").split("/") + if len(parts) < 3 or parts[0] != SKILLS_DIR: + continue # not a draft-skill issue + groups.setdefault(parts[1], []).append( + { + "id": str(issue.get("id") or ""), + "issue_type": str(issue.get("issue_type") or ""), + "description": str(issue.get("description") or ""), + "severity": str(issue.get("severity") or "warning"), + "updated_at": str(issue.get("updated_at") or ""), + } + ) + return [{"skill": name, "issues": items} for name, items in sorted(groups.items())] + + +# --------------------------------------------------------------------------- # +# Capacity & conflict pre-check +# --------------------------------------------------------------------------- # +def _skill_capacity(live_count: int) -> Dict[str, Any]: + if live_count >= _MAX_SKILLS: + warning = "red" + elif live_count >= _ORANGE_SKILLS: + warning = "orange" + else: + warning = "none" + return { + "current": live_count, + "max": _MAX_SKILLS, + "orange": _ORANGE_SKILLS, + "warning": warning, + } + + +def _conflict_precheck( + topic: Optional[str], skills: List[Dict[str, Any]] +) -> Dict[str, Any]: + """Jaccard(>0.6) conflict pre-check of *topic* against existing skills.""" + if not (topic or "").strip(): + return { + "provided": False, + "warnings": [], + "note": ( + "Pass topic=<planned skill name> to pre-check name/description " + "conflicts (Jaccard > 0.6) against existing skills." + ), + } + toks = _tokens(topic) + warnings: List[Dict[str, Any]] = [] + for s in skills: + sim_name = _jaccard(toks, _tokens(s["name"])) + sim_desc = _jaccard(toks, _tokens(s["description"])) + best, field = (sim_name, "name") if sim_name >= sim_desc else (sim_desc, "description") + if best > _CONFLICT_JACCARD: + warnings.append( + { + "skill": s["name"], + "field": field, + "similarity": round(best, 3), + "message": ( + f"topic '{topic}' is highly similar to skill " + f"'{s['name']}' ({field}, Jaccard {best:.2f} > " + f"{_CONFLICT_JACCARD}) — prefer UPDATE over CREATE." + ), + } + ) + return {"provided": True, "threshold": _CONFLICT_JACCARD, "warnings": warnings} + + +# --------------------------------------------------------------------------- # +# Submit: validation + writing +# --------------------------------------------------------------------------- # +def _validate_entry( + entry: Dict[str, Any], output_dir: Path +) -> Tuple[Optional[Dict[str, Any]], Dict[str, Any]]: + """Validate one report entry. Returns (error|None, normalized values).""" + from codewiki.src.store import slugify + + name = str(entry.get("name") or "").strip() + action = str(entry.get("action") or "").lower() + description = str(entry.get("description") or "").strip() + body = entry.get("body") + body = body if isinstance(body, str) else "" + + def _err(rule: str, message: str) -> Tuple[Dict[str, Any], Dict[str, Any]]: + return ( + {"name": name or "<missing>", "rule": rule, "message": message}, + {}, + ) + + # name slug compliance: slugify(name) == name == directory name + if not name or slugify(name) != name: + return _err( + "name_slug", + f"name '{name}' is not a valid slug (slugify mismatch); the " + "directory name must equal slugify(name).", + ) + if action not in _VALID_ACTIONS: + return _err( + "action_invalid", + f"action '{action}' invalid; expected one of created|updated.", + ) + if len(description) < _DESCRIPTION_MIN_CHARS: + return _err( + "description_trigger", + f"description must be non-empty and carry trigger semantics " + f"(condition + action, >= {_DESCRIPTION_MIN_CHARS} chars); got " + f"{len(description)}.", + ) + if not body.strip(): + return _err("body_required", "body is required (the skill instructions).") + if len(body.encode("utf-8")) > _BODY_LIMIT_BYTES: + return _err( + "body_too_large", + f"body is {len(body.encode('utf-8'))} bytes; the cap is " + f"{_BODY_LIMIT_BYTES} (split the skill or reference the scenario).", + ) + + raw_refs = entry.get("source_refs") + if not isinstance(raw_refs, list) or not raw_refs: + return _err( + "source_refs_required", + "source_refs must be a non-empty list of material paths " + "(wiki/scenarios/... or notes/...).", + ) + refs: List[str] = [] + for r in raw_refs: + # Reject traversal BEFORE normalizing: _norm_rel's lstrip("./") would + # silently turn "../../etc/passwd" into "etc/passwd", bypassing the + # escape check below (and note_consolidation's helper shares this + # quirk, so the check happens here on the RAW string). + raw = str(r).replace("\\", "/").strip() + if raw.startswith("../") or "/../" in f"/{raw}" or raw.startswith("/"): + return _err("source_refs_invalid", f"source_ref '{raw}' escapes output_dir.") + rel = _norm_rel(raw, output_dir) + if not rel: + continue + try: + (Path(output_dir) / rel).resolve().relative_to(Path(output_dir).resolve()) + except ValueError: + return _err("source_refs_invalid", f"source_ref '{rel}' escapes output_dir.") + refs.append(rel) + if not refs: + return _err("source_refs_required", "source_refs must contain at least one path.") + + # Sensitive strings: absolute paths / secret keys in description or body. + scan_text = f"{description}\n{body}" + for kind, pattern in _SENSITIVE_PATTERNS: + m = pattern.search(scan_text) + if m: + return _err( + "sensitive_content", + f"sensitive {kind} pattern matched ('{m.group(0)}'); absolute " + "paths and secrets are forbidden in skill bodies.", + ) + + path = _skills_dir(output_dir) / name / "SKILL.md" + if action == "created" and path.is_file(): + return _err( + "name_conflict", + f"skills/{name}/SKILL.md already exists; use action='updated' " + "(or retire + re-create).", + ) + if action == "updated" and not path.is_file(): + return _err( + "not_found", + f"skills/{name}/SKILL.md not found; action='updated' requires an " + "existing draft.", + ) + + return ( + None, + { + "name": name, + "action": action, + "description": description, + "body": body.strip(), + "source_refs": refs, + "summary": str(entry.get("summary") or "").strip()[:_SUMMARY_CHARS], + "revision_note": str(entry.get("revision_note") or "").strip(), + "path": path, + }, + ) + + +def _render_skill_doc( + fm: Dict[str, Any], body: str +) -> str: + from codewiki.src.frontmatter import render_frontmatter + + return render_frontmatter(fm) + "\n" + body + "\n" + + +def _create_skill_file(values: Dict[str, Any], output_dir: Path) -> bool: + """Write a brand-new draft skill (OKF frontmatter, design §4.2).""" + from codewiki.src.config import actor_id + from codewiki.src.store import locked_write + + fm: Dict[str, Any] = { + "name": values["name"], + "description": values["description"], + "type": "Skill", + "status": "draft", + "generated": {"by": actor_id(), "at": _now_iso()}, + "stale_after": _stale_after(output_dir), + "metadata": { + "summary": values["summary"], + "source_refs": values["source_refs"], + "revisions": [ + { + "at": _now_iso(), + "reason": values["revision_note"] or "created from candidate materials", + "source": "skill_creator", + } + ], + }, + } + try: + values["path"].parent.mkdir(parents=True, exist_ok=True) + locked_write(values["path"], _render_skill_doc(fm, values["body"])) + return True + except OSError as e: + logger.warning("skill create failed for %s: %s", values["name"], e) + return False + + +def _update_skill_file(values: Dict[str, Any], output_dir: Path) -> bool: + """Merge the report into an existing draft: append revision, refresh fields.""" + from codewiki.src.frontmatter import parse_frontmatter + from codewiki.src.store import locked_rmw + + def _transform(text: str) -> Optional[str]: + fm, _old_body = parse_frontmatter(text) + if not fm: + fm = {} + fm["name"] = values["name"] + fm["description"] = values["description"] + fm["type"] = "Skill" + fm["stale_after"] = _stale_after(output_dir) + meta = fm.get("metadata") if isinstance(fm.get("metadata"), dict) else {} + refs = meta.get("source_refs") + if isinstance(refs, str): + refs = [refs] + if not isinstance(refs, list): + refs = [] + merged = list(refs) + for r in values["source_refs"]: + if r not in merged: + merged.append(r) + meta["source_refs"] = merged + if values["summary"]: + meta["summary"] = values["summary"] + revisions = meta.get("revisions") + if not isinstance(revisions, list): + revisions = [] + revisions.append( + { + "at": _now_iso(), + "reason": values["revision_note"] or "updated via skill_creator", + "source": "skill_creator", + } + ) + meta["revisions"] = revisions + fm["metadata"] = meta + return _render_skill_doc(fm, values["body"]) + + try: + return locked_rmw(values["path"], _transform) is not None + except OSError as e: + logger.warning("skill update failed for %s: %s", values["name"], e) + return False + + +def _write_backlinks(values: Dict[str, Any], output_dir: Path) -> int: + """Append the skill path to each material's metadata.compiled_into.""" + skill_rel = _norm_rel(str(values["path"].relative_to(output_dir)), output_dir) + linked = 0 + for ref in values["source_refs"]: + target = Path(output_dir) / ref + if target.is_file() and _append_meta_list(target, "compiled_into", [skill_rel]): + linked += 1 + return linked + + +# --------------------------------------------------------------------------- # +# Tool handler +# --------------------------------------------------------------------------- # +def handle_skill_creator(arguments: Dict[str, Any], store: Any) -> str: + """Compile confirmed knowledge into draft-zone SKILL.md assets (Mode C).""" + session_id = arguments.get("session_id") + session = store.get(session_id) if session_id else None + if session is None and session_id: + return json.dumps({"error": f"Session {session_id} not found or expired."}) + + try: + from codewiki.mcp.tools.store_bridge import resolve_output_dir + + output_dir = resolve_output_dir(session, arguments) + except ValueError as e: + return json.dumps({"error": str(e)}) + + mode = str(arguments.get("mode") or "prepare").lower() + if mode in ("install", "retire"): + return json.dumps( + { + "error": ( + f"mode='{mode}' is not implemented yet (T3, issue #26); " + "this build covers prepare and submit only." + ) + } + ) + if mode not in ("prepare", "submit"): + return json.dumps( + {"error": f"Invalid mode '{mode}'. Expected one of: prepare, submit."} + ) + + # ---- mode == "prepare" (zero side effects) ---- # + if mode == "prepare": + try: + limit = min(200, max(1, int(arguments.get("limit") or _CANDIDATE_LIMIT))) + except (TypeError, ValueError): + limit = _CANDIDATE_LIMIT + + kinds = {"scenarios", "notes", "issues"} + raw_sources = arguments.get("sources") + if isinstance(raw_sources, str): + raw_sources = [raw_sources] + if isinstance(raw_sources, list): + mapped = { + _KIND_ALIASES.get(str(s).strip().lower()) for s in raw_sources + } - {None} + if mapped: + kinds = mapped + + skills = _scan_skills(output_dir) + absorbed = _absorbed_paths(skills) + scenarios = ( + _candidate_scenarios(output_dir, absorbed, limit) + if "scenarios" in kinds + else [] + ) + notes = _candidate_notes(output_dir, absorbed, limit) if "notes" in kinds else [] + open_issues = _open_issues_per_skill(output_dir) if "issues" in kinds else [] + capacity = _skill_capacity(len(skills)) + conflict = _conflict_precheck(arguments.get("topic"), skills) + + return json.dumps( + { + "status": "prepared", + "mode": "prepare", + "candidates": { + "scenarios": scenarios, + "notes": notes, + "total": len(scenarios) + len(notes), + }, + "open_issues_by_skill": open_issues, + "skills_index": [ + {k: s[k] for k in ("name", "file", "status", "summary")} + for s in skills + ], + "conflict_precheck": conflict, + "capacity": capacity, + "fragmentation_discipline": _FRAGMENTATION_DISCIPLINE, + "system_prompt": _SKILL_WRITING_SYSTEM, + "next": ( + "(1) Read the candidate materials (view_repo_file); (2) for " + "existing skills, fold in their open issues; (3) default to " + "UPDATE — at most ONE new skill per batch, read >= 2 similar " + "skills before CREATE; (4) obey the capacity warning (orange = " + "update only, red = merge/retire first); (5) submit the report " + "with skill_creator(mode='submit'). Empty output (no_action) is " + "legal when the material is not worth compiling." + ), + }, + indent=2, + ensure_ascii=False, + ) + + # ---- mode == "submit" ---- # + report = arguments.get("report") + if isinstance(report, str): + try: + report = json.loads(report) + except json.JSONDecodeError: + return json.dumps({"error": "report must be a JSON object."}) + if not isinstance(report, dict) or not isinstance(report.get("skills"), list): + return json.dumps( + { + "error": ( + "mode='submit' requires 'report': {skills: [{name, action, " + "description, body, source_refs, summary?, revision_note?}]} " + "with action in created|updated (skills may be empty)." + ) + } + ) + entries = report["skills"] + if not entries: + return json.dumps( + { + "status": "no_action", + "mode": "submit", + "message": ( + "Empty skill report accepted (no_action) — material judged " + "not worth compiling; nothing was written." + ), + }, + ensure_ascii=False, + ) + + # Per-entry validation (nothing written until all entries pass). + errors: List[Dict[str, Any]] = [] + validated: List[Dict[str, Any]] = [] + seen_names: Set[str] = set() + for entry in entries: + if not isinstance(entry, dict): + errors.append({"name": "<invalid>", "rule": "entry_invalid", "message": "not an object"}) + continue + err, values = _validate_entry(entry, output_dir) + if err: + errors.append(err) + continue + if values["name"] in seen_names: + errors.append( + { + "name": values["name"], + "rule": "duplicate_name", + "message": "the same skill name appears twice in this report.", + } + ) + continue + seen_names.add(values["name"]) + validated.append(values) + + # Batch-level anti-fragmentation & capacity enforcement (design §4.5). + created = [v for v in validated if v["action"] == "created"] + if len(created) > 1: + errors.append( + { + "name": None, + "rule": "batch_fragmentation", + "message": ( + f"{len(created)} skills marked created in one batch; at most " + "ONE new skill per batch (default to UPDATE)." + ), + } + ) + if created: + live = len(_scan_skills(output_dir)) + capacity = _skill_capacity(live) + if capacity["warning"] == "red": + errors.append( + { + "name": None, + "rule": "capacity_red", + "message": ( + f"capacity RED: {live} live skills >= {_MAX_SKILLS}; merge " + "or retire existing skills before creating new ones." + ), + } + ) + elif capacity["warning"] == "orange": + errors.append( + { + "name": None, + "rule": "capacity_orange", + "message": ( + f"capacity ORANGE: {live} live skills >= {_ORANGE_SKILLS}; " + "UPDATE only — no new skills." + ), + } + ) + + if errors: + return json.dumps( + { + "status": "error", + "mode": "submit", + "errors": errors, + "message": ( + f"{len(errors)} validation error(s); nothing was written. " + "Fix the reported rules and re-submit." + ), + }, + indent=2, + ensure_ascii=False, + ) + + # Write drafts + bidirectional provenance. + processed: List[Dict[str, Any]] = [] + for values in validated: + ok = ( + _create_skill_file(values, output_dir) + if values["action"] == "created" + else _update_skill_file(values, output_dir) + ) + if not ok: + processed.append( + { + "name": values["name"], + "action": values["action"], + "status": "write_failed", + } + ) + continue + backlinks = _write_backlinks(values, output_dir) + fm = _read_frontmatter(values["path"]) or {} + meta = fm.get("metadata") if isinstance(fm.get("metadata"), dict) else {} + revisions = meta.get("revisions") + processed.append( + { + "name": values["name"], + "action": values["action"], + "file": _norm_rel(str(values["path"].relative_to(output_dir)), output_dir), + "revisions": len(revisions) if isinstance(revisions, list) else 0, + "backlinks": backlinks, + } + ) + + # Rebuild the search index so the drafts are lintable/queryable metadata + # immediately (recall-side isolation is T5, issue #28). + index_result = None + try: + from codewiki.mcp.tools.wiki_search import build_full_index + + index_result = build_full_index(output_dir) + except Exception as e: # indexing is best-effort + logger.warning("search index rebuild failed after skill submit: %s", e) + + live = len(_scan_skills(output_dir)) + return json.dumps( + { + "status": "completed", + "mode": "submit", + "processed": processed, + "capacity": _skill_capacity(live), + **({"index": index_result} if index_result else {}), + "message": ( + f"Skill submit recorded: {len(processed)} operation(s), drafts " + "landed in the draft zone (status=draft, NOT effective — install " + "is a separate user action, T3). Source materials got " + "compiled_into backlinks; prompt the user to review the drafts." + ), + }, + indent=2, + ensure_ascii=False, + ) diff --git a/tests/test_skill_creator.py b/tests/test_skill_creator.py new file mode 100644 index 0000000..5ebc060 --- /dev/null +++ b/tests/test_skill_creator.py @@ -0,0 +1,488 @@ +"""Tests for skill-creator T2: prepare + submit compile loop (issue #25). + +Covers the first two modes of skill_creator (docs/skill-creator需求与设计 +方案.md §4.1-§4.3, ADR-0004): + +- prepare: candidate selection (unabsorbed scenarios / stable + pitfall-lesson-decision notes), per-skill open-issue aggregation, capacity + grading, conflict pre-check, zero side effects +- submit: created draft lands with OKF frontmatter + bidirectional + provenance + index rebuild; updated appends revisions; every validation + failure returns its specific rule name; no_action (empty report) is legal; + anti-fragmentation (batch > 1 created) and capacity (orange) rejection +""" + +import json +from pathlib import Path + +import yaml + +from codewiki.mcp.session import SessionStore +from codewiki.mcp.tools import skill_creator as sc +from codewiki.mcp.tools import wiki_search + + +# --------------------------------------------------------------------------- # +# Helpers (fixture style follows test_consolidation_p2 / test_skill_pages) +# --------------------------------------------------------------------------- # +_SECTIONS = ["工作场景", "适用条件", "核心 SOP", "判断逻辑", "禁忌与反模式"] + +_SKILL_SCHEMA = { + "page_types": { + "skill": { + "directory": "skills", + "description": "SKILL.md draft zone", + "required_sections": _SECTIONS, + } + } +} + + +def _mk_repo(tmp_path: Path) -> tuple[str, Path]: + repo = tmp_path / "repo" + od = repo / "repowiki" + (od / "notes").mkdir(parents=True) + (od / "wiki" / "scenarios").mkdir(parents=True) + (od / "wiki").mkdir(exist_ok=True) # build_full_index legacy fallback scans wiki/ + (od / "schema.yaml").write_text( + yaml.safe_dump(_SKILL_SCHEMA, allow_unicode=True), encoding="utf-8" + ) + return str(repo), od + + +def _write_scenario(od: Path, name: str, body: str = "## Work context\nx") -> str: + sdir = od / "wiki" / "scenarios" + sdir.mkdir(parents=True, exist_ok=True) + p = sdir / f"{name}.md" + p.write_text( + "---\n" + + yaml.safe_dump( + {"type": "Scenario", "title": name, "status": "draft", "metadata": {}}, + allow_unicode=True, + ) + + "---\n\n" + + body + + "\n", + encoding="utf-8", + ) + return f"wiki/scenarios/{name}.md" + + +def _write_note( + od: Path, + name: str, + note_type: str = "pitfall", + status: str = "stable", +) -> str: + p = od / "notes" / f"{name}.md" + p.write_text( + "---\n" + + yaml.safe_dump( + {"type": note_type, "title": name, "status": status, "metadata": {}}, + allow_unicode=True, + ) + + "---\n\n## 背景\n\nbody of the note\n", + encoding="utf-8", + ) + return f"notes/{name}.md" + + +def _write_skill_draft(od: Path, name: str, source_refs: list[str] | None = None) -> str: + sk_dir = od / "skills" / name + sk_dir.mkdir(parents=True, exist_ok=True) + meta: dict = {"summary": f"summary of {name}"} + if source_refs: + meta["source_refs"] = source_refs + p = sk_dir / "SKILL.md" + p.write_text( + "---\n" + + yaml.safe_dump( + { + "name": name, + "description": f"当遇到 {name} 场景时执行对应 SOP", + "type": "Skill", + "status": "draft", + "metadata": meta, + }, + allow_unicode=True, + ) + + "---\n\n" + + "\n".join(f"## {s}\n\ncontent" for s in _SECTIONS) + + "\n", + encoding="utf-8", + ) + return f"skills/{name}/SKILL.md" + + +def _write_issues(od: Path, issues: dict) -> None: + meta = od / ".meta" + meta.mkdir(parents=True, exist_ok=True) + (meta / "issues.json").write_text( + json.dumps({"version": 1, "issues": issues}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def _issue(iid: str, page: str, status: str = "open") -> dict: + return { + "id": iid, + "issue_type": "custom", + "page_path": page, + "description": f"issue on {page}", + "severity": "warning", + "created_at": "2026-09-01T00:00:00", + "updated_at": "2026-09-02T00:00:00", + "status": status, + "occurrences": 1, + "updates": [], + } + + +def _call(repo: str, args: dict) -> dict: + store = SessionStore() + return json.loads(sc.handle_skill_creator({"repo_path": repo, **args}, store)) + + +def _fm(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + end = text.find("---", 3) + return yaml.safe_load(text[3:end]) + + +def _snapshot(od: Path) -> dict: + return { + str(p.relative_to(od)).replace("\\", "/"): p.read_bytes() + for p in sorted(od.rglob("*")) + if p.is_file() + } + + +def _skill_entry(name: str, **overrides) -> dict: + entry = { + "name": name, + "action": "created", + "description": f"When {name} errors appear, run the recovery SOP before retrying", + "body": "\n".join(f"## {s}\n\ncontent for {s}" for s in _SECTIONS), + # valid default so rule-specific cases don't trip source_refs_required + # first; the source_refs cases override it explicitly + "source_refs": ["notes/some-note.md"], + "summary": f"{name} skill summary", + } + entry.update(overrides) + return entry + + +# --------------------------------------------------------------------------- # +# 1. prepare +# --------------------------------------------------------------------------- # +def test_prepare_lists_unabsorbed_candidates_zero_side_effects(tmp_path): + repo, od = _mk_repo(tmp_path) + absorbed_scen = _write_scenario(od, "absorbed-scene") + free_scen = _write_scenario(od, "free-scene") + absorbed_note = _write_note(od, "absorbed-note") + _write_note(od, "free-note", note_type="lesson") + _write_note(od, "draft-note", status="draft") # not stable + _write_note(od, "wrong-type-note", note_type="general") # wrong type + # existing skill absorbs absorbed-scene + absorbed-note + _write_skill_draft(od, "existing-skill", source_refs=[absorbed_scen, absorbed_note]) + + before = _snapshot(od) + resp = _call(repo, {"mode": "prepare"}) + assert resp["status"] == "prepared" + + # candidates: only unabsorbed scenario + unabsorbed stable note + assert [c["file"] for c in resp["candidates"]["scenarios"]] == [free_scen] + assert [c["file"] for c in resp["candidates"]["notes"]] == ["notes/free-note.md"] + assert resp["candidates"]["total"] == 2 + assert resp["candidates"]["scenarios"][0]["est_tokens"] > 0 + + # draft-zone index carries name/status/summary + assert [s["name"] for s in resp["skills_index"]] == ["existing-skill"] + assert resp["skills_index"][0]["status"] == "draft" + + # guidance fields + assert resp["system_prompt"] and "工作场景" in resp["system_prompt"] + assert resp["fragmentation_discipline"] + assert resp["capacity"]["warning"] == "none" + assert resp["capacity"]["max"] == 12 + + # zero side effects + assert _snapshot(od) == before + + +def test_prepare_capacity_grading_and_conflict_precheck(tmp_path): + repo, od = _mk_repo(tmp_path) + for i in range(9): + _write_skill_draft(od, f"skill-{i}") + resp = _call(repo, {"mode": "prepare"}) + assert resp["capacity"]["current"] == 9 + assert resp["capacity"]["warning"] == "orange" + + for i in range(9, 12): + _write_skill_draft(od, f"skill-{i}") + resp = _call(repo, {"mode": "prepare"}) + assert resp["capacity"]["current"] == 12 + assert resp["capacity"]["warning"] == "red" + + # conflict pre-check: topic nearly identical to an existing name + # (pure-word overlap — CJK chars are tokenized per-char, which dilutes + # Jaccard below the 0.6 threshold by design) + resp = _call(repo, {"mode": "prepare", "topic": "skill 3 handler"}) + assert resp["conflict_precheck"]["provided"] is True + warned = {w["skill"] for w in resp["conflict_precheck"]["warnings"]} + assert "skill-3" in warned + # without a topic: no warnings, just the hint + resp = _call(repo, {"mode": "prepare"}) + assert resp["conflict_precheck"]["provided"] is False + assert resp["conflict_precheck"]["warnings"] == [] + + +def test_prepare_aggregates_open_issues_per_skill(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_skill_draft(od, "target-skill") + _write_note(od, "some-note") + _write_issues( + od, + { + "id1": _issue("id1", "skills/target-skill/SKILL.md"), + "id2": _issue("id2", "skills/target-skill/SKILL.md"), + "id3": _issue("id3", "skills/target-skill/SKILL.md", status="resolved"), + "id4": _issue("id4", "notes/some-note.md"), # not a skill page + }, + ) + resp = _call(repo, {"mode": "prepare"}) + assert resp["open_issues_by_skill"] == [ + { + "skill": "target-skill", + "issues": [ + {"id": "id1", "issue_type": "custom", "description": "issue on skills/target-skill/SKILL.md", + "severity": "warning", "updated_at": "2026-09-02T00:00:00"}, + {"id": "id2", "issue_type": "custom", "description": "issue on skills/target-skill/SKILL.md", + "severity": "warning", "updated_at": "2026-09-02T00:00:00"}, + ], + } + ] + + +# --------------------------------------------------------------------------- # +# 2. submit: created — draft file + provenance + index rebuild +# --------------------------------------------------------------------------- # +def test_submit_created_writes_skill_backlinks_and_rebuilds_index(tmp_path): + repo, od = _mk_repo(tmp_path) + scen = _write_scenario(od, "redis-scene") + note = _write_note(od, "redis-note", note_type="decision") + resp = _call( + repo, + { + "mode": "submit", + "report": { + "skills": [ + _skill_entry( + "redis-pool-ops", + source_refs=[scen, note], + revision_note="first compile from redis materials", + ) + ] + }, + }, + ) + assert resp["status"] == "completed", resp + skill_path = od / "skills" / "redis-pool-ops" / "SKILL.md" + assert skill_path.is_file() + + fm = _fm(skill_path) + assert fm["name"] == "redis-pool-ops" + assert fm["type"] == "Skill" + assert fm["status"] == "draft" + assert fm["description"].startswith("When redis-pool-ops") + assert fm["generated"]["by"].startswith("codewiki/") + assert fm["stale_after"] + assert set(fm["metadata"]["source_refs"]) == {scen, note} + assert len(fm["metadata"]["revisions"]) == 1 + assert fm["metadata"]["revisions"][0]["reason"] == "first compile from redis materials" + assert fm["metadata"]["revisions"][0]["source"] == "skill_creator" + + # bidirectional provenance: materials got compiled_into backlinks + skill_rel = "skills/redis-pool-ops/SKILL.md" + assert skill_rel in _fm(od / scen)["metadata"]["compiled_into"] + assert skill_rel in _fm(od / note)["metadata"]["compiled_into"] + + # index rebuilt: the draft is indexed with source="skill" + idx = wiki_search._load_index(od) + assert "skills/redis-pool-ops/SKILL.md" in idx.docs + assert idx.docs["skills/redis-pool-ops/SKILL.md"]["source"] == "skill" + + # the absorbed material is no longer a candidate + again = _call(repo, {"mode": "prepare"}) + assert again["candidates"]["scenarios"] == [] + assert again["candidates"]["notes"] == [] + + +def test_submit_updated_appends_revision_and_merges_refs(tmp_path): + repo, od = _mk_repo(tmp_path) + scen1 = _write_scenario(od, "scene-one") + note1 = _write_note(od, "note-one") + resp = _call( + repo, + { + "mode": "submit", + "report": {"skills": [_skill_entry("iter-skill", source_refs=[scen1, note1])]}, + }, + ) + assert resp["status"] == "completed" + + note2 = _write_note(od, "note-two", note_type="lesson") + resp = _call( + repo, + { + "mode": "submit", + "report": { + "skills": [ + _skill_entry( + "iter-skill", + action="updated", + source_refs=["notes/note-two.md"], + revision_note="fold in lesson feedback", + ) + ] + }, + }, + ) + assert resp["status"] == "completed", resp + fm = _fm(od / "skills" / "iter-skill" / "SKILL.md") + revisions = fm["metadata"]["revisions"] + assert len(revisions) == 2 + assert revisions[-1]["reason"] == "fold in lesson feedback" + # source_refs merged, deduplicated + assert set(fm["metadata"]["source_refs"]) == {scen1, note1, "notes/note-two.md"} + # new material got the backlink too + assert "skills/iter-skill/SKILL.md" in _fm(od / note2)["metadata"]["compiled_into"] + + +# --------------------------------------------------------------------------- # +# 3. submit: validation failures name the exact rule +# --------------------------------------------------------------------------- # +def test_submit_validation_rule_names(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_skill_draft(od, "existing-skill") + + cases = [ + (_skill_entry("Bad Name!"), "name_slug"), + (_skill_entry("ok-name", description="short"), "description_trigger"), + (_skill_entry("ok-name", body="## 工作场景\n" + "x" * 9000), "body_too_large"), + ( + _skill_entry( + "ok-name", + body="## 工作场景\nrun C:\\Users\\john\\script", + source_refs=["notes/some-note.md"], + ), + "sensitive_content", + ), + ( + _skill_entry( + "ok-name", + body="## 工作场景\nkey sk-abc123def456ghi789jkl012 here", + source_refs=["notes/some-note.md"], + ), + "sensitive_content", + ), + (_skill_entry("ok-name", source_refs=[]), "source_refs_required"), + (_skill_entry("ok-name", action="installed"), "action_invalid"), + (_skill_entry("existing-skill"), "name_conflict"), + (_skill_entry("ghost-skill", action="updated"), "not_found"), + (_skill_entry("ok-name", body=" "), "body_required"), + (_skill_entry("ok-name", source_refs=["../../etc/passwd"]), "source_refs_invalid"), + ] + for entry, rule in cases: + resp = _call(repo, {"mode": "submit", "report": {"skills": [entry]}}) + assert resp["status"] == "error", (rule, resp) + rules = {e["rule"] for e in resp["errors"]} + assert rule in rules, (rule, resp["errors"]) + + # nothing was written by any failing round + assert not (od / "skills" / "ok-name").exists() + assert _snapshot(od / "skills") == _snapshot(od / "skills") + + +def test_submit_invalid_mode_and_report_shape(tmp_path): + repo, od = _mk_repo(tmp_path) + resp = _call(repo, {"mode": "install", "name": "x"}) + assert "error" in resp and "issue #26" in resp["error"] + resp = _call(repo, {"mode": "bogus"}) + assert "error" in resp + resp = _call(repo, {"mode": "submit"}) # missing report + assert "error" in resp + + +# --------------------------------------------------------------------------- # +# 4. no_action + anti-fragmentation + capacity enforcement +# --------------------------------------------------------------------------- # +def test_submit_empty_report_is_legal_no_action(tmp_path): + repo, od = _mk_repo(tmp_path) + before = _snapshot(od) + resp = _call(repo, {"mode": "submit", "report": {"skills": []}}) + assert resp["status"] == "no_action" + assert not (od / "skills").exists() + assert _snapshot(od) == before + + +def test_submit_rejects_multiple_created_in_one_batch(tmp_path): + repo, od = _mk_repo(tmp_path) + resp = _call( + repo, + { + "mode": "submit", + "report": { + "skills": [ + _skill_entry("skill-a", source_refs=["notes/n.md"]), + _skill_entry("skill-b", source_refs=["notes/n.md"]), + ] + }, + }, + ) + assert resp["status"] == "error" + rules = {e["rule"] for e in resp["errors"]} + assert "batch_fragmentation" in rules + # nothing written + assert not (od / "skills").exists() + + +def test_submit_capacity_orange_blocks_create_allows_update(tmp_path): + repo, od = _mk_repo(tmp_path) + for i in range(9): # orange line: 9 live skills + _write_skill_draft(od, f"cap-{i}") + note = _write_note(od, "cap-note") + + resp = _call( + repo, + { + "mode": "submit", + "report": {"skills": [_skill_entry("brand-new", source_refs=[note])]}, + }, + ) + assert resp["status"] == "error" + rules = {e["rule"] for e in resp["errors"]} + assert "capacity_orange" in rules + assert not (od / "skills" / "brand-new").exists() + + # UPDATE is still allowed at orange + resp = _call( + repo, + { + "mode": "submit", + "report": { + "skills": [ + _skill_entry( + "cap-0", + action="updated", + source_refs=[note], + revision_note="orange update", + ) + ] + }, + }, + ) + assert resp["status"] == "completed", resp + fm = _fm(od / "skills" / "cap-0" / "SKILL.md") + assert fm["description"].startswith("When cap-0") + assert fm["metadata"]["revisions"][-1]["reason"] == "orange update" From e08dae63931bd9206c14e029f4a621eeec3966a7 Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 18:01:33 +0800 Subject: [PATCH 11/23] =?UTF-8?q?feat(mcp):=20skill=5Fcreator=20install/re?= =?UTF-8?q?tire=20=E7=94=9F=E6=95=88=E5=8C=BA=E7=AE=A1=E7=90=86=EF=BC=88T3?= =?UTF-8?q?,=20#26=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两区制的生效侧(ADR-0004): - install:草稿 → 生效区 .codebuddy/skills/<name>/,剥离全部管理 frontmatter(只留 name/description/正文,省宿主上下文 token); installed_at/installed_to/installed_hash 写回草稿——规范化哈希 (sha256(name+description+body))是 T4 漂移检测的比对契约;幂等 - retire:草稿标 deprecated(正文保留审计,revisions 记录 reason, installed_* 三元组清除)+ 生效区移除;deprecated 拒绝再 install; reason 必填(审计链) - 注册同步:registry description / prompts 工作流(步骤 5/6)/ README 中英 / 测试 5 例(剥离断言/幂等/修订重装/退役闭环/错误路径) Closes #26 --- README.md | 4 +- codewiki/mcp/prompts.py | 18 ++- codewiki/mcp/registry.py | 30 ++-- codewiki/mcp/tools/skill_creator.py | 210 ++++++++++++++++++++++++++-- tests/test_skill_creator.py | 126 ++++++++++++++++- 5 files changed, 357 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 6f7c72d..94e51d7 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ repowiki/ | `batch_ingest` | 批量导入:一次调用处理多个笔记/文档 | | `init_wiki` | 初始化 Wiki 工作区目录结构与项目级 schema.yaml | | `wiki_stats` | Wiki 知识库统计(页面数、笔记状态分布、覆盖率概览) | -| `skill_creator` | 把已确认知识(场景块 + stable pitfall/lesson/decision 笔记 + 技能 open issues)编译为 SKILL.md 行为指令草稿(`repowiki/skills/` 草稿区,两区制:进索引进 lint、不生效)。Mode C:prepare 零副作用返回候选素材/冲突预检(Jaccard>0.6)/容量预警(橙 9 只 UPDATE、红 12 先合并)/写作规范提示 → Agent 撰写 → submit 校验(失败报具体规则名)落盘、写双向溯源(source_refs ⇄ compiled_into)、追加 revisions、重建索引;空产出 no_action 合法;每批最多新建 1 份 | +| `skill_creator` | 把已确认知识(场景块 + stable pitfall/lesson/decision 笔记 + 技能 open issues)编译为 SKILL.md 行为指令草稿(`repowiki/skills/` 草稿区,两区制:进索引进 lint、不生效)。Mode C:prepare 零副作用返回候选素材/冲突预检(Jaccard>0.6)/容量预警(橙 9 只 UPDATE、红 12 先合并)/写作规范提示 → Agent 撰写 → submit 校验(失败报具体规则名)落盘、写双向溯源(source_refs ⇄ compiled_into)、追加 revisions、重建索引;空产出 no_action 合法;每批最多新建 1 份。install(用户确认后):剥离管理元数据装入生效区 `.codebuddy/skills/<name>/`(幂等,写 installed_at/to/hash 供漂移检测);retire:草稿标 deprecated + 生效区移除(正文保留审计,需 reason) | **质量保障(2 个):** @@ -943,7 +943,7 @@ All tools require zero LLM config. The IDE Agent invokes them via MCP. The serve | `batch_ingest` | Batch import multiple notes/sources in one call | | `init_wiki` | Initialize Wiki workspace directories and project-level schema.yaml | | `wiki_stats` | Wiki statistics (page counts, note status distribution, coverage overview) | -| `skill_creator` | Compile confirmed knowledge (scenario blocks + stable pitfall/lesson/decision notes + per-skill open issues) into SKILL.md behaviour-instruction drafts in `repowiki/skills/` (two-zone draft area: indexed and linted, never effective). Mode C: prepare (zero side effects) returns candidates, conflict pre-check (Jaccard > 0.6), capacity warning (orange >= 9 update-only, red >= 12 merge-first) and the writing system prompt → the agent writes → submit validates (failures name the exact rule), writes the draft, records bidirectional provenance (source_refs ⇄ compiled_into), appends revisions and rebuilds the index. Empty output (no_action) is legal; at most one new skill per batch | +| `skill_creator` | Compile confirmed knowledge (scenario blocks + stable pitfall/lesson/decision notes + per-skill open issues) into SKILL.md behaviour-instruction drafts in `repowiki/skills/` (two-zone draft area: indexed and linted, never effective). Mode C: prepare (zero side effects) returns candidates, conflict pre-check (Jaccard > 0.6), capacity warning (orange >= 9 update-only, red >= 12 merge-first) and the writing system prompt → the agent writes → submit validates (failures name the exact rule), writes the draft, records bidirectional provenance (source_refs ⇄ compiled_into), appends revisions and rebuilds the index. Empty output (no_action) is legal; at most one new skill per batch. install (after user confirmation): strip management frontmatter into the effect zone `.codebuddy/skills/<name>/` (idempotent, stamps installed_at/to/hash for drift detection); retire: mark the draft deprecated + remove the effect copy (body kept for audit, reason required) | **Quality Assurance (2):** diff --git a/codewiki/mcp/prompts.py b/codewiki/mcp/prompts.py index 0c54eae..e307242 100644 --- a/codewiki/mcp/prompts.py +++ b/codewiki/mcp/prompts.py @@ -1264,11 +1264,11 @@ def _prompt_consolidate_knowledge(args: dict[str, str]) -> str: def _prompt_skill_creator(args: dict[str, str]) -> str: repo_path = _resolve_path(args.get("repo_path", "")) - return f"""技能编译工作流(skill-creator T2,docs/skill-creator需求与设计方案.md,ADR-0004 两区制)。当用户说"把经验编成技能""生成 SKILL""整理出可复用的行为指令",或希望把已确认知识(场景块 + 精选笔记)升级为 IDE 可触发的 SKILL.md 时,使用本流程。**编译出的技能只落草稿区 `repowiki/skills/`(进索引进 lint、不生效);install 到生效区是后续单独的用户动作(T3)。** + return f"""技能编译工作流(skill-creator T2+T3,docs/skill-creator需求与设计方案.md,ADR-0004 两区制)。当用户说"把经验编成技能""生成 SKILL""整理出可复用的行为指令",或希望把已确认知识(场景块 + 精选笔记)升级为 IDE 可触发的 SKILL.md 时,使用本流程。**编译出的技能只落草稿区 `repowiki/skills/`(进索引进 lint、不生效);install 到生效区 `.codebuddy/skills/` 是单独的用户动作——用户明确确认后才调用。** ## ⛔ 行为契约(必须遵守) - 素材边界:只用**已确认知识**——`wiki/scenarios/` 场景块、stable 状态的 pitfall/lesson/decision 笔记、既有技能名下的 open issues;**任务记忆不是技能素材**(直写落盘无确认闸门,ADR-0002)。 -- 永不自动编译;触发词出现先向用户确认再执行。 +- 永不自动编译、永不自动 install;触发词出现先向用户确认再执行。 ## 步骤 1:准备(零副作用) `skill_creator(mode="prepare", repo_path="{repo_path}", topic=<计划中的技能名>)`,关注返回中的: @@ -1291,8 +1291,18 @@ def _prompt_skill_creator(args: dict[str, str]) -> str: - 素材不足、不值得编译时:提交**空 report**(no_action 是合法轮次,不算失败) - 成功后工具自动写双向溯源(技能 source_refs ⇄ 素材 compiled_into)、追加 revisions、重建索引 -## 步骤 4:验证与收尾 -`lint_wiki(checks=["skill_sections"])` 确认草稿五段骨架齐全;向用户汇报新建/更新的技能清单,并提示:草稿不生效,审阅通过后走 install(T3)才能被 IDE 发现。试用中发现问题用 `flag_issue(page_path="skills/<name>/SKILL.md", issue_type="skill-ineffective")` 回流,下次 prepare 会聚合为修订素材。 +## 步骤 4:验证 +`lint_wiki(checks=["skill_sections"])` 确认草稿五段骨架齐全;向用户汇报新建/更新的技能清单,提示草稿尚未生效。 + +## 步骤 5:安装(仅用户确认后) +用户审阅草稿并明确同意后:`skill_creator(mode="install", repo_path="{repo_path}", name="<slug>")` +- 生效区文件只含 name/description/正文(管理元数据全部剥离,省宿主上下文) +- 工具写 installed_at/installed_to/installed_hash 回草稿——哈希是漂移检测契约(lint #27 据此发现"草稿已修订、生效区仍旧版") +- install 幂等;草稿修订后重装即可刷新生效区 + +## 步骤 6:退役与反馈 +- 技能过时/有害:`skill_creator(mode="retire", name="<slug>", reason="<为什么>")` —— 草稿标 deprecated(正文保留审计)、生效区移除 +- 试用发现问题:`flag_issue(page_path="skills/<name>/SKILL.md", issue_type="skill-ineffective")` 回流,下次 prepare 聚合为修订素材 ## 参数说明 - **repo_path**(必填):仓库根目录 diff --git a/codewiki/mcp/registry.py b/codewiki/mcp/registry.py index ba444a0..d6bd4e3 100644 --- a/codewiki/mcp/registry.py +++ b/codewiki/mcp/registry.py @@ -1783,16 +1783,16 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr name="skill_creator", description=( "Compile CONFIRMED knowledge into SKILL.md behaviour-instruction " - "drafts in the draft zone repowiki/skills/ (skill-creator T2, " - "issue #25, ADR-0004 two-zone gate: drafts are indexed and linted " - "but NEVER effective — install to .codebuddy/skills/ is a separate " - "user action, T3). Mode C protocol — the host agent writes, the " - "tool does deterministic bookkeeping. mode='prepare' (zero side " - "effects): candidate materials not yet absorbed by any skill " - "(scenario blocks + stable pitfall/lesson/decision notes), open " - "issues grouped per existing skill, the draft-zone skill index, " - "a name/description conflict pre-check (token Jaccard > 0.6), a " - "graded capacity warning (green / orange=update-only / " + "drafts in the draft zone repowiki/skills/ (skill-creator T2+T3, " + "issues #25/#26, ADR-0004 two-zone gate: drafts are indexed and " + "linted but NEVER effective — installation to .codebuddy/skills/ " + "is a separate user action). Mode C protocol — the host agent " + "writes, the tool does deterministic bookkeeping. mode='prepare' " + "(zero side effects): candidate materials not yet absorbed by any " + "skill (scenario blocks + stable pitfall/lesson/decision notes), " + "open issues grouped per existing skill, the draft-zone skill " + "index, a name/description conflict pre-check (token Jaccard > " + "0.6), a graded capacity warning (green / orange=update-only / " "red=merge-first, cap 12), the anti-fragmentation discipline and " "the writing system prompt (description = trigger condition + " "action, five-section skeleton, quantified note backlinks, body " @@ -1807,7 +1807,15 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "with OKF frontmatter, appends revisions on update, records " "bidirectional provenance (skill metadata.source_refs ⇄ material " "metadata.compiled_into) and rebuilds the search index. An empty " - "report (no_action) is a legal round. Task memories are NOT " + "report (no_action) is a legal round. mode='install' name=<slug>: " + "strip ALL management frontmatter and write {name, description} + " + "body into the effect zone .codebuddy/skills/<name>/ (IDE-" + "discovered); stamps installed_at/installed_to/installed_hash on " + "the draft — the normalized hash (name+description+body) is the " + "drift-detection contract; idempotent. mode='retire' " + "name=<slug> reason=<why>: mark the draft deprecated (body kept " + "for audit, revision records the reason), remove the effect-zone " + "copy; deprecated drafts refuse re-install. Task memories are NOT " "skill material (ADR-0002). NEVER runs automatically." ), inputSchema={ diff --git a/codewiki/mcp/tools/skill_creator.py b/codewiki/mcp/tools/skill_creator.py index e55d262..f74d0f4 100644 --- a/codewiki/mcp/tools/skill_creator.py +++ b/codewiki/mcp/tools/skill_creator.py @@ -28,10 +28,18 @@ report (``no_action``) is a legal round: "material not worth compiling" is not an error. -Out of scope here (later tickets): install/retire (#26), the eight SKILL.md -lint checks (#27), recall-side isolation (#28). Draft-zone skills are -indexed but never effective — the two-zone gate (ADR-0004) is physical: -the IDE never scans repowiki/skills/. +Out of scope here (later tickets): the eight SKILL.md lint checks (#27). +Draft-zone skills are indexed but never effective — the two-zone gate +(ADR-0004) is physical: the IDE never scans repowiki/skills/. + +install / retire (T3, issue #26) live here too: install strips all +management frontmatter and writes a minimal {name, description} + body +SKILL.md into the EFFECT zone (repo-root .codebuddy/skills/, discovered by +the IDE), stamping installed_at / installed_to / installed_hash back onto +the draft. The normalized hash (name + description + body, design §4.2) is +the drift-detection contract lint (#27) compares against. retire marks the +draft deprecated and removes the effect-zone copy; the draft body stays +for audit. """ from __future__ import annotations @@ -598,6 +606,189 @@ def _write_backlinks(values: Dict[str, Any], output_dir: Path) -> int: return linked +# --------------------------------------------------------------------------- # +# Install / retire (T3, issue #26) — effect-zone management +# --------------------------------------------------------------------------- # +def _normalized_hash(name: str, description: str, body: str) -> str: + """Drift-detection contract (design §4.2): hash of the EXACT content that + lands in the effect zone — name + description + body, management + frontmatter excluded (both sides strip it, whole-file hashes would never + match).""" + import hashlib + + payload = f"{name}\x00{description}\x00{body.strip()}".encode("utf-8") + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def _effect_zone_dir(output_dir: Path) -> Path: + """Effect zone = repo root / .codebuddy/skills/ (one level above the + repowiki output_dir; ADR-0004: outside repowiki, never scanned).""" + from codewiki.src.config import SKILL_EFFECT_DIR + + return output_dir.parent / SKILL_EFFECT_DIR + + +def _mode_install(arguments: Dict[str, Any], output_dir: Path) -> str: + """Draft → effect zone. Strips management frontmatter, stamps the + installed_* triple back onto the draft, idempotent.""" + name = str(arguments.get("name") or "").strip() + if not name: + return json.dumps({"error": "install requires 'name' (draft skill slug)."}) + draft = _skills_dir(output_dir) / name / "SKILL.md" + if not draft.is_file(): + return json.dumps( + {"error": f"draft skills/{name}/SKILL.md not found (install works on drafts)."} + ) + fm, body = _read_frontmatter(draft), _read_body(draft) + if not fm: + return json.dumps({"error": f"draft skills/{name}/SKILL.md has no parseable frontmatter."}) + if str(fm.get("status") or "").lower() == "deprecated": + return json.dumps( + {"error": f"skill '{name}' is deprecated — retire revoked it; re-create instead."} + ) + + skill_name = str(fm.get("name") or name) + description = str(fm.get("description") or "") + + # Effect-zone file: ONLY name/description frontmatter + body (design + # §4.2 install strip — the host reads the whole file into context; every + # management byte there is wasted tokens). + effect_file = _effect_zone_dir(output_dir) / name / "SKILL.md" + effect_content = ( + "---\n" + + f"name: {skill_name}\n" + + f"description: {description}\n" + + "---\n\n" + + body.strip() + + "\n" + ) + + import hashlib as _hashlib + + from codewiki.src.store import locked_rmw + + def _transform_draft(text: str) -> Optional[str]: + from codewiki.src.frontmatter import parse_frontmatter + + dfm, old_body = parse_frontmatter(text) + meta = dfm.get("metadata") if isinstance(dfm.get("metadata"), dict) else {} + meta["installed_at"] = _now_iso() + # installed_to points at the effect DIRECTORY (design §4.2 shape: + # ".codebuddy/skills/<name>/") — hand-built, NOT via _norm_rel whose + # lstrip("./") would eat the leading dot of .codebuddy/. + meta["installed_to"] = f".codebuddy/skills/{name}/" + meta["installed_hash"] = _normalized_hash(skill_name, description, body) + dfm["metadata"] = meta + return _render_skill_doc(dfm, old_body) + + try: + # Idempotent: rewriting the same effect content and re-stamping the + # draft is safe; content is deterministic from the draft alone. + effect_file.parent.mkdir(parents=True, exist_ok=True) + locked_write = None + from codewiki.src.store import locked_write as _lw + + locked_write = _lw + locked_write(effect_file, effect_content) + locked_rmw(draft, _transform_draft) + except OSError as e: + logger.warning("install failed for %s: %s", name, e) + return json.dumps({"error": f"install write failed: {e}"}) + + return json.dumps( + { + "status": "installed", + "mode": "install", + "name": name, + "installed_to": f".codebuddy/skills/{name}/", + "hash": _normalized_hash(skill_name, description, body), + "digest": _hashlib.sha256(effect_content.encode("utf-8")).hexdigest()[:12], + "message": ( + f"Skill '{name}' installed to the effect zone — the IDE will " + "discover it on its next scan. installed_at/to/hash stamped on " + "the draft (drift detection compares against this hash, #27)." + ), + }, + ensure_ascii=False, + indent=2, + ) + + +def _mode_retire(arguments: Dict[str, Any], output_dir: Path) -> str: + """Deprecate the draft + remove the effect-zone copy. Draft body stays.""" + name = str(arguments.get("name") or "").strip() + reason = str(arguments.get("reason") or "").strip() + if not name: + return json.dumps({"error": "retire requires 'name' and 'reason'."}) + if not reason: + return json.dumps( + {"error": "retire requires 'reason' (audit trail, design §4.1)."} + ) + draft = _skills_dir(output_dir) / name / "SKILL.md" + if not draft.is_file(): + return json.dumps({"error": f"draft skills/{name}/SKILL.md not found."}) + + from codewiki.src.store import locked_rmw + + def _transform(text: str) -> Optional[str]: + from codewiki.src.frontmatter import parse_frontmatter + + fm, body = parse_frontmatter(text) + fm["status"] = "deprecated" + meta = fm.get("metadata") if isinstance(fm.get("metadata"), dict) else {} + revisions = meta.get("revisions") + if not isinstance(revisions, list): + revisions = [] + revisions.append( + { + "at": _now_iso(), + "reason": f"retired: {reason}", + "source": "skill_creator", + } + ) + meta["revisions"] = revisions + meta.pop("installed_at", None) + meta.pop("installed_to", None) + meta.pop("installed_hash", None) + fm["metadata"] = meta + return _render_skill_doc(fm, body) + + # Effect-zone removal (absent = already clean; not an error). + removed_effect = False + effect_file = _effect_zone_dir(output_dir) / name / "SKILL.md" + if effect_file.is_file(): + try: + # Keep the body recoverable: move to system trash semantics are + # overkill for a derived file — the DRAFT still holds the full + # content, the effect copy is disposable. + effect_file.unlink() + removed_effect = True + except OSError as e: + logger.warning("effect-zone removal failed for %s: %s", name, e) + return json.dumps({"error": f"failed to remove effect copy: {e}"}) + + try: + locked_rmw(draft, _transform) + except OSError as e: + logger.warning("retire failed for %s: %s", name, e) + return json.dumps({"error": f"retire write failed: {e}"}) + + return json.dumps( + { + "status": "retired", + "mode": "retire", + "name": name, + "effect_removed": removed_effect, + "message": ( + f"Skill '{name}' retired: draft marked deprecated (body kept for " + "audit), effect-zone copy removed. The IDE stops discovering it." + ), + }, + ensure_ascii=False, + indent=2, + ) + + # --------------------------------------------------------------------------- # # Tool handler # --------------------------------------------------------------------------- # @@ -617,17 +808,12 @@ def handle_skill_creator(arguments: Dict[str, Any], store: Any) -> str: mode = str(arguments.get("mode") or "prepare").lower() if mode in ("install", "retire"): - return json.dumps( - { - "error": ( - f"mode='{mode}' is not implemented yet (T3, issue #26); " - "this build covers prepare and submit only." - ) - } + return _mode_install(arguments, output_dir) if mode == "install" else _mode_retire( + arguments, output_dir ) if mode not in ("prepare", "submit"): return json.dumps( - {"error": f"Invalid mode '{mode}'. Expected one of: prepare, submit."} + {"error": f"Invalid mode '{mode}'. Expected one of: prepare, submit, install, retire."} ) # ---- mode == "prepare" (zero side effects) ---- # diff --git a/tests/test_skill_creator.py b/tests/test_skill_creator.py index 5ebc060..726c89d 100644 --- a/tests/test_skill_creator.py +++ b/tests/test_skill_creator.py @@ -406,8 +406,6 @@ def test_submit_validation_rule_names(tmp_path): def test_submit_invalid_mode_and_report_shape(tmp_path): repo, od = _mk_repo(tmp_path) - resp = _call(repo, {"mode": "install", "name": "x"}) - assert "error" in resp and "issue #26" in resp["error"] resp = _call(repo, {"mode": "bogus"}) assert "error" in resp resp = _call(repo, {"mode": "submit"}) # missing report @@ -486,3 +484,127 @@ def test_submit_capacity_orange_blocks_create_allows_update(tmp_path): fm = _fm(od / "skills" / "cap-0" / "SKILL.md") assert fm["description"].startswith("When cap-0") assert fm["metadata"]["revisions"][-1]["reason"] == "orange update" + + +# --------------------------------------------------------------------------- # +# 5. install / retire (T3, issue #26) +# --------------------------------------------------------------------------- # +def test_install_strips_metadata_writes_hash_idempotent(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_skill_draft(od, "installable-skill") + + resp = _call(repo, {"mode": "install", "name": "installable-skill"}) + assert resp["status"] == "installed", resp + eff = Path(repo) / ".codebuddy" / "skills" / "installable-skill" / "SKILL.md" + assert eff.is_file() + + # effect-zone file: ONLY name/description + body (management strip) + eff_text = eff.read_text(encoding="utf-8") + assert eff_text.startswith("---\n") + head = eff_text[: eff_text.find("\n---", 3)] + assert "name: installable-skill" in head + assert "description:" in head + for banned in ("type:", "status:", "generated:", "stale_after:", "metadata:"): + assert banned not in head, banned + assert "## 工作场景" in eff_text # body survived + + # draft got the installed_* triple stamped + fm = _fm(od / "skills" / "installable-skill" / "SKILL.md") + meta = fm["metadata"] + assert meta["installed_to"] == ".codebuddy/skills/installable-skill/" + assert meta["installed_at"] + assert meta["installed_hash"].startswith("sha256:") + + # idempotent: second install, same hash, no duplicate side effects + resp2 = _call(repo, {"mode": "install", "name": "installable-skill"}) + assert resp2["status"] == "installed" + assert resp2["hash"] == resp["hash"] + fm2 = _fm(od / "skills" / "installable-skill" / "SKILL.md") + assert fm2["metadata"]["installed_hash"] == meta["installed_hash"] + + +def test_install_draft_revision_rewrites_effect_zone(tmp_path): + """After a draft update, install must refresh the effect copy (reinstall).""" + repo, od = _mk_repo(tmp_path) + _write_skill_draft(od, "iter-skill") + _call(repo, {"mode": "install", "name": "iter-skill"}) + note = _write_note(od, "more-note") + _call( + repo, + { + "mode": "submit", + "report": { + "skills": [ + _skill_entry( + "iter-skill", + action="updated", + description="When iter-skill v2 fires, run the v2 SOP now", + source_refs=[note], + revision_note="v2", + ) + ] + }, + }, + ) + resp = _call(repo, {"mode": "install", "name": "iter-skill"}) + assert resp["status"] == "installed" + eff = (Path(repo) / ".codebuddy" / "skills" / "iter-skill" / "SKILL.md").read_text( + encoding="utf-8" + ) + assert "run the v2 SOP now" in eff # effect zone reflects the revision + + +def test_install_errors(tmp_path): + repo, od = _mk_repo(tmp_path) + resp = _call(repo, {"mode": "install", "name": "ghost"}) + assert "error" in resp + resp = _call(repo, {"mode": "install"}) + assert "error" in resp + # deprecated drafts refuse to install + _write_skill_draft(od, "dead-skill") + draft = od / "skills" / "dead-skill" / "SKILL.md" + text = draft.read_text(encoding="utf-8") + draft.write_text(text.replace("status: draft", "status: deprecated"), encoding="utf-8") + resp = _call(repo, {"mode": "install", "name": "dead-skill"}) + assert "error" in resp and "deprecated" in resp["error"] + + +def test_retire_marks_draft_removes_effect_keeps_body(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_skill_draft(od, "bye-skill") + _call(repo, {"mode": "install", "name": "bye-skill"}) + eff = Path(repo) / ".codebuddy" / "skills" / "bye-skill" / "SKILL.md" + assert eff.is_file() + + resp = _call(repo, {"mode": "retire", "name": "bye-skill", "reason": "superseded by X"}) + assert resp["status"] == "retired", resp + assert resp["effect_removed"] is True + + # draft body kept, status deprecated, revision records the retire reason + assert not eff.exists() + draft = od / "skills" / "bye-skill" / "SKILL.md" + assert draft.is_file() + fm = _fm(draft) + assert fm["status"] == "deprecated" + reasons = [r["reason"] for r in fm["metadata"]["revisions"]] + assert any("superseded by X" in r for r in reasons) + # installed_* triple cleared + for key in ("installed_at", "installed_to", "installed_hash"): + assert key not in fm["metadata"] + # retire is idempotent on the effect zone (already gone is fine) + resp2 = _call(repo, {"mode": "retire", "name": "bye-skill", "reason": "again"}) + assert resp2["status"] == "retired" + assert resp2["effect_removed"] is False + + # and a retired draft can no longer be installed + resp3 = _call(repo, {"mode": "install", "name": "bye-skill"}) + assert "error" in resp3 + + +def test_retire_requires_reason(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_skill_draft(od, "reason-skill") + resp = _call(repo, {"mode": "retire", "name": "reason-skill"}) + assert "error" in resp and "reason" in resp["error"] + resp = _call(repo, {"mode": "retire", "name": "ghost", "reason": "x"}) + assert "error" in resp From c1beee982fddc6824557000c9e5b12b1a3bdf56f Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 18:08:33 +0800 Subject: [PATCH 12/23] =?UTF-8?q?feat(mcp):=20skill=5Flint=20=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E5=85=9C=E5=BA=95=E6=A3=80=E6=9F=A5=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=85=AD=20error=20+=20=E7=B4=A0=E6=9D=90=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E8=81=94=E5=8A=A8=20+=20=E6=BC=82=E7=A7=BB=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=EF=BC=88T4,=20#27=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计 §4.5 八项全量落地(submit 校验的 backstop,捕捉工具外手写/手改 的草稿): - errors:name_slug / description_trigger / frontmatter_required(status+ source_refs)/ body ≤8KB / sensitive_content(绝对路径+密钥,与 submit 共享 _sensitive_scan 单点实现,两层永不漂移)/ revisions_required - warnings:skill_possibly_stale(素材 deprecated/软删/缺失,设计 Q5 对端新鲜度联动,只提示不自动修订);skill_drift(草稿规范化哈希 ≠ installed_hash——install 后草稿又被修订,生效区仍旧版,设计 Q6, reinstall 留给用户) - 容量:红 12 error / 橙 9 warning(阈值 import skill_creator 常量, 单点收敛;deprecated 不计数) - registry 枚举 + 描述、README 同步;测试 6 例(含六 error 各自正反、 素材三种退化形态、漂移、容量分级) Closes #27 --- README.md | 1 + codewiki/mcp/registry.py | 8 +- codewiki/mcp/tools/skill_creator.py | 30 ++-- codewiki/mcp/tools/wiki_lint.py | 231 ++++++++++++++++++++++++++++ tests/test_skill_pages.py | 138 +++++++++++++++++ 5 files changed, 398 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 94e51d7..6e23e66 100644 --- a/README.md +++ b/README.md @@ -565,6 +565,7 @@ CodeWiki-Plus 采用 **SQLite 主存储 + JSON 兼容副本** 的双层架构: | `scenario_capacity` | L2 场景块数量达到/超过容量上限(error/warning 分级),需先 MERGE 腾位再新增 | | `scenario_orphan` | 无来源标注(metadata.source_notes)且长期未被检索的孤儿场景块,可能冗余或过时 | | `skill_sections` | 草稿区技能页(skills/<name>/SKILL.md)缺失 schema `page_types.skill` 五段骨架(工作场景/适用条件/核心 SOP/判断逻辑/禁忌与反模式)——缺章节的指令资产不可执行(error) | +| `skill_lint` | 技能页完整兜底检查:name slug / description 触发语义 / frontmatter 完整 / 正文 ≤8KB / 敏感串 / revisions 审计链(error);素材过期联动 possibly_stale 与 install 后草稿漂移(warning);容量红 12 / 橙 9 | `lint_wiki` 返回 **health_score**(0-100),计算方式为 `100 - Σ(error×10 + warning×3 + info×1)`。 diff --git a/codewiki/mcp/registry.py b/codewiki/mcp/registry.py index d6bd4e3..93c2a60 100644 --- a/codewiki/mcp/registry.py +++ b/codewiki/mcp/registry.py @@ -788,7 +788,12 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "recent retrieval — possibly redundant or outdated), " "skill_sections (draft-zone SKILL.md pages missing the schema " "page_types.skill five-section skeleton — an instruction asset without " - "its sections is not actionable). " + "its sections is not actionable), " + "skill_lint (full SKILL.md backstop: name slug / description " + "trigger semantics / frontmatter completeness / 8KB body cap / " + "sensitive strings / revisions audit trail; warnings for possibly-" + "stale source materials and draft-vs-effect-zone drift after " + "install). " "Run checks=['all'] for a comprehensive audit. " "After fixing issues, use flag_issue to track remaining problems. " "MANDATORY FINAL STEP: after lint passes (or issues are tracked), you MUST call " @@ -829,6 +834,7 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "scenario_capacity", "scenario_orphan", "skill_sections", + "skill_lint", "layout_violations", "team_layout_gitignore", ], diff --git a/codewiki/mcp/tools/skill_creator.py b/codewiki/mcp/tools/skill_creator.py index f74d0f4..72a2018 100644 --- a/codewiki/mcp/tools/skill_creator.py +++ b/codewiki/mcp/tools/skill_creator.py @@ -87,6 +87,20 @@ ("secret_key", _SECRET_KEY_RE), ) + +def _sensitive_scan(text: str) -> Optional[Tuple[str, str]]: + """Return (kind, matched_string) of the first sensitive hit, else None. + + Shared by submit validation and the wiki_lint backstop (#27) so the two + layers can never drift apart on what counts as sensitive. + """ + for kind, pattern in _SENSITIVE_PATTERNS: + m = pattern.search(text or "") + if m: + return (kind, m.group(0)) + return None + + _CJK_RE = re.compile(r"[\u4e00-\u9fff]") _WORD_RE = re.compile(r"[a-z0-9]+") @@ -468,15 +482,13 @@ def _err(rule: str, message: str) -> Tuple[Dict[str, Any], Dict[str, Any]]: return _err("source_refs_required", "source_refs must contain at least one path.") # Sensitive strings: absolute paths / secret keys in description or body. - scan_text = f"{description}\n{body}" - for kind, pattern in _SENSITIVE_PATTERNS: - m = pattern.search(scan_text) - if m: - return _err( - "sensitive_content", - f"sensitive {kind} pattern matched ('{m.group(0)}'); absolute " - "paths and secrets are forbidden in skill bodies.", - ) + scan_hit = _sensitive_scan(f"{description}\n{body}") + if scan_hit: + return _err( + "sensitive_content", + f"sensitive {scan_hit[0]} pattern matched ('{scan_hit[1]}'); absolute " + "paths and secrets are forbidden in skill bodies.", + ) path = _skills_dir(output_dir) / name / "SKILL.md" if action == "created" and path.is_file(): diff --git a/codewiki/mcp/tools/wiki_lint.py b/codewiki/mcp/tools/wiki_lint.py index 68159a5..39043e9 100644 --- a/codewiki/mcp/tools/wiki_lint.py +++ b/codewiki/mcp/tools/wiki_lint.py @@ -47,6 +47,9 @@ # skill-creator (issue #24, ADR-0004): draft-zone SKILL.md section # conformance against schema.page_types.skill.required_sections "skill_sections", + # skill-creator T4 (issue #27, design §4.5): full SKILL.md check set — + # six backstop errors + possibly_stale material linkage + install drift + "skill_lint", # P1 B-line: hot-but-never-adopted notes (usage utility dimension) "low_adoption", # Centralized-layout discipline (ticket 09) @@ -1636,6 +1639,229 @@ def _check_skill_sections(output_dir: Path) -> List[Dict[str, Any]]: return issues +def _check_skill_lint(output_dir: Path) -> List[Dict[str, Any]]: + """skill-creator T4 (issue #27, design §4.5): the full SKILL.md check set. + + Backstop lint — submit already validates most of these rules; lint + catches drafts written outside the tool or regressed by hand edits: + + errors: name_slug / description_trigger / frontmatter_required / + body_too_large / sensitive_content / revisions_required; capacity at + the red line. warnings: skill_possibly_stale (a source_refs material + is deprecated / soft-deleted / missing, design Q5 — prompt a human + revise/retire decision, never auto), skill_drift (draft normalized + hash != installed_hash: revised after install, the effect zone still + serves the old version; reinstall is a user action, ADR-0004), + capacity orange. + """ + issues: List[Dict[str, Any]] = [] + try: + from codewiki.mcp.tools.skill_creator import ( + _BODY_LIMIT_BYTES, + _MAX_SKILLS, + _ORANGE_SKILLS, + _normalized_hash, + _read_body, + _read_frontmatter, + _scan_skills, + _sensitive_scan, + ) + from codewiki.src.store import slugify + except Exception: + return issues + + skills = _scan_skills(output_dir) + if not skills: + return issues + + # --- per-skill errors / warnings --- + for s in skills: + rel = s["file"] + path = output_dir / rel + fm = _read_frontmatter(path) or {} + meta = fm.get("metadata") if isinstance(fm.get("metadata"), dict) else {} + name = str(fm.get("name") or "") + body = _read_body(path) + body_bytes = len(body.encode("utf-8")) + description = str(fm.get("description") or "") + + # name_slug: slugify(name) == directory name + dirname = path.parent.name + if not name or slugify(name) != dirname: + issues.append( + { + "check": "skill_lint", + "severity": "error", + "message": ( + f"Skill '{rel}': name '{name}' is not a slug match for " + f"its directory '{dirname}'." + ), + "file": rel, + "suggestion": "Rename so slugify(name) equals the directory name.", + } + ) + + # description_trigger: non-empty, >= 10 chars (submit's same heuristic) + if len(description.strip()) < 10: + issues.append( + { + "check": "skill_lint", + "severity": "error", + "message": ( + f"Skill '{rel}': description missing or too short to " + "carry trigger semantics (condition + action)." + ), + "file": rel, + "suggestion": "Rewrite as '<condition> — <specific action>'.", + } + ) + + # frontmatter_required: status + metadata.source_refs + status = str(fm.get("status") or "") + refs = meta.get("source_refs") + refs = refs if isinstance(refs, list) else [] + missing = [] + if status not in ("draft", "stable", "deprecated"): + missing.append("status") + if not refs: + missing.append("metadata.source_refs") + if missing: + issues.append( + { + "check": "skill_lint", + "severity": "error", + "message": f"Skill '{rel}': missing required frontmatter: {', '.join(missing)}.", + "file": rel, + "suggestion": "skill_creator submit writes these; hand-edited drafts need them too.", + } + ) + + # body_too_large + if body_bytes > _BODY_LIMIT_BYTES: + issues.append( + { + "check": "skill_lint", + "severity": "error", + "message": f"Skill '{rel}': body is {body_bytes} bytes (cap {_BODY_LIMIT_BYTES}).", + "file": rel, + "suggestion": "Split the skill or reference the scenario instead of restating it.", + } + ) + + # sensitive_content (description + body) + scan_hit = _sensitive_scan(f"{description}\n{body}") + if scan_hit: + issues.append( + { + "check": "skill_lint", + "severity": "error", + "message": ( + f"Skill '{rel}': sensitive {scan_hit[0]} pattern matched " + f"('{scan_hit[1]}')." + ), + "file": rel, + "suggestion": "Remove absolute paths and secrets; use repo-relative references.", + } + ) + + # revisions_required: generated provenance must carry an audit trail + revisions = meta.get("revisions") + revisions = revisions if isinstance(revisions, list) else [] + if not revisions and fm.get("generated"): + issues.append( + { + "check": "skill_lint", + "severity": "error", + "message": ( + f"Skill '{rel}': has generated provenance but no " + "metadata.revisions audit trail." + ), + "file": rel, + "suggestion": "Re-submit via skill_creator (updated) so the revision lands in revisions.", + } + ) + + # skill_possibly_stale (warning, design Q5) + for ref in refs: + ref_norm = str(ref).replace("\\", "/") + target = output_dir / ref_norm + stale_reason = None + if not target.is_file(): + stale_reason = "material file missing" + else: + mfm = _read_frontmatter(target) or {} + m_status = str(mfm.get("status") or "").lower() + if m_status in ("deprecated", "superseded", "rejected"): + stale_reason = f"material status={m_status}" + elif _read_body(target) == "[DELETED]": + stale_reason = "material soft-deleted" + if stale_reason: + issues.append( + { + "check": "skill_lint", + "severity": "warning", + "message": ( + f"Skill '{rel}' possibly stale: {stale_reason} " + f"(source: {ref_norm})." + ), + "file": rel, + "suggestion": "Review the skill; revise against current material or retire it.", + } + ) + break # one warning per skill is enough to trigger review + + # skill_drift (warning, design Q6): draft revised after install + installed_hash = meta.get("installed_hash") + if installed_hash: + current = _normalized_hash(name, description, body) + if current != installed_hash: + issues.append( + { + "check": "skill_lint", + "severity": "warning", + "message": ( + f"Skill '{rel}' drifted: the draft was revised after " + "install — the effect zone still serves the old version." + ), + "file": rel, + "suggestion": ( + "Re-run skill_creator(mode='install') after user " + "review (reinstall is a user action)." + ), + } + ) + + # --- capacity (mirror skill_creator grading; deprecated don't count) --- + live = [s for s in skills if s.get("status") != "deprecated"] + if len(live) >= _MAX_SKILLS: + issues.append( + { + "check": "skill_lint", + "severity": "error", + "message": ( + f"Draft skills at/over capacity: {len(live)}/{_MAX_SKILLS} " + "— merge or retire before creating more." + ), + "file": "skills/", + "suggestion": "Retire or merge similar skills first (skill_creator retire).", + } + ) + elif len(live) >= _ORANGE_SKILLS: + issues.append( + { + "check": "skill_lint", + "severity": "warning", + "message": ( + f"Draft skills near capacity: {len(live)}/{_MAX_SKILLS} " + f"(orange line {_ORANGE_SKILLS}) — UPDATE only." + ), + "file": "skills/", + "suggestion": "Default to UPDATE on the next skill_creator run.", + } + ) + return issues + + # --------------------------------------------------------------------------- # OKF v0.2 conformance (§11 / §12) # --------------------------------------------------------------------------- @@ -2197,6 +2423,11 @@ def handle_lint_wiki( # schema.yaml inside the check; dispatch passes no hardcoded values. all_issues.extend(_check_skill_sections(output_dir)) + if "skill_lint" in checks and output_dir: + # skill-creator T4 (issue #27): thresholds are imported from + # skill_creator constants — single source, no hardcoded copies. + all_issues.extend(_check_skill_lint(output_dir)) + if "okf_conformance" in checks and output_dir: all_issues.extend( _check_okf_conformance( diff --git a/tests/test_skill_pages.py b/tests/test_skill_pages.py index e248a26..3be2908 100644 --- a/tests/test_skill_pages.py +++ b/tests/test_skill_pages.py @@ -305,3 +305,141 @@ def test_search_expanded_terms_do_not_leak_skill(tmp_path): assert not [r for r in results if str(r["file"]).startswith("skills/")] finally: cache.close() + + +# --------------------------------------------------------------------------- # +# 6. T4 full SKILL.md check set (issue #27, design §4.5) +# --------------------------------------------------------------------------- # +def _write_raw_skill( + od: Path, + name: str, + *, + fm_overrides: dict | None = None, + body: str | None = None, +) -> Path: + """Hand-written draft (bypasses skill_creator submit) for backstop lint.""" + sk_dir = od / "skills" / name + sk_dir.mkdir(parents=True, exist_ok=True) + fm = { + "name": name, + "description": f"When {name} fires, run the recovery SOP", + "type": "Skill", + "status": "draft", + "generated": {"by": "codewiki/test", "at": "2026-09-06T00:00:00Z"}, + "metadata": { + "source_refs": ["notes/any-note.md"], + "revisions": [{"at": "2026-09-06T00:00:00Z", "reason": "test", "source": "test"}], + }, + } + if fm_overrides: + for k, v in fm_overrides.items(): + if k == "metadata": + fm["metadata"].update(v) + else: + fm[k] = v + text_body = body or "\n".join(f"## {s}\n\ncontent" for s in _SECTIONS) + p = sk_dir / "SKILL.md" + p.write_text( + "---\n" + yaml.safe_dump(fm, allow_unicode=True) + "---\n\n" + text_body + "\n", + encoding="utf-8", + ) + return p + + +def _lint_skill(repo: str) -> list[dict]: + return [i for i in _lint(repo, ["skill_lint"]) if i["check"] == "skill_lint"] + + +def test_skill_lint_clean_draft_passes(tmp_path): + repo, od = _mk_repo(tmp_path) + _write_raw_skill(od, "clean-skill") + issues = [i for i in _lint_skill(repo) if i["severity"] == "error"] + assert issues == [] + + +def test_skill_lint_six_error_rules(tmp_path): + repo, od = _mk_repo(tmp_path) + # name_slug mismatch + _write_raw_skill(od, "dir-a", fm_overrides={"name": "totally-different"}) + # description too short + _write_raw_skill(od, "short-desc", fm_overrides={"description": "do it"}) + # frontmatter incomplete (no status, no source_refs) + _write_raw_skill( + od, + "no-fm", + fm_overrides={"status": "", "metadata": {"source_refs": []}}, + ) + # body too large + _write_raw_skill(od, "big-body", body="## 工作场景\n" + "x" * 9000) + # sensitive content + _write_raw_skill(od, "leaky", body="## 工作场景\nrun C:\\Users\\john\\s.ps1") + # revisions missing though generated + _write_raw_skill( + od, "no-rev", fm_overrides={"metadata": {"revisions": []}} + ) + issues = _lint_skill(repo) + errors = {(i["file"], i["message"].split(":")[0]) for i in issues if i["severity"] == "error"} + rels = {f for f, _ in errors} + assert "skills/dir-a/SKILL.md" in rels + assert "skills/short-desc/SKILL.md" in rels + assert "skills/no-fm/SKILL.md" in rels + assert "skills/big-body/SKILL.md" in rels + assert "skills/leaky/SKILL.md" in rels + assert "skills/no-rev/SKILL.md" in rels + + +def test_skill_lint_possibly_stale(tmp_path): + repo, od = _mk_repo(tmp_path) + # material note exists and is stable -> no warning + (od / "notes").mkdir(exist_ok=True) + (od / "notes" / "mat.md").write_text( + "---\ntype: pitfall\ntitle: mat\nstatus: stable\n---\nbody\n", encoding="utf-8" + ) + _write_raw_skill(od, "fresh-skill", fm_overrides={"metadata": {"source_refs": ["notes/mat.md"]}}) + assert not [i for i in _lint_skill(repo) if "stale" in i["message"]] + + # material goes deprecated -> warning + (od / "notes" / "mat.md").write_text( + "---\ntype: pitfall\ntitle: mat\nstatus: deprecated\n---\nbody\n", encoding="utf-8" + ) + stale = [i for i in _lint_skill(repo) if "possibly stale" in i["message"]] + assert len(stale) == 1 and stale[0]["severity"] == "warning" + + # material removed entirely -> warning too + (od / "notes" / "mat.md").unlink() + stale = [i for i in _lint_skill(repo) if "possibly stale" in i["message"]] + assert len(stale) == 1 + + +def test_skill_lint_drift_after_revision(tmp_path): + repo, od = _mk_repo(tmp_path) + from codewiki.mcp.tools.skill_creator import _normalized_hash + + _write_raw_skill(od, "drifty-skill") + # simulate install stamping: hash of current content + p = od / "skills" / "drifty-skill" / "SKILL.md" + text = p.read_text(encoding="utf-8") + h = _normalized_hash( + "drifty-skill", "When drifty-skill fires, run the recovery SOP", "## x" + ) + fm = yaml.safe_load(text[3 : text.find("---", 3)]) + fm["metadata"]["installed_hash"] = h + p.write_text( + "---\n" + yaml.safe_dump(fm, allow_unicode=True) + "---\n\n## revised body\n", + encoding="utf-8", + ) + drift = [i for i in _lint_skill(repo) if "drifted" in i["message"]] + assert len(drift) == 1 and drift[0]["severity"] == "warning" + assert "effect zone still serves the old version" in drift[0]["message"] + + +def test_skill_lint_capacity_grading(tmp_path): + repo, od = _mk_repo(tmp_path) + for i in range(_ORANGE := 9): + _write_raw_skill(od, f"cap-{i}") + warns = [i for i in _lint_skill(repo) if "near capacity" in i["message"]] + assert len(warns) == 1 and warns[0]["severity"] == "warning" + for i in range(9, 12): + _write_raw_skill(od, f"cap-{i}") + errs = [i for i in _lint_skill(repo) if "at/over capacity" in i["message"]] + assert len(errs) == 1 and errs[0]["severity"] == "error" From 30a86b0d90293ed74bd0d014b88b2ea31346b48b Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 18:20:11 +0800 Subject: [PATCH 13/23] =?UTF-8?q?docs:=20skill-creator=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=8C=87=E5=8D=97=EF=BC=88=E7=94=A8=E6=88=B7=E8=A7=86?= =?UTF-8?q?=E8=A7=92=E5=AE=8C=E6=95=B4=E5=B7=A5=E4=BD=9C=E6=B5=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 六步工作流(prepare 候选/撰写纪律/submit 规则速查/lint/install 哈希 契约/反馈退役)、两区制机制图(mermaid)、submit 校验规则名速查表、 真实示例(GitHub push 交替重试经验全链 + flag_issue 修订续篇)、 边界与限制(素材边界/容量/防碎片/生效区兼容性)。 T1-T5 全部落地后的用户入口文档(issues #24-#28)。 --- ...77\347\224\250\346\214\207\345\215\227.md" | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 "docs/skill-creator\344\275\277\347\224\250\346\214\207\345\215\227.md" diff --git "a/docs/skill-creator\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/skill-creator\344\275\277\347\224\250\346\214\207\345\215\227.md" new file mode 100644 index 0000000..99b34b9 --- /dev/null +++ "b/docs/skill-creator\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -0,0 +1,248 @@ +# skill-creator 使用指南:把团队经验编译成 IDE 可触发的技能 + +> 对应实现:`skill_creator` 四 mode(issues #24-#28,commits b12e98c/3db4827/64fb95e/e08dae6/c1beee9)· +> 设计文档:[skill-creator需求与设计方案](skill-creator需求与设计方案.md) · 架构决策:[ADR-0004](adr/0004-skill-scenario-split-two-zone-gate.md) +> +> 适用版本:CodeWiki v5.7+(codewiki MCP 49 工具) + +## 这是什么 + +你在项目里沉淀了大量经验——CodeWiki 的知识管线已经把这些经验蒸馏成笔记(notes)、聚合为场景块(scenarios)。但这些都是**检索知识**:只有当 Agent 主动 `query_wiki` 时才被查阅,记不记得查、查没查到,全看缘分。 + +`skill_creator` 把这些已确认知识再往前推一步:**编译成 SKILL.md 行为指令**。技能文件放在 IDE 的技能发现目录里,宿主(CodeBuddy / Claude Code / Codex 等)在每次会话中按 description 自动判断是否加载——经验从"被动等人查"变成"主动改变 Agent 行为"。 + +一句话区分: + +| | scenario(场景块) | skill(技能) | +|---|---|---| +| 本质 | 检索知识 | 行为指令 | +| 消费方 | Agent 主动 `query_wiki` 查阅 | 宿主 IDE 按 description 自动触发 | +| 生成工具 | `consolidate_notes` | `skill_creator` | +| 落盘位置 | `repowiki/wiki/scenarios/` | 草稿区 `repowiki/skills/` → 生效区 `.codebuddy/skills/` | +| 关系 | **上游**(技能的素材源) | 下游(source_refs 回链素材) | + +## 核心机制:两区制 + +技能直接生效意味着风险——一份有错误指引的技能会污染所有后续会话。所以技能的生命周期被**物理隔离**成两个区(ADR-0004): + +```mermaid +flowchart LR + A[已确认知识<br/>scenarios + notes + open issues] -->|skill_creator prepare| B[候选素材清单] + B -->|Agent 撰写<br/>五段骨架正文| C[skill_creator submit] + C --> D[草稿区<br/>repowiki/skills/<name>/SKILL.md<br/>进索引进 lint · 不生效] + D -->|用户审阅确认<br/>skill_creator install| E[生效区<br/>.codebuddy/skills/<name>/SKILL.md<br/>IDE 发现即生效] + E -->|试用发现问题| F[flag_issue 打标] + F -->|下次 prepare 聚合 issues| B + D -->|技能过时/有害<br/>skill_creator retire| G[deprecated 归档<br/>正文保留审计] + E -->|retire 同步移除| G +``` + +- **草稿区**(`repowiki/skills/`):与 notes/ 同层的知识资产。进搜索索引、进 lint 扫描,但 IDE 永远不会扫描 repowiki 内部——**草稿物理上不生效**,`status: draft` 标记没有机制强制力,目录边界才是闸门。 +- **生效区**(仓库根 `.codebuddy/skills/`):宿主 IDE 的技能发现目录。只有 `install` 这个**用户显式动作**才会把技能写进去;工具永不自动安装、修订后也永不自动覆盖(漂移只告警,reinstall 由人决定)。 + +**注意**:草稿区技能虽然进索引,但 `query_wiki` **不会召回**它们(检索隔离,#28)——防止 Agent 把行为指令当检索知识引用。 + +## 完整工作流 + +以下全部动作通过 MCP 工具完成(CodeBuddy / 千问办公等宿主直接对 codewiki MCP 调用),推荐直接对 Agent 说"把经验编成技能"触发 [skill-creator 工作流 prompt](#附带资源)。 + +### 第 1 步:准备(零副作用) + +``` +skill_creator(mode="prepare", repo_path="<仓库路径>", topic="计划中的技能名") +``` + +返回内容(**纯只读,可放心反复调用**): + +| 字段 | 用途 | +|---|---| +| `candidates.scenarios` / `candidates.notes` | 未被任何技能吸收的素材(场景块 + stable 状态的 pitfall/lesson/decision 笔记),带 est_tokens 阅读成本 | +| `open_issues_by_skill` | 既有技能名下的 open issues(试用反馈的修订输入) | +| `skills_index` | 草稿区现有技能清单(name/status/summary) | +| `conflict_precheck.warnings` | topic 与既有技能 name/description 的 Jaccard 相似度 >0.6 —— 命中说明该 UPDATE 而不是新建 | +| `capacity` | 容量分级:绿 / 橙(≥9 份只准 UPDATE)/ 红(≥12 份先合并退役) | +| `system_prompt` | 写作规范全文(description=条件+行动、五段骨架、≤8KB、证据回链) | +| `fragmentation_discipline` | 防碎片纪律(默认 UPDATE、每批最多新建 1 份、新建前对比 ≥2 份相似技能) | + +`topic` 建议传:新建前预检冲突是防碎片的第一道闸。 + +### 第 2 步:Agent 撰写正文 + +这一步由宿主 Agent 完成(Mode C:工具不内嵌 LLM,正文由调用方产出)。产出的 SKILL.md 需满足: + +**frontmatter**(submit 自动生成): + +```yaml +--- +name: <slug> # slugify 后与目录名一致 +description: <条件 + 具体行动> # 一句话,IDE 据此判定是否触发 +type: Skill +status: draft +generated: { by: codewiki/<ver>, at: <ts> } +stale_after: <90 天后> +metadata: + summary: <40 字内> + source_refs: [wiki/scenarios/xxx.md, notes/yyy.md] # 溯源回链 + revisions: [{ at, reason, source }] # 审计链,每次修订追加 +--- +``` + +**正文五段骨架**(与 scenario 同构,schema 强制): + +```markdown +## 工作场景 ← 这份技能解决什么问题 +## 适用条件 ← When to Apply:什么时候触发 +## 核心 SOP ← Instructions:具体步骤 +## 判断逻辑 ← 遇到分叉怎么选 +## 禁忌与反模式 ← When NOT to Apply:千万别做什么 +``` + +写作纪律(submit 会拒收违规项): + +1. **description = 触发条件 + 具体行动**。对比:❌「数据库技能」 ✅「当 Agent 遇到 SQLite database is locked 报错时——先查长事务持有写锁,再设 busy_timeout,勿直接重试」 +2. **正文 ≤8KB**。超限说明该拆分,或引用 scenario 而非复述。 +3. **禁绝对路径与密钥**(`/Users/…`、`C:\…`、`sk-…` 模式都会被拒收)。 +4. **证据段带笔记回链**:不止写 source_refs 路径,正文里写"依据:notes/xxx.md 的 Y 结论"。 + +### 第 3 步:提交 + +``` +skill_creator(mode="submit", repo_path="<仓库路径>", report={ + "skills": [{ + "name": "<slug>", + "action": "created", # 或 updated + "description": "当…时——先…再…", + "body": "<五段正文>", + "source_refs": ["wiki/scenarios/xxx.md", "notes/yyy.md"], + "summary": "<40 字内>", + "revision_note": "<修订原因,updated 时建议附>" + }] +}) +``` + +行为要点: + +- **先全量校验后落盘**——任何一条违规,整批不落盘,失败返回**具体规则名**(按下表修正后重交): + +| 规则名 | 含义 | +|---|---| +| `name_slug` | name 不是合法 slug 或与目录名不一致 | +| `description_trigger` | description 太短(<10 字符)承载不了触发语义 | +| `body_too_large` | 正文超 8KB | +| `body_required` | 正文为空 | +| `sensitive_content` | 绝对路径或密钥 | +| `source_refs_required` / `source_refs_invalid` | 无素材溯源 / 路径逃逸出 repowiki | +| `name_conflict` / `not_found` | created 撞已有名 / updated 找不到草稿 | +| `batch_fragmentation` | 一批想新建多份(最多 1) | +| `capacity_orange` / `capacity_red` | 容量橙线只准 UPDATE / 红线禁止新建 | + +- **成功后自动**:写双向溯源互链(技能 `source_refs` ⇄ 素材 `metadata.compiled_into`)、追加 revisions、重建搜索索引。 +- **空产出合法**:素材不足不值得编译时,提交空 report `{"skills": []}` 返回 `no_action`——这不是失败,是诚实(wikiskill 的 no_action 同款语义)。 + +### 第 4 步:验证 + +``` +lint_wiki(repo_path="<仓库路径>", checks=["skill_sections", "skill_lint"]) +``` + +两项检查(#24/#27): + +- `skill_sections`(error):五段骨架缺章节。 +- `skill_lint`(error 兜底 + warning):六项 error(name slug / description 语义 / frontmatter 完整 / 8KB / 敏感串 / revisions 审计链)+ 三项 warning(素材过期联动 possibly_stale、install 后草稿漂移 drift、容量橙线)。submit 校验过的草稿通常全绿,这项主要捕捉手改回归。 + +### 第 5 步:安装(仅用户确认后) + +草稿审阅通过、**你明确同意后**(这是产品语义:生效是用户动作,工具不代劳): + +``` +skill_creator(mode="install", repo_path="<仓库路径>", name="<slug>") +``` + +install 做三件事: + +1. **剥离全部管理元数据**——生效区文件只留 `name` / `description` / 正文(宿主会把整个文件读进上下文,管理字节在那里全是浪费 token); +2. **装入生效区** `.codebuddy/skills/<name>/SKILL.md`,IDE 下次扫描即可发现; +3. **写回审计三元组**到草稿:`installed_at` / `installed_to` / `installed_hash`。 + +`installed_hash` 是**规范化哈希**(sha256(name+description+body))——因为草稿区带管理元数据、生效区没有,整文件哈希永远对不上;规范化哈希只比对实质内容,是后续漂移检测的比对契约。 + +install **幂等**:重复调用无副作用;草稿修订后重新 install 即刷新生效区。 + +### 第 6 步:试用、反馈与修订 + +安装后正常干活即可,技能会在匹配场景自动生效。两条反馈回路: + +**发现问题(负面反馈)**: + +``` +flag_issue(page_path="skills/<name>/SKILL.md", issue_type="skill-ineffective", description="...") +``` + +下次 `prepare` 会自动聚合该技能名下的 open issues 作为修订素材 → submit(action=updated,revisions 留痕)→ reinstall。**不需要**为技能学新的反馈工具——flag_issue 就是全库统一的"对资产提问题"通道。 + +**技能过时/有害**: + +``` +skill_creator(mode="retire", repo_path="<仓库路径>", name="<slug>", reason="被 X 取代") +``` + +retire:草稿标 deprecated(revisions 记录原因、正文**保留供审计**、installed_* 清除)+ 生效区移除(IDE 不再发现)。`reason` 必填。退役后的草稿拒绝重新 install——要复活就走正常 submit 重建。 + +**好用(正面反馈)**:沉默即默认。不 flag 不 retire 就是认可,无需任何操作(IDE 不回报技能触发次数,没有自动统计数据源,这是有意取舍)。 + +## 两条自动守护 + +你不需要主动记得这些,但值得知道它们在后台工作: + +- **素材过期联动**:技能 source_refs 指向的 scenario/note 后来被更新、退役或删除 → lint 报 `possibly_stale` warning(复用对端新鲜度语义),提醒你决定 revise 还是 retire。只提示,**不自动修订**。 +- **漂移检测**:install 之后草稿又被修订(submit updated)→ 草稿规范化哈希 ≠ installed_hash → lint 报 drift warning:"生效区仍旧版,建议 reinstall"。**不自动覆盖生效区**——生效永远是用户动作。 + +## 完整示例:一次真实编译 + +```text +你:把这个项目里"GitHub 网络不稳定怎么推代码"的经验编成一个技能 + +Agent:skill_creator(mode="prepare", topic="github push retry") + ← 候选:1 个场景块(repowiki/wiki/scenarios/对话蒸馏管线与raw暂存区.md) + + 2 条 stable 笔记(fork-PR 合入、网络双通道交替重试) + ← conflict_precheck: 无相似技能;capacity: green (0/12) + +Agent:(读素材,撰写五段正文,description = + "当 git push 因 connection reset / 443 timeout 失败时——在 + git push(走代理)与 git -c http.proxy= -c https.proxy= push + (直连)之间交替重试几轮;勿改全局代理配置") + +Agent:skill_creator(mode="submit", report={skills: [{ + name: "github-push-retry", action: "created", ...}]}) + ← completed:skills/github-push-retry/SKILL.md 落草稿区, + 3 份素材获得 compiled_into 回链 + +Agent:lint_wiki(checks=["skill_sections", "skill_lint"]) → 全绿 + → 向你展示草稿全文 + +你:可以,装上 +Agent:skill_creator(mode="install", name="github-push-retry") + ← installed:.codebuddy/skills/github-push-retry/SKILL.md + (只含 name/description/正文) + +【从此每个会话,遇到 push 失败场景宿主自动带上这条 SOP】 + +两周后你:这个技能漏了一种情况——push 成功但 mergeStateStatus=DIRTY +Agent:flag_issue(page_path="skills/github-push-retry/SKILL.md", ...) +下次编译:prepare 聚合到该 issue → updated(revisions +1)→ 你确认 → reinstall +``` + +## 边界与限制 + +- **素材边界**:只用已确认知识(stable 笔记 + 场景块 + open issues)。**任务记忆(task memories)不是技能素材**——它直写落盘不经确认闸门(ADR-0002),通用经验该在蒸馏双轨时落成 note,正道是重新蒸馏 raw 或手动 `ingest_note`。 +- **容量**:草稿区活技能(非 deprecated)硬顶 12 份、橙线 9——类比 scenario 的容量纪律,防止技能库碎片化。满了先 retire/合并。 +- **每次编译每批最多新建 1 份**,且新建前必须对比 ≥2 份相似技能(防碎片纪律,prepare 提示承载)。 +- **生效区兼容性**:install 产物是标准 SKILL.md(name/description frontmatter + 正文),纯 SKILL.md 直装 `.codebuddy/skills/` 在本仓已实证可行;其他宿主(Claude Code 等)按各家技能目录规范放置即可。 +- **CodeBuddy 真机发现行为**属于 T6(#29 闭环验证)的人工验收项。 + +## 附带资源 + +- MCP prompt:`skill-creator`(`prompts/list` 可取,宿主 Agent 的完整工作流指引) +- 设计文档:`docs/skill-creator需求与设计方案.md`(实施唯一输入) +- 精读底料:`docs/WikiSkill论文与wikiskill源码精读.md`(arXiv:2608.27454 + 源码调研) +- 领域词汇:`CONTEXT.md`「skill」词条 · 决策:`docs/adr/0004` From b368cc62352c891d2d4e4b4cbdfeeadbe87fcf59 Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 19:51:28 +0800 Subject: [PATCH 14/23] =?UTF-8?q?test(mcp):=20skill-creator=20=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=B4=A0=E6=9D=90=E9=97=AD=E7=8E=AF=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=20+=20=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=88T6,=20#29=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用本仓真实知识走完整生命周期(非 fixture): - prepare:38 个真实候选(8 场景块 + 30 笔记),容量绿、无冲突 - submit:maintain-fork-pr-merge 技能从「发布与依赖治理方法」场景 + fork-PR lesson 编译落草稿区,2 条素材获得 compiled_into 回链 - lint 全绿 → install(生效区产物验证:仅 name/description/正文) - flag_issue(skill-ineffective:DRAFT 态 caveat 缺失)→ prepare 聚合 → 修订(revisions 2)→ drift warning 实测触发 → reinstall 生效区刷新 - 七处同步清单逐项核对通过;设计文档状态更新为已实施 CodeBuddy 真机技能发现验证留人工步骤(安装产物已就位于 .codebuddy/skills/maintain-fork-pr-merge/)。 Closes #29 --- ...76\350\256\241\346\226\271\346\241\210.md" | 2 +- repowiki/.meta/issues.json | 173 ++++++++++++++++++ ...216\242\346\265\213-worktree-push-fork.md" | 2 + .../skills/maintain-fork-pr-merge/SKILL.md | 53 ++++++ ...73\347\220\206\346\226\271\346\263\225.md" | 5 +- 5 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 repowiki/skills/maintain-fork-pr-merge/SKILL.md diff --git "a/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" "b/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" index eb6bf2e..0e1758b 100644 --- "a/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" +++ "b/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" @@ -2,7 +2,7 @@ > 把已确认知识编译成 SKILL.md 行为指令资产:MVP 单向编译器 + 回流接口设计。 > -> 编制日期:2026-09-06 · 状态:设计定稿(grill 九问收敛完毕,代码未动) +> 编制日期:2026-09-06 · 状态:**已实施**(T1-T5 落地于 issues #24-#28,commits b12e98c/3db4827/64fb95e/e08dae6/c1beee9;闭环验证 #29 完成——真实素材全链 prepare→submit→lint→install→flag→revise→drift→reinstall 实测通过。用户文档:`docs/skill-creator使用指南.md`) > > 前置文档(本方案的输入,本文不重复其论证): > 1. `repowiki/wiki/comparisons/自动生成SKILL可行性-wikiskill闭环-vs-CodeWiki编译复用.md` — 三档方案定档(方案 C 半闭环,MVP = 单向编译器) diff --git a/repowiki/.meta/issues.json b/repowiki/.meta/issues.json index 20187eb..7de2007 100644 --- a/repowiki/.meta/issues.json +++ b/repowiki/.meta/issues.json @@ -35,6 +35,179 @@ "status": "open", "occurrences": 1, "updates": [] + }, + "5a846caf": { + "id": "5a846caf", + "issue_type": "custom", + "page_path": "notes/2026-08-03-mcp-工具-schema-不声明-session-idhandler-隐式读取.md", + "description": "test", + "severity": "warning", + "created_at": "2026-09-06T18:21:33.680536", + "updated_at": "2026-09-06T18:21:56.131616", + "status": "open", + "occurrences": 2, + "updates": [ + { + "timestamp": "2026-09-06T18:21:56.131616", + "description": "low_adoption: recalled 200 times / adopted 0 -> rewrite with concrete steps, commands and expected results, or fold into a scenario block." + } + ] + }, + "d2244c2f": { + "id": "d2244c2f", + "issue_type": "custom", + "page_path": "notes/2026-08-25-doctrine-不会自动注入-agent-上下文唯一通道是-query-wikimodeoverview.md", + "description": "low_adoption: recalled 88 times / adopted 0 -> rewrite with concrete steps, commands and expected results, or fold into a scenario block.", + "severity": "warning", + "created_at": "2026-09-06T18:21:56.602544", + "updated_at": "2026-09-06T18:21:56.602544", + "status": "open", + "occurrences": 1, + "updates": [] + }, + "f10c0a92": { + "id": "f10c0a92", + "issue_type": "custom", + "page_path": "notes/2026-08-23-distill-worker-subagent-定义随包发布hook-启用时自动拷贝到项目-codebuddyagent.md", + "description": "low_adoption: recalled 84 times / adopted 0 -> rewrite with concrete steps, commands and expected results, or fold into a scenario block.", + "severity": "warning", + "created_at": "2026-09-06T18:21:57.066236", + "updated_at": "2026-09-06T18:21:57.066236", + "status": "open", + "occurrences": 1, + "updates": [] + }, + "05be9a92": { + "id": "05be9a92", + "issue_type": "custom", + "page_path": "notes/2026-08-26-handle-query-wiki-在-session-存在时每次查询都全量重建检索索引.md", + "description": "low_adoption: recalled 74 times / adopted 0 -> rewrite with concrete steps, commands and expected results, or fold into a scenario block.", + "severity": "warning", + "created_at": "2026-09-06T18:21:57.530526", + "updated_at": "2026-09-06T18:21:57.530526", + "status": "open", + "occurrences": 1, + "updates": [] + }, + "a488df01": { + "id": "a488df01", + "issue_type": "custom", + "page_path": "notes/2026-08-25-mcp-参数长度受限时蒸馏-submit-走文件侧通道python-脚本直接调-handle-distill-conve.md", + "description": "low_adoption: recalled 58 times / adopted 0 -> rewrite with concrete steps, commands and expected results, or fold into a scenario block.", + "severity": "warning", + "created_at": "2026-09-06T18:21:58.098090", + "updated_at": "2026-09-06T18:21:58.098090", + "status": "open", + "occurrences": 1, + "updates": [] + }, + "0066c91f": { + "id": "0066c91f", + "issue_type": "custom", + "page_path": "notes/2026-08-25-untracked-新文件不在分析图谱内review-changes-changed-sources-切片为空.md", + "description": "low_adoption: recalled 50 times / adopted 0 -> rewrite with concrete steps, commands and expected results, or fold into a scenario block.", + "severity": "warning", + "created_at": "2026-09-06T18:21:58.557722", + "updated_at": "2026-09-06T18:21:58.557722", + "status": "open", + "occurrences": 1, + "updates": [] + }, + "18250574": { + "id": "18250574", + "issue_type": "custom", + "page_path": "notes/2026-08-26-load-project-checklist-对-yaml-损坏静默回退-none-无日志难排查.md", + "description": "low_adoption: recalled 43 times / adopted 0 -> rewrite with concrete steps, commands and expected results, or fold into a scenario block.", + "severity": "warning", + "created_at": "2026-09-06T18:21:59.010666", + "updated_at": "2026-09-06T18:21:59.010666", + "status": "open", + "occurrences": 1, + "updates": [] + }, + "3290e859": { + "id": "3290e859", + "issue_type": "custom", + "page_path": "notes/2026-08-29-生成的-ps1-必须带-utf-8-bom否则-powershell-51-按-gbk-误读.md", + "description": "low_adoption: recalled 41 times / adopted 0 -> rewrite with concrete steps, commands and expected results, or fold into a scenario block.", + "severity": "warning", + "created_at": "2026-09-06T18:21:59.465742", + "updated_at": "2026-09-06T18:21:59.465742", + "status": "open", + "occurrences": 1, + "updates": [] + }, + "c36a249c": { + "id": "c36a249c", + "issue_type": "custom", + "page_path": "notes/2026-08-29-工作区痕迹齐备时跳过-init-workspace直接跑-bootstrap-脚本补-clone.md", + "description": "low_adoption: recalled 39 times / adopted 0 -> rewrite with concrete steps, commands and expected results, or fold into a scenario block.", + "severity": "warning", + "created_at": "2026-09-06T18:21:59.901791", + "updated_at": "2026-09-06T18:21:59.901791", + "status": "open", + "occurrences": 1, + "updates": [] + }, + "60917120": { + "id": "60917120", + "issue_type": "custom", + "page_path": "notes/2026-08-29-登记业务仓后不自动生成-wiki必须等用户显式要求.md", + "description": "low_adoption: recalled 36 times / adopted 0 -> rewrite with concrete steps, commands and expected results, or fold into a scenario block.", + "severity": "warning", + "created_at": "2026-09-06T18:22:00.363329", + "updated_at": "2026-09-06T18:22:00.363329", + "status": "open", + "occurrences": 1, + "updates": [] + }, + "fad9fdf4": { + "id": "fad9fdf4", + "issue_type": "no_outlinks", + "page_path": "notes/", + "description": "BATCH: 153 note pages have no outgoing wikilinks (info). Adding cross-refs one-by-one is low value; decide on a bulk policy (auto-tag related notes or accept).", + "severity": "info", + "created_at": "2026-09-06T18:22:00.827939", + "updated_at": "2026-09-06T18:22:00.827939", + "status": "open", + "occurrences": 1, + "updates": [] + }, + "05d43ba8": { + "id": "05d43ba8", + "issue_type": "custom", + "page_path": "notes/", + "description": "BATCH: 76 note pages marked superseded (info). Archive/delete only after human review - retirement is a knowledge decision, not a lint fix.", + "severity": "info", + "created_at": "2026-09-06T18:22:01.366847", + "updated_at": "2026-09-06T18:22:01.366847", + "status": "open", + "occurrences": 1, + "updates": [] + }, + "d7c43aab": { + "id": "d7c43aab", + "issue_type": "custom", + "page_path": "codewiki/cli/", + "description": "BATCH: 127 isolated components (info), mostly CLI helpers with no dependency edges. Verify they are not dead code before documenting.", + "severity": "info", + "created_at": "2026-09-06T18:22:01.878474", + "updated_at": "2026-09-06T18:22:01.878474", + "status": "open", + "occurrences": 1, + "updates": [] + }, + "2ceafde2": { + "id": "2ceafde2", + "issue_type": "custom", + "page_path": "skills/maintain-fork-pr-merge/SKILL.md", + "description": "适用条件漏了 draft PR:mergeStateStatus=DRAFT 时探测冲突无意义,需先等 CI 出结果再判定", + "severity": "warning", + "created_at": "2026-09-06T19:45:11.933179", + "updated_at": "2026-09-06T19:45:11.933179", + "status": "open", + "occurrences": 1, + "updates": [] } }, "version": 1 diff --git "a/repowiki/notes/2026-08-26-fork-\346\235\245\346\272\220\347\232\204-pr-\344\270\216\347\233\256\346\240\207\345\210\206\346\224\257\345\206\262\347\252\201\346\227\266\347\232\204\347\273\264\346\212\244\350\200\205\345\220\210\345\205\245\346\265\201\347\250\213merge-tree-\346\216\242\346\265\213-worktree-push-fork.md" "b/repowiki/notes/2026-08-26-fork-\346\235\245\346\272\220\347\232\204-pr-\344\270\216\347\233\256\346\240\207\345\210\206\346\224\257\345\206\262\347\252\201\346\227\266\347\232\204\347\273\264\346\212\244\350\200\205\345\220\210\345\205\245\346\265\201\347\250\213merge-tree-\346\216\242\346\265\213-worktree-push-fork.md" index 1f96c49..423db3d 100644 --- "a/repowiki/notes/2026-08-26-fork-\346\235\245\346\272\220\347\232\204-pr-\344\270\216\347\233\256\346\240\207\345\210\206\346\224\257\345\206\262\347\252\201\346\227\266\347\232\204\347\273\264\346\212\244\350\200\205\345\220\210\345\205\245\346\265\201\347\250\213merge-tree-\346\216\242\346\265\213-worktree-push-fork.md" +++ "b/repowiki/notes/2026-08-26-fork-\346\235\245\346\272\220\347\232\204-pr-\344\270\216\347\233\256\346\240\207\345\210\206\346\224\257\345\206\262\347\252\201\346\227\266\347\232\204\347\273\264\346\212\244\350\200\205\345\220\210\345\205\245\346\265\201\347\250\213merge-tree-\346\216\242\346\265\213-worktree-push-fork.md" @@ -11,6 +11,8 @@ metadata: severity: medium consolidated_into: - wiki/scenarios/发布与依赖治理方法.md + compiled_into: + - skills/maintain-fork-pr-merge/SKILL.md status: stable generated: by: codewiki/5.4.3 diff --git a/repowiki/skills/maintain-fork-pr-merge/SKILL.md b/repowiki/skills/maintain-fork-pr-merge/SKILL.md new file mode 100644 index 0000000..7793f2e --- /dev/null +++ b/repowiki/skills/maintain-fork-pr-merge/SKILL.md @@ -0,0 +1,53 @@ +--- +name: maintain-fork-pr-merge +description: 当合入 fork 来源的 PR 且 mergeStateStatus=CONFLICTING/DIRTY 时——先 git merge-tree 探测冲突清单,查 maintainer_can_modify,再在 worktree 隔离目录解冲突后 push fork 分支,勿因 CI 绿直接 merge +type: Skill +status: draft +generated: + by: codewiki/5.6.1 + at: "2026-09-06T11:43:46Z" +stale_after: 2026-12-05 +metadata: + summary: fork PR 冲突时的维护者合入 SOP:merge-tree 探测 + worktree 隔离 + push fork + source_refs: ["wiki/scenarios/发布与依赖治理方法.md", "notes/2026-08-26-fork-来源的-pr-与目标分支冲突时的维护者合入流程merge-tree-探测-worktree-push-fork.md"] + revisions: ["at: \"2026-09-06T11:43:46Z\"", {"at": "2026-09-06T11:45:12Z", "reason": "试用反馈:适用条件补 DRAFT 态 caveat(flag_issue skill-ineffective)", "source": "skill_creator"}, {"at": "2026-09-06T11:46:06Z", "reason": "drift 验证轮", "source": "skill_creator"}, {"at": "2026-09-06T11:50:16Z", "reason": "移除 drift 验证临时段,恢复正式正文", "source": "skill_creator"}] + reason: created from candidate materials + source: skill_creator + installed_at: "2026-09-06T11:50:20Z" + installed_to: .codebuddy/skills/maintain-fork-pr-merge/ + installed_hash: "sha256:3aa4f3725ce83a7c9c83531a737828dd674039eb7734fbaf1fc5d23f15db670e" +--- + + +## 工作场景 + +维护者合入外部贡献者从 fork 发起的 PR,且 PR 分支与目标分支存在冲突(mergeStateStatus=CONFLICTING/DIRTY)。本仓的典型情境:评审期间 develop 又合入了新提交,导致 fork PR 的改动与主线重叠。 + +## 适用条件 + +- PR 的 head 分支在贡献者 fork 仓库中(isCrossRepository=true),本地 `git fetch <pr-branch>` 会 404 +- `gh pr view --json mergeable,mergeStateStatus` 显示 CONFLICTING 或 DIRTY +- 注意:此时 CI 检查可能是全绿——CI 通过不等于可合并,以 mergeStateStatus 为准 +- mergeStateStatus=DRAFT(CI 未跑完)时先等结果再探测冲突,勿提前判定 + +## 核心 SOP + +1. 探测冲突清单:`git merge-tree --write-tree --name-only <base> <head>`——无需真实 checkout/merge 即可拿到冲突文件列表 +2. 确认可否代改:`gh pr view --json isCrossRepository,headRepository`;fork PR 合入前查 `gh api repos/<owner>/<repo>/pulls/<n> --jq .maintainer_can_modify`——为 false 时只能请作者解决或换合并策略 +3. 隔离解冲突:工作区有未提交改动时,用 `git worktree add <tmp> <head> -b <branch>` 在独立目录解决冲突,不污染主工作区 +4. 更新 PR 分支:冲突解决后 `git push https://github.com/<fork-owner>/<repo>.git <local-branch>:<pr-branch>`(maintainer_can_modify=true 时 GitHub 允许维护者推 fork 的 PR head) +5. 收尾清理:`git worktree remove --force` + `git branch -D`,再 `gh pr merge --merge` + +## 判断逻辑 + +- mergeStateStatus=DIRTY ≠ CI 失败:先看冲突字段,不要先怪 CI +- 主工作区是否常驻未提交改动决定是否必须走 worktree:常驻则必须隔离 +- maintainer_can_modify 是代改的前提闸门:false 时一切 push fork 方案不可行 + +## 禁忌与反模式 + +- 不要直接在主工作区 checkout PR 分支解冲突(污染未提交改动) +- 不要因 CI 绿就点 merge(mergeStateStatus 会拒绝,但先探测可省一轮) +- 不要试图 fetch fork 的分支名到本地远程(不在主仓库,fetch 404 是预期行为) +- maintainer_can_modify=false 时不要尝试 push fork(无权限,会失败) + diff --git "a/repowiki/wiki/scenarios/\345\217\221\345\270\203\344\270\216\344\276\235\350\265\226\346\262\273\347\220\206\346\226\271\346\263\225.md" "b/repowiki/wiki/scenarios/\345\217\221\345\270\203\344\270\216\344\276\235\350\265\226\346\262\273\347\220\206\346\226\271\346\263\225.md" index 1302af6..6be4232 100644 --- "a/repowiki/wiki/scenarios/\345\217\221\345\270\203\344\270\216\344\276\235\350\265\226\346\262\273\347\220\206\346\226\271\346\263\225.md" +++ "b/repowiki/wiki/scenarios/\345\217\221\345\270\203\344\270\216\344\276\235\350\265\226\346\262\273\347\220\206\346\226\271\346\263\225.md" @@ -17,7 +17,10 @@ metadata: heat: 2 source_notes: - notes/2026-08-26-build-后端-setuptoolshatchling-迁移后-wheel-内容会变化需对比文件清单而非只看能否安装.md - - notes/2026-08-26-uv-工具链两坑--no-dev-已移除须用---no-group-devpython-version-补丁固定与-uv.md + - notes/2026-08-26-uv-工具链两坑--no-dev-已移除须用 + compiled_into: + - skills/maintain-fork-pr-merge/SKILL.md +---no-group-devpython-version-补丁固定与-uv.md - notes/2026-08-26-ruff-format-panic-的根因是误提交的一次性诊断脚本检查步骤勿因工具崩溃轻率移除.md - notes/2026-08-26-fork-来源的-pr-与目标分支冲突时的维护者合入流程merge-tree-探测-worktree-push-fork.md - notes/2026-08-29-生成的-ps1-必须带-utf-8-bom否则-powershell-51-按-gbk-误读.md From 67ad8ede3283694cf036d9f544bf9f0a5fd88f3c Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 20:04:02 +0800 Subject: [PATCH 15/23] =?UTF-8?q?feat(capture):=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E4=B8=A4=E7=BA=A7=E6=B6=88=E5=8C=96=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=BF=9D=E7=95=99=E5=91=BD=E4=BB=A4-=E6=8A=A5?= =?UTF-8?q?=E9=94=99-=E4=BF=AE=E5=A4=8D=E9=93=BE=EF=BC=88skill-creator=20?= =?UTF-8?q?=C2=A79=20=E6=96=B9=E6=A1=88=E4=B9=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回答「采集脚本丢工具信息影响技能生成」:压缩时丢掉的操作细节蒸馏 永远找不回来,正是技能素材的主体。原 _NOISE_BLOCK_TYPES 一刀切(11 类块全丢)改为两级消化,共享单点实现 codewiki/src/tool_digest.py (stdlib-only,两条采集路径永不漂移): - 纯噪音(thinking/reasoning/system/context)仍无条件丢弃 - tool 调用(tool_use/tool-call/function_call 双拼写)→ 一行压缩 [tool: 名 · 命令首行](≤160 字符),保持原序——顺序即修复链 - tool 结果仅疑似错误保留(is_error 或错误指纹)→ [tool-error: 摘录] (≤200 字符追加预算);成功结果仍丢弃 触点:capture_conversation + _ide_hook 双侧接入;测试断言改契约 + tool_digest 专项 3 例(85 相关测试全过);AGENTS.md 捕获协议同步 (保留关键命令/报错修复对/参数钉子);P0 设计文档 §2.1 勘误 (toolError 摩擦信号从不可得变为可扫 raw);设计方案 §9 收口。 副产品:未来捕获的会话 raw 天然携带命令-报错-修复链,蒸馏产出的 笔记即含完整 SOP 素材,skill_creator 编译不再缺操作细节。 --- AGENTS.md | 8 +- codewiki/mcp/_ide_hook.py | 31 +-- codewiki/mcp/tools/capture_conversation.py | 47 ++--- codewiki/src/tool_digest.py | 198 ++++++++++++++++++ ...76\350\256\241\346\226\271\346\241\210.md" | 67 +++--- ...346\241\210-P0\344\270\211\351\241\271.md" | 6 +- tests/test_ide_hook_capture.py | 63 +++++- 7 files changed, 323 insertions(+), 97 deletions(-) create mode 100644 codewiki/src/tool_digest.py diff --git a/AGENTS.md b/AGENTS.md index dab86cf..4c3e0dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,8 +198,12 @@ IDE 式磁盘 transcript;任务记忆的注入与捕获改由 Agent 执行: - **会话捕获(自然停顿点执行:任务告一段落 / 话题切换 / 用户空闲)**: 1. 委托后台子代理执行,**不阻塞回答**:`qw_query` 拉取当前会话列表定位 本会话 chatId → `qwenwork_task_get_detail(chatId, offset 分页)` 拉全部轮次 - 2. 按轮次做要点级压缩:保留决策脉络、关键事实、结论与提交号;丢弃寒暄、 - 过程噪音与工具调用细节 + 2. 按轮次做要点级压缩:保留决策脉络、关键事实、结论与提交号;**保留关键 + 命令原文、报错→修复对、版本/参数钉子**(它们是经验蒸馏与技能编译的素材, + 不是噪音);丢弃寒暄与纯过程脚印(重复读文件、失败搜索、确认往返)。 + 判断标准:这条工具信息换个会话还能复用吗?能 → 留。capture_conversation + 落盘时也会对 content-block 形式的工具调用做两级消化(压缩行 + error 片段, + `codewiki/src/tool_digest.py`),压缩阶段无须自行丢弃工具信息 3. 调 `capture_conversation(repo_path=<repo>, conversation=[{"role": ..., "content": ...}...], task_id=<绑定的任务id>, source_session_id="qwenwork-<chatId>")` 走标准管线落盘 diff --git a/codewiki/mcp/_ide_hook.py b/codewiki/mcp/_ide_hook.py index 8b05a9c..fe4541f 100644 --- a/codewiki/mcp/_ide_hook.py +++ b/codewiki/mcp/_ide_hook.py @@ -245,31 +245,18 @@ def _extract_codebuddy_message_text(msg_data: dict) -> str: return "" -_NOISE_BLOCK_TYPES = frozenset( - { - "tool-call", - "tool_call", - "tool-result", - "tool_result", - "reasoning", - "thinking", - } -) +# Content-block digestion (skill-creator §9): two-tier tool handling shared +# with capture_conversation — codewiki.src.tool_digest is stdlib-only, so +# importing it here does not break the hook's stdlib constraint. Tool calls +# become one ``[tool: name · command]`` line each (command/error/fix chains +# are skill material); tool results survive only as error excerpts; pure +# noise (thinking/system) stays dropped. +from codewiki.src.tool_digest import digest_blocks def _text_from_content_blocks(blocks: list) -> str: - """Join text from content blocks, skipping tool-call/tool-result/reasoning noise.""" - parts: list = [] - for block in blocks: - if not isinstance(block, dict): - continue - btype = block.get("type", "") - if btype in _NOISE_BLOCK_TYPES: - continue - text = block.get("text") - if isinstance(text, str) and text.strip(): - parts.append(text.strip()) - return "\n\n".join(parts) + """Flatten content blocks with two-tier tool digestion (see tool_digest).""" + return "\n\n".join(digest_blocks(blocks)) def _load_transcript(path: Optional[str]) -> Optional[list]: diff --git a/codewiki/mcp/tools/capture_conversation.py b/codewiki/mcp/tools/capture_conversation.py index 2339b6e..fe74215 100644 --- a/codewiki/mcp/tools/capture_conversation.py +++ b/codewiki/mcp/tools/capture_conversation.py @@ -201,48 +201,25 @@ def _should_capture_l0(content: str) -> bool: return True -# Content-block types that carry internal monologue / tool plumbing rather than -# user-facing assistant text. Skipped even when nested inside a content array. -_NOISE_BLOCK_TYPES = { - "thinking", - "reasoning", - "thought", - "tool_use", - "tool_result", - "tool_call", - "function_call", - "function_result", - "system", - "system_prompt", - "context", -} +# Content-block digestion (skill-creator §9, two-tier): pure noise +# (thinking/system) is dropped; tool calls survive as one compressed +# ``[tool: name · command]`` line each; tool results survive only as error +# excerpts. The logic lives in codewiki.src.tool_digest (shared with the +# stdlib-only IDE hook so the two capture paths never drift). +from codewiki.src.tool_digest import digest_blocks def _content_blocks_text(content: Any) -> Any: """If ``content`` is a list of content blocks (Claude/CodeBuddy format), - flatten to the concatenated text of user/assistant-facing blocks only, - dropping thinking/tool/system blocks. Otherwise return it unchanged. + flatten to text lines with two-tier tool digestion: plain text kept, + tool calls compressed to ``[tool: …]`` lines (command/error/fix chains + are skill material — see tool_digest docstring), tool results kept only + as error excerpts. Otherwise return it unchanged. """ if not isinstance(content, list): return content - texts: List[str] = [] - for block in content: - if not isinstance(block, dict): - if isinstance(block, str): - texts.append(block) - continue - btype = block.get("type") - if btype in _NOISE_BLOCK_TYPES: - continue - if btype == "text": - text = block.get("text") - if isinstance(text, str): - texts.append(text) - elif btype is None: - text = block.get("text") or block.get("content") - if isinstance(text, str): - texts.append(text) - return "\n".join(t for t in texts if t).strip() or content + lines = digest_blocks(content) + return "\n".join(lines).strip() or content def _extract_transcript(conversation: Any) -> List[Dict[str, str]]: diff --git a/codewiki/src/tool_digest.py b/codewiki/src/tool_digest.py new file mode 100644 index 0000000..1f3eeab --- /dev/null +++ b/codewiki/src/tool_digest.py @@ -0,0 +1,198 @@ +"""Content-block tool digestion for conversation capture (skill-creator §9). + +Shared by BOTH capture paths so they can never drift apart: + +- ``codewiki.mcp.tools.capture_conversation`` (MCP tool, QwenWork protocol) +- ``codewiki.mcp._ide_hook`` (stdlib-only IDE hook, deployed hook wrapper + runs it via ``python -m codewiki.mcp._ide_hook`` — inside the package + environment, so sharing this module is safe) + +Design (docs/skill-creator需求与设计方案.md §9, issue feedback 2026-09-06): +raw conversations used to drop EVERY tool block as noise. But the +"command → error → fix" pairs, exact flags and version pins carried by tool +calls are exactly the material skills are compiled from — what the capture +layer drops, distillation never recovers. + +Two-tier replacement for the old flat noise set: + +- Pure noise (internal monologue / system plumbing) is still dropped + unconditionally. +- ``tool_use`` / ``tool_call`` / ``function_call`` blocks survive as ONE + compressed line each (tool name + first parameter line, e.g. the bash + command), in original position — order is the "command → error → fix" + chain. +- ``tool_result`` / ``function_result`` blocks survive ONLY when they look + like an error (is_error flag or error fingerprint in the text), as a + truncated error excerpt. Successful results stay dropped. + +This module is stdlib-only by design (the IDE hook must import it without +the package's third-party deps). +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +# Tier 1: dropped unconditionally — internal monologue and system plumbing +# carry no reusable operational detail. +PURE_NOISE_BLOCK_TYPES = frozenset( + { + "thinking", + "reasoning", + "thought", + "system", + "system_prompt", + "context", + } +) + +# Tier 2a: tool invocation blocks → one compressed line each. +# (Both underscore and hyphen spellings: Claude/API uses tool_use, +# CodeBuddy transcripts use tool-call.) +TOOL_CALL_BLOCK_TYPES = frozenset( + { + "tool_use", + "tool_call", + "tool-call", + "function_call", + } +) + +# Tier 2b: tool result blocks → kept only as truncated error excerpts. +TOOL_RESULT_BLOCK_TYPES = frozenset( + { + "tool_result", + "tool-result", + "function_result", + } +) + +# Compressed-line budgets (chars). A tool line that exceeds its budget is +# truncated — the point is recognizability for distillation, not fidelity. +_TOOL_LINE_MAX = 160 +_RESULT_ERROR_EXCERPT = 200 + +# Error fingerprints for result classification (case-insensitive substring). +_ERROR_FINGERPRINTS = ( + "traceback (most recent call last)", + "error:", + "error:", + "exception", + "failed", + "permission denied", + "not found", + "exit code", + "fatal", +) + + +def _first_param_line(payload: Any) -> str: + """Extract the most informative single line from a tool-call payload. + + Preference order: ``command`` / ``cmd`` (shell), ``file_path`` / + ``path`` (fs), ``pattern`` (search), else the compact JSON of the whole + input. Returns at most one line. + """ + if not isinstance(payload, dict) or not payload: + return "" + for key in ("command", "cmd", "file_path", "path", "pattern", "query", "url"): + v = payload.get(key) + if isinstance(v, str) and v.strip(): + return v.strip().splitlines()[0] + try: + compact = json.dumps(payload, ensure_ascii=False, default=str) + except (TypeError, ValueError): + return "" + return compact + + +def _looks_like_error(text: str) -> bool: + lowered = text.lower() + return any(fp in lowered for fp in _ERROR_FINGERPRINTS) + + +def _clip(s: str, limit: int) -> str: + s = s.strip() + return s if len(s) <= limit else s[: limit - 1] + "…" + + +def digest_tool_call_block(block: Dict[str, Any]) -> str: + """Compress a tool-invocation block into one ``[tool: …]`` line.""" + name = block.get("name") or block.get("toolName") or block.get("tool_name") or "?" + payload = block.get("input") or block.get("arguments") or block.get("args") or block.get("params") + detail = _first_param_line(payload) + line = f"[tool: {name}" + (f" · {detail}" if detail else "") + "]" + return _clip(line, _TOOL_LINE_MAX) + + +def digest_tool_result_block(block: Dict[str, Any]) -> str: + """Compress a tool-result block into an error excerpt line, or ''. + + Empty string means "successful / unremarkable — drop entirely". + """ + if block.get("is_error") in (True, "true", 1): + is_err = True + else: + is_err = False + content = block.get("content") or block.get("text") or block.get("result") or "" + if isinstance(content, list): + content = " ".join( + p.get("text", "") for p in content if isinstance(p, dict) + ) + if not isinstance(content, str): + content = str(content) if content else "" + content = content.strip() + if not is_err and content and not _looks_like_error(content): + return "" # success output stays dropped + if not content: + return "[tool-error: <empty>]" if is_err else "" + excerpt = " ".join(content.split()) # collapse whitespace/newlines + return _clip(f"[tool-error: {excerpt}]", _TOOL_LINE_MAX + _RESULT_ERROR_EXCERPT) + + +def classify_block(btype: Any) -> str: + """Classify a content-block type: 'noise' | 'tool_call' | 'tool_result' | 'text'.""" + if btype in PURE_NOISE_BLOCK_TYPES: + return "noise" + if btype in TOOL_CALL_BLOCK_TYPES: + return "tool_call" + if btype in TOOL_RESULT_BLOCK_TYPES: + return "tool_result" + return "text" + + +def digest_blocks(blocks: List[Any]) -> List[str]: + """Flatten a content-block array to text lines with two-tier tool digestion. + + Order is preserved — the interleaving of text, ``[tool: …]`` and + ``[tool-error: …]`` lines IS the command→error→fix chain distillation + reads. Returns [] when nothing survives (caller decides the fallback). + """ + out: List[str] = [] + for block in blocks: + if isinstance(block, str): + if block.strip(): + out.append(block.strip()) + continue + if not isinstance(block, dict): + continue + btype = block.get("type") + kind = classify_block(btype) + if kind == "noise": + continue + if kind == "tool_call": + out.append(digest_tool_call_block(block)) + continue + if kind == "tool_result": + line = digest_tool_result_block(block) + if line: + out.append(line) + continue + # plain text-ish block + text = block.get("text") + if not isinstance(text, str): + text = block.get("content") if btype is None else None + if isinstance(text, str) and text.strip(): + out.append(text.strip()) + return out diff --git "a/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" "b/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" index 0e1758b..d13f156 100644 --- "a/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" +++ "b/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" @@ -239,43 +239,46 @@ Phase 2(本次不做):真实任务回流编排、技能采纳信号、hook - **素材保真度受捕获链路信息损耗制约**(见 §9):压缩时丢掉的工具操作细节,蒸馏 永远找不回来——这是 notes/scenarios 素材质量的上游杠杆,影响 skill 生成上限。 -## 9. 素材保真度:捕获链路的信息损耗(Phase 2 改进项,本次不改代码) +## 9. 素材保真度:捕获链路的信息损耗(✅ 方案乙已落地,2026-09-06 收官后实施) skill 的价值密度取决于素材里「命令-报错-修复对、版本/参数钉子、失败轨迹」的保有量 (wikiskill 演化产物全是此类;论文 Proposer 纪律要求读 ≥4 条失败 trace 诊断根因)。 -现状是这些信息在捕获链路被系统性丢弃: - -**丢弃触点清单(2026-09-06 实核)**: - -1. `codewiki/mcp/tools/capture_conversation.py:207` — `_NOISE_BLOCK_TYPES` 11 类 - 块(tool_use / tool_result / thinking / system 等)持久化前整体丢弃 -2. `codewiki/mcp/_ide_hook.py:248` — 同名集合,IDE hook 采集侧同样过滤 -3. `AGENTS.md`「QwenWork 捕获协议」— 指令层压缩准则「丢弃寒暄、过程噪音与 - **工具调用细节**」;千问办公链路的压缩由 agent 执行,口径即天花板 -4. `tests/test_ide_hook_capture.py:229,254` — 测试断言 tool 块被过滤(改口径须同步) -5. distill-worker 剧本(`.qoder/.codebuddy/agents/distill-worker.md`)— 蒸馏只读 - raw,raw 缺的提不出 - -既有自认:`docs/知识飞轮增强设计方案-P0三项.md` §设计前提已记录该过滤是刻意设计 -(raw 必须无噪音),且 toolError/toolReject 信号在现有管线下不可得。 - -**改进方向(两档,均为后续工单)**: - -- **方案甲(指令层,零代码)**:改 AGENTS.md 捕获协议与 distill-worker 剧本的压缩 - 准则——丢过程脚印(重复读、失败搜索、确认往返),**保留关键命令原文、报错→修复 - 对、版本/参数钉子**。判断标准:这条工具信息换个会话还能复用吗?能 → 留。覆盖 - QwenWork 链路(agent 压缩本就写要点),成本低,先行。 -- **方案乙(代码层)**:`_NOISE_BLOCK_TYPES` 拆两级——纯噪音(thinking/system) - 仍丢;tool_use 保留压缩形态(toolName + 关键参数首行,如 bash command), - tool_result 仅保留 error 片段(首 ~200 字符);落 raw 时折叠为附录块不污染正文。 - 触点:capture_conversation.py + _ide_hook.py 双处同步 + 测试 + P0 文档更新 - (IDE hook 是 stdlib-only 确定性脚本,无法做价值判断,只能做形态压缩)。 - -**与 MVP 的关系**:不阻塞 MVP(素材源 notes/scenarios 已有存量),但它是素材质量 -的上游杠杆;方案甲可在回流迭代(Phase 2)开工前先做,几乎零成本。 +原现状是这些信息在捕获链路被系统性丢弃: + +**丢弃触点清单(2026-09-06 实核,现已全部治理)**: + +1. `codewiki/mcp/tools/capture_conversation.py` — ~~`_NOISE_BLOCK_TYPES` 11 类 + 块整体丢弃~~ → 改接共享两级消化 +2. `codewiki/mcp/_ide_hook.py` — ~~同名集合,IDE hook 采集侧同样过滤~~ → 同款接入 +3. `AGENTS.md`「QwenWork 捕获协议」— ~~「丢弃工具调用细节」~~ → 改为「保留关键 + 命令原文、报错→修复对、版本/参数钉子;判断标准:换个会话还能复用吗」 +4. `tests/test_ide_hook_capture.py` — ~~断言 tool 块被过滤~~ → 断言压缩行 + + error 片段,新增 tool_digest 专项 3 例 +5. distill-worker 剧本 — raw 现已携带 `[tool: …]` / `[tool-error: …]` 行, + 蒸馏可直接提取命令-报错-修复对 + +**实施(方案乙,共享单点实现 `codewiki/src/tool_digest.py`,stdlib-only)**: + +- 纯噪音(thinking/reasoning/system/context)仍无条件丢弃 +- tool 调用(tool_use/tool-call/function_call,横杠与下划线两种拼写)保留为 + 一行压缩形态:`[tool: 名 · 命令首行]`(≤160 字符),保持原始顺序——顺序即 + 「命令→报错→修复」链 +- tool 结果仅当疑似错误时保留:is_error 标记或错误指纹(traceback/error/ + failed/permission denied/exit code…)命中 → `[tool-error: 摘录]`(≤200 字符 + 追加预算);成功结果仍丢弃 +- 两侧采集路径(capture_conversation + _ide_hook)共享同一实现,永不漂移 +- 副产品:P0 设计的 toolError 摩擦信号从「不可得」变为「可扫 raw 检出」 + (P0 文档 §2.1 已加勘误) + +**原始改进方向(存档)**:方案甲(指令层压缩准则)与方案乙(代码层两级消化) +原为两档后续工单;实际落地时合并——AGENTS.md 指令层与代码层同批完成,distill-worker +剧本随 AGENTS.md 口径自动继承(剧本引用捕获协议,无需单独改)。 + +**与 MVP 的关系**:MVP(T1-T6)已先行落地;本项在其后实施,成为回流迭代 +(Phase 2)的素材质量地基——从此捕获的会话天然携带命令-报错-修复链。 --- *源设计文档:repowiki/wiki/queries/skill-creator设计方案.md(stable)+ docs/WikiSkill论文与wikiskill源码精读.md(2026-09-06)+ grill Q1-Q9 收敛记录 -(2026-09-06 会话)。状态:设计定稿待实施。* +(2026-09-06 会话)。状态:已实施(MVP #24-#29 + §9 素材保真度均落地)。* diff --git "a/docs/\347\237\245\350\257\206\351\243\236\350\275\256\345\242\236\345\274\272\350\256\276\350\256\241\346\226\271\346\241\210-P0\344\270\211\351\241\271.md" "b/docs/\347\237\245\350\257\206\351\243\236\350\275\256\345\242\236\345\274\272\350\256\276\350\256\241\346\226\271\346\241\210-P0\344\270\211\351\241\271.md" index bf961b9..1615d6f 100644 --- "a/docs/\347\237\245\350\257\206\351\243\236\350\275\256\345\242\236\345\274\272\350\256\276\350\256\241\346\226\271\346\241\210-P0\344\270\211\351\241\271.md" +++ "b/docs/\347\237\245\350\257\206\351\243\236\350\275\256\345\242\236\345\274\272\350\256\276\350\256\241\346\226\271\346\241\210-P0\344\270\211\351\241\271.md" @@ -23,12 +23,14 @@ teamai-cli 调研的核心结论是:CodeWiki 的知识飞轮**触发靠 prompt ### 2.1 关键约束:capture 看不到工具流量 -设计前必须承认的现状:`_ide_hook.py` 与 `capture_conversation._extract_transcript` 只保留 `{user, assistant}` 对话轮次,tool-call/tool-result/reasoning 块在 `_NOISE_BLOCK_TYPES` 阶段被丢弃(这是刻意设计——raw 必须无噪音)。teamai 的 toolError/toolReject 两类信号**在现有管线下不可得**。 +设计时的现状:`_ide_hook.py` 与 `capture_conversation._extract_transcript` 只保留 `{user, assistant}` 对话轮次,tool-call/tool-result/reasoning 块在 `_NOISE_BLOCK_TYPES` 阶段被丢弃(当时是刻意设计——raw 必须无噪音)。teamai 的 toolError/toolReject 两类信号在**当时的管线下不可得**。 + +> **勘误(2026-09-06,skill-creator §9 方案乙落地)**:`_NOISE_BLOCK_TYPES` 一刀切已被两级消化取代——纯噪音(thinking/system)仍丢弃;tool 调用保留为压缩行(`[tool: 名 · 命令]`,`codewiki/src/tool_digest.py` 共享实现);tool 结果仅保留 error 片段(`[tool-error: …]`)。因此 **toolError 信号现已可得**(error 片段落 raw,摩擦检测可扫 raw 或在消化层计数);toolReject(权限拒绝)仍取决于宿主 transcript 是否携带该形态,保持 v1 不变。v1.5 的"只计数不留内容"路线已被更强的"留 error 片段"路线吸收。 因此分两步: - **v1(本方案):对话层信号。** 只用 user/assistant 文本即可检测的信号(纠正、打断标记、会话规模),零 capture 改动。 -- **v1.5(后续可选):工具层计数。** 在 hook 的块遍历处(`_text_from_content_blocks` / `_try_expand_codebuddy_index`)**只计数、不留内容**地统计 tool-result 错误块,把计数并入摩擦分。格式依赖性强(CodeBuddy index 格式 vs inline 格式),单独排期。 +- **v1.5(后续可选):工具层计数。** ~~在 hook 的块遍历处只计数、不留内容地统计 tool-result 错误块~~(已被 §9 两级消化吸收:error 片段落 raw,直接可检)。 ### 2.2 评分模型(移植 teamai 并适配) diff --git a/tests/test_ide_hook_capture.py b/tests/test_ide_hook_capture.py index bb98828..0fe25a3 100644 --- a/tests/test_ide_hook_capture.py +++ b/tests/test_ide_hook_capture.py @@ -251,10 +251,12 @@ def test_expand_codebuddy_index(tmp_path): turns = _ide_hook._load_transcript(str(index_file)) assert turns is not None - # tool messages are skipped, reasoning/tool-call blocks filtered + # user/assistant turns only (tool role messages skipped); inside the + # assistant turn, reasoning is dropped and the tool call survives as a + # compressed line (§9 two-tier digestion) assert len(turns) == 2 assert turns[0] == {"role": "user", "content": "hello world"} - assert turns[1] == {"role": "assistant", "content": "here is the answer"} + assert turns[1] == {"role": "assistant", "content": "[tool: list_dir]\n\nhere is the answer"} def test_expand_codebuddy_index_only_user_assistant(tmp_path): @@ -499,7 +501,8 @@ def test_extract_codebuddy_message_text_variants(): == "direct string" ) - # All noise -> empty + # Pure noise still dropped, but tool calls now survive as one compressed + # line each (skill-creator §9 two-tier digestion) assert ( _ide_hook._extract_codebuddy_message_text( { @@ -513,7 +516,7 @@ def test_extract_codebuddy_message_text_variants(): ), } ) - == "" + == "[tool: x]" ) @@ -653,3 +656,55 @@ def get(self, sid): assert len(stems) == len(files) # At least one carries the expected slug assert any("重复的开场白" in s for s in stems) + + +# --------------------------------------------------------------------------- # +# skill-creator §9: two-tier tool digestion (command→error→fix chains) +# --------------------------------------------------------------------------- # +def test_tool_digest_keeps_calls_drops_success_results(): + from codewiki.src.tool_digest import digest_blocks + + lines = digest_blocks( + [ + {"type": "thinking", "text": "internal"}, + {"type": "tool-call", "toolName": "Bash", "args": {"command": "git push origin develop"}}, + {"type": "tool-result", "text": "Everything up-to-date"}, + {"type": "text", "text": "pushed"}, + ] + ) + assert lines == ["[tool: Bash · git push origin develop]", "pushed"] + + +def test_tool_digest_keeps_error_excerpts(): + from codewiki.src.tool_digest import digest_blocks + + lines = digest_blocks( + [ + {"type": "tool-call", "toolName": "Bash", "args": {"command": "uv sync --no-dev"}}, + { + "type": "tool-result", + "text": "error: Unknown option '--no-dev'. Did you mean '--no-group dev'?\nexit code 2", + }, + {"type": "tool-call", "toolName": "Bash", "args": {"command": "uv sync --no-group dev"}}, + {"type": "tool-result", "text": "Installed 42 packages"}, + ] + ) + # the command→error→fix chain survives in order; the success result drops + assert len(lines) == 3 + assert lines[0] == "[tool: Bash · uv sync --no-dev]" + assert lines[1].startswith("[tool-error: error: Unknown option '--no-dev'") + assert lines[2] == "[tool: Bash · uv sync --no-group dev]" + + +def test_tool_digest_is_error_flag_and_budget(): + from codewiki.src.tool_digest import digest_blocks + + # is_error flag alone promotes the excerpt even without fingerprints + lines = digest_blocks([{"type": "tool-result", "is_error": True, "text": "weird failure shape"}]) + assert lines and lines[0].startswith("[tool-error: weird failure shape") + + # long payloads are clipped, not dumped wholesale + lines = digest_blocks( + [{"type": "tool-call", "toolName": "Bash", "args": {"command": "x" * 500}}] + ) + assert len(lines[0]) <= 161 # 160 budget + ellipsis char From ef884a26d2e3c96b3d53b4e276f78d0d43dbb114 Mon Sep 17 00:00:00 2001 From: wandering-bug <18668233261@163.com> Date: Sun, 6 Sep 2026 20:57:40 +0800 Subject: [PATCH 16/23] =?UTF-8?q?=E6=8A=80=E8=83=BD=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/maintain-fork-pr-merge/SKILL.md | 36 +++++++++ .research-competitors/OpenDeepWiki | 1 + .research-competitors/deepwiki-open | 1 + .research-competitors/deepwiki-rs | 1 + .../notes/OpenDeepWiki-notes.md | 63 +++++++++++++++ .../notes/deepwiki-open-notes.md | 80 +++++++++++++++++++ .../notes/deepwiki-rs-notes.md | 58 ++++++++++++++ .research-competitors/notes/openwiki-notes.md | 76 ++++++++++++++++++ .research-competitors/openwiki | 1 + .research-competitors/wikiskill | 1 + .scratch/skill-creator/issues/t1.md | 20 +++++ .scratch/skill-creator/issues/t2.md | 21 +++++ .scratch/skill-creator/issues/t3.md | 19 +++++ .scratch/skill-creator/issues/t4.md | 20 +++++ .scratch/skill-creator/issues/t5.md | 18 +++++ .scratch/skill-creator/issues/t6.md | 22 +++++ repowiki/.meta/page_manifest.json | 33 +++++--- repowiki/.meta/telemetry/local.jsonl | 56 +++++++++++++ ...iki-rs-\345\233\233\344\270\252-DeepWi.md" | 27 +++++++ ...74\217caveman-\346\217\220\347\202\274.md" | 2 +- ...\344\271\211\346\214\207\347\272\271-l.md" | 4 +- ...77\241\345\217\267\346\272\220adr-0005.md" | 33 ++++++++ ...00\347\274\272\350\265\204\344\272\247.md" | 32 ++++++++ ...11\345\244\204\346\225\231\350\256\255.md" | 31 +++++++ repowiki/wiki/doctrine.md | 1 + ...eepSeekHarness\346\217\222\344\273\266.md" | 3 +- repowiki/wiki/log-2026-09.md | 37 +++++++++ repowiki/wiki/modules/KnowledgeStore.md | 14 ++-- repowiki/wiki/modules/MCP_Tools_DocWriter.md | 4 +- repowiki/wiki/modules/MCP_Tools_Knowledge.md | 4 +- repowiki/wiki/modules/MCP_Tools_Workspace.md | 5 +- repowiki/wiki/reading-guide.md | 1 + 32 files changed, 698 insertions(+), 27 deletions(-) create mode 100644 .codebuddy/skills/maintain-fork-pr-merge/SKILL.md create mode 160000 .research-competitors/OpenDeepWiki create mode 160000 .research-competitors/deepwiki-open create mode 160000 .research-competitors/deepwiki-rs create mode 100644 .research-competitors/notes/OpenDeepWiki-notes.md create mode 100644 .research-competitors/notes/deepwiki-open-notes.md create mode 100644 .research-competitors/notes/deepwiki-rs-notes.md create mode 100644 .research-competitors/notes/openwiki-notes.md create mode 160000 .research-competitors/openwiki create mode 160000 .research-competitors/wikiskill create mode 100644 .scratch/skill-creator/issues/t1.md create mode 100644 .scratch/skill-creator/issues/t2.md create mode 100644 .scratch/skill-creator/issues/t3.md create mode 100644 .scratch/skill-creator/issues/t4.md create mode 100644 .scratch/skill-creator/issues/t5.md create mode 100644 .scratch/skill-creator/issues/t6.md create mode 100644 "repowiki/conversations/conv-\350\260\203\347\240\224-openwiki\343\200\201deepwiki-open\343\200\201OpenDeepWiki\343\200\201deepwiki-rs-\345\233\233\344\270\252-DeepWi.md" create mode 100644 "repowiki/notes/2026-09-06-b6-\350\220\275\345\234\260stale-evidence-\346\274\202\347\247\273\344\277\241\345\217\267\346\210\220\344\270\272\345\242\236\351\207\217\345\206\263\347\255\226\347\254\254\344\270\211\344\277\241\345\217\267\346\272\220adr-0005.md" create mode 100644 "repowiki/notes/2026-09-06-deepwiki-\347\261\273\345\233\233\347\253\236\345\223\201\346\272\220\347\240\201\346\250\252\350\257\204\347\273\223\350\256\272llm-\347\233\264\350\257\273\346\230\257\344\270\273\346\265\201ast-\350\260\203\347\224\250\345\233\276\346\230\257-codewiki-\345\224\257\344\270\200\347\250\200\347\274\272\350\265\204\344\272\247.md" create mode 100644 "repowiki/notes/2026-09-06-\350\260\203\347\240\224\346\212\245\345\221\212\347\232\204\345\200\237\351\211\264\345\273\272\350\256\256\345\277\205\351\241\273\345\205\210\350\277\207\344\273\243\347\240\201\346\240\270\345\257\271b2b1-\347\227\233\347\202\271\350\241\250\350\277\260\344\270\216\345\256\236\351\231\205\344\270\215\347\254\246\347\232\204\344\270\211\345\244\204\346\225\231\350\256\255.md" diff --git a/.codebuddy/skills/maintain-fork-pr-merge/SKILL.md b/.codebuddy/skills/maintain-fork-pr-merge/SKILL.md new file mode 100644 index 0000000..1db020b --- /dev/null +++ b/.codebuddy/skills/maintain-fork-pr-merge/SKILL.md @@ -0,0 +1,36 @@ +--- +name: maintain-fork-pr-merge +description: 当合入 fork 来源的 PR 且 mergeStateStatus=CONFLICTING/DIRTY 时——先 git merge-tree 探测冲突清单,查 maintainer_can_modify,再在 worktree 隔离目录解冲突后 push fork 分支,勿因 CI 绿直接 merge +--- + +## 工作场景 + +维护者合入外部贡献者从 fork 发起的 PR,且 PR 分支与目标分支存在冲突(mergeStateStatus=CONFLICTING/DIRTY)。本仓的典型情境:评审期间 develop 又合入了新提交,导致 fork PR 的改动与主线重叠。 + +## 适用条件 + +- PR 的 head 分支在贡献者 fork 仓库中(isCrossRepository=true),本地 `git fetch <pr-branch>` 会 404 +- `gh pr view --json mergeable,mergeStateStatus` 显示 CONFLICTING 或 DIRTY +- 注意:此时 CI 检查可能是全绿——CI 通过不等于可合并,以 mergeStateStatus 为准 +- mergeStateStatus=DRAFT(CI 未跑完)时先等结果再探测冲突,勿提前判定 + +## 核心 SOP + +1. 探测冲突清单:`git merge-tree --write-tree --name-only <base> <head>`——无需真实 checkout/merge 即可拿到冲突文件列表 +2. 确认可否代改:`gh pr view --json isCrossRepository,headRepository`;fork PR 合入前查 `gh api repos/<owner>/<repo>/pulls/<n> --jq .maintainer_can_modify`——为 false 时只能请作者解决或换合并策略 +3. 隔离解冲突:工作区有未提交改动时,用 `git worktree add <tmp> <head> -b <branch>` 在独立目录解决冲突,不污染主工作区 +4. 更新 PR 分支:冲突解决后 `git push https://github.com/<fork-owner>/<repo>.git <local-branch>:<pr-branch>`(maintainer_can_modify=true 时 GitHub 允许维护者推 fork 的 PR head) +5. 收尾清理:`git worktree remove --force` + `git branch -D`,再 `gh pr merge --merge` + +## 判断逻辑 + +- mergeStateStatus=DIRTY ≠ CI 失败:先看冲突字段,不要先怪 CI +- 主工作区是否常驻未提交改动决定是否必须走 worktree:常驻则必须隔离 +- maintainer_can_modify 是代改的前提闸门:false 时一切 push fork 方案不可行 + +## 禁忌与反模式 + +- 不要直接在主工作区 checkout PR 分支解冲突(污染未提交改动) +- 不要因 CI 绿就点 merge(mergeStateStatus 会拒绝,但先探测可省一轮) +- 不要试图 fetch fork 的分支名到本地远程(不在主仓库,fetch 404 是预期行为) +- maintainer_can_modify=false 时不要尝试 push fork(无权限,会失败) diff --git a/.research-competitors/OpenDeepWiki b/.research-competitors/OpenDeepWiki new file mode 160000 index 0000000..75840e5 --- /dev/null +++ b/.research-competitors/OpenDeepWiki @@ -0,0 +1 @@ +Subproject commit 75840e5e86213ca40ace9d5036b1f52603f8d038 diff --git a/.research-competitors/deepwiki-open b/.research-competitors/deepwiki-open new file mode 160000 index 0000000..d92819a --- /dev/null +++ b/.research-competitors/deepwiki-open @@ -0,0 +1 @@ +Subproject commit d92819a9c9f3b99416e3580ff235fc9d3adf8b89 diff --git a/.research-competitors/deepwiki-rs b/.research-competitors/deepwiki-rs new file mode 160000 index 0000000..7620742 --- /dev/null +++ b/.research-competitors/deepwiki-rs @@ -0,0 +1 @@ +Subproject commit 7620742dba31d1d61b01b46c18d2329b584f4caa diff --git a/.research-competitors/notes/OpenDeepWiki-notes.md b/.research-competitors/notes/OpenDeepWiki-notes.md new file mode 100644 index 0000000..b4c8bd3 --- /dev/null +++ b/.research-competitors/notes/OpenDeepWiki-notes.md @@ -0,0 +1,63 @@ +# OpenDeepWiki 调研笔记(AIDotNet/OpenDeepWiki) + +调研对象:本地克隆 `D:\repos\CodeWiki-CN\.research-competitors\OpenDeepWiki`。所有论断均核对源码,标注 `文件:行号`(相对于 `src/` 根,均实际读取验证)。README 用于交叉参考,未作为事实来源。 + +## 1. 整体架构与定位 + +定位为**多仓库托管的 SaaS 式代码知识库平台**(自部署版 DeepWiki):后台导入 Git/ZIP/本地目录 → AI 生成 wiki → Next.js 公共站点 `/{owner}/{repo}` 阅读并提供聊天、嵌入对话、MCP 服务。 + +- 后端:ASP.NET Core(.NET 10)单体应用,`src/OpenDeepWiki/Program.cs` 为唯一入口;自研轻量 API 框架 MiniApis(`Program.cs:73` `AddMiniApis`)+ Endpoints 模式(`src/OpenDeepWiki/Endpoints/`,含 Admin/Auth/Organization/Embed 等 17 组)。 +- 数据库:EF Core,SQLite / PostgreSQL 双提供程序(`src/EFCore/OpenDeepWiki.Sqlite`、`src/EFCore/OpenDeepWiki.Postgresql`),`Program.cs:76` `AddDatabase` 按配置切换。 +- 前端:Next.js 16 + React 19 App Router(`web/package.json:44,48`),含 admin 后台、i18n、分享页。 +- 多仓库模型:`Repository`(OrgName/RepoName)→ `RepositoryBranch`(每分支独立 wiki)→ `BranchLanguage`(每语言一套目录+文档),实体在 `src/OpenDeepWiki.Entities/Repositories/`。组织/部门/角色做权限隔离(`Services/Organizations/OrganizationService.cs:9`、`Program.cs:234` AdminDepartmentService),属"企业多租户"雏形而非硬隔离租户。 +- 认证:JWT + 角色(`Program.cs:99-118`,AdminOnly 策略:117)、Google OAuth、API Key、MCP 专用 OAuth(`Program.cs:130-136`)。 +- 额外有 IM 接入层:飞书/QQ/微信/Slack webhook(`src/OpenDeepWiki/Chat/Providers/`)。 + +## 2. 代码分析阶段 + +- 克隆/更新用 **LibGit2Sharp**(`Services/Repositories/RepositoryAnalyzer.cs:4` 引用,`:200-212` clone/pull 分支),工作区固定布局 `{data}/{org}/{repo}/branches/{branch}/tree`(`:352-360`)。ZIP 导入解压(`PrepareArchiveWorkspaceAsync` `:362-381`),本地目录导入有白名单根(`Program.cs:499-501` LOCAL_IMPORT_ROOT)。 +- **无语言特定解析器**(无 tree-sitter/AST/LSP)。对代码的"理解"完全下放给 LLM agent + 通用工具:`Agents/Tools/GitTool.cs:770-781` 只提供 ReadFile/ListFiles/Grep 三个函数。所谓"多语言代码分析"实为 (a) LLM 泛化能力 + (b) **输出文档多语言**(BranchLanguage 每语言独立生成/翻译)。 +- 目录树采集有自适应预算:`RepositoryScanPlan.cs:5-19`(`ResolvedRepositoryScanPlan` 按仓库规模动态决定树深度/节点上限)。 +- 任务队列:**DB 轮询的 BackgroundService**,非消息队列。`RepositoryProcessingWorker.cs:18-51` 每 30s 扫 Pending/Processing 状态的 Repository(`:85-94`),同类 worker 还有 Branch/Translation/MindMap/Graphify(`Program.cs:254-258`)。 + +## 3. 文档生成流程 + +Pipeline(`WikiGenerator.cs`,2884 行核心类):**PrepareWorkspace → GenerateCatalogAsync → GenerateDocumentsAsync(并行)→ TranslateWikiAsync → GenerateMindMapAsync → SkillMarkdown**。 + +- 目录生成:`GenerateCatalogAsync`(`WikiGenerator.cs:245`)预采集目录树(TOON)/README/入口点拼进 user message(`:263,293-319`),agent 用 `CatalogTool.WriteCatalog` 写出 JSON 目录(title/path/order/children)。源码探索工具有**调用预算**(`DocumentSourceToolBudget`,`WikiGenerator.cs:276`、`Agents/Tools/DocumentSourceToolBudget.cs:9-16`,超限返回 BUDGET_REACHED 迫使 agent 立即产出)。 +- 单篇文档:`GenerateDocumentsAsync`(`:366`)只给 leaf 节点生成正文;`Parallel.ForEachAsync` 控并发(`:561-568`),每篇独立硬超时(`:466-468`);单篇失败不中断(部分失败容忍 `:605-608`);已落盘的 path 跳过实现断点续跑(`:414-420`)。首篇先串行"预热 prompt cache"再开并行(`:545-559`)。 +- Prompt 策略:提示词是外置资产 `src/OpenDeepWiki/prompts/{catalog,content,mindmap}-generator.md + incremental-updater.md`,由 `FilePromptPlugin` 加载(`Program.cs:199-211`),系统 prompt 跨仓库固定、运行时上下文进 user message(catalog-generator.md `<context>` 段)。content prompt 硬编码极详细的反幻觉约束(代码块必须带源文件 blockquote 链接)和 Mermaid 语法规则(`WikiGenerator.cs:818-899` + content-generator.md constraints 段)。 +- 多语言:`TranslateWikiAsync`(`WikiGenerator.cs:1757`)创建目标 BranchLanguage 后**翻译已有目录与文档**(LLM 翻译,非从源码重新生成);`TranslationWorker.cs:17` 定时扫描 Completed 仓库按配置语言自动建翻译任务。 +- 增量更新:`IncrementalUpdateWorker.cs:13` 轮询手动任务 + 定时扫描(`CheckScheduledUpdatesAsync` 约 `:318-350`,按仓库级 `UpdateIntervalMinutes` 到期检查远程 HEAD commit,差异则建任务);changed files 由 `RepositoryAnalyzer.GetChangedFilesAsync`(`:297`)从 git diff 得出;增量 prompt 明确禁用 WriteCatalog 防止整树重写(`WikiGenerator.cs:704-707`),agent 用 EditDoc/WriteDoc 精准修改。 + +## 4. 检索/问答能力 + +- **没有向量 RAG**(全库无 embedding/向量代码)。网页聊天 `ChatAssistantService.StreamChatAsync`(`Services/Chat/ChatAssistantService.cs:186,440+`)是 agentic 方案:给 LLM 挂三类工具——GitTool(直接 checkout 到对应分支读源码,`:477-480`)、ChatDocReaderTool(**把整棵 wiki 目录塞进工具描述**,agent 按行区间读文档,`:496-503`)、管理员配置的外部 MCP/Skill 工具(`:506-519`)。SSE 流式输出含 tool_call/tool_result 事件(`:141-149`)。 +- 对话历史由前端每次全量携带(`ChatRequest.Messages` `:130-136`),服务端不维护会话状态;仅 `ChatLogService` 记审计日志。 +- IM 消息链路有真正的异步基础设施:DB 消息队列 + 死信处理(`Chat/Queue/DatabaseMessageQueue.cs`、`Chat/Processing/ChatMessageProcessingWorker.cs`、`DeadLetterProcessor.cs`)。 + +## 5. MCP / Agent 集成 + +- 用官方 Model Context Protocol C# SDK 内嵌 MCP server,HTTP transport,两级端点:全局 `/api/mcp` 与仓库级 `/api/mcp/{owner}/{repo}`(`Program.cs:313-353` 注册、`:385-386` 映射,scope 从 URL 解析注入 `:316-347`)。 +- 全局工具 4 个:ListRepositories / RouteQuestion(跨仓库路由)/ SearchDocs / ReadDoc(`MCP/McpGlobalTools.cs:26,127,163,262`);仓库级 3 个:搜索文档 / 目录结构 / 读源文件(`MCP/McpRepositoryTools.cs:25,164,216`)。路由与检索是 **token 关键词评分**(`McpGlobalTools.cs:146,456-464` ScoreRepository/ScoreDocument),非语义检索。 +- MCP 认证支持 API Key(`MCP/ApiKeyAuthenticationHandler.cs:12`)与完整 OAuth 2.1(`McpOAuthServer.cs` + `Program.cs:388-392` Protected Resource Metadata),且有用量统计中间件与聚合服务(`Program.cs:383,307`)。 +- **没有 AGENTS.md 自动生成**(全源码无该字样)。最接近的是 Skill 包:`RepositorySkillMarkdownBuilder.cs:16-100` 生成 SKILL.md(frontmatter+文档索引)并把全部文档打包 zip 供 Claude Code 等技能系统下载。 + +## 6. 值得注意的工程设计 + +- **集群级生成并发控制**:`WikiGenerationCoordinator`(`WikiGenerationConcurrencyService.cs:37-64`)用 DB 租约表(RepositoryGenerationLock)+ 全局槽位(WikiGenerationSlot)+ 心跳(`WikiGenerationHeartbeat`,`RepositoryProcessingWorker.cs:170-175`)+ 崩溃恢复 `RecoverStaleWorkAsync`(`:82`),实现多实例部署下"每仓库单写者、集群总并发上限"。这是单体应用内做的分布式调度,成本低于引入 MQ。 +- 5 个独立后台 Worker 职责分离(生成/分支/翻译/思维导图/Graphify 图谱产物,`Program.cs:254-258`),全量重生成有清理器(RepositoryFullRegenerationCleaner)。 +- AI 配置完全管理后台化:Provider 预设目录 + DB 系统设置覆盖启动配置(`Program.cs:418-421`),模型可按"目录模型/内容模型"分角色绑定(`WikiGenerator.cs` ResolveCatalogModelAsync/ResolveContentModelAsync)。 +- 缓存有独立抽象框架(`framework/OpenDeepWiki.Cache.*`,默认内存实现,`Program.cs:218`)。 +- 部署:Docker Compose(compose.yaml,SQLite 默认/PG 可选)、Makefile、Sealos 脚本;.env 多路径加载(`Program.cs:439-490`)。 + +## 7. 明显短板/局限 + +1. **检索无语义层**:无 embedding/向量库,聊天靠"目录注入 + agent 翻文档",跨仓库搜索靠关键词打分,大 wiki 下召回质量受限。 +2. **任务队列是 30s DB 轮询**:时效性与吞吐有限,Worker 与 Web 同进程,扩容粒度粗。 +3. **token 成本高**:每篇文档独立 agentic 调用(带完整系统 prompt + 工具往返),多语言靠逐篇翻译而非复用分析结果。 +4. **无代码级精确解析**:无 AST,代码事实正确性完全依赖 LLM,重构后的行号引用易漂移。 +5. 安全默认值偏弱:JWT 密钥有硬编码 fallback(`Program.cs:96`)、CORS 全放开 AllowAll(`Program.cs:146-153`)。 +6. 会话不服务端持久化(前端带全量历史),长对话成本递增。 + +**对 CodeWiki 对比报告的要点**:OpenDeepWiki 的差异化在于"托管平台化"(多仓库/多分支/多语言/组织权限/Admin 后台/MCP 商业化接口)与集群租约式并发调度;弱项是无 RAG、无精确代码分析、成本控制粗放。 diff --git a/.research-competitors/notes/deepwiki-open-notes.md b/.research-competitors/notes/deepwiki-open-notes.md new file mode 100644 index 0000000..8fbdb54 --- /dev/null +++ b/.research-competitors/notes/deepwiki-open-notes.md @@ -0,0 +1,80 @@ +# deepwiki-open(AsyncFuncAI/deepwiki-open)调研笔记 + +> 调研对象:本地克隆 `D:\repos\CodeWiki-CN\.research-competitors\deepwiki-open`,commit 时点代码为准。 +> 定位:Devin DeepWiki 的开源复刻(README 自称 "my own implementation attempt of DeepWiki"),输入 GitHub/GitLab/Bitbucket 仓库 URL,自动生成 wiki + 代码问答。 + +## 1. 整体架构与定位 + +**双进程 Web 服务**:Next.js 15 前端(`src/`,React 18 + tailwind,next-intl 多语言)+ **Python FastAPI 后端**(`api/main.py:50-72`,uvicorn 端口 8001)。注意:这不是纯 TypeScript 项目——核心逻辑(RAG、wiki 生成、聊天流)全在 Python 侧,`api/` 有独立的 `pyproject.toml`/`poetry.lock`。 + +- 前端 `src/app/api/*/route.ts` 只是**反向代理**,把请求转发到 `SERVER_BASE_URL`(默认 `http://localhost:8001`),如 `src/app/api/chat/stream/route.ts:5,18`(逐字节 pipe SSE 流)。 +- 单容器部署:Dockerfile 在一个镜像里同时跑 FastAPI 和 Next.js standalone(`Dockerfile:99-103` start.sh 后台起两个进程);docker-compose 挂载 `~/.adalflow` 持久化仓库克隆和向量库(`docker-compose.yml:17-18`)。 +- 后端路由:system/auth/repo/wiki/chat/codemap 六组(`api/main.py:64-72`)。聊天走 WebSocket `/ws/chat`(`api/routers/chat.py:20-66`),HTTP SSE `/chat/completions/stream` 为回退(chat.py:69-116);codemap 走 `/ws/codemap` NDJSON(`api/routers/codemap.py:14-49`)。 +- 核心依赖 **AdalFlow**(`api/rag/rag.py:7`,adalflow 的 Embedder/LocalDB/FAISSRetriever/TextSplitter 全家桶)。 +- 访问控制极简:可选的静态授权码 `DEEPWIKI_AUTH_MODE`/`DEEPWIKI_AUTH_CODE`(`api/config.py:60-62`,`api/routers/auth.py:17-22`),无用户体系、无数据库。 + +## 2. 代码分析阶段 + +- **抓取方式是本地 git clone,不是 GitHub API**:`Repo.download()` 用 GitPython 浅克隆 `--depth=1 --single-branch`(`api/repository.py:191-216`),支持 github/gitlab/bitbucket 三种 token 认证格式(repository.py:38-118),也支持本地路径仓库(`is_local`,repository.py:170-171)。 +- **不使用 GitHub Embeddings API**(全代码 grep 无 `api.github`/embeddings API 调用)。嵌入自建:读文件 → TextSplitter 分块 → ToEmbeddings 批量嵌入 → adalflow LocalDB pickle 持久化(`api/rag/pipeline.py:248-275`)。 +- 文件筛选:配置化 code/doc 扩展名白名单 + excluded/included dirs/files,RAG 索引与文件树展示共用同一实现 `iterate_files`(`api/config.py:467-516`)。超大文件跳过(token > 8192×10,`api/rag/pipeline.py:21,126-129`)。 +- 无 AST/依赖图等代码理解——纯文本文件树 + README + 向量检索(见 §4)。meta_data 记录 `is_code`/`is_implementation`(pipeline.py:144-154)但只用于展示,检索时不区分。 + +## 3. 文档生成流程 + +异步任务状态机(后端原版是从前端 page.tsx 移植到 Python,见 `api/services/wiki/tasks.py:316` 注释): + +1. **提交任务** `POST /wiki/tasks`(`api/routers/wiki.py:218-228`):get-or-create 语义——活动任务去重 join、已有缓存直接 `from_cache`(`api/services/wiki/tasks.py:161-190`)。 +2. **INDEXING**:仅当 `{repo_name}.pkl` 不存在才建索引(tasks.py:219-222)。 +3. **DETERMINING_STRUCTURE**:读本地克隆的文件树+README(`api/services/wiki/structure.py:20-48`),用 LLM 生成 XML 格式的 wiki 结构(sections + pages,每页含 title/importance/relevant_files/related_pages),`build_structure_prompt`(`api/services/wiki/prompts.py:213-260`)要求 4-6 页(concise)或 8-12 页(comprehensive)。XML 解析非常防御性:剥 markdown fence、修 bare `&`、截断抢救、正则 fallback(structure.py:179-250)。 +4. **GENERATING**:逐页生成,prompt 为 `build_page_prompt`(prompts.py:25-132)——强制首行 `<details>` 溯源文件块、大量 Mermaid 图、表格、`Sources: [path:line]()` 空括号引用格式(≥5 个文件)。每页生成**复用 RAG 聊天管线**(`_generate_page` 构造 ChatCompletionRequest 走 `research_chat`,tasks.py:369-404),即页面内容由向量检索到的代码块驱动。有界并发 + 每页 2 次重试 + 失败占位页不炸整个任务(tasks.py:268-312)。 +5. **缓存**:完成后整包写入 `~/.adalflow/wikicache/deepwiki_cache_{type}_{owner}_{repo}_{lang}.json`(`api/services/wiki/io.py:21-29,52-72`),**缓存键无 commit hash、无过期**。支持导出 Markdown/JSON(`POST /export/wiki`,io.py:146-235)。 +6. 进度推送:SSE `/wiki/tasks/{id}/stream`,1 秒轮询 registry 输出 progress/done/error 事件(`api/routers/wiki.py:271-302`)。 +7. **后处理**:`post_process_wiki_content` 把模型输出的空括号引用 `[path:line]()` 解析成 GitHub/GitLab/Bitbucket 真实带行锚链接(`api/services/wiki/content.py:84-151`)。 + +## 4. 检索/问答能力 + +- **RAG 链路**:adalflow `FAISSRetriever`,top_k=20(`api/config/embedder.json:33-35`),**无 rerank、无混合检索、无 query 改写**。检索结果按文件分组、块标注 `[lines A-B]` 行号注入上下文(`api/services/research.py:157-192`)。 +- **嵌入模型**:默认 OpenAI `text-embedding-3-small`(dimensions=256,embedder.json:2-10),可换 Ollama `nomic-embed-text`、Google `gemini-embedding-001`、Bedrock Titan v2(embedder.json:11-32);类型由 `DEEPWIKI_EMBEDDER_TYPE` 环境变量选择(config.py:65)。分块:word 级 chunk_size=350 / overlap=100(embedder.json:36-40);`LineTrackingTextSplitter` 为每个 chunk 回写起止行号(pipeline.py:174-208)。 +- **向量库**:FAISS + adalflow LocalDB,pickle 文件落盘(`api/rag/rag.py:290-296`,pipeline.py:163-171)。嵌入维度不一致时按多数派过滤(rag.py:183-236)。 +- **流式问答**:WebSocket 优先,断线/不可用回退 HTTP SSE(前端 `src/utils/websocketClient.ts` + `src/app/api/chat/stream/route.ts:16`)。 +- **Follow-up**:无服务端会话——前端每次带全量 messages,后端把历史 user/assistant 对灌进内存 `Memory`(`api/services/research.py:107-116`),以 `<turn>` 文本拼接进 prompt(research.py:252-259,`api/chat/_prompts.py:13-31`)。对话状态每次请求重建,重启即失。 +- **Deep Research 模式**:`mode=deep_research` 时前端自动循环发 "Continue the research"(`src/components/Ask.tsx:392-441`),后端按 `research_iteration` 换三套 prompt(首迭代出研究计划/中间迭代出增量发现/第 5 轮出最终结论,`api/prompts.py:60-151` + `api/services/research.py:214-242`);迭代由**前端驱动**,后端无状态。 +- **Codemap**:两段式 LLM 调用生成"带引用的分步指南"(skeleton JSON → enrich 补 prose+mermaid),JSON 解析带 3 次重试 + 修复(`api/services/codemap.py:39-126`,prompts 见 `api/prompts.py:195-265`)。 +- 输入超 7500 token 时跳过 RAG 直接裸答(research.py:23,63-73,147);超限报错时自动降级为无上下文 prompt 重试(research.py:261-288)。 + +## 5. 多语言 / 多提供商 + +- **LLM 提供商**:自研客户端注册表而非引入 LiteLLM SDK——`api/clients/` 下 10 个客户端(google/openai/openrouter/ollama/bedrock/azure/dashscope/anthropic-bedrock 等,`api/config.py:71-95`),`ChatStreamer` 按 provider 名注册子类(`api/chat/_stream.py:29-48`)。**LiteLLM 是其中一个可选 provider**:`LiteLLMClient` 继承 adalflow OpenAIClient 指向 LiteLLM proxy 的 OpenAI 兼容端点(`api/clients/litellm.py:8-49`,配套 docker-compose-litellm.yml/litellm-config.yml)。默认 provider 为 google gemini-2.5-flash(`api/config/generator.json:2-3`)。 +- **多语言**:UI 层 next-intl 10 种语言(`src/messages/*.json`);wiki 内容靠 **prompt 内嵌语言指令**生成(`api/services/wiki/prompts.py:126` "Generate the content in {language}"),**每种语言独立缓存文件独立生成**(io.py:26-29),即中文 wiki = 重新跑一遍完整 pipeline,无翻译管线。问答侧靠"跟随用户 query 语言"的 prompt 指令(`api/prompts.py:8-11`)。 + +## 6. 值得注意的工程设计 + +- **并发控制三层信号量**:RAG 索引准备 `DEEPWIKI_MAX_CONCURRENT_RAG=4`(`api/rag/rag.py:27-34`,`asyncio.to_thread` 包同步阻塞操作,rag.py:344-354);wiki 任务池 `DEEPWIKI_MAX_CONCURRENT_WIKI_TASKS`(默认 CPU/2,tasks.py:62-64);单任务页级并发 `DEEPWIKI_WIKI_PAGE_CONCURRENCY`(默认 1,tasks.py:66)。 +- **SSE 心跳防代理超时**:`/repo/prepare` 每 10s 发心跳注释帧,注释明说为了躲 undici 300s headers timeout;先 flush 首字节再跑慢索引、`asyncio.shield` 保证心跳超时不误杀索引任务(`api/routers/repo.py:20-57`);配套前端预热器把冷索引从首个聊天请求剥离(`src/utils/prepareRepo.ts:1-9`)。 +- **任务生命周期**:终端态任务 TTL 300s 自动出注册表(tasks.py:70,200-206);get-or-create 幂等提交(tasks.py:161-190)。 +- **健壮的 LLM 输出解析**:XML 截断抢救 + 双正则 fallback(structure.py:179-250)、JSON 平衡括号提取 + 修复 + 重试(codemap.py:47-126)、流式 token 超限降级重试(research.py:261-288)——明显是为小本地模型(qwen3:1.7b 是 ollama 默认,generator.json:117)调校的。 +- 测试 21 个 pytest 文件(tests/backend 分 routers/services/schemas/rag),在同类项目中算少而精。 + +## 7. 明显短板 / 局限 + +1. **无增量更新**:缓存键只有 repo 名不含 commit/branch(`api/rag/pipeline.py:163-167`,io.py:26-29);已克隆仓库直接复用旧克隆(pipeline.py:366-371),上游更新后 wiki/索引全部 stale,只能手动删缓存重建。 +2. **纯 Web 形态,无 MCP/API 优先的消费方式**:全代码无 MCP(grep 零命中);产出只进私有 JSON 缓存 + 前端渲染 + 手动导出 md/json,不回写仓库、无 PR/CI 集成。 +3. **无持久化用户/会话层**:对话记忆在请求内存对象里(research.py:107-116),无多用户隔离;"auth" 只是单个全局授权码(auth.py:17-22)。 +4. **检索质量上限低**:纯 FAISS 向量单路召回 top_k=20,无 rerank/混合检索/结构感知分块(embedder.json:33-40);代码块按词数切分,跨块语义割裂。 +5. **RAG 对象每次请求重建**:`research_chat` 每次聊天请求都重新 `RAG()` + `aprepare_retriever`(research.py:33-57)——从 pickle 反序列化整个 FAISS 库,大仓库热路径开销可观,无进程内 LRU。 +6. **每语言全量重生成**(§5)、**嵌入维度写死过滤逻辑**(rag.py:183-236 是补丁式容错而非根因修复)、单机单实例架构(TaskRegistry 是模块级内存对象 tasks.py:209,无法水平扩展)。 + +## 与对比报告相关的关键事实速查 + +| 维度 | deepwiki-open | +|---|---| +| 形态 | Next.js 前端 + FastAPI 后端,单 Docker 容器,无 MCP | +| 仓库获取 | git 浅克隆(GitPython),非 API | +| 嵌入 | OpenAI text-embedding-3-small(256d) / Ollama / Google / Bedrock 可切换 | +| 向量库 | FAISS + LocalDB pickle | +| 生成管线 | LLM 出 XML 结构 → 逐页 RAG 生成 → 引用后处理 → JSON 缓存 | +| rerank | 无 | +| 增量更新 | 无 | +| LLM 抽象 | 自研 10 客户端注册表,LiteLLM 为可选 provider | +| 多语言 | prompt 指令 + 每语言独立缓存,无翻译管线 | diff --git a/.research-competitors/notes/deepwiki-rs-notes.md b/.research-competitors/notes/deepwiki-rs-notes.md new file mode 100644 index 0000000..3dbad3a --- /dev/null +++ b/.research-competitors/notes/deepwiki-rs-notes.md @@ -0,0 +1,58 @@ +# deepwiki-rs(Litho)竞品调研笔记 + +> 仓库:sopaco/deepwiki-rs(本地克隆于 `.research-competitors/deepwiki-rs`),Rust,v1.5.1(`Cargo.toml:3`),MIT。所有论断均基于源码核对。 + +## 1. 整体架构与定位 + +- **形态:纯 CLI 工具,非 MCP、非服务**。入口 `src/main.rs:16-27`:`clap` 解析参数 → `generator::workflow::launch(config)`,唯一子命令是 `sync-knowledge`(`src/main.rs:31-37`)。自称 "AI-powered documentation generation engine",产出 **C4 模型**架构文档(Context/Container/Component)。 +- **"Litho" 是 deepwiki-rs 的产品名重命名**(README.md:5)。"litho-engine" 指其内置四阶段文档生成引擎(即 `workflow.rs` 的 launch 流水线),配置文件为 `litho.toml`,内部工作目录 `.litho`(`src/cli.rs:174`)。 +- **重要信号:项目已宣布演进为 Terrain**(README.md:31-40),Litho 定位收缩为 "fast, focused C4 doc generator",开发重心已转移。 + +## 2. 代码分析阶段(Preprocess) + +- 驱动入口 `src/generator/preprocess/mod.rs:45-127`:① 提取原始文档(README 等)→ ② `StructureExtractor` 扫描目录结构(`structure_extractor.rs:45-100`,walkdir 递归 + git tracked-only 过滤 + 文件重要性打分 + LLM 目录打分加成)→ ③ **逐目录 LLM 总结生成 DirectoryDossier**(`mod.rs:135-212`,>256KB 按字典序分批,`mod.rs:133`)→ ④ 依赖关系分析。 +- **解析层是正则/关键词匹配,非 AST**:12 个语言处理器(rust/js/ts/php/react/vue/svelte/kotlin/python/java/csharp/swift,`extractors/language_processors/mod.rs:44-57`),复杂度计算用 `content.matches("fn ")` 等字符串计数(`mod.rs:112-143`)。**没有 Go 处理器**(README.md:102 宣称支持 Go,代码不符)。 +- **Agentic 阶段划分**:四阶段 = Preprocess → Research → Compose → Output(`workflow.rs:83-133`)。Research 按 C4 层级组织 7 个 agent:C1 SystemContextResearcher → C2 DomainModulesDetector/ArchitectureResearcher/WorkflowResearcher → C3-C4 KeyModulesInsight → BoundaryAnalyzer → DatabaseOverviewAnalyzer(条件触发)(`research/orchestrator.rs:22-54`)。 +- **工作流引擎核心是 `StepForwardAgent` trait**(`step_forward_agent.rs:581-726`):每个 agent 声明式配置 `AgentDataConfig`(required/optional `DataSource`:Memory 键、上游 agent 结果、外部知识类别)+ `PromptTemplate` + `LLMCallMode`(Extract 结构化抽取 / Prompt / PromptWithTools),默认 `execute()` 自动完成数据可用性校验(`step_forward_agent.rs:624-641`)、标准 prompt 组装、LLM 调用、结果写回 Memory。新 agent 只需实现配置钩子,是典型的声明式流水线框架。 + +## 3. 文档生成流程 + +- Compose 阶段 6 个 editor 顺序执行(Overview/Architecture/Workflow/KeyModulesInsight/Boundary/Database,`compose/mod.rs:24-52`),产出写入固定 DocTree(`outlet/mod.rs:25-47`),`DiskOutlet` **删除整个输出目录后全量重写**(`outlet/mod.rs:79-82`),最后 `SummaryOutlet` 生成详/简双版执行报告(`summary_outlet.rs:21-23`)。 +- **Prompt 策略**(`step_forward_agent.rs:416-577`):标准 user prompt = 开场指令 + 时间占位符 + 数据源格式化输出(项目结构树/代码洞察 top-25/依赖关系/README 截断 16KB)+ 收尾强调指令;目标语言指令追加到 system/user 双端(`step_forward_agent.rs:664-665`),支持 8 种语言(`cli.rs:86`)。超过 64K token 触发 LLM 智能压缩至 50%,保留函数签名/类型定义等 pattern,压缩失败降级为应急截断(`utils/prompt_compressor.rs:44-58`、`step_forward_agent.rs:314-349`),压缩结果本身有缓存(`cache/mod.rs:183-197`)。 +- **并发策略(Rust 优势实际用得很节制)**:`do_parallel_with_limit`(`utils/threads.rs:6-27`,tokio Semaphore 限流 join_all)**仅用于两处**——KeyModulesInsight 按领域模块并行分析(`research/agents/key_modules_insight.rs:110-126`)和 Deep Dive 文档并行生成(`compose/agents/key_modules_insight_editor.rs:49-50`)。research/compose 各 agent 之间、目录总结(`preprocess/mod.rs:146` for 循环)均为**串行**。README.md:417 序列图宣称 "Execute multiple research agents in parallel",与代码不符。整体是 IO 密集(LLM 调用),Rust 并发优势主要体现在 tokio 异步与低开销 CLI 分发。 + +## 4. 检索/问答能力 + +**无问答、无 RAG、无任何检索接口**。`src/` 中没有 query/search/embedding 模块;产物是一次性生成的静态 markdown。README.md:141-149 提到的 AI 问答在**外部姊妹项目 Litho Book**(Rust+Axum markdown 阅读器)中,不在本仓库。 + +## 5. MCP / Agent 集成 + +- **无 MCP server**。面向 AI Agent 的输出走三条路: + 1. `.agents/skills/litho-documents-skill/`(SKILL.md:7-9):Litho 四阶段流水线的**纯 Agent 平行实现**——不依赖二进制,用 agent 工具调用复刻同一流程,含 `.litho-agent/` 中间产物持久化策略对抗上下文遗忘(SKILL.md:110-142),按项目规模分档扫描策略(SKILL.md:40-47)。已上 Smithery 分发(README.md:194)。 + 2. `.agents/skills/ai-context-generator/` + `.ai-context/`:生成**分层 AI 知识库**(Tier0 PROJECT-ESSENCE → Tier3 DYNAMICS,按稳定性分层,`.ai-context/SKILL.md:33-42`),供 coding agent 会话启动时消费,与 AGENTS.md 互补("AGENTS.md 讲怎么干活,.ai-context 讲项目是什么")。 + 3. 生成的 `docs/`(en/zh/zh_se 三语)本身面向人+Agent 阅读。 + +## 6. 值得注意的工程设计 + +- **双模型 + fallover**:efficient/powerful 双模型(`cli.rs:50-56`),主模型失败时把错误信息注入 prompt 换 fallback 模型重试(`llm/client/mod.rs:106-118`)+ 指数退避重试(`mod.rs:47-73`)。 +- **LLM 层基于 rig-core 0.35**(`Cargo.toml:12`),8 个 provider(openai/moonshot/deepseek/mistral/openrouter/anthropic/gemini/ollama,`config.rs:38-47`)。 +- **ReAct + summary reasoning 降级**:带工具 agent 达到最大迭代后,用无工具 agent 对 chat_history 做总结推理兜底(`llm/client/mod.rs:154-184`);工具并发默认 4、max_turns 100(`config.rs:187-193`)。 +- **文件级 prompt 缓存**:MD5(prompt) 为 key、按 category 目录存放 JSON、带 token 用量统计与过期时间(`cache/mod.rs:43-67,127-180`),并有缓存命中率性能监控(`cache/performance_monitor.rs`)——省 token 复跑利器。 +- **进程内 Memory**:`HashMap<"scope:key", serde_json::Value>` 带访问计数/大小统计(`memory/mod.rs:31-84`),作用域隔离(PREPROCESS/STUDIES_RESEARCH/DOCUMENTATION 等)。 +- **安全细节**:ReAct 的 file_explorer 工具做了路径逃逸防护(拒绝绝对路径与 `..` 穿越,`llm/tools/file_explorer.rs:48-71`);LLM 输出全部走宽松反序列化(`preprocess/agents/directory_summary.rs:105-152`)。 +- **外部知识集成**:PDF/MD/SQL/YAML 按语义分块(8000 字符/200 重叠,`integrations/local_docs.rs:65-88`),按 category 定向投递给指定 agent(`step_forward_agent.rs:540-555`)。 + +## 7. 明显短板/局限 + +1. **无增量**:每次运行全量重新生成并删除重写输出目录(`outlet/mod.rs:79-82`);缓存只省 LLM 调用,不省流程。 +2. **串行瓶颈**:阶段间与多数 agent 间串行,大仓库目录总结逐个 for 循环(`preprocess/mod.rs:146`),性能上限受 LLM 延迟串行叠加制约。 +3. **解析精度低**:无 tree-sitter/AST,正则计数定复杂度、依赖提取靠 import 语句匹配;无 Go/C/C++ 处理器。 +4. **无检索/问答/服务化**:纯一次性生成器,不能作为知识库被持续查询;无 MCP、HTTP API、watch 模式。 +5. **强外部依赖**:启动即校验 `mermaid-fixer` 二进制,未安装直接 bail(`workflow.rs:51-53`)——流程性依赖外部 crate 工具。 +6. **死旗标**:`--skip-preprocessing/--skip-research/--skip-documentation` 仅在 `cli.rs:36-44` 声明,全代码库无消费点,README.md:495 宣称可跳过阶段与实现不符。 +7. **缓存不校验模型**:`CacheEntry` 存了 `model_name` 但 `get()` 不比对(`cache/mod.rs:88-109`),换模型后可能命中旧模型答案。 +8. **项目生命周期**:主理人已转向 Terrain(README.md:31-40),本仓库大概率进入维护模式。 + +## 对比报告可用的一句话画像 + +Litho 是"声明式 StepForwardAgent 流水线 + 进程内 Memory + prompt 哈希文件缓存"的 Rust CLI 生成器,工程亮点在 prompt 组装/压缩、双模型 fallover 和 agent skill 生态(纯 Agent 平行实现 + 分层 .ai-context),但无检索问答、无增量、无 MCP,且核心维护已转向后继项目 Terrain。 diff --git a/.research-competitors/notes/openwiki-notes.md b/.research-competitors/notes/openwiki-notes.md new file mode 100644 index 0000000..538fb05 --- /dev/null +++ b/.research-competitors/notes/openwiki-notes.md @@ -0,0 +1,76 @@ +# OpenWiki(langchain-ai/openwiki)调研笔记 + +> 调研对象:本地克隆 `D:\repos\CodeWiki-CN\.research-competitors\openwiki`(TypeScript,v0.5.0)。 +> 所有论断均基于源码核对,标注 `文件:行号`(相对仓库根)。 + +## 1. 整体架构与定位 + +OpenWiki 是一个 **npm CLI**(`package.json` 的 `bin.openwiki` 指向 `dist/cli/cli.js`,description 为 "A CLI that uses a DeepAgents documentation agent to generate and maintain an OpenWiki for a codebase"),基于 **LangChain/LangGraph + deepagents** 构建,交互 UI 用 Ink(React for CLI)。它同时是三合一: + +1. **CLI Agent 工具**:命令解析入口 `src/cli/commands.ts:168`(`parseCommand`),支持 `--init/--update`、`code`/`personal` 双模式(`src/cli/commands.ts:26` 的 `OpenWikiRunMode`)、`ingest`/`cron`/`auth`/`ngrok`/`visualize`/`integrations`/`mcp` 子命令(`src/cli/commands.ts:107-164` 的 `CliCommand` 联合类型)。 +2. **MCP server**(内部):`openwiki mcp --host <id>` 启动一个"rootless"生命周期 MCP 服务器(`src/cli/commands.ts:658` `parseMcpCommand`;`src/integrations/mcp/server.ts:50` `createOpenWikiMcpServer`)。 +3. **Agent 库形态的集成**:把自身作为 skill+MCP 安装进 Codex/Claude Code/OpenCode/Cursor 四个宿主(`src/integrations/install/registry.ts:12,32,46,66`)。 + +核心生成流程入口是 `runNativeRepositoryGeneration`(`src/agent/repository-runner.ts:205`);聊天/agent 组装在 `src/agent/index.ts:481`(`createDeepAgent`),带 SQLite checkpointer(`src/agent/index.ts:9,811`)。定位 slogan 是"self-maintaining wiki, built for agents, explored by humans"——**wiki 首先写给 Agent 当记忆用**,人类用可视化浏览器(`src/visualize/server.ts`)阅读。 + +## 2. 代码分析阶段 + +**完全没有 AST/tree-sitter/ctags/依赖图提取,也没有嵌入向量**(对 src/ 全文 grep `embedding|vector|rag` 仅命中无关文案)。分析 = LLM agent 直接用文件系统工具读代码: + +- 工具集:planner 只有只读 `read_file/ls/glob/grep`(`src/agent/repository-runner.ts:74` `PLANNER_FILESYSTEM_TOOLS`);page worker 加上 `write_file/edit_file`(`src/agent/repository-runner.ts:75-79`),外加 `submit_plan/inspect_claims/submit_page` 三个生命周期工具(`src/agent/repository-runner.ts:80-85`)。 +- 文件系统是**虚拟根沙箱**:`OpenWikiLocalShellBackend`(`src/agent/docs-only-backend.ts:166`)把 `/` 映射到目标仓库,docsOnly 模式下写权限被限制在 `writableWikiPages` 白名单(每页 worker 只能写自己那一页,`src/agent/docs-only-backend.ts:517-527`;`src/agent/repository-runner.ts:415-424` 传入 `[job.path]`);shell `execute` 在 `.openwikiignore` 激活时被整体禁用(`src/agent/docs-only-backend.ts:487-508`),且永远禁止触碰 Claims 内部状态。 +- 依赖结构不显式提取。planner prompt 要求按"owned systems, runtime domains, cross-system workflows"组织信息架构而非镜像目录树(`src/agent/repository-prompts.ts:49-54`),探索策略是"先看 manifests/入口/公共面 → 追端到端流 → 看测试"(`src/agent/repository-prompts.ts:57-64`)。发现类指令偏好 `rg --files` 加排除目录(`src/agent/prompt.ts:161`),允许读 git 历史建立上下文(`src/agent/prompt.ts:152-156`)。 + +## 3. 文档生成流程 + +**明确两阶段**:planner(结构)→ 逐页 worker(内容),且全程持久化可恢复: + +- **Planning**:一个有界 planner agent 只能输出 `submit_plan`,plan schema 为 `pages[]{path,title,purpose,seedPaths,relatedPages,instructions}` + `deletePages`(`src/agent/repository-runner.ts:38-54`);init 必须含 `/openwiki/quickstart.md`,update 禁止删它(`src/agent/repository-prompts.ts:72-75`)。禁止子代理委派(`src/agent/repository-runner.ts:90-97` `NO_DELEGATION_MIDDLEWARE` 过滤掉 deepagents 注入的 task 工具)。 +- **逐页生成**:持久化有序队列,`nextRepositoryPage` 顺序取页(`src/agent/repository-runner.ts:373-397`),每页一个全新 agent(`runPageAgent`,`src/agent/repository-runner.ts:407`),prompt 含该页 purpose/seedPaths/relatedPages + 待决策的 Claims 清单(`src/agent/repository-prompts.ts:114-178`)。提交校验失败会以"可纠正的 tool 错误"回给 worker 重试(`src/agent/repository-runner.ts:113-134` `createSubmissionRejection`);worker 崩溃则恢复页快照并跳过、留待下次 update(`src/agent/repository-runner.ts:505-517`)。 +- **确定性收尾**(非 LLM):mermaid 校验→索引同步→内链校验→Claims sources 投影→generated provenance 盖章(`src/agent/wiki-finalizer.ts:248-285` `finalizeWikiArtifacts`)。目录 `index.md` 由代码确定性生成,禁止模型手写(`src/agent/prompts/code.ts:31`)。坏链不打断流程,原地标记 HTML 注释供下次自愈(`src/agent/prompt.ts:133-139`);mermaid 解析失败降级为 text fence 并留修复注释(`src/agent/prompt.ts:141-149`)。 +- **Prompt 策略亮点**:OKF v0.2(Google Knowledge Catalog 开放知识格式)frontmatter 强制要求 `type/title/description/tags`,description 明确"optimized for search & retrieval"(`src/agent/prompts/code.ts:63-88`);mermaid 图谱纪律按图型分类(sequenceDiagram/stateDiagram/erDiagram/flowchart,`src/agent/prompt.ts:141-149`);秘密文件(.env 等)禁读(`src/agent/prompts/code.ts:56-61`)。 +- **增量更新机制(最重的工程设计)**,多层: + 1. **no-op 检测**:对比 `.last-update.json` 记录的 gitHead 与当前 HEAD/worktree status,只有 openwiki 自身路径变化则跳过(`src/agent/utils.ts:126-195` `getUpdateNoopStatus`); + 2. **源指纹**:sha256 哈希全部 tracked+untracked 文件内容与 porcelain status(`src/agent/utils.ts:331-404` `createRepositorySourceSnapshot`),运行中源漂移会不推进 checkpoint、提示用户再 update(`src/agent/repository-runner.ts:244-253`); + 3. **每页源 checkpoint**:`openwiki/.page-manifest.json` 记录每页覆盖的指纹(`src/generation/page-manifest.ts:35` `RepositoryPageManifestEntry`),update 时未受影响页直接 fast-forward(`src/generation/repository-run.ts:391` `fastForwardUnchangedRepositoryPageCoverage`); + 4. **页级 update window**:按页的已提交 git baseline 分组,连同 changedPaths 喂给 planner 决定哪些页真正需要重写(`src/agent/repository-prompts.ts:215-231` `formatPageUpdateWindows`;`src/generation/repository-run.ts:145-167` 类型定义); + 5. **Grounded Claims**:每页的实质事实命题以 sidecar JSON 存于 `openwiki/.claims`(`src/claims/brains/code/store.ts:84,116`),证据 URI 为 `repo://<path>#L20-L48` 且带内容哈希版本;update 前 preflight 逐条比对证据版本,产出 stale/unresolved 清单(`src/claims/brains/code/preflight.ts:36-96`)。证据版本编码了行范围"重定位锚点"(首/末行哈希+上下文哈希),行号漂移后能自动重锚(`src/claims/evidence/repository/resolver.ts:22-69`)。worker 提交稀疏 reconciliation(confirm/revise/retract),无争议 Claims 自动保留(`src/claims/guidance.ts:18-24`)。 +- **init 安全性**:重新 init 会把旧 wiki 备份到临时目录,失败/ SIGINT/SIGTERM 均回滚,只保留用户手写的 `INSTRUCTIONS.md`(`src/agent/wiki-replacement.ts:42-80`)。运行状态持久化在 `openwiki/.run.json`,中断后 resume(`src/generation/run-state.ts`)。 +- **多语言**:`--language` 切换时先跑一轮确定性翻译 pass 把存量页面翻到目标语言,模型只写新增内容(`src/agent/translation-middleware.ts:187` `translateWiki`;`src/agent/prompt.ts:123-130`)。 + +## 4. 检索/问答能力 + +**无 RAG/嵌入/混合检索**(grep 证实)。问答链路是"agent + 文件系统工具": + +- code 模式聊天 prompt 明确要求 **wiki-first QA**:"inspect the generated wiki under /openwiki first",先 grep/glob wiki 再看源码,用户问"wiki 怎么说"时只用 wiki 页面(`src/agent/prompts/code.ts:25-28`)。 +- 聊天会话经 SQLite checkpointer 持久化(`src/agent/index.ts:416-422,811`),并有 checkpoint 裁剪防膨胀(`src/agent/index.ts:837-897`)。 +- **没有 web UI 问答**。`openwiki visualize` 只是只读的节点图 + Markdown 阅读器本地服务/静态导出(`src/cli/commands.ts:305-402`;`src/visualize/`)。OKF frontmatter 的 description 字段是为"外部检索工具"预留的接口,自身未实现检索。 +- personal 模式有 9 类连接器摄取(`src/connectors/sources/`:mcp、slack、gmail、x、web-search、hackernews、langsmith、git-repo),`openwiki ingest` 触发(`src/cli/commands.ts:404-486`),但这是个人知识库方向,与代码 wiki 主线正交。 + +## 5. MCP / Agent 集成 + +- **MCP server**:`openwiki mcp` 暴露 6 个生命周期工具 `openwiki_begin / submit_plan / next_page / inspect_page_claims / submit_page / finish`(`src/integrations/mcp/server.ts:11-30` 的 INSTRUCTIONS),本质是把第 3 节的持久化队列开放给外部宿主——宿主 coding agent(用自己的模型和原生仓库工具)做研究和写页,OpenWiki 管队列、校验、Claims、收尾(`integrations/openwiki/SKILL.md:8-65` 详述契约)。 +- **`skills/` 目录**:随包分发的 deepagents 技能(`mermaid-diagrams`、`write-connector`),安装时原子同步到 `~/.openwiki`(`src/agent/skills.ts:24-118`,含读-only Nix 场景的权限自愈),运行时以 `skills: ["/skills/"]` 注入 agent(`src/agent/repository-runner.ts:348,484`)。 +- **宿主集成**:`openwiki integrations install codex|claude|opencode|cursor` 安装 SKILL.md+MCP 配置(user 级或 `--project` 仓库级),支持 list/uninstall(`src/cli/commands.ts:539-650`;`src/integrations/install/registry.ts`)。 + +## 6. 值得注意的工程设计 + +- **可恢复页作业生命周期**:`begin → submit_plan → next_page → submit_page → finish`,每页推进前 Markdown+Claims+manifest 均落盘;CI(GitHub Actions/GitLab/Bitbucket 定时 workflow,由 `ensureCodeModeRepoSetup` 自动生成 `.github/workflows/openwiki-update.yml`,`src/ingestion/code-mode.ts:65-93`)跑挂后重跑即续。 +- **Claims 账本 + 证据版本重锚**(见第 3 节):把"文档是否过时"从模糊判断变成可机械验证的状态机,这是与 DeepWiki 类产品拉开差距的核心。 +- **eval 体系非常重**:LEDGER(`evals/ledger/`)——回放 git checkpoint、逐条抽取 wiki 原子事实命题、判定 supported/stale/invented(hallucinated)/unverified 四态等分母分区(`evals/ledger/README.md`),带 BM25 语义 evidence map 路由、金标准一致性 ≥0.90 的 judge 元评估门槛(`evals/ledger/meta/README.md`);另有 DeepSWE 配对实验验证"有 wiki 的 Codex 是否更能修 bug"(`evals/deepswe/README.md`)。 +- **成本/一致性取舍**:页队列**严格串行**(`while(true) nextRepositoryPage`,`src/agent/repository-runner.ts:380-396`),无并行页生成;planner/page worker 均禁止委派子代理。 +- 其他:细粒度 telemetry(仅 init/update 发一条 `openwiki_run` 事件,`src/cli/commands.ts:1062-1068`);技能目录原子安装处理 Windows EPERM/并发竞争(`src/agent/skills.ts:53-118`);Windows ACL 处理(`src/platform/windows-acl.ts`)。 + +## 7. 明显短板/局限 + +1. **无检索层**:问答靠 agent 拿 grep/glob 翻 wiki 文件,wiki 大了以后命中率和 token 成本都会退化;OKF description 字段是"留给别人做检索"的空位。 +2. **无静态分析**:不建 AST/调用图/依赖图,结构质量完全取决于 planner 模型的判断;跨页一致性只靠 plan 里的 relatedPages 和 quickstart 路由,无机械校验(除内链外)。 +3. **串行逐页生成**:大仓库一次 init 可能几十个 page job 顺序跑,时长和费用高,且没有页级并行。 +4. **worker 失败静默降级**:页 worker 崩溃只回滚快照并跳过(`src/agent/repository-runner.ts:505-517`),一次 run 可能留下不完整 wiki,要等下一轮 update 补。 +5. **状态文件入侵仓库**:`openwiki/.run.json`、`.claims/`、`.page-manifest.json`、`.last-update.json` 全部进 git;强 git 依赖(无 git 的目录指纹走 unborn 分支特殊路径)。 +6. **personal 模式 cron 偏 macOS**(launchd 语义,`src/cli/commands.ts:1151-1167` 帮助文本);交互聊天需 TTY,非 TTY 只能 `--print` 单轮(`src/cli/commands.ts:1040-1049`)。 +7. **无 web 问答服务**:visualizer 是只读阅读器,对比 DeepWiki 的"网页问答"形态是明显缺位。 + +## 一句话总结 + +OpenWiki 的差异化不在"生成 wiki"本身,而在**工程化的事实治理**:两阶段 plan→逐页生成、持久化可恢复队列、git 基线 + 源指纹 + 页级 checkpoint 的多层增量、带证据版本重锚的 Grounded Claims、OKF 标准化输出,以及 LEDGER 纵向漂移 eval——但检索/问答和静态分析层面几乎空白,问答完全靠 agent 翻文件。 diff --git a/.research-competitors/openwiki b/.research-competitors/openwiki new file mode 160000 index 0000000..1e6d54c --- /dev/null +++ b/.research-competitors/openwiki @@ -0,0 +1 @@ +Subproject commit 1e6d54cdfeec334c29cf61800610193f33dc0d24 diff --git a/.research-competitors/wikiskill b/.research-competitors/wikiskill new file mode 160000 index 0000000..02fac2c --- /dev/null +++ b/.research-competitors/wikiskill @@ -0,0 +1 @@ +Subproject commit 02fac2c804fe156e43b12c691e4ae527614d63a1 diff --git a/.scratch/skill-creator/issues/t1.md b/.scratch/skill-creator/issues/t1.md new file mode 100644 index 0000000..a1ebf48 --- /dev/null +++ b/.scratch/skill-creator/issues/t1.md @@ -0,0 +1,20 @@ +## Parent + +#23 + +## What to build + +技能(skill)成为一等页面资产被全库感知:schema 的页面类型声明新增 skill 类型(目录指向草稿区,必填章节沿用场景块五段骨架),配置层新增草稿区与生效区两个目录常量。验收方式本身就是 tracer:手写一份技能草稿放进草稿区,lint 按该类型的必填章节校验它,索引统计能看见它;生效区目录不受 repowiki 任何扫描(ADR-0004 的目录边界在此落地)。 + +## Acceptance criteria + +- [ ] schema 页面类型含 skill,声明草稿区目录与五段必填章节(工作场景/适用条件/核心 SOP/判断逻辑/禁忌与反模式) +- [ ] 配置层有草稿区与生效区目录常量,页面类型路由感知 skill +- [ ] 手写技能草稿落草稿区后,lint 按必填章节校验通过;缺章节报 error +- [ ] 索引构建纳入草稿区技能页(容量统计/检索可见性的地基) +- [ ] 生效区目录不进任何 repowiki 扫描(lint/索引) +- [ ] handler 级测试覆盖(fixture repowiki) + +## Blocked by + +- None(可立即开工) diff --git a/.scratch/skill-creator/issues/t2.md b/.scratch/skill-creator/issues/t2.md new file mode 100644 index 0000000..5e506a1 --- /dev/null +++ b/.scratch/skill-creator/issues/t2.md @@ -0,0 +1,21 @@ +## Parent + +#23 + +## What to build + +编译闭环的前半程:调用方 agent 调 `skill_creator(mode="prepare")` 拿到全部候选素材——未被任何技能吸收的场景块、精选高价值单条笔记、既有技能名下的 open issues(flag_issue 聚合)——外加 name/description 冲突预检、容量预警(红/橙)与写作规范系统提示(description 条件+行动、五段骨架、证据段带笔记回链、正文 ≤8KB、禁绝对路径与密钥),全程零副作用。agent 写完后 `mode="submit"` 把 SKILL.md 落草稿区:frontmatter 校验失败报具体规则名,成功则写溯源互链(技能 source_refs ⇄ 素材 compiled_into)、追加 revisions、重建索引。素材不足时空产出(no_action)是合法轮次,不视为失败。 + +## Acceptance criteria + +- [ ] prepare 返回候选素材、冲突预检、容量预警、写作系统提示,无任何落盘副作用 +- [ ] prepare 的候选集按 compiled_into 排除已编译素材,并聚合目标技能名下 open issues +- [ ] submit 校验失败(缺 status / 正文超限 / 敏感串 / description 无触发语义)返回具体规则名 +- [ ] submit 成功落草稿区,溯源双向互链,revisions 追加,索引重建 +- [ ] 空产出(no_action)合法,不产生文件 +- [ ] 防碎片纪律提示在 prepare 中返回(默认 UPDATE、每批最多新建 1 份、新建前对比 ≥2 份相似技能) +- [ ] handler 级测试覆盖全部 mode 行为 + +## Blocked by + +- T1(词汇层地基) diff --git a/.scratch/skill-creator/issues/t3.md b/.scratch/skill-creator/issues/t3.md new file mode 100644 index 0000000..f077985 --- /dev/null +++ b/.scratch/skill-creator/issues/t3.md @@ -0,0 +1,19 @@ +## Parent + +#23 + +## What to build + +两区制的生效侧(ADR-0004):用户确认后调 `skill_creator(mode="install")`,把草稿区技能**剥离管理元数据**(只留 name/description/正文)装入生效区供宿主 IDE 自动发现;同时把 installed_at 与规范化内容哈希(name+description+正文)写回草稿区 frontmatter,供漂移检测比对。install 幂等(重复调用不产生重复文件/副作用)。`mode="retire"` 标记 deprecated 并从生效区移除,草稿正文保留供审计、误退役可恢复。生效区文件只由 install 程序化生成,不支持直接编辑。 + +## Acceptance criteria + +- [ ] install 产出的生效区 SKILL.md 仅含 name/description/正文(type/status/generated/stale_after/metadata 全部剥离) +- [ ] installed_at 与规范化哈希写回草稿区 frontmatter +- [ ] install 幂等:二次调用无副作用 +- [ ] retire 标记 deprecated + 生效区移除 + 草稿正文保留 +- [ ] 生效区产物断言(外部可见文件内容,不测内部函数) + +## Blocked by + +- T1(词汇层地基;与 prepare/submit 工单并行——install 对手工 fixture 草稿即可测试) diff --git a/.scratch/skill-creator/issues/t4.md b/.scratch/skill-creator/issues/t4.md new file mode 100644 index 0000000..60d4a67 --- /dev/null +++ b/.scratch/skill-creator/issues/t4.md @@ -0,0 +1,20 @@ +## Parent + +#23 + +## What to build + +质量闸门:草稿区纳入 lint 扫描(进索引就进 lint,不留法外之地),新增八项技能检查——六项 error(name slug 合规 / description 非空且含触发条件语义 / frontmatter 完整 status+source_refs / 正文 ≤8KB / 无敏感串 / 修订必有 revisions)与两项 warning(素材过期联动:source_refs 指向的素材被更新/退役/deprecate → 技能标 possibly_stale,复用对端新鲜度语义;漂移检测:草稿区规范化哈希 ≠ installed_hash → 提示"草稿已修订,生效区仍旧版,建议 reinstall")。漂移只告警不自动覆盖生效区——生效是用户动作。 + +## Acceptance criteria + +- [ ] 六项 error 规则各自有正反用例(违规报 error、合规通过) +- [ ] possibly_stale 联动:素材 retire/deprecate 后技能被标 warning +- [ ] 漂移检测:草稿修订后生效区未 reinstall 时报 warning;reinstall 后恢复干净 +- [ ] 容量阈值(硬顶 12/橙线 9)在 lint 或 prepare 中告警 +- [ ] 既有 lint 行为无回归(全量测试) + +## Blocked by + +- T1(页面类型) +- T3(漂移检测依赖 install 定下的规范化哈希契约) diff --git a/.scratch/skill-creator/issues/t5.md b/.scratch/skill-creator/issues/t5.md new file mode 100644 index 0000000..00db047 --- /dev/null +++ b/.scratch/skill-creator/issues/t5.md @@ -0,0 +1,18 @@ +## Parent + +#23 + +## What to build + +检索隔离(spec §检索双通道防混淆):skill 页面参与索引构建(lint/容量统计/prepare 候选扫描依赖它),但**不进 query_wiki 召回排序**——agent 检索时不会把行为指令当检索知识引用。过滤收口在检索入口按页面类型排除(单点收敛,不散落到各调用方),与既有 freshness gate 同位置。 + +## Acceptance criteria + +- [ ] 含技能草稿的 fixture 下,任意查询不召回 skill 页面 +- [ ] 索引构建仍纳入 skill 页(容量统计/lint 可见) +- [ ] 过滤逻辑收口在检索入口单点,调用方无感知 +- [ ] 既有检索行为无回归(BM25 排序、est_tokens 等不受影响) + +## Blocked by + +- T1(页面类型;与 T2/T3/T4 并行) diff --git a/.scratch/skill-creator/issues/t6.md b/.scratch/skill-creator/issues/t6.md new file mode 100644 index 0000000..eb2e4bb --- /dev/null +++ b/.scratch/skill-creator/issues/t6.md @@ -0,0 +1,22 @@ +## Parent + +#23 + +## What to build + +集成与发布收口:用本仓真实素材(现存场景块/笔记)走一遍完整闭环——prepare → 宿主 agent 写 SKILL.md → submit → install → 真机验证 CodeBuddy 技能发现确实加载该技能 → 人工试用发现问题 flag_issue → prepare 聚合 issue → 修订 submit(revisions 留痕)→ reinstall。同时完成接口同步终检(handler / registry / prompts 正文+注册 / resources / README 中英 / docs 设计文档 / 测试七处)与全量回归。 + +## Acceptance criteria + +- [ ] 真实素材全链走通:编译→生效→真机发现→反馈→修订→再生效 +- [ ] CodeBuddy 真机确认 install 产物可被发现并触发(人工验证,结果记录回 issue) +- [ ] 七处接口同步清单逐项核对无遗漏 +- [ ] 全量 pytest 通过 + ruff(0.16.3)零告警 +- [ ] 设计文档(docs/skill-creator需求与设计方案.md)状态更新为已实施 + +## Blocked by + +- T2(prepare/submit) +- T3(install/retire) +- T4(lint 八项) +- T5(检索隔离) diff --git a/repowiki/.meta/page_manifest.json b/repowiki/.meta/page_manifest.json index 1dc4fd7..2ebb616 100644 --- a/repowiki/.meta/page_manifest.json +++ b/repowiki/.meta/page_manifest.json @@ -11,31 +11,31 @@ "written_at": "2026-09-04T02:53:13Z" }, "wiki/modules/MCP_Tools_DocWriter.md": { - "git_head": "2ce750757a12ff54756c42319c05d248a2454505", + "git_head": "c1beee982fddc6824557000c9e5b12b1a3bdf56f", "components": [], "files": [], - "source_fingerprint": null, + "source_fingerprint": "sha256:2e712f60c5e95c28c2954fc948e042a15c0a8afc3b66fec7cf827bd0f756b280", "repo": "CodeWiki-CN", - "producer": "codewiki/5.5.1", - "written_at": "2026-09-04T02:55:01Z" + "producer": "codewiki/5.6.1", + "written_at": "2026-09-06T10:14:35Z" }, "wiki/modules/KnowledgeStore.md": { - "git_head": "2ce750757a12ff54756c42319c05d248a2454505", + "git_head": "c1beee982fddc6824557000c9e5b12b1a3bdf56f", "components": [], "files": [], - "source_fingerprint": null, + "source_fingerprint": "sha256:7df42501dddae9f045aac3f18c662b9afed47ac1c9a49ce61fb347e5680c8b08", "repo": "CodeWiki-CN", - "producer": "codewiki/5.5.1", - "written_at": "2026-09-04T04:17:47Z" + "producer": "codewiki/5.6.1", + "written_at": "2026-09-06T10:15:01Z" }, "wiki/modules/MCP_Tools_Workspace.md": { - "git_head": "2ce750757a12ff54756c42319c05d248a2454505", + "git_head": "c1beee982fddc6824557000c9e5b12b1a3bdf56f", "components": [], "files": [], - "source_fingerprint": null, + "source_fingerprint": "sha256:7f17f7a8e3734e8b2a4936f6fc58ba5c5a6624890ab27d0e27d328a0faf50e9e", "repo": "CodeWiki-CN", - "producer": "codewiki/5.5.1", - "written_at": "2026-09-04T04:17:55Z" + "producer": "codewiki/5.6.1", + "written_at": "2026-09-06T10:13:54Z" }, "wiki/sources/README_CN_2.0.md": { "git_head": null, @@ -117,6 +117,15 @@ "repo": null, "producer": "codewiki/5.6.0", "written_at": "2026-09-05T15:39:25Z" + }, + "wiki/modules/MCP_Tools_Knowledge.md": { + "git_head": "c1beee982fddc6824557000c9e5b12b1a3bdf56f", + "components": [], + "files": [], + "source_fingerprint": "sha256:2319a3be4d4bdb1f4c072778c36709a292d2293497e857f5c8c9b104ffd0ca13", + "repo": "CodeWiki-CN", + "producer": "codewiki/5.6.1", + "written_at": "2026-09-06T10:14:09Z" } } } \ No newline at end of file diff --git a/repowiki/.meta/telemetry/local.jsonl b/repowiki/.meta/telemetry/local.jsonl index 75a972b..ff2be23 100644 --- a/repowiki/.meta/telemetry/local.jsonl +++ b/repowiki/.meta/telemetry/local.jsonl @@ -104,3 +104,59 @@ {"t": "hit", "doc": "notes/2026-08-26-analyze-changes-的-changed-components-行区间定位是近似跨函数边界会误报组件.md", "at": "2026-08-29", "n": 4} {"t": "hit", "doc": "notes/2026-08-25-知识摄入到自动检索链路ingest-note-自动写索引close-session-兜底终态.md", "at": "2026-08-29", "n": 4} {"t": "hit", "doc": "notes/2026-08-29-工作区痕迹齐备时跳过-init-workspace直接跑-bootstrap-脚本补-clone.md", "at": "2026-08-29", "n": 1} +{"t": "hit", "doc": "notes/2026-08-29-生成的-ps1-必须带-utf-8-bom否则-powershell-51-按-gbk-误读.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "notes/2026-09-05-github-release-正文乱码不可逆utf-8-字节被按-gbk-解码写入只能基于事实重写.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "notes/2026-08-03-mcp-工具-schema-不声明-session-idhandler-隐式读取.md", "at": "2026-09-06", "n": 7} +{"t": "hit", "doc": "wiki/modules/KnowledgeStore.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "wiki/scenarios/Wiki页面生成约定与数据结构.md", "at": "2026-09-06", "n": 8} +{"t": "hit", "doc": "wiki/scenarios/MCP-Server薄壳架构与参数约定.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "wiki/modules/CLI_Adapter.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "notes/2026-08-26-load-project-checklist-对-yaml-损坏静默回退-none-无日志难排查.md", "at": "2026-09-06", "n": 3} +{"t": "hit", "doc": "wiki/modules/CLI_Utils.md", "at": "2026-09-06", "n": 3} +{"t": "hit", "doc": "notes/2026-08-29-工作区痕迹齐备时跳过-init-workspace直接跑-bootstrap-脚本补-clone.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "notes/2026-08-29-subagent-定义的-frontmatter-按宿主家族分发同名文件不同-schema.md", "at": "2026-09-06", "n": 3} +{"t": "hit", "doc": "wiki/modules/MCP_Core.md", "at": "2026-09-06", "n": 8} +{"t": "hit", "doc": "wiki/modules/MCP_Server.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "wiki/modules/MCP_Prompts.md", "at": "2026-09-06", "n": 4} +{"t": "hit", "doc": "notes/2026-09-05-codewiki-plus-发布是手动流程三处版本引用-lightweight-tag-ci-不发布.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "notes/2026-08-25-doctrine-不会自动注入-agent-上下文唯一通道是-query-wikimodeoverview.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "notes/2026-08-23-distill-worker-subagent-定义随包发布hook-启用时自动拷贝到项目-codebuddyagent.md", "at": "2026-09-06", "n": 3} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Analysis.md", "at": "2026-09-06", "n": 9} +{"t": "hit", "doc": "notes/2026-08-26-analyze-repo-增量与依赖图谱的测试并行执行存在时序竞态.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "notes/2026-08-26-analyze-changes-的-changed-components-行区间定位是近似跨函数边界会误报组件.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/modules/LLM_Backend.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Quality.md", "at": "2026-09-06", "n": 4} +{"t": "hit", "doc": "notes/2026-08-26-fork-来源的-pr-与目标分支冲突时的维护者合入流程merge-tree-探测-worktree-push-fork.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "wiki/modules/DocVisualizer.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "wiki/modules/CLI_Commands.md", "at": "2026-09-06", "n": 3} +{"t": "hit", "doc": "wiki/scenarios/发布与依赖治理方法.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-09-01-teamai-cli-多人文件冲突处理全景靠划分防冲突不靠锁v0210-源码核实.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/modules/MCP_Cache.md", "at": "2026-09-06", "n": 9} +{"t": "hit", "doc": "notes/2026-08-29-analyze-workspace-增量模式内部自动判断做主路径锚点复用-metadatajson.md", "at": "2026-09-06", "n": 4} +{"t": "hit", "doc": "wiki/scenarios/代码评审与分析工具方法.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-08-25-untracked-新文件不在分析图谱内review-changes-changed-sources-切片为空.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-09-04-d19knowledgestore-跨进程锁文件集中到-wiki-rootmetalockssha256目标绝对路径20.md", "at": "2026-09-06", "n": 4} +{"t": "hit", "doc": "wiki/modules/AnalysisPipeline.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "notes/2026-09-05-stale-evidence-只驱动复核提醒仅处理带-content-hash-的条目报-warning-且不自动改写.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-09-05-query-wiki-p0-改进四项定案rev2-评审定稿est-tokens-by-file-v1-仅-notes-新.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "notes/2026-08-26-ruff-format-panic-的根因是误提交的一次性诊断脚本检查步骤勿因工具崩溃轻率移除.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "notes/2026-09-05-adr-0003知识新鲜度判据采用-git-最后提交时间否决-mtimeclone-场景全量假阳性.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-09-05-设计文档评审先派子代理核对代码事实引用行号偏移-25-30-行引用不存在的函数对与符号frontmatter-rende.md", "at": "2026-09-06", "n": 3} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Knowledge.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-08-25-mcp-参数长度受限时蒸馏-submit-走文件侧通道python-脚本直接调-handle-distill-conve.md", "at": "2026-09-06", "n": 3} +{"t": "hit", "doc": "wiki/reading-guide.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/modules/SharedConfig.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "wiki/entities/WeKnoraMCP_Server.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/modules/AnalyzerModels.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "wiki/modules/RouteExtractors.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_DocWriter.md", "at": "2026-09-06", "n": 4} +{"t": "hit", "doc": "wiki/modules/GraphAndSort.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-09-05-query-wiki-的-check-模式是轻量预检不计入检索统计不污染-usageheat-排序信号工作流若不内建到工.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-08-26-handle-query-wiki-在-session-存在时每次查询都全量重建检索索引.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Dependency.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-09-04-file-lock-的锁文件可能是数据文件本身释放即删只能加在-storelocked-不能下沉到-file-lock.md", "at": "2026-09-06", "n": 2} +{"t": "hit", "doc": "notes/2026-09-05-检索预算口径打架前门-1200-字符-snippet-300-10-条只-4-条带内容expand-后门-1020000.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-08-26-type-filter-是单值精确匹配设计要优先-5-类需多次查询合并.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-08-29-引用已有笔记前须检查其-statusdeprecated-笔记不应被采纳.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/modules/CLI_Config.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/modules/CLI.md", "at": "2026-09-06", "n": 1} diff --git "a/repowiki/conversations/conv-\350\260\203\347\240\224-openwiki\343\200\201deepwiki-open\343\200\201OpenDeepWiki\343\200\201deepwiki-rs-\345\233\233\344\270\252-DeepWi.md" "b/repowiki/conversations/conv-\350\260\203\347\240\224-openwiki\343\200\201deepwiki-open\343\200\201OpenDeepWiki\343\200\201deepwiki-rs-\345\233\233\344\270\252-DeepWi.md" new file mode 100644 index 0000000..8d43b54 --- /dev/null +++ "b/repowiki/conversations/conv-\350\260\203\347\240\224-openwiki\343\200\201deepwiki-open\343\200\201OpenDeepWiki\343\200\201deepwiki-rs-\345\233\233\344\270\252-DeepWi.md" @@ -0,0 +1,27 @@ +--- +type: Conversation +title: "conversation 20260906T080846Z" +status: distilled +stale_after: 2026-12-05 +generated: { by: codewiki/5.5.0, at: 2026-09-06T08:08:46Z } +captured_at: "2026-09-06T08:08:46Z" +content_hash: "sha256:5086dfd895681efcdd0a2605fcab0ca2c5dcd500e6cc913b415e3e93383f543b" +friction_score: 0 +friction_signals: "correction=0,interrupt=0,repeat=0,user_turns=4" +keep_raw: false +link_to: "" +source_session: "qwenwork-mtpcvqihrpgf2088" +turn_count: 9 +--- + +# Conversation Transcript + +user: 调研 openwiki、deepwiki-open、OpenDeepWiki、deepwiki-rs 四个 DeepWiki 类开源项目,与 CodeWiki-Plus 做五项目对比,产出可借鉴点调研报告。 +assistant: 跳过任务关联。clone 四仓(commit 时点 2026-08-15~09-04),四个后台子代理并行读源码产出 notes/*.md(全部带文件:行号依据)。关键事实:①deepwiki-open 是 Next.js 反代 + Python FastAPI/AdalFlow 双进程,FAISS 单路召回无 rerank,缓存键无 commit hash 无增量,纯 Web 无 MCP;②openwiki 零静态分析,LLM agent 读文件,四层增量机制(git HEAD no-op/sha256 源指纹/页级 checkpoint/Grounded Claims preflight),确定性收尾 finalizer;③OpenDeepWiki 无 AST,LLM+ReadFile/ListFiles/Grep 三件套,DB 租约+槽位+心跳集群调度,MCP 带 OAuth 2.1+用量统计;④deepwiki-rs 已转向后继项目 Terrain 维护态,正则冒充 AST、宣称并行实际串行、死旗标、缓存不校验 model_name——README 与代码多处不符。GitHub 元数据:17.9k/16.2k/3.6k/1.7k 全 MIT。产出 docs/DeepWiki类开源项目对比调研报告-2026-09.md:六维度对比表+宣传水分清单+B1-B10 借鉴清单(排除已落地的 D1-D5)。验证子代理核查 16 项代码引用全一致,修正 2 处路径层级。结论:四竞品全走 LLM 直读路线,CodeWiki 的 AST 调用图是唯一稀缺资产;知识治理仅 CodeWiki 与 openwiki 认真做。 +user: 报告 B2/B6/B1 三件事做完后各有什么收益? +assistant: B2=LLM 调用缓存省钱提速+token 可观测;B6=stale_evidence 回灌增量决策补盲区(共享池页 git diff 摸不到),D1/D2 投资回本;B1=索引内链确定性收尾自愈。三件连成一条线:重跑便宜→范围精确→产物干净。 +user: 用 grill-with-docs 对 B2/B6/B1 设计拷问。 +assistant: 读 grill-with-docs SKILL.md 发现是薄壳(调 grilling+domain-modeling)。代码核对发现三处调研报告偏差:B2 痛点过重(analyze_repo 已有 no_changes 短路+affected_modules 限定,且模块生成是 agentic 多轮调用,MD5(prompt) 缓存不适用,只能做模块级指纹缓存);B1 一半已存在(analyze_repo:380 与 doc_writer:1577 已调 rebuild_index,真实缺口仅 generate_docs 无收尾);B6 挂点=_enrich_stale_pages 之后。两轮拷问 7 问拍板:Q1 页面清单+状态分布进 changes_info(明细留 lint);Q2 抽公共函数 collect_evidence_drift 放 evidence.py 单点收敛,lint 改薄包装;Q3 no_changes 路径静默(用户拍板,与 stale_pages 行为不对称是接受的取舍,ADR-0005);Q4 hint 引导复核+re-stamp 不引导重写(守证据只驱动提醒红线);Q5 stale_pages 与 stale_evidence_pages 并存不去重;Q6 新增 _enrich_stale_evidence 与 D2 后置步对称;Q7 测试五项+不触发接口同步规则。 +user: 不做 B2/B1 了,只做 B6。全部按推荐。确认无误,按 1-4 开始实现。 +assistant: 实现:evidence.py 新增 collect_evidence_drift(entry 级明细 file/resource/status,跳过无 content_hash 存量页与 scratch/raw 目录,复用 evidence_roots 多根解析);wiki_lint.py _check_stale_evidence 改薄包装(行为与既有测试不变);analysis.py 新增 _enrich_stale_evidence 只挂 handle_analyze_repo 变更路径(发现 _build_no_change_response 里误挂后撤掉,显式不调并注释指向 ADR-0005)。测试 tests/test_stale_evidence_signal.py 10 项(漂移矩阵/存量页跳过/no_changes 静默/与 stale_pages 共存不去重/lint 薄包装回归/colocated 冒烟),中途修两处测试自身 bug(gone.py stamp 前不存在、Fresh 页误引被改的 calc.py)。定向回归 119 过+ruff 过;用户中断全量 pytest,确立定向验证偏好。产出 ADR-0005 与调研报告勘误段(勘误不改正文,记录调研报告与代码现实的差距作镜鉴)。提交 784e285(B6 实现)+ 626d684(调研报告+系列12文章)。 +assistant: (会话另含:公众号系列12文章《四个DeepWiki复刻的源码横评——谁在做工程谁在做Demo》的写作,humanizer 去AI味,编号续接系列11,8.6k字,修正一处无依据的'两年前'时间表述;用户偏好:子代理跑完直接写报告不确认、全量测试改定向) diff --git "a/repowiki/notes/2026-09-04-agent-hook-\346\263\250\345\205\245\347\232\204\345\267\245\347\250\213\345\214\226\351\230\262\345\276\241\344\270\216\351\230\262\346\274\202\347\247\273\345\217\257\345\244\215\347\224\250\346\250\241\345\274\217caveman-\346\217\220\347\202\274.md" "b/repowiki/notes/2026-09-04-agent-hook-\346\263\250\345\205\245\347\232\204\345\267\245\347\250\213\345\214\226\351\230\262\345\276\241\344\270\216\351\230\262\346\274\202\347\247\273\345\217\257\345\244\215\347\224\250\346\250\241\345\274\217caveman-\346\217\220\347\202\274.md" index 7dbe593..21476b5 100644 --- "a/repowiki/notes/2026-09-04-agent-hook-\346\263\250\345\205\245\347\232\204\345\267\245\347\250\213\345\214\226\351\230\262\345\276\241\344\270\216\351\230\262\346\274\202\347\247\273\345\217\257\345\244\215\347\224\250\346\250\241\345\274\217caveman-\346\217\220\347\202\274.md" +++ "b/repowiki/notes/2026-09-04-agent-hook-\346\263\250\345\205\245\347\232\204\345\267\245\347\250\213\345\214\226\351\230\262\345\276\241\344\270\216\351\230\262\346\274\202\347\247\273\345\217\257\345\244\215\347\224\250\346\250\241\345\274\217caveman-\346\217\220\347\202\274.md" @@ -10,6 +10,7 @@ metadata: severity: medium source_ref: conversations/conv-https-github.com-JuliusBrussee-caveman.git-研究下这个技能是如何生效的.md scene: 他山之石-caveman研究 + source_conversations: ['conversations/conv-https-github.com-DietrichGebert-ponytail-研究下这个技能是如何生效的.md'] status: stable author: iamwangbao-163-com generated: @@ -20,7 +21,6 @@ origin: conversation verified: - by: codewiki/5.5.1 at: '2026-09-04T04:25:35Z' -source_conversations: ['conversations/conv-https-github.com-DietrichGebert-ponytail-研究下这个技能是如何生效的.md'] --- diff --git "a/repowiki/notes/2026-09-05-ingest-source-\345\206\262\347\252\201\345\220\214\346\272\220\347\241\256\350\256\244\351\227\270\351\227\250\345\233\233\345\261\202l0-sha-256-l1-version-sibling-\350\257\255\344\271\211\346\214\207\347\272\271-l.md" "b/repowiki/notes/2026-09-05-ingest-source-\345\206\262\347\252\201\345\220\214\346\272\220\347\241\256\350\256\244\351\227\270\351\227\250\345\233\233\345\261\202l0-sha-256-l1-version-sibling-\350\257\255\344\271\211\346\214\207\347\272\271-l.md" index 1170ca1..c4b1a9b 100644 --- "a/repowiki/notes/2026-09-05-ingest-source-\345\206\262\347\252\201\345\220\214\346\272\220\347\241\256\350\256\244\351\227\270\351\227\250\345\233\233\345\261\202l0-sha-256-l1-version-sibling-\350\257\255\344\271\211\346\214\207\347\272\271-l.md" +++ "b/repowiki/notes/2026-09-05-ingest-source-\345\206\262\347\252\201\345\220\214\346\272\220\347\241\256\350\256\244\351\227\270\351\227\250\345\233\233\345\261\202l0-sha-256-l1-version-sibling-\350\257\255\344\271\211\346\214\207\347\272\271-l.md" @@ -4,16 +4,16 @@ title: "ingest_source 冲突/同源确认闸门四层:L0 SHA-256 / L1 version_ tags: ["decision"] metadata: date: 2026-09-05 -related_modules: ["source_ingest", "doc_similarity", "registry", "README_CN", "README_CN_f03499.md"] severity: high source_ref: "conversations/conv-user_command-commands-codewiki-外部文档知识抽取-请导入外部文档并从中抽取结构化知识。采用-2.md" scene: "知识生命周期" + source_conversations: ['conversations/conv-user_command-commands-codewiki-外部文档知识抽取-请导入外部文档并从中抽取结构化知识。采用.md'] +related_modules: ["source_ingest", "doc_similarity", "registry", "README_CN", "README_CN_f03499.md"] status: stable author: iamwangbao-163-com generated: { by: codewiki/5.6.0, at: 2026-09-05T11:35:03Z } stale_after: 2027-09-05 origin: conversation -source_conversations: ['conversations/conv-user_command-commands-codewiki-外部文档知识抽取-请导入外部文档并从中抽取结构化知识。采用.md'] --- diff --git "a/repowiki/notes/2026-09-06-b6-\350\220\275\345\234\260stale-evidence-\346\274\202\347\247\273\344\277\241\345\217\267\346\210\220\344\270\272\345\242\236\351\207\217\345\206\263\347\255\226\347\254\254\344\270\211\344\277\241\345\217\267\346\272\220adr-0005.md" "b/repowiki/notes/2026-09-06-b6-\350\220\275\345\234\260stale-evidence-\346\274\202\347\247\273\344\277\241\345\217\267\346\210\220\344\270\272\345\242\236\351\207\217\345\206\263\347\255\226\347\254\254\344\270\211\344\277\241\345\217\267\346\272\220adr-0005.md" new file mode 100644 index 0000000..fce73bb --- /dev/null +++ "b/repowiki/notes/2026-09-06-b6-\350\220\275\345\234\260stale-evidence-\346\274\202\347\247\273\344\277\241\345\217\267\346\210\220\344\270\272\345\242\236\351\207\217\345\206\263\347\255\226\347\254\254\344\270\211\344\277\241\345\217\267\346\272\220adr-0005.md" @@ -0,0 +1,33 @@ +--- +type: decision +title: "B6 落地:stale_evidence 漂移信号成为增量决策第三信号源(ADR-0005)" +tags: ["decision"] +metadata: + date: 2026-09-06 + related_modules: ["mcp_tools_analysis", "evidence"] + severity: high + source_ref: "conversations/conv-调研-openwiki、deepwiki-open、OpenDeepWiki、deepwiki-rs-四个-DeepWi.md" + scene: "增量更新" +status: draft +author: local +generated: { by: codewiki/5.5.0, at: 2026-09-06T08:23:44Z } +stale_after: 2027-09-06 +origin: conversation + +--- + +## 背景 + +D1(证据哈希)落地后,stale_evidence 检查结果只进 lint 报告,不参与 analyze_repo 的增量决策。B6 把它接入,成为与 affected_modules(git diff 驱动)、stale_pages(D2 manifest 驱动)并列的第三信号源,覆盖 git diff 摸不到的共享池页(notes/entities/concepts 不在模块树里)。 + +## 决策 + +1. 信号形态:changes_info["stale_evidence_pages"] = {页面: {stale/missing/unresolvable 计数}},明细留在 lint(lint_wiki(checks=['stale_evidence']) 按需取),hint 引导复核+re-stamp 而非重写(守"证据只驱动提醒"红线)。 +2. 单点收敛:collect_evidence_drift()(codewiki/mcp/tools/evidence.py)作为 lint 与增量的共享采集点;wiki_lint._check_stale_evidence 改薄包装,行为不变。 +3. no_changes 路径静默(ADR-0005,用户拍板):analyze_repo 的 no_changes 短路不跑证据漂移扫描,与 stale_pages 在该路径 enrich 的行为不对称是显式接受的取舍;漂移出口是显式 lint。 +4. stale_pages 与 stale_evidence_pages 两字段并存不去重:语义不同(文件级变更 vs 证据级漂移),同页双命中是诚实的双重报告,不改 D2 已落地契约。 +5. 挂点:_enrich_stale_evidence 紧跟 _enrich_stale_pages 之后,只挂 handle_analyze_repo 变更路径;_build_no_change_response 里不调(实现时误挂过一次后撤掉,注释指向 ADR-0005)。 + +## 验证 + +tests/test_stale_evidence_signal.py 10 项 + 定向回归 119 过 + ruff 过(全量 pytest 亦通过)。提交 784e285。 diff --git "a/repowiki/notes/2026-09-06-deepwiki-\347\261\273\345\233\233\347\253\236\345\223\201\346\272\220\347\240\201\346\250\252\350\257\204\347\273\223\350\256\272llm-\347\233\264\350\257\273\346\230\257\344\270\273\346\265\201ast-\350\260\203\347\224\250\345\233\276\346\230\257-codewiki-\345\224\257\344\270\200\347\250\200\347\274\272\350\265\204\344\272\247.md" "b/repowiki/notes/2026-09-06-deepwiki-\347\261\273\345\233\233\347\253\236\345\223\201\346\272\220\347\240\201\346\250\252\350\257\204\347\273\223\350\256\272llm-\347\233\264\350\257\273\346\230\257\344\270\273\346\265\201ast-\350\260\203\347\224\250\345\233\276\346\230\257-codewiki-\345\224\257\344\270\200\347\250\200\347\274\272\350\265\204\344\272\247.md" new file mode 100644 index 0000000..26c72aa --- /dev/null +++ "b/repowiki/notes/2026-09-06-deepwiki-\347\261\273\345\233\233\347\253\236\345\223\201\346\272\220\347\240\201\346\250\252\350\257\204\347\273\223\350\256\272llm-\347\233\264\350\257\273\346\230\257\344\270\273\346\265\201ast-\350\260\203\347\224\250\345\233\276\346\230\257-codewiki-\345\224\257\344\270\200\347\250\200\347\274\272\350\265\204\344\272\247.md" @@ -0,0 +1,32 @@ +--- +type: architecture +title: "DeepWiki 类四竞品源码横评结论:LLM 直读是主流,AST 调用图是 CodeWiki 唯一稀缺资产" +tags: ["architecture", "codewiki", "deepwiki", "opendeepwiki"] +metadata: + date: 2026-09-06 + related_modules: ["dependency_analyzer"] + severity: medium + source_ref: "conversations/conv-调研-openwiki、deepwiki-open、OpenDeepWiki、deepwiki-rs-四个-DeepWi.md" + scene: "竞品调研" +status: draft +author: local +generated: { by: codewiki/5.5.0, at: 2026-09-06T08:23:37Z } +stale_after: 2027-09-06 +origin: conversation + +--- + +## 背景 + +2026-09-06 对 openwiki(16.2k★)、deepwiki-open(17.9k★)、OpenDeepWiki(3.6k★)、deepwiki-rs/Litho(1.7k★,已转向后继项目 Terrain 维护态)四个 DeepWiki 类项目做源码级横评(clone 后子代理通读,全部带文件:行号依据),报告在 docs/DeepWiki类开源项目对比调研报告-2026-09.md。 + +## 结论 + +1. 四个竞品全部走 LLM 直读代码路线,无一家做 AST/tree-sitter 静态分析——CodeWiki 的依赖分析管线在同类项目中没有直接竞争者。 +2. 知识治理(证据哈希/确认闸门/采纳反馈)仅 CodeWiki 与 openwiki 认真做;deepwiki-open 缓存键无 commit hash(无增量)、deepwiki-rs 每次删目录全量重写。 +3. deepwiki-open 用最弱工程拿最高 star,验证网页 Demo 传播效应:若需传播性,复用现有 Vue 前端做只读可视化比补技术短板划算。 +4. README 与代码多处不符(deepwiki-rs 宣称支持 Go 实际无 Go 处理器、宣称并行实际串行、死旗标、缓存不校验 model_name)——竞品调研必读源码再次验证。 + +## 适用范围 + +后续定位讨论、竞品对标、借鉴选型时引用;详细六维度对比表与 B1-B10 借鉴清单见报告原文。 diff --git "a/repowiki/notes/2026-09-06-\350\260\203\347\240\224\346\212\245\345\221\212\347\232\204\345\200\237\351\211\264\345\273\272\350\256\256\345\277\205\351\241\273\345\205\210\350\277\207\344\273\243\347\240\201\346\240\270\345\257\271b2b1-\347\227\233\347\202\271\350\241\250\350\277\260\344\270\216\345\256\236\351\231\205\344\270\215\347\254\246\347\232\204\344\270\211\345\244\204\346\225\231\350\256\255.md" "b/repowiki/notes/2026-09-06-\350\260\203\347\240\224\346\212\245\345\221\212\347\232\204\345\200\237\351\211\264\345\273\272\350\256\256\345\277\205\351\241\273\345\205\210\350\277\207\344\273\243\347\240\201\346\240\270\345\257\271b2b1-\347\227\233\347\202\271\350\241\250\350\277\260\344\270\216\345\256\236\351\231\205\344\270\215\347\254\246\347\232\204\344\270\211\345\244\204\346\225\231\350\256\255.md" new file mode 100644 index 0000000..af584c2 --- /dev/null +++ "b/repowiki/notes/2026-09-06-\350\260\203\347\240\224\346\212\245\345\221\212\347\232\204\345\200\237\351\211\264\345\273\272\350\256\256\345\277\205\351\241\273\345\205\210\350\277\207\344\273\243\347\240\201\346\240\270\345\257\271b2b1-\347\227\233\347\202\271\350\241\250\350\277\260\344\270\216\345\256\236\351\231\205\344\270\215\347\254\246\347\232\204\344\270\211\345\244\204\346\225\231\350\256\255.md" @@ -0,0 +1,31 @@ +--- +type: lesson +title: "调研报告的借鉴建议必须先过代码核对:B2/B1 痛点表述与实际不符的三处教训" +tags: ["lesson"] +metadata: + date: 2026-09-06 + related_modules: ["mcp_tools_analysis"] + severity: medium + source_ref: "conversations/conv-调研-openwiki、deepwiki-open、OpenDeepWiki、deepwiki-rs-四个-DeepWi.md" + scene: "竞品调研" +status: draft +author: local +generated: { by: codewiki/5.5.0, at: 2026-09-06T08:22:43Z } +stale_after: 2027-03-05 +origin: conversation + +--- + +## 背景 + +竞品报告建议 B2(LLM 调用缓存)、B1(确定性收尾)进入实现拷问(grill-with-docs)时逐项核对代码,发现报告痛点陈述与代码现实有偏差。 + +## 正确做法 + +1. B2 痛点过重:analyze_repo 已有 no_changes 短路与 affected_modules 限定两层省钱机制;且模块生成是 agentic 多轮调用(run_module_agent 带 tools 走完整轨迹),竞品 deepwiki-rs 式 MD5(prompt)→response 缓存不适用,只能做模块级指纹缓存(命中跳过整个 agent run)。 +2. B1 一半已存在:analyze_repo 末尾与 write_doc_file 都已调 rebuild_index+append_log;真实缺口仅 generate_docs(legacy_tools.py)跑完无收尾。 +3. 报告勘误段不改正文:原表述与代码的差距本身有价值,与报告自己列的"竞品宣传水分清单"互为镜鉴——调研报告也要接受自己提出的"读代码才算数"标准检验。 + +## 根因 + +报告的 CodeWiki 现状陈述基于 repowiki wiki 文档而非直接读代码,与调研竞品时"必读源码"的纪律不对称。借鉴建议进入实现前,对自身代码现状的断言同样需要行号级核对。 diff --git a/repowiki/wiki/doctrine.md b/repowiki/wiki/doctrine.md index 1055a4e..4eef6fe 100644 --- a/repowiki/wiki/doctrine.md +++ b/repowiki/wiki/doctrine.md @@ -1,6 +1,7 @@ --- type: Doctrine title: Team Operating Doctrine +aliases: [Team Doctrine, 团队共识, Doctrine] status: stable generated: by: human:wangbao diff --git "a/repowiki/wiki/entities/DeepSeekHarness\346\217\222\344\273\266.md" "b/repowiki/wiki/entities/DeepSeekHarness\346\217\222\344\273\266.md" index 4047a6c..5bb88fb 100644 --- "a/repowiki/wiki/entities/DeepSeekHarness\346\217\222\344\273\266.md" +++ "b/repowiki/wiki/entities/DeepSeekHarness\346\217\222\344\273\266.md" @@ -4,12 +4,11 @@ type: Entity description: "WeKnora 官方 DeepSeek Harness 插件 @wxg-prc-cpg/dsh-weknora,提供四个只读编码 Agent 工具" generated: { by: codewiki/5.6.0, at: 2026-09-05T12:00:27Z } stale_after: 2026-12-04 -aliases: [dsh-weknora, @wxg-prc-cpg/dsh-weknora, DeepSeek Harness 插件] +aliases: ["dsh-weknora", "@wxg-prc-cpg/dsh-weknora", "DeepSeek Harness 插件"] status: stable metadata: category: "集成" source_refs: ["README_CN_2.0"] - source_refs: ["README_CN_2.0"] chunk_refs: ["README_CN_2.0:197", "README_CN_2.0:197", "README_CN_2.0:199-202", "README_CN_2.0:197"] code_fingerprint: sha256:829467a7f49459ddf16d1711753a335b7338eb7409360e8d30565d9f78d11621 sources: diff --git a/repowiki/wiki/log-2026-09.md b/repowiki/wiki/log-2026-09.md index 817ea0f..15d57a3 100644 --- a/repowiki/wiki/log-2026-09.md +++ b/repowiki/wiki/log-2026-09.md @@ -83,3 +83,40 @@ ## 2026-09-06 * **close_session**: 会话关闭 +* **ingest_note**: 添加笔记: 调研报告的借鉴建议必须先过代码核对:B2/B1 痛点表述与实际不符的三处教训 +* **ingest_note**: 添加笔记: DeepWiki 类四竞品源码横评结论:LLM 直读是主流,AST 调用图是 CodeWiki 唯一稀缺资产 +* **ingest_note**: 添加笔记: B6 落地:stale_evidence 漂移信号成为增量决策第三信号源(ADR-0005) +* **lint_wiki**: 检查完成: 302 个问题 +* **edit_doc_file**: 更新 KnowledgeStore.md (str_replace) +* **edit_doc_file**: 更新 MCP_Tools_Workspace.md (str_replace) +* **edit_doc_file**: 更新 MCP_Tools_Knowledge.md (str_replace) +* **edit_doc_file**: 更新 MCP_Tools_DocWriter.md (str_replace) +* **edit_doc_file**: 更新 KnowledgeStore.md (str_replace) +* **lint_wiki**: 检查完成: 289 个问题 +* **lint_wiki**: 检查完成: 289 个问题 +* **lint_wiki**: 检查完成: 289 个问题 +* **lint_wiki**: 检查完成: 289 个问题 +* **flag_issue**: 新增问题: [custom] notes/2026-08-03-mcp-工具-schema-不声明-session-idhandler-隐式读取.md +* **flag_issue**: 更新问题: [custom] notes/2026-08-03-mcp-工具-schema-不声明-session-idhandler-隐式读取.md +* **flag_issue**: 新增问题: [custom] notes/2026-08-25-doctrine-不会自动注入-agent-上下文唯一通道是-query-wikimodeoverview.md +* **flag_issue**: 新增问题: [custom] notes/2026-08-23-distill-worker-subagent-定义随包发布hook-启用时自动拷贝到项目-codebuddyagent.md +* **flag_issue**: 新增问题: [custom] notes/2026-08-26-handle-query-wiki-在-session-存在时每次查询都全量重建检索索引.md +* **flag_issue**: 新增问题: [custom] notes/2026-08-25-mcp-参数长度受限时蒸馏-submit-走文件侧通道python-脚本直接调-handle-distill-conve.md +* **flag_issue**: 新增问题: [custom] notes/2026-08-25-untracked-新文件不在分析图谱内review-changes-changed-sources-切片为空.md +* **flag_issue**: 新增问题: [custom] notes/2026-08-26-load-project-checklist-对-yaml-损坏静默回退-none-无日志难排查.md +* **flag_issue**: 新增问题: [custom] notes/2026-08-29-生成的-ps1-必须带-utf-8-bom否则-powershell-51-按-gbk-误读.md +* **flag_issue**: 新增问题: [custom] notes/2026-08-29-工作区痕迹齐备时跳过-init-workspace直接跑-bootstrap-脚本补-clone.md +* **flag_issue**: 新增问题: [custom] notes/2026-08-29-登记业务仓后不自动生成-wiki必须等用户显式要求.md +* **flag_issue**: 新增问题: [no_outlinks] notes/ +* **flag_issue**: 新增问题: [custom] notes/ +* **flag_issue**: 新增问题: [custom] codewiki/cli/ +* **close_session**: 会话关闭 +* **lint_wiki**: 检查完成: 289 个问题 +* **lint_wiki**: 检查完成: 0 个问题 +* **flag_issue**: 新增问题: [custom] skills/maintain-fork-pr-merge/SKILL.md +* **lint_wiki**: 检查完成: 1 个问题 +* **lint_wiki**: 检查完成: 0 个问题 +* **lint_wiki**: 检查完成: 1 个问题 +* **lint_wiki**: 检查完成: 1 个问题 +* **lint_wiki**: 检查完成: 1 个问题 +* **lint_wiki**: 检查完成: 1 个问题 diff --git a/repowiki/wiki/modules/KnowledgeStore.md b/repowiki/wiki/modules/KnowledgeStore.md index dd429a3..0b6125b 100644 --- a/repowiki/wiki/modules/KnowledgeStore.md +++ b/repowiki/wiki/modules/KnowledgeStore.md @@ -21,22 +21,22 @@ metadata: sources: - id: repo://codewiki/src/store.py#L79-L160 resource: repo://codewiki/src/store.py#L79-L160 - content_hash: sha256:a4dc5c088e334661d91434f482452b6a7d688f5fa20d741e2ba4f2bad97de3a0 + content_hash: sha256:a46670f87e3b121e3d1d989e49a011a28a777fb9fe695a8f8e11aee48a743388 - id: repo://codewiki/src/store.py#L260-L345 resource: repo://codewiki/src/store.py#L260-L345 - content_hash: sha256:51b5d82ab14df71c7bfef2bbe15fc6afb9b963b1a5b1256efbd4cdc3a769a68d + content_hash: sha256:5d994d1f90f1ddecd168ae242aa94347545a95aa46d8e75902b36fdfaaa253c2 - id: repo://codewiki/src/store.py#L493-L557 resource: repo://codewiki/src/store.py#L493-L557 - content_hash: sha256:2b8765df88dbb809960aa978d0d53dfafc8fa8890c54140ab483a05acbf7b214 + content_hash: sha256:2c3607ba4d9b546e26c81aa1e579c89d43220d16d2ba789471b17e60894f7518 - id: repo://codewiki/src/store.py#L842-L906 resource: repo://codewiki/src/store.py#L842-L906 - content_hash: sha256:fb3440f655646ad2837eaafc2f5e459499032ed5a5442fd2ee025250beb9fd4e + content_hash: sha256:1b13726ff0448dc8fa18ca8f5ab71eda196cab58d557154f17b9c430f7e68d57 - id: repo://codewiki/src/frontmatter.py#L425-L482 resource: repo://codewiki/src/frontmatter.py#L425-L482 content_hash: sha256:89b40825a6442ce2a333f93a8bac158a9bea73b483f063eb4c55624eeae2df28 - id: repo://codewiki/mcp/tools/store_bridge.py#L34-L70 resource: repo://codewiki/mcp/tools/store_bridge.py#L34-L70 - content_hash: sha256:31d95ddfafaa7054ce68731c2aefdf4d76ccbc9dbf85219e261f46d63cc53603 + content_hash: sha256:fe51742282ca4f2d69b77fdb55eec009f788f68b17405f4214f7f3320a3775ce --- # [KnowledgeStore](../../../codewiki/src/store.py) 模块文档 @@ -53,12 +53,16 @@ sources: | `locked_rmw` | 函数 | store.py | 锁内读-转换-原子写;`transform` 返回 `None` 中止写(只读窥探),否则返回新文本 | | `Page` | 类 | store.py | 轻量只读文档对象(relpath / 绝对路径 / frontmatter / body) | | `KnowledgeStore`(含 `path`/`relpath`/`_read_text`/`page`/`iter_pages`/`write`/`update_frontmatter`/`content_hash`/`_raw_index`/`_rebuild_raw_index`/`read_task_index`/`write_task_index`/`find_task`) | 类 | store.py | 单个 repowiki 根的持久化门面:目录/文件路径解析、BOM 容忍读、页面解析与枚举、原子写与 frontmatter 定点更新、raw 与任务索引缓存(目录为真相、缓存校验重建) | +| `capture_raw` / `pending_raws_by_task` / `mark_raw_distilled` / `sync_raw_index` / `delete_raw` | 方法 | store.py | raw 暂存区生命周期:落盘捕获(content_hash 去重 + supersede 继承 task_id)、按任务汇总待蒸馏清单、蒸馏后索引置位/删除(`_raw_rel` 同时接受 `raw/<name>` 与裸文件名两种约定) | +| `read_binding` / `write_binding` / `remove_binding` / `clear_bindings_for_task` / `gc_bindings` | 方法 | store.py | 会话绑定凭证(`.meta/task_bindings/<session>.json`)读写;`clear_bindings_for_task` 供 delete_task 级联、`gc_bindings` 按 `bound_at` 年龄清扫从未捕获的一次性凭证(无法定日期的损坏文件一律保留) | +| `split_entries` / `split_summary_and_entries` / `entry_sort_key` / `format_memory_entry` | 函数 | store.py | 任务记忆条目切分工具:按 `### ` 时间戳头切条、分离「早期记忆(摘要)」段与条目段、按时间戳排序、生成带 `### YYYY-MM-DD HH:MM` 头的条目——压缩/分层/截断的解析边界(ADR-0001) | | `parse_frontmatter` | 函数 | frontmatter.py | 分离文档 frontmatter 与正文(全仓库唯一解析收敛点,json 解码标量值) | | `resolve_output_dir` | 函数 | store_bridge.py | 把工具调用的 `output_dir` 解析到知识库根:集中式布局成员 → 工作区共享 repowiki;否则状态维持 `repo_path/repowiki` | ## 关键设计 - **原子文件族与集中锁**:Windows 上被打开/锁定的目标文件不能被 `os.replace` 覆盖,因此锁加在独立文件上(D19:集中存放于 `<wiki-root>/.meta/locks/`,按目标绝对路径哈希命名——锁语义只要求路径确定性映射,与相邻无关;无 `.meta` 祖先的裸 fixture 回退就地边车);`atomic_write` 用 pid+线程 id 保证跨进程/跨线程临时名唯一,`os.replace` 带退避重试,`finally` 兜底清理。团队布局约定:跨进程共享文件(index、telemetry 等)一律经 `locked_write`/`locked_rmw`,绝不裸 `write_text`。 +- **锁文件生命周期分平台**:锁本体由 `codewiki.src.locks.file_lock` 提供;释放后 Windows 尽力删除锁文件(无人持有时 unlink 才成功,无 inode 竞态),Unix 刻意保留——`flock` 锁的是 inode,删掉路径会让第三个进程锁上一个新文件,出现两个「独占」持有者(见 `locked` docstring,store.py:152-158)。 - **门面与 bridge 分层**:`KnowledgeStore` 构造即绑定 root,只认 root 之下的路径,不感知多仓布局;`resolve_output_dir` 是唯一的布局路由入口(集中式 vs 就地)。 - **目录为真相、索引为缓存**:任务索引 `read_task_index` 先做廉价一致性校验(目录名 id 集合 == 缓存 id 集合),失配/损坏才扫描 `task.md` frontmatter 全量重建并回写(失败不抛错);raw 索引同理以 `conv-*.md` frontmatter 为真相(`_rebuild_raw_index`)。`content_hash` 把 `task_id` 纳入摘要,同一段对话分属不同任务不会被去重误杀。 - **frontmatter 单点解析**:所有 frontmatter 读取统一经 `parse_frontmatter`(utf-8-sig BOM 容忍 + 读取失败返回 None),旧式手工剥引号补丁(如 `_unquote_fm`)已成兼容层——统一 reader 已做 json 解码。 diff --git a/repowiki/wiki/modules/MCP_Tools_DocWriter.md b/repowiki/wiki/modules/MCP_Tools_DocWriter.md index 341d29f..7b722c4 100644 --- a/repowiki/wiki/modules/MCP_Tools_DocWriter.md +++ b/repowiki/wiki/modules/MCP_Tools_DocWriter.md @@ -23,7 +23,7 @@ verified: sources: - id: repo://codewiki/mcp/tools/schema_generator.py#L209-L238 resource: repo://codewiki/mcp/tools/schema_generator.py#L209-L238 - content_hash: sha256:4b1ff56d0967ef975e6ba39ea04bb1df4d824c7dbade2bb2070324a267022dad + content_hash: sha256:7b40889a3cb47f8755da1c0ac29c153fc54cb37ec93467f7ca4d1b9ff14fc399 - id: repo://codewiki/templates/schema.yaml#L55-L65 resource: repo://codewiki/templates/schema.yaml#L55-L65 content_hash: sha256:40a438980aca923343a3105338e92600703381b10f6097cb7ba65aa3346ce2d6 @@ -63,7 +63,7 @@ sources: - **顺序化生成**:`module_tree.py` 通过依赖关系计算 `_get_processing_order`,保证底层模块先写。 - **可重入编辑**:`handle_edit_doc_file` 调用 `_resync_source_refs` 重同步源码引用,避免文档漂移。 - **配置模板单源**:`schema_generator._load_project_config` 只从包内 `codewiki/templates/schema.yaml`(`_CONFIG_PATH`)加载,已移除仓库根同名文件的回退分支;`_get_defaults` 用其覆盖硬编码默认值,因此新增/调整全局配置(如 `conventions`)只需改包内模板一份,源码树与 wheel 分发走同一路径。 -- **配置模板单源**:`schema_generator._load_project_config` 只从包内 `codewiki/templates/schema.yaml`(`_CONFIG_PATH`)加载,已移除仓库根同名文件的回退分支;`_get_defaults` 用其覆盖硬编码默认值,因此新增/调整全局配置(如 `conventions`)只需改包内模板一份,源码树与 wheel 分发走同一路径。 +- **两区制 skill 页面类型**:`_get_defaults` 的 `skill` 条目把 SKILL.md 草稿路由到 repowiki 根下 `skills/`(与 `notes/` 同级,进索引也进 lint),生效区 `.codebuddy/skills/` 在 repowiki 之外从不被扫描;五段骨架(工作场景 / 适用条件 / 核心 SOP / 判断逻辑 / 禁忌与反模式)缺段由 `skill_sections` lint 告警(issue #24 / ADR-0004)。 ## 数据流(mermaid) diff --git a/repowiki/wiki/modules/MCP_Tools_Knowledge.md b/repowiki/wiki/modules/MCP_Tools_Knowledge.md index 23196ee..c2dc538 100644 --- a/repowiki/wiki/modules/MCP_Tools_Knowledge.md +++ b/repowiki/wiki/modules/MCP_Tools_Knowledge.md @@ -38,7 +38,7 @@ sources: content_hash: sha256:b0c89f0634d1727f9e45e86324f1e0f5830c7a4149cefb6768eff835755be58e - id: repo://codewiki/mcp/tools/distill_conversation.py#L341-L399 resource: repo://codewiki/mcp/tools/distill_conversation.py#L341-L399 - content_hash: sha256:cb66ba5412ba9f86aed92fcea5c1e509e57b5d5487e4b98f41563be3248062fa + content_hash: sha256:d294bb9ad4a5951089bb1fa7a0a81113a351cfc7fddc5926aa99ccd9131b8f31 - id: repo://codewiki/mcp/tools/hook_registry.py#L31-L71 resource: repo://codewiki/mcp/tools/hook_registry.py#L31-L71 content_hash: sha256:ad0ab76fd97983b71d5c74fd62578bbe8bac67bac67ab6d8e1a7d32712edada0 @@ -76,7 +76,9 @@ sources: | `_norm_status` | 私有 | note_writer.py | 规范化笔记 status 值(别名归一、非法回退) | | `load_note_types` | 私有 | note_types.py | 从 schema 加载 note_type 定义(约束笔记类型集合) | | `_scan_scenarios` | 私有 | note_consolidation.py | 扫描 scenarios 目录待聚合入口笔记(consolidate 前置) | +| `_parse_frontmatter` | 私有 | distill_conversation.py | 页面 frontmatter 的扁平 str→str 视图:瘦委托给 `frontmatter.parse_frontmatter`(json 解码标量,避免 `task_id: "foo"` 把引号漏进路由键),非字符串值 json 化 | | `_unquote_fm` | 私有 | distill_conversation.py | 兼容层:剥离旧 raw 笔记中残留的包裹引号(统一 frontmatter reader 已做 json 解码) | +| `_is_retired_note` | 私有 | distill_conversation.py | OKF:已退役笔记不是知识——`deprecated` 及旧词汇 `rejected`/`superseded`(经 `note_writer._norm_status` 归一)一律跳过,避免去重时把新草稿并进死知识 | | `load_registry` | 私有 | hook_registry.py | 读取 IDE hook 注册表(启用的 hook 清单) | ## 关键设计 diff --git a/repowiki/wiki/modules/MCP_Tools_Workspace.md b/repowiki/wiki/modules/MCP_Tools_Workspace.md index 813a330..fb31c35 100644 --- a/repowiki/wiki/modules/MCP_Tools_Workspace.md +++ b/repowiki/wiki/modules/MCP_Tools_Workspace.md @@ -21,10 +21,10 @@ metadata: sources: - id: repo://codewiki/mcp/tools/workspace_layout.py#L68-L216 resource: repo://codewiki/mcp/tools/workspace_layout.py#L68-L216 - content_hash: sha256:eac410843c8e954c6856501f3b0b93af55a4164aefadc16496a5f54952358fa5 + content_hash: sha256:a7e3f6c3482eacbbf999022a546ddf72b7813a8574f8f9e08f6a986fd03ce47a - id: repo://codewiki/mcp/tools/workspace_layout.py#L238-L373 resource: repo://codewiki/mcp/tools/workspace_layout.py#L238-L373 - content_hash: sha256:a8b42d59e42a3ac8fa44c555b2d55d281172862411bc34c093609c9c3f79b602 + content_hash: sha256:448f787b1e39e266d0b76582f054123bb45bd0405ce00bae0086c1ef191cce8c - id: repo://codewiki/mcp/tools/workspace_bootstrap.py#L85-L110 resource: repo://codewiki/mcp/tools/workspace_bootstrap.py#L85-L110 content_hash: sha256:e1e31df054c586e0f4de606af03f977eac63a242125cd5536d3f6f736e0ccbe2 @@ -45,6 +45,7 @@ sources: | `WorkspaceResolution` | 类 | workspace_layout.py | 解析结果(root/layout/member);`centralized` 属性即集中式路由决策点 | | `resolve_workspace` | 函数 | workspace_layout.py | 按四条护栏解析 repo_path:仅 workspace.json 为发现信号 / 命中仍需注册表成员资格 / 三态回退到 colocated / 进程级结果缓存(`clear_cache` 供测试) | | `default_output_dir` | 函数 | workspace_layout.py | 集中式成员 → 工作区共享 `repowiki`;其余 → 状态维持 `repo_path/repowiki` | +| `is_foreign_output_dir` | 函数 | workspace_layout.py | 把调用方显式传入的 `output_dir` 与布局推导结果比对:不一致即 foreign,返回该外来路径供 handler 忽略并告警——外部调用(冒烟/测试)不得把某个仓的知识导向别处 | | `is_centralized_corpus` | 函数 | workspace_layout.py | 判断 output_dir 是否落在集中式 corpus(用于门控 `repo=` 查询过滤等布局专属语义) | | `routing_for_write` | 函数 | workspace_layout.py | 判定写入是否需要分区:仅当 repo 是集中式成员且 output_dir 恰为该工作区 repowiki 时返回注册目录名(module 页路由到共享池的依据) | | `read_provenance` / `parse_scope_arg` / `merge_provenance` | 函数 | workspace_layout.py | 读取/规范化 `repo:`/`repos:` provenance;合并时写入 frontmatter `metadata:` 节点之下(避免 OKF lint 顶层键告警),global 清空 provenance | diff --git a/repowiki/wiki/reading-guide.md b/repowiki/wiki/reading-guide.md index b3dc24d..a648341 100644 --- a/repowiki/wiki/reading-guide.md +++ b/repowiki/wiki/reading-guide.md @@ -1,6 +1,7 @@ --- type: Concept title: "阅读指南" +aliases: ["阅读指南", "Reading Guide", "推荐阅读顺序"] generated: { by: codewiki/reading_guide.py, at: 2026-09-05T16:15:58Z } stale_after: 2099-12-31 description: "> 基于 PageRank 依赖分析自动生成。排名越靠前的组件被越多模块依赖,建议优先阅读。" From 95507f67337b78acb457059bdbf4cc9065722fea Mon Sep 17 00:00:00 2001 From: mambo-wang <iamwangbao@163.com> Date: Mon, 7 Sep 2026 09:22:21 +0800 Subject: [PATCH 17/23] =?UTF-8?q?feat(mcp):=20consolidate=20submit=20?= =?UTF-8?q?=E5=80=99=E9=80=89=E5=8E=BB=E5=90=91=E2=80=94=E2=80=94dispositi?= =?UTF-8?q?ons=EF=BC=88deferred=20=E4=BF=9D=E7=95=99=20/=20excluded=20?= =?UTF-8?q?=E5=BF=85=E5=B8=A6=20reason=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每个未吸收候选必须有归宿,不允许无声跳过 - report.dispositions=[{file, verdict: deferred|excluded, reason?}],excluded 缺 reason 拒绝 - absorbed 由 source_notes 派生不可提交;prompt/registry 描述与测试同步 --- codewiki/mcp/prompts.py | 11 ++- codewiki/mcp/registry.py | 17 +++- tests/test_consolidation_p2.py | 149 +++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 6 deletions(-) diff --git a/codewiki/mcp/prompts.py b/codewiki/mcp/prompts.py index e307242..f8f0e20 100644 --- a/codewiki/mcp/prompts.py +++ b/codewiki/mcp/prompts.py @@ -1245,9 +1245,16 @@ def _prompt_consolidate_knowledge(args: dict[str, str]) -> str: ## 步骤 3:退役被吸收的笔记 对被场景块**完全吸收**的源笔记调用 `reject_note(note_file=..., reason="consolidated into <场景块标题>")`,使其退出检索。部分吸收的笔记保留。 +## 步骤 3.5:给未入选的候选一个去向 +每条 pending 候选都必须有明确归宿,**不允许无声跳过**: +- 被吸收 → 已由 `source_notes` 表达,无需额外动作 +- 知识为真但还太单薄、等更多同类素材 → `dispositions` 记 `deferred`(保留在 pending,下轮再判) +- 永不可能是场景素材(一次性任务状态、个人偏好、临时上下文)→ `dispositions` 记 `excluded`,**必须写 reason** + ## 步骤 4:提交报告 -`consolidate_notes(mode="submit", report={{"scenarios": [{{"file": "wiki/scenarios/xxx.md", "action": "created|updated|merged|deleted", "source_notes": ["notes/..."], "summary": "30-40 字摘要", "heat": 1}}]}})` -- 工具侧校验文件、写入双向溯源(source_notes ⇄ consolidated_into)、清理 [DELETED]、强制容量上限并归零聚合计数器 +`consolidate_notes(mode="submit", report={{"scenarios": [{{"file": "wiki/scenarios/xxx.md", "action": "created|updated|merged|deleted", "source_notes": ["notes/..."], "summary": "30-40 字摘要", "heat": 1}}], "dispositions": [{{"file": "notes/...", "verdict": "deferred|excluded", "reason": "..."}}]}})` +- 工具侧校验文件、写入双向溯源(source_notes ⇄ consolidated_into)、把 disposition 盖回笔记 frontmatter、清理 [DELETED]、强制容量上限并归零聚合计数器 +- `verdict` 只接受 `deferred|excluded`(`absorbed` 由 `consolidated_into` 派生,提交会被拒);`excluded` 缺 reason 会被拒 - 返回 `capacity_exceeded` 时:先 MERGE 再重新提交 - 返回 `error` 时:按 errors 修正后重交 diff --git a/codewiki/mcp/registry.py b/codewiki/mcp/registry.py index 93c2a60..33b6ce3 100644 --- a/codewiki/mcp/registry.py +++ b/codewiki/mcp/registry.py @@ -1675,8 +1675,12 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "then writes scene blocks via write_doc_file(page_type='scenario'), " "retires fully-absorbed source notes via reject_note, and calls " "mode='submit' with report.scenarios=[{file, action, source_notes, " - "summary?, heat?}] (action: created|updated|merged|deleted). Submit " - "validates files, stamps summary/heat, records provenance " + "summary?, heat?}] (action: created|updated|merged|deleted). Every " + "candidate needs a destination: absorbed ones via source_notes, the " + "rest via report.dispositions=[{file, verdict, reason?}] with verdict " + "in deferred|excluded — 'deferred' stays pending, 'excluded' (reason " + "REQUIRED) leaves the pending list for good. Submit validates files, " + "stamps summary/heat, records provenance " "(source_notes ⇄ consolidated_into), cleans [DELETED] markers, enforces " "the capacity cap and resets the aggregation counter. NEVER runs " "automatically — only on explicit request; when triggered by an " @@ -1710,10 +1714,15 @@ def _register(schema: Tool, handler_path: str, mode: str, takes_store: bool = Tr "type": "object", "description": ( "submit only: {scenarios: [{file, action, source_notes, " - "summary?, heat?}]} — file relative to output_dir " + "summary?, heat?}], dispositions?: [{file, verdict, " + "reason?}]} — file relative to output_dir " "(wiki/scenarios/...md), action in created|updated|merged|" "deleted, source_notes the absorbed note files. deleted " - "requires the file body to be exactly [DELETED]." + "requires the file body to be exactly [DELETED]. " + "dispositions records where every NON-absorbed candidate " + "went: file is a note path (notes/...md), verdict in " + "deferred|excluded (absorbed is derived from source_notes " + "and must not be submitted), reason required for excluded." ), }, }, diff --git a/tests/test_consolidation_p2.py b/tests/test_consolidation_p2.py index 197c3bb..bb9cdb3 100644 --- a/tests/test_consolidation_p2.py +++ b/tests/test_consolidation_p2.py @@ -322,6 +322,155 @@ def test_submit_validation_error_keeps_counter(tmp_path): assert state["notes_since_last_consolidation"] == 1 # NOT reset +# --------------------------------------------------------------------------- # +# 3b. candidate dispositions (every pending note needs a destination) +# --------------------------------------------------------------------------- # +def test_submit_disposition_excluded_drops_from_pending(tmp_path): + repo = str(tmp_path) + _set_thresholds(repo) + keep = _ingest(repo, "Reusable method note") + drop = _ingest(repo, "One off task state note") + _confirm(repo, keep) + _confirm(repo, drop) + + resp = _consolidate( + repo, + { + "mode": "submit", + "report": { + "dispositions": [ + { + "file": f"notes/{drop}", + "verdict": "excluded", + "reason": "一次性任务状态,非可复用工作方法", + } + ] + }, + }, + ) + assert resp["status"] == "completed", resp + assert resp["dispositions"] == [{"file": f"notes/{drop}", "verdict": "excluded"}] + + fm = _fm(repo, f"notes/{drop}") + assert fm["metadata"]["disposition"]["verdict"] == "excluded" + assert fm["metadata"]["disposition"]["reason"].startswith("一次性") + assert fm["metadata"]["disposition"]["at"] + + again = _consolidate(repo, {"mode": "prepare"}) + titles = [n["title"] for n in again["pending_notes"]] + assert "One off task state note" not in titles + assert "Reusable method note" in titles + + +def test_submit_disposition_deferred_stays_pending_with_marker(tmp_path): + repo = str(tmp_path) + _set_thresholds(repo) + thin = _ingest(repo, "Thin but real note") + _confirm(repo, thin) + + resp = _consolidate( + repo, + { + "mode": "submit", + "report": { + "dispositions": [ + {"file": f"notes/{thin}", "verdict": "deferred", "reason": "等同类素材"} + ] + }, + }, + ) + assert resp["status"] == "completed", resp + + again = _consolidate(repo, {"mode": "prepare"}) + assert len(again["pending_notes"]) == 1 + entry = again["pending_notes"][0] + assert entry["title"] == "Thin but real note" + # still pending, but now visibly already judged + assert entry["disposition"] == "deferred" + + +def test_prepare_disposition_null_when_never_judged(tmp_path): + repo = str(tmp_path) + _set_thresholds(repo) + fresh = _ingest(repo, "Never judged note") + _confirm(repo, fresh) + resp = _consolidate(repo, {"mode": "prepare"}) + assert resp["pending_notes"][0]["disposition"] is None + + +def test_submit_disposition_excluded_requires_reason(tmp_path): + repo = str(tmp_path) + _set_thresholds(repo) + nf = _ingest(repo, "Unreasoned exclusion note") + _confirm(repo, nf) + + resp = _consolidate( + repo, + { + "mode": "submit", + "report": { + "dispositions": [{"file": f"notes/{nf}", "verdict": "excluded"}] + }, + }, + ) + assert resp["status"] == "error" + assert any("requires a non-empty reason" in e["error"] for e in resp["errors"]) + # nothing written: still pending + again = _consolidate(repo, {"mode": "prepare"}) + assert [n["title"] for n in again["pending_notes"]] == ["Unreasoned exclusion note"] + + +def test_submit_disposition_rejects_absorbed_verdict(tmp_path): + """absorbed is derived from consolidated_into — submitting it is a bug.""" + repo = str(tmp_path) + _set_thresholds(repo) + nf = _ingest(repo, "Derived verdict note") + _confirm(repo, nf) + + resp = _consolidate( + repo, + { + "mode": "submit", + "report": { + "dispositions": [{"file": f"notes/{nf}", "verdict": "absorbed"}] + }, + }, + ) + assert resp["status"] == "error" + assert any("invalid verdict" in e["error"] for e in resp["errors"]) + + +def test_absorbed_disposition_is_derived_not_stored(tmp_path): + """source_notes alone must produce the absorbed disposition — no second copy.""" + repo = str(tmp_path) + _set_thresholds(repo) + n1 = _ingest(repo, "Absorbed source note") + _confirm(repo, n1) + + scen = _write_scenario(repo, "absorb-scene", with_provenance=False) + resp = _consolidate( + repo, + { + "mode": "submit", + "report": { + "scenarios": [ + { + "file": scen, + "action": "created", + "source_notes": [f"notes/{n1}"], + "summary": "absorb test", + "heat": 1, + } + ] + }, + }, + ) + assert resp["status"] == "completed", resp + # derived on read, not persisted + assert "disposition" not in _fm(repo, f"notes/{n1}")["metadata"] + assert cons._note_disposition(_fm(repo, f"notes/{n1}")["metadata"]) == "absorbed" + + def test_submit_capacity_exceeded_blocks_reset(tmp_path): repo = str(tmp_path) _set_thresholds(repo, max_scenes=2) From dbc5ef9e302dca28cac29a359b5094d8ef056539 Mon Sep 17 00:00:00 2001 From: mambo-wang <iamwangbao@163.com> Date: Mon, 7 Sep 2026 09:22:25 +0800 Subject: [PATCH 18/23] =?UTF-8?q?feat(mcp):=20=E6=8A=80=E8=83=BD=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=A7=A6=E5=8F=91=E6=8F=90=E7=A4=BA=E2=80=94=E2=80=94?= =?UTF-8?q?hook=20+=20=E5=8F=8C=20submit=20=E5=8F=AA=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E4=B8=8D=E8=87=AA=E5=8A=A8=E6=89=A7=E8=A1=8C=EF=BC=88skill-cre?= =?UTF-8?q?ator=20=C2=A710=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserPromptSubmit hook(claude 家族):匹配 status=draft 草稿注入 install 指针,只读不写 - skill_creator 状态语义补全:install→stable(不再被推荐)、修订回 draft(可再被推荐) - note_consolidation / distill_conversation submit 返回 additive skill_hint,默认返回值不变 - stdlib-only skill_match:containment 非 Jaccard(0.6 Jaccard 永不触发系实测推翻);L2 素材判据=命令密度 - distill-worker 剧本明令 skill_hint 只汇报不执行;真实技能迁移 stable;docs §10 + 28 例测试 --- .codebuddy/agents/distill-worker.md | 3 +- codewiki/hooks.yaml | 6 + codewiki/mcp/_ide_hook.py | 96 +++++ codewiki/mcp/tools/distill_conversation.py | 20 + codewiki/mcp/tools/note_consolidation.py | 166 +++++++- codewiki/mcp/tools/skill_creator.py | 11 + codewiki/src/skill_match.py | 266 ++++++++++++ ...77\347\224\250\346\214\207\345\215\227.md" | 23 + ...76\350\256\241\346\226\271\346\241\210.md" | 74 +++- .../.meta/telemetry/iamwangbao-163-com.jsonl | 127 ++++-- .../skills/maintain-fork-pr-merge/SKILL.md | 2 +- tests/test_skill_match.py | 402 ++++++++++++++++++ 12 files changed, 1152 insertions(+), 44 deletions(-) create mode 100644 codewiki/src/skill_match.py create mode 100644 tests/test_skill_match.py diff --git a/.codebuddy/agents/distill-worker.md b/.codebuddy/agents/distill-worker.md index a3fa059..4f9cb30 100644 --- a/.codebuddy/agents/distill-worker.md +++ b/.codebuddy/agents/distill-worker.md @@ -19,12 +19,13 @@ enabledAutoRun: true 1. **prepare**:调用 `distill_conversation(mode="prepare", task_id=<任务id>)`。返回积压对话清单(`captures`:每条含 `conversation_id` 与 `full_path`)和 `system_prompt`(提取规范)。 2. **逐条提取**:对清单中的每条 capture,用 `ReadFile` 读取 `full_path` 指向的 raw 文件正文;严格按 `system_prompt` 的提取规范,产出 `notes`(通用经验笔记,`status=draft`,待确认)与 `memories`(任务进度,直写落盘 memories.md——ADR-0002,无需确认)。 3. **submit**:逐条调用 `distill_conversation(mode="submit", conversation_id=<id>, distilled=<提取JSON>)` 交回结果,优先内联(subagent 逐条处理,单条载荷通常不超限)。**若单条载荷过大导致 MCP 传输失败**:改用 `distilled_file` 文件侧通道——先用写文件工具把提取 JSON(形状 `{conversation_id: {notes, memories}}`,或单条裸 `{notes, memories}` 配合 conversation_id)写入 `repowiki/raw/.distill-<id>.json`,再只传文件路径;工具读取后自动删除该暂存文件。产出物:待确认的草稿笔记(不直接成为正式知识)+ 直写落盘的任务记忆。 -4. **汇报**:全部完成后,向主 Agent 返回摘要——本次蒸馏的对话数、新建笔记数、去重抑制/合并数、落盘记忆数(memories_written),以及建议主 Agent 在停顿点向用户展示的待确认草稿清单。 +4. **汇报**:全部完成后,向主 Agent 返回摘要——本次蒸馏的对话数、新建笔记数、去重抑制/合并数、落盘记忆数(memories_written),以及建议主 Agent 在停顿点向用户展示的待确认草稿清单。若 submit 返回了 `skill_hint`,原样附上(只汇报,不执行)。 ## 约束 - 只蒸馏当前任务(`task_id` 过滤由 prepare 与工具自身保证),不触碰其他任务的 raw。 - **不执行** `confirm_note` / `reject_note` / `ingest_note` 等评审操作——笔记的确认闸门属于主 Agent 与用户的评审环节,本 subagent 只产出待确认草稿。(任务记忆由 `distill_conversation` 直写落盘,不经过 subagent 手动写文件。) +- **`skill_hint` 只汇报、不执行**:submit 返回中可能出现 `skill_hint`(本次产出的笔记匹配到未安装的技能草稿)。把它原样写进汇报摘要交给主 Agent;**严禁**自行调用 `skill_creator` 去编译或 install——install 是用户确认动作(skill-creator §10 / Doctrine「触发永远显式」)。 - 不修改 `repowiki/` 之外的任何文件;不做代码修改、不回答用户的功能性问题(那是主 Agent 的职责)。 - 若 prepare 返回空积压(已全部蒸馏/无 raw),直接返回"无待蒸馏积压",不要重复扫描。 - 遇到错误(文件缺失、JSON 非法)时记录并继续下一条,最后统一汇报失败项,不要中断整个流程。 diff --git a/codewiki/hooks.yaml b/codewiki/hooks.yaml index ed5a81c..3fa3f9f 100644 --- a/codewiki/hooks.yaml +++ b/codewiki/hooks.yaml @@ -11,6 +11,9 @@ # true = 已验证支持(日常使用背书,接线指引可直接信任) # false = 理论支持(家族归并推导,未经真机验证——接线后必须跑 # 模拟事件验证,失败即反馈修订本注册表) +# +# user_prompt 故意只填 claude 家族:cursor / codex 的事件名是归并推导的猜测, +# 未经真机验证,按「缺失回退默认源」处理,不写进注册表。 version: 1 @@ -21,6 +24,9 @@ families: events: session_start: [SessionStart] session_end: [SessionEnd] + # Advisory only (skill-creator §10): the hook matches the prompt against + # draft skills and injects a pointer via hookSpecificOutput. + user_prompt: [UserPromptSubmit] # Cursor 格式:hooks.json + camelCase 事件。 # 注意采集降级:Cursor 无 SessionEnd 等价事件,stop 不携带 diff --git a/codewiki/mcp/_ide_hook.py b/codewiki/mcp/_ide_hook.py index fe4541f..933c176 100644 --- a/codewiki/mcp/_ide_hook.py +++ b/codewiki/mcp/_ide_hook.py @@ -12,6 +12,13 @@ (see SPEC-conversation-to-wiki.md). The raw/ staging area is transient and is NOT indexed by query_wiki. +Second responsibility (skill-creator §10): on a ``UserPromptSubmit`` event the +hook matches the user's prompt against *uninstalled* draft skills +(``repowiki/skills/*/SKILL.md`` with ``status: draft``) and, on a hit, emits a +``hookSpecificOutput.additionalContext`` pointer carrying only the skill's name +and description. This branch is read-only — it never captures, never compiles +and never installs. + Security / opt-in: The hook is OFF by default. The IDE must set the environment variable ``CODEWIKI_TEAM_MEMORY_HOOK=1`` (or pass ``--enable``) before invoking this @@ -52,6 +59,8 @@ from pathlib import Path from typing import Any, Dict, Optional +from codewiki.src.skill_match import PROMPT_EVENTS + logger = None # lazily imported to keep CLI startup cheap @@ -337,6 +346,87 @@ def _cleanup_event_file(path: Optional[str]) -> None: pass +def _event_name(event: Dict[str, Any]) -> str: + """Lower-cased hook event name (several IDEs spell the key differently).""" + raw = ( + event.get("hook_event_name") + or event.get("hookEventName") + or event.get("event") + or "" + ) + return str(raw).strip().lower() + + +def _extract_prompt(event: Dict[str, Any]) -> str: + """Pull the submitted user prompt out of a UserPromptSubmit payload.""" + for key in ("prompt", "user_prompt", "message", "text", "input"): + value = event.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _resolve_skills_dir(repo_path: str) -> Optional[str]: + """Draft-zone skills directory for this repo, or None if not a CodeWiki repo.""" + if not repo_path: + return None + from codewiki.src.config import SKILLS_DIR + + for candidate in ( + Path(repo_path) / "repowiki" / SKILLS_DIR, + Path(repo_path) / SKILLS_DIR, + ): + if candidate.is_dir(): + return str(candidate) + return None + + +def _handle_user_prompt( + args: argparse.Namespace, event: Dict[str, Any], event_file: Optional[str] +) -> int: + """UserPromptSubmit → match draft skills → inject a one-line pointer. + + Read-only and hint-only: never captures, never compiles, never installs, + never writes (design §10). The injected text carries name + description + and nothing else — surfacing a SKILL body would blur retrieval knowledge + with behaviour instructions (ADR-0004 decision 2). + """ + try: + prompt = _extract_prompt(event) + repo_path = args.repo_path or event.get("repo_path") or event.get("cwd") or "" + skills_dir = _resolve_skills_dir(repo_path) + if not prompt or not skills_dir: + return 0 + + from codewiki.src.skill_match import build_skill_hint, match_draft_skills + + hit = match_draft_skills(prompt, skills_dir) + if not hit: + return 0 + hint = build_skill_hint("match", hit) + try: + sys.stdout.reconfigure(encoding="utf-8") + except (AttributeError, ValueError, OSError): + pass + sys.stdout.write( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": hint["skill_hint"]["message"], + } + }, + ensure_ascii=False, + ) + ) + except Exception as e: # never break the user's prompt on a hook failure + print(f"ide-hook: skill match failed: {e}", file=sys.stderr) + return 0 + finally: + _cleanup_event_file(event_file) + return 0 + + def main(argv: Optional[list] = None) -> int: parser = argparse.ArgumentParser( description="IDE hook: capture a conversation into repowiki/raw/ (no distillation)." @@ -383,6 +473,12 @@ def main(argv: Optional[list] = None) -> int: _cleanup_event_file(event_file_to_clean) return 0 + # UserPromptSubmit is a read-only advisory path (design §10): match the + # prompt against uninstalled draft skills and inject a pointer. It never + # captures and never writes — dispatch before the capture logic below. + if _event_name(event) in PROMPT_EVENTS: + return _handle_user_prompt(args, event, event_file_to_clean) + # Merge CLI args over the payload file/stdin. def _pick(key, cli_val): if cli_val: diff --git a/codewiki/mcp/tools/distill_conversation.py b/codewiki/mcp/tools/distill_conversation.py index c7cf8ef..deec563 100644 --- a/codewiki/mcp/tools/distill_conversation.py +++ b/codewiki/mcp/tools/distill_conversation.py @@ -1737,6 +1737,26 @@ def handle_distill_conversation( ret["git_sync"] = _push except Exception as e: logger.debug("auto_push skipped: %s", e) + + # Skill hint (design §10): distillation only MATCHES existing drafts — + # no material scoring here, because freshly distilled notes are not yet + # an SOP. Hint only: a background/subagent caller must REPORT it, never + # act on it (install remains the user's call). + try: + from codewiki.src.config import SKILLS_DIR + from codewiki.src.skill_match import build_skill_hint, match_draft_skills + + titles: List[str] = [] + for r in results: + for n in r.get("notes", []) or []: + if isinstance(n, dict) and n.get("title"): + titles.append(str(n["title"])) + if titles: + hit = match_draft_skills(" ".join(titles), str(Path(output_dir) / SKILLS_DIR)) + if hit: + ret["skill_hint"] = build_skill_hint("match", hit)["skill_hint"] + except Exception as e: + logger.debug("skill hint skipped: %s", e) return json.dumps(ret, indent=2, ensure_ascii=False) # Mode B: background diff --git a/codewiki/mcp/tools/note_consolidation.py b/codewiki/mcp/tools/note_consolidation.py index a66a6d8..09654b4 100644 --- a/codewiki/mcp/tools/note_consolidation.py +++ b/codewiki/mcp/tools/note_consolidation.py @@ -18,8 +18,14 @@ heat?}] with action ∈ created|updated|merged|deleted — validates the files, stamps summary/heat into frontmatter, records provenance (scenario.metadata.source_notes ⇄ note.metadata.consolidated_into), - cleans up [DELETED] soft-delete markers, enforces the capacity limit, - resets the aggregation counter and rebuilds the search index. + Optionally takes ``report.dispositions`` — [{file, verdict, reason?}] with + verdict ∈ deferred|excluded — for candidates that were NOT absorbed: + ``deferred`` stays pending (waiting for more evidence), ``excluded`` + (reason REQUIRED) drops out of the pending list for good. ``absorbed`` is + never submitted: it is derived from ``consolidated_into``, so the + provenance link stays the single source of truth. + Then: cleans up [DELETED] soft-delete markers, enforces the capacity + limit, resets the aggregation counter and rebuilds the search index. Constraints honoured: never consolidates automatically (explicit calls only), never writes knowledge itself (the agent does), confirmation gates untouched @@ -30,6 +36,7 @@ import json import logging +from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional @@ -39,6 +46,13 @@ _SOFT_DELETE_MARKER = "[DELETED]" _PENDING_NOTES_LIMIT = 50 _SUMMARY_CHARS = 300 +_REASON_CHARS = 200 +# Read model: every candidate note ends in exactly one of these. ``absorbed`` is +# derived from ``consolidated_into`` and never stored — the provenance link is +# the single source of truth, a second copy would only drift. +DISPOSITION_VERDICTS = ("absorbed", "deferred", "excluded") +# Write model: what the agent may submit. ``absorbed`` comes from source_notes. +_SUBMITTABLE_VERDICTS = ("deferred", "excluded") _CONSOLIDATE_SYSTEM = ( "You are the Team Work Method Memory Consolidation Architect.\n" @@ -92,6 +106,13 @@ "(5) Call consolidate_notes(mode='submit', report=...) listing every scene " "you created/updated/merged/deleted with its absorbed source notes, plus " "a 30-40-word summary and the heat value per scene.\n" + "(6) EVERY candidate from prepare must end with a destination — no silent " + "skips. Absorbed ones are covered by source_notes. For the rest add " + "report.dispositions=[{file, verdict, reason?}]: verdict='deferred' when the " + "knowledge is real but still too thin to stand alone (it stays pending), " + "verdict='excluded' when it can never be scenario material — one-off task " + "state, personal preference, temporary context. 'excluded' REQUIRES a " + "reason; a note must never disappear from pending without saying why.\n" "Ask the user before starting if the preparation context suggests the " "consolidation was tool-initiated by a reminder." ) @@ -257,8 +278,40 @@ def _scan_scenarios(output_dir: Path) -> List[Dict[str, Any]]: return out +def _note_disposition(meta: Dict[str, Any]) -> Optional[str]: + """Resolve a note's consolidation disposition (read model, never stored for + ``absorbed``): ``absorbed`` ← ``consolidated_into``, else the stored verdict. + """ + if meta.get("consolidated_into"): + return "absorbed" + disp = meta.get("disposition") + if isinstance(disp, dict): + verdict = str(disp.get("verdict") or "").lower() + if verdict in _SUBMITTABLE_VERDICTS: + return verdict + return None + + +def _resolve_note_path(output_dir: Path, rel: str) -> Optional[Path]: + """Resolve a note file from a repo-relative path or a bare file name.""" + from codewiki.src.config import NOTES_DIR + + p = Path(output_dir) / rel + if p.is_file(): + return p + notes_dir = Path(output_dir) / NOTES_DIR + cand = notes_dir / Path(rel).name + if cand.is_file(): + return cand + if not Path(rel).suffix: + cand = notes_dir / f"{Path(rel).name}.md" + if cand.is_file(): + return cand + return None + + def _pending_confirmed_notes(output_dir: Path, limit: int) -> List[Dict[str, Any]]: - """Stable notes not yet absorbed into a scene block (no consolidated_into).""" + """Stable notes still awaiting a destination (not absorbed, not excluded).""" from codewiki.src.config import NOTES_DIR notes_dir = Path(output_dir) / NOTES_DIR @@ -271,8 +324,9 @@ def _pending_confirmed_notes(output_dir: Path, limit: int) -> List[Dict[str, Any if status not in ("stable", "confirmed"): continue meta = fm.get("metadata") if isinstance(fm.get("metadata"), dict) else {} - if meta.get("consolidated_into"): - continue # already absorbed + disposition = _note_disposition(meta) + if disposition in ("absorbed", "excluded"): + continue # has a destination already body = _read_body(p) scene = "" if isinstance(meta.get("scene"), str): @@ -285,6 +339,7 @@ def _pending_confirmed_notes(output_dir: Path, limit: int) -> List[Dict[str, Any "scene": scene, "severity": str(meta.get("severity") or ""), "preview": body[:_SUMMARY_CHARS], + "disposition": disposition, # None = never judged | "deferred" } ) if len(out) >= limit: @@ -388,7 +443,12 @@ def handle_consolidate_notes(arguments: Dict[str, Any], store: Any) -> str: "(3) write blocks with write_doc_file(page_type='scenario'); obey " "the capacity warning (red=merge first, orange=update only); " "(4) reject_note fully-absorbed source notes with " - "reason='consolidated into <scene title>'; (5) submit the report. " + "reason='consolidated into <scene title>'; (5) submit the report " + "— every candidate needs a destination: source_notes for absorbed " + "ones, otherwise report.dispositions=[{file, verdict: deferred|" + "excluded, reason?}] where excluded REQUIRES a reason. Candidates " + "showing disposition='deferred' were already judged once, so weigh " + "the new evidence rather than repeating the old verdict. " "If this consolidation was triggered by an aggregation_hint " "reminder, confirm with the user before starting." ), @@ -415,8 +475,21 @@ def handle_consolidate_notes(arguments: Dict[str, Any], store: Any) -> str: } ) entries = report.get("scenarios") - if not isinstance(entries, list) or not entries: - return json.dumps({"error": "report.scenarios must be a non-empty list."}) + if entries is None: + entries = [] + if not isinstance(entries, list): + return json.dumps({"error": "report.scenarios must be a list."}) + # A pass may legitimately carry only dispositions (e.g. excluding a batch of + # one-off notes without touching any scene block). + if not entries and not report.get("dispositions"): + return json.dumps( + { + "error": ( + "report needs a non-empty 'scenarios' list, or a non-empty " + "'dispositions' list." + ) + } + ) processed: List[Dict[str, Any]] = [] errors: List[Dict[str, Any]] = [] @@ -506,6 +579,56 @@ def handle_consolidate_notes(arguments: Dict[str, Any], store: Any) -> str: } ) + # ---- candidate dispositions: every non-absorbed note needs a destination ---- + disp_entries = report.get("dispositions") + if disp_entries is None: + disp_entries = [] + if not isinstance(disp_entries, list): + errors.append({"entry": "dispositions", "error": "must be a list"}) + disp_entries = [] + disp_stamped: List[Dict[str, str]] = [] + for entry in disp_entries: + if not isinstance(entry, dict): + errors.append({"entry": str(entry), "error": "not an object"}) + continue + rel = _norm_rel(str(entry.get("file") or ""), output_dir) + verdict = str(entry.get("verdict") or "").lower() + if not rel: + errors.append({"entry": str(entry), "error": "missing file"}) + continue + if verdict not in _SUBMITTABLE_VERDICTS: + errors.append( + { + "file": rel, + "error": ( + f"invalid verdict '{verdict}'; expected one of " + f"{'|'.join(_SUBMITTABLE_VERDICTS)} (absorbed is derived " + "from source_notes, never submitted)" + ), + } + ) + continue + reason = str(entry.get("reason") or "").strip() + if verdict == "excluded" and not reason: + errors.append( + {"file": rel, "error": "verdict=excluded requires a non-empty reason"} + ) + continue + npath = _resolve_note_path(output_dir, rel) + if npath is None: + errors.append({"file": rel, "error": "note file not found"}) + continue + stamp: Dict[str, Any] = { + "verdict": verdict, + "at": datetime.now(timezone.utc).strftime("%Y-%m-%d"), + } + if reason: + stamp["reason"] = reason[:_REASON_CHARS] + if _update_frontmatter_meta(npath, {"disposition": stamp}): + disp_stamped.append({"file": rel, "verdict": verdict}) + else: + errors.append({"file": rel, "error": "frontmatter update failed"}) + if errors: return json.dumps( { @@ -513,6 +636,7 @@ def handle_consolidate_notes(arguments: Dict[str, Any], store: Any) -> str: "mode": "submit", "errors": errors, "processed": processed, + "dispositions": disp_stamped, "message": ( f"{len(errors)} report entr(y/ies) failed validation; counters " "NOT reset. Fix the reported issues and re-submit." @@ -575,13 +699,39 @@ def handle_consolidate_notes(arguments: Dict[str, Any], store: Any) -> str: except Exception as e: # indexing is best-effort logger.warning("search index rebuild failed after consolidate: %s", e) + # Skill-compile hint (design §10): a freshly consolidated scenario whose + # text reads like executable instructions (command-dense, backed by >= 2 + # notes, not yet compiled) is a candidate for skill_creator. Hint only — + # never compiles; the decision stays with the user. + skill_hint = None + try: + from codewiki.src.skill_match import build_skill_hint, score_skill_material + + for item in processed: + if str(item.get("action") or "").lower() == "deleted": + continue + scene = Path(output_dir) / str(item.get("file") or "") + if not scene.is_file(): + continue + score = score_skill_material(scene.read_text(encoding="utf-8", errors="ignore")) + if score.get("worth_compiling"): + skill_hint = build_skill_hint( + "material", {"file": item.get("file"), "score": score} + )["skill_hint"] + break + except Exception as e: # best-effort: a hint failure must not fail submit + logger.debug("skill hint skipped: %s", e) + skill_hint = None + return json.dumps( { "status": "completed", "mode": "submit", "processed": processed, + "dispositions": disp_stamped, "removed_deleted": removed, "capacity": capacity, + **({"skill_hint": skill_hint} if skill_hint else {}), "counters": { "notes_since_last_consolidation": int( state.get("notes_since_last_consolidation") or 0 diff --git a/codewiki/mcp/tools/skill_creator.py b/codewiki/mcp/tools/skill_creator.py index 72a2018..84aa165 100644 --- a/codewiki/mcp/tools/skill_creator.py +++ b/codewiki/mcp/tools/skill_creator.py @@ -597,6 +597,12 @@ def _transform(text: str) -> Optional[str]: } ) meta["revisions"] = revisions + # Status lifecycle (design §10): revising a skill makes the installed + # effect-zone copy stale, so the draft goes back to "draft" and the + # matcher starts offering it again (reinstall is the user's call). + # Deprecated skills stay deprecated — submit must not resurrect them. + if str(fm.get("status") or "").lower() != "deprecated": + fm["status"] = "draft" fm["metadata"] = meta return _render_skill_doc(fm, values["body"]) @@ -690,6 +696,11 @@ def _transform_draft(text: str) -> Optional[str]: # lstrip("./") would eat the leading dot of .codebuddy/. meta["installed_to"] = f".codebuddy/skills/{name}/" meta["installed_hash"] = _normalized_hash(skill_name, description, body) + # Status lifecycle (design §10): an installed draft is no longer an + # uninstalled draft, so it stops being offered by the UserPromptSubmit + # matcher. A later submit revision flips it back to "draft" (the + # effect-zone copy is then stale and must be re-offered). + dfm["status"] = "stable" dfm["metadata"] = meta return _render_skill_doc(dfm, old_body) diff --git a/codewiki/src/skill_match.py b/codewiki/src/skill_match.py new file mode 100644 index 0000000..99827cb --- /dev/null +++ b/codewiki/src/skill_match.py @@ -0,0 +1,266 @@ +"""Draft-skill matching and skill-material scoring (skill-creator §10). + +Stdlib-only by design: the IDE hook must import this without the package's +third-party deps (same constraint as ``codewiki.src.tool_digest``). Both +consumers share it so "is there a skill for this?" has ONE implementation: + +- ``codewiki.mcp._ide_hook`` — UserPromptSubmit: match the user's prompt + against uninstalled draft skills and inject a pointer. +- ``codewiki.mcp.tools.note_consolidation`` — L2 submit: score a freshly + consolidated scenario for "is this behaviour instructions or reference + knowledge?". +- ``codewiki.mcp.tools.distill_conversation`` — submit: match newly created + note titles against draft skills. + +Design decisions (grill 2026-09-07, see docs/skill-creator需求与设计方案.md §10): + +- Only ``status == "draft"`` skills are matchable. ``install`` flips the draft + to ``stable`` (it is no longer an uninstalled draft); a later ``submit`` + revision flips it back to ``draft`` because the effect-zone copy is now + stale and the user should be prompted to reinstall. +- Matching NEVER returns the skill body — ``name`` + ``description`` only. + ADR-0004 decision 2: the draft zone is indexed but not recallable; surfacing + SKILL bodies would blur "retrieval knowledge" vs "behaviour instruction". +- Material scoring uses command density, NOT structure. Measured on the 8 + existing L2 scenarios: all 8 share the identical six-section skeleton and + all have 5-11 numbered steps (zero discriminative power), while the single + scenario that was actually compiled into a skill scored 11 command hits vs + <= 2 for every other one. Section presence and step count are therefore + useless as signals; executable-command density is the discriminator. +""" + +from __future__ import annotations + +import os +import re +from typing import Any, Dict, List, Optional + +# Containment cutoff for prompt <-> draft-skill matching. +# +# CONTAINMENT, NOT JACCARD — measured, not assumed: a prompt that is a verbatim +# prefix of the skill description (a perfect match, i.e. the upper bound) +# scores only 0.205 Jaccard, because the long description inflates the union. +# A 0.6 Jaccard gate would therefore NEVER fire. Containment (|A∩B| / min) +# scores the same pair 1.0. Threshold 0.5 = "half the prompt's tokens show up +# in the skill". Tuned by judgement (only 2 sample skills existed); re-evaluate +# de-duplication when the draft zone grows past ~5 skills. +DEFAULT_MATCH_THRESHOLD = 0.5 + +# A prompt shorter than this cannot be judged — "继续" / "ok" would otherwise +# match anything sharing a couple of bigrams. +MIN_PROMPT_TOKENS = 8 + +# Command hits (see ``_CMD_RE``) needed before an L2 scenario is worth +# suggesting as skill material. Positive example scored 11; the highest +# negative scored 2 — 3 sits just above the negatives with margin. +DEFAULT_CMD_THRESHOLD = 3 + +# Minimum ``notes/`` back-references. A scenario backed by a single note is +# usually a one-off, not a repeatable procedure. +DEFAULT_MIN_NOTE_REFS = 2 + +# Command-position biased: the trailing whitespace keeps prose mentions of +# e.g. "git" from counting as an executed step. +_CMD_RE = re.compile( + r"(?i)\b(?:git|gh|uv|pytest|python|npm|npx|pnpm|curl|grep|rg|sed|awk|" + r"find|docker|make|psql|jq)\s" +) + +_CJK_RE = re.compile(r"[\u4e00-\u9fff]") +_ASCII_RE = re.compile(r"[a-z0-9][a-z0-9_.\-]*") + +# Event names (lower-cased) that carry a user prompt rather than a transcript. +PROMPT_EVENTS = frozenset({"userpromptsubmit"}) + + +def tokenize(text: str) -> set: + """Mixed CJK/ASCII tokenizer. + + ASCII runs become word tokens; CJK runs are sliced into character bigrams + (no word boundaries exist in Chinese, and bigrams give Jaccard something + to work with on short prompts). + """ + lowered = (text or "").lower() + tokens = set(_ASCII_RE.findall(lowered)) + cjk = "".join(_CJK_RE.findall(lowered)) + if len(cjk) == 1: + tokens.add(cjk) + for i in range(len(cjk) - 1): + tokens.add(cjk[i : i + 2]) + return tokens + + +def containment(a: set, b: set) -> float: + """Overlap coefficient: |A∩B| / min(|A|, |B|). + + Chosen over Jaccard for prompt↔description matching — see + ``DEFAULT_MATCH_THRESHOLD`` for the measurement that ruled Jaccard out. + """ + if not a or not b: + return 0.0 + return len(a & b) / min(len(a), len(b)) + + +def parse_skill_frontmatter(text: str) -> Dict[str, str]: + """Read only top-level ``key: value`` lines from a SKILL.md frontmatter. + + Deliberately not a general YAML parser: the hook path is stdlib-only and + we need exactly three fields (name / description / status). Nested blocks + (metadata.*) are skipped. + """ + out: Dict[str, str] = {} + if not text.startswith("---"): + return out + end = text.find("\n---", 3) + if end == -1: + return out + for line in text[3:end].splitlines(): + stripped = line.strip() + if not stripped or stripped[0] in " \t-#": + continue + key, sep, value = line.partition(":") + if not sep: + continue + value = value.strip().strip('"').strip("'") + if value: + out[key.strip()] = value + return out + + +def iter_draft_skills(skills_dir: str) -> List[Dict[str, str]]: + """Draft-zone skills that are NOT installed yet (``status == draft``). + + Installed drafts are ``stable`` and therefore excluded — prompting the + user to install an already-installed skill is pure noise (this was the + entire population when the mechanism first shipped: 1 draft, already + installed). + """ + out: List[Dict[str, str]] = [] + if not skills_dir or not os.path.isdir(skills_dir): + return out + for entry in sorted(os.listdir(skills_dir)): + path = os.path.join(skills_dir, entry, "SKILL.md") + if not os.path.isfile(path): + continue + try: + with open(path, "r", encoding="utf-8") as fh: + text = fh.read() + except OSError: + continue + fm = parse_skill_frontmatter(text) + if str(fm.get("status") or "draft").lower() != "draft": + continue + out.append( + { + "name": fm.get("name") or entry, + "description": fm.get("description") or "", + "file": f"skills/{entry}/SKILL.md", + } + ) + return out + + +def match_draft_skills( + prompt: str, + skills_dir: str, + threshold: float = DEFAULT_MATCH_THRESHOLD, +) -> Optional[Dict[str, Any]]: + """Best draft skill for ``prompt``, or None. Never returns the body.""" + p_tokens = tokenize(prompt) + if len(p_tokens) < MIN_PROMPT_TOKENS: + return None + best: Optional[Dict[str, Any]] = None + for skill in iter_draft_skills(skills_dir): + s_tokens = tokenize(skill["name"] + " " + skill["description"]) + if not s_tokens: + continue + score = containment(p_tokens, s_tokens) + if score < threshold: + continue + if best is None or score > best["score"]: + best = { + "name": skill["name"], + "description": skill["description"], + "file": skill["file"], + "score": round(score, 3), + } + return best + + +def score_skill_material( + text: str, + cmd_threshold: int = DEFAULT_CMD_THRESHOLD, + min_note_refs: int = DEFAULT_MIN_NOTE_REFS, +) -> Dict[str, Any]: + """Does this material read as behaviour instructions rather than knowledge? + + Command density is the discriminator (see module docstring for the + measurement that ruled out section presence and step count). + """ + body = text or "" + cmd_hits = len(_CMD_RE.findall(body)) + code_blocks = body.count("```") // 2 + notes_refs = len(re.findall(r"notes/", body)) + already_compiled = "compiled_into" in body + return { + "cmd_hits": cmd_hits, + "code_blocks": code_blocks, + "notes_refs": notes_refs, + "already_compiled": already_compiled, + "cmd_threshold": cmd_threshold, + "worth_compiling": bool( + cmd_hits >= cmd_threshold + and not already_compiled + and notes_refs >= min_note_refs + ), + } + + +def build_skill_hint(kind: str, payload: Dict[str, Any]) -> Dict[str, Any]: + """Build the additive ``skill_hint`` object. + + Two shapes: + + - ``kind="match"`` — an existing draft skill seems applicable to what the + user is doing / just produced. + - ``kind="material"`` — a fresh L2 scenario looks like skill material. + + Both are HINTS ONLY: never compile, never install, never write. The + message is phrased as an executable instruction because hook-injected + ``additionalContext`` is a soft constraint (see notes/2026-08-15-...). + """ + if kind == "match": + name = str(payload.get("name") or "") + description = str(payload.get("description") or "") + return { + "skill_hint": { + "kind": "match", + "name": name, + "description": description, + "file": payload.get("file"), + "score": payload.get("score"), + "message": ( + f"适用技能草稿 `{name}`:{description}\n" + f"如需启用:skill_creator(mode=\"install\", name=\"{name}\")\n" + "(需你确认后执行;我不会自动 install)" + ), + } + } + if kind == "material": + rel = str(payload.get("file") or "") + score = payload.get("score") if isinstance(payload.get("score"), dict) else {} + return { + "skill_hint": { + "kind": "material", + "file": rel, + "score": score, + "message": ( + f"场景块 `{rel}` 命令密度为 {score.get('cmd_hits', 0)}" + f"(阈值 {score.get('cmd_threshold', DEFAULT_CMD_THRESHOLD)})," + "读起来像可执行的行为指令而非参考知识,可能值得编译成技能。\n" + f"如需评估:skill_creator(mode=\"prepare\", sources=[\"scenarios\"])\n" + "(需你确认后执行;我不会自动编译)" + ), + } + } + return {} diff --git "a/docs/skill-creator\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/skill-creator\344\275\277\347\224\250\346\214\207\345\215\227.md" index 99b34b9..5d729dc 100644 --- "a/docs/skill-creator\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/skill-creator\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -197,6 +197,29 @@ retire:草稿标 deprecated(revisions 记录原因、正文**保留供审计 - **素材过期联动**:技能 source_refs 指向的 scenario/note 后来被更新、退役或删除 → lint 报 `possibly_stale` warning(复用对端新鲜度语义),提醒你决定 revise 还是 retire。只提示,**不自动修订**。 - **漂移检测**:install 之后草稿又被修订(submit updated)→ 草稿规范化哈希 ≠ installed_hash → lint 报 drift warning:"生效区仍旧版,建议 reinstall"。**不自动覆盖生效区**——生效永远是用户动作。 +## 自动提醒:什么时候系统会提示你 + +三条由系统主动提出的**候选提醒**——只提示,绝不自动编译或安装: + +1. **你正在做的事像某份草稿技能**(UserPromptSubmit,claude 家族 hook): + 每次提交指令时,hook 用 name + description 匹配草稿区里**未安装**的技能 + (`status: draft`),命中就注入一行指针。文本只含 name + description—— + 技能正文永远不会被塞进你的上下文(检索隔离的延伸)。 +2. **新场景块像技能素材**(L2 submit 返回 `skill_hint`):落盘后的场景块若 + 命令密集(实测唯一被编译的那份命令命中 11,其余 ≤2)、未被 `compiled_into` + 消费过、且 ≥2 条笔记背书 → 提示你评估编译。 +3. **蒸馏产出撞上草稿**(蒸馏 submit 返回 `skill_hint`):新笔记标题匹配到 + 草稿 → 提示 evaluate/install。蒸馏只做匹配不做素材判据——新笔记还没长成 + SOP,判据要等它被 consolidate 成 L2 才成立。 + +收到 `skill_hint` 怎么办:普通调用方照提示执行即可(install 前仍需你点头); +**蒸馏 worker 的纪律是只把 hint 写进汇报摘要,不自行调用 skill_creator**。 + +配套的状态语义:`install` 过的草稿会从 draft 变 `stable`(不再被提醒); +之后若草稿又被修订,会回到 draft 重新可被提醒(此时生效区已是旧版,lint 也 +会报 drift)。deprecated 永不因修订复活。提醒频次随草稿量增长——草稿区超过 +5 份后阈值需要重新校准。 + ## 完整示例:一次真实编译 ```text diff --git "a/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" "b/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" index d13f156..0ab3259 100644 --- "a/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" +++ "b/docs/skill-creator\351\234\200\346\261\202\344\270\216\350\256\276\350\256\241\346\226\271\346\241\210.md" @@ -277,8 +277,80 @@ skill 的价值密度取决于素材里「命令-报错-修复对、版本/参 **与 MVP 的关系**:MVP(T1-T6)已先行落地;本项在其后实施,成为回流迭代 (Phase 2)的素材质量地基——从此捕获的会话天然携带命令-报错-修复链。 +## 10. 自动触发与技能提示(Q31/Q32 收敛,2026-09-07 实施) + +skill_creator 是显式工具,但「什么时候该考虑编译技能」的判断不该全靠用户 +想起。本节加三处**只提示、永不自动执行**的触发点——与 install 的「触发永远 +显式」纪律一致:工具只把「候选」端到用户面前,编译与安装仍是用户动作。 + +### 10.1 状态语义补全:install → stable,修订 → 回到 draft + +matcher 需要「未被安装的草稿」这个状态,但原实现 install 从不写 status +(草稿永远是 draft)。补齐两处,使 `status: draft` 成为「**可被自动提示 +安装**」的精确含义: + +- `install`:草稿置 `stable` —— 已安装,不再被匹配器推荐; +- `submit updated`:置回 `draft` —— 生效区副本已过时(=漂移),重新可被推荐; +- `retire` 语义不变:deprecated 永不因 submit 复活。 + +lint 的 `frontmatter_required` 白名单本就是 `draft | stable | deprecated` +(wiki_lint.py),补全前 stable 只是「预留未用」,补全后它有了确切的运行时 +含义;漂移检查(installed_hash)不含 status 字段,不受影响。 + +### 10.2 匹配器:`codewiki/src/skill_match.py`(stdlib-only) + +与 `tool_digest` 同层、纯标准库——IDE hook 路径不能依赖包的三方依赖。三处 +消费点共享同一实现,使「这有没有技能」的判断只有一份,永不漂移。 + +**相似度取 containment,不是 Jaccard——实测推翻,不是假设**:以技能 +description 的前缀当 prompt(完美匹配的上界),在真实 +maintain-fork-pr-merge description 上 Jaccard 只有 0.205——长 description +撑大并集,0.6 的 Jaccard 闸门**永远不会触发**。containment(交集/较小集) +同对为 1.0。 + +匹配规则(`match_draft_skills`): +- 只匹配 `status == draft` 的草稿(§10.1 保证该状态的精确含义); +- 只返回 name + description,**永不返回正文**(ADR-0004 decision 2: + 行为指令不可被当检索知识召回); +- prompt ≥ 8 token(「继续」「ok」不可判),containment ≥ 0.5(≈「prompt + 一半的 token 出现在技能里」)。阈值凭判断设定(上线时草稿区仅 2 份样例, + 其中 1 份已安装即被 §10.1 排除),草稿区 > 5 份后重新校准。 + +### 10.3 素材判据:命令密度(不是结构) + +用户原话是「蒸馏/L2 时识别**可以创建**技能的素材」(Q31-Q32)。候选判据 +实测一轮:8 个 L2 场景块**六段骨架 100% 相同**(同一模板生成)、步骤数 +5-11 全过 ≥3——结构判据天生零区分度,作废。唯一强信号是**命令密度**: +唯一被编译成技能的那份(发布与依赖治理方法)`cmd=11 / fence=4`,是第二名 +(cmd=2)的 5 倍以上。 + +`score_skill_material(text)` 判据: +- 命令位命中 ≥ 3(`\b(?:git|gh|uv|pytest|…)\s` 命令位正则,尾随空白使 + 「提到 git」不计为「执行 git」); +- 未被 `compiled_into` 消费过; +- `notes/` 回链 ≥ 2(单笔记背书多半是一次性,不是可复用流程)。 + +### 10.4 三个触发点(提示只增不改,additive key 对齐 aggregation_hint 先例) + +| 触发点 | 载体 | 命中含义 | 提示形态 | +|---|---|---|---| +| UserPromptSubmit(claude 家族 hook) | `hookSpecificOutput.additionalContext` | 用户指令像某个未安装草稿 | `skill_creator install` 指针 | +| L2 submit(note_consolidation) | 返回值 `skill_hint` | 场景块像行为指令(§10.3) | `skill_creator prepare` 指针 | +| 蒸馏 submit(distill_conversation) | 返回值 `skill_hint` | 新笔记标题匹配到草稿 | `skill_creator install` 指针 | + +纪律: +- **提示永不自动执行**——hook 的 UserPromptSubmit 分支只读不写(测试断言 + repowiki 快照前后一致、raw/ 永不产生);两个 MCP submit 的 `skill_hint` + 为 additive key,默认返回值不变(下游无感); +- distill-worker 剧本明令「`skill_hint` 只汇报、不执行,严禁自行调用 + skill_creator」——install 是用户确认动作(Doctrine「触发永远显式」); +- 蒸馏 submit 只做**匹配**、不做素材判据:新蒸馏的笔记还没长成 SOP, + 判据要等它被 consolidate 成 L2 场景块后才成立(素材判据只看落盘后的 + 场景块形态)。 + --- *源设计文档:repowiki/wiki/queries/skill-creator设计方案.md(stable)+ docs/WikiSkill论文与wikiskill源码精读.md(2026-09-06)+ grill Q1-Q9 收敛记录 -(2026-09-06 会话)。状态:已实施(MVP #24-#29 + §9 素材保真度均落地)。* +(2026-09-06 会话)。状态:已实施(MVP #24-#29 + §9 素材保真度 + §10 自动 +触发提示均落地)。* diff --git a/repowiki/.meta/telemetry/iamwangbao-163-com.jsonl b/repowiki/.meta/telemetry/iamwangbao-163-com.jsonl index 136af39..9fcbfc9 100644 --- a/repowiki/.meta/telemetry/iamwangbao-163-com.jsonl +++ b/repowiki/.meta/telemetry/iamwangbao-163-com.jsonl @@ -203,28 +203,28 @@ {"t": "hit", "doc": "wiki/modules/MCP_Tools_DocWriter.md", "at": "2026-09-05", "n": 4} {"t": "hit", "doc": "wiki/modules/MCP_Tools_Analysis.md", "at": "2026-09-05", "n": 2} {"t": "hit", "doc": "wiki/modules/test_doc.md", "at": "2026-09-05", "n": 1} -{"t": "hit", "doc": "notes/2026-08-29-生成的-ps1-必须带-utf-8-bom否则-powershell-51-按-gbk-误读.md", "at": "2026-09-06", "n": 20} -{"t": "hit", "doc": "notes/2026-09-05-github-release-正文乱码不可逆utf-8-字节被按-gbk-解码写入只能基于事实重写.md", "at": "2026-09-06", "n": 5} -{"t": "hit", "doc": "notes/2026-08-03-mcp-工具-schema-不声明-session-idhandler-隐式读取.md", "at": "2026-09-06", "n": 45} -{"t": "hit", "doc": "wiki/modules/KnowledgeStore.md", "at": "2026-09-06", "n": 26} -{"t": "hit", "doc": "wiki/scenarios/MCP-Server薄壳架构与参数约定.md", "at": "2026-09-06", "n": 66} -{"t": "hit", "doc": "wiki/modules/CLI_Adapter.md", "at": "2026-09-06", "n": 15} -{"t": "hit", "doc": "notes/2026-08-26-load-project-checklist-对-yaml-损坏静默回退-none-无日志难排查.md", "at": "2026-09-06", "n": 10} -{"t": "hit", "doc": "wiki/modules/CLI_Utils.md", "at": "2026-09-06", "n": 14} -{"t": "hit", "doc": "wiki/scenarios/Wiki页面生成约定与数据结构.md", "at": "2026-09-06", "n": 33} -{"t": "hit", "doc": "notes/2026-08-29-工作区痕迹齐备时跳过-init-workspace直接跑-bootstrap-脚本补-clone.md", "at": "2026-09-06", "n": 25} -{"t": "hit", "doc": "wiki/modules/CLI_Commands.md", "at": "2026-09-06", "n": 15} -{"t": "hit", "doc": "wiki/modules/CLI.md", "at": "2026-09-06", "n": 15} -{"t": "hit", "doc": "wiki/modules/CLI_Config.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "notes/2026-08-29-生成的-ps1-必须带-utf-8-bom否则-powershell-51-按-gbk-误读.md", "at": "2026-09-06", "n": 21} +{"t": "hit", "doc": "notes/2026-09-05-github-release-正文乱码不可逆utf-8-字节被按-gbk-解码写入只能基于事实重写.md", "at": "2026-09-06", "n": 6} +{"t": "hit", "doc": "notes/2026-08-03-mcp-工具-schema-不声明-session-idhandler-隐式读取.md", "at": "2026-09-06", "n": 49} +{"t": "hit", "doc": "wiki/modules/KnowledgeStore.md", "at": "2026-09-06", "n": 28} +{"t": "hit", "doc": "wiki/scenarios/MCP-Server薄壳架构与参数约定.md", "at": "2026-09-06", "n": 69} +{"t": "hit", "doc": "wiki/modules/CLI_Adapter.md", "at": "2026-09-06", "n": 16} +{"t": "hit", "doc": "notes/2026-08-26-load-project-checklist-对-yaml-损坏静默回退-none-无日志难排查.md", "at": "2026-09-06", "n": 11} +{"t": "hit", "doc": "wiki/modules/CLI_Utils.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "wiki/scenarios/Wiki页面生成约定与数据结构.md", "at": "2026-09-06", "n": 38} +{"t": "hit", "doc": "notes/2026-08-29-工作区痕迹齐备时跳过-init-workspace直接跑-bootstrap-脚本补-clone.md", "at": "2026-09-06", "n": 26} +{"t": "hit", "doc": "wiki/modules/CLI_Commands.md", "at": "2026-09-06", "n": 16} +{"t": "hit", "doc": "wiki/modules/CLI.md", "at": "2026-09-06", "n": 16} +{"t": "hit", "doc": "wiki/modules/CLI_Config.md", "at": "2026-09-06", "n": 16} {"t": "hit", "doc": "notes/2026-09-04-在-codebuddy-使用跨-agent-技能纯-skillmd-直接装-codebuddyskillshooks-需.md", "at": "2026-09-06", "n": 5} -{"t": "hit", "doc": "notes/2026-08-29-subagent-定义的-frontmatter-按宿主家族分发同名文件不同-schema.md", "at": "2026-09-06", "n": 10} -{"t": "hit", "doc": "notes/2026-08-23-distill-worker-subagent-定义随包发布hook-启用时自动拷贝到项目-codebuddyagent.md", "at": "2026-09-06", "n": 15} -{"t": "hit", "doc": "wiki/modules/MCP_Prompts.md", "at": "2026-09-06", "n": 80} -{"t": "hit", "doc": "notes/2026-08-25-mcp-参数长度受限时蒸馏-submit-走文件侧通道python-脚本直接调-handle-distill-conve.md", "at": "2026-09-06", "n": 10} -{"t": "hit", "doc": "wiki/modules/MCP_Core.md", "at": "2026-09-06", "n": 140} -{"t": "hit", "doc": "wiki/modules/MCP_Server.md", "at": "2026-09-06", "n": 65} -{"t": "hit", "doc": "notes/2026-08-25-doctrine-不会自动注入-agent-上下文唯一通道是-query-wikimodeoverview.md", "at": "2026-09-06", "n": 25} -{"t": "hit", "doc": "notes/2026-09-05-codewiki-plus-发布是手动流程三处版本引用-lightweight-tag-ci-不发布.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-08-29-subagent-定义的-frontmatter-按宿主家族分发同名文件不同-schema.md", "at": "2026-09-06", "n": 13} +{"t": "hit", "doc": "notes/2026-08-23-distill-worker-subagent-定义随包发布hook-启用时自动拷贝到项目-codebuddyagent.md", "at": "2026-09-06", "n": 17} +{"t": "hit", "doc": "wiki/modules/MCP_Prompts.md", "at": "2026-09-06", "n": 83} +{"t": "hit", "doc": "notes/2026-08-25-mcp-参数长度受限时蒸馏-submit-走文件侧通道python-脚本直接调-handle-distill-conve.md", "at": "2026-09-06", "n": 11} +{"t": "hit", "doc": "wiki/modules/MCP_Core.md", "at": "2026-09-06", "n": 141} +{"t": "hit", "doc": "wiki/modules/MCP_Server.md", "at": "2026-09-06", "n": 68} +{"t": "hit", "doc": "notes/2026-08-25-doctrine-不会自动注入-agent-上下文唯一通道是-query-wikimodeoverview.md", "at": "2026-09-06", "n": 26} +{"t": "hit", "doc": "notes/2026-09-05-codewiki-plus-发布是手动流程三处版本引用-lightweight-tag-ci-不发布.md", "at": "2026-09-06", "n": 6} {"t": "hit", "doc": "notes/2026-09-05-analyze-repo-超时根因探测教训venv-早已在默认排除且生效真正拖累是-agent-临时目录caveman.md", "at": "2026-09-06", "n": 5} {"t": "hit", "doc": "notes/2026-08-29-analyze-workspace-增量模式内部自动判断做主路径锚点复用-metadatajson.md", "at": "2026-09-06", "n": 30} {"t": "hit", "doc": "notes/2026-08-28-telemetry-原子写入崩溃会残留孤儿-tmppid-文件且无自动清理机制.md", "at": "2026-09-06", "n": 10} @@ -236,12 +236,12 @@ {"t": "hit", "doc": "notes/2026-08-26-handle-query-wiki-在-session-存在时每次查询都全量重建检索索引.md", "at": "2026-09-06", "n": 52} {"t": "hit", "doc": "wiki/modules/MCP_Cache.md", "at": "2026-09-06", "n": 30} {"t": "hit", "doc": "wiki/modules/MCP_Tools_Analysis.md", "at": "2026-09-06", "n": 83} -{"t": "hit", "doc": "wiki/queries/skill-creator设计方案.md", "at": "2026-09-06", "n": 5} -{"t": "hit", "doc": "wiki/modules/MCP_Tools_Quality.md", "at": "2026-09-06", "n": 74} -{"t": "hit", "doc": "wiki/modules/MCP_Tools_Knowledge.md", "at": "2026-09-06", "n": 81} +{"t": "hit", "doc": "wiki/queries/skill-creator设计方案.md", "at": "2026-09-06", "n": 8} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Quality.md", "at": "2026-09-06", "n": 76} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Knowledge.md", "at": "2026-09-06", "n": 84} {"t": "hit", "doc": "wiki/modules/MCP_Tools_Dependency.md", "at": "2026-09-06", "n": 15} -{"t": "hit", "doc": "wiki/modules/AnalysisPipeline.md", "at": "2026-09-06", "n": 15} -{"t": "hit", "doc": "wiki/modules/LLM_Backend.md", "at": "2026-09-06", "n": 26} +{"t": "hit", "doc": "wiki/modules/AnalysisPipeline.md", "at": "2026-09-06", "n": 16} +{"t": "hit", "doc": "wiki/modules/LLM_Backend.md", "at": "2026-09-06", "n": 27} {"t": "hit", "doc": "notes/2026-08-25-untracked-新文件不在分析图谱内review-changes-changed-sources-切片为空.md", "at": "2026-09-06", "n": 5} {"t": "hit", "doc": "notes/2026-08-25-知识摄入到自动检索链路ingest-note-自动写索引close-session-兜底终态.md", "at": "2026-09-06", "n": 15} {"t": "hit", "doc": "notes/2026-08-29-登记业务仓后不自动生成-wiki必须等用户显式要求.md", "at": "2026-09-06", "n": 36} @@ -250,21 +250,21 @@ {"t": "hit", "doc": "notes/2026-09-05-confirmreject-生命周期已从-knowledge-looppy-拆到-note-lifecyclepy202.md", "at": "2026-09-06", "n": 30} {"t": "hit", "doc": "wiki/modules/RouteExtractors.md", "at": "2026-09-06", "n": 5} {"t": "hit", "doc": "notes/2026-08-26-record-hit-同日聚合只查最后一行交错写入下退化为纯追加n-恒为-1.md", "at": "2026-09-06", "n": 15} -{"t": "hit", "doc": "wiki/modules/MCP_Tools_DocWriter.md", "at": "2026-09-06", "n": 50} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_DocWriter.md", "at": "2026-09-06", "n": 51} {"t": "hit", "doc": "notes/2026-08-26-analyze-repo-增量与依赖图谱的测试并行执行存在时序竞态.md", "at": "2026-09-06", "n": 10} -{"t": "hit", "doc": "notes/2026-08-26-analyze-changes-的-changed-components-行区间定位是近似跨函数边界会误报组件.md", "at": "2026-09-06", "n": 8} -{"t": "hit", "doc": "wiki/modules/SharedConfig.md", "at": "2026-09-06", "n": 30} +{"t": "hit", "doc": "notes/2026-08-26-analyze-changes-的-changed-components-行区间定位是近似跨函数边界会误报组件.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "wiki/modules/SharedConfig.md", "at": "2026-09-06", "n": 31} {"t": "hit", "doc": "notes/2026-09-05-测试污染真实-meta-的-module-treefixture-把-module-treejson-写成-test-使.md", "at": "2026-09-06", "n": 20} -{"t": "hit", "doc": "wiki/scenarios/对话蒸馏管线与raw暂存区.md", "at": "2026-09-06", "n": 10} -{"t": "hit", "doc": "wiki/modules/WebApp.md", "at": "2026-09-06", "n": 10} +{"t": "hit", "doc": "wiki/scenarios/对话蒸馏管线与raw暂存区.md", "at": "2026-09-06", "n": 12} +{"t": "hit", "doc": "wiki/modules/WebApp.md", "at": "2026-09-06", "n": 11} {"t": "hit", "doc": "notes/2026-09-05-stale-evidence-只驱动复核提醒仅处理带-content-hash-的条目报-warning-且不自动改写.md", "at": "2026-09-06", "n": 5} {"t": "hit", "doc": "notes/2026-08-25-聚合doctrine-阈值等运行参数通过-repowikischemayaml-conventionsaggregati.md", "at": "2026-09-06", "n": 5} {"t": "hit", "doc": "wiki/modules/GraphAndSort.md", "at": "2026-09-06", "n": 5} -{"t": "hit", "doc": "notes/2026-09-04-d19knowledgestore-跨进程锁文件集中到-wiki-rootmetalockssha256目标绝对路径20.md", "at": "2026-09-06", "n": 15} +{"t": "hit", "doc": "notes/2026-09-04-d19knowledgestore-跨进程锁文件集中到-wiki-rootmetalockssha256目标绝对路径20.md", "at": "2026-09-06", "n": 16} {"t": "hit", "doc": "notes/2026-09-05-schemayaml-模板双源收敛为包内单源删根副本守卫测试只验包内模板清理-init-wikischema-gener.md", "at": "2026-09-06", "n": 9} {"t": "hit", "doc": "notes/2026-08-23-会话启动时的-query-wiki蒸馏等重操作委托-subagent-执行避免阻塞用户正常使用.md", "at": "2026-09-06", "n": 10} {"t": "hit", "doc": "notes/2026-08-23-hook-采集机制仅正式接线-codebuddyreadme-措辞用仅接线支持.md", "at": "2026-09-06", "n": 2} -{"t": "hit", "doc": "wiki/scenarios/任务记忆系统设计方法.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "wiki/scenarios/任务记忆系统设计方法.md", "at": "2026-09-06", "n": 6} {"t": "hit", "doc": "notes/2026-09-04-file-lock-的锁文件可能是数据文件本身释放即删只能加在-storelocked-不能下沉到-file-lock.md", "at": "2026-09-06", "n": 8} {"t": "hit", "doc": "notes/2026-09-04-锁文件清理采用仅-windows-释放即删unix-一律保留不删.md", "at": "2026-09-06", "n": 5} {"t": "hit", "doc": "wiki/reading-guide.md", "at": "2026-09-06", "n": 5} @@ -275,3 +275,64 @@ {"t": "hit", "doc": "wiki/modules/LanguageAnalyzers.md", "at": "2026-09-06", "n": 5} {"t": "hit", "doc": "notes/2026-09-05-doc-similaritypy同源判定用正文-shingle-的-minhash-bottom-k-sketch-ja.md", "at": "2026-09-06", "n": 5} {"t": "hit", "doc": "notes/2026-09-05-code-routing-代码注入分档的真实规则纯-boilerplate-文件仅签名businessinfra混合全量.md", "at": "2026-09-06", "n": 5} +{"t": "hit", "doc": "notes/2026-08-21-下一期方向资产置信分层与负反馈闭环roadmap-phase-5.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/concepts/混合检索策略.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/concepts/ReActAgent.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/concepts/文档知识图谱.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/entities/WeKnora.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/sources/README_CN_2.0.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/sources/README_CN.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/concepts/RAG.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/entities/ClawHubSkill.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/concepts/技能目录与沙箱运行时.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/entities/Langfuse.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/scenarios/代码评审与分析工具方法.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/modules/DocVisualizer.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/scenarios/IDE-Hook采集链路方法.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/modules/Frontend.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "wiki/modules/DependencyAnalyzer.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-09-06-调研报告的借鉴建议必须先过代码核对b2b1-痛点表述与实际不符的三处教训.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-09-05-竞品调研必须克隆源码读代码文档站可能系统性滞后claude-mem-文档-v5-说-4-工具代码-v13-实际-19fi.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-09-06-deepwiki-类四竞品源码横评结论llm-直读是主流ast-调用图是-codewiki-唯一稀缺资产.md", "at": "2026-09-06", "n": 1} +{"t": "adopted", "doc": "notes/2026-08-21-下一期方向资产置信分层与负反馈闭环roadmap-phase-5.md", "at": "2026-09-06T23:18:18", "key": "iamwangbao-163-com/4a93b9221bce4788a5ebc8bc4be149ce"} +{"t": "adopted", "doc": "notes/2026-09-06-deepwiki-类四竞品源码横评结论llm-直读是主流ast-调用图是-codewiki-唯一稀缺资产.md", "at": "2026-09-06T23:18:18", "key": "iamwangbao-163-com/4a93b9221bce4788a5ebc8bc4be149ce"} +{"t": "adopted", "doc": "notes/2026-09-06-调研报告的借鉴建议必须先过代码核对b2b1-痛点表述与实际不符的三处教训.md", "at": "2026-09-06T23:18:18", "key": "iamwangbao-163-com/4a93b9221bce4788a5ebc8bc4be149ce"} +{"t": "hit", "doc": "notes/2026-09-05-蒸馏-subagent-自报的笔记状态不可信需用-get-task-context-的-related-notes-状态.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-08-26-read-versioned-lines-对磁盘上已不存在的-untracked-文件返回空列表产生只有-header.md", "at": "2026-09-06", "n": 1} +{"t": "hit", "doc": "notes/2026-08-29-生成的-ps1-必须带-utf-8-bom否则-powershell-51-按-gbk-误读.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "notes/2026-09-05-github-release-正文乱码不可逆utf-8-字节被按-gbk-解码写入只能基于事实重写.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "notes/2026-08-03-mcp-工具-schema-不声明-session-idhandler-隐式读取.md", "at": "2026-09-07", "n": 8} +{"t": "hit", "doc": "wiki/modules/KnowledgeStore.md", "at": "2026-09-07", "n": 6} +{"t": "hit", "doc": "wiki/scenarios/Wiki页面生成约定与数据结构.md", "at": "2026-09-07", "n": 6} +{"t": "hit", "doc": "wiki/scenarios/MCP-Server薄壳架构与参数约定.md", "at": "2026-09-07", "n": 4} +{"t": "hit", "doc": "wiki/modules/CLI_Adapter.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "notes/2026-08-26-load-project-checklist-对-yaml-损坏静默回退-none-无日志难排查.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "wiki/modules/CLI_Utils.md", "at": "2026-09-07", "n": 4} +{"t": "hit", "doc": "notes/2026-08-29-工作区痕迹齐备时跳过-init-workspace直接跑-bootstrap-脚本补-clone.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "notes/2026-08-29-subagent-定义的-frontmatter-按宿主家族分发同名文件不同-schema.md", "at": "2026-09-07", "n": 10} +{"t": "hit", "doc": "notes/2026-08-23-distill-worker-subagent-定义随包发布hook-启用时自动拷贝到项目-codebuddyagent.md", "at": "2026-09-07", "n": 4} +{"t": "hit", "doc": "wiki/modules/MCP_Prompts.md", "at": "2026-09-07", "n": 6} +{"t": "hit", "doc": "notes/2026-08-25-mcp-参数长度受限时蒸馏-submit-走文件侧通道python-脚本直接调-handle-distill-conve.md", "at": "2026-09-07", "n": 4} +{"t": "hit", "doc": "wiki/modules/MCP_Core.md", "at": "2026-09-07", "n": 4} +{"t": "hit", "doc": "wiki/modules/MCP_Server.md", "at": "2026-09-07", "n": 6} +{"t": "hit", "doc": "notes/2026-09-05-codewiki-plus-发布是手动流程三处版本引用-lightweight-tag-ci-不发布.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "notes/2026-08-25-doctrine-不会自动注入-agent-上下文唯一通道是-query-wikimodeoverview.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "wiki/reading-guide.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "wiki/modules/SharedConfig.md", "at": "2026-09-07", "n": 4} +{"t": "hit", "doc": "notes/2026-09-04-d19knowledgestore-跨进程锁文件集中到-wiki-rootmetalockssha256目标绝对路径20.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "wiki/entities/WeKnoraMCP_Server.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "wiki/modules/WebApp.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "wiki/modules/AnalysisPipeline.md", "at": "2026-09-07", "n": 4} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Quality.md", "at": "2026-09-07", "n": 6} +{"t": "hit", "doc": "wiki/modules/CLI_Commands.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "wiki/modules/CLI.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "wiki/modules/AnalyzerUtils.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "wiki/queries/skill-creator设计方案.md", "at": "2026-09-07", "n": 6} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_Knowledge.md", "at": "2026-09-07", "n": 8} +{"t": "hit", "doc": "notes/2026-08-26-distill-conversation-submit-mcp-超时后仍会执行且不幂等超时重试导致任务记忆重复写入与字节.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "wiki/scenarios/对话蒸馏管线与raw暂存区.md", "at": "2026-09-07", "n": 4} +{"t": "hit", "doc": "notes/2026-08-26-analyze-changes-的-changed-components-行区间定位是近似跨函数边界会误报组件.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "notes/2026-09-05-蒸馏-subagent-自报的笔记状态不可信需用-get-task-context-的-related-notes-状态.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "wiki/scenarios/任务记忆系统设计方法.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "notes/2026-08-26-read-versioned-lines-对磁盘上已不存在的-untracked-文件返回空列表产生只有-header.md", "at": "2026-09-07", "n": 2} +{"t": "hit", "doc": "wiki/modules/MCP_Tools_DocWriter.md", "at": "2026-09-07", "n": 2} diff --git a/repowiki/skills/maintain-fork-pr-merge/SKILL.md b/repowiki/skills/maintain-fork-pr-merge/SKILL.md index 7793f2e..63bcf3e 100644 --- a/repowiki/skills/maintain-fork-pr-merge/SKILL.md +++ b/repowiki/skills/maintain-fork-pr-merge/SKILL.md @@ -2,7 +2,7 @@ name: maintain-fork-pr-merge description: 当合入 fork 来源的 PR 且 mergeStateStatus=CONFLICTING/DIRTY 时——先 git merge-tree 探测冲突清单,查 maintainer_can_modify,再在 worktree 隔离目录解冲突后 push fork 分支,勿因 CI 绿直接 merge type: Skill -status: draft +status: stable generated: by: codewiki/5.6.1 at: "2026-09-06T11:43:46Z" diff --git a/tests/test_skill_match.py b/tests/test_skill_match.py new file mode 100644 index 0000000..5dad5ed --- /dev/null +++ b/tests/test_skill_match.py @@ -0,0 +1,402 @@ +"""Tests for draft-skill matching / material scoring (skill-creator §10). + +Covers the three consumers of ``codewiki.src.skill_match`` and the status +lifecycle that makes "uninstalled draft" a meaningful state: + +- tokenization + containment scoring (Jaccard was measured and rejected) +- draft filtering: only ``status: draft`` is matchable +- matching never leaks the skill body (ADR-0004 decision 2) +- material scoring: command density is the discriminator +- UserPromptSubmit hook branch: injects a pointer, writes nothing +- install → ``stable``; submit revision → back to ``draft`` +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +from codewiki.mcp import _ide_hook as hook +from codewiki.mcp.session import SessionStore +from codewiki.mcp.tools import skill_creator as sc +from codewiki.src import skill_match as sm + +_SECTIONS = ["工作场景", "适用条件", "核心 SOP", "判断逻辑", "禁忌与反模式"] + +_DESCRIPTION = ( + "当合入 fork 来源的 PR 且状态冲突时,先 git merge-tree 探测冲突," + "再在 worktree 隔离目录解冲突后 push fork 分支" +) + + +def _write_draft( + skills_dir: Path, name: str, status: str = "draft", description: str = _DESCRIPTION +) -> Path: + path = skills_dir / name / "SKILL.md" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "---\n" + + yaml.safe_dump( + { + "name": name, + "description": description, + "type": "Skill", + "status": status, + "metadata": {"summary": f"summary of {name}"}, + }, + allow_unicode=True, + ) + + "---\n\n" + + "\n".join(f"## {s}\n\nSECRET-BODY-MARKER-{s}" for s in _SECTIONS) + + "\n", + encoding="utf-8", + ) + return path + + +# --------------------------------------------------------------------------- # +# tokenize / containment +# --------------------------------------------------------------------------- # +def test_tokenize_mixes_cjk_bigrams_and_ascii(): + tokens = sm.tokenize("合入 fork 的 PR") + assert "fork" in tokens + assert "pr" in tokens + assert "合入" in tokens + # spaces and latin punctuation contribute nothing + assert "" not in tokens + + +def test_containment_is_prompt_coverage_not_jaccard(): + """Regression guard for the measured Jaccard failure. + + A verbatim prefix of the description is a perfect match; Jaccard scored it + 0.205 (union inflated by the long description) which would never clear a + 0.6 gate. Containment must score it 1.0. + """ + prompt = _DESCRIPTION[:24] + a, b = sm.tokenize(prompt), sm.tokenize(_DESCRIPTION) + assert sm.containment(a, b) == 1.0 + # Same pair under Jaccard sits well below the 0.6 gate that was originally + # specified — on the real maintain-fork-pr-merge description it was 0.205. + # A Jaccard gate would therefore never fire, on any length of description. + assert len(a & b) / len(a | b) < 0.6 + + +def test_containment_empty_sets_are_zero(): + assert sm.containment(set(), {"a"}) == 0.0 + assert sm.containment({"a"}, set()) == 0.0 + + +# --------------------------------------------------------------------------- # +# draft filtering + matching +# --------------------------------------------------------------------------- # +def test_iter_draft_skills_only_returns_drafts(tmp_path: Path): + d = tmp_path / "skills" + _write_draft(d, "fresh", "draft") + _write_draft(d, "installed", "stable") + _write_draft(d, "gone", "deprecated") + + names = [s["name"] for s in sm.iter_draft_skills(str(d))] + assert names == ["fresh"] + + +def test_iter_draft_skills_missing_dir_is_empty(tmp_path: Path): + assert sm.iter_draft_skills(str(tmp_path / "nope")) == [] + + +def test_match_hits_on_overlapping_prompt(tmp_path: Path): + d = tmp_path / "skills" + _write_draft(d, "fork-pr-conflict") + hit = sm.match_draft_skills("当合入 fork 来源的 PR 且状态冲突时", str(d)) + assert hit is not None + assert hit["name"] == "fork-pr-conflict" + assert hit["score"] >= 0.5 + + +def test_match_misses_on_unrelated_prompt(tmp_path: Path): + d = tmp_path / "skills" + _write_draft(d, "fork-pr-conflict") + assert sm.match_draft_skills("把本周的会议纪要整理成一篇公众号文章", str(d)) is None + + +def test_match_ignores_very_short_prompts(tmp_path: Path): + """A two-token prompt must not match on shared bigrams alone.""" + d = tmp_path / "skills" + _write_draft(d, "fork-pr-conflict") + assert sm.match_draft_skills("继续", str(d)) is None + + +def test_match_never_returns_the_body(tmp_path: Path): + """ADR-0004 decision 2: name + description only, never the SKILL body.""" + d = tmp_path / "skills" + _write_draft(d, "fork-pr-conflict") + hit = sm.match_draft_skills("当合入 fork 来源的 PR 且状态冲突时", str(d)) + assert hit is not None + assert set(hit) == {"name", "description", "file", "score"} + assert "SECRET-BODY-MARKER" not in json.dumps(hit, ensure_ascii=False) + + +def test_match_returns_best_of_several(tmp_path: Path): + d = tmp_path / "skills" + _write_draft(d, "weak", description="一些完全不相关的技能描述内容放在这里") + _write_draft(d, "strong", description=_DESCRIPTION) + hit = sm.match_draft_skills("当合入 fork 来源的 PR 且状态冲突时", str(d)) + assert hit["name"] == "strong" + + +# --------------------------------------------------------------------------- # +# material scoring +# --------------------------------------------------------------------------- # +_CMD_HEAVY = ( + "---\nmetadata:\n source_refs: [notes/a.md, notes/b.md]\n---\n\n" + "## 核心 SOP\n\n1. git merge-tree 探测\n2. git worktree add 隔离\n" + "3. git push 到 fork\n4. gh pr merge 收尾\n" +) + + +def test_score_material_worth_compiling(): + score = sm.score_skill_material(_CMD_HEAVY) + assert score["cmd_hits"] >= 3 + assert score["notes_refs"] >= 2 + assert score["already_compiled"] is False + assert score["worth_compiling"] is True + + +def test_score_material_rejects_prose_only_scenario(): + score = sm.score_skill_material( + "---\nmetadata:\n source_refs: [notes/a.md]\n---\n\n" + "## 核心 SOP\n\n1. 先理解整体架构\n2. 再梳理模块边界\n" + ) + assert score["cmd_hits"] == 0 + assert score["worth_compiling"] is False + + +def test_score_material_rejects_already_compiled(): + score = sm.score_skill_material( + _CMD_HEAVY.replace("---\nmetadata:", "---\nmetadata:\n compiled_into: [skills/x]") + ) + assert score["already_compiled"] is True + assert score["worth_compiling"] is False + + +def test_score_material_rejects_single_note_backing(): + score = sm.score_skill_material( + "---\nmetadata:\n source_refs: [notes/a.md]\n---\n\n" + "1. git merge-tree\n2. git worktree add\n3. git push\n" + ) + assert score["notes_refs"] == 1 + assert score["worth_compiling"] is False + + +# --------------------------------------------------------------------------- # +# hint payloads +# --------------------------------------------------------------------------- # +def test_build_skill_hint_match_shape(): + hint = sm.build_skill_hint("match", {"name": "x", "description": "d", "score": 0.7}) + assert hint["skill_hint"]["kind"] == "match" + assert 'mode="install"' in hint["skill_hint"]["message"] + + +def test_build_skill_hint_material_shape(): + hint = sm.build_skill_hint( + "material", {"file": "wiki/scenarios/a.md", "score": sm.score_skill_material(_CMD_HEAVY)} + ) + assert hint["skill_hint"]["kind"] == "material" + assert 'mode="prepare"' in hint["skill_hint"]["message"] + + +def test_build_skill_hint_unknown_kind_is_empty(): + assert sm.build_skill_hint("nope", {}) == {} + + +# --------------------------------------------------------------------------- # +# UserPromptSubmit hook branch +# --------------------------------------------------------------------------- # +def _mk_repo(tmp_path: Path, skills: dict[str, str] | None = None) -> Path: + repo = tmp_path / "repo" + (repo / "repowiki" / "skills").mkdir(parents=True) + for name, status in (skills or {"fork-pr-conflict": "draft"}).items(): + _write_draft(repo / "repowiki" / "skills", name, status) + return repo + + +def _run_hook(repo: Path, event: dict, tmp_path: Path, capsys) -> str: + event_file = tmp_path / "event.json" + event_file.write_text(json.dumps(event, ensure_ascii=False), encoding="utf-8") + code = hook.main( + ["--enable", "--conversation", str(event_file), "--repo-path", str(repo)] + ) + assert code == 0 + return capsys.readouterr().out + + +def test_hook_user_prompt_injects_additional_context(tmp_path: Path, capsys): + repo = _mk_repo(tmp_path) + out = _run_hook( + repo, + { + "hook_event_name": "UserPromptSubmit", + "prompt": "当合入 fork 来源的 PR 且状态冲突时", + "repo_path": str(repo), + }, + tmp_path, + capsys, + ) + payload = json.loads(out) + ctx = payload["hookSpecificOutput"]["additionalContext"] + assert "fork-pr-conflict" in ctx + assert "SECRET-BODY-MARKER" not in ctx # body never injected + + +def test_hook_user_prompt_silent_without_match(tmp_path: Path, capsys): + repo = _mk_repo(tmp_path) + out = _run_hook( + repo, + { + "hook_event_name": "UserPromptSubmit", + "prompt": "把本周的会议纪要整理成一篇公众号文章", + }, + tmp_path, + capsys, + ) + assert out == "" + + +def test_hook_user_prompt_writes_nothing(tmp_path: Path, capsys): + repo = _mk_repo(tmp_path) + wiki = repo / "repowiki" + before = {str(p): p.read_bytes() for p in sorted(wiki.rglob("*")) if p.is_file()} + _run_hook( + repo, + { + "hook_event_name": "UserPromptSubmit", + "prompt": "当合入 fork 来源的 PR 且状态冲突时", + }, + tmp_path, + capsys, + ) + after = {str(p): p.read_bytes() for p in sorted(wiki.rglob("*")) if p.is_file()} + assert before == after + + +def test_hook_prompt_event_never_captures(tmp_path: Path, capsys): + """A prompt event must not reach the capture path (no raw/ file created).""" + repo = _mk_repo(tmp_path) + _run_hook( + repo, + { + "hook_event_name": "UserPromptSubmit", + "prompt": "当合入 fork 来源的 PR 且状态冲突时", + "conversation": [{"role": "user", "content": "should not be captured"}], + }, + tmp_path, + capsys, + ) + assert not (repo / "repowiki" / "raw").exists() + + +# --------------------------------------------------------------------------- # +# status lifecycle (install → stable, revision → draft) +# --------------------------------------------------------------------------- # +def _mk_skill_repo(tmp_path: Path, status: str = "draft") -> tuple[str, Path, Path]: + repo = tmp_path / "repo" + od = repo / "repowiki" + (od / "notes").mkdir(parents=True) + (od / "skills").mkdir(parents=True) + (od / "wiki" / "scenarios").mkdir(parents=True) + (od / "schema.yaml").write_text( + yaml.safe_dump( + {"page_types": {"skill": {"directory": "skills", "required_sections": _SECTIONS}}}, + allow_unicode=True, + ), + encoding="utf-8", + ) + draft = _write_draft(od / "skills", "fork-pr-conflict", status) + return str(repo), od, draft + + +def _call(repo: str, args: dict) -> dict: + return json.loads(sc.handle_skill_creator({"repo_path": repo, **args}, SessionStore())) + + +def _fm(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + return yaml.safe_load(text[3 : text.find("---", 3)]) + + +def test_install_flips_status_to_stable(tmp_path: Path): + repo, od, draft = _mk_skill_repo(tmp_path) + assert _fm(draft)["status"] == "draft" + + res = _call(repo, {"mode": "install", "name": "fork-pr-conflict"}) + assert res["status"] == "installed" + + fm = _fm(draft) + assert fm["status"] == "stable" + assert fm["metadata"]["installed_at"] + + +def test_installed_skill_is_no_longer_matchable(tmp_path: Path): + repo, od, _ = _mk_skill_repo(tmp_path) + assert sm.iter_draft_skills(str(od / "skills")) != [] + _call(repo, {"mode": "install", "name": "fork-pr-conflict"}) + assert sm.iter_draft_skills(str(od / "skills")) == [] + + +def test_submit_revision_flips_status_back_to_draft(tmp_path: Path): + repo, od, draft = _mk_skill_repo(tmp_path) + _call(repo, {"mode": "install", "name": "fork-pr-conflict"}) + assert _fm(draft)["status"] == "stable" + + res = _call( + repo, + { + "mode": "submit", + "report": { + "skills": [ + { + "name": "fork-pr-conflict", + "action": "updated", + "description": "当合入 fork 来源的 PR 且状态冲突时执行修订后的 SOP", + "body": "\n".join(f"## {s}\n\nrevised" for s in _SECTIONS), + "source_refs": ["notes/some.md"], + "revision_note": "revised after drift", + } + ] + }, + }, + ) + assert res["status"] == "completed" + assert _fm(draft)["status"] == "draft" + + +def test_submit_does_not_resurrect_deprecated(tmp_path: Path): + repo, _od, draft = _mk_skill_repo(tmp_path, status="deprecated") + _call( + repo, + { + "mode": "submit", + "report": { + "skills": [ + { + "name": "fork-pr-conflict", + "action": "updated", + "description": "当合入 fork 来源的 PR 且状态冲突时执行 SOP", + "body": "\n".join(f"## {s}\n\nrevised" for s in _SECTIONS), + "source_refs": ["notes/some.md"], + } + ] + }, + }, + ) + assert _fm(draft)["status"] == "deprecated" + + +@pytest.mark.parametrize("status", ["draft", "stable", "deprecated"]) +def test_parse_skill_frontmatter_reads_status(tmp_path: Path, status: str): + d = tmp_path / "skills" + path = _write_draft(d, "x", status) + assert sm.parse_skill_frontmatter(path.read_text(encoding="utf-8"))["status"] == status From 0db5ae19300d10ac5e63d856b39e068978bb6feb Mon Sep 17 00:00:00 2001 From: mambo-wang <iamwangbao@163.com> Date: Mon, 7 Sep 2026 09:36:18 +0800 Subject: [PATCH 19/23] =?UTF-8?q?feat(cli):=20install-hooks=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20UserPromptSubmit=20=E6=8A=80=E8=83=BD=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E4=BA=8B=E4=BB=B6=E2=80=94=E2=80=94advisory=20?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=E4=B8=80=E9=94=AE=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skill-creator §10 的 UserPromptSubmit(草稿技能提示)此前只能手动加 settings.json;本次纳入 install-hooks: - merge_settings_json 新增注册:matcher 空串 = 每条用户指令都过匹配器 (脚本内部 containment 阈值把关),command = PROMPT_HOOK_CMD `python -m codewiki.mcp._ide_hook --enable`(同步执行、stdout 回吐 hookSpecificOutput,timeout 10)。 - hook 命令不含任何路径,settings.json 随仓库共享天然可移植,无需 物理脚本 wrapper(与 SessionStart/End 的相对路径拷贝互补)。 - HOOKS_REGISTRATION 骨架与模块注释同步。 测试(116 通过):注册形状、幂等去重、保留用户自有 UserPromptSubmit matcher、真实 install 后 settings 三事件齐全。 本仓库 .codebuddy/settings.json 同步真实启用(当前草稿区为空,命中 静默不注入)。 --- .codebuddy/settings.json | 12 ++++++++ codewiki/cli/commands/install_hooks.py | 3 +- codewiki/cli/utils/ide_config.py | 32 ++++++++++++++++---- tests/test_install_hooks.py | 42 +++++++++++++++++++++++++- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/.codebuddy/settings.json b/.codebuddy/settings.json index dd07131..e35f709 100644 --- a/.codebuddy/settings.json +++ b/.codebuddy/settings.json @@ -23,6 +23,18 @@ } ] } + ], + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "python -m codewiki.mcp._ide_hook --enable", + "timeout": 10 + } + ] + } ] } } diff --git a/codewiki/cli/commands/install_hooks.py b/codewiki/cli/commands/install_hooks.py index e315193..9bf07bc 100644 --- a/codewiki/cli/commands/install_hooks.py +++ b/codewiki/cli/commands/install_hooks.py @@ -91,7 +91,8 @@ def install_hooks(ide: str, create_dir: bool, repo_path: str) -> None: 无 --ide 参数时自动检测项目根目录存在的智能体配置目录 (.codebuddy/.qoder/.claude),检测到哪些就为哪些接线。 每个 IDE 接线内容:强制拷贝 hook 脚本与 distill-worker subagent 到 - 对应目录、幂等合并 settings.json 的 SessionStart/SessionEnd 注册、 + 对应目录、幂等合并 settings.json 的 SessionStart/SessionEnd 采集注册 + + UserPromptSubmit 技能草稿提示注册(advisory,见 skill-creator §10)、 向 AGENTS.md upsert 任务记忆引导段(多 IDE 共享一份)。 Examples: diff --git a/codewiki/cli/utils/ide_config.py b/codewiki/cli/utils/ide_config.py index 7ed2041..ba0aa54 100644 --- a/codewiki/cli/utils/ide_config.py +++ b/codewiki/cli/utils/ide_config.py @@ -98,7 +98,18 @@ START_HOOK_CMD = 'python "{ide_dir}/hooks/task_session_start.py"' END_HOOK_CMD = 'python "{ide_dir}/hooks/capture_session_end.py"' -# hook 事件注册骨架,command 运行时补全为相对路径命令 +# UserPromptSubmit(技能草稿提示,skill-creator §10):走包内入口 `python -m` +# 而非物理脚本——IDE 以项目根为工作目录执行 hook 命令,cwd 在 sys.path 上, +# checkout 内或已 pip 安装的 codewiki 包即可 import。命令不含任何路径, +# settings.json 随仓库共享天然可移植。脚本同步读 stdin 事件,内部按 +# containment 阈值过滤,命中 `status: draft` 草稿才输出 hookSpecificOutput +# (advisory:只提示、不自动 install)。 +PROMPT_HOOK_CMD = "python -m codewiki.mcp._ide_hook --enable" + +# hook 事件注册骨架,command 运行时补全为相对路径命令。matcher 语义: +# SessionStart 的 "startup" 匹配会话启动;SessionEnd 的 "other" 匹配任意原因; +# UserPromptSubmit 的空串 matcher 让每条用户指令都过一遍匹配器,是否提示由 +# _ide_hook 内部的草稿匹配把关(命中才产生输出,未命中 stdout 为空不注入)。 HOOKS_REGISTRATION = { "SessionStart": [ {"matcher": "startup", "hooks": [{"type": "command", "command": "<cmd>", "timeout": 15}]} @@ -106,6 +117,9 @@ "SessionEnd": [ {"matcher": "other", "hooks": [{"type": "command", "command": "<cmd>", "timeout": 30}]} ], + "UserPromptSubmit": [ + {"matcher": "", "hooks": [{"type": "command", "command": "<cmd>", "timeout": 10}]} + ], } @@ -155,11 +169,13 @@ def detect_ide_dirs(repo: str) -> list[str]: def merge_settings_json(existing: Optional[dict], start_cmd: str, end_cmd: str) -> dict: """幂等合并 CodeWiki 的 hook 注册到现有 settings.json 配置。 - 保留 existing 中全部既有键;对 hooks.SessionStart/SessionEnd 数组按 command - 去重后合并 CodeWiki 注册项,避免重复注册。历史旧格式条目(绝对路径、 - 反斜杠路径或 ``$*_PROJECT_DIR`` 占位符形式)指向同一相对脚本路径时, - 原地迁移为相对路径命令(保留原 timeout),重跑接线不产生重复条目。 - 返回合并结果,由调用方原子写回。 + 保留 existing 中全部既有键;对 hooks.SessionStart/SessionEnd/UserPromptSubmit + 数组按 command 去重后合并 CodeWiki 注册项,避免重复注册。历史旧格式条目 + (绝对路径、反斜杠路径或 ``$*_PROJECT_DIR`` 占位符形式)指向同一相对脚本 + 路径时,原地迁移为相对路径命令(保留原 timeout),重跑接线不产生重复条目。 + UserPromptSubmit(advisory 技能提示)走常量命令 ``PROMPT_HOOK_CMD``—— + ``python -m`` 入口不含路径,无从迁移;matcher 空串 = 每条指令都过匹配器, + 由脚本内部 containment 阈值把关。返回合并结果,由调用方原子写回。 """ merged = copy.deepcopy(existing) if existing else {} hooks = merged.get("hooks") @@ -170,6 +186,10 @@ def merge_settings_json(existing: Optional[dict], start_cmd: str, end_cmd: str) registrations = [ ("SessionStart", "startup", start_cmd, 15), ("SessionEnd", "other", end_cmd, 30), + # matcher 空串:UserPromptSubmit 的匹配对象是用户指令文本,空串 = + # 每条都触发(区别于 SessionStart 的 "startup" 只匹配会话启动)。 + # 同步执行(IDE 要等 stdout 的 hookSpecificOutput),timeout 10 足够。 + ("UserPromptSubmit", "", PROMPT_HOOK_CMD, 10), ] for event, matcher, command, timeout in registrations: if event not in hooks or not isinstance(hooks[event], list): diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py index 3f7b2c5..6319c3d 100644 --- a/tests/test_install_hooks.py +++ b/tests/test_install_hooks.py @@ -20,6 +20,7 @@ from codewiki.cli.utils.ide_config import ( AGENT_FILE, HOOK_FILES, + PROMPT_HOOK_CMD, detect_ide_dirs, install_for_ide, merge_settings_json, @@ -111,11 +112,15 @@ def test_merge_keeps_unrelated_config(): assert end[0]["matcher"] == "other" assert end[0]["hooks"][0]["command"] == "end-cmd" assert end[0]["hooks"][0]["timeout"] == 30 + prompt = merged["hooks"]["UserPromptSubmit"] + assert prompt[0]["matcher"] == "" + assert prompt[0]["hooks"][0]["command"] == PROMPT_HOOK_CMD + assert prompt[0]["hooks"][0]["timeout"] == 10 def test_merge_none_existing(): merged = merge_settings_json(None, "start-cmd", "end-cmd") - assert set(merged["hooks"]) == {"SessionStart", "SessionEnd"} + assert set(merged["hooks"]) == {"SessionStart", "SessionEnd", "UserPromptSubmit"} def test_merge_is_idempotent(): @@ -125,6 +130,7 @@ def test_merge_is_idempotent(): # Re-running must not grow the registrations. assert len(twice["hooks"]["SessionStart"]) == 1 assert len(twice["hooks"]["SessionEnd"]) == 1 + assert len(twice["hooks"]["UserPromptSubmit"]) == 1 def test_merge_dedups_same_command_with_different_timeout(): @@ -144,6 +150,37 @@ def test_merge_dedups_same_command_with_different_timeout(): assert merged["hooks"]["SessionStart"][0]["hooks"][0]["timeout"] == 99 +def test_merge_registers_user_prompt_submit(): + merged = merge_settings_json(None, "start-cmd", "end-cmd") + up = merged["hooks"]["UserPromptSubmit"] + assert len(up) == 1 + assert up[0]["matcher"] == "" # empty matcher = every prompt runs the matcher + assert up[0]["hooks"] == [ + {"type": "command", "command": PROMPT_HOOK_CMD, "timeout": 10} + ] + + +def test_merge_keeps_existing_user_prompt_matchers(): + # User-owned UserPromptSubmit rules (any matcher) are kept untouched next to + # CodeWiki's empty-matcher registration. + existing = { + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "startup", + "hooks": [{"type": "command", "command": "my-own-hook", "timeout": 5}], + } + ] + } + } + merged = merge_settings_json(existing, "start-cmd", "end-cmd") + up = merged["hooks"]["UserPromptSubmit"] + assert len(up) == 2 + assert [e["matcher"] for e in up] == ["startup", ""] + own = next(e for e in up if e["matcher"] == "startup") + assert own["hooks"] == [{"type": "command", "command": "my-own-hook", "timeout": 5}] + + # Legacy entries (absolute / backslash paths, or $*_PROJECT_DIR placeholders) # must be migrated in place to the project-relative form instead of being # duplicated when install-hooks is re-run after a path-format change. @@ -288,6 +325,9 @@ def test_install_keeps_existing_settings_and_is_idempotent(tmp_path, fake_pkg): assert settings["telemetry"] == {"enabled": True} # unrelated config kept assert len(settings["hooks"]["SessionStart"]) == 1 assert len(settings["hooks"]["SessionEnd"]) == 1 + assert len(settings["hooks"]["UserPromptSubmit"]) == 1 + prompt_cmd = settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"] + assert prompt_cmd == PROMPT_HOOK_CMD # AGENTS.md must contain exactly one task-memory section. agents_md = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") From 76feeb9a1e9845367794a3597728e461c8736c41 Mon Sep 17 00:00:00 2001 From: mambo-wang <iamwangbao@163.com> Date: Mon, 7 Sep 2026 09:36:19 +0800 Subject: [PATCH 20/23] =?UTF-8?q?fix(agents):=20=E5=90=8C=E6=AD=A5=20disti?= =?UTF-8?q?ll-worker=20=E6=BA=90=E5=89=AF=E6=9C=AC=20skill=5Fhint=20?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F=EF=BC=88dbc5ef9=20=E6=BC=8F=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dbc5ef9 给 distill-worker 剧本补 "skill_hint 只汇报、不执行" 约束时只改了 .codebuddy/agents/distill-worker.md(本仓库 IDE 安装副本),codewiki/agents/ 下的两个包内源副本(默认 + claude 变体)漏改。install-hooks 每次强制拷贝 会用旧源覆盖新副本(本次真实接线即复现),且所有新接线项目拿到的剧本都 缺此约束,subagent 可能自行调用 skill_creator。 补齐两处源副本:第 4 步汇报句 + 约束区 bullet。 --- codewiki/agents/distill-worker.claude.md | 3 ++- codewiki/agents/distill-worker.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/codewiki/agents/distill-worker.claude.md b/codewiki/agents/distill-worker.claude.md index f7a202e..166ec1b 100644 --- a/codewiki/agents/distill-worker.claude.md +++ b/codewiki/agents/distill-worker.claude.md @@ -18,12 +18,13 @@ description: > 1. **prepare**:调用 `distill_conversation(mode="prepare", task_id=<任务id>)`。返回积压对话清单(`captures`:每条含 `conversation_id` 与 `full_path`)和 `system_prompt`(提取规范)。 2. **逐条提取**:对清单中的每条 capture,用 `Read` 工具读取 `full_path` 指向的 raw 文件正文;严格按 `system_prompt` 的提取规范,产出 `notes`(通用经验笔记,`status=draft`,待确认)与 `memories`(任务进度,直写落盘 memories——ADR-0002,无需确认)。 3. **submit**:逐条调用 `distill_conversation(mode="submit", conversation_id=<id>, distilled=<提取JSON>)` 交回结果,优先内联(subagent 逐条处理,单条载荷通常不超限)。**若单条载荷过大导致 MCP 传输失败**:改用 `distilled_file` 文件侧通道——先用 `Write` 工具把提取 JSON(形状 `{conversation_id: {notes, memories}}`,或单条裸 `{notes, memories}` 配合 conversation_id)写入 `repowiki/raw/.distill-<id>.json`,再只传文件路径;工具读取后自动删除该暂存文件。产出物:待确认的草稿笔记(不直接成为正式知识)+ 直写落盘的任务记忆。 -4. **汇报**:全部完成后,向主 Agent 返回摘要——本次蒸馏的对话数、新建笔记数、去重抑制/合并数、落盘记忆数(memories_written),以及建议主 Agent 在停顿点向用户展示的待确认草稿清单。 +4. **汇报**:全部完成后,向主 Agent 返回摘要——本次蒸馏的对话数、新建笔记数、去重抑制/合并数、落盘记忆数(memories_written),以及建议主 Agent 在停顿点向用户展示的待确认草稿清单。若 submit 返回了 `skill_hint`,原样附上(只汇报,不执行)。 ## 约束 - 只蒸馏当前任务(`task_id` 过滤由 prepare 与工具自身保证),不触碰其他任务的 raw。 - **不执行** `confirm_note` / `reject_note` / `ingest_note` 等评审操作——笔记的确认闸门属于主 Agent 与用户的评审环节,本 subagent 只产出待确认草稿。(任务记忆由 `distill_conversation` 直写落盘,不经过 subagent 手动写文件。) +- **`skill_hint` 只汇报、不执行**:submit 返回中可能出现 `skill_hint`(本次产出的笔记匹配到未安装的技能草稿)。把它原样写进汇报摘要交给主 Agent;**严禁**自行调用 `skill_creator` 去编译或 install——install 是用户确认动作(skill-creator §10 / Doctrine「触发永远显式」)。 - 不修改 `repowiki/` 之外的任何文件;不做代码修改、不回答用户的功能性问题(那是主 Agent 的职责)。 - 若 prepare 返回空积压(已全部蒸馏/无 raw),直接返回"无待蒸馏积压",不要重复扫描。 - 遇到错误(文件缺失、JSON 非法)时记录并继续下一条,最后统一汇报失败项,不要中断整个流程。 diff --git a/codewiki/agents/distill-worker.md b/codewiki/agents/distill-worker.md index a3fa059..4f9cb30 100644 --- a/codewiki/agents/distill-worker.md +++ b/codewiki/agents/distill-worker.md @@ -19,12 +19,13 @@ enabledAutoRun: true 1. **prepare**:调用 `distill_conversation(mode="prepare", task_id=<任务id>)`。返回积压对话清单(`captures`:每条含 `conversation_id` 与 `full_path`)和 `system_prompt`(提取规范)。 2. **逐条提取**:对清单中的每条 capture,用 `ReadFile` 读取 `full_path` 指向的 raw 文件正文;严格按 `system_prompt` 的提取规范,产出 `notes`(通用经验笔记,`status=draft`,待确认)与 `memories`(任务进度,直写落盘 memories.md——ADR-0002,无需确认)。 3. **submit**:逐条调用 `distill_conversation(mode="submit", conversation_id=<id>, distilled=<提取JSON>)` 交回结果,优先内联(subagent 逐条处理,单条载荷通常不超限)。**若单条载荷过大导致 MCP 传输失败**:改用 `distilled_file` 文件侧通道——先用写文件工具把提取 JSON(形状 `{conversation_id: {notes, memories}}`,或单条裸 `{notes, memories}` 配合 conversation_id)写入 `repowiki/raw/.distill-<id>.json`,再只传文件路径;工具读取后自动删除该暂存文件。产出物:待确认的草稿笔记(不直接成为正式知识)+ 直写落盘的任务记忆。 -4. **汇报**:全部完成后,向主 Agent 返回摘要——本次蒸馏的对话数、新建笔记数、去重抑制/合并数、落盘记忆数(memories_written),以及建议主 Agent 在停顿点向用户展示的待确认草稿清单。 +4. **汇报**:全部完成后,向主 Agent 返回摘要——本次蒸馏的对话数、新建笔记数、去重抑制/合并数、落盘记忆数(memories_written),以及建议主 Agent 在停顿点向用户展示的待确认草稿清单。若 submit 返回了 `skill_hint`,原样附上(只汇报,不执行)。 ## 约束 - 只蒸馏当前任务(`task_id` 过滤由 prepare 与工具自身保证),不触碰其他任务的 raw。 - **不执行** `confirm_note` / `reject_note` / `ingest_note` 等评审操作——笔记的确认闸门属于主 Agent 与用户的评审环节,本 subagent 只产出待确认草稿。(任务记忆由 `distill_conversation` 直写落盘,不经过 subagent 手动写文件。) +- **`skill_hint` 只汇报、不执行**:submit 返回中可能出现 `skill_hint`(本次产出的笔记匹配到未安装的技能草稿)。把它原样写进汇报摘要交给主 Agent;**严禁**自行调用 `skill_creator` 去编译或 install——install 是用户确认动作(skill-creator §10 / Doctrine「触发永远显式」)。 - 不修改 `repowiki/` 之外的任何文件;不做代码修改、不回答用户的功能性问题(那是主 Agent 的职责)。 - 若 prepare 返回空积压(已全部蒸馏/无 raw),直接返回"无待蒸馏积压",不要重复扫描。 - 遇到错误(文件缺失、JSON 非法)时记录并继续下一条,最后统一汇报失败项,不要中断整个流程。 From d54a086f5679edf80bb2d4c053c75c1757906271 Mon Sep 17 00:00:00 2001 From: mambo-wang <iamwangbao@163.com> Date: Mon, 7 Sep 2026 09:50:27 +0800 Subject: [PATCH 21/23] =?UTF-8?q?fix(ide-hook):=20=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=AE=9A=E5=90=91=20stderr=20=E4=BF=9D=20std?= =?UTF-8?q?out=20=E6=B3=A8=E5=85=A5=E9=80=9A=E9=81=93=E7=BA=AF=E5=87=80?= =?UTF-8?q?=EF=BC=9Bstdin=20=E5=85=BC=E5=AE=B9=20PowerShell=20UTF-8=20BOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- codewiki/mcp/_ide_hook.py | 27 ++++++++++--- tests/test_ide_hook_capture.py | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/codewiki/mcp/_ide_hook.py b/codewiki/mcp/_ide_hook.py index 933c176..8b0d77a 100644 --- a/codewiki/mcp/_ide_hook.py +++ b/codewiki/mcp/_ide_hook.py @@ -127,11 +127,16 @@ def _load_event(args: argparse.Namespace) -> Optional[Dict[str, Any]]: # ``sys.stdin.read()`` would use the platform locale codec (e.g. cp936 # on Chinese Windows), which turns non-ASCII bytes into lone surrogates # and later breaks ``write_text(encoding="utf-8")`` for CJK content. + # Decode with utf-8-sig and strip stray BOM chars: PowerShell pipes + # prepend a UTF-8 BOM to a native command's stdin (sometimes more than + # one), which would otherwise break ``json.loads`` below. Same + # tolerance as the hook wrapper's ``_read_event`` + # (.codebuddy/hooks/capture_session_end.py). try: stdin_bytes = sys.stdin.buffer.read() except AttributeError: # pragma: no cover - non-buffered stdin stdin_bytes = sys.stdin.read().encode("utf-8", "replace") - raw = stdin_bytes.decode("utf-8", "replace").strip() + raw = stdin_bytes.decode("utf-8-sig", "replace").lstrip("\ufeff").strip() if raw: try: data = json.loads(raw) @@ -463,13 +468,21 @@ def main(argv: Optional[list] = None) -> int: # Opt-in gate: never capture unless explicitly enabled. if not _enabled(args.enable): - print("ide-hook: disabled (set CODEWIKI_TEAM_MEMORY_HOOK=1 or pass --enable).") + # Diagnostic messages go to stderr: on UserPromptSubmit the IDE reads + # this script's stdout as the injection channel, so any non-JSON text + # here would become per-prompt noise in the agent context. + print( + "ide-hook: disabled (set CODEWIKI_TEAM_MEMORY_HOOK=1 or pass --enable).", + file=sys.stderr, + ) _cleanup_event_file(event_file_to_clean) return 0 event = _load_event(args) if event is None: - print("ide-hook: no conversation payload provided; nothing to capture.") + # stdout is the hook-injection channel — keep it empty when there is + # nothing to do, so an un-triggered invocation injects nothing. + print("ide-hook: no conversation payload provided; nothing to capture.", file=sys.stderr) _cleanup_event_file(event_file_to_clean) return 0 @@ -499,7 +512,8 @@ def _pick(key, cli_val): print( f"ide-hook: {hook_event} event has no conversation turns and no " "usable transcript_path; capturing the event envelope only " - "(the IDE did not provide an inline transcript)." + "(the IDE did not provide an inline transcript).", + file=sys.stderr, ) # Fall through: capture the event envelope as a minimal record. # NOTE: role must be "user" (not "system") -- capture_conversation @@ -520,7 +534,10 @@ def _pick(key, cli_val): } ] else: - print("ide-hook: payload has no 'conversation' turns; nothing to capture.") + print( + "ide-hook: payload has no 'conversation' turns; nothing to capture.", + file=sys.stderr, + ) return 0 arguments: Dict[str, Any] = { diff --git a/tests/test_ide_hook_capture.py b/tests/test_ide_hook_capture.py index 0fe25a3..25d671e 100644 --- a/tests/test_ide_hook_capture.py +++ b/tests/test_ide_hook_capture.py @@ -37,6 +37,27 @@ def isatty(self) -> bool: return False +class _FakeStdinBinary: + """Binary stdin carrying a real ``.buffer`` (shape of a piped native process). + + Required to exercise the hook's raw-bytes path, including the UTF-8 BOM + tolerance that a StringIO-only fake can never trigger. + """ + + def __init__(self, raw: bytes): + self.buffer = io.BytesIO(raw) + + def isatty(self) -> bool: + return False + + +class _TtyStdin(io.StringIO): + """StringIO reporting isatty() == True (no piped payload).""" + + def isatty(self) -> bool: + return True + + @pytest.fixture def enable_hook(monkeypatch): monkeypatch.setenv("CODEWIKI_TEAM_MEMORY_HOOK", "1") @@ -542,6 +563,55 @@ def test_hook_disabled_by_default(monkeypatch, tmp_path): assert not _raw_files(repo) +# --------------------------------------------------------------------------- # +# stdout purity (injection channel) + stdin BOM tolerance +# --------------------------------------------------------------------------- # +def test_no_payload_stdout_stays_clean(enable_hook, monkeypatch, tmp_path, capsys): + """A no-payload invocation must keep stdout EMPTY. + + On UserPromptSubmit the IDE reads this script's stdout as the injection + channel; diagnostic text would become per-prompt noise in the agent + context. The no-payload notice belongs on stderr. + """ + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.setattr("sys.stdin", _TtyStdin("")) + rc = _ide_hook.main(["--enable", "--repo-path", str(repo)]) + assert rc == 0 + captured = capsys.readouterr() + assert captured.out == "" + assert "no conversation payload provided" in captured.err + + +def test_stdin_utf8_bom_tolerated(enable_hook, monkeypatch, tmp_path): + """A UTF-8 BOM on piped stdin (PowerShell) must not break JSON parsing. + + Regression for the raw-bytes stdin path decoding with plain utf-8, which + turned the BOM into U+FEFF and made json.loads fail. + """ + repo = tmp_path / "repo" + repo.mkdir() + payload = ( + json.dumps( + { + "hook_event_name": "SessionEnd", + "session_id": "s-bom", + "conversation": [ + {"role": "user", "content": "bom question"}, + {"role": "assistant", "content": "bom answer"}, + ], + } + ) + + "\n" + ).encode("utf-8") + monkeypatch.setattr("sys.stdin", _FakeStdinBinary(b"\xef\xbb\xbf" + payload)) + rc = _ide_hook.main(["--repo-path", str(repo)]) + assert rc == 0 + files = _raw_files(repo) + assert len(files) == 1 + assert "bom question" in files[0].read_text(encoding="utf-8") + + # --------------------------------------------------------------------------- # # Filename is derived from the first user message (mirrors IDE title) # --------------------------------------------------------------------------- # From 4d6113030923d463758308903f923e6747adb265 Mon Sep 17 00:00:00 2001 From: mambo-wang <iamwangbao@163.com> Date: Mon, 7 Sep 2026 09:51:26 +0800 Subject: [PATCH 22/23] =?UTF-8?q?docs(repowiki):=20=E8=A1=A5=E8=92=B8?= =?UTF-8?q?=E9=A6=8F=E5=AF=B9=E8=AF=9D=E5=BD=92=E6=A1=A3=E4=B8=8E=E5=86=B3?= =?UTF-8?q?=E7=AD=96=E7=AC=94=E8=AE=B0=E8=90=BD=E7=9B=98=E3=80=81source=5F?= =?UTF-8?q?ref=20=E8=B7=AF=E5=BE=84=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notes: skill-creator/output_dir 纯函数/consolidate disposition 三值决策定稿 - conversations: 3 条积压会话归档(wikiskill 调研、微信文章、humanizer 安装) - notes 08-15: source_ref 随 raw→conversations 迁移同步修正 - 遥测、任务记忆与蒸馏日志随动更新 --- .../.meta/telemetry/iamwangbao-163-com.jsonl | 7 + ...14\347\234\213\347\234\213\345\257\271.md" | 200 ++++++ ...lls-Please-use-the-use_skill-tool-to-in.md | 616 ++++++++++++++++++ ...-github.com-blader-humanizer-tree-main.md" | 157 +++++ ...15\345\272\246\346\216\222\345\272\217.md" | 2 +- ...10\347\272\247\347\273\237\344\270\200.md" | 2 +- ...5\231excluded-\345\277\205\345\241\253.md" | 54 ++ ...73\345\235\200\345\207\272\345\217\243.md" | 51 ++ ...04\345\210\206\351\227\250\346\216\247.md" | 52 ++ ...11\345\261\202\351\230\262\346\212\244.md" | 57 ++ .../memories/iamwangbao-163-com.md" | 12 + repowiki/wiki/log-2026-09.md | 6 + 12 files changed, 1214 insertions(+), 2 deletions(-) create mode 100644 "repowiki/conversations/conv-https-mp.weixin.qq.com-s-NwU98lA_P7LpDdyhhkt-cg-\350\260\203\347\240\224\344\270\200\344\270\213\350\277\231\347\257\207\346\226\207\347\253\240\357\274\214\347\234\213\347\234\213\345\257\271.md" create mode 100644 repowiki/conversations/conv-manually_attached_skills-Please-use-the-use_skill-tool-to-in.md create mode 100644 "repowiki/conversations/conv-\345\256\211\350\243\205\346\212\200\350\203\275\357\274\232https-github.com-blader-humanizer-tree-main.md" create mode 100644 "repowiki/notes/2026-09-07-consolidate-notes-\345\200\231\351\200\211-disposition-\344\270\211\345\200\274\346\234\272\345\210\266\346\234\252\345\205\245\351\200\211\347\254\224\350\256\260\344\270\215\345\206\215\346\227\240\345\243\260\346\273\236\347\225\231excluded-\345\277\205\345\241\253.md" create mode 100644 "repowiki/notes/2026-09-07-output-dir-\346\230\257-repo-path-\347\232\204\347\272\257\345\207\275\346\225\260\345\206\231\350\267\257\345\276\204\344\270\200\345\276\213\345\270\203\345\261\200\346\216\250\345\257\274\347\240\215\350\267\250\350\277\233\347\250\213\346\214\201\344\271\205\345\214\226\345\217\252\350\257\273\346\243\200\347\264\242\344\277\235\347\225\231\350\267\250\344\273\223\345\257\273\345\235\200\345\207\272\345\217\243.md" create mode 100644 "repowiki/notes/2026-09-07-skill-creator-\345\267\245\345\205\267\350\256\276\350\256\241\345\256\232\346\241\243scenario-\347\233\264\350\257\221-mode-c-\344\270\244\345\214\272\345\210\266\347\241\256\350\256\244\351\227\270\351\227\250\344\270\215\345\273\272\350\207\252\345\212\250\350\257\204\345\210\206\351\227\250\346\216\247.md" create mode 100644 "repowiki/notes/2026-09-07-\350\267\250\344\273\223\345\272\223-output-dir-\345\212\253\346\214\201-session\351\207\215\345\273\272\346\243\200\347\264\242\347\264\242\345\274\225\350\242\253\346\270\205\347\251\272\345\241\253\345\205\245-smoke-\346\265\213\350\257\225\346\226\207\346\241\243\346\240\271\345\233\240\351\223\276\344\270\216\344\270\211\345\261\202\351\230\262\346\212\244.md" diff --git a/repowiki/.meta/telemetry/iamwangbao-163-com.jsonl b/repowiki/.meta/telemetry/iamwangbao-163-com.jsonl index 9fcbfc9..5868709 100644 --- a/repowiki/.meta/telemetry/iamwangbao-163-com.jsonl +++ b/repowiki/.meta/telemetry/iamwangbao-163-com.jsonl @@ -336,3 +336,10 @@ {"t": "hit", "doc": "wiki/scenarios/任务记忆系统设计方法.md", "at": "2026-09-07", "n": 2} {"t": "hit", "doc": "notes/2026-08-26-read-versioned-lines-对磁盘上已不存在的-untracked-文件返回空列表产生只有-header.md", "at": "2026-09-07", "n": 2} {"t": "hit", "doc": "wiki/modules/MCP_Tools_DocWriter.md", "at": "2026-09-07", "n": 2} +{"t": "by_file", "doc": "notes/2026-08-29-生成的-ps1-必须带-utf-8-bom否则-powershell-51-按-gbk-误读.md", "at": "2026-09-07", "n": 1} +{"t": "by_file", "doc": "notes/2026-08-25-review-changes-全轴-prepare-单次调用易挂起mcp-通道卡住.md", "at": "2026-09-07", "n": 1} +{"t": "by_file", "doc": "notes/2026-08-25-untracked-新文件不在分析图谱内review-changes-changed-sources-切片为空.md", "at": "2026-09-07", "n": 1} +{"t": "by_file", "doc": "notes/2026-08-23-distill-worker-subagent-定义随包发布hook-启用时自动拷贝到项目-codebuddyagent.md", "at": "2026-09-07", "n": 1} +{"t": "by_file", "doc": "notes/2026-08-23-hook-采集机制仅正式接线-codebuddyreadme-措辞用仅接线支持.md", "at": "2026-09-07", "n": 1} +{"t": "by_file", "doc": "notes/2026-08-21-下一期方向资产置信分层与负反馈闭环roadmap-phase-5.md", "at": "2026-09-07", "n": 1} +{"t": "by_file", "doc": "notes/2026-08-19-l0-对话归档采用链接优先零索引设计.md", "at": "2026-09-07", "n": 1} diff --git "a/repowiki/conversations/conv-https-mp.weixin.qq.com-s-NwU98lA_P7LpDdyhhkt-cg-\350\260\203\347\240\224\344\270\200\344\270\213\350\277\231\347\257\207\346\226\207\347\253\240\357\274\214\347\234\213\347\234\213\345\257\271.md" "b/repowiki/conversations/conv-https-mp.weixin.qq.com-s-NwU98lA_P7LpDdyhhkt-cg-\350\260\203\347\240\224\344\270\200\344\270\213\350\277\231\347\257\207\346\226\207\347\253\240\357\274\214\347\234\213\347\234\213\345\257\271.md" new file mode 100644 index 0000000..954b172 --- /dev/null +++ "b/repowiki/conversations/conv-https-mp.weixin.qq.com-s-NwU98lA_P7LpDdyhhkt-cg-\350\260\203\347\240\224\344\270\200\344\270\213\350\277\231\347\257\207\346\226\207\347\253\240\357\274\214\347\234\213\347\234\213\345\257\271.md" @@ -0,0 +1,200 @@ +--- +type: Conversation +title: "conversation 20260906T151817Z" +status: distilled +stale_after: 2026-12-05 +generated: { by: codewiki/5.6.1, at: 2026-09-06T15:18:17Z } +captured_at: "2026-09-06T15:18:17Z" +content_hash: "sha256:c43c73cb3df59f7b88bc9cb7e1b6f725c12033189d31116f5f7741b49c6a54cc" +friction_score: 5 +friction_signals: "correction=0,interrupt=0,repeat=0,user_turns=4" +keep_raw: false +link_to: "" +source_session: "4a93b9221bce4788a5ebc8bc4be149ce" +task_id: "他山之石" +turn_count: 42 +--- + +# Conversation Transcript + +user: https://mp.weixin.qq.com/s/NwU98lA_P7LpDdyhhkt-cg 调研一下这篇文章,看看对我们有什么借鉴价值吗 +assistant: [tool: ask_followup_question · {"questions": [{"question": "本次会话要关联哪个任务?(关联后本会话的对话会自动带上 task_id,并触发积压对话的后台补蒸馏)", "header": "任务关联", "options": ["新建任务(请在下一个弹框输入任… +user: <question>本次会话要关联哪个任务?(关联后本会话的对话会自动带上 task_id,并触发积压对话的后台补蒸馏)</question> +<answers> +他山之石 +</answers> +</question_item> +</questions> +</question_answer> +assistant: [tool: mcp_get_tool_description · {"toolRequests": "[[\"codewiki\",\"set_session_task\"],[\"codewiki\",\"get_task_context\"]]"}] + +[tool: web_fetch · https://mp.weixin.qq.com/s/NwU98lA_P7LpDdyhhkt-cg] +assistant: [tool: mcp_call_tool · {"serverName": "codewiki", "toolName": "set_session_task", "arguments": "{\"source_session_id\":\"4a93b9221bce4788a5ebc8bc4be149ce\",\"t… + +[tool: mcp_call_tool · {"serverName": "codewiki", "toolName": "get_task_context", "arguments": "{\"task_id\":\"他山之石\",\"repo_path\":\"d:/repos/CodeWiki-CN\",\"… +assistant: [tool: mcp_get_tool_description · {"toolRequests": "[[\"codewiki\",\"query_wiki\"]]"}] + +[tool: task · {"subagent_name": "distill-worker", "description": "蒸馏他山之石积压对话", "prompt": "请对任务「他山之石」(task_id=他山之石)的 2 条未蒸馏 raw 对话执行补蒸馏,走 Mode C(纯 MCP JSON)流程:\… +assistant: [tool: mcp_call_tool · {"serverName": "codewiki", "toolName": "query_wiki", "arguments": "{\"repo_path\":\"d:/repos/CodeWiki-CN\",\"query\":\"渐进式披露 分层导航 向量检索 检… + +[tool: mcp_call_tool · {"serverName": "codewiki", "toolName": "query_wiki", "arguments": "{\"repo_path\":\"d:/repos/CodeWiki-CN\",\"query\":\"CodeWiki 内容定位 概念 … +assistant: [tool: search_content · d:/repos/CodeWiki-CN/codewiki] + +[tool: search_content · d:/repos/CodeWiki-CN/codewiki] + +[tool: search_content · d:/repos/CodeWiki-CN/codewiki] + +[tool: mcp_call_tool · {"serverName": "codewiki", "toolName": "query_wiki", "arguments": "{\"repo_path\":\"d:/repos/CodeWiki-CN\",\"query\":\"调研报告的借鉴建议必须先过代码核对… +assistant: [tool: search_content · d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_query.py] + +[tool: search_content · d:/repos/CodeWiki-CN/codewiki/src/be/dependency_analyzer] + +[tool: search_content · d:/repos/CodeWiki-CN/codewiki/mcp/tools/skill_creator.py] +assistant: [tool: search_content · d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_query.py] + +[tool: search_content · d:/repos/CodeWiki-CN/repowiki/schema.yaml] +assistant: [tool: search_content · d:/repos/CodeWiki-CN/codewiki] + +[tool: search_content · d:/repos/CodeWiki-CN/repowiki/schema.yaml] +assistant: 已绑定任务「他山之石」,并拉取了上下文(2 条积压 raw 待蒸馏;本轮蒸馏子代理未拿到 codewiki MCP 权限,稍后我自己在自然停顿点处理)。 + +## 文章是什么 + +有赞共享技术的《KNOWLEDGE WIKI:面向 AI 的项目知识层建设实践》——在 workspace 里用 `.wiki/` 目录建分层领域知识库,配一个可安装的 skill(read/init/update 三模式),让 Agent「遇事先查 wiki、有收获就提案沉淀」。 + +## 核对后的总体判断:高度同构,我们在多数维度已经更强 + +按本仓库的纪律,借鉴建议先过代码核对(依据:`notes/2026-09-06-调研报告的借鉴建议必须先过代码核对b2b1-痛点表述与实际不符的三处教训.md`,draft 未确认): + +| 文章主张 | CodeWiki 现状(本次代码核对) | 判定 | +|---|---|---| +| 渐进式披露替代向量检索 | 已有且更硬:`query_wiki` 四档 `overview/directory/detail/check`(`codewiki/mcp/tools/note_query.py:179,284,374,478,853-861`)+ `est_tokens` 成本透传与「先按 est_tokens 挑再 expand」预算提示(`:955-1002,1176-1188`)+ `max_chars` 夹取(`:828`) | **已有,且多了 token 预算控制** | +| 异步链路是 wiki 最大价值(Producer/Consumer 代码串不起来) | **已覆盖**:`cross_service_matcher.py:5,216-234` 做 Kafka/RabbitMQ/RocketMQ 跨仓 producer↔consumer 匹配;`mq_patterns.py:1,51-73` 检测 MQ 生产/消费点 | **已做,且这是我们的稀缺资产** | +| 最终交付物是可安装的 skill | 已落地:`skill_creator.py` 四 mode `prepare/submit/install/retire`(`:821-826,643,729`) | **已定档并实现** | +| 知识跟着 git 走(submodule 分布式) | 我们是 `repowiki/` 仓内 + 集中式工作区布局,绕开了文章自认的短板「各域无法交叉检索、难以全局统计」 | **我们的选择更优** | +| 过时 wiki 比没有 wiki 更危险 | 陈旧信号只做到一半:`possibly_stale` 仅在 `by_file` 时间线计算(`note_query.py:702`),BM25 结果未带;`confidence_level` 在 `codewiki/` 源码 **0 命中**,Phase5 置信分层尚未落地 | **真缺口** | +| Harness 四要素(约束/反馈/验证/治理) | Doctrine 全覆盖:准入规则、`adopted_count` 采纳反馈、`confirm_note` 闸门、`lint_wiki` | 同构,可互为背书 | + +## 真正值得借鉴的只有 4 条 + +1. **补一份「不沉淀什么」负面清单**。文章明确列出不写:纯实现细节、逐行代码说明、SQL 细节、一次性排查、无复用价值的临时讨论、证据不足的猜测。我们的 `repowiki/schema.yaml` 只定义了 `note_types`(`:140`)和 `page_types`(`:254-323`),grep「不写/禁止/排除」**0 命中**——只有正面路由表,没有负面清单。这是最低成本的一条,写进 `schema.yaml` 即可被 lint 与提示词消费。 + +2. **最小作用域原则**(文章原话:能写 context 不写 app,能写 app 不写 workspace)。映射到我们是「能写 module 不写 scenario,能写 scenario 不写全局 doctrine」。目前 `ingest_note` 没有这个约束提示。 + +3. **候选必须有去向,排除要写原因**。文章用于初始化(保留/并入/排除三选一,排除必填原因)。我们 `consolidate_notes` 与蒸馏都有「候选消失」问题——被吸收的笔记为什么退役、为什么没入选,缺少可审计记录。中等成本,价值在于 `lint_wiki` 能据此查覆盖完整性。 + +4. **把「Harness Engineering」当作对外表达框架**。文章用「驾驭 Agent 而不是放任它」包装知识治理,传播效果好。呼应我们今天的竞品横评结论(draft):deepwiki-open 用最弱工程拿最高 star,验证网页 Demo 的传播效应——**定位语言本身就是杠杆,且几乎零成本**。 + +## 一条反向警示 + +文章放弃全量 Code Wiki 的三条理由里,最实的一条是「AI 理解代码能力已强,翻译一遍性价比低」。**这条不能照单全收**:它成立的前提是「只有 LLM 直读这条路」。而我们的横评结论是四家竞品全走 LLM 直读、无一家做 AST 静态分析,AST 调用图是我们唯一的稀缺资产;文章里「AI 从代码里根本串不起来的异步链路」,恰好是 `cross_service_matcher` 已经在做的事。**不要在定位讨论里把竞品放弃的东西也一起放弃。** + +要不要把上面「4 条借鉴 + 1 条反向警示」整理成一条 comparison 笔记(`wiki/comparisons/knowledge-wiki 有赞 vs CodeWiki`)落盘?按规矩需要你确认后才写入。 + +<!-- codewiki:referenced-docs: ["notes/2026-09-06-deepwiki-类四竞品源码横评结论llm-直读是主流ast-调用图是-codewiki-唯一稀缺资产.md", "notes/2026-09-06-调研报告的借鉴建议必须先过代码核对b2b1-痛点表述与实际不符的三处教训.md", "notes/2026-08-21-下一期方向资产置信分层与负反馈闭环roadmap-phase-5.md"] --> +user: 我觉得只有第三条有价值,这个可以怎么做? +assistant: [tool: mcp_call_tool · {"serverName": "codewiki", "toolName": "query_wiki", "arguments": "{\"repo_path\":\"d:/repos/CodeWiki-CN\",\"by_file\":\"codewiki/mcp/to… + +[tool: search_file · consolidate_notes.py] +assistant: [tool: search_content · d:/repos/CodeWiki-CN/codewiki] +assistant: [tool: read_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "offset": 240, "limit": 120}] + +[tool: read_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/registry.py", "offset": 1660, "limit": 70}] +assistant: [tool: read_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "offset": 395, "limit": 160}] + +[tool: search_content · d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py] +assistant: [tool: search_content · d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py] + +[tool: search_content · d:/repos/CodeWiki-CN/codewiki] +assistant: 先纠正一处映射偏差:文章说的是**生成侧**的「候选无声消失」(初始化时概念/流程被丢掉没记录);我们是**聚合侧**的「候选无声滞留」——同一个可审计性缺口,方向相反。 + +## 现状(已过代码核对) + +- 候选来源:`_pending_confirmed_notes` 只按「status ∈ stable/confirmed **且无** `consolidated_into`」筛(`codewiki/mcp/tools/note_consolidation.py:260-292`) +- submit 报告只收 `scenarios: [{file, action, source_notes, summary?, heat?}]`(`codewiki/mcp/registry.py:1677-1678,1709-1718`) +- 溯源只写在**入选者**身上:`consolidated_into` 双向链接(`note_consolidation.py:476,483-485`) +- **未入选者零标记**——既没有「为什么没入选」,也没有「这条已经判过」 + +后果是可观察的:每次 prepare 都要重新权衡同一批笔记;无法区分「还没轮到」和「判定不值得聚合」,后者会永久卡在 pending 里顶着计数器——`notes_since_last_consolidation` 现在 41 / 阈值 10,已经在持续告警。 + +## 最小做法:候选 disposition 三选一 + +复用已有收敛点,不新建写回函数:`_update_frontmatter_meta`(`note_consolidation.py:134`,locked RMW,与 `:499` 同一调用形态)。 + +| verdict | 含义 | reason | +|---|---|---| +| `absorbed` | 已入场景块 | 不用新增,走现有 `source_notes` ⇄ `consolidated_into` | +| `deferred` | 本轮不聚合,等素材/等同类积累 | 可选 | +| `excluded` | 判定永不入场景块(一次性任务状态、个人偏好、临时上下文) | **必填** | + +落盘形态(笔记 frontmatter `metadata`): + +```yaml +disposition: + verdict: excluded + reason: "一次性任务状态,非可复用工作方法" + at: "2026-09-06" +``` + +## 改动清单(4 处,约 40 行) + +1. `registry.py:1709-1718` —— report 增加可选 `dispositions: [{file, verdict, reason?}]` +2. `note_consolidation.py` submit —— 在 `:507` 后、`:509 if errors:` 前加校验与写回;`excluded` 无 reason 直接进 errors(沿用 `:443-452` 对 `action=deleted` 要求正文为 `[DELETED]` 的同一校验风格) +3. `note_consolidation.py:260-292` —— `excluded` 跳过不再进 pending;`deferred` 保留但回带 `disposition` 字段 +4. 提示词:`_CONSOLIDATE_SYSTEM`(`:43-92`)与 `prompts.py:1246-1250` 各加一句「每条候选必须给出去向」 + +## 明确不做 + +- **`lint_wiki` 加「长期 deferred 告警」**:文章那句「覆盖完整性审查」的落点在这儿,但阈值现在定是拍脑袋,等 disposition 积累几轮再定。 +- **不改 `distill_conversation`**:蒸馏的候选是 raw 对话,submit 后即删,不存在滞留问题,加了是纯负担。 + +<!-- codewiki:referenced-docs: ["notes/2026-09-06-调研报告的借鉴建议必须先过代码核对b2b1-痛点表述与实际不符的三处教训.md"] --> + +[tool: ask_followup_question · {"questions": [{"question": "verdict 用几值?", "options": [{"label": "三值(absorbed/deferred/excluded)", "description": "能表达「等素材积累」,但… +user: <title>候选去向外挂:两个决策 + +verdict 用几值? + +三值(absorbed/deferred/excluded)(能表达「等素材积累」,但多一个状态要维护) + + + +这一轮做到哪? + +四处全做(含 excluded 从 pending 剔除,能立刻缓解计数器告警) + + + + +assistant: [tool: read_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "offset": 1, "limit": 135}] + +[tool: search_file · *consolidat*] +assistant: [tool: read_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "offset": 356, "limit": 45}] + +[tool: read_file · {"filePath": "d:/repos/CodeWiki-CN/tests/test_consolidation_p2.py"}] +assistant: 开始实现。先改 `note_consolidation.py`。 + +[tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "old_str": " cleans up [DELETED] soft-delete marker… +assistant: [tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "old_str": "_VALID_ACTIONS = (\"created\", \"updated\", … +assistant: [tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "old_str": "def _pending_confirmed_notes(output_dir: Pat… +assistant: [tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "old_str": " processed.append(\n {\n … +assistant: [tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "old_str": "import json\nimport logging\nfrom pathlib im… +assistant: [tool: read_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "offset": 595, "limit": 70}] +assistant: [tool: read_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "offset": 664, "limit": 40}] +assistant: [tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "old_str": " \"status\": \"error\",\n … +assistant: [tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "old_str": " \"status\": \"completed\",\n … +assistant: [tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "old_str": " \"(5) Call consolidate_notes(mode='submi… +assistant: [tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "old_str": " \"(4) reject_note fully-… +assistant: Now `registry.py` 的 schema 与描述。 + +[tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/registry.py", "old_str": " \"mode='submit' with report.scenarios=[{file, ac… +assistant: [tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/registry.py", "old_str": " \"report\": {\n \"type\":… +assistant: [tool: read_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/prompts.py", "offset": 1238, "limit": 22}] +assistant: [tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/prompts.py", "old_str": "## 步骤 3:退役被吸收的笔记\n对被场景块**完全吸收**的源笔记调用 `reject_note(note_file=… +assistant: [tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "old_str": " entries = report.get(\"scenarios\")\n … +assistant: Now 补测试。 + +[tool: replace_in_file · {"filePath": "d:/repos/CodeWiki-CN/tests/test_consolidation_p2.py", "old_str": "def test_submit_capacity_exceeded_blocks_reset(tmp_pat… +assistant: [tool: read_lints · {"paths": ["d:/repos/CodeWiki-CN/codewiki/mcp/tools/note_consolidation.py", "d:/repos/CodeWiki-CN/codewiki/mcp/registry.py", "d:/repos/Code… +assistant: [tool: execute_command · cd d:/repos/CodeWiki-CN; .venv/Scripts/python.exe -m pytest tests/test_consolidation_p2.py -q --no-header 2>&1 | Select-Object -Last 4… +assistant: [tool: execute_command · cd d:/repos/CodeWiki-CN; .venv/Scripts/python.exe -m pytest tests -q --no-header 2>&1 | Select-Object -Last 30] diff --git a/repowiki/conversations/conv-manually_attached_skills-Please-use-the-use_skill-tool-to-in.md b/repowiki/conversations/conv-manually_attached_skills-Please-use-the-use_skill-tool-to-in.md new file mode 100644 index 0000000..d1cbbb0 --- /dev/null +++ b/repowiki/conversations/conv-manually_attached_skills-Please-use-the-use_skill-tool-to-in.md @@ -0,0 +1,616 @@ +--- +type: Conversation +title: "conversation 20260905T235851Z" +status: distilled +stale_after: 2026-12-04 +generated: { by: codewiki/5.6.1, at: 2026-09-05T23:58:51Z } +captured_at: "2026-09-05T23:58:51Z" +content_hash: "sha256:566aabd5b920499d531ddab9a4a8dff5108996c48daa26e3c53d2143e3ce13e2" +friction_score: 25 +friction_signals: "correction=0,interrupt=0,repeat=1,user_turns=14" +keep_raw: false +link_to: "" +source_session: "d98760bb763b4e7aa66b5bf3c5004168" +task_id: "他山之石" +turn_count: 139 +--- + +# Conversation Transcript + +user: +Please use the use_skill tool to invoke this skill. + +name: grill-me +description: A relentless interview to sharpen a plan or design. + + +@command://grill-me 调研这个项目https://github.com/ashutoshsinghpr7/wikiskill 和这篇文章https://mp.weixin.qq.com/s/Qj66EtKh4jyQEhehxLl6_Q 看看我们当前项目能否实现自动生成SKILL +user: 本会话是否关联到某个进行中的任务? + +他山之石(竞品/外部项目调研类任务) + + + + +assistant: 蒸馏 worker 未成功启动,稍后在停顿点再补。现在先做调研获取事实(grill 前的事实收集是 agent 的职责)。 +assistant: 微信文章已获取成功(WikiSkill 论文解读)。GitHub 页面抓取失败,换途径获取仓库信息,同时并行探索当前项目的能力现状。 +assistant: 调研完成。先摆事实(agent 负责找事实),然后开第一轮拷问。 + +## 调研结论速览 + +**外部对象:** +- **wikiskill 仓库**(arXiv:2608.27454 开源实现,Hermes Agent 后端):进化闭环 = `Inference Agent(跑任务产轨迹) → Wiki Maintainer(蒸馏成 wiki/patterns) → Skill Proposer(每次一个原子提案) → 门控(git 回滚,R_val > R_best 才接受)`。三层空间:`skills/`(可回滚)、`wiki/`(永不回滚)、`raw/`(不可变)。自带 demo bench(22 个自动评分任务)做门控。 +- **微信文章**:同论文解读,核心洞察 ① Wiki 不暴露给执行者(防抄答案,轨迹质量 63.7%→60.9%)② 知识发现与执行解耦(强模型进化、弱模型执行)③ 复利时间效应(被拒提案成为下轮素材)。 + +**CodeWiki 现状(依据本次代码核对 + code-explorer 检索):** +- 已有关键管线:`capture_conversation → repowiki/raw → distill_conversation → notes(draft) → confirm_note(stable) → consolidate_notes(L2 scenarios) → refresh_doctrine(L3)`(`codewiki/mcp/tools/distill_conversation.py`、`note_consolidation.py`、`note_lifecycle.py`) +- **无任何 SKILL.md 写入器**:仓库内 `.codebuddy/skills/` 仅 grill-me/grilling 两个手写技能;工具层无技能类型词汇、无 `.codebuddy/skills` 写入点 +- 可复用的成熟骨架:**Mode C「prepare→宿主 agent 当 LLM→submit 确定性簿记」协议**、确认闸门、场景聚合纪律、frontmatter/原子写/锁设施、hooks.yaml 家族分发基建 +- 缺口:技能目标产物类型(词汇/写入器/lint/注入执行端)完全未建 + +**初步可行性判断**:能实现,但有两档—— +- **A 档(简化编译器)**:把已确认笔记/scenarios 编译成 SKILL.md 草稿 → 人工确认 → 落 `.codebuddy/skills/`。用 Mode C 协议照 consolidate_notes 的模式做,工程上是新增一个平行工具,风险低。 +- **B 档(WikiSkill 全闭环)**:需要 Inference Agent + 自动评分基准做门控。CodeWiki 没有 held-out 自动评分环境,这是硬缺口。 + +--- + +现在开始拷问。第一轮问根层决策: + +❓ **Q1** - **本次会话的产出定位**:调研结论 + 可行性评估就够?还是要一路推进到设计提案(写进 repowiki)甚至落地原型工具?「能否实现」和「要不要做、做多大」是两件事。 + +➡️ 我推荐:先产出「可行性 + 方案取舍」笔记定档(A 档还是 B 档),代码一行不写;你确认方向后再决定是否进设计/实现。 + +❓ **Q2** - **生成范式档位**:CodeWiki 缺 wikiskill 那套「held-out 自动评分基准」。门控环节你有现成替代品——**确认闸门(draft→confirm)+ 人工试用反馈**。所以自动生成 SKILL 是走: +- (a) 单向编译器:从已有知识(notes/scenarios/记忆)生成 SKILL 草稿 → 你确认 → 落盘生效(无自动门控,人就是门) +- (b) 半闭环:加上「用生成的 SKILL 跑真实任务 → 轨迹回流 → 再提炼改进 SKILL」的迭代环(无自动评分,用任务完成质量/你的反馈当信号) +- (c) 全闭环:自己再造一套自动评分基准(成本高,接近重做 wikiskill) + +➡️ 我推荐 (b):CodeWiki 的知识管线天然是回流式的(任务记忆/raw 会采集对话),(b) 才真正发挥「经验复利」而不只是「一次性编译」。若你只想要快速见效,(a) 是 MVP。 + +❓ **Q3** - **素材源与生成分工**:生成的 SKILL 素材取自哪一层?(raw 未蒸馏太噪;notes/scenarios 已结构化可信;任务记忆是进度型不适合做技能正文)。生成正文的 LLM 放哪边——沿用仓库铁律「工具无状态、LLM 外置」,走 Mode C(工具选材/校验/落盘,宿主 agent 或子代理写 SKILL.md 正文)? + +➡️ 我推荐:素材源 = confirmed notes + scenarios(+ 可选任务级记忆作为背景);生成走 Mode C,完全契合你仓库「prepare→调用方推理→submit」的既有范式(`distill_conversation.py` 三模式已是先例)。 + +❓ **Q4** - **产物落盘与生效范围**:生成的 SKILL 落到哪、怎么生效? +- (a) 仓库内 `.codebuddy/skills//SKILL.md`(随仓库分发,IDE 自动发现) +- (b) 复制安装到 `~/.codebuddy/skills/`(跨项目生效,参考你已拍板的 caveman「全量安装」先例) +- (c) 家族分发:沿用 hooks.yaml 的多 IDE 拷贝管线,扩展到 `.claude/.qoder/.codebuddy` + +➡️ 我推荐:先 (a) 仓库内闭环验证生成质量,稳定后再决定是否升级到 (c) 家族分发;(b) 的宿主级安装是用户动作不该由工具代劳(与 Doctrine「不自动安装」一致)。 + +等你的 Q1–Q4 答复再开下一轮。 +user: 按你推荐的来处理 +assistant: 收到,按推荐路线执行:先出「可行性 + 方案取舍」定档笔记,代码不写。先做防重复检查 + 摸清落盘规范。 +assistant: 知识库无相关笔记覆盖,schema 已确认 comparison 页约定。现在落盘「可行性 + 方案取舍」对比文档页。 +assistant: 注意:写入被 session 缓存劫持到了临时 smoke 目录(正是 Doctrine 反模式「陈旧 session 劫持 output_dir」)。修正——显式传 `output_dir` 重写到 repowiki。 +assistant: 文档已正确落盘到 `repowiki/wiki/comparisons/`,任务记忆已更新。重建检索索引。 +assistant: `close_session` 也被 session 缓存劫持到了临时目录。显式指定 `output_dir` 重建仓库索引。 +assistant: 验证新页面已进索引。 +assistant: 索引验证通过。收尾前重试一次后台补蒸馏(他山之石还有 1 条 pending raw)。 +assistant: 补蒸馏子代理无 MCP 权限,两次尝试均为环境级失败(非业务错误)——停止重试,作为遗留项记录,不阻塞主任务。 + +## 执行结果汇报 + +按你的授权(Q1–Q4 全按推荐),本次定档产出已完成: + +**产出物:** `repowiki/wiki/comparisons/自动生成SKILL可行性-wikiskill闭环-vs-CodeWiki编译复用.md`(已进 BM25 索引,`query_wiki` 验证 top_score 16.77) + +**定档结论摘要:** +- **能实现**——CodeWiki 知识管线已走完「经验→结构化知识」半程(蒸馏已有),缺口集中在「技能产物类型」+「生成工具」两个新增件,非重构 +- **推荐形态 = 方案 C 半闭环**:MVP 先做单向编译器(confirmed notes/scenarios → SKILL.md draft → 确认闸门 → `.codebuddy/skills/`),稳定后再接回流迭代 +- **不建自动评分门控**(无 held-out 基准,确认闸门已担质量职责);**生成走 Mode C**(工具簿记、宿主 agent 写正文);**先仓库内闭环**再家族分发 +- 落地顺序:skill 词汇 → skill-creator 工具 → SKILL.md lint → 闭环验证 → 回流 + +**任务记忆**已直写更新(`他山之石`,366 字符)。 + +**遗留项:** 他山之石 1 条 pending raw(humanizer 安装技能对话)补蒸馏因蒸馏子代理拿不到 MCP 权限未完成,留待会话收尾或可用环境处理。 + +--- + +grill 继续。定档已落下,设计树的下一层 frontier 只有一个问题: + +❓ **Q5** - **本次会话范围收束**:你原话是「看看我们当前项目能否实现自动生成 SKILL」——现在可行性已定档并归档。要继续往下走吗? +- (a) **收尾**:本次到此为止,comparison 页即交付物;后续要动手时再开新会话/新任务做 skill-creator 设计实现 +- (b) **深化设计**:继续 grill 到 skill-creator 工具的具体形态(API/选材规则/frontmatter schema/触发入口),产出设计文档但代码仍不写 +- (c) **直接进实现**:按落地顺序第一步(skill 词汇 + skill-creator Mode C 原型)开始写代码 + +➡️ 我推荐 (a) 收尾:可行性定档是一次完整交付;设计/实现是另一颗番茄,带着「方案 C + 落地顺序」的上下文随时可重启,现在趁热收尾最干净。 +user: 继续 +user: 继续 +assistant: "继续"= 不收尾,往下深挖。走 (b) 深化设计:这轮 grill 我把 skill-creator 的具体形态(API/选材规则/frontmatter schema/触发入口)逼到可落地的程度,代码仍然不写。 + +先补齐设计所需的事实。 +assistant: 事实补齐。关键发现:`note_consolidation.py` 是现成的平行工具模板(Mode C prepare/submit + 容量 + 双向溯源 + 软删除 + 索引重建),`note_types.py` 是 note_type 权威表(schema 可覆盖新增),scenario 页的章节骨架(SOP/判断逻辑/禁忌)已经约等于 SKILL.md 正文骨架。 + +## 设计骨架(已可确定的部分) + +| 件 | 设计 | +|---|---| +| 工具 | `skill_creator`(`codewiki/mcp/tools/skill_creator.py` + `registry.py` 注册),对齐 `consolidate_notes` | +| 协议 | Mode C:`prepare`(候选素材清单 + 现有技能索引 + 冲突预检 + 系统提示)→ agent 写正文 → `submit`(frontmatter 校验、路径安全、溯源互链、索引重建) | +| 复用件 | `store.locked_rmw`、frontmatter round-trip 辅助、`note_types.py` 式权威表(新增 skill 资产类型)、`aggregation_state` 计数器 | +| 产物格式 | `name` + `description` frontmatter(`.codebuddy/skills/grilling/SKILL.md:1-4` 实证),Anthropic Agent Skill 兼容 | + +**两个必须指出的架构张力(这是设计的核心难点,不是细节):** + +1. **确认闸门 vs 即时生效**:写进 `.codebuddy/skills/` 的 SKILL.md 会被 IDE 立即发现并可触发——这与仓库「落盘必经确认闸门」铁律正面冲突。必须两区制:草稿区(不生效)→ 确认 → 生效区。 +2. **技能不可检索**:`lint_wiki` 只扫 `repowiki/*.md`,`.codebuddy` 不进索引也不进 lint。不建技能索引就会重复生成(consolidate 靠 `scenarios_index` 防重复,skill_creator 必须有对等件)。 +3. **scenario ≈ SKILL 正文骨架**:scenario 的「工作场景/适用条件/核心 SOP/判断逻辑/禁忌」基本就是 SKILL.md 需要的正文结构,而 `description` 天然对应「适用条件」。若不复用这条现成结构,等于重造一次聚合。 + +--- + +现在问这层 frontier(4 个决策,都带推荐): + +❓ **Q6** - **产物粒度与素材边界**:SKILL 从哪来、一份多大? +- (a) **scenario 直译**:每份已确认 scenario → 一份 SKILL(复用现成骨架,素材最干净,粒度一致) +- (b) 跨主题聚合:一批相关 notes → 一份 SKILL(更灵活,但要重做 consolidate 已做过的聚类) +- (c) 按 note_type 精选:只从 `pitfall`/`lesson`/`decision` 生成(可操作性强,但覆盖面窄) + +➡️ 我推荐 **(a) scenario 直译 + (c) 作为补充来源**:scenario 是 L2 已聚合的可复用方法,天然带 SOP/判断逻辑,编译成本最低;单条高价值 pitfall 若没被任何 scenario 吸收,允许作为独立素材(走 `_pending_confirmed_notes` 的同类扫描)。 + +❓ **Q7** - **选材与触发入口**:工具怎么知道该生成什么? +- (a) **prepare 列候选清单**(含素材/技能索引/冲突预检/容量预警),agent 挑——完全对齐 consolidate_notes +- (b) 用户显式指定(主题词 / 笔记文件列表 / scenario 文件) +- (c) 全自动(按 heat 阈值自动挑选,无需人参与) + +➡️ 我推荐 **(a) + (b) 并存**:prepare 默认列候选(默认 UPDATE 优先、最多新建 1 份/批,沿用 consolidate 的防碎片纪律),同时支持 `topic`/`sources` 显式指定。**(c) 不要**——违反 Doctrine「触发永远显式」。 + +❓ **Q8** - **落盘与确认闸门**(最关键):草稿区在哪、如何生效? +- (a) **两区制**:草稿落 `repowiki/skills//SKILL.md`(不生效、进索引、进 lint),确认后 `install` 到 `.codebuddy/skills/`(生效) +- (b) 直接落 `.codebuddy/skills/` 并标 `status: draft`,确认后去标记(单区,但草稿期技能**已经会被 IDE 触发**,闸门形同虚设) +- (c) 直接落并即刻生效,靠事后 reject 回滚 + +➡️ 我推荐 **(a)**:只有两区制能真正守住确认闸门;(b) 的致命问题是草稿期的坏技能会在你确认前就影响 agent 行为。代价是多一个 install 动作——这正好也把「宿主级安装」变成显式用户动作(沿用 Q4 结论)。 + +❓ **Q9** - **更新与退役**:技能怎么迭代、怎么废弃? +- (a) 整份重写(简单,但丢失 diff 历史) +- (b) **unified diff 增量 patch**(wikiskill 的做法,保留演化证据,但实现重) +- (c) 重写 + 变更记录段(正文整份重写,frontmatter `metadata` 追加 `revisions` 记录变更原因与来源) + +➡️ 我推荐 **(c)**:保留 wikiskill「改动可审计」的价值(正是 skill-impact 的意义),但不必实现 diff 引擎;退役沿用 `reject_note` 同款语义(草稿区标记 deprecated + 从生效区 uninstall)。 + +下轮等你 Q6–Q9 的答复,之后 frontier 会展开到 Q10(回流验证:技能"有效"怎么判定)、Q11(SKILL.md lint 规则与容量上限)。 +user: 按照你推荐方案来 +assistant: 设计定档。产出设计文档页(`query` 类型),仍不动代码。 +assistant: 设计页未出现在检索结果中,核实索引是否覆盖 `wiki/queries/`。 +assistant: 索引里查不到新写的设计页,直接核实文件与索引状态。 +assistant: 索引文件在 `repowiki/.meta/search_index.json`。核查设计页是否被写入索引。 +assistant: 索引走的是 SQLite 独立缓存,设计页未被纳入。查清索引重建路径,避免盲目重试。 +assistant: 设计页文件已正确落盘,但**没进 BM25 检索索引**(comparison 页能检索到,新 query 页检索不到,且 JSON 索引是陈旧遗留,实际索引在 `.codewiki/analysis_cache.db`)。这是工具侧行为,不是交付物问题——派子代理后台查明根因,同时继续 grill。 +user: +This session is being continued from a previous conversation that was compacted to stay within the model context window. +Messages after this summary (if any) are the original recent turns. Tool results from before this summary were omitted. +If you need exact code, command output, or file contents from before compaction, re-read those files. Do not treat omitted tool bodies as still in context. + +Earlier user requests: +- @command://grill-me 调研这个项目https://github.com/ashutoshsinghpr7/wikiskill 和这篇文章https://mp.weixin.qq.com/s/Qj66EtKh4jyQEhehxLl6_Q 看看我们当前项目能否实现自动生成SKILL +- 本会话是否关联到某个进行中的任务? 他山之石(竞品/外部项目调研类任务) +- 按你推荐的来处理 +- 继续 +- 继续 + +Earlier assistant notes (truncated): +- 知识库无相关笔记覆盖,schema 已确认 comparison 页约定。现在落盘「可行性 + 方案取舍」对比文档页。 +- 注意:写入被 session 缓存劫持到了临时 smoke 目录(正是 Doctrine 反模式「陈旧 session 劫持 output_dir」)。修正——显式传 `output_dir` 重写到 repowiki。 +- 文档已正确落盘到 `repowiki/wiki/comparisons/`,任务记忆已更新。重建检索索引。 +- `close_session` 也被 session 缓存劫持到了临时目录。显式指定 `output_dir` 重建仓库索引。 +- 验证新页面已进索引。 +- 索引验证通过。收尾前重试一次后台补蒸馏(他山之石还有 1 条 pending raw)。 +- 补蒸馏子代理无 MCP 权限,两次尝试均为环境级失败(非业务错误)——停止重试,作为遗留项记录,不阻塞主任务。 ## 执行结果汇报 按你的授权(Q1–Q4 全按推荐),本次定档产出已完成: **产出物:** `repowiki/wiki/comparisons/自动生成SKILL可行性-wikiskill闭环-vs-CodeWiki编译复用.md`(已进 BM25 索引,`query_wiki` 验证 top_score 16.77) **定档结论摘要:** - **能实现**——CodeWiki 知识管线已走完「经验→结构化知识」半程(蒸馏已有),缺口集中在「技能产物类型」+「生成工具」两个新增件,非重构 - **推荐形态 = 方案 C 半闭环**:MVP 先做单向编译器(confirmed notes/scenarios → SKILL.md draft → 确认闸门 → `.codebuddy/skills/`),稳定后再接回流迭代 - **不建自动评分门控**(无 held-out 基准,确认闸门已担质量职责);**生成走 Mode C**(工具簿记、宿主 agent 写正文);**先仓库内闭环**再家族分发 - 落地顺序:skill 词汇 → skill-creator 工具 → SKILL.md lint → 闭环验证 → 回流 **任务记忆**已直写更新(`他山之石`,366 字符)。 **遗留项:** 他山之石 1 条 pending raw(humanizer 安装技能对话)补蒸馏因蒸馏子代理拿不到 MCP 权限未完成,留待会话收尾或可用环境处理。 --- grill 继续。定档已落下,设计树的下一层 frontier 只有一个问题: ❓ **Q5** - **本次会话范围收束**:你原话是「看看我们当前项目能否实现自动生成 SKILL」… +- "继续"= 不收尾,往下深挖。走 (b) 深化设计:这轮 grill 我把 skill-creator 的具体形态(API/选材规则/frontmatter schema/触发入口)逼到可落地的程度,代码仍然不写。 先补齐设计所需的事实。 + +Tools used before this summary: mcp_call_tool × 10, read_file × 5, mcp_get_tool_description × 3, task × 3, web_fetch × 3, ask_followup_question × 1, use_skill × 1, web_search × 1, list_dir × 1 + + +继续 +assistant: 先确认磁盘状态(压缩后避免基于过期上下文行动),再继续 grill。 +assistant: 磁盘上两份交付物完好(`queries/skill-creator设计方案.md`、`comparisons/自动生成SKILL可行性…md`)。索引问题到此为止:子代理排查也是环境级失败(Too Many Requests),与之前 humanizer 蒸馏同型——不再重试,降级为遗留项记录。 + +--- + +索引问题的降级结论(不再尝试修复,留档): + +| 观察 | 结论 | +|---|---| +| 新写入 `wiki/queries/` 的页不进 `query_wiki` 检索 | 疑似 queries 目录未纳入 SQLite 检索索引扫描或增量刷新路径有盲区(`close_session(force)` 只重建元数据/图,未刷新 BM25) | +| `repowiki/.meta/search_index.json` 里搜不到任何近期页 | 陈旧遗留文件,实际索引在 `.codewiki/analysis_cache.db` | +| 影响 | 仅检索不可达;文件本身、wikilink、lint 均正常。不阻塞本轮设计 | + +→ 已记入任务记忆为遗留项;它同时是 **pitfall 候选**(「write_doc_file 落盘 ≠ 可检索」),走 L1 确认闸门后再入 note 层。 + +--- + +grill 继续。设计树还剩最后两层 frontier: + +❓ **Q10** - **回流验证**:半闭环的「技能有效」怎么判定?技能生成后不回流就永远是「生成了但没人知道好不好用」。 +- (a) **note 反馈 + 薄 frontmatter**:试用反馈以 note 落库(继承蒸馏+确认管线,知识闭环统一),技能 frontmatter 只留 `metadata.effectiveness.last_evaluated_at`;**长期无求值记录**的技能列为 UPDATE/retire 候选 +- (b) 只靠「从未被 reject」当隐含有效(无信号,形同没有) +- (c) 不做回流(纯单向编译器,省事但闭环断裂) + +➡️ 我推荐 **(a)**:`effectiveness.last_evaluated_at` 无值或超期(建议 90 天)→ `prepare` 将其列为「待求值」UPDATE 候选;超期且从未 install 的草稿技能(无人领养)→ 列为退役候选。删除仍走显式确认,不自动。 + +❓ **Q11** - **SKILL.md 校验与容量**:lint 规则清单 + 上限取值。 +- 草稿区要不要纳入 `lint_wiki`?——**必须**:草稿区技能是坏的话,install 会把坏技能送进生效区 +- 生效区容量硬顶建议 **12**(沿用 `max_scenarios`=15 的防碎片哲学) +- 必检项:`description` 非空且含触发条件、`name` 匹配 `^[a-z0-9-]+$`、无绝对路径/密钥串、正文 ≤4000 字、`source_refs` 目标存在、`status ∈ {draft, stable, deprecated}` + +➡️ 按推荐 = 草稿区入 lint + 生效区 12 硬顶 + 上述必检项。 + +两问合一拍板:**Q10/Q11 按推荐定稿?** 定稿后设计树即完整收敛,收官只剩元决策(实施 / 收尾归档)。 +user: 定位修复新写入的文档无法检索的问题 +assistant: 用户明确要求定位并修复检索索引问题。这是调查+修复任务,先加载排查工作流,再动手读代码定位根因。 +assistant: 先并行摸清仓库结构与索引相关代码位置。 +assistant: 核心索引文件找到。并行读取三个关键实现。 +assistant: 索引架构已清楚:SQLite (`analysis_cache.db`) 为主,JSON 为 legacy fallback;`ensure_fresh` 三层检查(count/manifest/mtime 采样)驱动自愈重建。现在需要两个关键证据:(1) SQLite 建索引时扫描/写入逻辑;(2) 磁盘 DB 里到底有没有这两个新页。 +assistant: PowerShell 环境语法不同。改用 PowerShell 语法查询 DB,同时读 `cache.py` 的索引构建与检索实现。 +assistant: PowerShell 引号转义太麻烦,改用临时脚本查询 DB。 +assistant: **重大发现**:`total_docs = 7`,但仓库实际有 284 个 md 文件。索引里只有 smoke 测试产物(`wiki/modules/test_doc.md`、`notes/2026-09-05-smoke-test-*`)+ 后来重建时扫到的 3 个真实文档(comparison 页是其中之一,query 页没赶上)——**这个 `.codewiki/analysis_cache.db` 是 smoke 测试留下的 7 文档索引**,不是真实仓库索引。 + +继续确认 DB 布局与配置,找出真实 DB 应在的位置。 +assistant: DB 在 repo 根 `.codewiki/`,符合标准布局(output_dir=repowiki → parent=repo 根)。现在要确认:索引里 7 个文档对应磁盘哪些真实文件,以及是否存在 smoke 临时目录写入了这个 DB。 +assistant: 磁盘真实结构清楚了:notes 146 个、wiki/modules 28 个——但索引里 notes 只有 4 个、modules 只有 1 个。**索引内容对应的是一个"子集镜像"目录,不是完整 repowiki**。验证磁盘上这些文件是否真实存在,并查找 smoke 临时目录。 +assistant: **根因已交叉确认**: + +1. 真实 `repowiki/` 磁盘上,索引里 5 个 doc 对应文件 **MISSING**(`notes/2026-09-05-smoke-test-*`、`mcp-subprocesses`、`registrypy`、`modules/test_doc.md`)——这些是 **e2e/smoke 测试产物**。 +2. repo 根 `.codewiki/analysis_cache.db`(`_resolve_db_path` 标准布局指向它)里装的 **7 个文档 = 某个 smoke/e2e 目录的内容**,不是真实仓库 284 个文件。 +3. 之前 comparison 页 query_wiki 验证"通过"是假象——DB 里有我第一版**误写入 smoke 目录的 comparison 副本**;而 query 页第二版只写进了真实 repowiki,smoke 目录没有 → 搜不到。**完全自洽。** + +即:**session 绑定的 cache(repo 根 DB)与 build/output_dir(smoke 目录)解耦,导致每次"重建"清空真实 DB 填入 smoke 内容**。现在确认 server 端 session 如何绑定 cache 与 output_dir,再定修复。 +assistant: `session.py` 的 `get_cache(repo_path)` 绑定 DB 到 `repo_path/.codewiki/`;而 `build_search_index(od)` 扫描的是 `output_dir`。**当 session 的 repo_path 与 output_dir 来自不同目录时(smoke 场景),重建会清空 repo 根 DB 填入错误目录内容**——这就是污染机制。 + +先做数据修复:用真实 `output_dir=repowiki` 对 repo 根 DB 重建索引,然后验证。 +assistant: 数据修复成功:`total_docs` 从 7 → **207**(wiki 57 + notes 146 + sources 4)。验证 query 页是否已入索引。 +assistant: 数据层修复完成。但还有关键风险:**MCP server 进程若仍持有错配的 session(smoke output_dir),下一次 `query_wiki` 的 `ensure_fresh` 又会触发重建、再次污染 DB**。先确认 MCP 侧工具的 session/output_dir 行为。 +assistant: MCP 工具都支持显式 `output_dir`。现在通过 MCP 端验证检索链路已修复——显式指定真实 output_dir 检索 query 页。 +assistant: MCP 检索链路已恢复(top_score 31.8 命中)。还需确认:(1) 不带显式 output_dir 时 server 端是否稳定;(2) 找出代码层防护点,防止 smoke 场景再次清空真实索引。 +assistant: **关键发现**:第二次不带 `output_dir`(仅 `repo_path`)只命中 comparison 页(4.42),query 页没命中——**说明 server 存在一个活跃/恢复的 session,其 `output_dir` 指向 smoke 目录**(smoke 目录里有我之前误写的 comparison 副本,但没有 query 页)。这正是污染源头。定位 session 存储与恢复逻辑。 +assistant: **根因机制找到了**:`session.py find_or_restore` 恢复 session 时用 `cache.get_output_dir()`(SQLite 里存的 output_dir),若无则 fallback `/repowiki`。若某次 `analyze_repo` 曾以 smoke 目录为 output_dir 分析仓库,该值被 `set_output_dir` 持久化进 repo 根 DB → 之后每次恢复的 session 都指向 smoke 目录 → 不带显式 output_dir 的所有 wiki 操作全部走错目录。 + +查 DB 里持久化的 output_dir 记录并确认代码。 +assistant: 更新诊断脚本查 `repo_meta.output_dir` 持久化值(污染源)。 +assistant: **铁证落定**:`repo_meta.output_dir = C:\Users\Administrator\AppData\Local\Temp\codewiki_smoke_v3nzwq48` —— smoke 测试把错误 output_dir 持久化进了 repo 根 DB。完整根因链: + +1. smoke 进程用 `repo_path=repo根 + output_dir=Temp/codewiki_smoke_*` 调 analyze_repo → `set_output_dir(smoke)` 写入 repo 根 DB +2. 后续 `find_or_restore(repo根)` → session.output_dir=smoke(`get_output_dir()` 优先于 `/repowiki` fallback) +3. 不带显式 output_dir 的 wiki 工具全部操作 smoke 目录;`close_session` 重建时经 session.cache(repo 根 DB)把真实索引**清空填入 smoke 的 7 个文档** + +验证 smoke 目录残留内容,并确认代码层修复点。 +assistant: smoke 目录 `codewiki_smoke_v3nzwq48` 存在且结构完整(我误写的 comparison 副本就在里面)。关键代码事实:`store_bridge.resolve_output_dir` 顺序是 **session.output_dir > 显式参数 > repo_path fallback**——只要 server 内存/恢复的 session 带 smoke output_dir,不带显式参数的调用就会被劫持。查看 `query_wiki` handler 的实际解析顺序,确定修复点。 +assistant: 需要看 `handle_query_wiki` 主体的解析逻辑(文件开头)。 +assistant: 解析顺序确认:`resolve_session` 先执行,无显式 `output_dir` 时用 `session.output_dir`。读 `resolve_session` 与 `find_or_restore` 的确切逻辑(恢复时如何用 `cache.get_output_dir()`)。 +assistant: 完整链路已闭合。`find_or_restore` 恢复 session 时:`output_dir = cache.get_output_dir()`(**DB 持久化的 smoke 路径**)优先于 `/repowiki` fallback → 每个无显式参数的调用都被劫持到 smoke。 + +执行修复。先做数据层纠正(删除 DB 中错误的 output_dir 记录 → 恢复 fallback 语义),同时查 smoke 的触发源。 +assistant: server.py 无 `--smoke` 入口——smoke 来自外部客户端(IDE 冒烟测试以真实 repo 为 repo_path、Temp 目录为 output_dir 调用 analyze_repo)。仓库侧防护在 `set_output_dir`/`get_output_dir`/`find_or_restore` 三点。先执行数据修复。 +assistant: 数据修复完成:`output_dir` 记录已删,恢复 `/repowiki` fallback 语义。现在做代码层防护。先看 `cache.py` 的 `_rel_path`/`_abs_path`/`resolve` 定义以便正确实现校验。 +assistant: `_rel_path` 对 repo 外路径原样存绝对路径(注释说明这是 centralized 自定义场景设计的),但**当 repo 自己有 `repowiki/` 时,外来 output_dir 就是污染**。实施两处核心修复。先确认 `cache.py` 顶部有 logger。 +assistant: `logger` 已存在。实施两处核心修复(`set_output_dir` 拒绝外来路径 + `get_output_dir` 恢复时校验)。 +assistant: 修复 1+2 完成。加第三层纵深防御:`wiki_search` 的 session.cache 分支校验 DB 归属,防止 session 缓存的 DB 被用于重建其他目录。先重读相关结构。 +assistant: 结构确认:4 处 `session.cache` 使用点(`build_full_index`/`update_file`/`search`/df 查询 helper)。加一个归属校验 helper,在 3 处写入/检索路径应用。先读 `build_full_index` 全段确保安全改写。 +assistant: 结构清晰。实施修复 3:加 `_cache_serves_output_dir` 归属校验 helper,替换三处 `session.cache` 使用条件,防止 session 缓存 DB 被用于重建/更新其他目录的索引。 +assistant: Helper 已加。现在替换三处 `session.cache` 使用条件。 +assistant: 替换第 4 处只读连接点保持一致性,然后检查 lint。 +assistant: Lint 干净。现在分两层验证:(1) 数据层——MCP 端不带 output_dir 是否已命中 query 页;(2) 代码层——新逻辑在内存中模拟污染场景是否被拦截。 +assistant: 代码层修复全部验证通过(污染场景被拦截、fallback 正确、归属校验生效)。 + +但 MCP 端仍只命中 comparison 页——因为 **server 进程内存中残留了第二次调用创建的 smoke session**(TTL 2h,`find_or_restore` 优先返回活跃内存 session,不查 DB)。必须清理它。先确认 `close_session` 的重建/移除顺序,避免清理时再污染。 +assistant: `close_session` 解析顺序:**显式 output_dir > session.output_dir**,且重建/移除使用解析后的 output_dir。用显式 `output_dir=repowiki` + `force=true` 清理 server 内存中的 smoke session,同时正确重建索引。 +assistant: `close_session` 成功(output_dir=repowiki,session 已清除)。最终端到端验证:不带 output_dir、仅 repo_path 检索。 +assistant: 端到端验证通过:不带 `output_dir` 的检索已命中 query 页(top 18.97)。修复完整生效。 + +跑相关测试确认无回归。 +assistant: 跑与改动最相关的测试子集(索引新鲜度、centralized layout、变更分析)验证无回归。 +assistant: 50 个相关测试全部通过,无回归。清理临时诊断脚本,收尾汇报。 +assistant: 临时文件在系统 Temp 由系统自行清理。确认最终代码改动。 +assistant: 修复完成。汇报如下: + +## 定位结果:完整根因链 + +**现象**:新写入 `wiki/queries/` 的文档搜不到,`wiki/comparisons/` 的却能搜到——这是假象。 + +**根因链(5 环)**: + +1. 某次 smoke/harness 运行以 `repo_path=CodeWiki-CN` + `output_dir=C:\...\Temp\codewiki_smoke_v3nzwq48` 调 `analyze_repo`,`cache.set_output_dir()` 把这个**跨仓库临时目录持久化进了 repo 根 DB** 的 `repo_meta.output_dir` +2. `SessionStore.find_or_restore()` 恢复 session 时优先用 `cache.get_output_dir()`(smoke 路径),而非 `/repowiki` fallback → 后续无显式参数的会话全部劫持到 smoke 目录 +3. 我的 `write_doc_file` 第一版因此误写入 smoke 目录(session output_dir 劫持);`close_session` 重建时经 session 共享 cache(**repo 根 DB**)执行 `build_search_index(smoke)` → **真实索引(应有 284 文件)被清空,填入 smoke 目录仅 7 个测试文档** +4. comparison 页恰好 smoke 目录里也有副本(我误写的那版)→ 之前验证"通过"是命中副本;query 页只写进了真实 repowiki → 搜不到 +5. 铁证:索引 `total_docs=7` vs 磁盘 284 个 md;索引里 5 个 doc 对应文件在真实 repo 中不存在(`test_doc.md`、`smoke-test-decision-*` 等) + +## 修复(三层) + +| 层 | 改动 | +|---|---| +| 数据 | 用真实 output_dir 重建索引(7→**207** docs);删除被污染的 `repo_meta.output_dir` 记录 | +| 代码 | `cache.py`:`set_output_dir` 拒绝 repo 外 output_dir(当 repo 拥有 `repowiki/`),`get_output_dir` 恢复时同样拦截——外来路径不再被持久化/继承(centralized 合法场景保留) | +| 代码 | `wiki_search.py`:新增 `_cache_serves_output_dir` 归属校验,4 处 `session.cache` 使用点(build/update/search/coverage)仅在 cache DB 确实归属该 output_dir 时生效——纵深防御,防 session 缓存再次清空他库索引 | +| 运行 | `close_session(force, output_dir=repowiki)` 清理 server 内存中被劫持的 smoke session | + +## 验证 + +- 端到端:MCP `query_wiki(repo_path)`(不带 output_dir)现在命中 `wiki/queries/skill-creator设计方案.md`(top 18.97)✓ +- 模拟测试:smoke 场景被 `set_output_dir` 拒绝、repo 内目录正常、归属校验 smoke=False/repowiki=True,全过 ✓ +- 回归:`test_index_freshness` + `test_centralized_layout_fixes` + `test_change_analysis` = **50 passed** ✓ + +## 说明 + +- 代码防护在 **MCP server 下次重启后**才加载(当前进程旧代码),但数据与 session 状态已修好,旧进程行为已正确 +- 两个代码文件改动即 git diff 全部内容(`cache.py` +50、`wiki_search.py` +35/-4),无多余改动 +- 此事故是高质量 pitfall 候选(「跨仓库 output_dir 劫持清空真实索引」),建议走 L1 确认闸门后入 note 层 +user: rEPO_PATH和OUTPUT_DIR都什么情况下需要自定义,我觉得是不是可以删掉或只保留REPO_PATH。输出目录写死REPOWIkI +user: +This session is being continued from a previous conversation that was compacted to stay within the model context window. +Messages after this summary (if any) are the original recent turns. Tool results from before this summary were omitted. +If you need exact code, command output, or file contents from before compaction, re-read those files. Do not treat omitted tool bodies as still in context. + +Earlier user requests: +- 继续 +- 定位修复新写入的文档无法检索的问题 + +Earlier assistant notes (truncated): +- **铁证落定**:`repo_meta.output_dir = C:\Users\Administrator\AppData\Local\Temp\codewiki_smoke_v3nzwq48` —— smoke 测试把错误 output_dir 持久化进了 repo 根 DB。完整根因链: 1. smoke 进程用 `repo_path=repo根 + output_dir=Temp/codewiki_smoke_*` 调 analyze_repo → `set_output_dir(smoke)` 写入 repo 根 DB 2. 后续 `find_or_restore(repo根)` → session.output_dir=smoke(`get_output_dir()` 优先于 `/repowiki` fallback) 3. 不带显式 output_dir 的 wiki 工具全部操作 smoke 目录;`close_session` 重建时经 session.cache(repo 根 DB)把真实索引**清空填入 smoke 的 7 个文档** 验证 smoke 目录残留内容,并确认代码层修复点。 +- smoke 目录 `codewiki_smoke_v3nzwq48` 存在且结构完整(我误写的 comparison 副本就在里面)。关键代码事实:`store_bridge.resolve_output_dir` 顺序是 **session.output_dir > 显式参数 > repo_path fallback**——只要 server 内存/恢复的 session 带 smoke output_dir,不带显式参数的调用就会被劫持。查看 `query_wiki` handler 的实际解析顺序,确定修复点。 +- 需要看 `handle_query_wiki` 主体的解析逻辑(文件开头)。 +- 解析顺序确认:`resolve_session` 先执行,无显式 `output_dir` 时用 `session.output_dir`。读 `resolve_session` 与 `find_or_restore` 的确切逻辑(恢复时如何用 `cache.get_output_dir()`)。 +- 完整链路已闭合。`find_or_restore` 恢复 session 时:`output_dir = cache.get_output_dir()`(**DB 持久化的 smoke 路径**)优先于 `/repowiki` fallback → 每个无显式参数的调用都被劫持到 smoke。 执行修复。先做数据层纠正(删除 DB 中错误的 output_dir 记录 → 恢复 fallback 语义),同时查 smoke 的触发源。 +- server.py 无 `--smoke` 入口——smoke 来自外部客户端(IDE 冒烟测试以真实 repo 为 repo_path、Temp 目录为 output_dir 调用 analyze_repo)。仓库侧防护在 `set_output_dir`/`get_output_dir`/`find_or_restore` 三点。先执行数据修复。 +- 数据修复完成:`output_dir` 记录已删,恢复 `/repowiki` fallback 语义。现在做代码层防护。先看 `cache.py` 的 `_rel_path`/`_abs_path`/`resolve` 定义以便正确实现校验。 +- `_rel_path` 对 repo 外路径原样存绝对路径(注释说明这是 centralized 自定义场景设计的),但**当 repo 自己有 `repowiki/` 时,外来 output_dir 就是污染**。实施两处核心修复。先确认 `cache.py` 顶部有 logger。 + +Tools used before this summary: read_file × 13, execute_command × 13, search_content × 9, list_dir × 8, write_to_file × 4, search_file × 2, mcp_call_tool × 2, use_skill × 1, mcp_get_tool_description × 1 + + +rEPO_PATH和OUTPUT_DIR都什么情况下需要自定义,我觉得是不是可以删掉或只保留REPO_PATH。输出目录写死REPOWIkI +assistant: 这是一个设计评估问题,需要先摸清 `output_dir` 的真实使用面再回答。并行调研关键代码与模板事实。 +assistant: 布局路由机制已清楚(`default_output_dir(repo_path)` 布局感知)。继续确认 `query_wiki` 参数面与 `default_output_dir` 完整逻辑,判断第二跳是否必须显式 output_dir。 +assistant: 机制已基本清楚。再确认两点事实:`query_wiki` 等只读工具的参数面(是否存在"只要 output_dir、不要 repo_path"的独立寻址设计),以及仓库内是否有相关设计文档讨论过参数收敛。 +assistant: 关键设计文档已揭示集中式布局是"repo 与 output_dir 同仓绑定"原则的显式例外(有布局路由机制完备支撑)。最后确认 `query_wiki` 的实际参数面。 +assistant: 调研完成。证据链已齐,直接给结论。 + +## 现状:`output_dir` 其实已经可以被推导 + +代码里有唯一的布局感知推导函数 `default_output_dir(repo_path)`(`workspace_layout.py`),所有工具的解析链都是: + +``` +session.output_dir > 显式 output_dir 参数 > default_output_dir(repo_path) +``` + +第三级意味着:**不传 `output_dir` 时,`repo_path` 已经能唯一确定目录**——普通单仓 → `/repowiki`,centralized 布局成员 → workspace 根 repowiki。解析链第三级事实上就是你说的"写死"逻辑,只是它布局感知(不是字面写死 `repowiki`)。 + +## 盘点:`output_dir` 在哪些场景真的需要自定义 + +| 场景 | 是否必须显式 | 说明 | +|---|---|---| +| 单仓 analyze/写入 | ❌ | 恒 `/repowiki`,自动 | +| centralized 分析业务仓 | ❌ | 已自动路由到 workspace 根 repowiki(`workspace.json` 布局驱动) | +| **跨仓/直接寻址检索**(workspace 第二跳) | ✅ | `query_wiki(output_dir=<业务仓>/repowiki)`——agent 在 harness 根工作、session 绑 harness,要查的是**另一个仓**的知识库。这是唯一 repo_path 无法自然表达的场景(repo_path 的宿主 ≠ 目标知识库宿主) | +| **session/cache 持久化 output_dir** | ❌(本次事故根源) | 这是个**伪需求**:output_dir 是 repo_path 的纯函数,恢复 session 时用 `default_output_dir(repo_path)` 重新推导即可,根本不用存。它反而让任何进程(smoke/脚本)都能 `set_output_dir` 全局改写,污染所有后续会话——上次 207 条索引被清空就发生在这一环 | + +## 所以,"删掉还是写死"的正确切法 + +字面"写死 `/repowiki`"**不能做**——会废掉 centralized(业务仓没有自己的 repowiki)和 workspace 第二跳。该收敛的是下面两点: + +1. **写路径收敛**(analyze/write/ingest/close 等):`output_dir` 移出必选参数面,一律 `default_output_dir(repo_path)`。显式 `output_dir` 降级为 legacy 覆盖并**拒绝指向 repo 外**(上次事故的另一半已修复)。 +2. **砍掉跨进程持久化**:`cache.repo_meta.output_dir` 删除,`find_or_restore` 恢复 session 时用布局推导——`set_output_dir` 不再存在,smoke 污染在机制上不可能。 + +3. **只读检索保留唯一独立寻址出口**:`query_wiki`/`note_query` 等允许 `output_dir` 指向任意 wiki(含别的仓),这是 workspace 两跳路由的刚需,且只读不产生污染。若要再收一档,第二跳也可统一改成 `repo_path=<业务仓目录>`(layout fallback 自动给 `<业务仓>/repowiki`),只是模板里的 AGENTS 指引需要同步改。 + +## 代价提示 + +- 收敛方案 1+2 影响面:53 个工具文件引用 `output_dir`,但绝大多数是透传 store_bridge——真正改的是 schema 描述、handler 解析、session 恢复、`cache.py` 三个点 + 一堆测试断言。 +- centralized/colocated 的回归测试已覆盖布局路由(`test_centralized_layout_fixes` 等 50 项刚跑过全绿),改完后能立刻兜住。 + +要不要我按收敛版落地?给两个粒度可选:**A. 最小收敛**(删 DB 持久化 + session 恢复改布局推导 + 写路径 schema 标 legacy)或 **B. 激进收敛**(A + 第二跳模板统一 repo_path 寻址、移除 query 外的 output_dir 参数)。你定范围我再动手。 +user: +This session is being continued from a previous conversation that was compacted to stay within the model context window. +Messages after this summary (if any) are the original recent turns. Tool results from before this summary were omitted. +If you need exact code, command output, or file contents from before compaction, re-read those files. Do not treat omitted tool bodies as still in context. + +Earlier user requests: +- rEPO_PATH和OUTPUT_DIR都什么情况下需要自定义,我觉得是不是可以删掉或只保留REPO_PATH。输出目录写死REPOWIkI + +Earlier assistant notes (truncated): +- 这是一个设计评估问题,需要先摸清 `output_dir` 的真实使用面再回答。并行调研关键代码与模板事实。 + +Tools used before this summary: search_content × 2, read_file × 2 + + +B激进方案一步到位,输出修改方案 +assistant: 方案 B 已选。当前处于 Plan 模式,我先把改动目标面核实清楚,再产出修改方案。快速补几处定位(模板用法、cache 持久化现状、schema 面分布)。 +assistant: cache.py 已有部分事故修复(`set_output_dir` 拒绝 repo 外)。再看几个 B 方案锁定的关键点:session 恢复链、模板第二跳写法。 +assistant: 信息已足够。方案 B 的目标面已核实(模板三件套 + `cache.py` 持久化 + `note_query.py` 解析链 + `workspace_layout.py` 布局函数可复用)。调用方案生成。 +assistant: 修改方案已生成完毕(方案 B 激进收敛)。计划已就绪,待你确认后开始执行: + +**方案概览**:先把 `output_dir` 全部消费点盘点固化为收敛清单,再分四步落地——退役 `cache.set/get_output_dir` 跨进程持久化(session 创建/恢复一律 `default_output_dir(repo_path)` 布局推导)→ 移除写路径工具的 `output_dir` 参数(handler 加降级校验)、只读检索保留跨仓显式寻址 → 同步 workspace 模板/AGENTS 注入/prompts/hook/docs 文案(第二跳改 repo_path)→ 适配测试并新增外来目录污染回归,全量跑绿。 + +请确认计划即可开始实施;如需调整范围或次序也可以直接提出。 +user: 两跳也可以只传REPO_PATH吧 +assistant: 问得好——这正是把"纯函数"贯彻到底的体现。先核实 `query_wiki` 现在的参数面是否已经能直接吃 `repo_path`,再回答可不可行。 +assistant: `handle_query_wiki` 已能收 `repo_path`(`note_query.py:801-805` 走 `default_output_dir` 布局推导)。关键是解析顺序里 `session` 优先于 `repo_path` 参数——需确认 session 恢复逻辑,判断第二跳传 `repo_path` 是否会被 harness session 掩盖。 +assistant: 第二跳链路已核实清楚。再看一处:MCP 层是否对 `repo_path` 有默认注入(决定第一跳能否零参数直接查本仓)。 +assistant: 确认结论:**对,两跳都可以只传 `repo_path`,而且这是收敛后的最优写法**。核实证据如下: + +**`query_wiki` 已原生支持 `repo_path` 寻址**(`codewiki/mcp/tools/note_query.py:801-805`)——无显式 `output_dir` 时经 `default_output_dir(repo_path)` 布局推导。且两跳各自的路由结果收敛一致: + +| 跳 | 写法 | 解析路径 | 结果 | +|---|---|---|---| +| 一跳(harness 产品级) | `query_wiki(query=…, repo_path=)` | session 恢复或布局推导 | harness repowiki | +| 二跳(业务仓深度) | `query_wiki(query=…, repo_path=<业务仓目录>)` | 业务仓有 DB→session;无 DB→`default_output_dir` 兜底 | 业务仓 repowiki | + +两条路径殊途同归的原因正是收敛核心——**output_dir 是 repo_path 的纯函数**,不管走 session 还是推导,指向同一个目录(`codewiki/mcp/session.py:156-211` 的 `find_or_restore` 在业务仓无缓存时返回 `None`,恰好落到 `repo_path` 推导分支,语义自洽)。 + +第一跳甚至可以不传任何参数:`registry.py:2956-2968` 已有 `_inject_repo_path_default`,零锚点时注入 server 启动 CWD(harness 根)走布局推导。但模板里显式写 `repo_path=` 更可移植(示例可复制)。 + +**唯一保留显式 `output_dir` 的例外**:目标知识库不属于任何仓库布局(无宿主仓的孤立 wiki 目录)——没有 `repo_path` 可表达,只读直接寻址兜底。 + +因此对方案模板段的精确化(执行时按此落地): +- `agents-md-workspace.md.tpl` 两跳统一写成 `query_wiki(query=..., repo_path= / <业务仓目录>)`,第二跳不再出现 `output_dir=`; +- `repo-map.md.tpl` / `readme.md.tpl` 示例同步; +- centralized 模板本就是一跳(成员仓指向 workspace 共享 corpus),验证无 `output_dir=` 残留即可。 + +计划其余部分不变。确认后我就开始执行这版方案。 +user: 开始执行 +assistant: 用户确认执行。将计划置为 building 并启动第一个盘点任务。 +assistant: 子代理盘点未产出完整清单(中途返回)。改为并行收口:我直接精确 grep L1-L3 代码面,同时再派一个聚焦 L4 文案/测试面的子代理。 +assistant: 子代理未产出清单,改为亲自定向收集关键缺项。 +assistant: 分析层多出一条持久化链路(`project.json` 的 output_dir 映射)。继续锁定 registry 结构、注入调用点与 store_bridge 消费链。 +assistant: 信息已足够形成清单骨架。补最后两块:`project.json` 读取方(第二条 output_dir 持久化消费链)与 dispatch 参数处理。 +assistant: 盘点面已齐。产出收敛清单文件固化盘点结果,并推进下一个任务。 +assistant: 开始 todo2 退役持久化。先精确查 `set/get_output_dir` 全部调用点与 `default_output_dir` 定义。 +assistant: 调用点确认只有 `session.py:192`(读)与 `analysis.py:276/518`(写)。先确认 `default_output_dir` 无副作用、`session.py` 顶部结构,再动刀。 +assistant: `default_output_dir` 无副作用(仅布局推导)。开始退役编辑(3 个文件 5 处)。 +assistant: 第 5 处写锁超时,重读后重试。 +assistant: 第二处退役完成。检查 lints 并准备在 `workspace_layout.py` 加上共享 `is_foreign_output_dir` helper。 +assistant: cache.py 的 lint 错误是**存量历史问题**(行号都与本次删除区无关,如 176/199/431 等旧错误),非本次引入。继续:在 `workspace_layout.py` 追加共享 helper。先定位 `default_output_dir` 函数体。 +assistant: 插入共享 helper 到 `workspace_layout.py`(`default_output_dir` 之后): +assistant: todo2 主体完成。验证无残留引用并跑 lint: +assistant: todo2 干净收尾(lint 0,无残留引用)。推进 todo3:先看 `store_bridge.resolve_output_dir`(多数 handler 汇聚点)与 registry 工具定义结构。 +assistant: `registry.py` 是 schema 集中注册处。先解析工具名↔行号映射,并确认 `query_wiki` schema 的确切归属。 +assistant: 48 个工具 schema 全在 `registry.py`。在设计 handler 收敛方式前,先统计 `store_for` / `resolve_output_dir` 的调用分布(决定收敛落点的缺省语义)。 +assistant: store_bridge 消费者全为写/管理工具,可安全收敛其默认语义。先改 `store_bridge.resolve_output_dir` 为写侧推导语义: +assistant: `store_bridge` 需要 `logging` import。补上: +assistant: 现在处理 `registry.py` 的 37 处 `output_dir` schema。分段读取相关区块以精确构造删除。 +assistant: 上下文里已有 `100-500` 与 `800-1120` 区域。继续读取剩余两段以拿全所有 `output_dir` 块: +assistant: `registry.py` 结构已完全摸清。为精确且不破坏语法地删除 30+ 处 `output_dir` schema 块,我用一次性脚本做转换(dry-run 先行验证): +assistant: 脚本工具块边界括号计数被 description 文本干扰。改用更稳的方式:每个 `output_dir` 键**向前**找最近的 `name=` 判定归属,属性块内括号平衡即可。同时脚本用绝对路径。 +assistant: Dry-run 全部命中、语法通过。执行落地: +assistant: 落地完成。验证 registry 可导入 + 查看 diff 摘要: +assistant: registry 干净(48 工具可导入)。继续 handler 层:看写工具 handler 里 `output_dir` 的实际解析写法,找共性以便统一收敛。 +assistant: Handler 解析点分布广泛,但多数已通过 `store_bridge` 收敛。逐批处理仍直接 honor 显式 `output_dir` 的核心写工具。先读四处关键解析段: +assistant: 写 handler 解析点分散,统一收敛到 `store_bridge.resolve_output_dir`(已含 session 优先+忽略外来告警)。先读全三处待改段的精确上下文: +user: ### 6. All user messages + +1. "B激进方案一步到位,输出修改方案" +2. "两跳也可以只传REPO_PATH吧" +3. "开始执行" + +### 7. Pending Tasks + +- **todo3-in-progress**:完成 handler 解析重写,把显式 `output_dir` 分支收敛为 `resolve_output_dir(session, arguments)`(已读 `note_ingest.py:219`、`note_lifecycle.py:66-99/182-191` 待改;`doc_writer` +assistant: 继续执行 todo3(handler 解析收敛)。先确认当前代码状态和已完成改动。 +assistant: 先盘点当前 `output_dir` 在代码中的残留消费点,确认已完成的改动范围。 +assistant: `store_bridge.py` 收敛 helper 已完成。现在查看核心文件的当前状态,确认 schema 定义与 handler 的改动进度。 +assistant: 仓库改动面很大,先系统梳理 registry 中所有工具 schema 与 handler 的当前状态。用 outline 看 registry 结构。 +assistant: `registry.py` 中还有大量 `output_dir` schema。先看下代码仓库整体清单文件(.output_dir-convergence-checklist.txt),理解当前进度。 +assistant: 清单确认。现在核实各 handler 文件当前的实际状态,判断哪些已收敛、哪些待改。 +assistant: todo2 已基本完成。现在看 handler 解析层的改动细节。先读关键文件的相关区段与 dispatch 的 ValueError 处理。 +assistant: handler 层与 dispatch 语义已确认。todo3 的范围很大,先并行推进:一边盘点 registry 中每个工具 schema 的 output_dir 状态,一边查看已改动文件清单。 +assistant: 盘点结果清晰:schema 层只剩 `lint_wiki`(802-805) 仍暴露 `output_dir`,另有三处文案/副本残留;handler 层还有大量裸解析。先批量读取需要改动的文件上下文。 +assistant: 现在读取其余待收敛 handler 的解析片段,分批并行。 +assistant: 让我看一下 `lint_wiki` handler 主体与 `workspace_layout` helper 的当前实现,确认收敛函数的可用语义。 +assistant: handler 改造是 todo3 核心。逐文件确认上下文后统一收敛为 `resolve_output_dir`。先并行读取剩余文件的解析区段。 +assistant: 需要确认 `resolve_session` 的语义以正确收敛。读取 `workspace_result.py` 与 `analysis.py` 已收敛的现状。 +assistant: 需要精确的 handler 上下文。继续并行读取待改文件的关键区段。 +assistant: 改动面大,先精读 `store_bridge.py` 中 `resolve_output_dir` 的当前完整实现,以及待改 handler 的准确上下文,避免错误改写。 +assistant: 让我精确定位 registry 中 schema 属性定义与残留位置。 +assistant: handler 收敛需要弄清 dispatch 的异常处理。读取 registry 的 dispatch 部分与已改动概览。 +assistant: todo1 的收敛清单在 `.output_dir-convergence-checklist.txt`,读取它以获取精确的目标映射。 +assistant: 让我精确定位 registry 各工具注册名行号,并读 workspace_layout / session 当前态。 +assistant: schema 层已删 101 行但仍有残留。精读各残留位置的上下文判断读写类别。 +assistant: 现在 schema 残留状态清楚了。整理 L3 待处理清单: + +**写路径(schema 移除 output_dir)**: +- `lint_wiki` registry.py:802-805 仍暴露(保留 repo_path) +- `close_session.py`(模块自带 schema)— 需看,registry 内 close_session schema output_dir 已删?diff 显示 -101 行已删了一部分 + +**只读(保留 output_dir)**: +- `query_wiki`:1046 ✓(保留) +- `query_cross_service`:2060-2062 ✓(只读,保留) +- `wiki_stats`:2348 ✓(只读,保留) +- `get_module_tree`:2140 ✓(legacy 只读,但 default 是 `repowiki`,保留) +- `get_prompt`:455 ✓(prompt 只读 bundle,保留) + +注意 query_cross_service 的 description 是 workspace_path 相关,但它是只读 query,保留 output_dir。 + +而 `workspace_path` 附近 2057 的描述也还在说 "Auto-derives output_dir"。这是只读 query 的 output_dir,OK。 + +generate_docs legacy 写(保留 legacy 原样还是收敛?generate_docs 是写路径 —— 它内部 `handle_generate_docs` 中 `repo_path` 必须、`output_dir` default repowiki。计划写路径移除 schema output_dir,generate_docs schema 已经没有了 output_dir?看 registry.py 2087-2121 generate_docs schema 中已经没有 output_dir 属性了。但 legacy_tools.py 的模块内 TOOLS 副本(36-42)还有,handler 内部 `arguments.get("output_dir", "repowiki")` 也用。由于 schema 层已无 output_dir(registry),handler 容错默认 repowiki 相对 repo_path 可保留。 + +好,registry.py:802-805(lint_wiki)是唯一真正残留的写路径 schema output_dir。 + +close_session 是模块内 schema?看盘点 L3 第 40 行:`codewiki/mcp/tools/close_session.py:31 TOOLS=[Tool(name="close_session",...)]`。但我之前 grep registry.py 没有 close_session schema output_dir? registry.py:473 close_session. 让我确认 close_session 工具 schema 定义在 registry 还是模块内。 + +刚才 search registry.py:57 行附近的 `output_dir` property 定义只出现在 tools 文件 close_session.py。说明 close_session schema 可能在模块内定义!需要看 close_session.py:31 附近。 + +还需要确认 L3 每处 output_dir 描述是否已从 registry 中移除,除了 lint_wiki。从 9 处 schema property 结果: +1. legacy_tools.py:39 (generate_docs TOOLS 模块内副本 — 但 registry generate_docs 已无该属性) +2. legacy_tools.py:76 (get_module_tree 模块内副本) +3. registry.py:455 get_prompt (bundle 目录 - 保留?get_prompt 读 prompt templates from bundle output_dir) +4. registry.py:802 lint_wiki — 写路径残留,移除 +5. registry.py:1046 query_wiki — 保留(只读寻址) +6. registry.py:2060 query_cross_service — 保留(只读) +7. registry.py:2140 get_module_tree — 保留(只读 legacy) +8. registry.py:2348 wiki_stats — 保留(只读统计) +9. close_session.py:57 — 待确认(写生命周期,应移除) + +先看 close_session 模块 schema 定义与 registry 的 close_session。 + +看 registry close_session 附近473-524 区域,看看 registry schema 是否已经移除 output_dir。刚才 diff stat 中 registry -101 行,可能就是已移除大部分 output_dir schema。让我读 registry close_session 与 wiki_stats 描述。 + +不过 L3 schema 已通过 todo3? 这一部分我需要谨慎判断到底哪些已经改过。git diff registry.py 已 -101。看具体 diff。但 git diff 已看 stat。我读 registry 剩余 output_dir 描述的区域需要判断是否从属写工具。剩余的 schema property 位置在文件当前内容:455(get_prompt), 802(lint_wiki), 1046(query_wiki), 2060(query_cross_service), 2140(get_module_tree legacy), 2348(wiki_stats)。close_session.py:57 是模块内。 + +但注意 registry.py:473 close_session 中 output_dir 已不存在?在 9 处 property 结果中未包含 registry close_session。那可能是 registry close_session 无 output_dir 属性,而 close_session.py 内 57 行是模块自带的副本 Tool? 但也可能 close_session.py 只定义 Tool 在别处被使用。 + +需要读 close_session.py 的 TOOLS 定义与 registry.py close_session 定义确认重复性。 + +还有从 grep 中 registry.py:57 output_dir 附近? 没有。registry.py 473 close_session 工具。让我读。 + +现在既然 L2 handler 层是大量待办,重新审视:todo3 的目标应该是: +- registry/schema 移除写路径 output_dir 参数(剩余 lint_wiki registry:802-805 + 若有 close_session 模块 schema) +- handler 加降级校验(写路径 handler 内部对显式 output_dir 采用 resolve_output_dir 收敛,忽略/告警) +- query 只读寻址保留(这些保持 schema output_dir) + +这里有一个重要的张力:如果 registry schema 已把写工具 output_dir 移除,但 handler 仍读 arguments.get("output_dir") 并使用之,那旧客户端继续传 output_dir 仍会导致 handler 把显式 output_dir 当权威路径。因此 handler 必须改为:session.output_dir 恒为推导;写 handler 内直接 output_dir = resolve_output_dir(session, arguments)。resolve_output_dir 写路径(allow_explicit=False)已忽略显式 output_dir(用 repo_path 推导;当只有 output_dir 无 repo 锚时保留 legacy)。 + +所以 todo3 的 handler 任务 = 将各写 handler 的 output_dir 解析统一替换成 resolve_output_dir(session, arguments)。 + +但要小心:写 handler 若调用 resolve_output_dir(session, args),当 session 非 None,直接返回 session.output_dir —— 不再允许 arguments 中的 repo_path 覆盖 session?session.output_dir 在 session 创建时就按 repo_path 推导,所以 session 恒正确。若传入不同 repo_path 怎么办?resolve_session(arguments, store) 若 session_id 存在则按 session_id 取。通常写 handler 只传 repo_path → find_or_restore 到该 repo session。于是 session.output_dir 对应 repo_path。OK 一致。 + +注意 store_bridge.resolve_output_dir 里 session 优先于显式 output_dir 和 repo_path——对"通过 session_id 复用 session 写别的 repo"场景,本就该用 session 自己的知识库目录。 + +现在看 handler 需要做的:对于无 session 仅 repo_path 的调用,resolve_output_dir 给出 default_output_dir(repo_path);若有显式 output_dir 不一致,仅告警。这正是"容忍层"。 + +因此 L2 中所有写工具 handler 应改为: +``` +session = resolve_session(arguments, store) +try: + output_dir = resolve_output_dir(session, arguments) +except ValueError as e: + return json.dumps({"error": str(e)}) +``` + +但有些 handler 内部已经把逻辑写复杂。为最小改动且保证收敛目标,我需要逐文件改造。 + +注意 store_bridge.resolve_output_dir 已存在而且被很多已改工具使用(capture_conversation/distill/task/source_ingest/consolidation 等,50-60 行 store_bridge diff)。而后续 handler 还需改造的有: + +1. `workspace_analyzer.py:452` analyze_workspace — 写/分析。但 analyze_workspace 用 workspace_path + output_dir,通过 workspace_path 推导。实际 analyze_workspace output_dir = workspace_path / "repowiki"? 需要看。 +2. `workspace_bootstrap.py:669` init_workspace 族(workspace_path-based)— 布局特殊。 +3. `doc_writer.py:1308/1616` — write_doc_file 两个入口。 +4. `note_ingest.py:219` — ingest_note。 +5. `note_lifecycle.py:66/99/182` — confirm/reject/batch_set_status。 +6. `batch_ingest.py:65`。 +7. `close_session.py:174` — close_session 生命周期。 +8. `wiki_lint.py:231` — lint_wiki。 +9. `evidence.py:85` — stamp_evidence/evidence query?(写是 stamp_evidence,读…)需要核。 +10. `module_tree.py:239` — save_module_tree/get_module_tree? 写 save_module_tree。 +11. `doctrine.py:181` — refresh/init doctrine(写)。 +12. `note_query.py:792` — **只读保留**。 +13. `cross_service.py:53` — **只读保留**。 +14. `issue_tracker.py:81` — flag_issue(写)。 +15. `prompt_server.py:378` — **只读保留**。 +16. `wiki_stats.py:52` — **只读保留**。 +17. `legacy_tools.py` — generate_docs/get_module_tree legacy。registry generate_docs 已无 output_dir;handler 保留 default repowiki 相对 repo_path,可收敛为 default_output_dir(repo_path)? legacy 简单处理保留原样即可,因为它们不强跨进程持久化? generate_docs 走 legacy CLI 本地调用。也许保留。get_module_tree 只读保留。 + +这相当多的文件。这是个大任务,但都是机械重写。我需要一一读取并编辑。 + +不过从计划来看,todo3 的完整度?之前 pending task 说"todo3-in-progress:完成 handler 解析重写,把显式 output_dir 分支收敛为 resolve_output_dir(session, arguments)(已读 note_ingest.py:219、note_lifecycle.py:66-99/182-191 待改;doc_writer..." + +说明 todo3 目标核心就是这些 handler 文件的收敛改写。当前刚做完 schema 盘点。我应推进 handler 层的重写。 + +由于 handler 文件多且各不相同,我按组处理,一次读完整文件相关函数并重写。为控制风险,用 read_file 完整读取每个文件需要重点改的部分。 + +先从 note_ingest.py 全文读一遍(可能要改造 handle_ingest_note)。然后再看 note_lifecycle 等。 + +为了加快,先并行读取所有待改文件的关键段落。文件行数可能较大,但我可以限定 offset。 + +现在将进行编辑。让我读取 note_ingest.py 完整、close_session.py 完整。note_ingest 是全功能 handler,改动大,需完整上下文。todo3 核心是 handler 收敛。先精读几个重点文件全貌,再统一改写。 +assistant: 上下文过大,已进行多次自动总结但上下文大小仍超出限制。请检查 MCP 服务器或 Skills 是否注入了过多内容,建议减少启用的 MCP 服务器或 Skills 数量,以避免过量消耗 Token。 diff --git "a/repowiki/conversations/conv-\345\256\211\350\243\205\346\212\200\350\203\275\357\274\232https-github.com-blader-humanizer-tree-main.md" "b/repowiki/conversations/conv-\345\256\211\350\243\205\346\212\200\350\203\275\357\274\232https-github.com-blader-humanizer-tree-main.md" new file mode 100644 index 0000000..1aec771 --- /dev/null +++ "b/repowiki/conversations/conv-\345\256\211\350\243\205\346\212\200\350\203\275\357\274\232https-github.com-blader-humanizer-tree-main.md" @@ -0,0 +1,157 @@ +--- +type: Conversation +title: "conversation 20260905T114828Z" +status: distilled +stale_after: 2026-12-04 +generated: { by: codewiki/5.6.0, at: 2026-09-05T11:48:28Z } +captured_at: "2026-09-05T11:48:28Z" +content_hash: "sha256:67aaf5782faf33886e3f41e55ba2e4a34bd6bc628d9f1d9729895ec331d7d99e" +friction_score: 5 +friction_signals: "correction=0,interrupt=0,repeat=0,user_turns=6" +keep_raw: false +link_to: "" +source_session: "323a382c8bd94992ab8d74cb91c32ce7" +task_id: "他山之石" +turn_count: 23 +--- + +# Conversation Transcript + +user: 安装技能:https://github.com/blader/humanizer/tree/main +user: 任务关联 + +本次会话要关联到哪个任务?(任务记忆用于跨会话延续上下文) + +关联已有任务(在下方 Q2/Q3 中点选一个进行中的任务) + + + +已有任务(A 组)—— 若 Q1 选了「关联已有任务」,请在 A/B 组中点选一个 + +另一组 + + + +已有任务(B 组) + +他山之石 + + + + +user: 继续 +assistant: 用户选择了「他山之石」任务。我先建立会话绑定并拉取任务上下文,同时启动补蒸馏。 +assistant: 任务「他山之石」已绑定(此前已研究过 caveman/ponytail 类技能,正好延续)。积压 1 条 raw(ponytail 研究对话),我先后台启动补蒸馏,同时把 humanizer 仓库拉下来。 +assistant: humanizer 结构与本机 skills 布局都清楚了。再确认仓库内 `agents/`、`scripts/` 内容以及现有技能目录的标准布局。 +assistant: humanizer 已克隆到 `.humanizer-tmp`。我先查看仓库结构和本机 skills 目录布局,确定安装方式。 +assistant: humanizer 是纯 `SKILL.md` 技能(v2.11.2,无 hooks),与本机已装的 caveman 布局一致。现在把它安装到用户级 skills 目录: +assistant: 外层 shell 把 `$dest` 变量提前展开了,改为直接使用绝对路径: +assistant: humanizer 技能安装完成。 + +**安装结果** + +- 目标:`C:\Users\Administrator\.codebuddy\skills\humanizer\` +- 内容:`SKILL.md`(30.8 KB,v2.11.2)+ `README.md` +- 方式:纯 `SKILL.md` 直接装用户级 skills 目录(与之前 caveman 的安装决策一致,仓库 v2.11.2 已无 hooks/symlink 依赖) + +**该技能是什么** + +`humanizer` 是一个「AI 文本去 AI 味」技能:基于 Wikipedia 的 *Signs of AI writing* 一文,按 5 大类 **35 种 AI 写作模式** 重写文本,使其像真人书写,同时严格不改变原意、不编造事实。适用场景:编辑/审阅夸张断言、销售腔、空泛来源、重复结构、AI 套路词、被动语态、填充语、聊天机器人痕迹等文本。 + +**使用方式** + +新开会话后即可通过技能触发(如「请把这段文字人性化:…」),或对文件路径操作时只改写散文、不动代码与 frontmatter。技能规则已声明于 `SKILL.md` frontmatter,加载后出现在可用技能列表中。 + +**待办确认** + +1. `.humanizer-tmp/` 临时 clone 目录是否删除?(上回 caveman 安装后你选择了删除临时目录) +2. 后台蒸馏 worker 正在为「他山之石」补蒸馏 1 条积压 raw(ponytail 研究对话),完成后我会在停顿点汇报产出。 + +另外,本次沿用了一条既有模式(纯 SKILL.md 装 `~/.codebuddy/skills/`),对应任务记忆里已有一条 draft 笔记待确认。需要我现在把本次 humanizer 安装的过程/结论也沉淀为一条笔记吗? +user: +his-distill-w1 +Message from his-distill-w1 + +「他山之石」任务补蒸馏完成汇报(Mode C,纯 MCP JSON + 文件侧通道)。 + +【本次处理】1 条积压对话: +- conv-https-github.com-DietrichGebert-ponytail-研究下这个技能是如何生效的(25 轮,ponytail 技能生效机制研究 + grill 融合方向拷问) + +【产出】 +- 新建草稿笔记 2 条(status=draft,待你与用户确认): + 1. ponytail 生效机制:单份 SKILL.md 规则经三层加载档位(T1 指令/T2 技能/T3 hooks)在宿主生效(architecture, priority=88) + 文件:repowiki/notes/2026-09-05-ponytail-生效机制单份-skillmd-规则经三层加载档位t1-指令t2-技能t3-hooks在宿主生效.md + 2. ponytail/caveman 融合 CodeWiki 的方向已定:hook 需加反向(注入)通道,三层分离设计,先私有验证再产品化(decision, priority=85) + 文件:repowiki/notes/2026-09-05-ponytailcaveman-融合-codewiki-的方向已定hook-需加反向注入通道三层分离设计先私有验证再产品.md +- 合并 1 条:ponytail 5 个可复用工程手法 → 与已有 stable 笔记高度同主题,以 dedup_action=merge 追加增量 section(fail-open 明文化/副作用最小化/debt 台账/Windows EOF issue #443/check-rule-copies.js 同源校验)进: + repowiki/notes/2026-09-04-agent-hook-注入的工程化防御与防漂移可复用模式caveman-提炼.md +- 去重抑制/丢弃:0(首轮 submit 触发 3 次弱重复冲突,逐一核对候选后判定:2 条为不同主题真实新知识 → store,1 条互补 → merge;conflicts_pending=0) +- 落盘记忆数:memories_written=3(任务进度已直写落盘,无需确认): + 1) ponytail 三层档位机制研究完成(本机 CodeBuddy 为 T2 无自动层) + 2) grill Q1–Q4 用户答复:Q1=b 产品能力/Q2=三层分离/Q3=C/Q4=C 先私有验证 + 3) 下一步:hook-probe 核实 CodeBuddy stdout 隐藏注入能力后开第二轮;方案未落地 + +【建议展示给用户的待确认草稿】上面 2 条 draft(决策 2 即 grill 方向记录,与当前主线直接相关;注意第一条 ponytail 机制笔记与该任务曾被用户拒绝的 caveman 机制笔记同类——均为"外部技能机制解剖",用户审阅时可能同样判定不需要,建议展示时带上这条上下文供其参考)。raw 已自动归档至 repowiki/conversations/。无失败项。 + + +assistant: 蒸馏 worker 已完成补蒸馏并汇报。我先收尾 worker,再向你展示待确认草稿。 +assistant: 「他山之石」补蒸馏完成。汇总如下: + +**蒸馏产出(1 条积压 raw:ponytail 技能生效机制研究,25 轮)** + +- **记忆 3 条**(已直写落盘,无需确认):ponytail 三层档位机制研究完成;grill Q1–Q4 用户答复(Q2=三层分离、Q4=先私有验证);下一步 hook-probe 核实 CodeBuddy stdout 隐藏注入后开第二轮。 +- **合并 1 条**:ponytail 5 个可复用工程手法并入既有 stable 笔记 `2026-09-04-agent-hook-注入的工程化防御与防漂移可复用模式`。 + +**2 条草稿笔记待你确认**(确认前仅作只读参考): + +| # | 类型 | 标题 | 备注 | +|---|------|------|------| +| 1 | architecture | ponytail 生效机制:单份 SKILL.md 规则经三层加载档位(T1 指令/T2 技能/T3 hooks)在宿主生效 | 与本任务中你**已拒绝**的 caveman 机制笔记同类(外部技能机制解剖),可能同样不需要 | +| 2 | decision | ponytail/caveman 融合 CodeWiki 方向已定:hook 加反向注入通道、三层分离、先私有验证再产品化 | 与当前主线(「他山之石」→ 借鉴融合)直接相关,但属于方案方向决策 | + +请你裁决这两条的去留: +- 可以两条都确认、都拒绝,或单独处理; +- 也可以说明想先看某条全文再决定。 + +另外此前两个待办仍挂着,一并确认: +1. `.humanizer-tmp/` 临时 clone 目录是否删除? +2. 是否把本次 humanizer 安装过程/结论沉淀为一条任务笔记? +user: +his-distill-w1 +Message from his-distill-w1 + +{"type":"shutdown_response","request_id":"shutdown-1788607364174","approve":true,"reason":"Auto-acknowledged by team-member runtime","from":"his-distill-w1","timestamp":"2026-09-05T11:22:46.213Z"} + + +assistant: 蒸馏 worker 已确认关闭,清理团队资源。 +assistant: 补蒸馏 worker 已关闭、团队资源已清理。 + +当前等待你裁决的事项(均无阻塞): + +1. **2 条草稿笔记去留**(上一条已列摘要):ponytail 机制笔记(可能与你拒绝的 caveman 同类)+ ponytail/caveman 融合方向 decision 笔记 +2. `.humanizer-tmp/` 临时目录是否删除 +3. 是否沉淀本次 humanizer 安装笔记 + +你回复后我继续执行。 +user: 不保留笔记 删除临时目录 不沉淀笔记 +assistant: 明白:两条草稿笔记不保留(reject)、删除 `.humanizer-tmp/`、不沉淀安装笔记。先查 `reject_note` 参数,同时执行清理。 +assistant: 用户裁决已明确。并行执行:reject 两条草稿 + 删除临时目录。 +assistant: 第一条已 reject,第二条文件名可能不准确,搜索实际文件名: +assistant: 第二条笔记搜索不到,查看 notes 目录实际内容: +assistant: 第二条 decision 草稿在 notes 目录中并不存在——按此前「蒸馏 subagent 自报不可信」的经验,用 `get_task_context` 的 related_notes 状态核实: +assistant: 执行结果核实完毕: + +**已完成** +1. ✅ 草稿①「ponytail 生效机制」→ 已 `reject_note`,状态 `deprecated`(从检索中排除) +2. ✅ 草稿②「ponytail/caveman 融合 decision」→ **实际未落盘**:用 `get_task_context` 的 related_notes 核实(自报不可信),notes 目录与任务关联中均不存在此文件,无副作用可清理,无需 reject +3. ✅ `.humanizer-tmp/` 临时目录已删除(`Test-Path` 返回 False) +4. ✅ 不沉淀本次安装笔记 +5. 任务记忆已由 worker 直写(ponytail 研究完成、grill Q1–Q4 答复、下一步 hook-probe 待办),`pending_raw_count=0`,本任务积压已清零 + +**一处遗留提示** + +任务还有一条**旧 draft**(2026-09-04 的 caveman 收尾遗留,一直「保留待定」未裁决): + +> `2026-09-04-在-codebuddy-使用跨-agent-技能纯-skillmd-直接装-codebuddyskillshooks-需...`(关于「纯 SKILL.md 直接装 `~/.codebuddy/skills/`」的结论,本次 humanizer 安装正好沿用了它) + +它不属于本次蒸馏产出,我未擅动。是否一并处理?(确认生效 / reject 排除 / 继续保留) diff --git "a/repowiki/notes/2026-08-15-mcp-server-\345\261\202\346\236\266\346\236\204\346\221\251\346\223\246\347\202\271\346\211\253\346\217\217\347\273\223\350\256\2727-\351\241\271\346\214\211\344\270\245\351\207\215\345\272\246\346\216\222\345\272\217.md" "b/repowiki/notes/2026-08-15-mcp-server-\345\261\202\346\236\266\346\236\204\346\221\251\346\223\246\347\202\271\346\211\253\346\217\217\347\273\223\350\256\2727-\351\241\271\346\214\211\344\270\245\351\207\215\345\272\246\346\216\222\345\272\217.md" index 0ccdd4f..fea566c 100644 --- "a/repowiki/notes/2026-08-15-mcp-server-\345\261\202\346\236\266\346\236\204\346\221\251\346\223\246\347\202\271\346\211\253\346\217\217\347\273\223\350\256\2727-\351\241\271\346\214\211\344\270\245\351\207\215\345\272\246\346\216\222\345\272\217.md" +++ "b/repowiki/notes/2026-08-15-mcp-server-\345\261\202\346\236\266\346\236\204\346\221\251\346\223\246\347\202\271\346\211\253\346\217\217\347\273\223\350\256\2727-\351\241\271\346\214\211\344\270\245\351\207\215\345\272\246\346\216\222\345\272\217.md" @@ -11,7 +11,7 @@ metadata: - distill_conversation - knowledge_loop - workspace - source_ref: raw\conv-manually_attached_skills-Please-use-the-use_skill-tool-to-in.md + source_ref: conversations/conv-manually_attached_skills-Please-use-the-use_skill-tool-to-in.md consolidated_into: - wiki/scenarios/MCP-Server薄壳架构与参数约定.md status: deprecated diff --git "a/repowiki/notes/2026-08-15-output-dir-\350\247\243\346\236\220\346\224\266\346\225\233\346\226\271\346\241\210resolve-workspace-\345\215\225\347\202\271-\344\274\230\345\205\210\347\272\247\347\273\237\344\270\200.md" "b/repowiki/notes/2026-08-15-output-dir-\350\247\243\346\236\220\346\224\266\346\225\233\346\226\271\346\241\210resolve-workspace-\345\215\225\347\202\271-\344\274\230\345\205\210\347\272\247\347\273\237\344\270\200.md" index 9cfb112..3165748 100644 --- "a/repowiki/notes/2026-08-15-output-dir-\350\247\243\346\236\220\346\224\266\346\225\233\346\226\271\346\241\210resolve-workspace-\345\215\225\347\202\271-\344\274\230\345\205\210\347\272\247\347\273\237\344\270\200.md" +++ "b/repowiki/notes/2026-08-15-output-dir-\350\247\243\346\236\220\346\224\266\346\225\233\346\226\271\346\241\210resolve-workspace-\345\215\225\347\202\271-\344\274\230\345\205\210\347\272\247\347\273\237\344\270\200.md" @@ -13,7 +13,7 @@ metadata: - capture_conversation - distill_conversation - knowledge_loop - source_ref: raw\conv-manually_attached_skills-Please-use-the-use_skill-tool-to-in.md + source_ref: conversations/conv-manually_attached_skills-Please-use-the-use_skill-tool-to-in.md consolidated_into: - wiki/scenarios/MCP-Server薄壳架构与参数约定.md status: deprecated diff --git "a/repowiki/notes/2026-09-07-consolidate-notes-\345\200\231\351\200\211-disposition-\344\270\211\345\200\274\346\234\272\345\210\266\346\234\252\345\205\245\351\200\211\347\254\224\350\256\260\344\270\215\345\206\215\346\227\240\345\243\260\346\273\236\347\225\231excluded-\345\277\205\345\241\253.md" "b/repowiki/notes/2026-09-07-consolidate-notes-\345\200\231\351\200\211-disposition-\344\270\211\345\200\274\346\234\272\345\210\266\346\234\252\345\205\245\351\200\211\347\254\224\350\256\260\344\270\215\345\206\215\346\227\240\345\243\260\346\273\236\347\225\231excluded-\345\277\205\345\241\253.md" new file mode 100644 index 0000000..3b9f982 --- /dev/null +++ "b/repowiki/notes/2026-09-07-consolidate-notes-\345\200\231\351\200\211-disposition-\344\270\211\345\200\274\346\234\272\345\210\266\346\234\252\345\205\245\351\200\211\347\254\224\350\256\260\344\270\215\345\206\215\346\227\240\345\243\260\346\273\236\347\225\231excluded-\345\277\205\345\241\253.md" @@ -0,0 +1,54 @@ +--- +type: decision +title: consolidate_notes 候选 disposition 三值机制:未入选笔记不再无声滞留,excluded 必填 reason +tags: +- codewiki +- decision +metadata: + date: 2026-09-07 + task_id: 他山之石 + related_modules: + - note_consolidation + - registry + - prompts + severity: medium + source_ref: conversations/conv-https-mp.weixin.qq.com-s-NwU98lA_P7LpDdyhhkt-cg-调研一下这篇文章,看看对.md + scene: note 聚合可审计性 +status: stable +author: iamwangbao-163-com +generated: + by: codewiki/5.6.1 + at: 2026-09-07 01:42:36+00:00 +stale_after: '2027-09-07' +origin: conversation +verified: +- by: codewiki/5.6.1 + at: '2026-09-07T01:47:01Z' +--- + +## Background + +有赞《KNOWLEDGE WIKI》一文提出「候选必须有去向,排除要写原因」(生成侧初始化时概念被丢没记录)。对照 CodeWiki,映射到**聚合侧**的同类缺口(方向相反):consolidate_notes 的 `_pending_confirmed_notes` 只按「status ∈ stable/confirmed 且无 consolidated_into」筛,溯源只写在入选者身上(`consolidated_into` 双向链接),**未入选者零标记**——既没有「为什么没入选」,也没有「这条已经判过」。后果:每次 prepare 都要重新权衡同一批笔记,无法区分「还没轮到」与「判定不值得聚合」;后者永久卡在 pending 顶着 `notes_since_last_consolidation` 计数器(实测 41 / 阈值 10,持续告警)。 + +## Decision + +用户确认实现「候选 disposition 三值机制」(verdict 三值、四处全做,含 excluded 从 pending 剔除以缓解计数器告警): + +- 复用既有收敛点 `_update_frontmatter_meta`(locked RMW)写回,不新建写回函数。 +- verdict 三值:`absorbed`(已入场景块,走现有 source_notes ⇄ consolidated_into,不用新增)、`deferred`(本轮不聚合,等素材/同类积累,reason 可选)、`excluded`(判定永不入场景块:一次性任务状态/个人偏好/临时上下文,**reason 必填**)。 +- 落盘形态:笔记 frontmatter `metadata.disposition: {verdict, reason?, at}`。 + +改动清单(4 处约 40 行): +1. `registry.py` consolidate_notes report 增加可选 `dispositions: [{file, verdict, reason?}]`; +2. `note_consolidation.py` submit 加校验与写回,`excluded` 无 reason 直接进 errors(沿用 `action=deleted` 要求正文为 [DELETED] 的校验风格); +3. `_pending_confirmed_notes` 候选筛选中 `excluded` 跳过不再进 pending,`deferred` 保留但回带 disposition 字段; +4. 提示词 `_CONSOLIDATE_SYSTEM` 与 `prompts.py` 各加「每条候选必须给出去向」。 + +## 明确不做 + +- `lint_wiki` 加「长期 deferred 告警」:覆盖完整性审查的落点在此,但阈值现在定是拍脑袋,等 disposition 积累几轮再定。 +- 不改 `distill_conversation`:蒸馏候选是 raw 对话,submit 后即删,不存在滞留问题。 + +## Rationale + +借鉴建议必须先过代码核对,且要纠正方向映射(文章是生成侧「候选无声消失」,我们是聚合侧「候选无声滞留」——同一可审计性缺口,方向相反)。最小做法是复用 `_update_frontmatter_meta`(note_consolidation.py:134,locked RMW),不新建写回路径。 diff --git "a/repowiki/notes/2026-09-07-output-dir-\346\230\257-repo-path-\347\232\204\347\272\257\345\207\275\346\225\260\345\206\231\350\267\257\345\276\204\344\270\200\345\276\213\345\270\203\345\261\200\346\216\250\345\257\274\347\240\215\350\267\250\350\277\233\347\250\213\346\214\201\344\271\205\345\214\226\345\217\252\350\257\273\346\243\200\347\264\242\344\277\235\347\225\231\350\267\250\344\273\223\345\257\273\345\235\200\345\207\272\345\217\243.md" "b/repowiki/notes/2026-09-07-output-dir-\346\230\257-repo-path-\347\232\204\347\272\257\345\207\275\346\225\260\345\206\231\350\267\257\345\276\204\344\270\200\345\276\213\345\270\203\345\261\200\346\216\250\345\257\274\347\240\215\350\267\250\350\277\233\347\250\213\346\214\201\344\271\205\345\214\226\345\217\252\350\257\273\346\243\200\347\264\242\344\277\235\347\225\231\350\267\250\344\273\223\345\257\273\345\235\200\345\207\272\345\217\243.md" new file mode 100644 index 0000000..a86b2a3 --- /dev/null +++ "b/repowiki/notes/2026-09-07-output-dir-\346\230\257-repo-path-\347\232\204\347\272\257\345\207\275\346\225\260\345\206\231\350\267\257\345\276\204\344\270\200\345\276\213\345\270\203\345\261\200\346\216\250\345\257\274\347\240\215\350\267\250\350\277\233\347\250\213\346\214\201\344\271\205\345\214\226\345\217\252\350\257\273\346\243\200\347\264\242\344\277\235\347\225\231\350\267\250\344\273\223\345\257\273\345\235\200\345\207\272\345\217\243.md" @@ -0,0 +1,51 @@ +--- +type: decision +title: output_dir 是 repo_path 的纯函数:写路径一律布局推导、砍跨进程持久化、只读检索保留跨仓寻址出口 +tags: +- decision +metadata: + date: 2026-09-07 + task_id: 他山之石 + related_modules: + - workspace_layout + - cache + - session + - registry + - note_query + severity: high + source_ref: conversations/conv-manually_attached_skills-Please-use-the-use_skill-tool-to-in.md + scene: MCP 工具参数面设计 +status: stable +author: iamwangbao-163-com +generated: + by: codewiki/5.6.1 + at: 2026-09-07 01:41:34+00:00 +stale_after: '2027-09-07' +origin: conversation +verified: +- by: codewiki/5.6.1 + at: '2026-09-07T01:47:00Z' +--- + +## Background + +用户质疑:REPO_PATH 与 OUTPUT_DIR 什么情况下需要自定义,能否删掉 output_dir 或只保留 repo_path,输出目录写死 repowiki?此前发生过跨仓库 output_dir 劫持清空真实索引的事故,深层原因是 output_dir 被跨进程持久化。 + +## 决策 + +字面"写死 `/repowiki`"不可行——会废掉 centralized 布局(业务仓没有自己的 repowiki)与 workspace 第二跳跨仓寻址。正确收敛(用户选定 B 激进方案): + +1. 写路径收敛:analyze/write/ingest/close 等工具的 `output_dir` 移出必选参数面,一律 `default_output_dir(repo_path)` 布局推导;显式 output_dir 降级为 legacy 覆盖并拒绝指向 repo 外。 +2. 砍掉跨进程持久化:删除 `cache.repo_meta.output_dir`,`find_or_restore` 恢复 session 时用布局推导;`set_output_dir` 不再存在,smoke 污染在机制上不可能。 +3. 只读检索保留唯一独立寻址出口:`query_wiki`/`note_query` 允许 output_dir 指向任意 wiki(含别的仓)——workspace 两跳路由刚需,且只读不产生污染。 + +## Rationale + +- 代码中已有唯一布局感知推导函数 `default_output_dir(repo_path)`(`workspace_layout.py`);所有工具解析链第三级已是"repo_path 能唯一确定目录",只是布局感知而非字面写死。 +- 两跳都可只传 repo_path:`query_wiki` 原生支持 repo_path 寻址(`note_query.py`),无显式 output_dir 时经 `default_output_dir` 布局推导;`find_or_restore` 在业务仓无缓存时返回 None 恰好落到 repo_path 推导分支。 +- 唯一保留显式 output_dir 的例外:目标知识库不属于任何仓库布局(无宿主仓的孤立 wiki 目录)。 +- session.output_dir 应恒为推导值;写 handler 统一 `output_dir = resolve_output_dir(session, arguments)`(allow_explicit=False 时忽略显式 output_dir,仅告警不一致)。 + +## 待确认范围 + +影响面:53 个工具文件引用 output_dir,绝大多数是透传 store_bridge;真正改 schema 描述、handler 解析、session 恢复、`cache.py` 三处点 + 测试断言。centralized/colocated 回归测试(`test_centralized_layout_fixes` 等)可兜底。 diff --git "a/repowiki/notes/2026-09-07-skill-creator-\345\267\245\345\205\267\350\256\276\350\256\241\345\256\232\346\241\243scenario-\347\233\264\350\257\221-mode-c-\344\270\244\345\214\272\345\210\266\347\241\256\350\256\244\351\227\270\351\227\250\344\270\215\345\273\272\350\207\252\345\212\250\350\257\204\345\210\206\351\227\250\346\216\247.md" "b/repowiki/notes/2026-09-07-skill-creator-\345\267\245\345\205\267\350\256\276\350\256\241\345\256\232\346\241\243scenario-\347\233\264\350\257\221-mode-c-\344\270\244\345\214\272\345\210\266\347\241\256\350\256\244\351\227\270\351\227\250\344\270\215\345\273\272\350\207\252\345\212\250\350\257\204\345\210\206\351\227\250\346\216\247.md" new file mode 100644 index 0000000..8066a00 --- /dev/null +++ "b/repowiki/notes/2026-09-07-skill-creator-\345\267\245\345\205\267\350\256\276\350\256\241\345\256\232\346\241\243scenario-\347\233\264\350\257\221-mode-c-\344\270\244\345\214\272\345\210\266\347\241\256\350\256\244\351\227\270\351\227\250\344\270\215\345\273\272\350\207\252\345\212\250\350\257\204\345\210\206\351\227\250\346\216\247.md" @@ -0,0 +1,52 @@ +--- +type: decision +title: skill-creator 工具设计定档:scenario 直译 + Mode C + 两区制确认闸门,不建自动评分门控 +tags: +- codewiki +- decision +metadata: + date: 2026-09-07 + task_id: 他山之石 + related_modules: + - skill_creator + - note_consolidation + - note_types + severity: medium + source_ref: conversations/conv-manually_attached_skills-Please-use-the-use_skill-tool-to-in.md + scene: 技能产物类型建设 +status: stable +author: iamwangbao-163-com +generated: + by: codewiki/5.6.1 + at: 2026-09-07 01:41:38+00:00 +stale_after: '2027-09-07' +origin: conversation +verified: +- by: codewiki/5.6.1 + at: '2026-09-07T01:47:01Z' +--- + +## Background + +调研 wikiskill(arXiv:2608.27454 开源实现)后评估 CodeWiki 能否自动生成 SKILL。用户授权 Q1–Q4 全按推荐,先定档产出 `repowiki/wiki/comparisons/自动生成SKILL可行性-…md`,随后深化设计产出 `repowiki/wiki/queries/skill-creator设计方案.md`。 + +## 决策 + +- 可行性:能实现。CodeWiki 已走完"经验→结构化知识"半程(蒸馏已有),缺口集中在"技能产物类型"+"生成工具"两个新增件,非重构。 +- 推荐形态 = 半闭环:MVP 先做单向编译器(confirmed notes/scenarios → SKILL.md draft → 确认闸门 → `.codebuddy/skills/`),稳定后再接回流迭代。 +- 不建自动评分门控(无 held-out 基准,确认闸门已担质量职责);生成走 Mode C(工具簿记、宿主 agent 写正文);先仓库内闭环再家族分发。 +- 落地顺序:skill 词汇 → skill_creator 工具 → SKILL.md lint → 闭环验证 → 回流。 + +## 关键设计点(Q6–Q11 定稿) + +- 素材:scenario 直译为主(每份已确认 scenario → 一份 SKILL),单条高价值 pitfall 作补充(走 `_pending_confirmed_notes` 同类扫描)。 +- 入口:prepare 列候选清单(默认 UPDATE 优先、最多新建 1 份/批),同时支持 topic/sources 显式指定;不做全自动(违反"触发永远显式")。 +- **两区制**(最关键):草稿落 `repowiki/skills//SKILL.md`(不生效、进索引、进 lint),确认后 install 到 `.codebuddy/skills/`(生效)。直接落 `.codebuddy/skills/` 并标 status:draft 不可行——草稿期坏技能会在确认前就影响 agent 行为。 +- 更新:重写 + frontmatter `metadata.revisions` 记录变更(不做 unified diff 引擎);退役沿用 reject_note 语义(草稿区标记 deprecated + 生效区 uninstall)。 +- 回流验证:note 反馈 + 薄 frontmatter(`metadata.effectiveness.last_evaluated_at`);超期(建议 90 天)列为 UPDATE/retire 候选。 +- lint:草稿区必须纳入 `lint_wiki`;生效区容量硬顶 12;必检 `description` 非空含触发条件、`name` 匹配 `^[a-z0-9-]+$`、无绝对路径/密钥、正文 ≤4000 字、`source_refs` 目标存在、`status ∈ {draft, stable, deprecated}`。 + +## Rationale + +- 复用 `note_consolidation.py` 作为平行工具模板(Mode C prepare/submit + 容量 + 双向溯源 + 软删除 + 索引重建);`note_types.py` 是 note_type 权威表可扩展。 +- scenario 页章节骨架(工作场景/适用条件/核心 SOP/判断逻辑/禁忌)约等于 SKILL.md 正文骨架;`description` 天然对应"适用条件"。不建技能索引会重复生成(skill_creator 需 scenario_index 对等件)。 diff --git "a/repowiki/notes/2026-09-07-\350\267\250\344\273\223\345\272\223-output-dir-\345\212\253\346\214\201-session\351\207\215\345\273\272\346\243\200\347\264\242\347\264\242\345\274\225\350\242\253\346\270\205\347\251\272\345\241\253\345\205\245-smoke-\346\265\213\350\257\225\346\226\207\346\241\243\346\240\271\345\233\240\351\223\276\344\270\216\344\270\211\345\261\202\351\230\262\346\212\244.md" "b/repowiki/notes/2026-09-07-\350\267\250\344\273\223\345\272\223-output-dir-\345\212\253\346\214\201-session\351\207\215\345\273\272\346\243\200\347\264\242\347\264\242\345\274\225\350\242\253\346\270\205\347\251\272\345\241\253\345\205\245-smoke-\346\265\213\350\257\225\346\226\207\346\241\243\346\240\271\345\233\240\351\223\276\344\270\216\344\270\211\345\261\202\351\230\262\346\212\244.md" new file mode 100644 index 0000000..d46aa0c --- /dev/null +++ "b/repowiki/notes/2026-09-07-\350\267\250\344\273\223\345\272\223-output-dir-\345\212\253\346\214\201-session\351\207\215\345\273\272\346\243\200\347\264\242\347\264\242\345\274\225\350\242\253\346\270\205\347\251\272\345\241\253\345\205\245-smoke-\346\265\213\350\257\225\346\226\207\346\241\243\346\240\271\345\233\240\351\223\276\344\270\216\344\270\211\345\261\202\351\230\262\346\212\244.md" @@ -0,0 +1,57 @@ +--- +type: pitfall +title: 跨仓库 output_dir 劫持 session:重建检索索引被清空填入 smoke 测试文档(根因链与三层防护) +tags: +- pitfall +- sessionstore +metadata: + date: 2026-09-07 + task_id: 他山之石 + related_modules: + - cache + - wiki_search + - session + - store_bridge + severity: high + source_ref: conversations/conv-manually_attached_skills-Please-use-the-use_skill-tool-to-in.md + scene: 检索索引维护 +status: deprecated +author: iamwangbao-163-com +generated: + by: codewiki/5.6.1 + at: 2026-09-07 01:41:26+00:00 +stale_after: 2027-03-06 +origin: conversation +reject_reason: 用户评审拒绝收录 +--- + +## Background + +新写入 `wiki/queries/` 的文档 `query_wiki` 检索不到,而 `wiki/comparisons/` 的能搜到——这是假象,真实原因是整个 repo 根检索索引被污染后清空重填。 + +## 根因链(5 环) + +1. 某次 smoke/harness 运行以 `repo_path=<真实仓库>` + `output_dir=/codewiki_smoke_*` 调 `analyze_repo`,`cache.set_output_dir()` 把这个跨仓库临时目录持久化进 repo 根 DB 的 `repo_meta.output_dir`。 +2. `SessionStore.find_or_restore()` 恢复 session 时优先用 `cache.get_output_dir()`(smoke 路径)而非 `/repowiki` fallback → 后续无显式 output_dir 参数的会话全部被劫持到 smoke 目录。 +3. `write_doc_file` 等工具因 session.output_dir 劫持把文档误写入 smoke 目录;`close_session` 重建时经 session 共享 cache(repo 根 DB)执行 `build_search_index(smoke)` → 真实索引(应有 284 文件)被清空,填入 smoke 目录仅 7 个测试文档。 +4. comparison 页恰好在 smoke 目录也有误写副本,故验证"通过"是命中副本;query 页只写进真实 repowiki → 搜不到。 +5. 铁证:索引 `total_docs=7` vs 磁盘 284 个 md;索引里 5 个 doc 对应文件在真实 repo 中不存在(`test_doc.md`、`smoke-test-decision-*` 等)。 + +## 修复(三层) + +- 数据层:用真实 output_dir 重建索引(7→207 docs);删除被污染的 `repo_meta.output_dir` 记录。 +- 代码层 1:`cache.py` 的 `set_output_dir` 拒绝 repo 外 output_dir(当 repo 自己拥有 `repowiki/` 时);`get_output_dir` 恢复时同样拦截——外来路径不再被持久化/继承(centralized 合法场景保留)。 +- 代码层 2:`wiki_search.py` 新增 `_cache_serves_output_dir` 归属校验 helper,4 处 `session.cache` 使用点(build/update/search/coverage)仅在 cache DB 确实归属该 output_dir 时生效——纵深防御,防 session 缓存再次清空他库索引。 +- 运行层:`close_session(force, output_dir=repowiki)` 清理 server 内存中被劫持的 smoke session(TTL 2h,`find_or_restore` 优先返回活跃内存 session)。 + +## 验证与回归 + +- 端到端:MCP `query_wiki(repo_path)`(不带 output_dir)命中新写页面(top 18.97)。 +- 模拟:smoke 场景被 `set_output_dir` 拒绝、repo 内目录正常、归属校验 smoke=False/repowiki=True。 +- 回归:`test_index_freshness` + `test_centralized_layout_fixes` + `test_change_analysis` = 50 passed。 + +## 启示 + +- `output_dir` 解析顺序 session.output_dir > 显式参数 > repo_path fallback(`store_bridge.resolve_output_dir`),session 被污染则一切无显式参数调用被劫持。 +- 代码防护需 MCP server 重启后加载(旧进程跑旧代码),数据与 session 状态修复即时生效。 +- `write_doc_file` 落盘 ≠ 可检索:检索可达性取决于索引是否真覆盖该目录。 diff --git "a/repowiki/tasks/\344\273\226\345\261\261\344\271\213\347\237\263/memories/iamwangbao-163-com.md" "b/repowiki/tasks/\344\273\226\345\261\261\344\271\213\347\237\263/memories/iamwangbao-163-com.md" index 7b03280..daa8a91 100644 --- "a/repowiki/tasks/\344\273\226\345\261\261\344\271\213\347\237\263/memories/iamwangbao-163-com.md" +++ "b/repowiki/tasks/\344\273\226\345\261\261\344\271\213\347\237\263/memories/iamwangbao-163-com.md" @@ -41,3 +41,15 @@ grill 拷问「ponytail/caveman 融合 CodeWiki MCP」第一轮 Q1–Q4 用户 ### 2026-09-05 23:39 2026-09-05(续):grill 第二轮 Q6-Q9 用户仍全按推荐,skill-creator 设计定档并落 wiki/queries/skill-creator设计方案.md。定档:①产物粒度=scenario 直译为主 + 未吸收的高价值 pitfall/lesson/decision 单条补充;②选材=prepare 列候选 + 显式 topic/sources 指定,禁止全自动触发(Doctrine 显式触发);③落盘=两区制,草稿 repowiki/skills/ 不生效 → 确认后 install 到 .codebuddy/skills/ 生效(单区 draft 标记方案否决:草稿期技能已被 IDE 触发,闸门形同虚设);④更新=整份重写 + metadata.revisions 变更记录段(不做 unified diff);⑤工具形态 skill_creator 四 mode:prepare/submit/install/retire,容量硬顶 12,批次最多新建 1 份。分工:consolidate_notes 产知识层 scenario,skill_creator 产行为层 SKILL,单向下游。未决:Q10 回流验证判定、Q11 lint 规则与容量取值。 + +### 2026-09-07 09:42 + +humanizer v2.11.2(AI 文本去 AI 味技能,基于 Wikipedia Signs of AI writing,5 大类 35 种 AI 写作模式)已按既有模式安装到用户级 C:\Users\Administrator\.codebuddy\skills\humanizer\(纯 SKILL.md + README,无 hooks/symlink 依赖,与 caveman 安装决策一致);.humanizer-tmp 临时 clone 已删除。 + +### 2026-09-07 09:42 + +用户裁决(2026-09-05):ponytail 机制草稿已 reject_note(status=deprecated);ponytail/caveman 融合 decision 草稿经 get_task_context 核实实际未落盘(蒸馏 subagent 自报不可信,related_notes 复核为凭)无需 reject;humanizer 安装过程明确不沉淀笔记。 + +### 2026-09-07 09:42 + +遗留待办:2026-09-04 旧 draft「在 CodeBuddy 使用跨 Agent 技能:纯 SKILL.md 直接装 ~/.codebuddy/skills/(hooks 需 CLI/插件市场)」一直保留待定未裁决,等待用户确认生效 / reject / 继续保留。 diff --git a/repowiki/wiki/log-2026-09.md b/repowiki/wiki/log-2026-09.md index 15d57a3..ebf4880 100644 --- a/repowiki/wiki/log-2026-09.md +++ b/repowiki/wiki/log-2026-09.md @@ -120,3 +120,9 @@ * **lint_wiki**: 检查完成: 1 个问题 * **lint_wiki**: 检查完成: 1 个问题 * **lint_wiki**: 检查完成: 1 个问题 + +## 2026-09-07 +* **ingest_note**: 添加笔记: 跨仓库 output_dir 劫持 session:重建检索索引被清空填入 smoke 测试文档(根因链与三层防护) +* **ingest_note**: 添加笔记: output_dir 是 repo_path 的纯函数:写路径一律布局推导、砍跨进程持久化、只读检索保留跨仓寻址出口 +* **ingest_note**: 添加笔记: skill-creator 工具设计定档:scenario 直译 + Mode C + 两区制确认闸门,不建自动评分门控 +* **ingest_note**: 添加笔记: consolidate_notes 候选 disposition 三值机制:未入选笔记不再无声滞留,excluded 必填 reason From 37a31baf8b0ffa0d9a03194b16e2728660912003 Mon Sep 17 00:00:00 2001 From: mambo-wang Date: Mon, 7 Sep 2026 10:09:50 +0800 Subject: [PATCH 23/23] chore: bump version to 5.7.0 --- codewiki/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codewiki/__init__.py b/codewiki/__init__.py index 4db5ae2..3812a33 100644 --- a/codewiki/__init__.py +++ b/codewiki/__init__.py @@ -5,7 +5,7 @@ and an MCP server for IDE-driven documentation generation. """ -__version__ = "5.6.1" +__version__ = "5.7.0" __author__ = "CodeWiki Contributors" __license__ = "MIT" diff --git a/pyproject.toml b/pyproject.toml index 8cae249..148a706 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "codewiki-plus" -version = "5.6.1" +version = "5.7.0" description = "Transform codebases into comprehensive documentation using AI-powered analysis" readme = "README.md" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index d9a46e0..7218682 100644 --- a/uv.lock +++ b/uv.lock @@ -585,7 +585,7 @@ wheels = [ [[package]] name = "codewiki-plus" -version = "5.6.1" +version = "5.7.0" source = { editable = "." } dependencies = [ { name = "click" },