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.
- package/README.md +75 -21
- package/dist/chronology.js +6 -0
- package/dist/config.d.ts +9 -5
- package/dist/config.js +3 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +60 -18
- package/dist/loop-state.d.ts +28 -0
- package/dist/loop-state.js +46 -0
- package/dist/maintain.d.ts +123 -0
- package/dist/maintain.js +352 -0
- package/dist/map.d.ts +7 -1
- package/dist/map.js +17 -3
- package/dist/migrate-score.d.ts +2 -1
- package/dist/migrate-score.js +58 -5
- package/dist/templates/evidence.d.ts +28 -0
- package/dist/templates/evidence.js +34 -0
- package/dist/templates/genres.d.ts +3 -3
- package/dist/templates/genres.js +32 -5
- package/dist/templates/skeletons.d.ts +7 -0
- package/dist/templates/skeletons.js +105 -4
- package/dist/tools/create.js +90 -12
- package/dist/tools/local.js +51 -3
- package/dist/tools/mutate.js +10 -3
- package/dist/tools/read.js +112 -4
- package/dist/tools/shared.d.ts +77 -1
- package/dist/tools/shared.js +219 -0
- package/dist/tools/write.js +37 -2
- package/dist/wiki/locale.d.ts +2 -1
- package/dist/wiki/locale.js +8 -2
- package/package.json +1 -1
- package/skills/historian/SKILL.md +140 -31
- package/skills/historian/references/adapting-your-own-wiki.md +17 -5
- package/skills/historian/references/genres.md +98 -5
- package/skills/historian/references/rules.md +7 -4
- package/skills/historian/references/style.md +11 -1
package/dist/tools/shared.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/shared.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tools/write.js
CHANGED
|
@@ -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 (
|
|
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) {
|
package/dist/wiki/locale.d.ts
CHANGED
|
@@ -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.
|
package/dist/wiki/locale.js
CHANGED
|
@@ -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
|
-
|
|
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