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.
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Contract of every skeleton constant:
9
9
  * - Rubric dimension C anatomy: H1 placeholder → status line
10
- * `**状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD`
10
+ * `**状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD`
11
11
  * → one-line scope (`This page answers:` / `本页回答:`) → tail
12
12
  * `Related Pages`/`相关页面` section with a real-link hint (SYN-9 fixed tail).
13
13
  * - wiki.js 2.x expression pieces only: blockquote admonitions
@@ -26,7 +26,7 @@
26
26
  /** G1 — 事件/复盘页 (incident postmortem), zh. */
27
27
  export const G1_ZH = `# 页面标题(占位:写完后替换为实际标题,须与页面 title 一致)
28
28
 
29
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
29
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
30
30
 
31
31
  **本页回答:** 一句话范围句(占位:本页记录哪次故障/事件的起因、影响与处置)
32
32
 
@@ -117,7 +117,7 @@ export const G1_ZH = `# 页面标题(占位:写完后替换为实际标题
117
117
  /** G1 — incident postmortem, en (section-for-section twin of G1_ZH). */
118
118
  export const G1_EN = `# Page Title (placeholder: replace with the real title, must match the page title)
119
119
 
120
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
120
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
121
121
 
122
122
  **This page answers:** one-line scope sentence (placeholder: which incident this page records, its impact and handling)
123
123
 
@@ -208,7 +208,7 @@ See footnote[^1].
208
208
  /** G2 — 对比/选型页 (comparison / selection), zh. */
209
209
  export const G2_ZH = `# 页面标题(占位:写完后替换为实际标题,须与页面 title 一致)
210
210
 
211
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
211
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
212
212
 
213
213
  **本页回答:** 一句话范围句(占位:本页在哪些对象之间、按什么维度对比,结论是什么)
214
214
 
@@ -258,7 +258,7 @@ export const G2_ZH = `# 页面标题(占位:写完后替换为实际标题
258
258
  /** G2 — comparison / selection, en (section-for-section twin of G2_ZH). */
259
259
  export const G2_EN = `# Page Title (placeholder: replace with the real title, must match the page title)
260
260
 
261
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
261
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
262
262
 
263
263
  **This page answers:** one-line scope sentence (placeholder: what is compared, on which dimensions, and the pick)
264
264
 
@@ -308,7 +308,7 @@ export const G2_EN = `# Page Title (placeholder: replace with the real title, mu
308
308
  /** G3 — 清单/参考页 (inventory / reference), zh. */
309
309
  export const G3_ZH = `# 页面标题(占位:写完后替换为实际标题,须与页面 title 一致)
310
310
 
311
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
311
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
312
312
 
313
313
  **本页回答:** 一句话范围句(占位:本页收录哪类条目、覆盖到哪里、不覆盖什么)
314
314
 
@@ -343,7 +343,7 @@ export const G3_ZH = `# 页面标题(占位:写完后替换为实际标题
343
343
  /** G3 — inventory / reference, en (section-for-section twin of G3_ZH). */
344
344
  export const G3_EN = `# Page Title (placeholder: replace with the real title, must match the page title)
345
345
 
346
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
346
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
347
347
 
348
348
  **This page answers:** one-line scope sentence (placeholder: which entries are covered, up to what boundary)
349
349
 
@@ -378,7 +378,7 @@ export const G3_EN = `# Page Title (placeholder: replace with the real title, mu
378
378
  /** G4 — 概念/原理解析页 (concept / explanation), zh. */
379
379
  export const G4_ZH = `# 页面标题(占位:写完后替换为实际标题,须与页面 title 一致)
380
380
 
381
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
381
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
382
382
 
383
383
  **本页回答:** 一句话范围句(占位:本页解释哪个概念,读者看完能理解什么)
384
384
 
@@ -416,7 +416,7 @@ export const G4_ZH = `# 页面标题(占位:写完后替换为实际标题
416
416
  /** G4 — concept / explanation, en (section-for-section twin of G4_ZH). */
417
417
  export const G4_EN = `# Page Title (placeholder: replace with the real title, must match the page title)
418
418
 
419
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
419
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
420
420
 
