opencode-wiki-historian 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,12 +6,15 @@ opencode-historian 把 wiki.js 的读写、翻译、页型规范、迁移工具
6
6
 
7
7
  功能一览:
8
8
 
9
- * 10 个 `historian_*` 工具,覆盖创建、更新、追加、翻译、搜索、阅读、地图/时间轴、迁移、删除、移动
9
+ * 10 个 `historian_*` 工具,覆盖创建、更新、追加、翻译、搜索、阅读、地图/时间轴/维护报告、迁移、删除、移动
10
10
  * Skill v5 随插件自动注入(config hook),无需手动安装 skill 文件
11
- * G1 至 G5 页型契约,每种页型对应专属骨架模板
11
+ * G1 至 G6 页型契约,每种页型对应专属骨架模板(v4 新增 G6 操作手册/how-to)
12
12
  * 双语孪生页面(en/zh)自动翻译,翻译引擎可配置
13
- * 页面地图缓存、本地镜像与时间轴聚合视图
14
- * 开工前置查阅回路(reading loop,默认关,双信号启用)+ `/historian-capture` 会话留痕(默认关)
13
+ * 页面地图缓存、本地镜像、时间轴聚合视图与 `maintain` 维护策展报告(light/deep 两档)
14
+ * `options.sections` 路径前缀白名单强制生效:配置非空时越界写入硬拒(`ConfigError`,请求发出前拒绝);`home`、`wiki-index`、`_sandbox`、`_data`、`_meta`、`_evidence` 系统路径恒豁免;默认 `[]` = 不限制
15
+ * `historian_search` 标签过滤:`tags` 1-5 个,`tagsMode` 默认 `all`(服务端 AND)、`any` 为客户端逐标签 fan-out 求并集
16
+ * 顶层 `home` 落地页解锁:仅豁免精确顶层 `home`,其余保留字(login/register/graphql/healthz/_assets/favicon)与嵌套 `home` 照旧拒绝
17
+ * 开工前置查阅回路(reading loop,默认关,双信号启用;v4 advisory:先查索引、update 优先、引页引日期、标注过期)+ `/historian-capture` 会话留痕(默认关)
15
18
  * 试点与评测均通过:pilot 7 页迁移 PASS,eval 7/7 场景首跑全过
16
19
 
17
20
  ## 史官宣言 / The Historian's Manifest
@@ -28,7 +31,7 @@ opencode-historian 把 wiki.js 的读写、翻译、页型规范、迁移工具
28
31
  | 法文 | 中立、非个人化、清晰、精确、易懂、有教学性六种品质;溢美词强制转成事实(用排名、奖项、销量替代"最伟大的"这类断言);耐久过滤:明天就会过时的内容不值得写 |
29
32
  | 俄文 | ХС/ИС 条目门槛(导言普通读者可懂、术语最少、不少于 10 行);二手权威来源优先于一手(АИ);每种观点按影响力分配篇幅、禁止虚假平衡(ВЕС);风格要求精确、简明、无歧义,同时保持内容饱和 |
30
33
 
31
- SRE postmortem 文化单独值得点名:它把"事件"当一等文档对待,过程/原因/后果/改进四段式正是 `/historian-capture` 命令的输出协议(`src/index.ts` 的 capture 模板),而"改进项要有负责人和可验证终态"落在了 G1 行动项表格里。
34
+ SRE postmortem 文化单独值得点名:它把"事件"当一等文档对待,先证据后结论的证据链四段式(`src/index.ts` 的 `CAPTURE_COMMAND_TEMPLATE` 模板)正是 `/historian-capture` 命令的正文契约:证据链→方法→修复手段→函数级实现,而"改进项要有负责人和可验证终态"落在了 G1 行动项表格里。
32
35
 
33
36
  把这些文化做成插件的直接动因,是一个机构记忆问题:这台机器由一个人加一群 AI agent 操作。会话结束,终端滚动条就没了;上下文压缩,细节就丢了。部署过什么、发生过什么事故、踩过什么坑、做过什么决定,三个月后人和 AI 都无从查起。史官要解决的,就是让这台机器的历史变得可检索。
34
37
 
@@ -42,18 +45,28 @@ SRE postmortem 文化单独值得点名:它把"事件"当一等文档对待,
42
45
 
43
46
  ### 作用 / What it does
44
47
 
45
- 五柱页型契约,加上读写两条自动化回路:
48
+ 六柱页型契约,加上读写两条自动化回路:
46
49
 
47
50
  | 能力 | 机制 | 落点 |
48
51
  |---|---|---|
