opencode-wiki-historian 0.2.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.
@@ -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).
@@ -50,3 +51,78 @@ export declare function okJson(body: Record<string, unknown>): ToolResult;
50
51
  export declare function errEnvelope(err: unknown): ToolResult;
51
52
  /** Destructive-action gate: refusal BEFORE any fetch when confirm is absent. */
52
53
  export declare function confirmRequiredJson(toolName: string, got: unknown): ToolResult;
54
+ /** Machine namespaces: pages whose first path segment lives here are hidden
55
+ * machine-tier pages (the `_meta/page-map` cache precedent in src/map.ts —
56
+ * isPublished:false + isPrivate:true + tags + twin:false). */
57
+ export declare const INTERNAL_NAMESPACES: readonly ["_meta", "_evidence"];
58
+ /** Plan-mandated note on every evidence-tier success envelope (verbatim). */
59
+ export declare const MACHINE_TIER_NOTE = "machine-tier page; anonymous visits 404 by design";
60
+ /** Tier enum values — single source of truth. The zod schema itself must be
61
+ * declared locally per tool via `s.enum(TIERS)`: exporting a zod value from
62
+ * here breaks declaration emit (TS2742 — tool.schema is zod 4.1.8 nested in
63
+ * @opencode-ai/plugin, unnameable without a zod import, and root zod is v3). */
64
+ export declare const TIERS: readonly ["front", "evidence"];
65
+ /** Page tier: 'front' = bilingual human surface; 'evidence' = machine namespace. */
66
+ export type Tier = (typeof TIERS)[number];
67
+ /** True when the path's first segment is an internal (machine) namespace. */
68
+ export declare function isInternalPath(path: string): boolean;
69
+ /** Pure tier↔path guard: null when the pair is legal, an error string when
70
+ * not (evidence ⇒ internal namespace; front ⇒ NOT internal). */
71
+ export declare function enforceTierPath(tier: Tier, path: string): string | null;
72
+ /** Tier↔path mismatch → uniform failure envelope (no fetch has run yet). */
73
+ export declare function tierMismatchJson(message: string): ToolResult;
74
+ /** Evidence-tier zh-side input → refusal envelope (monolingual invariant). */
75
+ export declare function monolingualRefusalJson(toolName: string, argumentName: string): ToolResult;
76
+ /** Soft-gate advisory for front-tier raw dumps: null when nothing to say
77
+ * (evidence tier NEVER checked — machine pages are the raw-material home;
78
+ * fence at or under the limit). The advisory is informational only: every
79
+ * caller still performs the write. */
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 };
@@ -85,3 +87,220 @@ function hintFor(errorKind) {
85
87
  return 'Inspect the message and retry.';
86
88
  }
87
89
  }