421
421
  **This page answers:** one-line scope sentence (placeholder: which concept is explained and what the reader will understand)
422
422
 
@@ -457,7 +457,7 @@ export const G4_EN = `# Page Title (placeholder: replace with the real title, mu
457
457
  * belongs to G1 event pages, linked from 变更记录. */
458
458
  export const G5_ZH = `# 页面标题(占位:写完后替换为实际标题,须与页面 title 一致)
459
459
 
460
- **状态/Status**: Active <!-- or Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
460
+ **状态/Status**: draft <!-- 复核后改 Active / Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
461
461
 
462
462
  **本页回答:** 当前部署状态(占位:写明范围)
463
463
 
@@ -508,7 +508,7 @@ export const G5_ZH = `# 页面标题(占位:写完后替换为实际标题
508
508
  /** G5 — current-state ledger, en (section-for-section twin of G5_ZH). */
509
509
  export const G5_EN = `# Page Title (placeholder: replace with the real title, must match the page title)
510
510
 
511
- **状态/Status**: Active <!-- or Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
511
+ **状态/Status**: draft <!-- 复核后改 Active / Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
512
512
 
513
513
  **This page answers:** what is deployed and running right now (placeholder: name the system and scope)
514
514
 
@@ -556,3 +556,104 @@ export const G5_EN = `# Page Title (placeholder: replace with the real title, mu
556
556
  ## Related Pages
557
557
 
558
558
  <!-- List the real page paths that link here and back. Event histories go in G1 pages, cited in Evidence. -->`;
559
+ /** G6 — 操作手册/how-to 页 (goal-titled operational manual), zh. Diátaxis doing
560
+ * leg: a reader with one goal follows the numbered steps to the outcome; every
561
+ * step carries 动作 + 预期结果 + 失败处置. Concepts stay in G4 pages, raw
562
+ * command lists in G3 pages, linked from 相关页面. */
563
+ export const G6_ZH = `# 如何做某事(占位:目标句式标题,须与页面 title 一致)
564
+
565
+ **状态/Status**: draft <!-- 复核后改 Active / Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
566
+
567
+ **本页回答:** 如何完成某事(占位:写明具体目标)
568
+
569
+ > **提示**
570
+ > 操作手册面向带着目标来的读者:步骤可复制、可执行、可核对。
571
+ > 原理写 G4 概念页,命令清单写 G3 页,从相关页面链过来。
572
+ {.is-info}
573
+
574
+ ## 目标
575
+
576
+ <!-- 1–2 句:完成后读者得到什么结果。成功判据须可观察。 -->
577
+
578
+ ## 前置条件
579
+
580
+ <!-- 权限、版本、依赖、环境变量逐项列出。每项须能当场自检。 -->
581
+
582
+ | 条件 | 检查方法 | 预期结果 |
583
+ | --- | --- | --- |
584
+ | example-service 可达 | \`curl -s http://example.com:8000/health\` | HTTP 200 |
585
+
586
+ ## 操作步骤
587
+
588
+ <!-- 编号步骤,每步三段:动作、预期结果、失败处置。命令须可直接复制。 -->
589
+
590
+ 1. **动作**:<!-- 做什么或运行哪条命令。 -->
591
+ **预期结果**:<!-- 正常时看到的输出或状态。 -->
592
+ **失败处置**:<!-- 未达预期时的补救,或链向 G1/G4 页。 -->
593
+
594
+ ## 回退
595
+
596
+ <!-- 出错后如何恢复原状:撤销命令、备份位置。不可逆操作须前置警告。 -->
597
+
598
+ ## 元数据表
599
+
600
+ | 元数据 | 值 |
601
+ | --- | --- |
602
+ | 状态 | <!-- Active / Superseded-by: <path> / Deprecated --> |
603
+ | 上次核实 | <!-- YYYY-MM-DD,在哪套环境按本页步骤重跑过 --> |
604
+ | 复核周期 | <!-- 如每 90 天,到期重跑本页步骤 --> |
605
+ | 被取代于 | <!-- 新手册路径,无则填 — --> |
606
+ | 来源类型 | <!-- human / agent / imported --> |
607
+
608
+ ## 相关页面
609
+
610
+ <!-- 列出互链的真实页面路径。原理在 G4,清单在 G3,事故史在 G1。 -->`;
611
+ /** G6 — how-to manual, en (section-for-section twin of G6_ZH). */
612
+ export const G6_EN = `# How to Do X (placeholder: goal-titled heading, must match the page title)
613
+
614
+ **状态/Status**: draft <!-- 复核后改 Active / Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
615
+
616
+ **This page answers:** how to finish one concrete task (placeholder: name the goal)
617
+
618
+ > **Tip**
619
+ > A how-to serves readers who arrive with a goal: steps are copy-pasteable and checkable.
620
+ > Concepts belong in G4 pages, raw command lists in G3 pages, linked under Related Pages.
621
+ {.is-info}
622
+
623
+ ## Goal
624
+
625
+ <!-- One or two sentences: the outcome the reader gets. State an observable success test. -->
626
+
627
+ ## Prerequisites
628
+
629
+ <!-- Permissions, versions, dependencies, env vars: one row each. Every row must be self-checkable. -->
630
+
631
+ | Condition | Check | Expected |
632
+ | --- | --- | --- |
633
+ | example-service reachable | \`curl -s http://example.com:8000/health\` | HTTP 200 |
634
+
635
+ ## Steps
636
+
637
+ <!-- Numbered steps, three legs per step: action, expected result, on failure. Commands must be copy-pasteable. -->
638
+
639
+ 1. **Action**: <!-- what to run or change. -->
640
+ **Expected result**: <!-- the output or state that proves success. -->
641
+ **On failure**: <!-- the fix, or a link to the G1/G4 page. -->
642
+
643
+ ## Rollback
644
+
645
+ <!-- How to restore the prior state: revert commands, backup locations. Warn before any irreversible step. -->
646
+
647
+ ## Metadata
648
+
649
+ | Field | Value |
650
+ | --- | --- |
651
+ | Status | <!-- Active / Superseded-by: <path> / Deprecated --> |
652
+ | Last verified | <!-- YYYY-MM-DD and the environment the steps were re-run in --> |
653
+ | Review by | <!-- e.g. every 90 days; re-run the steps when due --> |
654
+ | Superseded by | <!-- path of the newer manual, or — --> |
655
+ | Source kind | <!-- human / agent / imported --> |
656
+
657
+ ## Related Pages
658
+
659
+ <!-- Real page paths that link here and back. Why-it-works lives in G4, checklists in G3, incidents in G1. -->`;
@@ -7,16 +7,16 @@
7
7
  import { tool } from '@opencode-ai/plugin';