49
- | 五柱页型 | G1 事件复盘 / G2 对比选型 / G3 清单索引 / G4 概念原理 / G5 现状账本,写前声明页型,套固定骨架、过来源列检查 | `src/templates/genres.ts`,skill Phase 1.5 |
50
- | 双视图 | 地图视图(en/zh 对应关系,Locale/Twin 列)+ 时间轴视图(ISO 周分组,支持 `days` 窗口与 `path` 前缀过滤,人读周表 + 机读 weeks JSON | `historian_map` 的 `action:'show'` / `action:'timeline'` |
52
+ | 六柱页型 | G1 事件复盘 / G2 对比选型 / G3 清单索引 / G4 概念原理 / G5 现状账本 / G6 操作手册,写前声明页型,套固定骨架、过来源列检查 | `src/templates/genres.ts`,skill Phase 1.5 |
53
+ | 三视图 | 地图视图(en/zh 对应关系,Locale/Twin 列)+ 时间轴视图(ISO 周分组,支持 `days` 窗口与 `path` 前缀过滤,人读周表 + 机读 weeks JSON)+ 维护策展报告(`action:'maintain'`:双语缺口、近似重复标题、过期、孤儿候选、标签词表、章节分布;light 档只扫地图行加每 locale 一次 `pages.list`,`deep:true` 逐页读正文补新鲜度标记与 Redirect 存根识别) | `historian_map` 的 `action:'show'` / `'timeline'` / `'maintain'` |
51
54
  | 迁移与评分门禁 | 存量页按骨架重排:dry-run 评分在前,`apply=true` 自动 pre-image 备份;每页写入后过 10 项自检门 | `historian_migrate`,`selfReviewChecklist()` |
52
- | 前置查阅回路 | 向每次请求的 system 提示注入"先查 wiki"指令(单块合并:追加到最后一个 system 块,绝不产生第二条 system 消息;双信号门控:选项与本机哨兵文件同时到位才注入):动这台机器的部署/历史/坑/决定之前先 `historian_search`、查 timeline、核对 G5 卡的核实日期,引用查过的页面 URL | `readingLoop` 选项(默认 false,开启需配置+哨兵双确认)+ 哨兵文件 `~/.config/opencode/historian-reading-loop.json`,`src/index.ts` 的 `experimental.chat.system.transform` 钩子 |
55
+ | 前置查阅回路 | 向每次请求的 system 提示注入"先查 wiki"指令(单块合并:追加到最后一个 system 块,绝不产生第二条 system 消息;双信号门控:选项与本机哨兵文件同时到位才注入):v4 advisory 四条——先读 `wiki-index` / `historian_map show` 定位再 `historian_search`(index-first);update 优先于 create;引用页面时带 URL 与日期;发现矛盾/过期在页内标注(supersede 或复核章)而非静默覆盖,G5 账本逐行核对「上次核实于」 | `readingLoop` 选项(默认 false,开启需配置+哨兵双确认)+ 哨兵文件 `~/.config/opencode/historian-reading-loop.json`,`src/index.ts` 的 `experimental.chat.system.transform` 钩子 |
53
56
  | 主动留痕 | `/historian-capture` 命令把当前会话总结成 G1 事件页;开启 `capture.enabled` 后额外在会话空闲时弹一次提醒,仅提醒,绝不自动写页 | `src/index.ts` 的 `config` / `event` 钩子 |
54
57
 
55
58
  G5 现状卡回答"现在跑着什么",timeline 回答"最近两周变了什么"。比如问"`service-a` 现在监听哪个端口",应当命中现状账本里的一行(形如 `example.com:8000`,带上次核实日期与验证命令),而不是某次会话的聊天记录。这两样合起来,wiki 才从文档堆变成可查询的运维账本。
56
59
 
60
+ 知识库要同时满足四种诉求——档案、手册、百科、知识库(速查)。六柱页型加索引层按层分工:
61
+
62
+ | 层诉求 | 回答的问题 | 对应页型 | 承载机制 |
63
+ |---|---|---|---|
64
+ | 档案 | 发生过什么、现在跑着什么 | G1 事件复盘 + G5 现状账本 | G1 时间线带来源列;G5 每行带「上次核实于」+ 验证命令 |
65
+ | 手册 | 怎么把一件事做完 | G6 操作手册 | 目标句式标题、步骤三段(动作+预期结果+失败处置)、上次核实+复核周期 |
66
+ | 百科 | 它是什么、为什么 | G4 概念原理 | 概念定义 → 图示 → 示例 |
67
+ | 知识库(速查) | 该选哪个、去哪查 | G2 对比选型 + G3 清单索引 | 对比表带来源列;可勾选检查项 |
68
+ | 索引层 | 从哪读起 | ——(不是页型) | `wiki-index` 人策展目录 + `historian_map` 的 show/timeline/maintain |
69
+
57
70
  ### 使用方式 / Usage
58
71
 
59
72
  端到端最短路径(适配你自己 wiki 的六步完整指南见 `skills/historian/references/adapting-your-own-wiki.md`):
@@ -74,7 +87,7 @@ G5 现状卡回答"现在跑着什么",timeline 回答"最近两周变了什
74
87
  > `service-a` 今天 OOM 重启,根因是缓存没设上限,已加告警,记下来。
75
88
 
76
89
  → 分诊为事件复盘,声明 G1 → `historian_page_create`(`genre: "G1"`)→ 回报 `http://<your-wiki>:3000/team-notes/<slug>` 与它的 `/zh/` 孪生页。
77
- 4. **检索与整理**:`historian_search` 按主题查;`historian_map` 的 `show` 看双语地图、`timeline`(可选 `days` / `path`)看最近变动;存量页不合规用 `historian_migrate` 先 dry-run 再 apply。
90
+ 4. **检索与整理**:`historian_search` 按主题查,可按标签过滤(`tags` 1-5 个,`tagsMode` 默认 `all` 即服务端 AND,`any` 为客户端逐标签 fan-out 并集);`historian_map` 的 `show` 看双语地图、`timeline`(可选 `days` / `path`)看最近变动、`maintain`(可选 `deep:true`)出维护策展报告;存量页不合规用 `historian_migrate` 先 dry-run 再 apply。
78
91
  5. **开关**:reading loop 默认 false,开启需配置+哨兵双确认,两步缺一不可:
79
92
 
80
93
  1. 插件二元组第二参数写 `"readingLoop": true`:`["opencode-wiki-historian", { "readingLoop": true }]`
@@ -179,14 +192,14 @@ opencode run --command historian --message "historian_map show"
179
192
  | `translate.model` | string | `qwen3.7-plus` | 翻译模型 |
180
193
  | `translate.apiKey` | string | 见下方链 | 翻译 API 密钥 |
181
194
  | `translate.providerKey` | string | 未配置 | jsonc 兜底腿读取的 provider 名;须显式设置才会启用该腿 |
182
- | `sections` | string[] | `[]`(不限制) | 插件可操作的 wiki 路径前缀白名单 |
195
+ | `sections` | string[] | `[]`(不限制) | 写入路径前缀白名单。v4 起强制生效:非空时 create/update/append/delete/move(目标路径) 在发出请求前做 `sectionGuard` 检查,越界返回 `ConfigError`;`home`、`wiki-index`、`_sandbox`、`_data`、`_meta`、`_evidence` 恒豁免;按首路径段匹配、区分大小写 |
183
196
  | `locales` | string[] | `["en", "zh"]` | 启用的语言列表 |
184
197
  | `readingLoop` | boolean | `false` | 开工前置查阅 advisory,向每次请求注入"先查 wiki"提示;默认 false,true 需配置+哨兵双确认(见「使用方式」开关步骤);单块合并追加到最后一个 system 块,绝不产生第二条 system 消息,vLLM 等拒绝多条 system 的严格后端同样安全 |
185
198
  | `capture.enabled` | boolean | `false` | 开启后会话空闲时弹一次 `/historian-capture` 留痕提醒;仅提醒,不自动写页 |
186
199
 
187
200
  `translate.endpoint` 解析链(优先级从高到低):`translate.endpoint` 选项 → 环境变量 `HISTORIAN_TRANSLATE_ENDPOINT` → 未配置。包内**不**内置任何网关地址;未配置时翻译调用直接以 `translate.endpoint not configured` 失败(见降级行为)。
188
201
 
189
- `sections` 默认为空列表 = 不限制路径前缀(任意合法路径可写,实际权限由 wiki.js token 的 page rules 决定)。按机器通过选项传入白名单,例如 `["team-notes", "infra", "ops"]`。
202
+ `sections` 默认为空列表 = 不限制路径前缀(任意合法路径可写,实际权限由 wiki.js token 的 page rules 决定)。按机器通过选项传入白名单,例如 `["team-notes", "infra", "ops"]`。传入非空列表即强制生效(v4):五个写入工具先过 `sectionGuard`(`src/tools/shared.ts`),不在白名单内的路径直接以 `ConfigError` 拒绝、不发任何写请求;报错信息会点名越界的首段并提示把它加进 `sections`。两个细节:匹配按首路径段、区分大小写,`doc` 授权 `doc` 与 `doc/x` 但不授权 `docs/x`;配置项首尾斜杠可省。`home`、`wiki-index`、`_sandbox`、`_data`、`_meta`、`_evidence` 是插件自记账与索引胶水所需,恒豁免。
190
203
 
191
204
  ### API Key 获取优先级
192
205
 
@@ -212,6 +225,7 @@ key 缺失时 `ConfigError` 记录一次日志,插件工具全部禁用,open
212
225
  | 现象 | 定性 | 说明 |
213
226
  |---|---|---|
214
227
  | 裸配置未传 `translate` 选项(三段 key 链 `translate.apiKey` → `DASHSCOPE_API_KEY` → `translate.providerKey` 全缺)时插件工具全部禁用 | by design | 翻译腿是写操作的前提,缺 key 时宁可整体禁用也不静默半成品;配置按上方选项全表补齐即恢复。缺配置时的部分降级(工具照常注册、仅翻译调用失败)在议 |
228
+ | 自进化驱动(readingLoop + capture)默认关闭,作者本机也尚未点亮 | 状态如实 | 两个开关缺省均为 `false`(`src/config.ts:100` `DEFAULT_READING_LOOP`、`src/config.ts:102` `DEFAULT_CAPTURE_ENABLED`),readingLoop 还需配置+哨兵双确认。v0.4.0 发布仍待人工 2FA 步骤(`npm publish` 需 OTP),本机启用要等发布落地后进行;回路代码与测试已就绪,但在真正点亮之前本文档不假装它在跑 |
215
229
 
216
230
  ## wiki.js 前置检查 / Prerequisites
217
231
 
@@ -263,11 +277,13 @@ zh: http://<host>/zh/ops/example
263
277
 
264
278
  **时间轴视图**:`historian_map action:'timeline'` 把镜像行按 ISO 周分组(可选 `days` 窗口与 `path` 前缀过滤),输出人读周表 + 机读 `weeks` JSON,回答"最近哪些页面变过"。
265
279
 
280
+ **维护视图**:`historian_map action:'maintain'` 输出策展报告——双语缺口、近似重复标题、过期页面、孤儿/扩散候选、标签词表、章节分布。默认 light 档只扫地图行加每个 locale 一次 `pages.list`;`deep:true` 逐页读正文,补新鲜度标记与 Redirect 存根识别。
281
+
266
282
  ## 写作质量体系 / Quality System
267
283
 
268
- ### G1 至 G5 页型
284
+ ### G1 至 G6 页型
269
285
 
270
- 插件根据内容形态把每页归入五种页型之一,每种有专属骨架模板:
286
+ 插件根据内容形态把每页归入六种页型之一,每种有专属骨架模板:
271
287
 
272
288
  | 页型 | 用途 | 骨架结构 |
273
289
  |---|---|---|
@@ -276,10 +292,11 @@ zh: http://<host>/zh/ops/example
276
292
  | G3 清单 | 操作步骤、检查项 | 可勾选的检查项列表 |
277
293
  | G4 概念 | 架构说明、原理讲解 | 概念定义 → 图示 → 示例 |
278
294
  | G5 现状账本 | 此刻的部署/运行态,回答"现在跑着什么" | 状态块 → 部署物清单(每行带「上次核实于」+ 验证命令)→ 失效策略,禁止叙事正文 |
295
+ | G6 操作手册 | how-to,回答"怎么把一件事做完",目标句式标题 | 前置条件 → 操作步骤(每步含预期结果+失败处置)→ 回退 → 元数据表(上次核实 + 复核周期)→ 相关页面 |
279
296
 
280
297
  ### 10 项自检门
281
298
 
282
- 每页写入后过一遍自检清单(源码 `src/templates/genres.ts` `selfReviewChecklist()`,参考文件 `skills/historian/references/rules.md` + `genres.md`)。dry-run 阶段评 1–8,apply 后评 9–10。genre-specific 条目对不匹配的页型记 N/A=PASS;G5 页的第 4–6 项换成账本变体(`G5_CHECKLIST_VARIANTS`):
299
+ 每页写入后过一遍自检清单(源码 `src/templates/genres.ts` `selfReviewChecklist()`,参考文件 `skills/historian/references/rules.md` + `genres.md`)。dry-run 阶段评 1–8,apply 后评 9–10。genre-specific 条目对不匹配的页型记 N/A=PASS;G5 页的第 4–6 项换成账本变体(`G5_CHECKLIST_VARIANTS`),G6 页换成手册变体(`G6_CHECKLIST_VARIANTS`):第 4 项要求标题是目标句式 "How to X" / "如何/怎么做X",第 5 项要求每条操作步骤三段(动作 + 预期结果 + 失败处置),第 6 项要求元数据表含「上次核实」与「复核周期」行:
283
300
 
284
301
  1. **导言占比 10–15%**:导言 ≈ 正文的 10–15%,每个重要小节在导言至少占一句
285
302
  2. **句长上限**:中文句 ≤20 字、英文句 ≤25 词
@@ -302,7 +319,7 @@ wiki 内容按读者分三层,工具按层执行不同语义(`historian_page
302
319
 
303
320
  | 层 | 位置 | 职责 |
304
321
  |---|---|---|
305
- | 前台 (front) | 主题章节的 G1-G5 页 | 人写人读的知识页;双语孪生、进索引;只放提炼后的内容与链接 |
322
+ | 前台 (front) | 主题章节的 G1-G6 页 | 人写人读的知识页;双语孪生、进索引;只放提炼后的内容与链接 |
306
323
  | 后台 (backstage) | `_meta/` 页 + 本地镜像文件 | 机器记账:page-map 缓存页、迁移 checkpoint、reading loop 哨兵文件;不参与人读正文 |
307
324
  | 证据 (evidence) | `_evidence/` | 超 10 行原始件(日志、转写、大 diff)的归宿:单语 en、不发布(匿名访问 404 是 by design),人类页面只链接不复制 |
308
325
 
@@ -407,7 +424,7 @@ harness repo(与本插件仓库同工作区)提供 7 个行为验收场景
407
424
 
408
425
  ```bash
409
426
  npm run build # tsc 编译到 dist/
410
- npm test # vitest run(331 tests, 15 files)
427
+ npm test # vitest run(493 tests, 20 files)
411
428
  npm pack --dry-run # 检查打包文件列表
412
429
  ```
413
430
 
package/dist/index.js CHANGED
@@ -40,29 +40,47 @@ const skillsDir = fileURLToPath(new URL('../skills/', import.meta.url));
40
40
  function unique(items) {
41
41
  return [...new Set(items)];
42
42
  }
43
- /** Reading-loop advisory (plan v2 todo 8): the machine wiki is the
44
- * authoritative institutional memory; consult it before acting, cite URLs.
45
- * Shipped text generic wording only (privacy-audit scans dist). */
43
+ /** Reading-loop advisory (plan v4 todo 8 / D6): the machine wiki is the
44
+ * authoritative institutional memory; consult it index-first before acting,
45
+ * prefer updating over duplicating, cite pages+dates, mark staleness.
46
+ * Hard budget ≤8 lines (context-rot). Shipped text — generic wording only
47
+ * (privacy-audit scans dist). */
46
48
  const READING_LOOP_ADVISORY = [
47
49
  'You have a historian: a wiki.js knowledge base acting as this machine\'s authoritative institutional memory.',
48
- 'Before doing work that touches this machine\'s deployments, history, pitfalls, or decisions, consult it first:',
49
- '- historian_search by topic for relevant pages; historian_map action:"timeline" for what changed recently;',
50
- '- G5 current-state ledger pages answer "what is deployed/running now" check each row\'s verified date before trusting it.',
51
- 'Cite the wiki page URLs you relied on. If you learn something new worth keeping, offer to record it as a page.',
50
+ 'Before doing work that touches this machine\'s deployments, history, pitfalls, or decisions, consult it index-first:',
51
+ '- Read the curated map before blind search: historian_read wiki-index or historian_map action:"show", then historian_search by topic;',
52
+ '- PREFER UPDATE over CREATE: if a page may already exist, historian_read it first, then historian_page_update rather than creating a duplicate;',
53
+ '- CITE evidence: name the wiki page URL and its date whenever you quote it;',
54
+ '- MARK stale rows instead of silently overwriting: supersede or review-stamp contradictions (G5 ledger: check each row\'s verified date).',
55
+ 'If you learn something new worth keeping, offer to record it as a page.',
52
56
  ].join('\n');
53
- /** /historian-capture command (plan v2 todo 9): the always-available manual
54
- * path from "notable session" to "G1 event page" — registered regardless of
55
- * capture.enabled; the enabled-gated toast only nudges toward it. Agent-facing
56
- * instruction text, generic wording only (ships in the tarball). */
57
+ /** /historian-capture command (plan v2 todo 9, upgraded by v4 todo-10 / D2+D3):
58
+ * the always-available manual path from "notable session" to "G1 event page" —
59
+ * trigger list, 四段式 evidence-chain body contract, evidence split, and the
60
+ * draft→review→Active publish flow are the drive surface (idle toast is
61
+ * non-observable; this template text is the contract). Registered regardless
62
+ * of capture.enabled; the enabled-gated toast only nudges toward it.
63
+ * Agent-facing instruction text, generic wording only (ships in the tarball).
64
+ * Context-rot budget ≤~30 lines. */
57
65
  const CAPTURE_COMMAND_DESCRIPTION = '把本次会话记为史官事件页 / record this session as a historian event page';
58
66
  const CAPTURE_COMMAND_TEMPLATE = [
59
- 'Summarize the current session as a historian G1 event page (an append-only record of what happened).',
67
+ 'Summarize the current session as a historian G1 event page only if it produced capture-worthy knowledge.',
60
68
  '',
61
- '1. Draft four sections: 过程/Process (what was done, in order), 原因/Cause (why it was needed), 后果/Consequence (impact, artifacts), 改进/Improvement (follow-ups, preventions).',
69
+ '0. Capture triggers proceed only if at least one matches, else say so and skip writing:',
70
+ ' 事故闭环 incident closed with a root cause | 部署完成 deployment completed | bug修复合入 bugfix merged',
71
+ ' | 探针结论 probe/eval conclusion | 被否决方案 rejected option (record the 否决理由 veto reason).',
72
+ '1. Draft the body in the 四段式 evidence-chain order: 证据链/Evidence (what was observed, artifacts first)',
73
+ ' → 方法/Method (how it was proven) → 修复手段/Fix (what changed, or the decision) → 函数级实现/Implementation (file:symbol detail).',
62
74
  "2. Run historian_map action:'show' to see existing sections, then choose a short factual path under one.",
63
- '3. If the session produced raw artifacts (logs, transcripts, big diffs), first store each via historian_page_create with tier:"evidence" under `_evidence/`, then cite those URLs in the G1 appendix.',
64
- '4. Save with historian_page_create (genre "G1"); the zh twin is auto-created. If the session only repeated known knowledge, say so and skip writing.',
65
- '5. Echo both page URLs (en + zh) back to the user.',
75
+ '3. Evidence split (G1 appendix contract): store the full 四段 material via historian_page_create with tier:"evidence"',
76
+ ' under `_evidence/`; the main page body stays the cited/summarized form linking to those evidence pages.',
77
+ '4. Carry SRE discipline in the 元数据表 metadata table rows: 影响/impact | 负责人/owner',
78
+ ' | 后续动作/action items (owner + 优先级/priority + verifiable done-state) | 来源类型/source type.',
79
+ '5. Pre-write self-check: create the page (genre "G1", 状态:draft, content included) so the 十项自检 scoring',
80
+ ' advisory runs on the draft; fix its FAIL items via historian_page_update before publishing.',
81
+ '6. Publish flow capture→review→Active: the page starts 状态:draft; once the self-check passes,',
82
+ ' historian_page_update it to Active. The zh twin is auto-created.',
83
+ '7. Echo both page URLs (en + zh) back to the user.',
66
84
  ].join('\n');
67
85
  const CAPTURE_TOAST_MESSAGE = '会话空闲:有值得留存的决定/修复/踩坑就跑 /historian-capture。Session idle — run /historian-capture if it produced decisions, fixes, or pitfalls worth keeping.';
68
86
  async function server(input, options) {
package/dist/lint.d.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Body lint: one fence-aware pass over a page body producing the structural
3
+ * facts every hard gate and surface detector reuses (plan v0.5.0 #6).
4
+ * Pure functions, zero wiki I/O — the deep scanner injects bodies, the write
5
+ * tools lint drafts before they hit the API. This is the module the
6
+ * architecture-page audit (HANDOFF Issue #6) forced into existence: nothing
7
+ * here was optional before, which is exactly how a page with five empty
8
+ * sections shipped for three days claiming `状态: Active`.
9
+ */
10
+ import type { Locale } from './wiki/pages.read.js';
11
+ /** Machine rule keys; the human report renders zh labels from these. */
12
+ export type GateViolation = 'redirect-stub-no-exit' | 'active-with-unfinished-skeleton';
13
+ export interface LintFinding {
14
+ readonly key: 'todo-markers' | 'empty-sections' | 'intro-empty' | 'no-related-pages' | 'no-state-block' | 'h1-mismatch' | 'zh-english-dominant' | 'claims-without-stamp';
15
+ readonly detail: string;
16
+ }
17
+ export interface PageLink {
18
+ /** Normalized wiki path (locale prefix stripped, leading `/` removed, no anchor/query). */
19
+ readonly path: string;
20
+ readonly locale: Locale;
21
+ /** True when the raw target was a same-page anchor (`#…`) — never resolved. */
22
+ readonly anchor: boolean;
23
+ }
24
+ export interface ClaimCounts {
25
+ readonly ports: number;
26
+ readonly paths: number;
27
+ readonly commands: number;
28
+ }
29
+ export interface BodyLint {
30
+ readonly isRedirectStub: boolean;
31
+ readonly redirectTarget: string | null;
32
+ readonly stubHasLink: boolean;
33
+ readonly hasStateBlock: boolean;
34
+ readonly state: 'active' | 'draft' | 'superseded' | 'deprecated' | null;
35
+ readonly todoMarkers: number;
36
+ /** Heading text of sections whose body (before the next heading ≤ level) is empty. */
37
+ readonly emptySections: readonly string[];
38
+ readonly introEmpty: boolean;
39
+ readonly hasRelatedPages: boolean;
40
+ readonly h1: string | null;
41
+ /** All heading spans in document order (twin-parity + structure checks). */
42
+ readonly headings: readonly HeadingSpan[];
43
+ readonly links: readonly PageLink[];
44
+ readonly hasStamp: boolean;
45
+ readonly claims: ClaimCounts;
46
+ readonly claimTotal: number;
47
+ /** CJK chars / non-whitespace chars, zh body only meaningful; 0..1. */
48
+ readonly cjkRatio: number;
49
+ }
50
+ export interface HeadingSpan {
51
+ readonly level: number;
52
+ readonly heading: string;
53
+ /** Subtree body: weighted length until the next heading ≤ level, INCLUDING
54
+ * descendant sections' content (masked+comment-free). */
55
+ readonly bodyChars: number;
56
+ /** Direct body only: weighted length until the next heading at ANY level.
57
+ * Used for the intro ratio so an H1 span never swallows the whole page. */
58
+ readonly directChars: number;
59
+ }
60
+ /** Section headings (##+) whose SUBTREE holds no content. A parent that groups
61
+ * non-empty subsections is an outline container, not an unfinished product;
62
+ * only wholly-empty subtrees indicate an unfilled skeleton section. */
63
+ export declare function emptySectionsOf(headings: readonly HeadingSpan[]): readonly string[];
64
+ export interface LinkScanOpts {
65
+ readonly baseUrl: string;
66
+ }
67
+ /** Extract internal page links (relative wiki paths + same-host absolute URLs). */
68
+ export declare function extractLinks(masked: string, opts: LinkScanOpts): readonly PageLink[];
69
+ export interface LintOpts {
70
+ readonly locale: Locale;
71
+ readonly baseUrl: string;
72
+ /** Page title for the h1-mismatch check. */
73
+ readonly title?: string;
74
+ }
75
+ export declare function lintBody(body: string, opts: LintOpts): BodyLint;
76
+ /** Structural h1≠title flag (kept out of BodyLint's hot fields on purpose). */
77
+ export declare function h1TitleMismatch(lint: BodyLint, title: string | undefined): boolean;
78
+ /** The two hard publish-gate rules (shared.ts refuses the write on a hit). */
79
+ export declare function publishGateViolations(lint: BodyLint): readonly GateViolation[];
package/dist/lint.js ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Body lint: one fence-aware pass over a page body producing the structural
3
+ * facts every hard gate and surface detector reuses (plan v0.5.0 #6).
4
+ * Pure functions, zero wiki I/O — the deep scanner injects bodies, the write
5
+ * tools lint drafts before they hit the API. This is the module the
6
+ * architecture-page audit (HANDOFF Issue #6) forced into existence: nothing
7
+ * here was optional before, which is exactly how a page with five empty
8
+ * sections shipped for three days claiming `状态: Active`.
9
+ */
10
+ // --- fence + comment masking ----------------------------------------------------
11
+ /** Blank out fenced code-block interiors (keep line structure) so markers,
12
+ * headings and links inside code fences never count (SYN fence rule). */
13
+ function maskFences(body) {
14
+ const lines = body.split('\n');
15
+ let fence = null;
16
+ for (let i = 0; i < lines.length; i++) {
17
+ const line = lines[i];
18
+ const m = /^(`{3,}|~{3,})/.exec(line.trimStart());
19
+ if (fence === null && m !== null) {
20
+ fence = m[1][0];
21
+ continue;
22
+ }
23
+ if (fence !== null) {
24
+ if (line.trimStart().startsWith(fence.repeat(3))) {
25
+ fence = null;
26
+ }
27
+ else {
28
+ lines[i] = '';
29
+ }
30
+ }
31
+ }
32
+ return lines.join('\n');
33
+ }
34
+ const COMMENT_RE = /<!--[\s\S]*?-->/g;
35
+ // --- redirect stub --------------------------------------------------------------
36
+ const REDIRECT_LINE_RE = /^>\s*Redirect:\s*(\S.*?)\s*$/im;
37
+ function redirectTarget(masked) {
38
+ const m = REDIRECT_LINE_RE.exec(masked);
39
+ return m !== null ? m[1] : null;
40
+ }
41
+ /** Clickable exit (Issue #5.1): a stub only lives if it carries at least one
42
+ * real internal link — a bare `<code>` path or an in-page anchor is a dead
43
+ * end for the reader who lands here. Wrong-target links belong to the
44
+ * dead-link scanner, not this gate. */
45
+ function hasClickableExit(visibleNoAnchors) {
46
+ return visibleNoAnchors > 0;
47
+ }
48
+ // --- status block -----------------------------------------------------------------
49
+ const STATE_LINE_RE = /(?:^|\n)\s*\*{0,2}\s*(?:状态\s*\/\s*Status|状态|Status)\s*\*{0,2}\s*[::]\s*([A-Za-z\u4e00-\u9fff][^\n·|<]*)/i;
50
+ function parseState(maskedNoComments) {
51
+ const m = STATE_LINE_RE.exec(maskedNoComments);
52
+ if (m === null)
53
+ return { has: false, state: null };
54
+ const v = m[1].trim().toLowerCase();
55
+ if (v.startsWith('active'))
56
+ return { has: true, state: 'active' };
57
+ if (v.startsWith('draft'))
58
+ return { has: true, state: 'draft' };
59
+ if (v.startsWith('superseded'))
60
+ return { has: true, state: 'superseded' };
61
+ if (v.startsWith('deprecated'))
62
+ return { has: true, state: 'deprecated' };
63
+ return { has: true, state: null };
64
+ }
65
+ // --- markers, headings, intro -------------------------------------------------------
66
+ const TODO_COMMENT_RE = /(TODO|TBD|PLACEHOLDER|占位)/i;
67
+ const LITERAL_TODO_RE = /\bTODO:/g;
68
+ function countTodoMarkers(masked) {
69
+ let n = 0;
70
+ for (const m of masked.matchAll(COMMENT_RE)) {
71
+ if (TODO_COMMENT_RE.test(m[0]))
72
+ n++;
73
+ }
74
+ // literal TODO: outside comments (comments already masked to '' for this pass)
75
+ const noComments = masked.replace(COMMENT_RE, '');
76
+ n += (noComments.match(LITERAL_TODO_RE) ?? []).length;
77
+ return n;
78
+ }
79
+ const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*$/;
80
+ const STATUS_BOILERPLATE_RE = /状态\s*\/\s*Status|日期\s*\/\s*Date|本页回答|This page answers/i;
81
+ /** Parse headings + emptiness over masked, comment-stripped text. Also returns
82
+ * the intro span (before the first heading) for the 导言占比 detector. */
83
+ function parseStructure(masked) {
84
+ const noComments = masked.replace(COMMENT_RE, '');
85
+ const lines = noComments.split('\n');
86
+ const headings = [];
87
+ let h1 = null;
88
+ let intro = 0;
89
+ const stack = [];
90
+ // CJK glyphs carry ~3x the visual weight of a latin char per cell — count
91
+ // weight, not raw chars, so a terse Chinese intro is not flagged empty.
92
+ const weight = (s) => {
93
+ const stripped = s.replace(/\s/g, '');
94
+ const cjk = (stripped.match(CJK_WEIGHT_RE) ?? []).length;
95
+ return cjk * 3 + (stripped.length - cjk);
96
+ };
97
+ const closeTo = (level) => {
98
+ const closing = [];
99
+ for (;;) {
100
+ const s = stack[stack.length - 1];
101
+ if (s === undefined || s.level < level)
102
+ break;
103
+ stack.pop();
104
+ closing.push({ level: s.level, heading: s.heading, bodyChars: s.subtree, directChars: s.direct });
105
+ }
106
+ // inner-first pop order must be flipped back to document order
107
+ headings.push(...closing.reverse());
108
+ };
109
+ for (const line of lines) {
110
+ const m = HEADING_RE.exec(line);
111
+ if (m !== null) {
112
+ const level = m[1].length;
113
+ closeTo(level);
114
+ if (level === 1 && h1 === null)
115
+ h1 = m[2].trim();
116
+ stack.push({ level, heading: m[2].trim(), direct: 0, subtree: 0 });
117
+ continue;
118
+ }
119
+ if (STATUS_BOILERPLATE_RE.test(line))
120
+ continue;
121
+ const w = weight(line);
122
+ if (stack.length === 0) {
123
+ intro += w;
124
+ continue;
125
+ }
126
+ for (const s of stack)
127
+ s.subtree += w;
128
+ stack[stack.length - 1].direct += w;
129
+ }
130
+ closeTo(1);
131
+ // The intro is the H1 section's DIRECT span (real pages all open with
132
+ // `# 标题`); the pre-heading accumulator only carries weight on heading-less bodies.
133
+ const firstH1 = headings.find((h) => h.level === 1);
134
+ const introWeight = firstH1 !== undefined ? firstH1.directChars : intro;
135
+ return { headings, introEmpty: introWeight < 40, h1 };
136
+ }
137
+ /** Section headings (##+) whose SUBTREE holds no content. A parent that groups
138
+ * non-empty subsections is an outline container, not an unfinished product;
139
+ * only wholly-empty subtrees indicate an unfilled skeleton section. */
140
+ export function emptySectionsOf(headings) {
141
+ return headings.filter((h) => h.level >= 2 && h.bodyChars === 0).map((h) => h.heading);
142
+ }
143
+ // --- links -----------------------------------------------------------------------
144
+ const LINK_MD_RE = /\]\(([^)\s]+)[^)]*\)/g;
145
+ const LINK_HREF_RE = /href="([^"]+)"/gi;
146
+ /** Extract internal page links (relative wiki paths + same-host absolute URLs). */
147
+ export function extractLinks(masked, opts) {
148
+ const out = [];
149
+ const host = opts.baseUrl.replace(/\/+$/, '');
150
+ const seen = new Set();
151
+ const push = (raw) => {
152
+ let target = raw.trim();
153
+ if (target === '' || target.startsWith('#')) {
154
+ if (target.startsWith('#'))
155
+ out.push({ path: target.slice(1), locale: 'en', anchor: true });
156
+ return;
157
+ }
158
+ if (/^(mailto:|javascript:)/i.test(target))
159
+ return;
160
+ let locale = 'en';
161
+ if (/^https?:\/\//i.test(target)) {
162
+ if (!target.toLowerCase().startsWith(host.toLowerCase()))
163
+ return; // external
164
+ target = target.slice(host.length);
165
+ }
166
+ if (/\.(png|jpe?g|gif|svg|webp|pdf|zip|css|js)\b/i.test(target))
167
+ return;
168
+ target = target.split('#')[0];
169
+ target = target.split('?')[0];
170
+ target = target.replace(/^\/+/, '');
171
+ if (target === '')
172
+ return;
173
+ if (target.startsWith('zh/')) {
174
+ locale = 'zh';
175
+ target = target.slice(3);
176
+ }
177
+ else if (target.startsWith('en/')) {
178
+ target = target.slice(3);
179
+ }
180
+ const key = `${locale}\u0000${target}`;
181
+ if (seen.has(key))
182
+ return;
183
+ seen.add(key);
184
+ out.push({ path: target, locale, anchor: false });
185
+ };
186
+ for (const re of [LINK_MD_RE, LINK_HREF_RE]) {
187
+ re.lastIndex = 0;
188
+ let m;
189
+ while ((m = re.exec(masked)) !== null)
190
+ push(m[1] ?? '');
191
+ }
192
+ return out;
193
+ }
194
+ // --- claims + stamps + language -----------------------------------------------------
195
+ const STAMP_RE = /上次核实|上次验证|last verified/i;
196
+ const PORT_RE = /:\d{4,5}\b/g;
197
+ const FS_PATH_RE = /\/(?:opt|var|etc|usr|srv)\/[\w./-]+/g;
198
+ const CMD_RE = /\b(?:systemctl|journalctl|docker|curl|nc|wget|iptables|nginx|caddy)\b/gi;
199
+ const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30ff]/g;
200
+ const CJK_WEIGHT_RE = /[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30ff]/g;
201
+ function distinct(text, re) {
202
+ return new Set(text.match(re) ?? []).size;
203
+ }
204
+ // --- main entry ---------------------------------------------------------------------
205
+ export function lintBody(body, opts) {
206
+ const masked = maskFences(body);
207
+ const visible = masked.replace(COMMENT_RE, '');
208
+ const target = redirectTarget(masked);
209
+ const isRedirectStub = /^\s*>\s*Redirect:/im.test(masked);
210
+ const { headings, introEmpty, h1 } = parseStructure(masked);
211
+ const links = extractLinks(visible, { baseUrl: opts.baseUrl }).filter((l) => !l.anchor);
212
+ const nonSpace = (visible.match(/\S/g) ?? []).length;
213
+ const cjk = (visible.match(CJK_RE) ?? []).length;
214
+ const claims = {
215
+ ports: distinct(visible, PORT_RE),
216
+ paths: distinct(visible, FS_PATH_RE),
217
+ commands: distinct(visible, CMD_RE),
218
+ };
219
+ const stateInfo = parseState(visible);
220
+ return {
221
+ isRedirectStub,
222
+ redirectTarget: target,
223
+ stubHasLink: !isRedirectStub || hasClickableExit(links.length),
224
+ hasStateBlock: stateInfo.has,
225
+ state: stateInfo.state,
226
+ todoMarkers: countTodoMarkers(masked),
227
+ emptySections: emptySectionsOf(headings),
228
+ introEmpty,
229
+ hasRelatedPages: /^#{1,6}\s+.*(相关页面|Related Pages)/im.test(masked),
230
+ h1,
231
+ headings,
232
+ links,
233
+ hasStamp: STAMP_RE.test(visible),
234
+ claims,
235
+ claimTotal: claims.ports + claims.paths + claims.commands,
236
+ cjkRatio: nonSpace === 0 ? 0 : cjk / nonSpace,
237
+ };
238
+ }
239
+ /** Structural h1≠title flag (kept out of BodyLint's hot fields on purpose). */
240
+ export function h1TitleMismatch(lint, title) {
241
+ if (title === undefined || lint.h1 === null)
242
+ return false;
243
+ return lint.h1.trim().toLowerCase() !== title.trim().toLowerCase();
244
+ }
245
+ /** The two hard publish-gate rules (shared.ts refuses the write on a hit). */
246
+ export function publishGateViolations(lint) {
247
+ const out = [];
248
+ if (lint.isRedirectStub && !lint.stubHasLink)
249
+ out.push('redirect-stub-no-exit');
250
+ if (lint.state === 'active' && (lint.todoMarkers > 0 || lint.emptySections.length > 0)) {
251
+ out.push('active-with-unfinished-skeleton');
252
+ }
253
+ return out;
254
+ }