opencode-wiki-historian 0.2.0 → 0.3.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
@@ -7,11 +7,11 @@ opencode-historian 把 wiki.js 的读写、翻译、页型规范、迁移工具
7
7
  功能一览:
8
8
 
9
9
  * 10 个 `historian_*` 工具,覆盖创建、更新、追加、翻译、搜索、阅读、地图/时间轴、迁移、删除、移动
10
- * Skill v4 随插件自动注入(config hook),无需手动安装 skill 文件
10
+ * Skill v5 随插件自动注入(config hook),无需手动安装 skill 文件
11
11
  * G1 至 G5 页型契约,每种页型对应专属骨架模板
12
12
  * 双语孪生页面(en/zh)自动翻译,翻译引擎可配置
13
13
  * 页面地图缓存、本地镜像与时间轴聚合视图
14
- * 开工前置查阅回路(reading loop,默认开)+ `/historian-capture` 会话留痕(默认关)
14
+ * 开工前置查阅回路(reading loop,默认关,双信号启用)+ `/historian-capture` 会话留痕(默认关)
15
15
  * 试点与评测均通过:pilot 7 页迁移 PASS,eval 7/7 场景首跑全过
16
16
 
17
17
  ## 史官宣言 / The Historian's Manifest
@@ -49,7 +49,7 @@ SRE postmortem 文化单独值得点名:它把"事件"当一等文档对待,
49
49
  | 五柱页型 | G1 事件复盘 / G2 对比选型 / G3 清单索引 / G4 概念原理 / G5 现状账本,写前声明页型,套固定骨架、过来源列检查 | `src/templates/genres.ts`,skill Phase 1.5 |
50
50
  | 双视图 | 地图视图(en/zh 对应关系,Locale/Twin 列)+ 时间轴视图(ISO 周分组,支持 `days` 窗口与 `path` 前缀过滤,人读周表 + 机读 weeks JSON) | `historian_map` 的 `action:'show'` / `action:'timeline'` |
51
51
  | 迁移与评分门禁 | 存量页按骨架重排:dry-run 评分在前,`apply=true` 自动 pre-image 备份;每页写入后过 10 项自检门 | `historian_migrate`,`selfReviewChecklist()` |
52
- | 前置查阅回路 | 向每次请求的 system 提示注入"先查 wiki"指令:动这台机器的部署/历史/坑/决定之前先 `historian_search`、查 timeline、核对 G5 卡的核实日期,引用查过的页面 URL | `readingLoop` 选项(默认 `true`),`src/index.ts` 的 `experimental.chat.system.transform` 钩子 |
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` 钩子 |
53
53
  | 主动留痕 | `/historian-capture` 命令把当前会话总结成 G1 事件页;开启 `capture.enabled` 后额外在会话空闲时弹一次提醒,仅提醒,绝不自动写页 | `src/index.ts` 的 `config` / `event` 钩子 |
54
54
 
55
55
  G5 现状卡回答"现在跑着什么",timeline 回答"最近两周变了什么"。比如问"`service-a` 现在监听哪个端口",应当命中现状账本里的一行(形如 `example.com:8000`,带上次核实日期与验证命令),而不是某次会话的聊天记录。这两样合起来,wiki 才从文档堆变成可查询的运维账本。
@@ -75,7 +75,18 @@ G5 现状卡回答"现在跑着什么",timeline 回答"最近两周变了什
75
75
 
76
76
  → 分诊为事件复盘,声明 G1 → `historian_page_create`(`genre: "G1"`)→ 回报 `http://<your-wiki>:3000/team-notes/<slug>` 与它的 `/zh/` 孪生页。
77
77
  4. **检索与整理**:`historian_search` 按主题查;`historian_map` 的 `show` 看双语地图、`timeline`(可选 `days` / `path`)看最近变动;存量页不合规用 `historian_migrate` 先 dry-run 再 apply。
78
- 5. **开关**:严格 OpenAI 兼容后端(如 vLLM,会拒绝多条 system 消息)把 `"readingLoop": false` 关掉;想要空闲留痕提醒就 `"capture": { "enabled": true }`,`/historian-capture` 命令本身与开关无关、始终注册。
78
+ 5. **开关**:reading loop 默认 false,开启需配置+哨兵双确认,两步缺一不可:
79
+
80
+ 1. 插件二元组第二参数写 `"readingLoop": true`:`["opencode-wiki-historian", { "readingLoop": true }]`
81
+ 2. 人工写入本机哨兵文件(agent 不能自我启用):
82
+
83
+ ```bash
84
+ cat > ~/.config/opencode/historian-reading-loop.json <<'EOF'
85
+ {"version":1,"confirmed":true}
86
+ EOF
87
+ ```
88
+
89
+ 任一信号缺失即不注入;配置已开而哨兵缺失时,插件加载期会打一条提示(给出哨兵路径与内容),不会静默失灵。删除哨兵文件即刻回退,无需改配置。想要空闲留痕提醒就 `"capture": { "enabled": true }`,`/historian-capture` 命令本身与开关无关、始终注册。
79
90
 
80
91
  ### 解耦声明 / Decoupling
81
92
 
@@ -131,7 +142,7 @@ opencode run --command historian --message "historian_map show"
131
142
 
132
143
  如果工具列表中出现 `historian_page_create` 等 10 个工具,安装成功。
133
144
 
134
- > **升级提示**:如果你之前使用过 historian v2 的扁平 skill 文件(如 `~/.config/opencode/skills/historian.md`),需要先重命名为 `historian.md.v2-disabled` 或移到别处。插件通过 config hook 自动注入 v4 skill,两个同名 skill 不能共存。
145
+ > **升级提示**:如果你之前使用过 historian v2 的扁平 skill 文件(如 `~/.config/opencode/skills/historian.md`),需要先重命名为 `historian.md.v2-disabled` 或移到别处。插件通过 config hook 自动注入 v5 skill,两个同名 skill 不能共存。
135
146
 
136
147
  ## 配置 / Configuration
137
148
 
@@ -170,7 +181,7 @@ opencode run --command historian --message "historian_map show"
170
181
  | `translate.providerKey` | string | 未配置 | jsonc 兜底腿读取的 provider 名;须显式设置才会启用该腿 |
171
182
  | `sections` | string[] | `[]`(不限制) | 插件可操作的 wiki 路径前缀白名单 |
172
183
  | `locales` | string[] | `["en", "zh"]` | 启用的语言列表 |
173
- | `readingLoop` | boolean | `true` | 向每次请求注入"先查 wiki"的开工前置查阅 advisory;拒绝多条 system 消息的严格 OpenAI 兼容后端(如 vLLM)须设 `false` |
184
+ | `readingLoop` | boolean | `false` | 开工前置查阅 advisory,向每次请求注入"先查 wiki"提示;默认 false,true 需配置+哨兵双确认(见「使用方式」开关步骤);单块合并追加到最后一个 system 块,绝不产生第二条 system 消息,vLLM 等拒绝多条 system 的严格后端同样安全 |
174
185
  | `capture.enabled` | boolean | `false` | 开启后会话空闲时弹一次 `/historian-capture` 留痕提醒;仅提醒,不自动写页 |
175
186
 
176
187
  `translate.endpoint` 解析链(优先级从高到低):`translate.endpoint` 选项 → 环境变量 `HISTORIAN_TRANSLATE_ENDPOINT` → 未配置。包内**不**内置任何网关地址;未配置时翻译调用直接以 `translate.endpoint not configured` 失败(见降级行为)。
@@ -196,6 +207,12 @@ opencode run --command historian --message "historian_map show"
196
207
 
197
208
  key 缺失时 `ConfigError` 记录一次日志,插件工具全部禁用,opencode 正常启动不受影响。`translate.endpoint` 未配置时双语孪生功能降级为 pending 状态(`twinReason: 'translate.endpoint not configured — ...'`),创建页面只写入请求 locale 的内容,不会发起任何翻译网络请求。
198
209
 
210
+ ### 已知限制
211
+
212
+ | 现象 | 定性 | 说明 |
213
+ |---|---|---|
214
+ | 裸配置未传 `translate` 选项(三段 key 链 `translate.apiKey` → `DASHSCOPE_API_KEY` → `translate.providerKey` 全缺)时插件工具全部禁用 | by design | 翻译腿是写操作的前提,缺 key 时宁可整体禁用也不静默半成品;配置按上方选项全表补齐即恢复。缺配置时的部分降级(工具照常注册、仅翻译调用失败)在议 |
215
+
199
216
  ## wiki.js 前置检查 / Prerequisites
