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.
@@ -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/map.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Locale-aware page map (todo 10): full cross-locale inventory with en/zh twin
3
3
  * pairing, rendered as the markdown `_meta/page-map` cache page + local mirror.
4
+ *
5
+ * Intended readers: harness cache-read scripts, the admin UI, and
6
+ * remote/cross-machine review. Role split — live queries (getMap) read the
7
+ * LOCAL MIRROR (historian-map.json) only and never this wiki page; the wiki
8
+ * page exists as the audit ledger (each refresh commits a new revision) and
9
+ * for human inspection.
4
10
  */
5
11
  import type { HistorianOptions } from './config.js';
6
12
  import type { GqlClient } from './wiki/client.js';
@@ -40,7 +46,7 @@ export declare function mirrorPath(home: string): string;
40
46
  * namespace pages are excluded — they are not anonymously reachable), twins
41
47
  * paired by exact path, rows sorted by path then locale. */
42
48
  export declare function buildPageMap(deps: MapDeps): Promise<PageMap>;
43
- export declare function renderMapMarkdown(rows: readonly MapRow[]): string;
49
+ export declare function renderMapMarkdown(rows: readonly MapRow[], generatedAt: string): string;
44
50
  export interface RefreshOptions {
45
51
  readonly now?: Date;
46
52
  readonly homeDir?: string;
package/dist/map.js CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Locale-aware page map (todo 10): full cross-locale inventory with en/zh twin
3
3
  * pairing, rendered as the markdown `_meta/page-map` cache page + local mirror.
4
+ *
5
+ * Intended readers: harness cache-read scripts, the admin UI, and
6
+ * remote/cross-machine review. Role split — live queries (getMap) read the
7
+ * LOCAL MIRROR (historian-map.json) only and never this wiki page; the wiki
8
+ * page exists as the audit ledger (each refresh commits a new revision) and
9
+ * for human inspection.
4
10
  */
5
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
12
  import { homedir } from 'node:os';
@@ -89,9 +95,17 @@ export async function buildPageMap(deps) {
89
95
  return { rows, stats: { rows: rows.length, paths: sortedPaths.length, perLocale: perLocaleCounts, missingTwinPaths } };
90
96
  }
91
97
  // --- renderMapMarkdown ------------------------------------------------------
92
- export function renderMapMarkdown(rows) {
98
+ export function renderMapMarkdown(rows, generatedAt) {
99
+ const perLocale = { en: 0, zh: 0 };
100
+ for (const r of rows)
101
+ if (r.locale === 'en' || r.locale === 'zh')
102
+ perLocale[r.locale] += 1;
103
+ const preamble = `> Snapshot generated ${generatedAt} · ${rows.length} pages (${perLocale.en}/${perLocale.zh}). ` +
104
+ `This page is the audit ledger: each 'historian_map refresh' commits a new wiki revision (page ` +
105
+ `history = chronological record of the whole wiki). Live queries read the local mirror ` +
106
+ `(historian-map.json); this page serves harness audits, the admin UI, and cross-machine review.`;
93
107
  const lines = rows.map((r) => `| ${r.id} | ${r.locale} | ${r.path} | ${r.title.replaceAll('|', '\\|')} | ${r.url} | ${r.twinUrl ?? '—'} | ${r.updatedAt} |`);
94
- return [HEADER_ROW, '| --- | --- | --- | --- | --- | --- | --- |', ...lines, ''].join('\n');
108
+ return [preamble, '', HEADER_ROW, '| --- | --- | --- | --- | --- | --- | --- |', ...lines, ''].join('\n');
95
109
  }
96
110
  // --- Local mirror -----------------------------------------------------------
97
111
  function writeMirror(home, mirror) {
@@ -159,7 +173,7 @@ export async function refreshMapCache(deps, opts) {
159
173
  const home = opts?.homeDir ?? homedir();
160
174
  const { rows, stats } = await buildPageMap(deps);
161
175
  writeMirror(home, { generatedAt: now.toISOString(), rows, stats });
162
- const markdown = renderMapMarkdown(rows);
176
+ const markdown = renderMapMarkdown(rows, now.toISOString());
163
177
  const existing = await readPage(deps.client, CACHE_PATH, 'en');
164
178
  if (existing === null) {
165
179
  await createPage(deps, {
@@ -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);
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Machine-tier evidence page skeleton (v3 todo 5).
3
+ *
4
+ * Evidence pages live under the internal namespaces (`_meta/`, `_evidence/`),
5
+ * are hidden and monolingual — the wiki is their only store (no local mirror,
6
+ * no bilingual twin machinery). They are deliberately NOT genre-templated:
7
+ * the G1–G5 anatomy rubric targets human front pages, while an evidence page
8
+ * is a verbatim capture plus provenance, and nothing else.
9
+ *
10
+ * The shape mirrors the `_meta/page-map` cache precedent (src/map.ts): header
11
+ * blockquote with source/captured/context metadata, an empty fenced block the
12
+ * caller fills with the raw material verbatim, and a capture-context section
13
+ * whose three placeholder lines the caller replaces at capture time.
14
+ */
15
+ export interface EvidenceSkeletonInput {
16
+ /** Path of the human page this evidence backs (placeholder hint when unknown). */
17
+ readonly sourcePath: string;
18
+ /** URL of that human page. */
19
+ readonly sourceUrl: string;
20
+ /** ISO-8601 capture timestamp, supplied by the caller (pure function: no clock reads). */
21
+ readonly capturedAt: string;
22
+ /** One-line context for why this material was captured. */
23
+ readonly context: string;
24
+ }
25
+ /** Full markdown skeleton for one machine-tier evidence page. Pure string
26
+ * transform — deterministic, no state, no I/O; empty inputs degrade to
27
+ * visible placeholders, never `undefined` leakage. */
28
+ export declare function evidenceSkeleton(o: EvidenceSkeletonInput): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Machine-tier evidence page skeleton (v3 todo 5).
3
+ *
4
+ * Evidence pages live under the internal namespaces (`_meta/`, `_evidence/`),
5
+ * are hidden and monolingual — the wiki is their only store (no local mirror,
6
+ * no bilingual twin machinery). They are deliberately NOT genre-templated:
7
+ * the G1–G5 anatomy rubric targets human front pages, while an evidence page
8
+ * is a verbatim capture plus provenance, and nothing else.
9
+ *
10
+ * The shape mirrors the `_meta/page-map` cache precedent (src/map.ts): header
11
+ * blockquote with source/captured/context metadata, an empty fenced block the
12
+ * caller fills with the raw material verbatim, and a capture-context section
13
+ * whose three placeholder lines the caller replaces at capture time.
14
+ */
15
+ /** Full markdown skeleton for one machine-tier evidence page. Pure string
16
+ * transform — deterministic, no state, no I/O; empty inputs degrade to
17
+ * visible placeholders, never `undefined` leakage. */
18
+ export function evidenceSkeleton(o) {
19
+ return [
20
+ `> 机器层证据页 (machine-tier evidence). 来源页 (source): [${o.sourcePath}](${o.sourceUrl})` +
21
+ ` · 采集 (captured): ${o.capturedAt} · 上下文 (context): ${o.context}`,
22
+ '',
23
+ '## 原文 (verbatim)',
24
+ '',
25
+ '```',
26
+ '```',
27
+ '',
28
+ '## 采集环境 (capture context)',
29
+ '',
30
+ '- 命令 (command): `<the exact command as executed>`',
31
+ '- 目录 (cwd): `<working directory at capture time>`',
32
+ '- 时间 (time): `<capture timestamp>`',
33
+ ].join('\n');
34
+ }
@@ -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
  }