90
+ // --- Tier plumbing (v3 todo 4) ------------------------------------------------
91
+ /** Machine namespaces: pages whose first path segment lives here are hidden
92
+ * machine-tier pages (the `_meta/page-map` cache precedent in src/map.ts —
93
+ * isPublished:false + isPrivate:true + tags + twin:false). */
94
+ export const INTERNAL_NAMESPACES = ['_meta', '_evidence'];
95
+ /** Plan-mandated note on every evidence-tier success envelope (verbatim). */
96
+ export const MACHINE_TIER_NOTE = 'machine-tier page; anonymous visits 404 by design';
97
+ /** Tier enum values — single source of truth. The zod schema itself must be
98
+ * declared locally per tool via `s.enum(TIERS)`: exporting a zod value from
99
+ * here breaks declaration emit (TS2742 — tool.schema is zod 4.1.8 nested in
100
+ * @opencode-ai/plugin, unnameable without a zod import, and root zod is v3). */
101
+ export const TIERS = ['front', 'evidence'];
102
+ /** True when the path's first segment is an internal (machine) namespace. */
103
+ export function isInternalPath(path) {
104
+ const first = path.split('/')[0];
105
+ return INTERNAL_NAMESPACES.includes(first);
106
+ }
107
+ /** Pure tier↔path guard: null when the pair is legal, an error string when
108
+ * not (evidence ⇒ internal namespace; front ⇒ NOT internal). */
109
+ export function enforceTierPath(tier, path) {
110
+ const internal = isInternalPath(path);
111
+ switch (tier) {
112
+ case 'evidence':
113
+ return internal
114
+ ? null
115
+ : `tier "evidence" requires a machine namespace path (${INTERNAL_NAMESPACES.map((n) => `${n}/`).join(' or ')}) — got '${path}'`;
116
+ case 'front':
117
+ return internal
118
+ ? `tier "front" cannot write to the machine namespace '${path}' — use tier "evidence" for ${INTERNAL_NAMESPACES.map((n) => `${n}/`).join(' or ')} paths`
119
+ : null;
120
+ }
121
+ }
122
+ /** Tier↔path mismatch → uniform failure envelope (no fetch has run yet). */
123
+ export function tierMismatchJson(message) {
124
+ return dump({
125
+ ok: false,
126
+ error: 'tier-path-mismatch',
127
+ errorKind: 'TierPathMismatchError',
128
+ message,
129
+ actionableHint: 'Pass the tier that matches the path namespace: _meta/ and _evidence/ are machine (evidence) paths; everything else is front.',
130
+ });
131
+ }
132
+ /** Evidence-tier zh-side input → refusal envelope (monolingual invariant). */
133
+ export function monolingualRefusalJson(toolName, argumentName) {
134
+ return dump({
135
+ ok: false,
136
+ error: 'evidence-monolingual',
137
+ errorKind: 'TierMonolingualError',
138
+ message: `${toolName} received "${argumentName}" on an evidence-tier page, which is monolingual en`,
139
+ actionableHint: 'Drop the zh-side argument (locale "zh" / sectionZh) — evidence machine pages never carry a bilingual twin.',
140
+ });
141
+ }
142
+ // --- Front-tier raw-dump soft gate (v3 todo 6) --------------------------------
143
+ /** Contract band (SKILL.md SYN-16): a human page stays a 5-10 line excerpt;
144
+ * longer raw material belongs on an evidence page. Strictly ABOVE this many
145
+ * fence-interior lines triggers the advisory — 30 is the soft gate, never a
146
+ * hard refusal. Deliberately layered vs the contract bands; do not unify. */
147
+ const FRONT_DUMP_FENCE_LIMIT = 30;
148
+ /** Longest fenced code block in the content, counting lines STRICTLY inside
149
+ * the ``` fences (fence markers excluded). An unterminated fence counts to
150
+ * EOF. Fences opening at line start (after optional indentation) close on
151
+ * the next ```-leading line. */
152
+ function longestFenceLines(content) {
153
+ let longest = 0;
154
+ let inside = false;
155
+ let count = 0;
156
+ for (const line of content.split('\n')) {
157
+ if (line.trimStart().startsWith('```')) {
158
+ if (inside) {
159
+ if (count > longest)
160
+ longest = count;
161
+ inside = false;
162
+ count = 0;
163
+ }
164
+ else {
165
+ inside = true;
166
+ count = 0;
167
+ }
168
+ }
169
+ else if (inside) {
170
+ count++;
171
+ }
172
+ }
173
+ if (inside && count > longest)
174
+ longest = count; // unterminated fence → EOF
175
+ return longest;
176
+ }
177
+ /** Soft-gate advisory for front-tier raw dumps: null when nothing to say
178
+ * (evidence tier NEVER checked — machine pages are the raw-material home;
179
+ * fence at or under the limit). The advisory is informational only: every
180
+ * caller still performs the write. */
181
+ export function frontDumpAdvisory(tier, content) {
182
+ if (tier !== 'front')
183
+ return null;
184
+ const lines = longestFenceLines(content);
185
+ if (lines <= FRONT_DUMP_FENCE_LIMIT)
186
+ return null;
187
+ return (`content contains a ${lines}-line fenced block; per contract, move raw material to a ` +
188
+ `tier:"evidence" page under _evidence/ and link it from the human page (SYN-16)`);
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 { errEnvelope, okJson, 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) {
@@ -36,6 +39,7 @@ export function makeUpdateTool(deps) {
36
39
  description: args.description,
37
40
  tags: args.tags,
38
41
  });
42
+ const advisory = frontDumpAdvisory(inferredTier(page.path), args.content ?? '');
39
43
  return okJson({
40
44
  mode: 'update',
41
45
  path: args.path,
@@ -51,6 +55,7 @@ export function makeUpdateTool(deps) {
51
55
  updatedAt: result.page.updatedAt,
52
56
  },
53
57
  urls: urlPair(result),
58
+ ...(advisory === null ? {} : { advisory }),
54
59
  });
55
60
  }