200
217
 
201
218
  安装插件前,确认 wiki.js 实例已完成以下配置。本插件不自动执行任何管理端操作。
@@ -279,6 +296,26 @@ zh: http://<host>/zh/ops/example
279
296
 
280
297
  更细的 20 条写作规则散布在 `skills/historian/references/` 目录下各参考文件中,agent 加载 skill 时自动读取。
281
298
 
299
+ ## 前台 / 后台 / 证据三层 / Three Content Tiers
300
+
301
+ wiki 内容按读者分三层,工具按层执行不同语义(`historian_page_create` 的 `tier` 参数):
302
+
303
+ | 层 | 位置 | 职责 |
304
+ |---|---|---|
305
+ | 前台 (front) | 主题章节的 G1-G5 页 | 人写人读的知识页;双语孪生、进索引;只放提炼后的内容与链接 |
306
+ | 后台 (backstage) | `_meta/` 页 + 本地镜像文件 | 机器记账:page-map 缓存页、迁移 checkpoint、reading loop 哨兵文件;不参与人读正文 |
307
+ | 证据 (evidence) | `_evidence/` | 超 10 行原始件(日志、转写、大 diff)的归宿:单语 en、不发布(匿名访问 404 是 by design),人类页面只链接不复制 |
308
+
309
+ 用法示例:
310
+
311
+ ```jsonc
312
+ // 大段原始材料先落证据页,再在人读页附录里给链接
313
+ historian_page_create({ path: "_evidence/<topic>--<yyyymmdd>", tier: "evidence", content: "<原始件全文>" })
314
+ ```
315
+
316
+ - **软提醒语义(soft advisory)**:写前台页时若内容含超过 30 行的围栏代码块,工具结果附一条 `advisory`,提示把原始件搬到 `_evidence/` 页、正文改放决定性摘录(每段 ≤10 行)+ 证据页链接 + 外部链接(commit/PR/告警)。提醒归提醒,写入永不阻断;证据层页自身不跑这项检查。
317
+ - **镜像与快照页分工**:本地镜像 `~/.config/opencode/historian-map.json` 是活查询的唯一来源(`historian_map show` 直接读它);`_meta/page-map` wiki 页是审计账本,`historian_map refresh` 每次提交一个新修订,wiki 的页面历史即全库变更时间线。
318
+
282
319
  ## wiki.js 用法与定制化 / Usage Guide
283
320
 
284
321
  ### Markdown 速查
