opencode-wiki-historian 0.2.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/LICENSE +21 -0
- package/README.md +381 -0
- package/dist/chronology.d.ts +36 -0
- package/dist/chronology.js +67 -0
- package/dist/config.d.ts +112 -0
- package/dist/config.js +158 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +136 -0
- package/dist/jsonc.d.ts +17 -0
- package/dist/jsonc.js +131 -0
- package/dist/map.d.ts +58 -0
- package/dist/map.js +196 -0
- package/dist/migrate-apply.d.ts +40 -0
- package/dist/migrate-apply.js +144 -0
- package/dist/migrate-score.d.ts +29 -0
- package/dist/migrate-score.js +267 -0
- package/dist/migrate-store.d.ts +52 -0
- package/dist/migrate-store.js +77 -0
- package/dist/migrate.d.ts +65 -0
- package/dist/migrate.js +111 -0
- package/dist/templates/genres.d.ts +65 -0
- package/dist/templates/genres.js +228 -0
- package/dist/templates/skeletons.d.ts +48 -0
- package/dist/templates/skeletons.js +558 -0
- package/dist/tools/create.d.ts +9 -0
- package/dist/tools/create.js +77 -0
- package/dist/tools/local.d.ts +10 -0
- package/dist/tools/local.js +107 -0
- package/dist/tools/mutate.d.ts +11 -0
- package/dist/tools/mutate.js +157 -0
- package/dist/tools/read.d.ts +9 -0
- package/dist/tools/read.js +104 -0
- package/dist/tools/shared.d.ts +52 -0
- package/dist/tools/shared.js +87 -0
- package/dist/tools/write.d.ts +10 -0
- package/dist/tools/write.js +148 -0
- package/dist/tools.d.ts +23 -0
- package/dist/tools.js +43 -0
- package/dist/translate.d.ts +44 -0
- package/dist/translate.js +207 -0
- package/dist/wiki/assets.d.ts +42 -0
- package/dist/wiki/assets.js +91 -0
- package/dist/wiki/client.d.ts +67 -0
- package/dist/wiki/client.js +221 -0
- package/dist/wiki/locale.d.ts +66 -0
- package/dist/wiki/locale.js +154 -0
- package/dist/wiki/pages.d.ts +7 -0
- package/dist/wiki/pages.js +7 -0
- package/dist/wiki/pages.read.d.ts +114 -0
- package/dist/wiki/pages.read.js +114 -0
- package/dist/wiki/pages.write.d.ts +109 -0
- package/dist/wiki/pages.write.js +201 -0
- package/package.json +36 -0
- package/skills/historian/SKILL.md +294 -0
- package/skills/historian/references/adapting-your-own-wiki.md +53 -0
- package/skills/historian/references/genres.md +160 -0
- package/skills/historian/references/rules.md +30 -0
- package/skills/historian/references/style.md +84 -0
- package/skills/historian/references/wikijs-guide.md +87 -0
package/dist/map.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale-aware page map (todo 10): full cross-locale inventory with en/zh twin
|
|
3
|
+
* pairing, rendered as the markdown `_meta/page-map` cache page + local mirror.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { isRecord } from './jsonc.js';
|
|
9
|
+
import { assertLocalePair, localeUrl, normalizeLocale, PathValidationError, twinOf } from './wiki/locale.js';
|
|
10
|
+
import { createPage, readPage, updatePage } from './wiki/pages.js';
|
|
11
|
+
import { listPages } from './wiki/pages.read.js';
|
|
12
|
+
// --- Constants --------------------------------------------------------------
|
|
13
|
+
export const CACHE_PATH = '_meta/page-map';
|
|
14
|
+
const CACHE_TITLE = 'Page Map Cache';
|
|
15
|
+
export const HEADER_ROW = '| ID | Locale | Path | Title | View URL | Twin | Updated At |';
|
|
16
|
+
export function mirrorPath(home) {
|
|
17
|
+
return join(home, '.config', 'opencode', 'historian-map.json');
|
|
18
|
+
}
|
|
19
|
+
// --- buildPageMap -----------------------------------------------------------
|
|
20
|
+
/** URLs for a server-reported path. assertLocalePair validates paths — but
|
|
21
|
+
* that guard protects MUTATIONS; a live page can legally sit on a reserved
|
|
22
|
+
* path (the instance hosts a page at 'home'). For inventory reads the raw
|
|
23
|
+
* join is the fallback, not a failure. */
|
|
24
|
+
function urlsOf(path, baseUrl, locale) {
|
|
25
|
+
try {
|
|
26
|
+
return assertLocalePair(path, locale, baseUrl);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (!(err instanceof PathValidationError))
|
|
30
|
+
throw err;
|
|
31
|
+
const twinLocale = twinOf(locale);
|
|
32
|
+
return {
|
|
33
|
+
path,
|
|
34
|
+
locale,
|
|
35
|
+
url: `${baseUrl.replace(/\/+$/, '')}/${locale}/${path}`,
|
|
36
|
+
twinLocale,
|
|
37
|
+
twinUrl: `${baseUrl.replace(/\/+$/, '')}/${twinLocale}/${path}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Full inventory: every page of every configured locale (only private-
|
|
42
|
+
* namespace pages are excluded — they are not anonymously reachable), twins
|
|
43
|
+
* paired by exact path, rows sorted by path then locale. */
|
|
44
|
+
export async function buildPageMap(deps) {
|
|
45
|
+
const locales = [...new Set(deps.options.locales.map(normalizeLocale))].sort();
|
|
46
|
+
const lists = await Promise.all(locales.map((locale) => listPages(deps.client, { locale })));
|
|
47
|
+
const byPath = new Map();
|
|
48
|
+
for (let i = 0; i < locales.length; i++) {
|
|
49
|
+
for (const item of lists[i]) {
|
|
50
|
+
if (item.privateNS !== null)
|
|
51
|
+
continue;
|
|
52
|
+
let perLocale = byPath.get(item.path);
|
|
53
|
+
if (perLocale === undefined) {
|
|
54
|
+
perLocale = new Map();
|
|
55
|
+
byPath.set(item.path, perLocale);
|
|
56
|
+
}
|
|
57
|
+
perLocale.set(item.locale, item);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const rows = [];
|
|
61
|
+
const missingTwinPaths = [];
|
|
62
|
+
const sortedPaths = [...byPath.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
63
|
+
for (const [path, perLocale] of sortedPaths) {
|
|
64
|
+
if (perLocale.size === 1)
|
|
65
|
+
missingTwinPaths.push(path);
|
|
66
|
+
for (const locale of locales) {
|
|
67
|
+
const item = perLocale.get(locale);
|
|
68
|
+
if (item === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
const pair = urlsOf(path, deps.options.baseUrl, locale);
|
|
71
|
+
const twin = perLocale.get(twinOf(locale));
|
|
72
|
+
rows.push({
|
|
73
|
+
id: item.id,
|
|
74
|
+
locale,
|
|
75
|
+
path,
|
|
76
|
+
title: item.title,
|
|
77
|
+
updatedAt: item.updatedAt,
|
|
78
|
+
url: pair.url,
|
|
79
|
+
twinUrl: twin === undefined ? null : pair.twinUrl,
|
|
80
|
+
twinId: twin === undefined ? null : twin.id,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const perLocaleCounts = {};
|
|
85
|
+
for (const locale of locales)
|
|
86
|
+
perLocaleCounts[locale] = 0;
|
|
87
|
+
for (const row of rows)
|
|
88
|
+
perLocaleCounts[row.locale] += 1;
|
|
89
|
+
return { rows, stats: { rows: rows.length, paths: sortedPaths.length, perLocale: perLocaleCounts, missingTwinPaths } };
|
|
90
|
+
}
|
|
91
|
+
// --- renderMapMarkdown ------------------------------------------------------
|
|
92
|
+
export function renderMapMarkdown(rows) {
|
|
93
|
+
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');
|
|
95
|
+
}
|
|
96
|
+
// --- Local mirror -----------------------------------------------------------
|
|
97
|
+
function writeMirror(home, mirror) {
|
|
98
|
+
const file = mirrorPath(home);
|
|
99
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
100
|
+
writeFileSync(file, `${JSON.stringify(mirror, null, 2)}\n`);
|
|
101
|
+
}
|
|
102
|
+
function isMapRow(v) {
|
|
103
|
+
if (!isRecord(v))
|
|
104
|
+
return false;
|
|
105
|
+
return (typeof v.id === 'number' &&
|
|
106
|
+
(v.locale === 'en' || v.locale === 'zh') &&
|
|
107
|
+
typeof v.path === 'string' &&
|
|
108
|
+
typeof v.title === 'string' &&
|
|
109
|
+
typeof v.updatedAt === 'string' &&
|
|
110
|
+
typeof v.url === 'string' &&
|
|
111
|
+
(v.twinUrl === null || typeof v.twinUrl === 'string') &&
|
|
112
|
+
(v.twinId === null || typeof v.twinId === 'number'));
|
|
113
|
+
}
|
|
114
|
+
function parseStats(v) {
|
|
115
|
+
if (typeof v.rows !== 'number' || typeof v.paths !== 'number' || !isRecord(v.perLocale) || !Array.isArray(v.missingTwinPaths)) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const perLocale = {};
|
|
119
|
+
for (const [key, value] of Object.entries(v.perLocale)) {
|
|
120
|
+
if (typeof value !== 'number')
|
|
121
|
+
return null;
|
|
122
|
+
perLocale[key] = value;
|
|
123
|
+
}
|
|
124
|
+
const missingTwinPaths = v.missingTwinPaths.filter((p) => typeof p === 'string');
|
|
125
|
+
if (missingTwinPaths.length !== v.missingTwinPaths.length)
|
|
126
|
+
return null;
|
|
127
|
+
return { rows: v.rows, paths: v.paths, perLocale, missingTwinPaths };
|
|
128
|
+
}
|
|
129
|
+
/** Missing or malformed mirror → null: the mirror is a regenerable cache, not
|
|
130
|
+
* a trustworthy boundary — a damaged file falls back to a live build. */
|
|
131
|
+
function readMirror(home) {
|
|
132
|
+
const file = mirrorPath(home);
|
|
133
|
+
if (!existsSync(file))
|
|
134
|
+
return null;
|
|
135
|
+
let raw;
|
|
136
|
+
try {
|
|
137
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
if (!isRecord(raw) || typeof raw.generatedAt !== 'string')
|
|
143
|
+
return null;
|
|
144
|
+
if (!Array.isArray(raw.rows) || !isRecord(raw.stats))
|
|
145
|
+
return null;
|
|
146
|
+
const rows = raw.rows.filter(isMapRow);
|
|
147
|
+
if (rows.length !== raw.rows.length)
|
|
148
|
+
return null;
|
|
149
|
+
const stats = parseStats(raw.stats);
|
|
150
|
+
if (stats === null)
|
|
151
|
+
return null;
|
|
152
|
+
return { generatedAt: raw.generatedAt, rows, stats };
|
|
153
|
+
}
|
|
154
|
+
/** Rebuild + write cycle: fresh map → local mirror → `_meta/page-map` upsert.
|
|
155
|
+
* An existing cache page is patched content-only — updatePage is a full RMW,
|
|
156
|
+
* so the machine-fact isPrivate/isPublished/tags survive (pitfall #1). */
|
|
157
|
+
export async function refreshMapCache(deps, opts) {
|
|
158
|
+
const now = opts?.now ?? new Date();
|
|
159
|
+
const home = opts?.homeDir ?? homedir();
|
|
160
|
+
const { rows, stats } = await buildPageMap(deps);
|
|
161
|
+
writeMirror(home, { generatedAt: now.toISOString(), rows, stats });
|
|
162
|
+
const markdown = renderMapMarkdown(rows);
|
|
163
|
+
const existing = await readPage(deps.client, CACHE_PATH, 'en');
|
|
164
|
+
if (existing === null) {
|
|
165
|
+
await createPage(deps, {
|
|
166
|
+
path: CACHE_PATH,
|
|
167
|
+
locale: 'en',
|
|
168
|
+
title: CACHE_TITLE,
|
|
169
|
+
content: markdown,
|
|
170
|
+
tags: ['meta'],
|
|
171
|
+
isPublished: false,
|
|
172
|
+
isPrivate: true,
|
|
173
|
+
twin: false,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
await updatePage(deps, existing.id, { content: markdown });
|
|
178
|
+
}
|
|
179
|
+
return { stats, cacheUrl: localeUrl(deps.options.baseUrl, 'en', CACHE_PATH) };
|
|
180
|
+
}
|
|
181
|
+
// --- getMap -----------------------------------------------------------------
|
|
182
|
+
/** Read the local mirror with staleness in whole seconds; absent or damaged
|
|
183
|
+
* mirror → a live build (read-only — no cache page write, no mirror write). */
|
|
184
|
+
export async function getMap(deps, homeDir) {
|
|
185
|
+
const home = homeDir ?? homedir();
|
|
186
|
+
const mirror = readMirror(home);
|
|
187
|
+
if (mirror === null) {
|
|
188
|
+
const { rows, stats } = await buildPageMap(deps);
|
|
189
|
+
return { rows, stats, generatedAt: null, staleSeconds: null };
|
|
190
|
+
}
|
|
191
|
+
const parsed = Date.parse(mirror.generatedAt);
|
|
192
|
+
const staleSeconds = Number.isNaN(parsed)
|
|
193
|
+
? null
|
|
194
|
+
: Math.max(0, Math.floor((Date.now() - parsed) / 1000));
|
|
195
|
+
return { rows: mirror.rows, stats: mirror.stats, generatedAt: mirror.generatedAt, staleSeconds };
|
|
196
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration engine — apply side (plan todo 14): the write pipeline. Order is
|
|
3
|
+
* the safety contract: ① pre-image backup FIRST (before any write; both
|
|
4
|
+
* locales, missing twin recorded null), ② per-locale upsert (present →
|
|
5
|
+
* updatePage RMW; missing twin → auto-created via the translation engine,
|
|
6
|
+
* isPublished/tags inherited from the source — never publishes fixtures),
|
|
7
|
+
* ③ path-level checkpoint (~/.config/opencode/historian-migrate.json;
|
|
8
|
+
* re-apply of the same path+pre-state contentHash is a no-op skip),
|
|
9
|
+
* ④ second-pass verification: checklist re-scored on the STORED content.
|
|
10
|
+
* Mid-apply failure → {ok:false}, backup left, NO checkpoint → resumable.
|
|
11
|
+
*/
|
|
12
|
+
import { type MigrateDeps } from './migrate.js';
|
|
13
|
+
import { type Genre } from './templates/genres.js';
|
|
14
|
+
import { type ChecklistVerdict } from './migrate-score.js';
|
|
15
|
+
import { type Locale } from './wiki/pages.read.js';
|
|
16
|
+
export interface ApplyArgs {
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly genre?: Genre;
|
|
19
|
+
readonly draft?: string;
|
|
20
|
+
readonly now?: Date;
|
|
21
|
+
}
|
|
22
|
+
export interface AppliedEntry {
|
|
23
|
+
readonly locale: Locale;
|
|
24
|
+
readonly action: 'updated' | 'created';
|
|
25
|
+
readonly urls: {
|
|
26
|
+
en: string;
|
|
27
|
+
zh: string;
|
|
28
|
+
};
|
|
29
|
+
readonly checklist: readonly ChecklistVerdict[];
|
|
30
|
+
}
|
|
31
|
+
export type ApplyOutcome = {
|
|
32
|
+
ok: true;
|
|
33
|
+
applied: readonly AppliedEntry[];
|
|
34
|
+
backupPath: string;
|
|
35
|
+
skipped?: string;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
error: Error;
|
|
39
|
+
};
|
|
40
|
+
export declare function applyMigration(deps: MigrateDeps, args: ApplyArgs): Promise<ApplyOutcome>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration engine — apply side (plan todo 14): the write pipeline. Order is
|
|
3
|
+
* the safety contract: ① pre-image backup FIRST (before any write; both
|
|
4
|
+
* locales, missing twin recorded null), ② per-locale upsert (present →
|
|
5
|
+
* updatePage RMW; missing twin → auto-created via the translation engine,
|
|
6
|
+
* isPublished/tags inherited from the source — never publishes fixtures),
|
|
7
|
+
* ③ path-level checkpoint (~/.config/opencode/historian-migrate.json;
|
|
8
|
+
* re-apply of the same path+pre-state contentHash is a no-op skip),
|
|
9
|
+
* ④ second-pass verification: checklist re-scored on the STORED content.
|
|
10
|
+
* Mid-apply failure → {ok:false}, backup left, NO checkpoint → resumable.
|
|
11
|
+
*/
|
|
12
|
+
import { reformatPageDraft, urlsOf } from './migrate.js';
|
|
13
|
+
import { makeTranslator } from './translate.js';
|
|
14
|
+
import { scoreChecklist } from './migrate-score.js';
|
|
15
|
+
import { backupFileFor, dateKey, defaultResultsDir, hashText, readBackup, readCheckpoint, sectionOf, writeBackup, writeCheckpoint, } from './migrate-store.js';
|
|
16
|
+
import { createPage, PageNotFoundError, updatePage } from './wiki/pages.js';
|
|
17
|
+
import { readPage } from './wiki/pages.read.js';
|
|
18
|
+
import { readPageState } from './wiki/pages.write.js';
|
|
19
|
+
function resolveTranslator(deps) {
|
|
20
|
+
if (deps.translate !== undefined)
|
|
21
|
+
return deps.translate;
|
|
22
|
+
// Production has no injected translator: fall back to the real-fetch engine.
|
|
23
|
+
return deps.fetchImpl !== undefined ? makeTranslator(deps.options, { fetchImpl: deps.fetchImpl }) : makeTranslator(deps.options);
|
|
24
|
+
}
|
|
25
|
+
async function preImageOf(deps, en, zh) {
|
|
26
|
+
const capture = async (p) => {
|
|
27
|
+
if (p === null)
|
|
28
|
+
return null;
|
|
29
|
+
const s = await readPageState(deps.client, p.id);
|
|
30
|
+
return {
|
|
31
|
+
content: s.content,
|
|
32
|
+
title: s.title,
|
|
33
|
+
description: s.description,
|
|
34
|
+
tags: s.tags,
|
|
35
|
+
isPublished: s.isPublished,
|
|
36
|
+
publishStartDate: s.publishStartDate,
|
|
37
|
+
publishEndDate: s.publishEndDate,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
return { en: await capture(en), zh: await capture(zh) };
|
|
41
|
+
}
|
|
42
|
+
export async function applyMigration(deps, args) {
|
|
43
|
+
const now = args.now ?? (deps.now !== undefined ? deps.now() : new Date());
|
|
44
|
+
const resultsDir = deps.resultsDir ?? defaultResultsDir();
|
|
45
|
+
const backupFile = backupFileFor(sectionOf(args.path), dateKey(now), resultsDir);
|
|
46
|
+
const en = await readPage(deps.client, args.path, 'en');
|
|
47
|
+
const zh = await readPage(deps.client, args.path, 'zh');
|
|
48
|
+
const source = en ?? zh;
|
|
49
|
+
if (source === null) {
|
|
50
|
+
return { ok: false, error: new PageNotFoundError(`page '${args.path}' does not exist (checked en and zh locales)`) };
|
|
51
|
+
}
|
|
52
|
+
let draft;
|
|
53
|
+
let genre;
|
|
54
|
+
if (args.draft === undefined || args.genre === undefined) {
|
|
55
|
+
const r = await reformatPageDraft(deps, { path: args.path, genre: args.genre });
|
|
56
|
+
if (!r.ok)
|
|
57
|
+
return { ok: false, error: r.error };
|
|
58
|
+
draft = r.draft;
|
|
59
|
+
genre = r.genre;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
draft = args.draft;
|
|
63
|
+
genre = args.genre;
|
|
64
|
+
}
|
|
65
|
+
const translator = resolveTranslator(deps);
|
|
66
|
+
if (translator === undefined) {
|
|
67
|
+
return { ok: false, error: new Error('translator not wired — the twin locale content must be translated; add translate config or pass a translator to buildTools') };
|
|
68
|
+
}
|
|
69
|
+
const pageDeps = { client: deps.client, options: deps.options, translate: translator };
|
|
70
|
+
// Checkpoint latch: identical pre-state (path + contentHash) → no-op skip.
|
|
71
|
+
const cp = readCheckpoint(deps.homeDir);
|
|
72
|
+
const recorded = cp.paths[args.path];
|
|
73
|
+
const curHash = hashText(source.content);
|
|
74
|
+
const curZhHash = zh === null ? null : hashText(zh.content);
|
|
75
|
+
if (recorded !== undefined && recorded.contentHash === curHash && recorded.zhHash === curZhHash) {
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
applied: [],
|
|
79
|
+
backupPath: backupFile,
|
|
80
|
+
skipped: `checkpoint no-op: '${args.path}' unchanged since applied at ${recorded.appliedAt} — nothing to redo`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// ① pre-image backup FIRST — before any write (restore program of record).
|
|
84
|
+
const backup = readBackup(backupFile);
|
|
85
|
+
if (backup.paths[args.path] === undefined) {
|
|
86
|
+
const paths = { ...backup.paths, [args.path]: await preImageOf(deps, en, zh) };
|
|
87
|
+
writeBackup(backupFile, { section: sectionOf(args.path), createdAt: now.toISOString(), paths });
|
|
88
|
+
}
|
|
89
|
+
// ② per-locale upsert (source locale gets the raw draft, twin gets the
|
|
90
|
+
// engine translation — the twin is created, never spuriously duplicating
|
|
91
|
+
// createPage's internal twin flow, which would re-parse the draft).
|
|
92
|
+
try {
|
|
93
|
+
const applied = [];
|
|
94
|
+
for (const locale of ['en', 'zh']) {
|
|
95
|
+
const existing = locale === 'en' ? en : zh;
|
|
96
|
+
const isSource = locale === source.locale;
|
|
97
|
+
if (existing !== null) {
|
|
98
|
+
const content = isSource ? draft : await translator(draft, source.locale, locale);
|
|
99
|
+
const after = await updatePage(pageDeps, existing.id, { content });
|
|
100
|
+
applied.push({
|
|
101
|
+
locale,
|
|
102
|
+
action: 'updated',
|
|
103
|
+
urls: urlsOf(deps.options.baseUrl, existing.path, locale),
|
|
104
|
+
checklist: scoreChecklist(genre, after.page.content),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const twinTitle = await translator(source.title, source.locale, locale);
|
|
109
|
+
const twinContent = await translator(draft, source.locale, locale);
|
|
110
|
+
await createPage(pageDeps, {
|
|
111
|
+
path: args.path,
|
|
112
|
+
locale,
|
|
113
|
+
title: twinTitle,
|
|
114
|
+
content: twinContent,
|
|
115
|
+
tags: source.tags,
|
|
116
|
+
isPublished: source.isPublished,
|
|
117
|
+
twin: false,
|
|
118
|
+
});
|
|
119
|
+
const stored = await readPage(deps.client, args.path, locale);
|
|
120
|
+
if (stored === null) {
|
|
121
|
+
throw new Error(`create reported success but the re-read of '${args.path}' (${locale}) returned nothing`);
|
|
122
|
+
}
|
|
123
|
+
applied.push({
|
|
124
|
+
locale,
|
|
125
|
+
action: 'created',
|
|
126
|
+
urls: urlsOf(deps.options.baseUrl, source.path, locale),
|
|
127
|
+
checklist: scoreChecklist(genre, stored.content),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// ③ checkpoint — written only after ALL writes succeeded.
|
|
132
|
+
writeCheckpoint(deps.homeDir, {
|
|
133
|
+
version: 1,
|
|
134
|
+
paths: {
|
|
135
|
+
...cp.paths,
|
|
136
|
+
[args.path]: { contentHash: curHash, zhHash: curZhHash, appliedAt: now.toISOString(), genre },
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
return { ok: true, applied, backupPath: backupFile };
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic self-review checklist scoring (plan todo 14/15): the 10-item
|
|
3
|
+
* gate is scored by CODE on the reformatted draft — the LLM only restyles,
|
|
4
|
+
* it never scores (an auditable gate). Verdict contract:
|
|
5
|
+
* - items 1-8 (content) → 'pass' | 'fail' by the checks below
|
|
6
|
+
* - items 4-6 are 'na' → when the page genre is not in appliesTo
|
|
7
|
+
* - items 9-10 (post-write) → 'deferred' until after apply (they are FAIL
|
|
8
|
+
* by construction pre-write; todo 15 writes them into the pilot report)
|
|
9
|
+
*
|
|
10
|
+
* Every threshold below is tolerant-but-real: it catches egregious
|
|
11
|
+
* violations, never bikesheds a conformant page. Measured values ride in the
|
|
12
|
+
* verdict note so a reviewer sees exactly what was counted.
|
|
13
|
+
*/
|
|
14
|
+
import type { Genre } from './templates/genres.js';
|
|
15
|
+
export type Verdict = 'pass' | 'fail' | 'na' | 'deferred';
|
|
16
|
+
export interface ChecklistVerdict {
|
|
17
|
+
readonly id: number;
|
|
18
|
+
readonly verdict: Verdict;
|
|
19
|
+
readonly note?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Score the full 10-item gate on a draft. `genre` decides items 4-6:
|
|
22
|
+
* the base gate's item 4 applies to G2 only and items 5-6 to G1 only
|
|
23
|
+
* (G3/G4 → 'na'); G5 pages get the ledger variants of all three
|
|
24
|
+
* (last-verified column, verification commands, table-not-prose). */
|
|
25
|
+
export declare function scoreChecklist(genre: Genre, draft: string): readonly ChecklistVerdict[];
|
|
26
|
+
/** Whitespace-normalized equality (the strong signal); otherwise trigram
|
|
27
|
+
* Jaccard similarity ≥ 0.95. Deterministic, diff-based — never prose. */
|
|
28
|
+
export declare function contentSimilar(a: string, b: string): boolean;
|
|
29
|
+
export declare function normalize(s: string): string;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic self-review checklist scoring (plan todo 14/15): the 10-item
|
|
3
|
+
* gate is scored by CODE on the reformatted draft — the LLM only restyles,
|
|
4
|
+
* it never scores (an auditable gate). Verdict contract:
|
|
5
|
+
* - items 1-8 (content) → 'pass' | 'fail' by the checks below
|
|
6
|
+
* - items 4-6 are 'na' → when the page genre is not in appliesTo
|
|
7
|
+
* - items 9-10 (post-write) → 'deferred' until after apply (they are FAIL
|
|
8
|
+
* by construction pre-write; todo 15 writes them into the pilot report)
|
|
9
|
+
*
|
|
10
|
+
* Every threshold below is tolerant-but-real: it catches egregious
|
|
11
|
+
* violations, never bikesheds a conformant page. Measured values ride in the
|
|
12
|
+
* verdict note so a reviewer sees exactly what was counted.
|
|
13
|
+
*/
|
|
14
|
+
import { selfReviewChecklist } from './templates/genres.js';
|
|
15
|
+
// --- Text preprocessing (prose extraction for sentence metrics) -------------
|
|
16
|
+
const FENCED = /```[\s\S]*?```/g;
|
|
17
|
+
const INLINE_CODE = /`[^`]+`/g;
|
|
18
|
+
const TABLE_LINE = /^\s*\|.*\|.*\|/;
|
|
19
|
+
const HEADING_LINE = /^\s{0,3}#{1,6}\s/;
|
|
20
|
+
const QUOTE_LINE = /^\s{0,3}>\s?/;
|
|
21
|
+
const HR_LINE = /^\s{0,3}(---|\*\*\*|___)\s*$/;
|
|
22
|
+
/** Prose-only view of a draft: code blocks, inline code, table rows, headings
|
|
23
|
+
* and blockquotes are not sentence prose (tables are scored by items 4-6). */
|
|
24
|
+
function proseOf(markdown) {
|
|
25
|
+
return markdown
|
|
26
|
+
.replace(FENCED, ' ')
|
|
27
|
+
.replace(INLINE_CODE, ' ')
|
|
28
|
+
.split('\n')
|
|
29
|
+
.filter((l) => !TABLE_LINE.test(l) && !HEADING_LINE.test(l) && !QUOTE_LINE.test(l) && !HR_LINE.test(l))
|
|
30
|
+
.join('\n');
|
|
31
|
+
}
|
|
32
|
+
const SENTENCE_BREAK = /[。!?!?]+/;
|
|
33
|
+
const CJK_RE = /[\u4e00-\u9fff]/g;
|
|
34
|
+
function sentencesOf(prose) {
|
|
35
|
+
return prose
|
|
36
|
+
.split(SENTENCE_BREAK)
|
|
37
|
+
.map((s) => s.replace(/[|*_`#>-]/g, '').trim())
|
|
38
|
+
.filter((s) => s.length > 0)
|
|
39
|
+
.slice(0, 30);
|
|
40
|
+
}
|
|
41
|
+
/** zh: ≤20 chars (CJK chars + latin tokens); en: ≤25 words. A sentence with
|
|
42
|
+
* any CJK char is judged by the zh rule. */
|
|
43
|
+
function sentenceTooLong(sentence) {
|
|
44
|
+
const tokens = sentence.match(/[A-Za-z0-9][\w-]*/g) ?? [];
|
|
45
|
+
const cjkChars = (sentence.match(CJK_RE) ?? []).length;
|
|
46
|
+
const effectiveLength = cjkChars > 0 ? cjkChars + tokens.length : tokens.length;
|
|
47
|
+
return cjkChars > 0 ? effectiveLength > 20 : effectiveLength > 25;
|
|
48
|
+
}
|
|
49
|
+
/** Tables = a header row (`| a | b |`) followed by a separator row. Cells are
|
|
50
|
+
* de-fenced and lowercased for the header checks. */
|
|
51
|
+
function tablesOf(markdown) {
|
|
52
|
+
const lines = markdown.split('\n');
|
|
53
|
+
const tables = [];
|
|
54
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
55
|
+
const next = lines[i + 1];
|
|
56
|
+
if (/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(next)) {
|
|
57
|
+
const cells = lines[i].split('|').map((c) => c.trim().replace(/`/g, '').toLowerCase()).filter((c) => c.length > 0);
|
|
58
|
+
if (cells.length >= 3)
|
|
59
|
+
tables.push({ header: cells });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return tables;
|
|
63
|
+
}
|
|
64
|
+
// --- Scoring ----------------------------------------------------------------
|
|
65
|
+
function leadRatio(draft) {
|
|
66
|
+
const firstH2 = draft.search(/\n#{2,3}\s/);
|
|
67
|
+
if (firstH2 === -1)
|
|
68
|
+
return 1;
|
|
69
|
+
const lead = draft.slice(0, firstH2);
|
|
70
|
+
const total = Math.max(draft.length, 1);
|
|
71
|
+
return lead.length / total;
|
|
72
|
+
}
|
|
73
|
+
function scoreItem1(draft) {
|
|
74
|
+
const measured = leadRatio(draft);
|
|
75
|
+
const pass = measured >= 0.04 && measured <= 0.45;
|
|
76
|
+
return { id: 1, verdict: pass ? 'pass' : 'fail', note: pass ? undefined : `lead=${(measured * 100).toFixed(1)}% of body (tol 4-45%)` };
|
|
77
|
+
}
|
|
78
|
+
function scoreItem2(draft) {
|
|
79
|
+
const sentences = sentencesOf(proseOf(draft));
|
|
80
|
+
if (sentences.length === 0)
|
|
81
|
+
return { id: 2, verdict: 'pass', note: 'no prose sentences sampled' };
|
|
82
|
+
const overflow = sentences.filter(sentenceTooLong).length;
|
|
83
|
+
const allowance = Math.max(1, Math.floor(sentences.length / 10));
|
|
84
|
+
const pass = overflow <= allowance;
|
|
85
|
+
return {
|
|
86
|
+
id: 2,
|
|
87
|
+
verdict: pass ? 'pass' : 'fail',
|
|
88
|
+
note: pass ? undefined : `${overflow}/${sentences.length} sampled sentences exceed the cap (zh ≤20 chars, en ≤25 words)`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function scoreItem3(draft) {
|
|
92
|
+
const hasTable = tablesOf(draft).length > 0;
|
|
93
|
+
return { id: 3, verdict: hasTable ? 'pass' : 'fail', note: hasTable ? undefined : 'no ≥3-column markdown table found' };
|
|
94
|
+
}
|
|
95
|
+
function scoreItem4(draft) {
|
|
96
|
+
const offenders = tablesOf(draft).filter((t) => !t.header.some((c) => /来源|source|reference/.test(c)));
|
|
97
|
+
return {
|
|
98
|
+
id: 4,
|
|
99
|
+
verdict: offenders.length === 0 ? 'pass' : 'fail',
|
|
100
|
+
note: offenders.length === 0 ? undefined : `${offenders.length} table(s) lack a source column`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function scoreItem5(draft) {
|
|
104
|
+
const timeline = tablesOf(draft).find((t) => t.header.some((c) => /时间|time/.test(c)));
|
|
105
|
+
if (timeline === undefined)
|
|
106
|
+
return { id: 5, verdict: 'fail', note: 'no timeline table (时间|事件|来源) found' };
|
|
107
|
+
const hasSource = timeline.header.some((c) => /来源|source/.test(c));
|
|
108
|
+
return { id: 5, verdict: hasSource ? 'pass' : 'fail', note: hasSource ? undefined : 'timeline table has no source column' };
|
|
109
|
+
}
|
|
110
|
+
const FIVE_ESSENTIALS_ZH = ['类型', '负责人', '期限', '验证', '状态'];
|
|
111
|
+
const FIVE_ESSENTIALS_EN = [
|
|
112
|
+
['type'],
|
|
113
|
+
['owner'],
|
|
114
|
+
['due', 'deadline'],
|
|
115
|
+
['verif'],
|
|
116
|
+
['status', 'state'],
|
|
117
|
+
];
|
|
118
|
+
function scoreItem6(draft) {
|
|
119
|
+
const actionTable = tablesOf(draft).find((t) => {
|
|
120
|
+
if (t.header.length < 5)
|
|
121
|
+
return false;
|
|
122
|
+
const five = FIVE_ESSENTIALS_ZH.every((k) => t.header.some((c) => c.includes(k)))
|
|
123
|
+
? FIVE_ESSENTIALS_ZH
|
|
124
|
+
: FIVE_ESSENTIALS_EN.every((k) => k.some((pre) => t.header.some((c) => c.startsWith(pre))))
|
|
125
|
+
? 'en'
|
|
126
|
+
: null;
|
|
127
|
+
return five !== null;
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
id: 6,
|
|
131
|
+
verdict: actionTable !== undefined ? 'pass' : 'fail',
|
|
132
|
+
note: actionTable !== undefined ? undefined : 'no action-item table with the five essentials (类型|负责人|期限|验证|状态) as columns',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const CATCH_ALL = ['其他', '杂项', 'miscellaneous', 'misc', 'other'];
|
|
136
|
+
const EXEMPT_TAILS = ['附录', 'appendix', '参见', 'see also', 'see-also', '参考'];
|
|
137
|
+
function scoreItem7(draft) {
|
|
138
|
+
const headings = draft.split('\n').filter((l) => /^\s{0,3}##{1,3}\s/.test(l));
|
|
139
|
+
const h = (line) => line.replace(/^\s{0,3}#+/, '').replace(/[#*`]/g, '').trim().toLowerCase();
|
|
140
|
+
const banned = headings.map(h).find((t) => CATCH_ALL.some((w) => t.includes(w)) && !EXEMPT_TAILS.some((w) => t.includes(w)));
|
|
141
|
+
return {
|
|
142
|
+
id: 7,
|
|
143
|
+
verdict: banned === undefined ? 'pass' : 'fail',
|
|
144
|
+
note: banned === undefined ? undefined : `catch-all section heading: "${banned}"`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const MARKETING = [
|
|
148
|
+
['robust', 'streamline', 'leverage', 'utilize', 'world-class', 'cutting-edge', 'delve'],
|
|
149
|
+
['强大的', '领先的', '先进的', '赋能'],
|
|
150
|
+
];
|
|
151
|
+
function scoreItem8(draft) {
|
|
152
|
+
const body = draft.replace(FENCED, ' ');
|
|
153
|
+
const hit = MARKETING[0].find((w) => new RegExp(`\\b${w}`, 'i').test(body)) ?? MARKETING[1].find((w) => body.includes(w));
|
|
154
|
+
return { id: 8, verdict: hit === undefined ? 'pass' : 'fail', note: hit === undefined ? undefined : `unbacked praise term: "${hit}"` };
|
|
155
|
+
}
|
|
156
|
+
// --- G5 ledger scorers (selfReviewChecklist G5 variants of items 4-6) --------
|
|
157
|
+
const G5_LEDGER_COL = /(组件|component)/;
|
|
158
|
+
const G5_VERSION_COL = /(版本|version)/;
|
|
159
|
+
const G5_VERIFIED_COL = /(核实|verif)/;
|
|
160
|
+
const COMMENT_ONLY_LINE = /^\s*<!--.*-->\s*$/;
|
|
161
|
+
function scoreG5Item4(draft) {
|
|
162
|
+
const ledgers = tablesOf(draft).filter((t) => t.header.some((c) => G5_LEDGER_COL.test(c)) && t.header.some((c) => G5_VERSION_COL.test(c)));
|
|
163
|
+
if (ledgers.length === 0) {
|
|
164
|
+
return { id: 4, verdict: 'fail', note: 'no component ledger table (组件|版本|…|上次核实于) found' };
|
|
165
|
+
}
|
|
166
|
+
const missing = ledgers.filter((t) => !t.header.some((c) => G5_VERIFIED_COL.test(c)));
|
|
167
|
+
return {
|
|
168
|
+
id: 4,
|
|
169
|
+
verdict: missing.length === 0 ? 'pass' : 'fail',
|
|
170
|
+
note: missing.length === 0 ? undefined : `${missing.length} ledger table(s) lack a last-verified (上次核实于) column`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function scoreG5Item5(draft) {
|
|
174
|
+
const lines = draft.split('\n');
|
|
175
|
+
const start = lines.findIndex((l) => /^#{2,3}\s/.test(l) && /验证方法|verification/i.test(l));
|
|
176
|
+
if (start === -1) {
|
|
177
|
+
return { id: 5, verdict: 'fail', note: 'no 验证方法/Verification section' };
|
|
178
|
+
}
|
|
179
|
+
const end = lines.findIndex((l, i) => i > start && /^#{1,3}\s/.test(l));
|
|
180
|
+
const section = lines.slice(start + 1, end === -1 ? lines.length : end).join('\n');
|
|
181
|
+
const hasCommand = /`[^`\n]+`/.test(section) || /```/.test(section);
|
|
182
|
+
return {
|
|
183
|
+
id: 5,
|
|
184
|
+
verdict: hasCommand ? 'pass' : 'fail',
|
|
185
|
+
note: hasCommand ? undefined : '验证方法 section carries no executable command (inline code or fenced block)',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function scoreG5Item6(draft) {
|
|
189
|
+
const tableLines = draft.split('\n').filter((l) => TABLE_LINE.test(l)).length;
|
|
190
|
+
const narrative = proseOf(draft)
|
|
191
|
+
.split('\n')
|
|
192
|
+
.map((l) => l.trim())
|
|
193
|
+
.filter((l) => l.length > 0 && !COMMENT_ONLY_LINE.test(l)).length;
|
|
194
|
+
const pass = tablesOf(draft).length > 0 && narrative <= Math.max(tableLines, 6);
|
|
195
|
+
return {
|
|
196
|
+
id: 6,
|
|
197
|
+
verdict: pass ? 'pass' : 'fail',
|
|
198
|
+
note: pass ? undefined : `narrative lines (${narrative}) outnumber table lines (${tableLines}) — a ledger is status + tables, not prose`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** Score the full 10-item gate on a draft. `genre` decides items 4-6:
|
|
202
|
+
* the base gate's item 4 applies to G2 only and items 5-6 to G1 only
|
|
203
|
+
* (G3/G4 → 'na'); G5 pages get the ledger variants of all three
|
|
204
|
+
* (last-verified column, verification commands, table-not-prose). */
|
|
205
|
+
export function scoreChecklist(genre, draft) {
|
|
206
|
+
const items = selfReviewChecklist(genre);
|
|
207
|
+
const na = (item) => ({
|
|
208
|
+
id: item.id,
|
|
209
|
+
verdict: 'na',
|
|
210
|
+
note: `N/A — genre ${genre} not in appliesTo`,
|
|
211
|
+
});
|
|
212
|
+
const deferred = (item) => ({
|
|
213
|
+
id: item.id,
|
|
214
|
+
verdict: 'deferred',
|
|
215
|
+
note: 'post-write item — score after apply and write into the pilot report',
|
|
216
|
+
});
|
|
217
|
+
const ledger = genre === 'G5';
|
|
218
|
+
return items.map((item) => {
|
|
219
|
+
const applies = item.appliesTo === 'all' || item.appliesTo.includes(genre);
|
|
220
|
+
switch (item.id) {
|
|
221
|
+
case 1: return scoreItem1(draft);
|
|
222
|
+
case 2: return scoreItem2(draft);
|
|
223
|
+
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);
|
|
227
|
+
case 7: return scoreItem7(draft);
|
|
228
|
+
case 8: return scoreItem8(draft);
|
|
229
|
+
default: return deferred(item);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// --- Conformance signal (todo 14 roundtrip step ③) ---------------------------
|
|
234
|
+
/** Whitespace-normalized equality (the strong signal); otherwise trigram
|
|
235
|
+
* Jaccard similarity ≥ 0.95. Deterministic, diff-based — never prose. */
|
|
236
|
+
export function contentSimilar(a, b) {
|
|
237
|
+
const na = normalize(a);
|
|
238
|
+
if (na === normalize(b))
|
|
239
|
+
return true;
|
|
240
|
+
return trigramSimilarity(na, normalize(b)) >= 0.95;
|
|
241
|
+
}
|
|
242
|
+
export function normalize(s) {
|
|
243
|
+
return s.trim().replace(/\s+/g, ' ');
|
|
244
|
+
}
|
|
245
|
+
function trigramSimilarity(a, b) {
|
|
246
|
+
const grams = (s) => {
|
|
247
|
+
const out = new Set();
|
|
248
|
+
if (s.length < 3) {
|
|
249
|
+
if (s.length > 0)
|
|
250
|
+
out.add(s);
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
for (let i = 0; i <= s.length - 3; i++)
|
|
254
|
+
out.add(s.slice(i, i + 3));
|
|
255
|
+
return out;
|
|
256
|
+
};
|
|
257
|
+
const ga = grams(a);
|
|
258
|
+
const gb = grams(b);
|
|
259
|
+
if (ga.size === 0 && gb.size === 0)
|
|
260
|
+
return 1;
|
|
261
|
+
let inter = 0;
|
|
262
|
+
for (const g of ga)
|
|
263
|
+
if (gb.has(g))
|
|
264
|
+
inter++;
|
|
265
|
+
const union = ga.size + gb.size - inter;
|
|
266
|
+
return union === 0 ? 1 : inter / union;
|
|
267
|
+
}
|