opencode-wiki-historian 0.4.0 → 0.5.1

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.
@@ -11,6 +11,7 @@
11
11
  import { assertLocalePair, PathValidationError } from '../wiki/locale.js';
12
12
  import { scoreChecklist } from '../migrate-score.js';
13
13
  import { selfReviewChecklist } from '../templates/genres.js';
14
+ import { lintBody, publishGateViolations } from '../lint.js';
14
15
  /** Engine deps for one operation; client resolved lazily at use time. */
15
16
  export function pageDeps(deps) {
16
17
  return { client: deps.getClient(), options: deps.options, translate: deps.translate };
@@ -83,6 +84,8 @@ function hintFor(errorKind) {
83
84
  return 'The wiki endpoint is unreachable or misconfigured — check baseUrl and network.';
84
85
  case 'GraphQLError':
85
86
  return 'The wiki answered a GraphQL error — check the path/locale arguments.';
87
+ case 'PublishGateError':
88
+ return '消除 TODO/空节并把状态置 Active,或保留 状态:draft 待自检通过后发布;重定向存根正文必须带可点击的 [链接](目标URL)。';
86
89
  default:
87
90
  return 'Inspect the message and retry.';
88
91
  }
@@ -304,3 +307,23 @@ export function sectionRefusalJson(path, allowedSections) {
304
307
  const violation = sectionGuard(path, allowedSections);
305
308
  return violation === null ? null : errEnvelope(new ConfigError(violation));
306
309
  }
310
+ // --- publish gate (V6.1, HANDOFF #5.1 / #6.1) --------------------------------
311
+ export class PublishGateError extends Error {
312
+ constructor(message) {
313
+ super(message);
314
+ this.name = 'PublishGateError';
315
+ }
316
+ }
317
+ /** Hard gate on front-tier writes: refuses the two shapes that shipped real
318
+ * incidents — a page claiming Active with TODO markers or empty skeleton
319
+ * sections, and a redirect stub whose body carries no clickable exit.
320
+ * `_sandbox/*` and internal namespaces are exempt; 状态:draft stays the
321
+ * sanctioned work-in-progress escape hatch. Null = proceed. */
322
+ export function publishGateRefusalJson(content, locale, path, baseUrl) {
323
+ if (path.startsWith('_sandbox/') || isInternalPath(path))
324
+ return null;
325
+ const violations = publishGateViolations(lintBody(content, { locale, baseUrl }));
326
+ if (violations.length === 0)
327
+ return null;
328
+ return errEnvelope(new PublishGateError(`publish-gate: ${violations.join('; ')} on '${path}' (${locale})`));
329
+ }
@@ -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 { enforceTierPath, errEnvelope, frontDumpAdvisory, isInternalPath, MACHINE_TIER_NOTE, monolingualRefusalJson, okJson, sectionRefusalJson, tierMismatchJson, TIERS, urlPair, URL_MANDATE, pageDeps, } from './shared.js';
10
+ import { enforceTierPath, errEnvelope, frontDumpAdvisory, isInternalPath, MACHINE_TIER_NOTE, monolingualRefusalJson, okJson, publishGateRefusalJson, sectionRefusalJson, 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(),
@@ -33,6 +33,11 @@ export function makeUpdateTool(deps) {
33
33
  if (page === null) {
34
34
  return errEnvelope(new PageNotFoundError(`page '${args.path}' (${args.locale}) does not exist`));
35
35
  }
36
+ if (args.content !== undefined) {
37
+ const gate = publishGateRefusalJson(args.content, args.locale, page.path, deps.options.baseUrl);
38
+ if (gate !== null)
39
+ return gate;
40
+ }
36
41
  const result = await updatePage(pageDeps(deps), page.id, {
37
42
  title: args.title,
38
43
  content: args.content,
@@ -0,0 +1,21 @@
1
+ import { type GqlClient } from './client.js';
2
+ export interface NavItem {
3
+ readonly label: string;
4
+ readonly targetType: string;
5
+ readonly target: string;
6
+ }
7
+ export interface NavTree {
8
+ readonly locale: string;
9
+ readonly items: readonly NavItem[];
10
+ }
11
+ export interface NavSnapshot {
12
+ readonly mode: string;
13
+ readonly trees: readonly NavTree[];
14
+ }
15
+ /** Shape-tolerant parse: unknown/nullish shapes degrade to '' entries; a
16
+ * payload without `navigation` is not-a-nav (null), letting the caller mark
17
+ * the check unavailable instead of claiming "clean". */
18
+ export declare function parseNav(raw: unknown): NavSnapshot | null;
19
+ /** Never throws: an unreadable nav (older server, token without navigation
20
+ * read) returns null so the scan degrades to "unavailable", not failure. */
21
+ export declare function readPrimaryNav(client: GqlClient): Promise<NavSnapshot | null>;
@@ -0,0 +1,43 @@
1
+ // Live navigation reader (v0.5.1). Issue #1's actual disease was the SIDEBAR
2
+ // mirroring the filesystem (DYNAMIC/MIXED exposing _meta/_evidence/_sandbox),
3
+ // not the existence of machine-namespace pages — those are by design. So the
4
+ // detector reads the real primary nav (mode + curated flat trees) instead of
5
+ // inferring exposure from the page tree.
6
+ //
7
+ // Shape note: this wiki.js generation REJECTS `children` on NavigationItem
8
+ // (HTTP 400, re-probed 2026-09-08) — flat `items` are the whole truth.
9
+ import { gql } from './client.js';
10
+ const NAV_QUERY = '{ navigation { config { mode } tree { locale items { label targetType target } } } }';
11
+ /** Shape-tolerant parse: unknown/nullish shapes degrade to '' entries; a
12
+ * payload without `navigation` is not-a-nav (null), letting the caller mark
13
+ * the check unavailable instead of claiming "clean". */
14
+ export function parseNav(raw) {
15
+ const nav = raw?.navigation;
16
+ if (nav === undefined || nav === null)
17
+ return null;
18
+ const trees = [];
19
+ for (const t of Array.isArray(nav.tree) ? nav.tree : []) {
20
+ const row = t;
21
+ const items = [];
22
+ for (const i of Array.isArray(row?.items) ? row.items : []) {
23
+ const it = i;
24
+ items.push({
25
+ label: typeof it?.label === 'string' ? it.label : '',
26
+ targetType: typeof it?.targetType === 'string' ? it.targetType : '',
27
+ target: typeof it?.target === 'string' ? it.target : '',
28
+ });
29
+ }
30
+ trees.push({ locale: typeof row?.locale === 'string' ? row.locale : '', items });
31
+ }
32
+ return { mode: typeof nav.config?.mode === 'string' ? nav.config.mode : '', trees };
33
+ }
34
+ /** Never throws: an unreadable nav (older server, token without navigation
35
+ * read) returns null so the scan degrades to "unavailable", not failure. */
36
+ export async function readPrimaryNav(client) {
37
+ try {
38
+ return parseNav(await gql(client, NAV_QUERY, {}));
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-wiki-historian",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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",
@@ -200,7 +200,7 @@ G5 现状卡的硬约束:状态块是机读单行(`Active` / `Superseded-by:
200
200
  | `historian_translate_snippet` | 翻译片段 | `text`, `from`(en/zh), `to`(en/zh) |
201
201
  | `historian_search` | 搜索页面 | `query`, `kind`(title/content), `tags?`(1-5 个), `tagsMode?`(all 缺省/any) |
202
202
  | `historian_read` | 读取页面 | `path`, `locale` |
203
- | `historian_map` | 页面地图/时间线/维护扫描 | `action`(show/refresh/timeline/maintain);maintain 可选 `deep`(缺省 false=light 扫);timeline 可选 `days`(近 N 天)与 `path`(前缀过滤);输出人读 markdown + 机读 JSON,zh/en 行独立 |
203
+ | `historian_map` | 页面地图/时间线/维护扫描 | `action`(show/refresh/timeline/maintain);maintain 可选 `deep`(缺省 false=light 扫);timeline 可选 `days`(近 N 天)与 `path`(前缀过滤);输出人读 markdown + 机读 JSON,zh/en 行独立;maintain 同时返回 surface 接口面体检(信封 `historian.maintain.v3`) |
204
204
  | `historian_migrate` | 迁移页面到规范 | `path`, `genre?`, `apply`(false/true) |
205
205
  | `historian_delete` | 删除页面 | `path`, `locale`, `confirm`(必须 "yes") |
206
206
  | `historian_move` | 移动页面 | `path`, `locale`, `newPath`, `newLocale?`, `confirm`(必须 "yes") |
@@ -327,17 +327,29 @@ historian_map action:'refresh'
327
327
  - **light 扫:每次批量写后必跑**——只基于地图行 + 每 locale 一次只读 `pages.list`(便宜,随批走)
328
328
  - **deep 扫:每周至多一次**——逐页读正文,跑新鲜度(缺「上次核实于」/ 复核过期)与 `> Redirect:` 存根计数(贵,克制用)
329
329
 
330
+ light 扫在 maintain 行之外附带 **surface-light**:`coverage`(live 页面与地图不一致)、`nav`(侧栏真相=实时导航树:mode 非 STATIC 即把页面树镜像回侧栏 / 树内挂 `_` 段链接 / 章节缺落地页→面包屑 404)、`tagsEmpty`;deep 扫附带 **surface-deep**:正文级检测,逐页一次读取、双消费者共享缓存。
331
+
330
332
  报告行 → 处置映射表:
331
333
 
332
334
  | 报告行 | 含义 | 处置 |
333
335
  |--------|------|------|
334
336
  | `missingTwinPaths` | 双语孪生缺口 | 补孪生:翻译腿建 zh(或 en)页,走翻译失败处理 |
335
- | `duplicates.clusters` | 近重复标题簇(trigram-Jaccard 阈值) | bold-merge 流程:选最完整页为权威(bold),其余走 supersede 或 Redirect 存根 |
337
+ | `duplicates.clusters` | 近重复标题簇(trigram-Jaccard 阈值;「(重定向)」存根不参与聚类) | bold-merge 流程:选最完整页为权威(bold),其余走 supersede 或 Redirect 存根 |
336
338
  | `staleness.oldest` | 最陈旧页 | mark/refresh:G5 卡重新核实或标记 stale,不静默覆盖 |
337
- | `rootOrphans` / `diffusion.singleChildDirs` | 顶级孤儿 / 独子目录 | 归架:并入正确章节、建章节索引,或按冻结协议做 Redirect 存根 |
339
+ | `flatRootPages` / `diffusion.singleChildDirs` | 章节根平铺页清单(归架提示,非入链判定)/ 独子目录 | 归架:并入正确章节、建章节索引,或按冻结协议做 Redirect 存根;真孤儿看 surface deep 的 `orphanPages` |
338
340
  | `tags.vocabulary` | 标签漂移 | 词表映射:近义标签收敛到主词,`historian_page_update` 批量改 |
339
341
  | `redirects.stubs`(deep) | 重定向存根清单 | 核对目标存在、入链已改写;死链存根即修 |
340
342
  | `freshness`(deep) | 缺核实戳 / reviewBy 过期 | 回 G5 卡补核;到期页列入下周复核 |
343
+ | `coverage.missingFromMap`(surface) | 新页/迁移未进地图 | `action:'refresh'` 后重扫 |
344
+ | `nav.filesystemExposed` / `nav.machineLinks`(surface) | 侧栏被 DYNAMIC/MIXED 镜像出页面树 / 导航树里挂了 `_` 段链接 | 导航树手工策划只挂主题章节,mode 固定 STATIC;`nav.available=false` 时先修 token 导航读权限再下结论 |
345
+ | `nav.sectionLandingMissing`(surface) | 章节缺落地页(面包屑 404) | 建章节总览页并链入 wiki-index |
346
+ | `unfinished`(surface deep) | Active 页含 TODO/空节/导言空 | 补全或降回 draft |
347
+ | `stubs` / `links.broken` / `toStubs` / `sameTargetStacks`(deep) | 存根无可点出口 / 死链 / 指存根 / 同页多锚点 | 修出口与目标;锚点收敛到规范页 |
348
+ | `orphanPages` / `indexMissing`(deep) | 无入链 / 未入索引 | 归架:从相关页与 wiki-index 补链 |
349
+ | `roleDivergence`(deep) | 同题页 en/zh 一存根一活页 | 双侧收敛到同一权威页 |
350
+ | `twinParity`(deep) | 孪生正文分叉(长度/节结构) | 重译或重排落后的孪生腿 |
351
+ | `zhEnglishDominant`(deep) | zh 页英文为主(违反中文为主) | 按 zh-first 政策重写 |
352
+ | `ledgerClaims`(deep) | 事实密集页缺「上次核实于」戳 | 逐条对机器核实后补戳,或标 stale |
341
353
 
342
354
  ### 闸门回路 (gate):reading loop + sections guard
343
355
 
@@ -350,6 +362,14 @@ historian_map action:'refresh'
350
362
  - 注入语义为**单块追加**:advisory 拼接到 system 提示的最后一个块(`\n\n` 分隔),system 为空数组时才新建块——绝不产生第二条 system 消息。严格 OpenAI 兼容后端(如 vLLM)会以 `System message must be at the beginning.` 拒绝多 system 请求,单块追加从根上规避此坑。
351
363
  - 幂等去重:同一请求的任一 system 块已含 `historian_search` 字样则跳过注入。
352
364
 
365
+ #### 发布闸门 publish gate(写入硬拒,v0.5.0)
366
+
367
+ `historian_page_create` 与带 `content` 的 `historian_page_update` 在**任何写入前**硬检正文,违例即零写入拒绝(`errorKind: PublishGateError`):
368
+
369
+ - **R1 存根须有出口**:`> Redirect:` 开头的正文必须含 ≥1 条可点击链接;纯行内代码路径不算出口
370
+ - **R2 Active 不许半成品**:状态行为 `Active` 且正文含 TODO/TBD/占位注释或空节 → 拒绝;未完稿保持 `draft`——G1-G6 骨架状态行缺省即 `draft`,翻 Active 就是过闸动作
371
+ - 豁免:`_sandbox/**` 与内部层(`_meta/`、`_evidence/`);`append` 不过闸(增量语义),由 deep 扫描兜底
372
+
353
373
  #### sections guard(路径闸门)
354
374
 
355
375
  写入路径首段受插件选项 `sections` 白名单强制(配置后不在白名单的前缀被拒)。豁免段恒可写:`home`、`wiki-index`、`_sandbox`、`_data`、`_meta`、`_evidence`——机构记忆不能反锁落地的着陆页与机器命名空间。`tier:"evidence"` 的页面不校验(证据层是原材料归宿)。白名单为空的部署不做前缀限制,实际权限由 wiki.js token 的 page rules 决定。