@@ -348,7 +385,7 @@ wiki.js GraphQL API 有 9 个常见陷阱。插件在内部处理了每一个(
348
385
 
349
386
  1. 重命名旧 skill 文件:`mv ~/.config/opencode/skills/historian.md ~/.config/opencode/skills/historian.md.v2-disabled`
350
387
  2. 在 `opencode.json[c]` 的 `plugin` 数组中添加 `opencode-wiki-historian`
351
- 3. 重启 opencode,`/historian` 命令可用即表示 v4 skill 已注入
388
+ 3. 重启 opencode,`/historian` 命令可用即表示 v5 skill 已注入
352
389
 
353
390
  ## 运维 / Operations
354
391
 
@@ -4,6 +4,7 @@
4
4
  * Kept out of map.ts to hold both modules under the 250 pure-LOC ceiling.
5
5
  */
6
6
  import { classifyGenre } from './templates/genres.js';
7
+ import { INTERNAL_NAMESPACES } from './tools/shared.js';
7
8
  const DAY_MS = 86_400_000;
8
9
  const WEEK_MS = 7 * DAY_MS;
9
10
  /** ISO-8601 week key: the Thursday inside the row's Monday-start week fixes
@@ -42,7 +43,12 @@ export function buildChronology(rows, opts) {
42
43
  const cutoff = opts?.days === undefined
43
44
  ? Number.NEGATIVE_INFINITY
44
45
  : (opts.now ?? new Date()).getTime() - opts.days * DAY_MS;
46
+ // Machine-namespace rows (INTERNAL_NAMESPACES) are dropped before grouping:
47
+ // the ledger page and evidence raw material are not wiki content, and their
48
+ // churn would drown the human timeline. All other namespaces (incl.
49
+ // `_sandbox`) stay visible.
45
50
  const kept = rows
51
+ .filter((r) => !INTERNAL_NAMESPACES.includes(r.path.split('/')[0]))
46
52
  .map((r) => ({ r, t: Date.parse(r.updatedAt) }))
47
53
  .filter((x) => !Number.isNaN(x.t) && x.t >= cutoff)
48
54
  .sort((a, b) => b.t - a.t ||
package/dist/config.d.ts CHANGED
@@ -37,9 +37,12 @@ export interface HistorianOptions {
37
37
  * real authorization gate. Consumers must treat [] as allow-any. */
38
38
  readonly sections: readonly string[];
39
39
  readonly locales: readonly string[];
40
- /** v2 reading-loop gate: true (the shipped default) makes the plugin push a
41
- * consult-the-wiki advisory block into every chat request's system array via
42
- * the experimental.chat.system.transform hook. false = hook is a pure no-op. */
40
+ /** v3 reading-loop gate (double signal): false by default. The plugin pushes
41
+ * a consult-the-wiki advisory block into every chat request's system array
42
+ * via the experimental.chat.system.transform hook ONLY when this option is
43
+ * true AND the local confirmation sentinel agrees (src/loop-state.ts:
44
+ * ~/.config/opencode/historian-reading-loop.json, written by hand).
45
+ * 默认 false,true 需配置+哨兵双确认;either signal missing = pure no-op. */
43
46
  readonly readingLoop: boolean;
44
47
  /** v2 capture gate: reminder-only. When enabled the event hook fires one
45
48
  * TUI toast per idle session nudging the agent/user to run the
@@ -79,8 +82,9 @@ export declare const DEFAULT_TRANSLATE_MODEL = "qwen3.7-plus";
79
82
  /** Empty = no path-prefix restriction (see HistorianOptions.sections). */
80
83
  export declare const DEFAULT_SECTIONS: readonly string[];
81
84
  export declare const DEFAULT_LOCALES: readonly ["en", "zh"];
82
- /** Reading loop is on unless explicitly disabled (plan v2 todo 8). */
83
- export declare const DEFAULT_READING_LOOP = true;
85
+ /** Reading loop is opt-in: activated only when configured true AND the local
86
+ * sentinel confirms it (plan v3 todo 1; see src/loop-state.ts). */
87
+ export declare const DEFAULT_READING_LOOP = false;
84
88
  /** Capture reminders are opt-in (plan v2 todo 9). */
85
89
  export declare const DEFAULT_CAPTURE_ENABLED = false;
86
90
  /**
package/dist/config.js CHANGED
@@ -27,8 +27,9 @@ export const DEFAULT_TRANSLATE_MODEL = 'qwen3.7-plus';
27
27
  /** Empty = no path-prefix restriction (see HistorianOptions.sections). */
28
28
  export const DEFAULT_SECTIONS = [];
29
29
  export const DEFAULT_LOCALES = ['en', 'zh'];
30
- /** Reading loop is on unless explicitly disabled (plan v2 todo 8). */
31
- export const DEFAULT_READING_LOOP = true;
30
+ /** Reading loop is opt-in: activated only when configured true AND the local
31
+ * sentinel confirms it (plan v3 todo 1; see src/loop-state.ts). */
32
+ export const DEFAULT_READING_LOOP = false;
32
33
  /** Capture reminders are opt-in (plan v2 todo 9). */
33
34
  export const DEFAULT_CAPTURE_ENABLED = false;
34
35
  // --- Resolution -------------------------------------------------------------
package/dist/index.d.ts CHANGED
@@ -8,8 +8,11 @@
8
8
  * - config: mutates cfg.skills.paths to ship the bundled skills/ directory
9
9
  * and registers the /historian-capture command (todo 9)
10
10
  * - tool: 10 historian_* tools wired by buildTools(opts)
11
- * - experimental.chat.system.transform: pushes the historian-first reading
12
- * loop advisory onto output.system[] (gated by opts.readingLoop)
11
+ * - experimental.chat.system.transform: merges the historian-first reading
12
+ * loop advisory into the LAST system block (single-block-safe append — a
13
+ * second entry is never added). v3 double gate: 默认 false,true 需配置+哨兵双确认.
14
+ * Both the readingLoop option and the on-machine confirmation sentinel
15
+ * (src/loop-state.ts, re-read per request, never cached) must pass.
13
16
  * - event: on session.idle emits ONE capture reminder toast per session
14
17
  * (gated by opts.capture.enabled; reminder-only — the page write happens
15
18
  * through /historian-capture -> historian_page_create, never here)
package/dist/index.js CHANGED
@@ -8,8 +8,11 @@
8
8
  * - config: mutates cfg.skills.paths to ship the bundled skills/ directory
9
9
  * and registers the /historian-capture command (todo 9)
10
10
  * - tool: 10 historian_* tools wired by buildTools(opts)
11
- * - experimental.chat.system.transform: pushes the historian-first reading
12
- * loop advisory onto output.system[] (gated by opts.readingLoop)
11
+ * - experimental.chat.system.transform: merges the historian-first reading
12
+ * loop advisory into the LAST system block (single-block-safe append — a
13
+ * second entry is never added). v3 double gate: 默认 false,true 需配置+哨兵双确认.
14
+ * Both the readingLoop option and the on-machine confirmation sentinel
15
+ * (src/loop-state.ts, re-read per request, never cached) must pass.
13
16
  * - event: on session.idle emits ONE capture reminder toast per session
14
17
  * (gated by opts.capture.enabled; reminder-only — the page write happens
15
18
  * through /historian-capture -> historian_page_create, never here)
@@ -22,7 +25,9 @@
22
25
  * contract.
23
26
  */
24
27
  import { fileURLToPath } from 'url';
28
+ import { homedir } from 'node:os';
25
29
  import { resolveOptions } from './config.js';
30
+ import { isReadingLoopConfirmed, loopStatePath } from './loop-state.js';
26
31
  import { buildTools } from './tools.js';
27
32
  /** Resolve the absolute path to the bundled skills/ directory. Uses
28
33
  * import.meta.url so it works whether loaded from dist/ (compiled) or src/
@@ -55,8 +60,9 @@ const CAPTURE_COMMAND_TEMPLATE = [
55
60
  '',
56
61
  '1. Draft four sections: 过程/Process (what was done, in order), 原因/Cause (why it was needed), 后果/Consequence (impact, artifacts), 改进/Improvement (follow-ups, preventions).',
57
62
  "2. Run historian_map action:'show' to see existing sections, then choose a short factual path under one.",
58
- '3. 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.',
59
- '4. Echo both page URLs (en + zh) back to the user.',
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.',
60
66
  ].join('\n');
61
67
  const CAPTURE_TOAST_MESSAGE = '会话空闲:有值得留存的决定/修复/踩坑就跑 /historian-capture。Session idle — run /historian-capture if it produced decisions, fixes, or pitfalls worth keeping.';
62
68
  async function server(input, options) {
@@ -71,6 +77,14 @@ async function server(input, options) {
71
77
  console.error('[opencode-historian] Failed to resolve plugin options; tools disabled.', err instanceof Error ? err.message : err);
72
78
  return {};
73
79
  }
80
+ // Once per plugin load (never in the per-request hot path): a config-only
81
+ // opt-in leaves the loop dark because this machine has not confirmed it —
82
+ // point at the missing second signal instead of silently no-oping forever.
83
+ if (opts.readingLoop === true && !isReadingLoopConfirmed(homedir())) {
84
+ console.error('[opencode-historian] readingLoop enabled in config but not confirmed on this machine; to activate, create ' +
85
+ loopStatePath(homedir()) +
86
+ ' with {"version":1,"confirmed":true}');
87
+ }
74
88
  const captureReminded = new Set();
75
89
  const hooks = {
76
90
  config: async (cfg) => {
@@ -90,9 +104,19 @@ async function server(input, options) {
90
104
  try {
91
105
  if (opts.readingLoop !== true)
92
106
  return;
107
+ if (!isReadingLoopConfirmed(homedir()))
108
+ return;
93
109
  if (output.system.some((block) => block.includes('historian_search')))
94
110
  return;
95
- output.system.push(READING_LOOP_ADVISORY);
111
+ if (output.system.length === 0) {
112
+ output.system.push(READING_LOOP_ADVISORY);
113
+ }
114
+ else {
115
+ // Single-block-safe merge: a second system entry makes strict chat
116
+ // templates (e.g. vLLM) reject the request with HTTP 400.
117
+ const last = output.system.length - 1;
118
+ output.system[last] += '\n\n' + READING_LOOP_ADVISORY;
119
+ }
96
120
  }
97
121
  catch (err) {
98
122
  // A broken inject must never crash a chat request (plan v2 todo 8).
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Sentinel state for the v3 reading loop (double gate, config + machine).
3
+ *
4
+ * The reading loop activates only when BOTH signals agree: the plugin option
5
+ * `readingLoop: true` (see src/config.ts) and a confirmation sentinel written
6
+ * BY HAND on this machine at '<home>/.config/opencode/historian-reading-loop.json'
7
+ * holding {"version":1,"confirmed":true}. Agent self-enablement is forbidden by
8
+ * design, so the reader here is deliberately tolerant: a missing file, bad
9
+ * JSON, wrong shape, mismatched version, or any read error yields false.
10
+ * This function NEVER throws.
11
+ *
12
+ * The home directory is an injectable parameter so unit tests drive fixtures
13
+ * under tmp dirs; the real ~/.config is never touched (same discipline as
14
+ * src/config.ts / test/config.test.ts).
15
+ */
16
+ /** Sentinel schema version accepted by the reader (strict equality, not truthy). */
17
+ export declare const LOOP_STATE_VERSION = 1;
18
+ /** Path of the human-written reading-loop confirmation sentinel under a home dir. */
19
+ export declare function loopStatePath(home: string): string;
20
+ /**
21
+ * True only when the sentinel exists and parses to an object with
22
+ * version === LOOP_STATE_VERSION and confirmed === true (both strict).
23
+ * Parsed via parseJsonc, so comments in the sentinel are a bonus feature.
24
+ *
25
+ * Pure-sync and intentionally NOT cached: callers re-check per request so a
26
+ * mid-session sentinel write or removal takes effect immediately.
27
+ */
28
+ export declare function isReadingLoopConfirmed(home: string): boolean;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Sentinel state for the v3 reading loop (double gate, config + machine).
3
+ *
4
+ * The reading loop activates only when BOTH signals agree: the plugin option
5
+ * `readingLoop: true` (see src/config.ts) and a confirmation sentinel written
6
+ * BY HAND on this machine at '<home>/.config/opencode/historian-reading-loop.json'
7
+ * holding {"version":1,"confirmed":true}. Agent self-enablement is forbidden by
8
+ * design, so the reader here is deliberately tolerant: a missing file, bad
9
+ * JSON, wrong shape, mismatched version, or any read error yields false.
10
+ * This function NEVER throws.
11
+ *
12
+ * The home directory is an injectable parameter so unit tests drive fixtures
13
+ * under tmp dirs; the real ~/.config is never touched (same discipline as
14
+ * src/config.ts / test/config.test.ts).
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { parseJsonc, isRecord } from './jsonc.js';
19
+ /** Sentinel schema version accepted by the reader (strict equality, not truthy). */
20
+ export const LOOP_STATE_VERSION = 1;
21
+ /** Path of the human-written reading-loop confirmation sentinel under a home dir. */
22
+ export function loopStatePath(home) {
23
+ return join(home, '.config', 'opencode', 'historian-reading-loop.json');
24
+ }
25
+ /**
26
+ * True only when the sentinel exists and parses to an object with
27
+ * version === LOOP_STATE_VERSION and confirmed === true (both strict).
28
+ * Parsed via parseJsonc, so comments in the sentinel are a bonus feature.
29
+ *
30
+ * Pure-sync and intentionally NOT cached: callers re-check per request so a
31
+ * mid-session sentinel write or removal takes effect immediately.
32
+ */
33
+ export function isReadingLoopConfirmed(home) {
34
+ const path = loopStatePath(home);
35
+ let parsed;
36
+ try {
37
+ parsed = parseJsonc(readFileSync(path, 'utf8'), path);
38
+ }
39
+ catch {
40
+ return false; // missing file, unreadable, or malformed JSONC — all mean "not confirmed"
41
+ }
42
+ if (!isRecord(parsed)) {
43
+ return false; // top-level array / string / number / null — wrong shape
44
+ }
45
+ return parsed.version === LOOP_STATE_VERSION && parsed.confirmed === true;
46
+ }
package/dist/map.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Locale-aware page map (todo 10): full cross-locale inventory with en/zh twin
3
3
  * pairing, rendered as the markdown `_meta/page-map` cache page + local mirror.
4
+ *
5
+ * Intended readers: harness cache-read scripts, the admin UI, and
6
+ * remote/cross-machine review. Role split — live queries (getMap) read the
7
+ * LOCAL MIRROR (historian-map.json) only and never this wiki page; the wiki
8
+ * page exists as the audit ledger (each refresh commits a new revision) and
9
+ * for human inspection.
4
10
  */
5
11
  import type { HistorianOptions } from './config.js';
6
12
  import type { GqlClient } from './wiki/client.js';
@@ -40,7 +46,7 @@ export declare function mirrorPath(home: string): string;
40
46
  * namespace pages are excluded — they are not anonymously reachable), twins
