opencode-wiki-historian 0.5.1 → 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;
@@ -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
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
@@ -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) {
@@ -204,6 +229,117 @@ function reviewByOf(body) {
204
229
  }
205
230
  return null;
206
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
+ }
207
343
  // --- buildMaintainReport --------------------------------------------------------
208
344
  export async function buildMaintainReport(input, opts = {}) {
209
345
  const now = opts.now ?? new Date();
@@ -222,11 +358,13 @@ export async function buildMaintainReport(input, opts = {}) {
222
358
  localeCount.set(r.path, (localeCount.get(r.path) ?? 0) + 1);
223
359
  let redirects = { available: false, count: 0, stubs: [] };
224
360
  let freshness = null;
361
+ let dueForReview = null;
225
362
  if (deep && opts.readBody !== undefined) {
226
363
  const readBody = opts.readBody;
227
364
  const stubs = [];
228
365
  const missing = [];
229
366
  const expired = [];
367
+ const due = [];
230
368
  let scanned = 0;
231
369
  let unreadable = 0;
232
370
  for (const r of kept) {
@@ -242,6 +380,9 @@ export async function buildMaintainReport(input, opts = {}) {
242
380
  continue; // stubs are pointers — exempt from the freshness-stamp rule
243
381
  }
244
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 });
245
386
  if (!FRESHNESS_GENRES.includes(genre))
246
387
  continue;
247
388
  if (!STAMP_RE.test(body))
@@ -256,6 +397,7 @@ export async function buildMaintainReport(input, opts = {}) {
256
397
  }
257
398
  redirects = { available: true, count: stubs.length, stubs };
258
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));
259
401
  }
260
402
  return {
261
403
  schema: MAINTAIN_SCHEMA,
@@ -278,6 +420,7 @@ export async function buildMaintainReport(input, opts = {}) {
278
420
  redirects,
279
421
  sections: sectionDist(kept),
280
422
  freshness,
423
+ dueForReview,
281
424
  };
282
425
  }
283
426
  // --- renderMaintainMarkdown -----------------------------------------------------
@@ -350,6 +493,17 @@ export function renderMaintainMarkdown(r) {
350
493
  for (const e of r.freshness.expiredReviewBy)
351
494
  L.push(` - review overdue: \`${e.path}\` (${e.locale}) since ${e.reviewBy} (${fmt(e.daysExpired)}d)`);
352
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
+ }
353
507
  L.push('', '## Machine-readable JSON', '', '```json', JSON.stringify(r, null, 2), '```', '');
354
508
  return L.join('\n');
355
509
  }
package/dist/surface.d.ts CHANGED
@@ -11,7 +11,7 @@
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
16
  import type { NavSnapshot } from './wiki/nav.js';
17
17
  export declare const SURFACE_SCHEMA: "historian.surface.v1";
package/dist/surface.js CHANGED
@@ -14,6 +14,7 @@
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 = /^_/;
19
20
  // nav targets carry a leading slash and may pre-pend the locale segment
@@ -111,6 +112,13 @@ function resolveTargetPath(baseUrl, rawHref) {
111
112
  return { path: t.slice(3), locale: 'en' };
112
113
  return { path: t, locale: 'en' };
113
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
+ }
114
122
  async function scanBodies(input) {
115
123
  if (input.readBody === undefined)
116
124
  return [];
@@ -252,7 +260,13 @@ function buildDeep(input, scans) {
252
260
  .map((s) => ({ path: s.row.path, cjkRatio: Number(s.lint.cjkRatio.toFixed(3)) }));
253
261
  const ledgerClaims = [];
254
262
  for (const s of ok) {
255
- 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)
256
270
  continue;
257
271
  const genre = classifyGenre({ title: s.row.title, body: s.body }).genre;
258
272
  if (!(genre === 'G4' || genre === 'G5' || genre === 'G6'))
@@ -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,10 +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
16
  import { readPrimaryNav } from '../wiki/nav.js';
16
- import { errEnvelope, okJson, reportUrls, URL_MANDATE } from './shared.js';
17
+ import { errEnvelope, isInternalPath, okJson, reportUrls, URL_MANDATE } from './shared.js';
17
18
  const s = tool.schema;
18
19
  const TRANSLATE_ARGS = {
19
20
  text: s.string(),
@@ -64,6 +65,24 @@ const MAP_ARGS = {
64
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'),
65
66
  };
66
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
+ }
67
86
  /** maintain: light tier is map rows + ONE read-only pages.list pass per locale
68
87
  * (the mirror's MapRow carries no tags; the list join restores the vocab view);
69
88
  * deep additionally reads each body via readPage. Reserved-path pages (e.g.
@@ -113,15 +132,23 @@ async function runMaintain(deps, mapDeps, snapshot, deep) {
113
132
  deep,
114
133
  readBody,
115
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';
116
143
  return {
117
144
  action: 'maintain',
118
145
  schema: 'historian.maintain.v3',
119
146
  deep: report.deep,
120
147
  generatedAt: report.generatedAt,
121
148
  rowCount: report.rowCount,
122
- report,
149
+ report: { ...report, statusTokenConflicts },
123
150
  surface,
124
- markdown: `${renderMaintainMarkdown(report)}\n\n${renderSurfaceMarkdown(surface)}`,
151
+ markdown: `${renderMaintainMarkdown(report)}${conflictNote}\n\n${renderSurfaceMarkdown(surface)}`,
125
152
  urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
126
153
  };
127
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-wiki-historian",
3
- "version": "0.5.1",
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",