opencode-wiki-historian 0.3.0 → 0.4.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,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, 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,13 @@ export function makeCreateTool(deps) {
81
109
  });
82
110
  }
83
111
  try {
112
+ const collision = await collisionAdvice(deps, {
113
+ tier,
114
+ path: args.path,
115
+ locale,
116
+ title: args.title,
117
+ baseUrl: deps.options.baseUrl,
118
+ });
84
119
  const result = await createPage(pageDeps(deps), {
85
120
  path: args.path,
86
121
  locale,
@@ -92,7 +127,12 @@ export function makeCreateTool(deps) {
92
127
  twin: isEvidence ? false : args.twin,
93
128
  description: args.description,
94
129
  });
95
- const advisory = frontDumpAdvisory(tier, args.content);
130
+ const advisories = [
131
+ frontDumpAdvisory(tier, args.content),
132
+ collision,
133
+ // Evidence raw material is not a genre page — the gate is front-only.
134
+ isEvidence ? null : checklistAdvisory(genre, args.content),
135
+ ].filter((a) => a !== null);
96
136
  return okJson({
97
137
  mode: 'create',
98
138
  path: args.path,
@@ -104,7 +144,7 @@ export function makeCreateTool(deps) {
104
144
  urls: urlPair(result),
105
145
  ...(isEvidence ? { note: MACHINE_TIER_NOTE } : {}),
106
146
  ...(localeHint === undefined ? {} : { localeHint }),
107
- ...(advisory === null ? {} : { advisory }),
147
+ ...(advisories.length === 0 ? {} : { advisory: advisories.join('\n') }),
108
148
  });
109
149
  }
110
150
  catch (err) {
@@ -8,6 +8,9 @@ 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 { normalizeLocale, PathValidationError } from '../wiki/locale.js';
13
+ import { listPages, readPage } from '../wiki/pages.read.js';
11
14
  import { errEnvelope, okJson, reportUrls, URL_MANDATE } from './shared.js';
12
15
  const s = tool.schema;
13
16
  const TRANSLATE_ARGS = {
@@ -53,11 +56,50 @@ export function makeTranslateSnippetTool(deps) {
53
56
  });
54
57
  }
55
58
  const MAP_ARGS = {
56
- action: s.enum(['show', 'refresh', 'timeline']).default('show'),
59
+ action: s.enum(['show', 'refresh', 'timeline', 'maintain']).default('show'),
57
60
  days: s.number().int().positive().optional().describe('timeline: keep only rows updated within the last N days'),
58
61
  path: s.string().optional().describe('timeline: section/path prefix filter (e.g. ops)'),
62
+ deep: s.boolean().optional().describe('maintain: additionally read every page body (freshness stamps + redirect stubs) — one bounded read per row'),
59
63
  };
60
64
  const MapArgsSchema = s.object(MAP_ARGS);
65
+ /** maintain: light tier is map rows + ONE read-only pages.list pass per locale
66
+ * (the mirror's MapRow carries no tags; the list join restores the vocab view);
67
+ * deep additionally reads each body via readPage. Reserved-path pages (e.g.
68
+ * 'home', probe p1) answer null instead of killing the sweep. Read-only. */
69
+ async function runMaintain(deps, mapDeps, snapshot, deep) {
70
+ const client = deps.getClient();
71
+ const tagIndex = new Map();
72
+ const locales = [...new Set(deps.options.locales.map(normalizeLocale))].sort();
73
+ for (const locale of locales) {
74
+ for (const item of await listPages(client, { locale })) {
75
+ tagIndex.set(`${item.locale}\u0000${item.path}`, item.tags);
76
+ }
77
+ }
78
+ const rows = snapshot.rows.map((r) => ({ ...r, tags: tagIndex.get(`${r.locale}\u0000${r.path}`) ?? [] }));
79
+ const readBody = deep
80
+ ? async (path, locale) => {
81
+ try {
82
+ return (await readPage(client, path, locale))?.content ?? null;
83
+ }
84
+ catch (err) {
85
+ // Unreadable page (invalid path / transport) is a scan miss, not a report failure.
86
+ if (err instanceof PathValidationError)
87
+ return null;
88
+ throw err;
89
+ }
90
+ }
91
+ : undefined;
92
+ const report = await buildMaintainReport({ rows, mapGeneratedAt: snapshot.generatedAt, mapStaleSeconds: snapshot.staleSeconds }, { deep, readBody });
93
+ return {
94
+ action: 'maintain',
95
+ deep: report.deep,
96
+ generatedAt: report.generatedAt,
97
+ rowCount: report.rowCount,
98
+ report,
99
+ markdown: renderMaintainMarkdown(report),
100
+ urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
101
+ };
102
+ }
61
103
  // --- historian_map -----------------------------------------------------------
62
104
  export function makeMapTool(deps) {
63
105
  return tool({
@@ -67,7 +109,9 @@ export function makeMapTool(deps) {
67
109
  `show reads the local mirror (zero writes); refresh rebuilds from the wiki and writes the mirror + cache page ` +
68
110
  `(idempotent — the engine upserts via full RMW); timeline groups mirror rows by ISO week (newest first, ` +
69
111
  `optional days window + section/path prefix filter) into a human markdown table + machine-readable weeks JSON. ` +
70
- `${URL_MANDATE}.`,
112
+ `maintain runs the read-only curation sweep (twin gap, near-duplicate titles, staleness, diffusion/orphan ` +
113
+ `candidates, tag vocab, section distribution; deep:true adds per-body freshness stamps + redirect stubs) and ` +
114
+ `answers a markdown report with a stable-key JSON tail. ${URL_MANDATE}.`,
71
115
  args: MAP_ARGS,
72
116
  execute: async (raw) => {
73
117
  const args = MapArgsSchema.parse(raw);
@@ -92,6 +136,9 @@ export function makeMapTool(deps) {
92
136
  urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
93
137
  });
94
138
  }
139
+ if (args.action === 'maintain') {
140
+ return okJson(await runMaintain(deps, mapDeps, snapshot, args.deep ?? false));
141
+ }
95
142
  return okJson({
96
143
  action: 'show',
97
144
  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) {
@@ -12,8 +12,9 @@ import type { ToolResult } from '@opencode-ai/plugin';
12
12
  import type { GqlClient } from '../wiki/client.js';
13
13
  import type { HistorianOptions } from '../config.js';
14
14
  import type { PageDeps } from '../wiki/pages.write.js';
15
- import type { TranslateFn, Locale } from '../wiki/pages.read.js';
15
+ import type { TranslateFn, Locale, PageListItem } from '../wiki/pages.read.js';
16
16
  import { type LocalePair } from '../wiki/locale.js';
17
+ import { type Genre } from '../templates/genres.js';
17
18
  /** Per-tool dependency bag; the client is a thunk so a bad key surface at
18
19
  * buildTools time as nothing — only the first execution that touches the
19
20
  * wiki resolves it (and then yields a ConfigError envelope, not a crash).
@@ -77,3 +78,51 @@ export declare function monolingualRefusalJson(toolName: string, argumentName: s
77
78
  * fence at or under the limit). The advisory is informational only: every
78
79
  * caller still performs the write. */
79
80
  export declare function frontDumpAdvisory(tier: Tier, content: string): string | null;
81
+ export interface CollisionInput {
82
+ readonly tier: Tier;
83
+ readonly path: string;
84
+ readonly locale: Locale;
85
+ readonly title: string;
86
+ readonly baseUrl: string;
87
+ /** Exact-(path, locale) pre-read result. A FAILED read must pass false — a
88
+ * transport hiccup never masquerades as a collision (the write proceeds
89
+ * with no advice rather than with wrong advice). */
90
+ readonly exists: boolean;
91
+ /** listPages inventory snapshot; a failed listPages read yields [] → no
92
+ * duplicate advice, only the (independent) path-existence line can fire. */
93
+ readonly inventory: readonly PageListItem[];
94
+ }
95
+ /** Advisory-only duplicate detector for historian_page_create:
96
+ * (a) the exact target (path, locale) already exists → prefer
97
+ * historian_page_update, with the page URL;
98
+ * (b) the same normalized title lives on a DIFFERENT non-machine path →
99
+ * 疑似重复 … 先读再写, with each path's URLs (first 3, then a count).
100
+ * Pure over its inputs and advisory-only: it NEVER throws and NEVER blocks —
101
+ * a same-path other-locale twin is not a duplicate, evidence-tier writes skip
102
+ * (b) (raw-material pages legitimately echo human titles), and machine
103
+ * namespaces (_meta/, _evidence/) never surface as duplicates. */
104
+ export declare function collisionAdvisory(input: CollisionInput): string | null;
105
+ /**
106
+ * Pre-write self-check advisory (plan todo 4): score the draft with the SAME
107
+ * 10-item gate the migrate pipeline uses (scoreChecklist — no duplicated
108
+ * scoring logic), purely locally, zero network. 3+ failing items produce a
109
+ * hint naming them; items 9-10 are 'deferred' pre-write by the scorer's own
110
+ * contract (migrate-score.ts) and can never count as fail. Informational
111
+ * only — the write always proceeds; evidence tier is exempt (callers pass
112
+ * front only: raw material is not a genre page).
113
+ */
114
+ export declare function checklistAdvisory(genre: Genre, draft: string): string | null;
115
+ /** Config-level write refusal, paralleling PathValidationError's shape. The
116
+ * class NAME is the routing key errEnvelope dispatches on: this shares the
117
+ * 'ConfigError' hint case with jsonc.ConfigError (which adds a code field
118
+ * this pure path rule does not need). */
119
+ export declare class ConfigError extends Error {
120
+ constructor(message: string);
121
+ }
122
+ /** Pure allow-list check on a write target path: null = allowed, string =
123
+ * refusal message. Empty/undefined allow-list = allow-all (the documented
124
+ * default, config.ts HistorianOptions.sections). Match is segment-wise and
125
+ * case-sensitive: section 'doc' authorizes 'doc' and 'doc/x', never 'docs/x'. */
126
+ export declare function sectionGuard(path: string, allowedSections: readonly string[] | undefined): string | null;
127
+ /** Guard + envelope in one step: null = proceed, ToolResult = refuse. */
128
+ export declare function sectionRefusalJson(path: string, allowedSections: readonly string[] | undefined): ToolResult | null;
@@ -9,6 +9,8 @@
9
9
  * here.
10
10
  */
11
11
  import { assertLocalePair, PathValidationError } from '../wiki/locale.js';
12
+ import { scoreChecklist } from '../migrate-score.js';
13
+ import { selfReviewChecklist } from '../templates/genres.js';
12
14
  /** Engine deps for one operation; client resolved lazily at use time. */
13
15
  export function pageDeps(deps) {
14
16
  return { client: deps.getClient(), options: deps.options, translate: deps.translate };
@@ -185,3 +187,120 @@ export function frontDumpAdvisory(tier, content) {
185
187
  return (`content contains a ${lines}-line fenced block; per contract, move raw material to a ` +
186
188
  `tier:"evidence" page under _evidence/ and link it from the human page (SYN-16)`);
187
189
  }
190
+ // --- Create-path collision advisory (v4 todo 3) --------------------------------
191
+ /** Title equality for duplicate detection: trim, collapse internal whitespace,
192
+ * casefold — 'LLM Eval' == ' llm\neval '. */
193
+ function normalizeTitle(title) {
194
+ return title.trim().replace(/\s+/gu, ' ').toLocaleLowerCase();
195
+ }
196
+ /** Number of duplicate paths shown verbatim before the overflow count. */
197
+ const COLLISION_DUPE_DISPLAY_LIMIT = 3;
198
+ /** Advisory-only duplicate detector for historian_page_create:
199
+ * (a) the exact target (path, locale) already exists → prefer
200
+ * historian_page_update, with the page URL;
201
+ * (b) the same normalized title lives on a DIFFERENT non-machine path →
202
+ * 疑似重复 … 先读再写, with each path's URLs (first 3, then a count).
203
+ * Pure over its inputs and advisory-only: it NEVER throws and NEVER blocks —
204
+ * a same-path other-locale twin is not a duplicate, evidence-tier writes skip
205
+ * (b) (raw-material pages legitimately echo human titles), and machine
206
+ * namespaces (_meta/, _evidence/) never surface as duplicates. */
207
+ export function collisionAdvisory(input) {
208
+ const parts = [];
209
+ if (input.exists) {
210
+ const urls = reportUrls(input.baseUrl, input.path, input.locale);
211
+ parts.push(`path exists — '${input.path}' (${input.locale}) already holds a page; ` +
212
+ `prefer historian_page_update to amend it; ${urls[input.locale]}`);
213
+ }
214
+ const norm = normalizeTitle(input.title);
215
+ if (input.tier === 'front' && norm !== '') {
216
+ const dupes = input.inventory.filter((row) => row.path !== input.path && !isInternalPath(row.path) && normalizeTitle(row.title) === norm);
217
+ const paths = [...new Set(dupes.map((row) => row.path))];
218
+ if (paths.length > 0) {
219
+ const shown = paths.slice(0, COLLISION_DUPE_DISPLAY_LIMIT).map((p) => {
220
+ const urls = reportUrls(input.baseUrl, p, input.locale);
221
+ return `${p} (en=${urls.en} zh=${urls.zh})`;
222
+ });
223
+ const overflow = paths.length - shown.length;
224
+ parts.push(`疑似重复: title matches other path(s) ${shown.join('; ')}` +
225
+ (overflow > 0 ? ` +${overflow} more` : '') +
226
+ ` — 先读再写 (historian_read the existing page, prefer historian_page_update over a new twin)`);
227
+ }
228
+ }
229
+ return parts.length === 0 ? null : parts.join('\n');
230
+ }
231
+ // --- Create-path pre-write checklist gate (v4 todo 4) --------------------------
232
+ /** Failing items needed to speak up: 1-2 stragglers are noise, 3+ is a draft
233
+ * worth flagging. Threshold per the plan (todo 4). */
234
+ const CHECKLIST_FAIL_TRIGGER = 3;
235
+ /** The zh short name of a checklist item: its label up to the first
236
+ * full-width/latin colon or bracket — '导言占比 10–15%'. */
237
+ function itemShortName(label) {
238
+ const cut = (label.split(/[::((]/u, 1)[0] ?? label).trim();
239
+ return cut === '' ? label.trim() : cut;
240
+ }
241
+ /**
242
+ * Pre-write self-check advisory (plan todo 4): score the draft with the SAME
243
+ * 10-item gate the migrate pipeline uses (scoreChecklist — no duplicated
244
+ * scoring logic), purely locally, zero network. 3+ failing items produce a
245
+ * hint naming them; items 9-10 are 'deferred' pre-write by the scorer's own
246
+ * contract (migrate-score.ts) and can never count as fail. Informational
247
+ * only — the write always proceeds; evidence tier is exempt (callers pass
248
+ * front only: raw material is not a genre page).
249
+ */
250
+ export function checklistAdvisory(genre, draft) {
251
+ const failed = scoreChecklist(genre, draft).filter((v) => v.verdict === 'fail');
252
+ if (failed.length < CHECKLIST_FAIL_TRIGGER)
253
+ return null;
254
+ const labels = new Map(selfReviewChecklist(genre).map((item) => [item.id, item.label]));
255
+ const names = failed.map((v) => `#${v.id} ${itemShortName(labels.get(v.id) ?? '?')}`).join('; ');
256
+ return `自检 ${failed.length}/10 未通过: ${names} (不阻断, 发布前请补齐)`;
257
+ }
258
+ // --- options.sections enforcement (v4 todo 5) ---------------------------------
259
+ /** Config-level write refusal, paralleling PathValidationError's shape. The
260
+ * class NAME is the routing key errEnvelope dispatches on: this shares the
261
+ * 'ConfigError' hint case with jsonc.ConfigError (which adds a code field
262
+ * this pure path rule does not need). */
263
+ export class ConfigError extends Error {
264
+ constructor(message) {
265
+ super(message);
266
+ this.name = 'ConfigError';
267
+ }
268
+ }
269
+ /** First path segments the plugin maintains for its own bookkeeping — always
270
+ * writable regardless of sections: a topic taxonomy configured for human
271
+ * knowledge must never lock out the home landing page, the wiki-index map,
272
+ * the _sandbox scratch area, the _data store, or the machine namespaces. */
273
+ const SECTION_EXEMPT_SEGMENTS = [
274
+ 'home',
275
+ 'wiki-index',
276
+ '_sandbox',
277
+ '_data',
278
+ ...INTERNAL_NAMESPACES,
279
+ ];
280
+ /** Section entry as configured → comparable form (strip slashes/whitespace). */
281
+ function normalizeSection(entry) {
282
+ return entry.trim().replace(/^\/+|\/+$/gu, '');
283
+ }
284
+ /** Pure allow-list check on a write target path: null = allowed, string =
285
+ * refusal message. Empty/undefined allow-list = allow-all (the documented
286
+ * default, config.ts HistorianOptions.sections). Match is segment-wise and
287
+ * case-sensitive: section 'doc' authorizes 'doc' and 'doc/x', never 'docs/x'. */
288
+ export function sectionGuard(path, allowedSections) {
289
+ if (allowedSections === undefined)
290
+ return null;
291
+ const sections = allowedSections.map(normalizeSection).filter((sec) => sec !== '');
292
+ if (sections.length === 0)
293
+ return null;
294
+ const first = path.split('/')[0];
295
+ if (SECTION_EXEMPT_SEGMENTS.includes(first))
296
+ return null;
297
+ if (sections.some((sec) => path === sec || path.startsWith(`${sec}/`)))
298
+ return null;
299
+ return (`section '${first}' is not in the configured sections [${sections.join(', ')}] — ` +
300
+ `write the page under an allowed section or add '${first}' to the plugin's sections option`);
301
+ }
302
+ /** Guard + envelope in one step: null = proceed, ToolResult = refuse. */
303
+ export function sectionRefusalJson(path, allowedSections) {
304
+ const violation = sectionGuard(path, allowedSections);
305
+ return violation === null ? null : errEnvelope(new ConfigError(violation));
306
+ }
@@ -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, tierMismatchJson, TIERS, urlPair, URL_MANDATE, pageDeps, } from './shared.js';
10
+ import { enforceTierPath, errEnvelope, frontDumpAdvisory, isInternalPath, MACHINE_TIER_NOTE, monolingualRefusalJson, okJson, 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(),
@@ -25,6 +25,9 @@ export function makeUpdateTool(deps) {
25
25
  args: UPDATE_ARGS,
26
26
  execute: async (raw) => {
27
27
  const args = UpdateArgsSchema.parse(raw);
28
+ const offSections = sectionRefusalJson(args.path, deps.options.sections);
29
+ if (offSections !== null)
30
+ return offSections;
28
31
  try {
29
32
  const page = await readPage(deps.getClient(), args.path, args.locale);
30
33
  if (page === null) {
@@ -116,6 +119,9 @@ export function makeAppendTool(deps) {
116
119
  if (isEvidence && args.sectionZh !== undefined) {
117
120
  return monolingualRefusalJson('historian_page_append', 'sectionZh');
118
121
  }
122
+ const offSections = sectionRefusalJson(args.path, deps.options.sections);
123
+ if (offSections !== null)
124
+ return offSections;
119
125
  try {
120
126
  const appended = await appendSection(pageDeps(deps), args.path, args.locale, args.section);
121
127
  let zhStatus;
@@ -22,7 +22,8 @@ export declare class PathValidationError extends Error {
22
22
  * 5. first segment matches the locale shape (pitfall #9)
23
23
  * 6. any segment is length 1 (wiki.js rejects single-char path components)
24
24
  * 7. any segment contains characters outside `[A-Za-z0-9._-]`
25
- * 8. any segment is a reserved word (wiki.js endpoint collision)
25
+ * 8. any segment is a reserved word (wiki.js endpoint collision), except the
26
+ * exact top-level 'home' (a live published path — see D9 note at the check)
26
27
  *
27
28
  * Order matters: cheap substring checks first, then per-segment rules.
28
29
  * Every rejection names the offending segment + the rule in the message.
@@ -44,7 +44,8 @@ const SEGMENT_CHARS = /^[A-Za-z0-9._-]+$/;
44
44
  * 5. first segment matches the locale shape (pitfall #9)
45
45
  * 6. any segment is length 1 (wiki.js rejects single-char path components)
46
46
  * 7. any segment contains characters outside `[A-Za-z0-9._-]`
47
- * 8. any segment is a reserved word (wiki.js endpoint collision)
47
+ * 8. any segment is a reserved word (wiki.js endpoint collision), except the
48
+ * exact top-level 'home' (a live published path — see D9 note at the check)
48
49
  *
49
50
  * Order matters: cheap substring checks first, then per-segment rules.
50
51
  * Every rejection names the offending segment + the rule in the message.
@@ -84,7 +85,12 @@ export function validatePath(p) {
84
85
  if (!SEGMENT_CHARS.test(seg)) {
85
86
  throw new PathValidationError(`segment '${seg}' contains invalid characters (allowed: [A-Za-z0-9._-])`);
86
87
  }
87
- if (RESERVED_WORDS.has(seg.toLowerCase())) {
88
+ // D9 bypass (probe p1): wiki.js 2.5.314 hosts a live published page at
89
+ // path=home (id48, en+zh) — the reserved-word block was plugin-side
90
+ // folklore, not a server limit. Allow the EXACT top-level segment 'home'
91
+ // only; nested 'home' (foo/home) and case variants (HOME) stay rejected.
92
+ const isExactTopLevelHome = segments.length === 1 && seg === 'home';
93
+ if (!isExactTopLevelHome && RESERVED_WORDS.has(seg.toLowerCase())) {
88
94
  throw new PathValidationError(`segment '${seg}' is a reserved wiki.js word (home|login|register|graphql|healthz|_assets|favicon)`);
89
95
  }
90
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-wiki-historian",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",