56
61
  catch (err) {
@@ -59,6 +64,11 @@ export function makeUpdateTool(deps) {
59
64
  },
60
65
  });
61
66
  }
67
+ /** Path-only tier resolution (same rule the append fallback uses): an
68
+ * internal namespace first segment ⇒ evidence, anything else ⇒ front. */
69
+ function inferredTier(path) {
70
+ return isInternalPath(path) ? 'evidence' : 'front';
71
+ }
62
72
  async function bootstrapTwin(deps, primary, section, source) {
63
73
  const translate = source === 'translate' ? deps.translate : undefined;
64
74
  try {
@@ -84,21 +94,43 @@ const APPEND_ARGS = {
84
94
  section: s.string(),
85
95
  locale: s.enum(['en', 'zh']).default('en'),
86
96
  sectionZh: s.string().optional().describe('Explicit zh section; when absent the zh side falls back to translation/wiring'),
97
+ tier: s.enum(TIERS).optional().describe('Explicit tier; absent → inferred from the path (first segment _meta/ or _evidence/ ⇒ evidence, else front)'),
87
98
  };
88
99
  const AppendArgsSchema = s.object(APPEND_ARGS);
89
100
  export function makeAppendTool(deps) {
90
101
  return tool({
91
102
  description: `Append a section to an existing page (engine append + RMW). For the en page with a MISSING zh twin, ` +
92
103
  `the twin is auto-created — from sectionZh when given, else translated when the translator is wired. ` +
104
+ `Evidence-tier pages (_meta/ or _evidence/) are monolingual en — no twin handling. ` +
93
105
  `${URL_MANDATE}.`,
94
106
  args: APPEND_ARGS,
95
107
  execute: async (raw) => {
96
108
  const args = AppendArgsSchema.parse(raw);
109
+ // Exactly ONE resolution rule: explicit tier arg wins; otherwise infer
110
+ // from the path prefix (internal namespace ⇒ evidence).
111
+ const tier = args.tier ?? inferredTier(args.path);
112
+ const mismatch = enforceTierPath(tier, args.path);
113
+ if (mismatch !== null)
114
+ return tierMismatchJson(mismatch);
115
+ const isEvidence = tier === 'evidence';
116
+ if (isEvidence && args.locale === 'zh') {
117
+ return monolingualRefusalJson('historian_page_append', 'locale "zh"');
118
+ }
119
+ if (isEvidence && args.sectionZh !== undefined) {
120
+ return monolingualRefusalJson('historian_page_append', 'sectionZh');
121
+ }
122
+ const offSections = sectionRefusalJson(args.path, deps.options.sections);
123
+ if (offSections !== null)
124
+ return offSections;
97
125
  try {
98
126
  const appended = await appendSection(pageDeps(deps), args.path, args.locale, args.section);
99
127
  let zhStatus;
100
128
  let zhNote;
101
- if (args.locale === 'zh') {
129
+ if (isEvidence) {
130
+ zhStatus = 'skipped';
131
+ zhNote = 'evidence tier is monolingual en — no zh twin is bootstrapped or touched.';
132
+ }
133
+ else if (args.locale === 'zh') {
102
134
  zhStatus = 'appended';
103
135
  zhNote = 'Primary locale is zh — the en twin is untouched (check with historian_read(path, "en")).';
104
136
  }
@@ -130,6 +162,7 @@ export function makeAppendTool(deps) {
130
162
  zhNote = 'No zh twin exists and no translator is wired — provide sectionZh to bootstrap it.';
131
163
  }
132
164
  }
165
+ const advisory = frontDumpAdvisory(tier, args.section);
133
166
  return okJson({
134
167
  mode: 'append',
135
168
  path: args.path,
@@ -138,6 +171,8 @@ export function makeAppendTool(deps) {
138
171
  urls: urlPair(appended),
139
172
  zhStatus,
140
173
  zhNote,
174
+ ...(isEvidence ? { note: MACHINE_TIER_NOTE } : {}),
175
+ ...(advisory === null ? {} : { advisory }),
141
176
  });
142
177
  }
143
178
  catch (err) {
@@ -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.2.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",