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.
- package/README.md +34 -17
- package/dist/index.js +34 -16
- package/dist/maintain.d.ts +123 -0
- package/dist/maintain.js +352 -0
- package/dist/migrate-score.d.ts +2 -1
- package/dist/migrate-score.js +58 -5
- 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 +101 -0
- package/dist/tools/create.js +47 -7
- package/dist/tools/local.js +49 -2
- package/dist/tools/mutate.js +10 -3
- package/dist/tools/read.js +112 -4
- package/dist/tools/shared.d.ts +50 -1
- package/dist/tools/shared.js +119 -0
- package/dist/tools/write.js +7 -1
- 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 +128 -29
- package/skills/historian/references/adapting-your-own-wiki.md +4 -2
- package/skills/historian/references/genres.md +87 -5
- package/skills/historian/references/rules.md +6 -3
- package/skills/historian/references/style.md +11 -1
package/dist/maintain.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maintain curation report (plan todo-7, D3 CURATE / D12): deterministic
|
|
3
|
+
* metric sweeps over already-fetched map rows (tier-1 light) plus optional
|
|
4
|
+
* injected page-body reads (deep). The chronology.ts precedent holds here —
|
|
5
|
+
* no client, no fs, no wiki logic: pure functions over MapRow-shaped input and
|
|
6
|
+
* a readBody dependency; the tool layer (tools/local.ts) supplies both.
|
|
7
|
+
*
|
|
8
|
+
* Output is dual-form: a human markdown report (renderMaintainMarkdown) and a
|
|
9
|
+
* machine-readable JSON tail with stable top-level keys (schema
|
|
10
|
+
* 'historian.maintain.v1') so later todos can parse it without re-deriving.
|
|
11
|
+
*
|
|
12
|
+
* allow: SIZE_OK — the plan pins this commit to src/maintain.ts +
|
|
13
|
+
* tools/local.ts + test/maintain.test.ts only, so the metric builder and its
|
|
14
|
+
* markdown renderer ship as one module instead of a third file.
|
|
15
|
+
*/
|
|
16
|
+
import { classifyGenre } from './templates/genres.js';
|
|
17
|
+
import { isInternalPath } from './tools/shared.js';
|
|
18
|
+
import { normalize } from './migrate-score.js';
|
|
19
|
+
// --- Constants --------------------------------------------------------------
|
|
20
|
+
export const MAINTAIN_SCHEMA = 'historian.maintain.v1';
|
|
21
|
+
/** Trigram-Jaccard bar for calling two (different-path) titles near-duplicates. */
|
|
22
|
+
export const DUP_TITLE_THRESHOLD = 0.75;
|
|
23
|
+
const DAY_MS = 86_400_000;
|
|
24
|
+
const DEFAULT_TOP_N = 10;
|
|
25
|
+
/** Genres whose pages must carry the D4 last-verified stamp (G6 how-to joins
|
|
26
|
+
* this set when todo-2 lands it — string membership, no type coupling). */
|
|
27
|
+
// D4 freshness applies to knowledge-fact pages: G5 today; 'G6' is listed
|
|
28
|
+
// pre-emptively so the deep sweep lights up when the genre lane lands.
|
|
29
|
+
const FRESHNESS_GENRES = ['G5', 'G6'];
|
|
30
|
+
const STAMP_RE = /上次核实|last verified/i;
|
|
31
|
+
const REVIEW_LABEL_RE = /^(复核周期|复核期限|复核日期|review[-_ ]?by|review[-_ ]?due)$/i;
|
|
32
|
+
const ISO_DATE_RE = /\d{4}-\d{2}-\d{2}/;
|
|
33
|
+
const REDIRECT_RE = /^>\s*Redirect:/i;
|
|
34
|
+
// --- Title similarity (trigram core copied from migrate-score.ts:289, where it
|
|
35
|
+
// --- is private with a 0.95 roundtrip bar; maintain needs its own threshold) ---
|
|
36
|
+
function titleGrams(s) {
|
|
37
|
+
const out = new Set();
|
|
38
|
+
if (s.length < 3) {
|
|
39
|
+
if (s.length > 0)
|
|
40
|
+
out.add(s);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
for (let i = 0; i <= s.length - 3; i++)
|
|
44
|
+
out.add(s.slice(i, i + 3));
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
function jaccard(a, b) {
|
|
48
|
+
if (a.size === 0 && b.size === 0)
|
|
49
|
+
return 1;
|
|
50
|
+
let inter = 0;
|
|
51
|
+
for (const g of a)
|
|
52
|
+
if (b.has(g))
|
|
53
|
+
inter++;
|
|
54
|
+
const union = a.size + b.size - inter;
|
|
55
|
+
return union === 0 ? 1 : inter / union;
|
|
56
|
+
}
|
|
57
|
+
// --- Metric builders ----------------------------------------------------------
|
|
58
|
+
function cmpStr(a, b) {
|
|
59
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
60
|
+
}
|
|
61
|
+
function findDuplicates(rows) {
|
|
62
|
+
const units = rows.map((r) => ({
|
|
63
|
+
path: r.path,
|
|
64
|
+
title: r.title,
|
|
65
|
+
grams: titleGrams(normalize(r.title).toLowerCase()),
|
|
66
|
+
}));
|
|
67
|
+
const parent = units.map((_, i) => i);
|
|
68
|
+
const find = (i) => {
|
|
69
|
+
while (parent[i] !== i) {
|
|
70
|
+
parent[i] = parent[parent[i]];
|
|
71
|
+
i = parent[i];
|
|
72
|
+
}
|
|
73
|
+
return i;
|
|
74
|
+
};
|
|
75
|
+
for (let i = 0; i < units.length; i++) {
|
|
76
|
+
for (let j = i + 1; j < units.length; j++) {
|
|
77
|
+
// twins share a path by design — never a duplicate; cross-locale title
|
|
78
|
+
// similarity is low enough that same-path exclusion is the only guard needed
|
|
79
|
+
if (units[i].path === units[j].path)
|
|
80
|
+
continue;
|
|
81
|
+
if (jaccard(units[i].grams, units[j].grams) >= DUP_TITLE_THRESHOLD)
|
|
82
|
+
parent[find(i)] = find(j);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const clusters = new Map();
|
|
86
|
+
units.forEach((u, i) => {
|
|
87
|
+
const root = find(i);
|
|
88
|
+
const bucket = clusters.get(root) ?? { paths: new Set(), titles: new Set() };
|
|
89
|
+
bucket.paths.add(u.path);
|
|
90
|
+
bucket.titles.add(u.title);
|
|
91
|
+
clusters.set(root, bucket);
|
|
92
|
+
});
|
|
93
|
+
const out = [...clusters.values()]
|
|
94
|
+
.filter((c) => c.paths.size >= 2)
|
|
95
|
+
.map((c) => ({ paths: [...c.paths].sort(cmpStr), titles: [...c.titles].sort(cmpStr) }));
|
|
96
|
+
out.sort((a, b) => b.paths.length - a.paths.length || cmpStr(a.paths[0], b.paths[0]));
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
function staleness(rows, now, topN) {
|
|
100
|
+
const byPath = new Map();
|
|
101
|
+
for (const r of rows) {
|
|
102
|
+
const t = Date.parse(r.updatedAt);
|
|
103
|
+
if (Number.isNaN(t))
|
|
104
|
+
continue;
|
|
105
|
+
const cur = byPath.get(r.path);
|
|
106
|
+
if (cur === undefined)
|
|
107
|
+
byPath.set(r.path, { t, updatedAt: r.updatedAt, locales: new Set([r.locale]) });
|
|
108
|
+
else {
|
|
109
|
+
cur.locales.add(r.locale);
|
|
110
|
+
if (t > cur.t) {
|
|
111
|
+
cur.t = t;
|
|
112
|
+
cur.updatedAt = r.updatedAt;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const round1 = (x) => Math.round(x * 10) / 10;
|
|
117
|
+
return [...byPath.entries()]
|
|
118
|
+
.map(([path, v]) => ({
|
|
119
|
+
path,
|
|
120
|
+
updatedAt: v.updatedAt,
|
|
121
|
+
daysOld: round1((now.getTime() - v.t) / DAY_MS),
|
|
122
|
+
locales: [...v.locales].sort(cmpStr),
|
|
123
|
+
}))
|
|
124
|
+
.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt) || cmpStr(a.path, b.path))
|
|
125
|
+
.slice(0, topN);
|
|
126
|
+
}
|
|
127
|
+
function singleChildDirs(paths) {
|
|
128
|
+
const subtree = new Map();
|
|
129
|
+
for (const path of paths) {
|
|
130
|
+
const segs = path.split('/');
|
|
131
|
+
for (let i = 1; i < segs.length; i++) {
|
|
132
|
+
const dir = segs.slice(0, i).join('/');
|
|
133
|
+
const bucket = subtree.get(dir) ?? new Set();
|
|
134
|
+
bucket.add(path);
|
|
135
|
+
subtree.set(dir, bucket);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const single = new Set([...subtree.entries()].filter(([, v]) => v.size === 1).map(([d]) => d));
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const dir of [...single].sort(cmpStr)) {
|
|
141
|
+
const cut = dir.lastIndexOf('/');
|
|
142
|
+
if (cut > 0 && single.has(dir.slice(0, cut)))
|
|
143
|
+
continue; // report the shallowest of a chain
|
|
144
|
+
const children = subtree.get(dir);
|
|
145
|
+
if (children !== undefined)
|
|
146
|
+
out.push({ dir, childPath: [...children][0] });
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
function rootOrphans(paths) {
|
|
151
|
+
const bySection = new Map();
|
|
152
|
+
for (const path of paths) {
|
|
153
|
+
if (path.split('/').length !== 2)
|
|
154
|
+
continue;
|
|
155
|
+
const section = path.split('/')[0];
|
|
156
|
+
const bucket = bySection.get(section) ?? [];
|
|
157
|
+
bucket.push(path);
|
|
158
|
+
bySection.set(section, bucket);
|
|
159
|
+
}
|
|
160
|
+
return [...bySection.entries()]
|
|
161
|
+
.map(([section, ps]) => ({ section, paths: ps.sort(cmpStr) }))
|
|
162
|
+
.sort((a, b) => cmpStr(a.section, b.section));
|
|
163
|
+
}
|
|
164
|
+
function tagVocab(rows) {
|
|
165
|
+
const available = rows.some((r) => r.tags !== undefined);
|
|
166
|
+
if (!available)
|
|
167
|
+
return { available: false, vocabulary: [] };
|
|
168
|
+
const counts = new Map();
|
|
169
|
+
for (const r of rows)
|
|
170
|
+
for (const tag of r.tags ?? [])
|
|
171
|
+
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
172
|
+
const vocabulary = [...counts.entries()]
|
|
173
|
+
.map(([tag, count]) => ({ tag, count }))
|
|
174
|
+
.sort((a, b) => b.count - a.count || cmpStr(a.tag, b.tag));
|
|
175
|
+
return { available: true, vocabulary };
|
|
176
|
+
}
|
|
177
|
+
function sectionDist(rows) {
|
|
178
|
+
const bySection = new Map();
|
|
179
|
+
for (const r of rows) {
|
|
180
|
+
const section = r.path.split('/')[0];
|
|
181
|
+
const bucket = bySection.get(section) ?? { paths: new Set(), rows: 0 };
|
|
182
|
+
bucket.paths.add(r.path);
|
|
183
|
+
bucket.rows += 1;
|
|
184
|
+
bySection.set(section, bucket);
|
|
185
|
+
}
|
|
186
|
+
return [...bySection.entries()]
|
|
187
|
+
.map(([section, v]) => ({ section, paths: v.paths.size, rows: v.rows }))
|
|
188
|
+
.sort((a, b) => b.paths - a.paths || cmpStr(a.section, b.section));
|
|
189
|
+
}
|
|
190
|
+
/** D4 review-by row inside a 元数据 markdown table (wiki.js has no front matter). */
|
|
191
|
+
function reviewByOf(body) {
|
|
192
|
+
for (const line of body.split('\n')) {
|
|
193
|
+
if (!line.trimStart().startsWith('|'))
|
|
194
|
+
continue;
|
|
195
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
196
|
+
if (cells.length < 3 || !REVIEW_LABEL_RE.test(cells[1]))
|
|
197
|
+
continue;
|
|
198
|
+
const m = cells.slice(2).join(' ').match(ISO_DATE_RE);
|
|
199
|
+
if (m !== null)
|
|
200
|
+
return m[0];
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
// --- buildMaintainReport --------------------------------------------------------
|
|
205
|
+
export async function buildMaintainReport(input, opts = {}) {
|
|
206
|
+
const now = opts.now ?? new Date();
|
|
207
|
+
const topN = opts.topN ?? DEFAULT_TOP_N;
|
|
208
|
+
const deep = opts.deep ?? false;
|
|
209
|
+
if (deep && opts.readBody === undefined) {
|
|
210
|
+
throw new TypeError('buildMaintainReport: deep:true requires a readBody dependency');
|
|
211
|
+
}
|
|
212
|
+
const kept = input.rows.filter((r) => !isInternalPath(r.path));
|
|
213
|
+
const paths = [...new Set(kept.map((r) => r.path))].sort(cmpStr);
|
|
214
|
+
const perLocale = {};
|
|
215
|
+
for (const r of kept)
|
|
216
|
+
perLocale[r.locale] = (perLocale[r.locale] ?? 0) + 1;
|
|
217
|
+
const localeCount = new Map();
|
|
218
|
+
for (const r of kept)
|
|
219
|
+
localeCount.set(r.path, (localeCount.get(r.path) ?? 0) + 1);
|
|
220
|
+
let redirects = { available: false, count: 0, stubs: [] };
|
|
221
|
+
let freshness = null;
|
|
222
|
+
if (deep && opts.readBody !== undefined) {
|
|
223
|
+
const readBody = opts.readBody;
|
|
224
|
+
const stubs = [];
|
|
225
|
+
const missing = [];
|
|
226
|
+
const expired = [];
|
|
227
|
+
let scanned = 0;
|
|
228
|
+
let unreadable = 0;
|
|
229
|
+
for (const r of kept) {
|
|
230
|
+
const body = await readBody(r.path, r.locale);
|
|
231
|
+
if (body === null) {
|
|
232
|
+
unreadable += 1;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
scanned += 1;
|
|
236
|
+
const firstLine = body.split('\n').find((l) => l.trim() !== '');
|
|
237
|
+
if (firstLine !== undefined && REDIRECT_RE.test(firstLine.trim())) {
|
|
238
|
+
stubs.push({ path: r.path, locale: r.locale, target: firstLine.trim().replace(REDIRECT_RE, '').trim() });
|
|
239
|
+
continue; // stubs are pointers — exempt from the freshness-stamp rule
|
|
240
|
+
}
|
|
241
|
+
const genre = classifyGenre({ title: r.title, body }).genre;
|
|
242
|
+
if (!FRESHNESS_GENRES.includes(genre))
|
|
243
|
+
continue;
|
|
244
|
+
if (!STAMP_RE.test(body))
|
|
245
|
+
missing.push({ path: r.path, locale: r.locale, genre });
|
|
246
|
+
const reviewBy = reviewByOf(body);
|
|
247
|
+
if (reviewBy !== null) {
|
|
248
|
+
const t = Date.parse(reviewBy);
|
|
249
|
+
if (!Number.isNaN(t) && t < now.getTime()) {
|
|
250
|
+
expired.push({ path: r.path, locale: r.locale, reviewBy, daysExpired: Math.floor((now.getTime() - t) / DAY_MS) });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
redirects = { available: true, count: stubs.length, stubs };
|
|
255
|
+
freshness = { scanned, unreadable, missingLastVerified: missing, expiredReviewBy: expired };
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
schema: MAINTAIN_SCHEMA,
|
|
259
|
+
generatedAt: now.toISOString(),
|
|
260
|
+
rowCount: input.rows.length,
|
|
261
|
+
mapGeneratedAt: input.mapGeneratedAt ?? null,
|
|
262
|
+
mapStaleSeconds: input.mapStaleSeconds ?? null,
|
|
263
|
+
deep,
|
|
264
|
+
pages: {
|
|
265
|
+
rows: kept.length,
|
|
266
|
+
paths: paths.length,
|
|
267
|
+
perLocale,
|
|
268
|
+
missingTwinPaths: paths.filter((p) => localeCount.get(p) === 1),
|
|
269
|
+
},
|
|
270
|
+
duplicates: { threshold: DUP_TITLE_THRESHOLD, clusters: findDuplicates(kept) },
|
|
271
|
+
staleness: { topN, oldest: staleness(kept, now, topN) },
|
|
272
|
+
diffusion: { singleChildDirs: singleChildDirs(paths) },
|
|
273
|
+
rootOrphans: rootOrphans(paths),
|
|
274
|
+
tags: tagVocab(kept),
|
|
275
|
+
redirects,
|
|
276
|
+
sections: sectionDist(kept),
|
|
277
|
+
freshness,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// --- renderMaintainMarkdown -----------------------------------------------------
|
|
281
|
+
const fmt = (n) => String(n);
|
|
282
|
+
/** Human report + (always) a fenced machine-readable JSON block at the END —
|
|
283
|
+
* the same MaintainReport object the tool envelope carries. */
|
|
284
|
+
export function renderMaintainMarkdown(r) {
|
|
285
|
+
const L = [];
|
|
286
|
+
L.push(`# Maintain Report (${r.deep ? 'deep' : 'light'})`, '');
|
|
287
|
+
L.push(`> Generated ${r.generatedAt} · ${fmt(r.rowCount)} map rows · ` +
|
|
288
|
+
`map snapshot ${r.mapGeneratedAt ?? 'live build'}` +
|
|
289
|
+
`${r.mapStaleSeconds === null ? '' : ` · stale ${fmt(r.mapStaleSeconds)}s`}`, '');
|
|
290
|
+
L.push('## Pages & twins', '');
|
|
291
|
+
L.push(`- ${fmt(r.pages.rows)} kept rows / ${fmt(r.pages.paths)} paths (${Object.entries(r.pages.perLocale)
|
|
292
|
+
.map(([l, n]) => `${l}:${fmt(n)}`)
|
|
293
|
+
.join(' ')})`);
|
|
294
|
+
L.push(`- twin gap — paths missing one locale (${fmt(r.pages.missingTwinPaths.length)}):`);
|
|
295
|
+
for (const p of r.pages.missingTwinPaths)
|
|
296
|
+
L.push(` - \`${p}\``);
|
|
297
|
+
L.push('', `## Near-duplicate title clusters (threshold ${r.duplicates.threshold})`, '');
|
|
298
|
+
if (r.duplicates.clusters.length === 0)
|
|
299
|
+
L.push('- none');
|
|
300
|
+
r.duplicates.clusters.forEach((c, i) => {
|
|
301
|
+
L.push(`${i + 1}. ${c.titles.map((t) => `"${t}"`).join(' ≡ ')}`);
|
|
302
|
+
for (const p of c.paths)
|
|
303
|
+
L.push(` - \`${p}\``);
|
|
304
|
+
});
|
|
305
|
+
L.push('', `## Staleness — oldest ${fmt(r.staleness.topN)} paths`, '');
|
|
306
|
+
L.push('| Path | Updated | Days old | Locales |', '| --- | --- | --- | --- |');
|
|
307
|
+
for (const s of r.staleness.oldest) {
|
|
308
|
+
L.push(`| \`${s.path}\` | ${s.updatedAt.slice(0, 10)} | ${fmt(s.daysOld)} | ${s.locales.join(',')} |`);
|
|
309
|
+
}
|
|
310
|
+
L.push('', '## Diffusion candidates', '');
|
|
311
|
+
L.push('- single-child dirs (upmerge candidates):');
|
|
312
|
+
if (r.diffusion.singleChildDirs.length === 0)
|
|
313
|
+
L.push(' - none');
|
|
314
|
+
for (const d of r.diffusion.singleChildDirs)
|
|
315
|
+
L.push(` - \`${d.dir}/\` holds only \`${d.childPath}\``);
|
|
316
|
+
L.push('- root-level orphans (depth-2 pages, no sub-shelf):');
|
|
317
|
+
if (r.rootOrphans.length === 0)
|
|
318
|
+
L.push(' - none');
|
|
319
|
+
for (const o of r.rootOrphans)
|
|
320
|
+
L.push(` - \`${o.section}/\` (${fmt(o.paths.length)}): ${o.paths.map((p) => `\`${p}\``).join(', ')}`);
|
|
321
|
+
L.push('', '## Tag vocabulary', '');
|
|
322
|
+
if (!r.tags.available)
|
|
323
|
+
L.push('- no tag data: rows carry no tags (light mode over a tagless mirror) — refresh or pass list-joined rows');
|
|
324
|
+
else {
|
|
325
|
+
L.push('| Tag | Count |', '| --- | --- |');
|
|
326
|
+
for (const t of r.tags.vocabulary)
|
|
327
|
+
L.push(`| ${t.tag} | ${fmt(t.count)} |`);
|
|
328
|
+
}
|
|
329
|
+
L.push('', '## Redirect stubs', '');
|
|
330
|
+
if (!r.redirects.available)
|
|
331
|
+
L.push('- not visible from map rows (bodies unread) — rerun with `deep:true` to count `> Redirect:` stubs');
|
|
332
|
+
else {
|
|
333
|
+
L.push(`- ${fmt(r.redirects.count)} stub(s)`);
|
|
334
|
+
for (const s of r.redirects.stubs)
|
|
335
|
+
L.push(` - \`${s.path}\` (${s.locale}) → ${s.target}`);
|
|
336
|
+
}
|
|
337
|
+
L.push('', '## Section distribution', '');
|
|
338
|
+
L.push('| Section | Paths | Rows |', '| --- | --- | --- |');
|
|
339
|
+
for (const s of r.sections)
|
|
340
|
+
L.push(`| ${s.section} | ${fmt(s.paths)} | ${fmt(s.rows)} |`);
|
|
341
|
+
if (r.freshness !== null) {
|
|
342
|
+
L.push('', '## Freshness (deep)', '');
|
|
343
|
+
L.push(`- scanned ${fmt(r.freshness.scanned)} bodies (${fmt(r.freshness.unreadable)} unreadable) · ` +
|
|
344
|
+
`missing 上次核实 stamp: ${fmt(r.freshness.missingLastVerified.length)} · expired review-by: ${fmt(r.freshness.expiredReviewBy.length)}`);
|
|
345
|
+
for (const m of r.freshness.missingLastVerified)
|
|
346
|
+
L.push(` - stamp missing: \`${m.path}\` (${m.locale}, ${m.genre})`);
|
|
347
|
+
for (const e of r.freshness.expiredReviewBy)
|
|
348
|
+
L.push(` - review overdue: \`${e.path}\` (${e.locale}) since ${e.reviewBy} (${fmt(e.daysExpired)}d)`);
|
|
349
|
+
}
|
|
350
|
+
L.push('', '## Machine-readable JSON', '', '```json', JSON.stringify(r, null, 2), '```', '');
|
|
351
|
+
return L.join('\n');
|
|
352
|
+
}
|
package/dist/migrate-score.d.ts
CHANGED
|
@@ -21,7 +21,8 @@ export interface ChecklistVerdict {
|
|
|
21
21
|
/** Score the full 10-item gate on a draft. `genre` decides items 4-6:
|
|
22
22
|
* the base gate's item 4 applies to G2 only and items 5-6 to G1 only
|
|
23
23
|
* (G3/G4 → 'na'); G5 pages get the ledger variants of all three
|
|
24
|
-
* (last-verified column, verification commands, table-not-prose)
|
|
24
|
+
* (last-verified column, verification commands, table-not-prose) and G6
|
|
25
|
+
* pages the how-to variants (goal-titled H1, step triples, freshness rows). */
|
|
25
26
|
export declare function scoreChecklist(genre: Genre, draft: string): readonly ChecklistVerdict[];
|
|
26
27
|
/** Whitespace-normalized equality (the strong signal); otherwise trigram
|
|
27
28
|
* Jaccard similarity ≥ 0.95. Deterministic, diff-based — never prose. */
|
package/dist/migrate-score.js
CHANGED
|
@@ -198,10 +198,63 @@ function scoreG5Item6(draft) {
|
|
|
198
198
|
note: pass ? undefined : `narrative lines (${narrative}) outnumber table lines (${tableLines}) — a ledger is status + tables, not prose`,
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
|
+
// --- G6 how-to scorers (selfReviewChecklist G6 variants of items 4-6) --------
|
|
202
|
+
const G6_GOAL_H1 = /(?:如何|怎么|怎样|how\s+to)/i;
|
|
203
|
+
const G6_STEPS_HEADING = /^#{2,3}\s.*(?:操作步骤|\bsteps\b)/i;
|
|
204
|
+
function stepsSectionOf(draft) {
|
|
205
|
+
const lines = draft.split('\n');
|
|
206
|
+
const start = lines.findIndex((l) => /^#{2,3}\s/.test(l) && G6_STEPS_HEADING.test(l));
|
|
207
|
+
if (start === -1)
|
|
208
|
+
return undefined;
|
|
209
|
+
const end = lines.findIndex((l, i) => i > start && /^#{1,3}\s/.test(l));
|
|
210
|
+
return lines.slice(start + 1, end === -1 ? lines.length : end).join('\n');
|
|
211
|
+
}
|
|
212
|
+
function scoreG6Item4(draft) {
|
|
213
|
+
const h1 = draft.split('\n').find((l) => /^#\s/.test(l)) ?? '';
|
|
214
|
+
const pass = G6_GOAL_H1.test(h1);
|
|
215
|
+
return {
|
|
216
|
+
id: 4,
|
|
217
|
+
verdict: pass ? 'pass' : 'fail',
|
|
218
|
+
note: pass ? undefined : `H1 is not goal-titled ("How to X" / "如何(怎么)做X"): "${h1.replace(/^#\s*/, '').slice(0, 40)}"`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function scoreG6Item5(draft) {
|
|
222
|
+
const section = stepsSectionOf(draft);
|
|
223
|
+
if (section === undefined) {
|
|
224
|
+
return { id: 5, verdict: 'fail', note: 'no 操作步骤/Steps section' };
|
|
225
|
+
}
|
|
226
|
+
const numbered = /^\s*1[.、]/m.test(section);
|
|
227
|
+
const expected = /(预期|expected)/i.test(section);
|
|
228
|
+
const failure = /(失败|on failure|fallback)/i.test(section);
|
|
229
|
+
const pass = numbered && expected && failure;
|
|
230
|
+
return {
|
|
231
|
+
id: 5,
|
|
232
|
+
verdict: pass ? 'pass' : 'fail',
|
|
233
|
+
note: pass ? undefined : `steps section lacks ${numbered ? '' : 'numbered items '}${expected ? '' : 'expected-result leg '}${failure ? '' : 'on-failure leg '}`.trim(),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function scoreG6Item6(draft) {
|
|
237
|
+
const verified = /(上次核实|last verified)/i.test(draft);
|
|
238
|
+
const review = /(复核周期|review[- ]by|cadence)/i.test(draft);
|
|
239
|
+
const pass = verified && review;
|
|
240
|
+
return {
|
|
241
|
+
id: 6,
|
|
242
|
+
verdict: pass ? 'pass' : 'fail',
|
|
243
|
+
note: pass ? undefined : `metadata table lacks ${verified ? '' : 'last-verified (上次核实) '}${review ? '' : 'review-by (复核周期)'}row(s)`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/** Items 4-6 genre variants: G5 ledgers and G6 how-tos swap in their own
|
|
247
|
+
* gates; every other genre keeps the base trio. */
|
|
248
|
+
const GENRE_SCORERS = {
|
|
249
|
+
G5: [scoreG5Item4, scoreG5Item5, scoreG5Item6],
|
|
250
|
+
G6: [scoreG6Item4, scoreG6Item5, scoreG6Item6],
|
|
251
|
+
};
|
|
252
|
+
const BASE_SCORERS = [scoreItem4, scoreItem5, scoreItem6];
|
|
201
253
|
/** Score the full 10-item gate on a draft. `genre` decides items 4-6:
|
|
202
254
|
* the base gate's item 4 applies to G2 only and items 5-6 to G1 only
|
|
203
255
|
* (G3/G4 → 'na'); G5 pages get the ledger variants of all three
|
|
204
|
-
* (last-verified column, verification commands, table-not-prose)
|
|
256
|
+
* (last-verified column, verification commands, table-not-prose) and G6
|
|
257
|
+
* pages the how-to variants (goal-titled H1, step triples, freshness rows). */
|
|
205
258
|
export function scoreChecklist(genre, draft) {
|
|
206
259
|
const items = selfReviewChecklist(genre);
|
|
207
260
|
const na = (item) => ({
|
|
@@ -214,16 +267,16 @@ export function scoreChecklist(genre, draft) {
|
|
|
214
267
|
verdict: 'deferred',
|
|
215
268
|
note: 'post-write item — score after apply and write into the pilot report',
|
|
216
269
|
});
|
|
217
|
-
const
|
|
270
|
+
const [score4, score5, score6] = GENRE_SCORERS[genre] ?? BASE_SCORERS;
|
|
218
271
|
return items.map((item) => {
|
|
219
272
|
const applies = item.appliesTo === 'all' || item.appliesTo.includes(genre);
|
|
220
273
|
switch (item.id) {
|
|
221
274
|
case 1: return scoreItem1(draft);
|
|
222
275
|
case 2: return scoreItem2(draft);
|
|
223
276
|
case 3: return scoreItem3(draft);
|
|
224
|
-
case 4: return applies ? (
|
|
225
|
-
case 5: return applies ? (
|
|
226
|
-
case 6: return applies ? (
|
|
277
|
+
case 4: return applies ? score4(draft) : na(item);
|
|
278
|
+
case 5: return applies ? score5(draft) : na(item);
|
|
279
|
+
case 6: return applies ? score6(draft) : na(item);
|
|
227
280
|
case 7: return scoreItem7(draft);
|
|
228
281
|
case 8: return scoreItem8(draft);
|
|
229
282
|
default: return deferred(item);
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
* see skeletons.ts for the raw strings. Consumers (tools.ts, todo 14/15 pilot)
|
|
25
25
|
* import everything from this barrel.
|
|
26
26
|
*/
|
|
27
|
-
export { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, } from './skeletons.js';
|
|
28
|
-
export type Genre = 'G1' | 'G2' | 'G3' | 'G4' | 'G5';
|
|
27
|
+
export { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, G6_EN, G6_ZH, } from './skeletons.js';
|
|
28
|
+
export type Genre = 'G1' | 'G2' | 'G3' | 'G4' | 'G5' | 'G6';
|
|
29
29
|
export type GenreLang = 'en' | 'zh';
|
|
30
30
|
export type Confidence = 'high' | 'medium' | 'low';
|
|
31
|
-
export declare const GENRES: readonly
|
|
31
|
+
export declare const GENRES: readonly ["G1", "G2", "G3", "G4", "G5", "G6"];
|
|
32
32
|
export interface ClassifyInput {
|
|
33
33
|
readonly title: string;
|
|
34
34
|
readonly body: string;
|
package/dist/templates/genres.js
CHANGED
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
* see skeletons.ts for the raw strings. Consumers (tools.ts, todo 14/15 pilot)
|
|
25
25
|
* import everything from this barrel.
|
|
26
26
|
*/
|
|
27
|
-
import { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, } from './skeletons.js';
|
|
27
|
+
import { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, G6_EN, G6_ZH, } from './skeletons.js';
|
|
28
28
|
// Contract re-exports: raw skeleton data stays reachable through the barrel
|
|
29
29
|
// (todo 13 renders these double-checked strings into skill references).
|
|
30
|
-
export { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, } from './skeletons.js';
|
|
31
|
-
export const GENRES = ['G1', 'G2', 'G3', 'G4', 'G5'];
|
|
30
|
+
export { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, G6_EN, G6_ZH, } from './skeletons.js';
|
|
31
|
+
export const GENRES = ['G1', 'G2', 'G3', 'G4', 'G5', 'G6'];
|
|
32
32
|
// --- genreSkeleton ----------------------------------------------------------
|
|
33
33
|
const SKELETONS = {
|
|
34
34
|
G1: { en: G1_EN, zh: G1_ZH },
|
|
@@ -36,6 +36,7 @@ const SKELETONS = {
|
|
|
36
36
|
G3: { en: G3_EN, zh: G3_ZH },
|
|
37
37
|
G4: { en: G4_EN, zh: G4_ZH },
|
|
38
38
|
G5: { en: G5_EN, zh: G5_ZH },
|
|
39
|
+
G6: { en: G6_EN, zh: G6_ZH },
|
|
39
40
|
};
|
|
40
41
|
/** Full markdown skeleton for a genre × language pair. Pure string data —
|
|
41
42
|
* the author copies it, replaces placeholders, and fills the commented slots. */
|
|
@@ -60,6 +61,9 @@ const GENRE_KEYWORDS = {
|
|
|
60
61
|
// Deployed-state cues only — bare 部署/版本 would collide with G3 "部署清单"
|
|
61
62
|
// and general changelog talk, regressing existing corpus classifications.
|
|
62
63
|
G5: ['现状卡', '当前状态', '已部署', '部署物', '现役', '上线', '端口', '上次核实', '失效策略', '验证命令', 'current state', 'deployed', 'last verified', 'running now'],
|
|
64
|
+
// Goal-titled how-to cues only — bare 步骤/操作 would steal G3 清单 pages
|
|
65
|
+
// that merely mention 验证步骤 and scratch notes titled 今日操作记录.
|
|
66
|
+
G6: ['如何', '怎么', '上手', '指南', '操作手册', '操作步骤', 'how to', 'steps to', 'runbook'],
|
|
63
67
|
};
|
|
64
68
|
function escapeRegex(s) {
|
|
65
69
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -155,6 +159,28 @@ const G5_CHECKLIST_VARIANTS = {
|
|
|
155
159
|
appliesTo: ['G5'],
|
|
156
160
|
},
|
|
157
161
|
};
|
|
162
|
+
/** G6 swaps the genre-specific items 4–6 for how-to gates (goal-titled H1,
|
|
163
|
+
* step triples, D4 freshness metadata); everything else is shared. */
|
|
164
|
+
const G6_CHECKLIST_VARIANTS = {
|
|
165
|
+
4: {
|
|
166
|
+
id: 4,
|
|
167
|
+
label: '标题是目标句式 "How to X" / "如何/怎么做X"(goal-titled H1: the page name IS the reader\'s goal)',
|
|
168
|
+
kind: 'genre-specific',
|
|
169
|
+
appliesTo: ['G6'],
|
|
170
|
+
},
|
|
171
|
+
5: {
|
|
172
|
+
id: 5,
|
|
173
|
+
label: '操作步骤每条三段:动作 + 预期结果 + 失败处置(every numbered step carries action + expected result + on-failure handling)',
|
|
174
|
+
kind: 'genre-specific',
|
|
175
|
+
appliesTo: ['G6'],
|
|
176
|
+
},
|
|
177
|
+
6: {
|
|
178
|
+
id: 6,
|
|
179
|
+
label: '元数据表含「上次核实」与「复核周期」行(metadata table carries last-verified + review-by rows; 被取代于 once retired)',
|
|
180
|
+
kind: 'genre-specific',
|
|
181
|
+
appliesTo: ['G6'],
|
|
182
|
+
},
|
|
183
|
+
};
|
|
158
184
|
export function selfReviewChecklist(genre) {
|
|
159
185
|
if (!GENRES.includes(genre)) {
|
|
160
186
|
throw new Error(`unknown genre: ${genre}`);
|
|
@@ -221,8 +247,9 @@ export function selfReviewChecklist(genre) {
|
|
|
221
247
|
appliesTo: 'all',
|
|
222
248
|
},
|
|
223
249
|
];
|
|
224
|
-
|
|
225
|
-
|
|
250
|
+
const variants = genre === 'G5' ? G5_CHECKLIST_VARIANTS : genre === 'G6' ? G6_CHECKLIST_VARIANTS : undefined;
|
|
251
|
+
if (variants !== undefined) {
|
|
252
|
+
return items.map((item) => variants[item.id] ?? item);
|
|
226
253
|
}
|
|
227
254
|
return items;
|
|
228
255
|
}
|
|
@@ -46,3 +46,10 @@ export declare const G4_EN: string;
|
|
|
46
46
|
export declare const G5_ZH: string;
|
|
47
47
|
/** G5 — current-state ledger, en (section-for-section twin of G5_ZH). */
|
|
48
48
|
export declare const G5_EN: string;
|
|
49
|
+
/** G6 — 操作手册/how-to 页 (goal-titled operational manual), zh. Diátaxis doing
|
|
50
|
+
* leg: a reader with one goal follows the numbered steps to the outcome; every
|
|
51
|
+
* step carries 动作 + 预期结果 + 失败处置. Concepts stay in G4 pages, raw
|
|
52
|
+
* command lists in G3 pages, linked from 相关页面. */
|
|
53
|
+
export declare const G6_ZH: string;
|
|
54
|
+
/** G6 — how-to manual, en (section-for-section twin of G6_ZH). */
|
|
55
|
+
export declare const G6_EN: string;
|
|
@@ -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**: Active <!-- or 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**: Active <!-- or 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. -->`;
|