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.
@@ -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
+ }
@@ -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. */
@@ -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 ledger = genre === 'G5';
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 ? (ledger ? scoreG5Item4(draft) : scoreItem4(draft)) : na(item);
225
- case 5: return applies ? (ledger ? scoreG5Item5(draft) : scoreItem5(draft)) : na(item);
226
- case 6: return applies ? (ledger ? scoreG5Item6(draft) : scoreItem6(draft)) : na(item);
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 Genre[];
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;
@@ -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
- if (genre === 'G5') {
225
- return items.map((item) => G5_CHECKLIST_VARIANTS[item.id] ?? item);
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. -->`;