8
8
  import { validatePath } from '../wiki/locale.js';
9
9
  import { createPage } from '../wiki/pages.js';
10
- import { classifyGenre, genreSkeleton } from '../templates/genres.js';
10
+ import { listPages, readPage } from '../wiki/pages.read.js';
11
+ import { classifyGenre, genreSkeleton, GENRES } from '../templates/genres.js';
11
12
  import { evidenceSkeleton } from '../templates/evidence.js';
12
- import { enforceTierPath, errEnvelope, frontDumpAdvisory, MACHINE_TIER_NOTE, okJson, pageDeps, tierMismatchJson, TIERS, urlPair, URL_MANDATE, } from './shared.js';
13
+ import { checklistAdvisory, collisionAdvisory, enforceTierPath, errEnvelope, frontDumpAdvisory, MACHINE_TIER_NOTE, okJson, pageDeps, publishGateRefusalJson, sectionRefusalJson, tierMismatchJson, TIERS, urlPair, URL_MANDATE, } from './shared.js';
13
14
  const s = tool.schema;
14
- const GENRES = ['G1', 'G2', 'G3', 'G4', 'G5'];
15
15
  const ARGS_SHAPE = {
16
16
  path: s.string().describe('Wiki path, e.g. docs/guides/foo (first segment must NOT look like a locale code)'),
17
17
  title: s.string().describe('Page title'),
18
18
  content: s.string().optional().describe('Page body (markdown). ABSENT → local template mode, nothing written'),
19
- genre: s.enum(GENRES).optional().describe('Genre hint: G1..G5 (template mode / classification)'),
19
+ genre: s.enum(GENRES).optional().describe('Genre hint: G1..G6 (template mode / classification)'),
20
20
  locale: s.enum(['en', 'zh']).default('en'),
21
21
  isPublished: s.boolean().default(true),
22
22
  tags: s.array(s.string()).default([]),
@@ -25,6 +25,28 @@ const ARGS_SHAPE = {
25
25
  tier: s.enum(TIERS).default('front').describe('front = bilingual human page; evidence = machine page under _meta/ or _evidence/ (hidden, unpublished, monolingual en)'),
26
26
  };
27
27
  const ArgsSchema = s.object(ARGS_SHAPE);
28
+ /** Best-effort collision advice for the content branch: read-only pre-checks
29
+ * whose every failure is SWALLOWED — the write proceeds with no advice
30
+ * rather than being blocked or errored by the adviser itself (the
31
+ * "hint, never throw" precedent above). */
32
+ async function collisionAdvice(deps, probe) {
33
+ const client = deps.getClient();
34
+ let exists = false;
35
+ try {
36
+ exists = (await readPage(client, probe.path, probe.locale)) !== null;
37
+ }
38
+ catch {
39
+ /* failed existence read → assume absence: never advise on unknowns */
40
+ }
41
+ let inventory = [];
42
+ try {
43
+ inventory = await listPages(client);
44
+ }
45
+ catch {
46
+ /* failed inventory read → no duplicate advice */
47
+ }
48
+ return collisionAdvisory({ ...probe, exists, inventory });
49
+ }
28
50
  export function makeCreateTool(deps) {
29
51
  return tool({
30
52
  description: `Create a wiki page: primary locale content plus an optional auto-translated twin. ` +
@@ -40,6 +62,9 @@ export function makeCreateTool(deps) {
40
62
  catch (err) {
41
63
  return errEnvelope(err);
42
64
  }
65
+ const offSections = sectionRefusalJson(args.path, deps.options.sections);
66
+ if (offSections !== null)
67
+ return offSections;
43
68
  const mismatch = enforceTierPath(tier, args.path);
44
69
  if (mismatch !== null)
45
70
  return tierMismatchJson(mismatch);
@@ -50,6 +75,10 @@ export function makeCreateTool(deps) {
50
75
  const localeHint = isEvidence && args.locale === 'zh'
51
76
  ? 'evidence pages are monolingual en — the locale argument was forced to "en"'
52
77
  : undefined;
78
+ // Genre is resolved ONCE, above the template/content fork (v4 todo 4):
79
+ // the template branch feeds it the skeleton, the content branch the
80
+ // pre-write checklist gate. Explicit arg wins over classification.
81
+ const genre = args.genre ?? classifyGenre({ title: args.title, body: args.description ?? '' }).genre;
53
82
  if (args.content === undefined || args.content.trim() === '') {
54
83
  if (isEvidence) {
55
84
  // Evidence pages are not genre-templated: echo the machine skeleton.
@@ -70,7 +99,6 @@ export function makeCreateTool(deps) {
70
99
  ...(localeHint === undefined ? {} : { localeHint }),
71
100
  });
72
101
  }
73
- const genre = args.genre ?? classifyGenre({ title: args.title, body: args.description ?? '' }).genre;
74
102
  return okJson({
75
103
  mode: 'template',
76
104
  genre,
@@ -81,6 +109,16 @@ export function makeCreateTool(deps) {
81
109
  });
82
110
  }
83
111
  try {
112
+ const gate = publishGateRefusalJson(args.content, locale, args.path, deps.options.baseUrl);
113
+ if (gate !== null)
114
+ return gate;
115
+ const collision = await collisionAdvice(deps, {
116
+ tier,
117
+ path: args.path,
118
+ locale,
119
+ title: args.title,
120
+ baseUrl: deps.options.baseUrl,
121
+ });
84
122
  const result = await createPage(pageDeps(deps), {
85
123
  path: args.path,
86
124
  locale,
@@ -92,7 +130,12 @@ export function makeCreateTool(deps) {
92
130
  twin: isEvidence ? false : args.twin,
93
131
  description: args.description,
94
132
  });
95
- const advisory = frontDumpAdvisory(tier, args.content);
133
+ const advisories = [
134
+ frontDumpAdvisory(tier, args.content),
135
+ collision,
136
+ // Evidence raw material is not a genre page — the gate is front-only.
137
+ isEvidence ? null : checklistAdvisory(genre, args.content),
138
+ ].filter((a) => a !== null);
96
139
  return okJson({
97
140
  mode: 'create',
98
141
  path: args.path,
@@ -104,7 +147,7 @@ export function makeCreateTool(deps) {
104
147
  urls: urlPair(result),
105
148
  ...(isEvidence ? { note: MACHINE_TIER_NOTE } : {}),
106
149
  ...(localeHint === undefined ? {} : { localeHint }),
107
- ...(advisory === null ? {} : { advisory }),
150
+ ...(advisories.length === 0 ? {} : { advisory: advisories.join('\n') }),
108
151
  });
109
152
  }
110
153
  catch (err) {
@@ -8,6 +8,10 @@ import { tool } from '@opencode-ai/plugin';
8
8
  import { TranslateError } from '../translate.js';
9
9
  import { buildChronology, filterRowsByPath } from '../chronology.js';
10
10
  import { getMap, refreshMapCache, CACHE_PATH } from '../map.js';
11
+ import { buildMaintainReport, renderMaintainMarkdown } from '../maintain.js';
12
+ import { buildSurfaceReport, renderSurfaceMarkdown } from '../surface.js';
13
+ import { normalizeLocale, PathValidationError } from '../wiki/locale.js';
14
+ import { listPages, readPage } from '../wiki/pages.read.js';
11
15
  import { errEnvelope, okJson, reportUrls, URL_MANDATE } from './shared.js';
12
16
  const s = tool.schema;
13
17
  const TRANSLATE_ARGS = {
@@ -53,11 +57,71 @@ export function makeTranslateSnippetTool(deps) {
53
57
  });
54
58
  }
55
59
  const MAP_ARGS = {
56
- action: s.enum(['show', 'refresh', 'timeline']).default('show'),
60
+ action: s.enum(['show', 'refresh', 'timeline', 'maintain']).default('show'),
57
61
  days: s.number().int().positive().optional().describe('timeline: keep only rows updated within the last N days'),
58
62
  path: s.string().optional().describe('timeline: section/path prefix filter (e.g. ops)'),
63
+ deep: s.boolean().optional().describe('maintain: additionally read every page body (freshness + stubs + broken/stacked links + twin parity + zh-first + unfinished skeletons + claim ledgers) — one bounded read per row, cached across both scans'),
59
64
  };
60
65
  const MapArgsSchema = s.object(MAP_ARGS);
66
+ /** maintain: light tier is map rows + ONE read-only pages.list pass per locale
67
+ * (the mirror's MapRow carries no tags; the list join restores the vocab view);
68
+ * deep additionally reads each body via readPage. Reserved-path pages (e.g.
69
+ * 'home', probe p1) answer null instead of killing the sweep. Read-only. */
70
+ async function runMaintain(deps, mapDeps, snapshot, deep) {
71
+ const client = deps.getClient();
72
+ const tagIndex = new Map();
73
+ const liveInventory = [];
74
+ const locales = [...new Set(deps.options.locales.map(normalizeLocale))].sort();
75
+ for (const locale of locales) {
76
+ for (const item of await listPages(client, { locale })) {
77
+ tagIndex.set(`${item.locale}\u0000${item.path}`, item.tags);
78
+ liveInventory.push({ path: item.path, locale: item.locale, isPublished: item.isPublished });
79
+ }
80
+ }
81
+ const rows = snapshot.rows.map((r) => ({ ...r, tags: tagIndex.get(`${r.locale}\u0000${r.path}`) ?? [] }));
82
+ const bodyCache = new Map();
83
+ const readBody = deep
84
+ ? (path, locale) => {
85
+ const key = `${locale}\u0000${path}`;
86
+ const hit = bodyCache.get(key);
87
+ if (hit !== undefined)
88
+ return hit;
89
+ const pending = (async () => {
90
+ try {
91
+ return (await readPage(client, path, locale))?.content ?? null;
92
+ }
93
+ catch (err) {
94
+ // Unreadable page (invalid path / transport) is a scan miss, not a report failure.
95
+ if (err instanceof PathValidationError)
96
+ return null;
97
+ throw err;
98
+ }
99
+ })();
100
+ bodyCache.set(key, pending);
101
+ return pending;
102
+ }
103
+ : undefined;
104
+ const report = await buildMaintainReport({ rows, mapGeneratedAt: snapshot.generatedAt, mapStaleSeconds: snapshot.staleSeconds }, { deep, readBody });
105
+ const surface = await buildSurfaceReport({
106
+ rows,
107
+ generatedAt: report.generatedAt,
108
+ baseUrl: deps.options.baseUrl,
109
+ liveInventory,
110
+ deep,
111
+ readBody,
112
+ });
113
+ return {
114
+ action: 'maintain',
115
+ schema: 'historian.maintain.v2',
116
+ deep: report.deep,
117
+ generatedAt: report.generatedAt,
118
+ rowCount: report.rowCount,
119
+ report,
120
+ surface,
121
+ markdown: `${renderMaintainMarkdown(report)}\n\n${renderSurfaceMarkdown(surface)}`,
122
+ urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
123
+ };
124
+ }
61
125
  // --- historian_map -----------------------------------------------------------
62
126
  export function makeMapTool(deps) {
63
127
  return tool({
@@ -67,7 +131,11 @@ export function makeMapTool(deps) {
67
131
  `show reads the local mirror (zero writes); refresh rebuilds from the wiki and writes the mirror + cache page ` +
68
132
  `(idempotent — the engine upserts via full RMW); timeline groups mirror rows by ISO week (newest first, ` +
69
133
  `optional days window + section/path prefix filter) into a human markdown table + machine-readable weeks JSON. ` +
70
- `${URL_MANDATE}.`,
134
+ `maintain runs the read-only curation sweep + surface report (twin gap, near-duplicate titles, staleness, ` +
135
+ `diffusion/orphan candidates, tag vocab, section distribution, map-vs-live coverage, nav hygiene; ` +
136
+ `deep:true adds per-body freshness, stub reachability, broken/stacked/index-less links, twin parity, ` +
137
+ `unfinished skeletons, claim ledgers) and ` +
138
+ `answers a markdown report with a stable-key JSON tail. ${URL_MANDATE}.`,
71
139
  args: MAP_ARGS,
72
140
  execute: async (raw) => {
73
141
  const args = MapArgsSchema.parse(raw);
@@ -92,6 +160,9 @@ export function makeMapTool(deps) {
92
160
  urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
93
161
  });
94
162
  }
163
+ if (args.action === 'maintain') {
164
+ return okJson(await runMaintain(deps, mapDeps, snapshot, args.deep ?? false));
165
+ }
95
166
  return okJson({
96
167
  action: 'show',
97
168
  generatedAt: snapshot.generatedAt,
@@ -6,12 +6,11 @@
6
6
  */
7
7
  import { tool } from '@opencode-ai/plugin';
8
8
  import { deletePage, movePage } from '../wiki/pages.js';
9
- import { selfReviewChecklist } from '../templates/genres.js';
10
- import { confirmRequiredJson, errEnvelope, okJson, urlPair, URL_MANDATE, pageDeps } from './shared.js';
9
+ import { GENRES, selfReviewChecklist } from '../templates/genres.js';
10
+ import { confirmRequiredJson, errEnvelope, okJson, sectionRefusalJson, urlPair, URL_MANDATE, pageDeps, } from './shared.js';
11
11
  import { reformatPageDraft } from '../migrate.js';
12
12
  import { applyMigration } from '../migrate-apply.js';
13
13
  const s = tool.schema;
14
- const GENRES = ['G1', 'G2', 'G3', 'G4', 'G5'];
15
14
  const DELETE_ARGS = {
16
15
  path: s.string(),
17
16
  locale: s.enum(['en', 'zh']).default('en'),
@@ -28,6 +27,9 @@ export function makeDeleteTool(deps) {
28
27
  const args = DeleteArgsSchema.parse(raw);
29
28
  if (args.confirm !== 'yes')
30
29
  return confirmRequiredJson('historian_delete', args.confirm);
30
+ const offSections = sectionRefusalJson(args.path, deps.options.sections);
31
+ if (offSections !== null)
32
+ return offSections;
31
33
  try {
32
34
  const result = await deletePage(pageDeps(deps), args.path, args.locale, 'yes');
33
35
  return okJson({
@@ -62,6 +64,11 @@ export function makeMoveTool(deps) {
62
64
  const args = MoveArgsSchema.parse(raw);
63
65
  if (args.confirm !== 'yes')
64
66
  return confirmRequiredJson('historian_move', args.confirm);
67
+ // The allow-list guards the DESTINATION (the write target); the source
68
+ // page may legally live somewhere the new configuration no longer admits.
69
+ const offSections = sectionRefusalJson(args.newPath, deps.options.sections);
70
+ if (offSections !== null)
71
+ return offSections;
65
72
  try {
66
73
  const destLocale = args.newLocale ?? args.locale;
67
74
  const result = await movePage(pageDeps(deps), args.path, args.locale, args.newPath, destLocale, 'yes');
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { tool } from '@opencode-ai/plugin';
7
7
  import { normalizeLocale } from '../wiki/locale.js';
8
- import { readPage, searchPages } from '../wiki/pages.js';
8
+ import { listPages, readPage, searchPages } from '../wiki/pages.js';
9
9
  import { errEnvelope, okJson, reportUrls, URL_MANDATE } from './shared.js';
10
10
  const s = tool.schema;
11
11
  const READ_ARGS = {
@@ -56,22 +56,89 @@ export function makeReadTool(deps) {
56
56
  },
57
57
  });
58
58
  }
59
+ const TAG_MODES = ['any', 'all'];
59
60
  const SEARCH_ARGS = {
60
61
  query: s.string(),
61
62
  kind: s.enum(['title', 'content']).default('content').describe('Informational intent; the wiki index covers both title and content'),
63
+ tags: s
64
+ .array(s.string().min(1))
65
+ .min(1)
66
+ .max(5)
67
+ .refine((arr) => arr.every((t) => t.trim().length > 0), {
68
+ message: 'tags entries must be non-blank (whitespace-only rejected)',
69
+ })
70
+ .optional()
71
+ .describe('Filter to pages carrying these tags (1-5). tagsMode "all" (DEFAULT) = EVERY listed tag ' +
72
+ 'must be present on the page — the server $tags mechanism is AND-only. tagsMode "any" = at ' +
73
+ 'LEAST ONE tag matches (client-side per-tag fan-out + union). Entries are trimmed and ' +
74
+ 'deduped; blank entries are rejected. Result rows include each page\'s tags so the agent ' +
75
+ 'can see the live tag vocabulary.'),
76
+ tagsMode: s
77
+ .enum(TAG_MODES)
78
+ .default('all')
79
+ .describe('"all" (default): every tag must match (server-side AND, one request). "any": at least one tag (client-side union).'),
62
80
  };
63
81
  const SearchArgsSchema = s.object(SEARCH_ARGS);
82
+ /** (path, locale) identity for union dedupe and query intersection. The NUL
83
+ * separator cannot appear in a path or locale, so concatenation stays injective. */
84
+ const matchKey = (path, locale) => `${locale}\u0000${path}`;
85
+ /** Keys of the text-query result set, locale-normalized. Rows whose locale is
86
+ * outside the en/zh whitelist never equal a list row (those are always en/zh),
87
+ * so they simply cannot join the intersection. */
88
+ function textKeysOf(resp) {
89
+ const keys = new Set();
90
+ for (const r of resp.results) {
91
+ try {
92
+ keys.add(matchKey(r.path, normalizeLocale(r.locale)));
93
+ }
94
+ catch {
95
+ /* unsupported locale — legacy mapper keeps the row with url:null; the tag
96
+ intersection just ignores it */
97
+ }
98
+ }
99
+ return keys;
100
+ }
101
+ /** tagsMode "any": one list call PER tag, unioned and deduped by (path, locale).
102
+ * Resilience contract: a failed leg is dropped and named in `failed` — no new
103
+ * error class surfaces while ≥1 leg survives; only when EVERY leg fails does
104
+ * the original rejection propagate unchanged (standard error envelope). */
105
+ async function unionByAnyTag(client, tags) {
106
+ const legs = await Promise.allSettled(tags.map((t) => listPages(client, { tags: [t] })));
107
+ const seen = new Set();
108
+ const rows = [];
109
+ const failures = [];
110
+ legs.forEach((leg, i) => {
111
+ if (leg.status === 'fulfilled') {
112
+ for (const row of leg.value) {
113
+ const k = matchKey(row.path, row.locale);
114
+ if (!seen.has(k)) {
115
+ seen.add(k);
116
+ rows.push(row);
117
+ }
118
+ }
119
+ }
120
+ else {
121
+ failures.push({ tag: tags[i], reason: leg.reason });
122
+ }
123
+ });
124
+ if (failures.length === tags.length)
125
+ throw failures[0].reason; // schema guarantees tags.length ≥ 1
126
+ return { rows, failed: failures.map((f) => f.tag) };
127
+ }
64
128
  // --- historian_search --------------------------------------------------------
65
129
  export function makeSearchTool(deps) {
66
130
  return tool({
67
131
  description: `Full-text search over the wiki. kind is informational intent only — the live wiki.js ` +
68
132
  `search indexes title AND content (the engine signature is search(query, path, locale), no field scope). ` +
133
+ `Optional tags filter: tagsMode "all" (DEFAULT) requires EVERY tag on the page (server-side ` +
134
+ `AND); "any" matches AT LEAST ONE tag. Result rows carry each page's tags. ` +
69
135
  `Results carry their en/zh URLs. ${URL_MANDATE}.`,
70
136
  args: SEARCH_ARGS,
71
137
  execute: async (raw) => {
72
138
  const args = SearchArgsSchema.parse(raw);
73
139
  try {
74
- const resp = await searchPages(deps.getClient(), args.query);
140
+ const client = deps.getClient();
141
+ const resp = await searchPages(client, args.query);
75
142
  const results = resp.results.map((r) => {
76
143
  try {
77
144
  const locale = normalizeLocale(r.locale);
@@ -88,12 +155,53 @@ export function makeSearchTool(deps) {
88
155
  return { id: r.id, title: r.title, description: r.description, path: r.path, locale: r.locale, url: null };
89
156
  }
90
157
  });
158
+ if (args.tags === undefined) {
159
+ // Legacy path — no tags given, no tags keys leak into the envelope.
160
+ return okJson({
161
+ query: args.query,
162
+ kind: args.kind,
163
+ totalHits: resp.totalHits,
164
+ suggestions: resp.suggestions,
165
+ results,
166
+ });
167
+ }
168
+ const wanted = [...new Set(args.tags.map((t) => t.trim()))];
169
+ let tagged;
170
+ let tagsFailed;
171
+ switch (args.tagsMode) {
172
+ case 'all':
173
+ // Straight passthrough: ONE request, the server's $tags AND mechanism.
174
+ tagged = await listPages(client, { tags: wanted });
175
+ tagsFailed = [];
176
+ break;
177
+ case 'any': {
178
+ const union = await unionByAnyTag(client, wanted);
179
+ tagged = union.rows;
180
+ tagsFailed = union.failed;
181
+ break;
182
+ }
183
+ }
184
+ const textKeys = textKeysOf(resp);
185
+ const matched = tagged
186
+ .filter((row) => textKeys.has(matchKey(row.path, row.locale)))
187
+ .map((row) => ({
188
+ id: row.id,
189
+ title: row.title,
190
+ description: row.description,
191
+ path: row.path,
192
+ locale: row.locale,
193
+ url: reportUrls(deps.options.baseUrl, row.path, row.locale)[row.locale],
194
+ tags: row.tags,
195
+ }));
91
196
  return okJson({
92
197
  query: args.query,
93
198
  kind: args.kind,
94
- totalHits: resp.totalHits,
199
+ tags: wanted,
200
+ tagsMode: args.tagsMode,
201
+ tagsFailed,
202
+ totalHits: matched.length,
95
203
  suggestions: resp.suggestions,
96
- results,
204
+ results: matched,
97
205
  });
98
206
  }
99
207
  catch (err) {