41
47
  * paired by exact path, rows sorted by path then locale. */
42
48
  export declare function buildPageMap(deps: MapDeps): Promise<PageMap>;
43
- export declare function renderMapMarkdown(rows: readonly MapRow[]): string;
49
+ export declare function renderMapMarkdown(rows: readonly MapRow[], generatedAt: string): string;
44
50
  export interface RefreshOptions {
45
51
  readonly now?: Date;
46
52
  readonly homeDir?: string;
package/dist/map.js CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Locale-aware page map (todo 10): full cross-locale inventory with en/zh twin
3
3
  * pairing, rendered as the markdown `_meta/page-map` cache page + local mirror.
4
+ *
5
+ * Intended readers: harness cache-read scripts, the admin UI, and
6
+ * remote/cross-machine review. Role split — live queries (getMap) read the
7
+ * LOCAL MIRROR (historian-map.json) only and never this wiki page; the wiki
8
+ * page exists as the audit ledger (each refresh commits a new revision) and
9
+ * for human inspection.
4
10
  */
5
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
12
  import { homedir } from 'node:os';
@@ -89,9 +95,17 @@ export async function buildPageMap(deps) {
89
95
  return { rows, stats: { rows: rows.length, paths: sortedPaths.length, perLocale: perLocaleCounts, missingTwinPaths } };
90
96
  }
91
97
  // --- renderMapMarkdown ------------------------------------------------------
92
- export function renderMapMarkdown(rows) {
98
+ export function renderMapMarkdown(rows, generatedAt) {
99
+ const perLocale = { en: 0, zh: 0 };
100
+ for (const r of rows)
101
+ if (r.locale === 'en' || r.locale === 'zh')
102
+ perLocale[r.locale] += 1;
103
+ const preamble = `> Snapshot generated ${generatedAt} · ${rows.length} pages (${perLocale.en}/${perLocale.zh}). ` +
104
+ `This page is the audit ledger: each 'historian_map refresh' commits a new wiki revision (page ` +
105
+ `history = chronological record of the whole wiki). Live queries read the local mirror ` +
106
+ `(historian-map.json); this page serves harness audits, the admin UI, and cross-machine review.`;
93
107
  const lines = rows.map((r) => `| ${r.id} | ${r.locale} | ${r.path} | ${r.title.replaceAll('|', '\\|')} | ${r.url} | ${r.twinUrl ?? '—'} | ${r.updatedAt} |`);
94
- return [HEADER_ROW, '| --- | --- | --- | --- | --- | --- | --- |', ...lines, ''].join('\n');
108
+ return [preamble, '', HEADER_ROW, '| --- | --- | --- | --- | --- | --- | --- |', ...lines, ''].join('\n');
95
109
  }
96
110
  // --- Local mirror -----------------------------------------------------------
97
111
  function writeMirror(home, mirror) {
@@ -159,7 +173,7 @@ export async function refreshMapCache(deps, opts) {
159
173
  const home = opts?.homeDir ?? homedir();
160
174
  const { rows, stats } = await buildPageMap(deps);
161
175
  writeMirror(home, { generatedAt: now.toISOString(), rows, stats });
162
- const markdown = renderMapMarkdown(rows);
176
+ const markdown = renderMapMarkdown(rows, now.toISOString());
163
177
  const existing = await readPage(deps.client, CACHE_PATH, 'en');
164
178
  if (existing === null) {
165
179
  await createPage(deps, {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Machine-tier evidence page skeleton (v3 todo 5).
3
+ *
4
+ * Evidence pages live under the internal namespaces (`_meta/`, `_evidence/`),
5
+ * are hidden and monolingual — the wiki is their only store (no local mirror,
6
+ * no bilingual twin machinery). They are deliberately NOT genre-templated:
7
+ * the G1–G5 anatomy rubric targets human front pages, while an evidence page
8
+ * is a verbatim capture plus provenance, and nothing else.
9
+ *
10
+ * The shape mirrors the `_meta/page-map` cache precedent (src/map.ts): header
11
+ * blockquote with source/captured/context metadata, an empty fenced block the
12
+ * caller fills with the raw material verbatim, and a capture-context section
13
+ * whose three placeholder lines the caller replaces at capture time.
14
+ */
15
+ export interface EvidenceSkeletonInput {
16
+ /** Path of the human page this evidence backs (placeholder hint when unknown). */
17
+ readonly sourcePath: string;
18
+ /** URL of that human page. */
19
+ readonly sourceUrl: string;
20
+ /** ISO-8601 capture timestamp, supplied by the caller (pure function: no clock reads). */
21
+ readonly capturedAt: string;
22
+ /** One-line context for why this material was captured. */
23
+ readonly context: string;
24
+ }
25
+ /** Full markdown skeleton for one machine-tier evidence page. Pure string
26
+ * transform — deterministic, no state, no I/O; empty inputs degrade to
27
+ * visible placeholders, never `undefined` leakage. */
28
+ export declare function evidenceSkeleton(o: EvidenceSkeletonInput): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Machine-tier evidence page skeleton (v3 todo 5).
3
+ *
4
+ * Evidence pages live under the internal namespaces (`_meta/`, `_evidence/`),
5
+ * are hidden and monolingual — the wiki is their only store (no local mirror,
6
+ * no bilingual twin machinery). They are deliberately NOT genre-templated:
7
+ * the G1–G5 anatomy rubric targets human front pages, while an evidence page
8
+ * is a verbatim capture plus provenance, and nothing else.
9
+ *
10
+ * The shape mirrors the `_meta/page-map` cache precedent (src/map.ts): header
11
+ * blockquote with source/captured/context metadata, an empty fenced block the
12
+ * caller fills with the raw material verbatim, and a capture-context section
13
+ * whose three placeholder lines the caller replaces at capture time.
14
+ */
15
+ /** Full markdown skeleton for one machine-tier evidence page. Pure string
16
+ * transform — deterministic, no state, no I/O; empty inputs degrade to
17
+ * visible placeholders, never `undefined` leakage. */
18
+ export function evidenceSkeleton(o) {
19
+ return [
20
+ `> 机器层证据页 (machine-tier evidence). 来源页 (source): [${o.sourcePath}](${o.sourceUrl})` +
21
+ ` · 采集 (captured): ${o.capturedAt} · 上下文 (context): ${o.context}`,
22
+ '',
23
+ '## 原文 (verbatim)',
24
+ '',
25
+ '```',
26
+ '```',
27
+ '',
28
+ '## 采集环境 (capture context)',
29
+ '',
30
+ '- 命令 (command): `<the exact command as executed>`',
31
+ '- 目录 (cwd): `<working directory at capture time>`',
32
+ '- 时间 (time): `<capture timestamp>`',
33
+ ].join('\n');
34
+ }
@@ -106,7 +106,7 @@ export const G1_ZH = `# 页面标题(占位:写完后替换为实际标题
106
106
 
107
107
  ## 附录
108
108
 
109
- <!-- 原始证据:告警截图、日志片段、commit/PR 链接。脚注示例: -->
109
+ <!-- 决定性摘录(每段 ≤10 行)+ 证据页链接(\`_evidence/...\`,historian_page_create tier:"evidence" 建页)+ 外部链接(commit/PR/告警)。脚注示例: -->
110
110
  见脚注[^1]。
111
111
 
112
112
  [^1]: 来源链接(替换为真实出处)
@@ -197,7 +197,7 @@ export const G1_EN = `# Page Title (placeholder: replace with the real title, mu
197
197
 
198
198
  ## Appendix
199
199
 
200
- <!-- Raw evidence: alert screenshots, log excerpts, commit/PR links. Footnote example: -->
200
+ <!-- Decisive excerpts (≤10 lines each) + evidence-page links (\`_evidence/...\`, created via historian_page_create with tier:"evidence") + external links (commits/PRs/alerts). Footnote example: -->
201
201
  See footnote[^1].
202
202
 
203
203
  [^1]: Source link (replace with the real source)
@@ -500,7 +500,7 @@ export const G5_ZH = `# 页面标题(占位:写完后替换为实际标题
500
500
  | --- | --- | --- |
501
501
  | YYYY-MM-DD | 初版快照 | — |
502
502
 
503
- <!-- 追加式小表:只记影响清单行的变更。完整历史写 G1 事件页并互链。 -->
503
+ <!-- 追加式小表:只记影响清单行的变更。完整历史写 G1 事件页并互链;「依据」可指向证据页 URL(\`_evidence/...\`)。 -->
504
504
 
505
505
  ## 相关页面
506
506
 
@@ -551,7 +551,7 @@ export const G5_EN = `# Page Title (placeholder: replace with the real title, mu
551
551
  | --- | --- | --- |
552
552
  | YYYY-MM-DD | initial snapshot | — |
553
553
 
554
- <!-- Append-only mini table for list-affecting changes. Full history lives in G1 event pages. -->
554
+ <!-- Append-only mini table for list-affecting changes. Full history lives in G1 event pages; Evidence may point at an evidence-page URL (\`_evidence/...\`). -->
555
555
 
556
556
  ## Related Pages
557
557
 
@@ -8,7 +8,8 @@ import { tool } from '@opencode-ai/plugin';
8
8
  import { validatePath } from '../wiki/locale.js';
9
9
  import { createPage } from '../wiki/pages.js';
10
10
  import { classifyGenre, genreSkeleton } from '../templates/genres.js';
11
- import { errEnvelope, okJson, urlPair, URL_MANDATE, pageDeps } from './shared.js';
11
+ import { evidenceSkeleton } from '../templates/evidence.js';
12
+ import { enforceTierPath, errEnvelope, frontDumpAdvisory, MACHINE_TIER_NOTE, okJson, pageDeps, tierMismatchJson, TIERS, urlPair, URL_MANDATE, } from './shared.js';
12
13
  const s = tool.schema;
13
14
  const GENRES = ['G1', 'G2', 'G3', 'G4', 'G5'];
14
15
  const ARGS_SHAPE = {
@@ -21,6 +22,7 @@ const ARGS_SHAPE = {
21
22
  tags: s.array(s.string()).default([]),
22
23
  twin: s.boolean().default(true).describe('Auto-create the opposite-locale twin via translation'),
23
24
  description: s.string().optional(),
25
+ tier: s.enum(TIERS).default('front').describe('front = bilingual human page; evidence = machine page under _meta/ or _evidence/ (hidden, unpublished, monolingual en)'),
24
26
  };
25
27
  const ArgsSchema = s.object(ARGS_SHAPE);
26
28
  export function makeCreateTool(deps) {
@@ -31,42 +33,78 @@ export function makeCreateTool(deps) {
31
33
  args: ARGS_SHAPE,
32
34
  execute: async (raw) => {
33
35
  const args = ArgsSchema.parse(raw);
36
+ const tier = args.tier;
34
37
  try {
35
38
  validatePath(args.path);
36
39
  }
37
40
  catch (err) {
38
41
  return errEnvelope(err);
39
42
  }
43
+ const mismatch = enforceTierPath(tier, args.path);
44
+ if (mismatch !== null)
45
+ return tierMismatchJson(mismatch);
46
+ // Evidence pages are monolingual en: a zh locale is FORCED to en with an
47
+ // envelope hint — plan ruling: hint, never throw.
48
+ const isEvidence = tier === 'evidence';
49
+ const locale = isEvidence ? 'en' : args.locale;
50
+ const localeHint = isEvidence && args.locale === 'zh'
51
+ ? 'evidence pages are monolingual en — the locale argument was forced to "en"'
52
+ : undefined;
40
53
  if (args.content === undefined || args.content.trim() === '') {
54
+ if (isEvidence) {
55
+ // Evidence pages are not genre-templated: echo the machine skeleton.
56
+ // The source page is unknown at template time, so the fields ship as
57
+ // placeholder hints; the description arg (if given) is the context hint.
58
+ return okJson({
59
+ mode: 'template',
60
+ locale,
61
+ skeleton: evidenceSkeleton({
62
+ sourcePath: '<human-page-path>',
63
+ sourceUrl: '<human-page-url>',
64
+ capturedAt: new Date().toISOString(),
65
+ context: args.description ?? '<one-line context>',
66
+ }),
67
+ note: 'Nothing was written to the wiki (template mode, no content). Paste the raw material verbatim ' +
68
+ 'into the 原文 fence, replace the <human-page-path> / <human-page-url> / <one-line context> ' +
69
+ 'placeholders with the citing human page, then call historian_page_create again with tier:"evidence" and content.',
70
+ ...(localeHint === undefined ? {} : { localeHint }),
71
+ });
72
+ }
41
73
  const genre = args.genre ?? classifyGenre({ title: args.title, body: args.description ?? '' }).genre;
42
74
  return okJson({
43
75
  mode: 'template',
44
76
  genre,
45
- locale: args.locale,
46
- skeleton: genreSkeleton(genre, args.locale),
77
+ locale,
78
+ skeleton: genreSkeleton(genre, locale),
47
79
  note: 'Nothing was written to the wiki (template mode, no content). Fill the skeleton and call historian_page_create again with content.',
80
+ ...(localeHint === undefined ? {} : { localeHint }),
48
81
  });
49
82
  }
50
83
  try {
51
84
  const result = await createPage(pageDeps(deps), {
52
85
  path: args.path,
53
- locale: args.locale,
86
+ locale,
54
87
  title: args.title,
55
88
  content: args.content,
56
- tags: args.tags,
57
- isPublished: args.isPublished,
58
- twin: args.twin,
89
+ tags: isEvidence ? [...new Set([...args.tags, 'evidence'])] : args.tags,
90
+ isPublished: isEvidence ? false : args.isPublished,
91
+ isPrivate: isEvidence,
92
+ twin: isEvidence ? false : args.twin,
59
93
  description: args.description,
60
94
  });
95
+ const advisory = frontDumpAdvisory(tier, args.content);
61
96
  return okJson({
62
97
  mode: 'create',
63
98
  path: args.path,
64
- locale: args.locale,
99
+ locale,
65
100
  pageId: result.pageId,
66
101
  twinStatus: result.twinStatus,
67
102
  twinReason: result.twinReason,
68
103
  twinId: result.twinId,
69
104
  urls: urlPair(result),
105
+ ...(isEvidence ? { note: MACHINE_TIER_NOTE } : {}),
106
+ ...(localeHint === undefined ? {} : { localeHint }),
107
+ ...(advisory === null ? {} : { advisory }),
70
108
  });
71
109
  }
72
110
  catch (err) {
@@ -62,7 +62,8 @@ const MapArgsSchema = s.object(MAP_ARGS);
62
62
  export function makeMapTool(deps) {
63
63
  return tool({
64
64
  description: `Inspect (show), rebuild (refresh), or aggregate recent updates (timeline) over the en/zh page map ` +
65
- `with its local mirror + _meta/page-map cache page. ` +
65
+ `with its local mirror + _meta/page-map cache page. Two roles: the local mirror is the live query ` +
66
+ `source for show/timeline; the wiki page is the audit ledger (every refresh commits a new wiki revision). ` +
66
67
  `show reads the local mirror (zero writes); refresh rebuilds from the wiki and writes the mirror + cache page ` +
67
68
  `(idempotent — the engine upserts via full RMW); timeline groups mirror rows by ISO week (newest first, ` +
68
69
  `optional days window + section/path prefix filter) into a human markdown table + machine-readable weeks JSON. ` +
@@ -50,3 +50,30 @@ export declare function okJson(body: Record<string, unknown>): ToolResult;
50
50
  export declare function errEnvelope(err: unknown): ToolResult;
51
51
  /** Destructive-action gate: refusal BEFORE any fetch when confirm is absent. */
52
52
  export declare function confirmRequiredJson(toolName: string, got: unknown): ToolResult;
53
+ /** Machine namespaces: pages whose first path segment lives here are hidden
54
+ * machine-tier pages (the `_meta/page-map` cache precedent in src/map.ts —
55
+ * isPublished:false + isPrivate:true + tags + twin:false). */
56
+ export declare const INTERNAL_NAMESPACES: readonly ["_meta", "_evidence"];
57
+ /** Plan-mandated note on every evidence-tier success envelope (verbatim). */
58
+ export declare const MACHINE_TIER_NOTE = "machine-tier page; anonymous visits 404 by design";
59
+ /** Tier enum values — single source of truth. The zod schema itself must be
60
+ * declared locally per tool via `s.enum(TIERS)`: exporting a zod value from
61
+ * here breaks declaration emit (TS2742 — tool.schema is zod 4.1.8 nested in
62
+ * @opencode-ai/plugin, unnameable without a zod import, and root zod is v3). */
63
+ export declare const TIERS: readonly ["front", "evidence"];
64
+ /** Page tier: 'front' = bilingual human surface; 'evidence' = machine namespace. */
65
+ export type Tier = (typeof TIERS)[number];
66
+ /** True when the path's first segment is an internal (machine) namespace. */
67
+ export declare function isInternalPath(path: string): boolean;
68
+ /** Pure tier↔path guard: null when the pair is legal, an error string when
69
+ * not (evidence ⇒ internal namespace; front ⇒ NOT internal). */
70
+ export declare function enforceTierPath(tier: Tier, path: string): string | null;
71
+ /** Tier↔path mismatch → uniform failure envelope (no fetch has run yet). */
72
+ export declare function tierMismatchJson(message: string): ToolResult;
73
+ /** Evidence-tier zh-side input → refusal envelope (monolingual invariant). */
74
+ export declare function monolingualRefusalJson(toolName: string, argumentName: string): ToolResult;
75
+ /** Soft-gate advisory for front-tier raw dumps: null when nothing to say
76
+ * (evidence tier NEVER checked — machine pages are the raw-material home;
77
+ * fence at or under the limit). The advisory is informational only: every
78
+ * caller still performs the write. */
79
+ export declare function frontDumpAdvisory(tier: Tier, content: string): string | null;
@@ -85,3 +85,103 @@ function hintFor(errorKind) {
85
85
  return 'Inspect the message and retry.';
86
86
  }
87
87
  }
88
+ // --- Tier plumbing (v3 todo 4) ------------------------------------------------
89
+ /** Machine namespaces: pages whose first path segment lives here are hidden
90
+ * machine-tier pages (the `_meta/page-map` cache precedent in src/map.ts —
91
+ * isPublished:false + isPrivate:true + tags + twin:false). */
92
+ export const INTERNAL_NAMESPACES = ['_meta', '_evidence'];
93
+ /** Plan-mandated note on every evidence-tier success envelope (verbatim). */
94
+ export const MACHINE_TIER_NOTE = 'machine-tier page; anonymous visits 404 by design';
95
+ /** Tier enum values — single source of truth. The zod schema itself must be
96
+ * declared locally per tool via `s.enum(TIERS)`: exporting a zod value from
97
+ * here breaks declaration emit (TS2742 — tool.schema is zod 4.1.8 nested in
98
+ * @opencode-ai/plugin, unnameable without a zod import, and root zod is v3). */
99
+ export const TIERS = ['front', 'evidence'];
100
+ /** True when the path's first segment is an internal (machine) namespace. */
101
+ export function isInternalPath(path) {
102
+ const first = path.split('/')[0];
103
+ return INTERNAL_NAMESPACES.includes(first);
104
+ }
105
+ /** Pure tier↔path guard: null when the pair is legal, an error string when
106
+ * not (evidence ⇒ internal namespace; front ⇒ NOT internal). */
107
+ export function enforceTierPath(tier, path) {
108
+ const internal = isInternalPath(path);
109
+ switch (tier) {
110
+ case 'evidence':
111
+ return internal
112
+ ? null
113
+ : `tier "evidence" requires a machine namespace path (${INTERNAL_NAMESPACES.map((n) => `${n}/`).join(' or ')}) — got '${path}'`;
114
+ case 'front':
115
+ return internal
116
+ ? `tier "front" cannot write to the machine namespace '${path}' — use tier "evidence" for ${INTERNAL_NAMESPACES.map((n) => `${n}/`).join(' or ')} paths`
117
+ : null;
118
+ }
119
+ }
120
+ /** Tier↔path mismatch → uniform failure envelope (no fetch has run yet). */
121
+ export function tierMismatchJson(message) {
122
+ return dump({
123
+ ok: false,
124
+ error: 'tier-path-mismatch',
125
+ errorKind: 'TierPathMismatchError',
126
+ message,
127
+ actionableHint: 'Pass the tier that matches the path namespace: _meta/ and _evidence/ are machine (evidence) paths; everything else is front.',
128
+ });
129
+ }
130
+ /** Evidence-tier zh-side input → refusal envelope (monolingual invariant). */
131
+ export function monolingualRefusalJson(toolName, argumentName) {
132
+ return dump({
133
+ ok: false,
134
+ error: 'evidence-monolingual',
135
+ errorKind: 'TierMonolingualError',
136
+ message: `${toolName} received "${argumentName}" on an evidence-tier page, which is monolingual en`,
137
+ actionableHint: 'Drop the zh-side argument (locale "zh" / sectionZh) — evidence machine pages never carry a bilingual twin.',
138
+ });
139
+ }
140
+ // --- Front-tier raw-dump soft gate (v3 todo 6) --------------------------------
141
+ /** Contract band (SKILL.md SYN-16): a human page stays a 5-10 line excerpt;
142
+ * longer raw material belongs on an evidence page. Strictly ABOVE this many
143
+ * fence-interior lines triggers the advisory — 30 is the soft gate, never a
144
+ * hard refusal. Deliberately layered vs the contract bands; do not unify. */
145
+ const FRONT_DUMP_FENCE_LIMIT = 30;
146
+ /** Longest fenced code block in the content, counting lines STRICTLY inside
147
+ * the ``` fences (fence markers excluded). An unterminated fence counts to
148
+ * EOF. Fences opening at line start (after optional indentation) close on
149
+ * the next ```-leading line. */
150
+ function longestFenceLines(content) {
151
+ let longest = 0;
152
+ let inside = false;
153
+ let count = 0;
154
+ for (const line of content.split('\n')) {
155
+ if (line.trimStart().startsWith('```')) {
156
+ if (inside) {
157
+ if (count > longest)
158
+ longest = count;
159
+ inside = false;
160
+ count = 0;
161
+ }
162
+ else {
163
+ inside = true;
164
+ count = 0;
165
+ }
166
+ }
167
+ else if (inside) {
168
+ count++;
169
+ }
170
+ }
171
+ if (inside && count > longest)
172
+ longest = count; // unterminated fence → EOF
173
+ return longest;
174
+ }
175
+ /** Soft-gate advisory for front-tier raw dumps: null when nothing to say
176
+ * (evidence tier NEVER checked — machine pages are the raw-material home;
177
+ * fence at or under the limit). The advisory is informational only: every
178
+ * caller still performs the write. */
179
+ export function frontDumpAdvisory(tier, content) {
180
+ if (tier !== 'front')
181
+ return null;
182
+ const lines = longestFenceLines(content);
183
+ if (lines <= FRONT_DUMP_FENCE_LIMIT)
184
+ return null;
185
+ return (`content contains a ${lines}-line fenced block; per contract, move raw material to a ` +
186
+ `tier:"evidence" page under _evidence/ and link it from the human page (SYN-16)`);
187
+ }
@@ -7,7 +7,7 @@
7
7
  import { tool } from '@opencode-ai/plugin';
8
8
  import { appendSection, createPage, updatePage, PageNotFoundError } from '../wiki/pages.js';
9
9
  import { readPage } from '../wiki/pages.read.js';
10
- import { errEnvelope, okJson, urlPair, URL_MANDATE, pageDeps } from './shared.js';
10
+ import { enforceTierPath, errEnvelope, frontDumpAdvisory, isInternalPath, MACHINE_TIER_NOTE, monolingualRefusalJson, okJson, tierMismatchJson, TIERS, urlPair, URL_MANDATE, pageDeps, } from './shared.js';
11
11
  const s = tool.schema;
12
12
  const UPDATE_ARGS = {
13
13
  path: s.string(),
@@ -36,6 +36,7 @@ export function makeUpdateTool(deps) {
36
36
  description: args.description,
37
37
  tags: args.tags,
38
38
  });
39
+ const advisory = frontDumpAdvisory(inferredTier(page.path), args.content ?? '');
39
40
  return okJson({
40
41
  mode: 'update',
41
42
  path: args.path,
@@ -51,6 +52,7 @@ export function makeUpdateTool(deps) {
51
52
  updatedAt: result.page.updatedAt,
52
53
  },
53
54
  urls: urlPair(result),
55
+ ...(advisory === null ? {} : { advisory }),
54
56
  });
55
57
  }
56
58
  catch (err) {
@@ -59,6 +61,11 @@ export function makeUpdateTool(deps) {
59
61
  },
60
62
  });
61
63
  }
64
+ /** Path-only tier resolution (same rule the append fallback uses): an
65
+ * internal namespace first segment ⇒ evidence, anything else ⇒ front. */
66
+ function inferredTier(path) {
67
+ return isInternalPath(path) ? 'evidence' : 'front';
68
+ }
62
69
  async function bootstrapTwin(deps, primary, section, source) {
63
70
  const translate = source === 'translate' ? deps.translate : undefined;
64
71
  try {
@@ -84,21 +91,40 @@ const APPEND_ARGS = {
84
91
  section: s.string(),
85
92
  locale: s.enum(['en', 'zh']).default('en'),
86
93
  sectionZh: s.string().optional().describe('Explicit zh section; when absent the zh side falls back to translation/wiring'),
94
+ tier: s.enum(TIERS).optional().describe('Explicit tier; absent → inferred from the path (first segment _meta/ or _evidence/ ⇒ evidence, else front)'),
87
95
  };
88
96
  const AppendArgsSchema = s.object(APPEND_ARGS);
89
97
  export function makeAppendTool(deps) {
90
98
  return tool({
91
99
  description: `Append a section to an existing page (engine append + RMW). For the en page with a MISSING zh twin, ` +
92
100
  `the twin is auto-created — from sectionZh when given, else translated when the translator is wired. ` +
101
+ `Evidence-tier pages (_meta/ or _evidence/) are monolingual en — no twin handling. ` +
93
102
  `${URL_MANDATE}.`,
94
103
  args: APPEND_ARGS,
95
104
  execute: async (raw) => {
96
105
  const args = AppendArgsSchema.parse(raw);
106
+ // Exactly ONE resolution rule: explicit tier arg wins; otherwise infer
107
+ // from the path prefix (internal namespace ⇒ evidence).
108
+ const tier = args.tier ?? inferredTier(args.path);
109
+ const mismatch = enforceTierPath(tier, args.path);
110
+ if (mismatch !== null)
111
+ return tierMismatchJson(mismatch);
112
+ const isEvidence = tier === 'evidence';
113
+ if (isEvidence && args.locale === 'zh') {
114
+ return monolingualRefusalJson('historian_page_append', 'locale "zh"');
115
+ }
116
+ if (isEvidence && args.sectionZh !== undefined) {
117
+ return monolingualRefusalJson('historian_page_append', 'sectionZh');
118
+ }
97
119
  try {
98
120
  const appended = await appendSection(pageDeps(deps), args.path, args.locale, args.section);
99
121
  let zhStatus;
100
122
  let zhNote;
101
- if (args.locale === 'zh') {
123
+ if (isEvidence) {
124
+ zhStatus = 'skipped';
125
+ zhNote = 'evidence tier is monolingual en — no zh twin is bootstrapped or touched.';
126
+ }
127
+ else if (args.locale === 'zh') {
102
128
  zhStatus = 'appended';
103
129
  zhNote = 'Primary locale is zh — the en twin is untouched (check with historian_read(path, "en")).';
104
130
  }
@@ -130,6 +156,7 @@ export function makeAppendTool(deps) {
130
156
  zhNote = 'No zh twin exists and no translator is wired — provide sectionZh to bootstrap it.';
131
157
  }
132
158
  }
159
+ const advisory = frontDumpAdvisory(tier, args.section);
133
160
  return okJson({
134
161
  mode: 'append',
135
162
  path: args.path,
@@ -138,6 +165,8 @@ export function makeAppendTool(deps) {
138
165
  urls: urlPair(appended),
139
166
  zhStatus,
140
167
  zhNote,
168
+ ...(isEvidence ? { note: MACHINE_TIER_NOTE } : {}),
169
+ ...(advisory === null ? {} : { advisory }),
141
170
  });
142
171
  }
143
172
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-wiki-historian",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "opencode plugin that manages a wiki.js knowledge base with bilingual pages, genre templates, and migration tooling.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: historian
3
- description: "Wiki.js 史官插件技能:双语孪生页面管理(en/zh)、G1-G5 页型骨架、V4 机构记忆层(reading loop 自动注入 + /historian-capture 主动留痕)、可发布 OpenCode 插件。Phase 1.5 页型分类确保每页匹配正确的知识形态。操作 10 个 historian_* 工具完成搜索、阅读、创建、更新、追加、翻译、迁移、移动、删除与页面地图/时间线管理。"
3
+ description: "Wiki.js 史官插件技能:双语孪生页面管理(en/zh)、G1-G5 页型骨架、V5 机构记忆层(双信号 reading loop + /historian-capture 主动留痕 + `_evidence/` 证据页)、可发布 OpenCode 插件。Phase 1.5 页型分类确保每页匹配正确的知识形态。操作 10 个 historian_* 工具完成搜索、阅读、创建、更新、追加、翻译、迁移、移动、删除与页面地图/时间线管理。"
4
4
  ---
5
5
 
6
- # 史官 (Historian) — 行为契约 V4 机构记忆层
6
+ # 史官 (Historian) — 行为契约 V5 机构记忆层
7
7
 
8
8
  你是史官:本地 Wiki.js 知识库的策展人。不是文字搬运工,而是决定**什么值得成页、放在哪里、如何组织、链接给谁**的编辑。每次变更必须让 wiki 更有序。
9
9
 
@@ -15,6 +15,14 @@ description: "Wiki.js 史官插件技能:双语孪生页面管理(en/zh)
15
15
  4. **每页可达**。无入链的页面是孤儿债。创建的每个页面在同一次运行中拿到反向链接。
16
16
  5. **索引是每次变更的一部分**。让 wiki-index 过期的变更是未完成的变更。
17
17
 
18
+ ### 分层职责 (Tier responsibilities)
19
+
20
+ | 层 | 位置 | 职责 |
21
+ |----|------|------|
22
+ | 前台 (front) | 主题章节的 G1-G5 页 | 人写人读的知识页;双语孪生、进索引;只放提炼后的内容 + 链接 |
23
+ | 后台 (backstage) | `_meta/` + 本地镜像 | 机器记账:cache map、时间线、哨兵文件;不参与人读正文 |
24
+ | 证据 (evidence) | `_evidence/` | 原始件超 10 行时的归宿(`historian_page_create` 传 `tier:"evidence"`):单语 en、不发布;人工页面只引用其 URL |
25
+
18
26
  ---
19
27
 
20
28
  ## Phase 0: 分诊 (Triage)
@@ -60,7 +68,7 @@ description: "Wiki.js 史官插件技能:双语孪生页面管理(en/zh)
60
68
 
61
69
  - **无教训的临时操作** — "重启了容器就好了,不知道为什么"没有可复用知识。故障复盘页至少需要根因或可复现的修复。
62
70
  - **秘密** — 凭证、token、私钥绝不入 wiki。
63
- - **原始转储** — 聊天记录和 shell 输出是原材料,不是页面内容。先提取。
71
+ - **原始转储** — 聊天记录和 shell 输出是原材料,不是页面内容。先提取决定性摘录(每段 ≤10 行);完整原文转存 `_evidence/` 证据页(`tier:"evidence"`),页面里只放链接。
64
72
  - **重复** — 已有页面覆盖的知识 → 整合到那里,不要创建新页。
65
73
  - **琐碎临时** — 今天的时间戳状态,明天就过时。
66
74
 
@@ -243,16 +251,18 @@ historian_map action=refresh
243
251
 
244
252
  ---
245
253
 
246
- ## 机构记忆层 (v4):reading loop 与 capture
254
+ ## 机构记忆层 (v5):reading loop 与 capture
247
255
 
248
256
  插件从"被动工具集"升级为"机构记忆层":机器侧两个机制,均不影响下述人工流程。
249
257
 
250
- ### Reading loop(自动注入,默认开启)
258
+ ### Reading loop(自动注入,默认关闭)
251
259
 
252
260
  插件经 `experimental.chat.system.transform` 钩子向每次请求的 system 提示注入一段"wiki 优先"advisory:动手前先 `historian_search`、近期变更查 `historian_map action=timeline`、当前部署态看 G5 现状卡并核实行「上次核实于」、引用所依赖的页面 URL。agent 的义务是**执行**它,不是忽略它。
253
261
 
254
- - 选项 `readingLoop` 默认 `true`;关闭用插件二元组第二参数:`["<plugin-url>", { "readingLoop": false }]`。
255
- - 严格 OpenAI 兼容后端(如 vLLM)拒绝多条 system 消息(报 `System message must be at the beginning.`)——此类部署必须设 `readingLoop: false`。
262
+ - 双信号门控:仅当**同时**满足两条信号才注入——插件二元组第二参数配 `"readingLoop": true`,**且**本机存在哨兵文件 `~/.config/opencode/historian-reading-loop.json`(内容 `{"version":1,"confirmed":true}`)。任一缺失即不注入。开关默认 false,开启需配置+哨兵双确认;启用步骤见 `references/adapting-your-own-wiki.md`。
263
+ - 配置已开而哨兵缺失时,插件加载期打一条 console.error(给出哨兵路径与内容),不会静默失灵。
264
+ - 注入语义为**单块追加**:advisory 拼接到 system 提示的最后一个块(`\n\n` 分隔),system 为空数组时才新建块——绝不产生第二条 system 消息。严格 OpenAI 兼容后端(如 vLLM)会以 `System message must be at the beginning.` 拒绝多 system 请求,单块追加从根上规避此坑。
265
+ - 幂等去重:同一请求的任一 system 块已含 `historian_search` 字样则跳过注入。
256
266
 
257
267
  ### Capture(主动留痕,默认关闭)
258
268
 
@@ -39,15 +39,25 @@ Six-step self-onboarding for pointing the historian plugin at your own Wiki.js i
39
39
  - 任何进入包/文档的内容不得包含:真实主机名、真实用户路径(`/home/...`)、token、真实私有部署的页面标题与章节表。
40
40
  - 硬门:`node tools/privacy-audit.mjs` 扫描 `npm pack` 全部文件(含 `dist/` 与 `skills/`)。改过任何随包文本后必须跑到 exit 0 再发布。
41
41
 
42
- ## 6. 机构记忆层开关 / reading-loop & capture switches
42
+ ## 6. 机构记忆层开关与证据层 / reading-loop & capture switches, evidence tier
43
43
 
44
- - `readingLoop` 默认 `true`:插件向每次请求的 system 注入"先查 wiki"提示。严格 OpenAI 兼容后端(如 vLLM)会拒绝多条 system 消息——此类部署设 `"readingLoop": false`。
44
+ - `readingLoop` 开关默认关闭(false),true 需配置+哨兵双确认:插件向每次请求的 system 注入"先查 wiki"提示,但只有配置信号与本机哨兵文件同时到位才生效。注入是单块合并(追加到最后一个 system 块),绝不产生第二条 system 消息——严格 OpenAI 兼容后端(如 vLLM)同样安全。
45
+ - 哨兵文件必须由人在本机写入(agent 自我启用被禁止):
46
+
47
+ ```bash
48
+ cat > ~/.config/opencode/historian-reading-loop.json <<'EOF'
49
+ {"version":1,"confirmed":true}
50
+ EOF
51
+ ```
52
+
53
+ - 缺一不生效:只配了选项而没有哨兵,插件加载期打一条提示(给出哨兵路径与内容),不注入;删除哨兵文件即刻回退。
45
54
  - `capture.enabled` 默认 `false`:置 `true` 后会话空闲时弹一条 toast 提醒;**只提醒、绝不自动写页**。`/historian-capture` 命令始终注册,与此开关无关。
55
+ - 证据层接入:会话产出的原始件(日志、转写、大 diff)超过 10 行时,用 `historian_page_create` 传 `tier: "evidence"` 存入 `_evidence/<主题>--<yyyymmdd>`(单语 en、不发布、匿名访问 404 属 by design),人读页附录只放决定性摘录加链接。往人读页写超过 30 行的围栏块会收到一条 `advisory` 软提醒,不阻断写入。
46
56
 
47
57
  ```jsonc
48
- ["opencode-historian", { "readingLoop": false, "capture": { "enabled": true } }]
58
+ ["opencode-historian", { "readingLoop": true, "capture": { "enabled": true } }]
49
59
  ```
50
60
 
51
61
  ---
52
62
 
53
- 配完六步,你的史官即就位:consult(reading loop 自动引路)、notice(capture 提醒留痕)、record(G1-G5 骨架 + map/timeline 归档)。
63
+ 配完六步,你的史官即就位:consult(reading loop 双信号启用后自动引路)、notice(capture 提醒留痕)、record(G1-G5 骨架 + `_evidence/` 证据页 + map/timeline 归档)。
@@ -35,7 +35,7 @@
35
35
  7. 处置 (Remediation) — 止血 vs 根治
36
36
  8. 行动项 (Action Items) — **六列(含五要素)**:措施(行内容) | 类型 | 负责人 | 期限 | 验证 | 状态
37
37
  9. 教训 (Lessons Learned) — 做得好 / 做错 / 侥幸
38
- 10. 附录 (Appendix) — 原始日志片段、截图
38
+ 10. 附录 (Appendix) — 决定性摘录(每段 ≤10 行)+ 证据页链接(`_evidence/...`)+ 外部链接(commit/PR/告警)
39
39
  11. 相关页面 (Related Pages)
40
40
 
41
41
  **状态块示例**:
@@ -125,6 +125,17 @@
125
125
 
126
126
  ---
127
127
 
128
+ ## 证据页协议 (Evidence Pages)
129
+
130
+ 触发条件 (Trigger):任何想贴进页面的原始件——日志、会话转写、大 diff——超过 10 行时不贴正文,转存证据页。
131
+ Whenever a raw artifact (log, transcript, big diff) destined for a page exceeds 10 lines, store it as an evidence page instead of pasting it.
132
+
133
+ - 命名 (Naming):`_evidence/<主题>--<yyyymmdd>`(如 `_evidence/wiki-oom--20260805`)
134
+ - 建页 (Create):`historian_page_create` 传 `tier: "evidence"`——证据页为机器层:单语 en、隐藏、不发布,不走 G1-G5 骨架、不占双语孪生与索引。
135
+ - 引用 (Cite):人工页面(G1 附录、G5 变更记录「依据」列等)只放证据页 URL 加决定性摘录(每段 ≤10 行),永不内嵌原文转储。
136
+
137
+ ---
138
+
128
139
  ## 通用元素(所有页型共享)
129
140
 
130
141
  ### 状态块(H1 后紧跟)
@@ -19,7 +19,7 @@
19
19
  | SYN-13 | **无占位债** | 禁止 TODO、TBD、待补充。不知道就不写那个节。 |
20
20
  | SYN-14 | **原始数据精度** | 数字按需保留精度。`65.38461538461539/100` 写 `65.4/100`,除非精度本身是要点。 |
21
21
  | SYN-15 | **禁止空节** | `### 优点` 下写 `(无)`→ 删掉整个节。空节占空间不传递信息。 |
22
- | SYN-16 | **禁止原始转储** | shell 输出、聊天日志、超过 5-10 行的堆栈跟踪不直接入页。提取发现,只引用决定性行。 |
22
+ | SYN-16 | **禁止原始转储** | shell 输出、聊天日志、超过 5-10 行的堆栈跟踪不直接入页。提取发现,只引用决定性行。确需保全原文时转存证据页:`_evidence/<主题>--<yyyymmdd>`(`historian_page_create` 传 `tier: "evidence"`),页面里只放链接。 |
23
23
  | SYN-17 | **时间 vs 主题** | 参考页按主题组织,不按天记日记。时间线结构只用于事件/事故页(G1)。 |
24
24
  | SYN-18 | **链接规范** | 内部链接用 `[Label](/path)` 格式。禁止 `[[path|label]]` 旧语法。每条链接必须指向 cache map 中现存的路径。 |
25
25
  | SYN-19 | **双语孪生** | 每个 en 页有 zh 孪生页,路径相同、语言不同。孪生标题各用本语言(如 `Architecture` / `建筑`)。正文节对节镜像。 |