opencode-wiki-historian 0.5.0 → 0.5.2

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/dist/lint.d.ts CHANGED
@@ -9,9 +9,9 @@
9
9
  */
10
10
  import type { Locale } from './wiki/pages.read.js';
11
11
  /** Machine rule keys; the human report renders zh labels from these. */
12
- export type GateViolation = 'redirect-stub-no-exit' | 'active-with-unfinished-skeleton';
12
+ export type GateViolation = 'redirect-stub-no-exit' | 'active-with-unfinished-skeleton' | 'status-token-conflict';
13
13
  export interface LintFinding {
14
- readonly key: 'todo-markers' | 'empty-sections' | 'intro-empty' | 'no-related-pages' | 'no-state-block' | 'h1-mismatch' | 'zh-english-dominant' | 'claims-without-stamp';
14
+ readonly key: 'todo-markers' | 'empty-sections' | 'intro-empty' | 'no-related-pages' | 'no-state-block' | 'h1-mismatch' | 'zh-english-dominant' | 'claims-without-stamp' | 'status-token-conflict';
15
15
  readonly detail: string;
16
16
  }
17
17
  export interface PageLink {
@@ -32,6 +32,15 @@ export interface BodyLint {
32
32
  readonly stubHasLink: boolean;
33
33
  readonly hasStateBlock: boolean;
34
34
  readonly state: 'active' | 'draft' | 'superseded' | 'deprecated' | null;
35
+ /** First state token parsed from a `| Status | X |` metadata table row
36
+ * (P1); null when the body carries no parseable table token. The colon
37
+ * header in `state` stays the AUTHORITY — this is the view, never a fix. */
38
+ readonly tableState: 'active' | 'draft' | 'superseded' | 'deprecated' | null;
39
+ /** Every state token found in table rows, document order, deduped. */
40
+ readonly tableStates: readonly NonNullable<BodyLint['state']>[];
41
+ /** True when the header token and at least one table token disagree (P1:
42
+ * the machine-induced contradiction of live ids 9/92). */
43
+ readonly statusTokenConflict: boolean;
35
44
  readonly todoMarkers: number;
36
45
  /** Heading text of sections whose body (before the next heading ≤ level) is empty. */
37
46
  readonly emptySections: readonly string[];
@@ -47,6 +56,14 @@ export interface BodyLint {
47
56
  /** CJK chars / non-whitespace chars, zh body only meaningful; 0..1. */
48
57
  readonly cjkRatio: number;
49
58
  }
59
+ /** The one finding kind {@link statusTokenFindings} emits. */
60
+ export type StatusTokenFinding = LintFinding & {
61
+ readonly key: 'status-token-conflict';
62
+ };
63
+ /** Finding-key emission for status-token integrity (P1): both tokens present
64
+ * and disagreeing. The colon header remains the authority — this reports the
65
+ * contradiction, it never resolves it. */
66
+ export declare function statusTokenFindings(lint: BodyLint): readonly StatusTokenFinding[];
50
67
  export interface HeadingSpan {
51
68
  readonly level: number;
52
69
  readonly heading: string;
package/dist/lint.js CHANGED
@@ -47,20 +47,58 @@ function hasClickableExit(visibleNoAnchors) {
47
47
  }
48
48
  // --- status block -----------------------------------------------------------------
49
49
  const STATE_LINE_RE = /(?:^|\n)\s*\*{0,2}\s*(?:状态\s*\/\s*Status|状态|Status)\s*\*{0,2}\s*[::]\s*([A-Za-z\u4e00-\u9fff][^\n·|<]*)/i;
50
- function parseState(maskedNoComments) {
51
- const m = STATE_LINE_RE.exec(maskedNoComments);
52
- if (m === null)
53
- return { has: false, state: null };
54
- const v = m[1].trim().toLowerCase();
50
+ /** `| Status | X |` metadata-table row (P1). Line-anchored so only the FIRST
51
+ * cell is the label — column headers like `| Action | … | Status |` and
52
+ * separator rows never match. CJK-tolerant label (状态 / Status / 状态/Status
53
+ * in either order), optional bold. Run on fence-masked, comment-stripped
54
+ * text, so an injected fake token inside a code fence or HTML comment is
55
+ * inert. The value is only a token when classifyState() recognizes it —
56
+ * `| Status | — |` and free-form cells carry no token. */
57
+ const TABLE_STATE_RE = /^\s*\|\s*(?:\*\*)?\s*(?:状态|Status)(?:\s*\/\s*(?:状态|Status))?\s*(?:\*\*)?\s*\|\s*([^|\n]+?)\s*\|/i;
58
+ function classifyState(raw) {
59
+ const v = raw.trim().toLowerCase();
55
60
  if (v.startsWith('active'))
56
- return { has: true, state: 'active' };
61
+ return 'active';
57
62
  if (v.startsWith('draft'))
58
- return { has: true, state: 'draft' };
63
+ return 'draft';
59
64
  if (v.startsWith('superseded'))
60
- return { has: true, state: 'superseded' };
65
+ return 'superseded';
61
66
  if (v.startsWith('deprecated'))
62
- return { has: true, state: 'deprecated' };
63
- return { has: true, state: null };
67
+ return 'deprecated';
68
+ return null;
69
+ }
70
+ function parseState(maskedNoComments) {
71
+ const m = STATE_LINE_RE.exec(maskedNoComments);
72
+ if (m === null)
73
+ return { has: false, state: null };
74
+ return { has: true, state: classifyState(m[1]) };
75
+ }
76
+ /** Deduped state tokens of every `| Status | X |` row, in document order. */
77
+ function parseTableStates(visible) {
78
+ const out = [];
79
+ for (const line of visible.split('\n')) {
80
+ const m = TABLE_STATE_RE.exec(line);
81
+ if (m === null)
82
+ continue;
83
+ const st = classifyState(m[1]);
84
+ if (st !== null && !out.includes(st))
85
+ out.push(st);
86
+ }
87
+ return out;
88
+ }
89
+ /** Finding-key emission for status-token integrity (P1): both tokens present
90
+ * and disagreeing. The colon header remains the authority — this reports the
91
+ * contradiction, it never resolves it. */
92
+ export function statusTokenFindings(lint) {
93
+ if (!lint.statusTokenConflict || lint.state === null)
94
+ return [];
95
+ const table = lint.tableStates.filter((s) => s !== lint.state).join(', ');
96
+ return [
97
+ {
98
+ key: 'status-token-conflict',
99
+ detail: `colon header '${lint.state}' vs table row '${table}' on '${lint.h1 ?? '?'}'`,
100
+ },
101
+ ];
64
102
  }
65
103
  // --- markers, headings, intro -------------------------------------------------------
66
104
  const TODO_COMMENT_RE = /(TODO|TBD|PLACEHOLDER|占位)/i;
@@ -217,12 +255,17 @@ export function lintBody(body, opts) {
217
255
  commands: distinct(visible, CMD_RE),
218
256
  };
219
257
  const stateInfo = parseState(visible);
258
+ const tableStates = parseTableStates(visible);
259
+ const statusTokenConflict = stateInfo.state !== null && tableStates.some((s) => s !== stateInfo.state);
220
260
  return {
221
261
  isRedirectStub,
222
262
  redirectTarget: target,
223
263
  stubHasLink: !isRedirectStub || hasClickableExit(links.length),
224
264
  hasStateBlock: stateInfo.has,
225
265
  state: stateInfo.state,
266
+ tableState: tableStates.length > 0 ? tableStates[0] : null,
267
+ tableStates,
268
+ statusTokenConflict,
226
269
  todoMarkers: countTodoMarkers(masked),
227
270
  emptySections: emptySectionsOf(headings),
228
271
  introEmpty,
@@ -250,5 +293,8 @@ export function publishGateViolations(lint) {
250
293
  if (lint.state === 'active' && (lint.todoMarkers > 0 || lint.emptySections.length > 0)) {
251
294
  out.push('active-with-unfinished-skeleton');
252
295
  }
296
+ if (lint.state === 'active' && lint.statusTokenConflict) {
297
+ out.push('status-token-conflict');
298
+ }
253
299
  return out;
254
300
  }
@@ -64,6 +64,18 @@ export interface FreshnessScan {
64
64
  readonly missingLastVerified: readonly MissingStamp[];
65
65
  readonly expiredReviewBy: readonly ExpiredReview[];
66
66
  }
67
+ /** One cadence-due page (advisory queue — maintain NEVER auto-writes the stamp). */
68
+ export interface DueForReviewRow {
69
+ readonly path: string;
70
+ readonly locale: Locale;
71
+ /** Whole days from the newest honest stamp date to now (floored; confessional-only
72
+ * stamps stamp their own date but never reset the clock — see dueForReviewOf). */
73
+ readonly stampAge: number;
74
+ /** Days: metadata 复核周期/Review cadence row when parseable, else genre default. */
75
+ readonly cadence: number;
76
+ /** Re-check commands parsed from the ledger's command column (empty when none). */
77
+ readonly verifyCommands: readonly string[];
78
+ }
67
79
  export interface MaintainReport {
68
80
  readonly schema: typeof MAINTAIN_SCHEMA;
69
81
  readonly generatedAt: string;
@@ -91,7 +103,7 @@ export interface MaintainReport {
91
103
  childPath: string;
92
104
  }[];
93
105
  };
94
- readonly rootOrphans: readonly {
106
+ readonly flatRootPages: readonly {
95
107
  section: string;
96
108
  paths: readonly string[];
97
109
  }[];
@@ -113,10 +125,20 @@ export interface MaintainReport {
113
125
  rows: number;
114
126
  }[];
115
127
  readonly freshness: FreshnessScan | null;
128
+ /** Deep-only advisory queue (null in light, like freshness). */
129
+ readonly dueForReview: readonly DueForReviewRow[] | null;
116
130
  }
117
- export declare const MAINTAIN_SCHEMA = "historian.maintain.v1";
131
+ export declare const MAINTAIN_SCHEMA = "historian.maintain.v2";
118
132
  /** Trigram-Jaccard bar for calling two (different-path) titles near-duplicates. */
119
133
  export declare const DUP_TITLE_THRESHOLD = 0.75;
134
+ /** Stamp label incl. the 上次验证 variant (the set lint.ts STAMP_RE tests) — one
135
+ * line-level source of truth for the honesty rule shared by surface + freshness. */
136
+ export declare const STAMP_LINE_RE: RegExp;
137
+ /** A stamp that confesses the review never executed (swarm-A P2 "stamp honesty"):
138
+ * it must not exempt the claim ledger (surface) nor reset the cadence clock
139
+ * (dueForReview). Matched per stamp line, never page-wide — "baseline" is a
140
+ * common GPU-benchmark noun outside a stamp. */
141
+ export declare const CONFESSIONAL_STAMP_RE: RegExp;
120
142
  export declare function buildMaintainReport(input: MaintainInput, opts?: MaintainOptions): Promise<MaintainReport>;
121
143
  /** Human report + (always) a fenced machine-readable JSON block at the END —
122
144
  * the same MaintainReport object the tool envelope carries. */
package/dist/maintain.js CHANGED
@@ -17,7 +17,7 @@ import { classifyGenre } from './templates/genres.js';
17
17
  import { isInternalPath } from './tools/shared.js';
18
18
  import { normalize } from './migrate-score.js';
19
19
  // --- Constants --------------------------------------------------------------
20
- export const MAINTAIN_SCHEMA = 'historian.maintain.v1';
20
+ export const MAINTAIN_SCHEMA = 'historian.maintain.v2';
21
21
  /** Trigram-Jaccard bar for calling two (different-path) titles near-duplicates. */
22
22
  export const DUP_TITLE_THRESHOLD = 0.75;
23
23
  const DAY_MS = 86_400_000;
@@ -28,9 +28,34 @@ const DEFAULT_TOP_N = 10;
28
28
  // pre-emptively so the deep sweep lights up when the genre lane lands.
29
29
  const FRESHNESS_GENRES = ['G5', 'G6'];
30
30
  const STAMP_RE = /上次核实|last verified/i;
31
+ /** Stamp label incl. the 上次验证 variant (the set lint.ts STAMP_RE tests) — one
32
+ * line-level source of truth for the honesty rule shared by surface + freshness. */
33
+ export const STAMP_LINE_RE = /上次核实|上次验证|last verified/i;
34
+ /** A stamp that confesses the review never executed (swarm-A P2 "stamp honesty"):
35
+ * it must not exempt the claim ledger (surface) nor reset the cadence clock
36
+ * (dueForReview). Matched per stamp line, never page-wide — "baseline" is a
37
+ * common GPU-benchmark noun outside a stamp. */
38
+ export const CONFESSIONAL_STAMP_RE = /(not re-run|未复跑|未复核|baseline)/i;
31
39
  const REVIEW_LABEL_RE = /^(复核周期|复核期限|复核日期|review[-_ ]?by|review[-_ ]?due)$/i;
32
40
  const ISO_DATE_RE = /\d{4}-\d{2}-\d{2}/;
33
41
  const REDIRECT_RE = /^>\s*Redirect:/i;
42
+ /** Table column headers that carry the per-row last-verified date (G5 ledger card). */
43
+ const STAMP_COL_RE = /^(?:上次核实于?|上次验证于?|last verified)$/i;
44
+ /** Metadata row naming the review cadence — its value is a DURATION, unlike the
45
+ * ISO-date 复核期限/review-by row REVIEW_LABEL_RE owns. */
46
+ const CADENCE_LABEL_RE = /^(?:复核周期|复核节奏|review[-_ ]?cadence|cadence)$/i;
47
+ /** Ledger command-column headers: zh 复核命令/验证命令/命令/用法\/命令,
48
+ * en Re-check command / Verify command(s) / Command(s) / Usage \/ Command. */
49
+ const COMMAND_COL_RE = /^(?:命令|用法\s*\/\s*命令|复核命令|验证命令|commands?|usage\s*\/\s*commands?|re[-_ ]?check[-_ ]?commands?|verify[-_ ]?commands?)$/i;
50
+ const DASH_CELL_RE = /^:?-{2,}:?$/;
51
+ const NULLISH_CELL_RE = /^(?:—|–|-|n\/?a|待补充|todo|\?)$/i;
52
+ /** Cadence floors when a stamped page carries no parseable metadata row:
53
+ * G5 machine-state ledgers 7d (weekly re-run — the round-1 plan supersedes the
54
+ * older 30d skeleton hint), G4 concepts and G6 how-tos 90d (the G6 skeleton's
55
+ * own 复核周期 example "如每 90 天"); the remaining genres (G1/G2/G3) have no
56
+ * cadence guidance anywhere, so the conservative quarterly 90d default applies. */
57
+ const GENRE_CADENCE_DAYS = { G5: 7, G4: 90, G6: 90 };
58
+ const DEFAULT_CADENCE_DAYS = 90;
34
59
  // --- Title similarity (trigram core copied from migrate-score.ts:289, where it
35
60
  // --- is private with a 0.95 roundtrip bar; maintain needs its own threshold) ---
36
61
  function titleGrams(s) {
@@ -58,8 +83,11 @@ function jaccard(a, b) {
58
83
  function cmpStr(a, b) {
59
84
  return a < b ? -1 : a > b ? 1 : 0;
60
85
  }
86
+ // bold-merge deliberately leaves (重定向)/(redirect)-suffixed stubs beside their
87
+ // live twins; clustering those pairs reports non-defects and buries true near-dupes.
88
+ const STUB_TITLE_RE = /[((]\s*(?:重定向|redirect)\s*[))]\s*$/i;
61
89
  function findDuplicates(rows) {
62
- const units = rows.map((r) => ({
90
+ const units = rows.filter((r) => !STUB_TITLE_RE.test(r.title)).map((r) => ({
63
91
  path: r.path,
64
92
  title: r.title,
65
93
  grams: titleGrams(normalize(r.title).toLowerCase()),
@@ -147,7 +175,7 @@ function singleChildDirs(paths) {
147
175
  }
148
176
  return out;
149
177
  }
150
- function rootOrphans(paths) {
178
+ function flatRootPages(paths) {
151
179
  const bySection = new Map();
152
180
  for (const path of paths) {
153
181
  if (path.split('/').length !== 2)
@@ -201,6 +229,117 @@ function reviewByOf(body) {
201
229
  }
202
230
  return null;
203
231
  }
232
+ // --- dueForReview (cadence × stamp join; advisory only) --------------------------
233
+ function tableCells(line) {
234
+ const t = line.trim();
235
+ return t.startsWith('|') ? t.split('|').map((c) => c.trim()) : null;
236
+ }
237
+ const isSeparatorRow = (cells) => cells.some((c) => c !== '' && DASH_CELL_RE.test(c));
238
+ /** Every (date, confesses-non-execution) attestation in a body, two real shapes:
239
+ * a stamp LABEL line carrying its ISO date (metadata rows, quote stamps, struck
240
+ * history — the cockpit / 09-08-sweep forms) and each date under a
241
+ * 上次核实于/Last verified table column (the G5 card's per-row ledger dates). */
242
+ function stampEntriesOf(body) {
243
+ const out = [];
244
+ let dateCol = -1;
245
+ for (const line of body.split('\n')) {
246
+ const lineDate = STAMP_LINE_RE.test(line) ? line.match(ISO_DATE_RE) : null;
247
+ if (lineDate !== null)
248
+ out.push({ ms: Date.parse(lineDate[0]), confessional: CONFESSIONAL_STAMP_RE.test(line) });
249
+ const cells = tableCells(line);
250
+ if (cells === null) {
251
+ dateCol = -1;
252
+ continue;
253
+ }
254
+ if (isSeparatorRow(cells))
255
+ continue;
256
+ // A `| 上次核实 | 2026-01-01 |` label row carries its own date (line rule above
257
+ // owns it) — only a date-less header row arms the column extraction, else the
258
+ // next value row (any label!) would be mis-read as a ledger date.
259
+ const h = ISO_DATE_RE.test(line) ? -1 : cells.findIndex((c) => STAMP_COL_RE.test(c));
260
+ if (h >= 0) {
261
+ dateCol = h;
262
+ continue;
263
+ }
264
+ const cell = dateCol >= 0 ? cells[dateCol] : undefined;
265
+ if (cell !== undefined) {
266
+ const m = cell.match(ISO_DATE_RE);
267
+ if (m !== null)
268
+ out.push({ ms: Date.parse(m[0]), confessional: CONFESSIONAL_STAMP_RE.test(cell) });
269
+ }
270
+ }
271
+ return out.filter((e) => !Number.isNaN(e.ms));
272
+ }
273
+ /** '30天' / '每 30 天' / 'every 30 days' / '7d' / '2 weeks' / bare '14' → days.
274
+ * Dates, TODOs and prose return null — no cadence is ever fabricated. */
275
+ function parseDays(text) {
276
+ const t = text.trim();
277
+ const w = t.match(/(\d{1,3})\s*(?:weeks?|wks?|w\b|周)/i);
278
+ if (w !== null)
279
+ return Number(w[1]) * 7;
280
+ const d = t.match(/(\d{1,4})\s*(?:days?|d\b|天|日)/i);
281
+ if (d !== null)
282
+ return Number(d[1]);
283
+ return /^\d{1,3}$/.test(t) ? Number(t) : null;
284
+ }
285
+ function cadenceDaysOf(body) {
286
+ for (const line of body.split('\n')) {
287
+ const cells = tableCells(line);
288
+ if (cells === null || cells.length < 3 || !CADENCE_LABEL_RE.test(cells[1] ?? ''))
289
+ continue;
290
+ for (const cell of cells.slice(2)) {
291
+ const days = parseDays(cell);
292
+ if (days !== null)
293
+ return days;
294
+ }
295
+ }
296
+ return null;
297
+ }
298
+ /** Re-check commands from ledger verification tables (see COMMAND_COL_RE):
299
+ * backticks stripped, empty/nullish cells skipped, order kept, duplicates dropped. */
300
+ function verifyCommandsOf(body) {
301
+ const out = [];
302
+ let cmdCol = -1;
303
+ for (const line of body.split('\n')) {
304
+ const cells = tableCells(line);
305
+ if (cells === null) {
306
+ cmdCol = -1;
307
+ continue;
308
+ }
309
+ if (isSeparatorRow(cells))
310
+ continue;
311
+ const h = cells.findIndex((c) => COMMAND_COL_RE.test(c));
312
+ if (h >= 0) {
313
+ cmdCol = h;
314
+ continue;
315
+ }
316
+ const cell = cmdCol >= 0 ? cells[cmdCol] : undefined;
317
+ if (cell === undefined)
318
+ continue;
319
+ const cmd = cell.replace(/^`([\s\S]*)`$/, '$1').trim();
320
+ if (cmd !== '' && !NULLISH_CELL_RE.test(cmd) && !out.includes(cmd))
321
+ out.push(cmd);
322
+ }
323
+ return out;
324
+ }
325
+ /** The join (swarm-B P-dueForReview, stamp honesty as its immune system): due
326
+ * when the newest HONEST stamp is at or past the cadence (a weekly card is due
327
+ * again on day 7), or when every stamp confesses non-execution — a confession
328
+ * records intent, not verification, so it never exempts the page. A struck old
329
+ * confessional stamp beside a fresh honest one (supersede-keeping-struck-old)
330
+ * runs on the honest clock. */
331
+ function dueForReviewOf(body, genre, now) {
332
+ const entries = stampEntriesOf(body);
333
+ if (entries.length === 0)
334
+ return null;
335
+ const honest = entries.filter((e) => !e.confessional);
336
+ const clock = honest.length > 0 ? honest : entries;
337
+ const stampAge = Math.floor((now.getTime() - Math.max(...clock.map((e) => e.ms))) / DAY_MS);
338
+ const cadence = cadenceDaysOf(body) ?? GENRE_CADENCE_DAYS[genre] ?? DEFAULT_CADENCE_DAYS;
339
+ if (honest.length > 0 && stampAge < cadence)
340
+ return null;
341
+ return { stampAge, cadence, verifyCommands: verifyCommandsOf(body) };
342
+ }
204
343
  // --- buildMaintainReport --------------------------------------------------------
205
344
  export async function buildMaintainReport(input, opts = {}) {
206
345
  const now = opts.now ?? new Date();
@@ -219,11 +358,13 @@ export async function buildMaintainReport(input, opts = {}) {
219
358
  localeCount.set(r.path, (localeCount.get(r.path) ?? 0) + 1);
220
359
  let redirects = { available: false, count: 0, stubs: [] };
221
360
  let freshness = null;
361
+ let dueForReview = null;
222
362
  if (deep && opts.readBody !== undefined) {
223
363
  const readBody = opts.readBody;
224
364
  const stubs = [];
225
365
  const missing = [];
226
366
  const expired = [];
367
+ const due = [];
227
368
  let scanned = 0;
228
369
  let unreadable = 0;
229
370
  for (const r of kept) {
@@ -239,6 +380,9 @@ export async function buildMaintainReport(input, opts = {}) {
239
380
  continue; // stubs are pointers — exempt from the freshness-stamp rule
240
381
  }
241
382
  const genre = classifyGenre({ title: r.title, body }).genre;
383
+ const dfr = dueForReviewOf(body, genre, now);
384
+ if (dfr !== null)
385
+ due.push({ path: r.path, locale: r.locale, ...dfr });
242
386
  if (!FRESHNESS_GENRES.includes(genre))
243
387
  continue;
244
388
  if (!STAMP_RE.test(body))
@@ -253,6 +397,7 @@ export async function buildMaintainReport(input, opts = {}) {
253
397
  }
254
398
  redirects = { available: true, count: stubs.length, stubs };
255
399
  freshness = { scanned, unreadable, missingLastVerified: missing, expiredReviewBy: expired };
400
+ dueForReview = due.sort((a, b) => b.stampAge - a.stampAge || cmpStr(a.path, b.path) || cmpStr(a.locale, b.locale));
256
401
  }
257
402
  return {
258
403
  schema: MAINTAIN_SCHEMA,
@@ -270,11 +415,12 @@ export async function buildMaintainReport(input, opts = {}) {
270
415
  duplicates: { threshold: DUP_TITLE_THRESHOLD, clusters: findDuplicates(kept) },
271
416
  staleness: { topN, oldest: staleness(kept, now, topN) },
272
417
  diffusion: { singleChildDirs: singleChildDirs(paths) },
273
- rootOrphans: rootOrphans(paths),
418
+ flatRootPages: flatRootPages(paths),
274
419
  tags: tagVocab(kept),
275
420
  redirects,
276
421
  sections: sectionDist(kept),
277
422
  freshness,
423
+ dueForReview,
278
424
  };
279
425
  }
280
426
  // --- renderMaintainMarkdown -----------------------------------------------------
@@ -313,10 +459,10 @@ export function renderMaintainMarkdown(r) {
313
459
  L.push(' - none');
314
460
  for (const d of r.diffusion.singleChildDirs)
315
461
  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)
462
+ L.push('- flat root pages per section (depth-2 listing, shelving hint — NOT inbound analysis; true orphans = surface deep links.orphanPages):');
463
+ if (r.flatRootPages.length === 0)
318
464
  L.push(' - none');
319
- for (const o of r.rootOrphans)
465
+ for (const o of r.flatRootPages)
320
466
  L.push(` - \`${o.section}/\` (${fmt(o.paths.length)}): ${o.paths.map((p) => `\`${p}\``).join(', ')}`);
321
467
  L.push('', '## Tag vocabulary', '');
322
468
  if (!r.tags.available)
@@ -347,6 +493,17 @@ export function renderMaintainMarkdown(r) {
347
493
  for (const e of r.freshness.expiredReviewBy)
348
494
  L.push(` - review overdue: \`${e.path}\` (${e.locale}) since ${e.reviewBy} (${fmt(e.daysExpired)}d)`);
349
495
  }
496
+ if (r.dueForReview !== null) {
497
+ L.push('', `## 到期复核 Due for review(deep,advisory)`, '');
498
+ L.push(`- ${fmt(r.dueForReview.length)} 条 —仅提示,机器不代写;复核由人或复核协议执行`);
499
+ for (const d of r.dueForReview) {
500
+ const why = d.stampAge >= d.cadence
501
+ ? `戳龄 ${fmt(d.stampAge)}d ≥ 周期 ${fmt(d.cadence)}d`
502
+ : `confessional stamp(自称未复跑),周期 ${fmt(d.cadence)}d 未到亦列`;
503
+ const cmds = d.verifyCommands.length > 0 ? ` · ${d.verifyCommands.map((c) => '`' + c + '`').join(' ')}` : '';
504
+ L.push(` - \`${d.locale}/${d.path}\` — ${why}${cmds}`);
505
+ }
506
+ }
350
507
  L.push('', '## Machine-readable JSON', '', '```json', JSON.stringify(r, null, 2), '```', '');
351
508
  return L.join('\n');
352
509
  }
package/dist/surface.d.ts CHANGED
@@ -11,8 +11,9 @@
11
11
  * tier costs nothing beyond the map baseline, the deep tier reads every body
12
12
  * once (caller pools the reads).
13
13
  */
14
- import type { MaintainRow } from './maintain.js';
14
+ import { type MaintainRow } from './maintain.js';
15
15
  import type { Locale } from './wiki/pages.read.js';
16
+ import type { NavSnapshot } from './wiki/nav.js';
16
17
  export declare const SURFACE_SCHEMA: "historian.surface.v1";
17
18
  /** Minimal shape of a live `pages.list` row the surface diff needs. */
18
19
  export interface LiveRow {
@@ -26,6 +27,12 @@ export interface SurfaceInput {
26
27
  readonly generatedAt: string;
27
28
  readonly baseUrl: string;
28
29
  readonly liveInventory?: readonly LiveRow[];
30
+ /**
31
+ * Live primary nav. Issue #1's disease is sidebar exposure, so the check
32
+ * must read the real tree — omit/null reports `available: false` rather
33
+ * than falsely claiming a clean nav.
34
+ */
35
+ readonly nav?: NavSnapshot | null;
29
36
  readonly deep?: boolean;
30
37
  readonly readBody?: (path: string, locale: Locale) => Promise<string | null>;
31
38
  }
@@ -39,7 +46,18 @@ export interface SurfaceCoverage {
39
46
  readonly removedFromLive: number;
40
47
  }
41
48
  export interface SurfaceNav {
42
- readonly machineSections: readonly string[];
49
+ readonly available: boolean;
50
+ readonly mode: string | null;
51
+ /** DYNAMIC/MIXED re-mirror the filesystem page tree into the sidebar — the exact Issue #1 relapse. */
52
+ readonly filesystemExposed: boolean;
53
+ /** Underscore-prefixed (machine-namespace) links explicitly mounted in the curated tree. */
54
+ readonly machineLinks: {
55
+ readonly locale: string;
56
+ readonly label: string;
57
+ readonly target: string;
58
+ }[];
59
+ /** Informational: underscore segments in the PAGE tree — by design (_meta/_evidence/_sandbox/_data). */
60
+ readonly machinePaths: readonly string[];
43
61
  readonly sectionLandingMissing: {
44
62
  readonly dir: string;
45
63
  readonly pagePaths: number;
package/dist/surface.js CHANGED
@@ -14,8 +14,12 @@
14
14
  import { isInternalPath } from './tools/shared.js';
15
15
  import { lintBody } from './lint.js';
16
16
  import { classifyGenre } from './templates/genres.js';
17
+ import { CONFESSIONAL_STAMP_RE, STAMP_LINE_RE } from './maintain.js';
17
18
  export const SURFACE_SCHEMA = 'historian.surface.v1';
18
19
  const MACHINE_SEG_RE = /^_/;
20
+ // nav targets carry a leading slash and may pre-pend the locale segment
21
+ // (/zh/_meta/x) — machine check runs on the first real path segment.
22
+ const MACHINE_TARGET_RE = /^\/(?:(?:en|zh)\/)?_[^/]+/;
19
23
  const ROOT_EXEMPT = new Set(['home', 'wiki-index']);
20
24
  const visible = (r) => r.isPublished !== false && r.isPrivate !== true;
21
25
  const rowKey = (path, locale) => `${locale}\u0000${path}`;
@@ -23,15 +27,24 @@ function isFrontPath(path) {
23
27
  return !isInternalPath(path) && !path.startsWith('_sandbox/') && !path.startsWith('_data/');
24
28
  }
25
29
  // --- light tier ---------------------------------------------------------------
26
- function buildNav(rows, live) {
30
+ function buildNav(rows, live, nav) {
27
31
  const paths = new Set();
28
32
  for (const r of rows)
29
33
  paths.add(r.path);
30
34
  for (const r of live ?? [])
31
35
  paths.add(r.path);
32
- const machineSections = [...new Set([...paths].map((p) => p.split('/')[0]))]
36
+ const machinePaths = [...new Set([...paths].map((p) => p.split('/')[0]))]
33
37
  .filter((s) => MACHINE_SEG_RE.test(s))
34
38
  .sort();
39
+ const mode = nav?.mode ?? null;
40
+ const machineLinks = [];
41
+ for (const t of nav?.trees ?? []) {
42
+ for (const it of t.items) {
43
+ if (MACHINE_TARGET_RE.test(it.target)) {
44
+ machineLinks.push({ locale: t.locale, label: it.label, target: it.target });
45
+ }
46
+ }
47
+ }
35
48
  const perDir = new Map();
36
49
  for (const p of paths) {
37
50
  const seg = p.split('/');
@@ -43,7 +56,14 @@ function buildNav(rows, live) {
43
56
  .filter(([dir, n]) => n >= 2 && !paths.has(dir))
44
57
  .map(([dir, pagePaths]) => ({ dir, pagePaths }))
45
58
  .sort((a, b) => b.pagePaths - a.pagePaths || a.dir.localeCompare(b.dir));
46
- return { machineSections, sectionLandingMissing };
59
+ return {
60
+ available: nav != null,
61
+ mode,
62
+ filesystemExposed: mode === 'DYNAMIC' || mode === 'MIXED',
63
+ machineLinks,
64
+ machinePaths,
65
+ sectionLandingMissing,
66
+ };
47
67
  }
48
68
  function buildCoverage(rows, live) {
49
69
  if (live === undefined)
@@ -92,6 +112,13 @@ function resolveTargetPath(baseUrl, rawHref) {
92
112
  return { path: t.slice(3), locale: 'en' };
93
113
  return { path: t, locale: 'en' };
94
114
  }
115
+ /** True when EVERY stamp-bearing line confesses non-execution — see the
116
+ * ledgerClaims rule in buildDeep. Lines are the unit of judgement because a
117
+ * struck old stamp beside a fresh honest one must keep the exemption. */
118
+ function isConfessionalStamp(body) {
119
+ const stampLines = body.split('\n').filter((l) => STAMP_LINE_RE.test(l));
120
+ return stampLines.length > 0 && stampLines.every((l) => CONFESSIONAL_STAMP_RE.test(l));
121
+ }
95
122
  async function scanBodies(input) {
96
123
  if (input.readBody === undefined)
97
124
  return [];
@@ -233,7 +260,13 @@ function buildDeep(input, scans) {
233
260
  .map((s) => ({ path: s.row.path, cjkRatio: Number(s.lint.cjkRatio.toFixed(3)) }));
234
261
  const ledgerClaims = [];
235
262
  for (const s of ok) {
236
- if (s.lint.isRedirectStub || !isFrontPath(s.row.path) || s.lint.hasStamp)
263
+ // Stamp honesty (swarm-A P2): a stamp that only ever confesses the review
264
+ // never ran ("…(content not re-run)"/未复跑/未复核/baseline) is not evidence —
265
+ // claiming review without execution must not suppress the claim ledger.
266
+ // Any single non-confessional stamp line (incl. supersede-keeping-struck-old)
267
+ // exempts exactly as before.
268
+ const exempt = s.lint.hasStamp && !isConfessionalStamp(s.body);
269
+ if (s.lint.isRedirectStub || !isFrontPath(s.row.path) || exempt)
237
270
  continue;
238
271
  const genre = classifyGenre({ title: s.row.title, body: s.body }).genre;
239
272
  if (!(genre === 'G4' || genre === 'G5' || genre === 'G6'))
@@ -269,7 +302,7 @@ export async function buildSurfaceReport(input) {
269
302
  generatedAt: input.generatedAt,
270
303
  deep: input.deep === true,
271
304
  coverage: buildCoverage(input.rows, input.liveInventory),
272
- nav: buildNav(input.rows, input.liveInventory),
305
+ nav: buildNav(input.rows, input.liveInventory, input.nav),
273
306
  tagsEmpty: input.rows
274
307
  .filter((r) => isFrontPath(r.path) && (r.tags?.length ?? 0) === 0)
275
308
  .map((r) => ({ path: r.path, locale: r.locale }))
@@ -291,8 +324,21 @@ export function renderSurfaceMarkdown(r) {
291
324
  for (const m of cap(r.coverage.missingFromMap, 40))
292
325
  L.push(` - \`${m.locale}/${m.path}\``);
293
326
  }
294
- L.push('', '## 导航 Nav(动态侧栏镜像)', '');
295
- L.push(`- 机器命名空间暴露 machineSections: ${r.nav.machineSections.map((s) => `\`${s}/\``).join(' ') || 'none'}`);
327
+ L.push('', '## 导航 Nav(真相 = 实时导航树,非页面树推断)', '');
328
+ if (!r.nav.available) {
329
+ L.push('- ⚠ 导航树不可读(nav.available=false)— 机器段暴露无法核验,请检查 token 的导航读取权限');
330
+ }
331
+ else {
332
+ L.push(`- mode: \`${r.nav.mode}\` · 文件系统暴露 filesystemExposed: ${r.nav.filesystemExposed ? '⚠ 是 — DYNAMIC/MIXED 会把页面树镜像回侧栏(Issue #1 复发)' : '否'}`);
333
+ if (r.nav.machineLinks.length > 0) {
334
+ L.push(`- ⚠ 导航树内机器段链接 machineLinks (${r.nav.machineLinks.length}):`);
335
+ for (const m of cap(r.nav.machineLinks, 20))
336
+ L.push(` - [${m.locale}] ${m.label} → \`${m.target}\``);
337
+ }
338
+ else {
339
+ L.push(`- 导航树内机器段链接: none ✓(页面树存档段 ${r.nav.machinePaths.map((s) => `\`${s}/\``).join(' ') || '—'} 属设计内,仅备查)`);
340
+ }
341
+ }
296
342
  if (r.nav.sectionLandingMissing.length > 0) {
297
343
  L.push(`- 落地页缺失 sectionLandingMissing(面包屑 404 / 空目录页):`);
298
344
  for (const s of r.nav.sectionLandingMissing)
@@ -599,7 +599,7 @@ export const G6_ZH = `# 如何做某事(占位:目标句式标题,须与
599
599
 
600
600
  | 元数据 | 值 |
601
601
  | --- | --- |
602
- | 状态 | <!-- Active / Superseded-by: <path> / Deprecated --> |
602
+ | 状态 | draft <!-- 必须与顶部 状态/Status 头同 token;改状态时两处一起改 --> |
603
603
  | 上次核实 | <!-- YYYY-MM-DD,在哪套环境按本页步骤重跑过 --> |
604
604
  | 复核周期 | <!-- 如每 90 天,到期重跑本页步骤 --> |
605
605
  | 被取代于 | <!-- 新手册路径,无则填 — --> |
@@ -648,7 +648,7 @@ export const G6_EN = `# How to Do X (placeholder: goal-titled heading, must matc
648
648
 
649
649
  | Field | Value |
650
650
  | --- | --- |
651
- | Status | <!-- Active / Superseded-by: <path> / Deprecated --> |
651
+ | Status | draft <!-- must carry the SAME token as the 状态/Status header above; change both together --> |
652
652
  | Last verified | <!-- YYYY-MM-DD and the environment the steps were re-run in --> |
653
653
  | Review by | <!-- e.g. every 90 days; re-run the steps when due --> |
654
654
  | Superseded by | <!-- path of the newer manual, or — --> |
@@ -10,9 +10,11 @@ import { buildChronology, filterRowsByPath } from '../chronology.js';
10
10
  import { getMap, refreshMapCache, CACHE_PATH } from '../map.js';
11
11
  import { buildMaintainReport, renderMaintainMarkdown } from '../maintain.js';
12
12
  import { buildSurfaceReport, renderSurfaceMarkdown } from '../surface.js';
13
+ import { lintBody, statusTokenFindings } from '../lint.js';
13
14
  import { normalizeLocale, PathValidationError } from '../wiki/locale.js';
14
15
  import { listPages, readPage } from '../wiki/pages.read.js';
15
- import { errEnvelope, okJson, reportUrls, URL_MANDATE } from './shared.js';
16
+ import { readPrimaryNav } from '../wiki/nav.js';
17
+ import { errEnvelope, isInternalPath, okJson, reportUrls, URL_MANDATE } from './shared.js';
16
18
  const s = tool.schema;
17
19
  const TRANSLATE_ARGS = {
18
20
  text: s.string(),
@@ -63,6 +65,24 @@ const MAP_ARGS = {
63
65
  deep: s.boolean().optional().describe('maintain: additionally read every page body (freshness + stubs + broken/stacked links + twin parity + zh-first + unfinished skeletons + claim ledgers) — one bounded read per row, cached across both scans'),
64
66
  };
65
67
  const MapArgsSchema = s.object(MAP_ARGS);
68
+ /** P1 surfacing: lint's status-token-conflict finding per scanned body,
69
+ * reusing runMaintain's per-page body cache (zero extra wiki reads). Machine
70
+ * rows carry the `status-token-conflict` key so harness graders can count it
71
+ * without re-deriving the rule. */
72
+ async function scanStatusTokenConflicts(rows, readBody, baseUrl) {
73
+ const out = [];
74
+ for (const r of rows) {
75
+ if (isInternalPath(r.path))
76
+ continue;
77
+ const body = await readBody(r.path, r.locale);
78
+ if (body === null || body === '')
79
+ continue;
80
+ const findings = statusTokenFindings(lintBody(body, { locale: r.locale, baseUrl, title: r.title }));
81
+ for (const f of findings)
82
+ out.push({ path: r.path, locale: r.locale, key: f.key, detail: f.detail });
83
+ }
84
+ return out;
85
+ }
66
86
  /** maintain: light tier is map rows + ONE read-only pages.list pass per locale
67
87
  * (the mirror's MapRow carries no tags; the list join restores the vocab view);
68
88
  * deep additionally reads each body via readPage. Reserved-path pages (e.g.
@@ -102,23 +122,33 @@ async function runMaintain(deps, mapDeps, snapshot, deep) {
102
122
  }
103
123
  : undefined;
104
124
  const report = await buildMaintainReport({ rows, mapGeneratedAt: snapshot.generatedAt, mapStaleSeconds: snapshot.staleSeconds }, { deep, readBody });
125
+ const nav = await readPrimaryNav(client);
105
126
  const surface = await buildSurfaceReport({
106
127
  rows,
107
128
  generatedAt: report.generatedAt,
108
129
  baseUrl: deps.options.baseUrl,
109
130
  liveInventory,
131
+ nav,
110
132
  deep,
111
133
  readBody,
112
134
  });
135
+ const statusTokenConflicts = deep && readBody !== undefined
136
+ ? await scanStatusTokenConflicts(rows, readBody, deps.options.baseUrl)
137
+ : [];
138
+ const conflictNote = statusTokenConflicts.length === 0
139
+ ? ''
140
+ : `\n\n## 状态令牌冲突 Status-token conflicts (${statusTokenConflicts.length})\n` +
141
+ statusTokenConflicts.map((c) => `- \`${c.locale}/${c.path}\` status-token-conflict: ${c.detail}`).join('\n') +
142
+ '\n';
113
143
  return {
114
144
  action: 'maintain',
115
- schema: 'historian.maintain.v2',
145
+ schema: 'historian.maintain.v3',
116
146
  deep: report.deep,
117
147
  generatedAt: report.generatedAt,
118
148
  rowCount: report.rowCount,
119
- report,
149
+ report: { ...report, statusTokenConflicts },
120
150
  surface,
121
- markdown: `${renderMaintainMarkdown(report)}\n\n${renderSurfaceMarkdown(surface)}`,
151
+ markdown: `${renderMaintainMarkdown(report)}${conflictNote}\n\n${renderSurfaceMarkdown(surface)}`,
122
152
  urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
123
153
  };
124
154
  }
@@ -129,9 +129,10 @@ export declare function sectionRefusalJson(path: string, allowedSections: readon
129
129
  export declare class PublishGateError extends Error {
130
130
  constructor(message: string);
131
131
  }
132
- /** Hard gate on front-tier writes: refuses the two shapes that shipped real
132
+ /** Hard gate on front-tier writes: refuses the three shapes that shipped real
133
133
  * incidents — a page claiming Active with TODO markers or empty skeleton
134
- * sections, and a redirect stub whose body carries no clickable exit.
134
+ * sections, a redirect stub whose body carries no clickable exit, and (R4/P1)
135
+ * an Active write whose table-row 状态/Status token contradicts the header.
135
136
  * `_sandbox/*` and internal namespaces are exempt; 状态:draft stays the
136
137
  * sanctioned work-in-progress escape hatch. Null = proceed. */
137
138
  export declare function publishGateRefusalJson(content: string, locale: Locale, path: string, baseUrl: string): ToolResult | null;
@@ -85,7 +85,7 @@ function hintFor(errorKind) {
85
85
  case 'GraphQLError':
86
86
  return 'The wiki answered a GraphQL error — check the path/locale arguments.';
87
87
  case 'PublishGateError':
88
- return '消除 TODO/空节并把状态置 Active,或保留 状态:draft 待自检通过后发布;重定向存根正文必须带可点击的 [链接](目标URL)';
88
+ return '消除 TODO/空节并把状态置 Active,或保留 状态:draft 待自检通过后发布;重定向存根正文必须带可点击的 [链接](目标URL);Active 页的表格行 状态/Status 必须与顶部冒号状态头同 token(改状态两处一起改)。';
89
89
  default:
90
90
  return 'Inspect the message and retry.';
91
91
  }
@@ -314,9 +314,10 @@ export class PublishGateError extends Error {
314
314
  this.name = 'PublishGateError';
315
315
  }
316
316
  }
317
- /** Hard gate on front-tier writes: refuses the two shapes that shipped real
317
+ /** Hard gate on front-tier writes: refuses the three shapes that shipped real
318
318
  * incidents — a page claiming Active with TODO markers or empty skeleton
319
- * sections, and a redirect stub whose body carries no clickable exit.
319
+ * sections, a redirect stub whose body carries no clickable exit, and (R4/P1)
320
+ * an Active write whose table-row 状态/Status token contradicts the header.
320
321
  * `_sandbox/*` and internal namespaces are exempt; 状态:draft stays the
321
322
  * sanctioned work-in-progress escape hatch. Null = proceed. */
322
323
  export function publishGateRefusalJson(content, locale, path, baseUrl) {
@@ -0,0 +1,21 @@
1
+ import { type GqlClient } from './client.js';
2
+ export interface NavItem {
3
+ readonly label: string;
4
+ readonly targetType: string;
5
+ readonly target: string;
6
+ }
7
+ export interface NavTree {
8
+ readonly locale: string;
9
+ readonly items: readonly NavItem[];
10
+ }
11
+ export interface NavSnapshot {
12
+ readonly mode: string;
13
+ readonly trees: readonly NavTree[];
14
+ }
15
+ /** Shape-tolerant parse: unknown/nullish shapes degrade to '' entries; a
16
+ * payload without `navigation` is not-a-nav (null), letting the caller mark
17
+ * the check unavailable instead of claiming "clean". */
18
+ export declare function parseNav(raw: unknown): NavSnapshot | null;
19
+ /** Never throws: an unreadable nav (older server, token without navigation
20
+ * read) returns null so the scan degrades to "unavailable", not failure. */
21
+ export declare function readPrimaryNav(client: GqlClient): Promise<NavSnapshot | null>;
@@ -0,0 +1,43 @@
1
+ // Live navigation reader (v0.5.1). Issue #1's actual disease was the SIDEBAR
2
+ // mirroring the filesystem (DYNAMIC/MIXED exposing _meta/_evidence/_sandbox),
3
+ // not the existence of machine-namespace pages — those are by design. So the
4
+ // detector reads the real primary nav (mode + curated flat trees) instead of
5
+ // inferring exposure from the page tree.
6
+ //
7
+ // Shape note: this wiki.js generation REJECTS `children` on NavigationItem
8
+ // (HTTP 400, re-probed 2026-09-08) — flat `items` are the whole truth.
9
+ import { gql } from './client.js';
10
+ const NAV_QUERY = '{ navigation { config { mode } tree { locale items { label targetType target } } } }';
11
+ /** Shape-tolerant parse: unknown/nullish shapes degrade to '' entries; a
12
+ * payload without `navigation` is not-a-nav (null), letting the caller mark
13
+ * the check unavailable instead of claiming "clean". */
14
+ export function parseNav(raw) {
15
+ const nav = raw?.navigation;
16
+ if (nav === undefined || nav === null)
17
+ return null;
18
+ const trees = [];
19
+ for (const t of Array.isArray(nav.tree) ? nav.tree : []) {
20
+ const row = t;
21
+ const items = [];
22
+ for (const i of Array.isArray(row?.items) ? row.items : []) {
23
+ const it = i;
24
+ items.push({
25
+ label: typeof it?.label === 'string' ? it.label : '',
26
+ targetType: typeof it?.targetType === 'string' ? it.targetType : '',
27
+ target: typeof it?.target === 'string' ? it.target : '',
28
+ });
29
+ }
30
+ trees.push({ locale: typeof row?.locale === 'string' ? row.locale : '', items });
31
+ }
32
+ return { mode: typeof nav.config?.mode === 'string' ? nav.config.mode : '', trees };
33
+ }
34
+ /** Never throws: an unreadable nav (older server, token without navigation
35
+ * read) returns null so the scan degrades to "unavailable", not failure. */
36
+ export async function readPrimaryNav(client) {
37
+ try {
38
+ return parseNav(await gql(client, NAV_QUERY, {}));
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-wiki-historian",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "opencode plugin that manages a wiki.js knowledge base with bilingual pages, genre templates, and migration tooling.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -200,7 +200,7 @@ G5 现状卡的硬约束:状态块是机读单行(`Active` / `Superseded-by:
200
200
  | `historian_translate_snippet` | 翻译片段 | `text`, `from`(en/zh), `to`(en/zh) |
201
201
  | `historian_search` | 搜索页面 | `query`, `kind`(title/content), `tags?`(1-5 个), `tagsMode?`(all 缺省/any) |
202
202
  | `historian_read` | 读取页面 | `path`, `locale` |
203
- | `historian_map` | 页面地图/时间线/维护扫描 | `action`(show/refresh/timeline/maintain);maintain 可选 `deep`(缺省 false=light 扫);timeline 可选 `days`(近 N 天)与 `path`(前缀过滤);输出人读 markdown + 机读 JSON,zh/en 行独立;maintain 同时返回 surface 接口面体检(信封 `historian.maintain.v2`) |
203
+ | `historian_map` | 页面地图/时间线/维护扫描 | `action`(show/refresh/timeline/maintain);maintain 可选 `deep`(缺省 false=light 扫);timeline 可选 `days`(近 N 天)与 `path`(前缀过滤);输出人读 markdown + 机读 JSON,zh/en 行独立;maintain 同时返回 surface 接口面体检(信封 `historian.maintain.v3`) |
204
204
  | `historian_migrate` | 迁移页面到规范 | `path`, `genre?`, `apply`(false/true) |
205
205
  | `historian_delete` | 删除页面 | `path`, `locale`, `confirm`(必须 "yes") |
206
206
  | `historian_move` | 移动页面 | `path`, `locale`, `newPath`, `newLocale?`, `confirm`(必须 "yes") |
@@ -327,21 +327,21 @@ historian_map action:'refresh'
327
327
  - **light 扫:每次批量写后必跑**——只基于地图行 + 每 locale 一次只读 `pages.list`(便宜,随批走)
328
328
  - **deep 扫:每周至多一次**——逐页读正文,跑新鲜度(缺「上次核实于」/ 复核过期)与 `> Redirect:` 存根计数(贵,克制用)
329
329
 
330
- light 扫在 maintain 行之外附带 **surface-light**:`coverage`(live 页面与地图不一致)、`nav`(`_*` 机器命名空间暴露于侧栏 / 章节缺落地页→面包屑 404)、`tagsEmpty`;deep 扫附带 **surface-deep**:正文级检测,逐页一次读取、双消费者共享缓存。
330
+ light 扫在 maintain 行之外附带 **surface-light**:`coverage`(live 页面与地图不一致)、`nav`(侧栏真相=实时导航树:mode 非 STATIC 即把页面树镜像回侧栏 / 树内挂 `_` 段链接 / 章节缺落地页→面包屑 404)、`tagsEmpty`;deep 扫附带 **surface-deep**:正文级检测,逐页一次读取、双消费者共享缓存。
331
331
 
332
332
  报告行 → 处置映射表:
333
333
 
334
334
  | 报告行 | 含义 | 处置 |
335
335
  |--------|------|------|
336
336
  | `missingTwinPaths` | 双语孪生缺口 | 补孪生:翻译腿建 zh(或 en)页,走翻译失败处理 |
337
- | `duplicates.clusters` | 近重复标题簇(trigram-Jaccard 阈值) | bold-merge 流程:选最完整页为权威(bold),其余走 supersede 或 Redirect 存根 |
337
+ | `duplicates.clusters` | 近重复标题簇(trigram-Jaccard 阈值;「(重定向)」存根不参与聚类) | bold-merge 流程:选最完整页为权威(bold),其余走 supersede 或 Redirect 存根 |
338
338
  | `staleness.oldest` | 最陈旧页 | mark/refresh:G5 卡重新核实或标记 stale,不静默覆盖 |
339
- | `rootOrphans` / `diffusion.singleChildDirs` | 顶级孤儿 / 独子目录 | 归架:并入正确章节、建章节索引,或按冻结协议做 Redirect 存根 |
339
+ | `flatRootPages` / `diffusion.singleChildDirs` | 章节根平铺页清单(归架提示,非入链判定)/ 独子目录 | 归架:并入正确章节、建章节索引,或按冻结协议做 Redirect 存根;真孤儿看 surface deep 的 `orphanPages` |
340
340
  | `tags.vocabulary` | 标签漂移 | 词表映射:近义标签收敛到主词,`historian_page_update` 批量改 |
341
341
  | `redirects.stubs`(deep) | 重定向存根清单 | 核对目标存在、入链已改写;死链存根即修 |
342
342
  | `freshness`(deep) | 缺核实戳 / reviewBy 过期 | 回 G5 卡补核;到期页列入下周复核 |
343
343
  | `coverage.missingFromMap`(surface) | 新页/迁移未进地图 | `action:'refresh'` 后重扫 |
344
- | `nav.machineSections`(surface) | `_*` 机器命名空间进侧栏 | 导航树手工策划,只挂主题章节 |
344
+ | `nav.filesystemExposed` / `nav.machineLinks`(surface) | 侧栏被 DYNAMIC/MIXED 镜像出页面树 / 导航树里挂了 `_` 段链接 | 导航树手工策划只挂主题章节,mode 固定 STATIC;`nav.available=false` 时先修 token 导航读权限再下结论 |
345
345
  | `nav.sectionLandingMissing`(surface) | 章节缺落地页(面包屑 404) | 建章节总览页并链入 wiki-index |
346
346
  | `unfinished`(surface deep) | Active 页含 TODO/空节/导言空 | 补全或降回 draft |
347
347
  | `stubs` / `links.broken` / `toStubs` / `sameTargetStacks`(deep) | 存根无可点出口 / 死链 / 指存根 / 同页多锚点 | 修出口与目标;锚点收敛到规范页 |