@gobing-ai/knowledge-kit 0.1.10 → 0.1.11

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.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +128 -25
  3. package/package.json +1 -1
  4. package/plugins/kk/plugin.json +1 -1
  5. package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
  6. package/plugins/kk/scripts/daily-stages.ts +1057 -108
  7. package/plugins/kk/scripts/kk-workflow-stages.ts +76 -10
  8. package/plugins/kk/scripts/safety-artifact-check.ts +235 -0
  9. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +168 -121
  10. package/plugins/kk/workflows/kk-daily-safety.yaml +129 -50
  11. package/plugins/publishings/bilibili-pub/dist/index.js +2 -1
  12. package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
  13. package/plugins/publishings/cnblogs-pub/dist/index.js +2 -1
  14. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
  15. package/plugins/publishings/csdn-pub/dist/index.js +2 -1
  16. package/plugins/publishings/csdn-pub/dist/profile-check.js +2 -1
  17. package/plugins/publishings/infoq-pub/dist/index.js +2 -1
  18. package/plugins/publishings/infoq-pub/dist/profile-check.js +2 -1
  19. package/plugins/publishings/jike-pub/dist/index.js +2 -1
  20. package/plugins/publishings/jike-pub/dist/profile-check.js +2 -1
  21. package/plugins/publishings/juejin-pub/dist/index.js +2 -1
  22. package/plugins/publishings/juejin-pub/dist/profile-check.js +2 -1
  23. package/plugins/publishings/podcast-pub/dist/index.js +46 -8
  24. package/plugins/publishings/podcast-pub/src/index.ts +62 -5
  25. package/plugins/publishings/shipinhao-pub/dist/index.js +2 -1
  26. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
  27. package/plugins/publishings/sspai-pub/dist/index.js +2 -1
  28. package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
  29. package/plugins/publishings/substack-pub/dist/index.js +2 -1
  30. package/plugins/publishings/substack-pub/dist/profile-check.js +2 -1
  31. package/plugins/publishings/surfdash-pub/dist/index.js +460 -52
  32. package/plugins/publishings/surfdash-pub/src/digest-check.ts +85 -19
  33. package/plugins/publishings/surfdash-pub/src/index.ts +589 -64
  34. package/plugins/publishings/wechatmp-pub/dist/index.js +933 -98
  35. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +60 -3
  36. package/plugins/publishings/wechatmp-pub/src/flow.ts +1125 -85
  37. package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
  38. package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
  39. package/plugins/publishings/weibo-pub/dist/index.js +2 -1
  40. package/plugins/publishings/weibo-pub/dist/profile-check.js +2 -1
  41. package/plugins/publishings/x-pub/dist/index.js +2 -1
  42. package/plugins/publishings/x-pub/dist/profile-check.js +2 -1
  43. package/plugins/publishings/xhs-pub/dist/index.js +2 -1
  44. package/plugins/publishings/xhs-pub/dist/profile-check.js +2 -1
  45. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +2 -1
  46. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +2 -1
@@ -57,6 +57,7 @@ import {
57
57
  copyFileSync,
58
58
  existsSync,
59
59
  mkdirSync,
60
+ readdirSync,
60
61
  readFileSync,
61
62
  renameSync,
62
63
  rmSync,
@@ -64,7 +65,7 @@ import {
64
65
  unlinkSync,
65
66
  writeFileSync,
66
67
  } from 'node:fs';
67
- import { basename, join, resolve } from 'node:path';
68
+ import { basename, isAbsolute, join, resolve } from 'node:path';
68
69
  import {
69
70
  ADMISSION_CRITERIA,
70
71
  applyTopicBoundary,
@@ -135,11 +136,42 @@ export function dailyQcContent(args: string[]): void {
135
136
  const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
136
137
  const exclusions = join(workDir, '21-plan', `${runDate}_04_quality-control-content_exclusions.json`);
137
138
  const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
139
+ const cachePath = `${candidates}.cache.json`;
140
+ const fingerprint = stageCacheFingerprint(planContent, {
141
+ qcCategories,
142
+ minQuality,
143
+ minImportance,
144
+ minUrgency,
145
+ minImpact,
146
+ minEpisodeItems,
147
+ minMinutes,
148
+ vdriver,
149
+ });
150
+ let recomputed = false;
138
151
  timed(workDir, 'quality-control-content', () => {
139
- if (isFile(candidates)) {
152
+ // Voice1's standalone QC tests and old workspaces may carry a candidates artifact without
153
+ // the newer plan input or sidecar. Preserve that explicitly cache-only surface; voice2
154
+ // always has planContent, so its stale/malformed cache is recomputed below.
155
+ const legacyCandidatesCache = isFile(candidates) && !isFile(planContent) && !isFile(cachePath);
156
+ if (stageCacheMatches(candidates, cachePath, fingerprint) || legacyCandidatesCache) {
140
157
  console.log(`candidates cached: ${candidates}`);
141
158
  return;
142
159
  }
160
+ if (isFile(candidates)) {
161
+ const descendants = authoredPlanDescendants(workDir, runDate);
162
+ if (descendants.length > 0) {
163
+ failWith(
164
+ 1,
165
+ `QC cache changed after downstream artifacts were authored; refusing to pair new candidates with stale outputs (${descendants.join(', ')})`,
166
+ );
167
+ }
168
+ console.log(`candidates cache stale: ${candidates}`);
169
+ }
170
+ for (const stale of [exclusions, `${runDate}_04_quality-control-content_floor-refusal.json`].map((name) =>
171
+ name.startsWith('/') ? name : join(workDir, '21-plan', name),
172
+ )) {
173
+ if (isFile(stale)) unlinkSync(stale);
174
+ }
143
175
  sted('write', annotated, () => writeJqText(annotated, metadataOf(readJson(planContent)).docs ?? null));
144
176
  kk(
145
177
  [
@@ -164,6 +196,9 @@ export function dailyQcContent(args: string[]): void {
164
196
  },
165
197
  },
166
198
  );
199
+ if (!isFile(candidates)) failWith(1, `quality-control-content: ${candidates} not written`);
200
+ sted('write', cachePath, () => writeStageCache(cachePath, fingerprint));
201
+ recomputed = true;
167
202
  });
168
203
  // Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
169
204
  // candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
@@ -212,7 +247,48 @@ export function dailyQcContent(args: string[]): void {
212
247
  // already produced. 2026-10-22 cleared the floor on a 4-item pool (21.0 min estimated) that the
213
248
  // topic boundary then cut to 3 (14.4 min), so the run paid for a full synthesis the measured
214
249
  // gate refused. Covered here it also re-runs on a cached-rerun, which the timed block skipped.
215
- applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
250
+ applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver, recomputed);
251
+ }
252
+
253
+ /**
254
+ * Cache only deterministic stage inputs. The source bytes are part of the key so a new collect
255
+ * pool or translated plan cannot inherit a prior run's refusal; the invocation parameters cover
256
+ * every threshold that changes the QC/floor result.
257
+ */
258
+ function stageCacheFingerprint(sourcePath: string, inputs: Record<string, unknown>): string {
259
+ const source = isFile(sourcePath) ? readFileSync(sourcePath, 'utf-8') : '<missing>';
260
+ return createHash('sha256')
261
+ .update(JSON.stringify({ version: 1, source, inputs }))
262
+ .digest('hex');
263
+ }
264
+
265
+ function stageCacheMatches(outputPath: string, cachePath: string, fingerprint: string): boolean {
266
+ if (!isFile(outputPath) || !isFile(cachePath)) return false;
267
+ try {
268
+ const cache = JSON.parse(readFileSync(cachePath, 'utf-8')) as { version?: unknown; fingerprint?: unknown };
269
+ return cache.version === 1 && cache.fingerprint === fingerprint;
270
+ } catch {
271
+ return false;
272
+ }
273
+ }
274
+
275
+ function writeStageCache(path: string, fingerprint: string): void {
276
+ writeJsonText(path, { version: 1, fingerprint });
277
+ }
278
+
279
+ function authoredPlanDescendants(workDir: string, runDate: string): string[] {
280
+ const descendants: string[] = [];
281
+ for (const directory of ['22-article', '23-cover', '24-script', '30-audio', '31-publish']) {
282
+ const root = join(workDir, directory);
283
+ if (!existsSync(root)) continue;
284
+ // The directory contents are stage-owned artifacts. A date-prefixed file means that a
285
+ // previous non-empty run already authored downstream material that must not be silently
286
+ // paired with a newly generated plan.
287
+ for (const entry of readdirSync(root)) {
288
+ if (entry.startsWith(`${runDate}_`)) descendants.push(join(root, entry));
289
+ }
290
+ }
291
+ return descendants;
216
292
  }
217
293
 
218
294
  /**
@@ -261,18 +337,26 @@ function applyFloorGate(
261
337
  runDate: string,
262
338
  minMinutes: string,
263
339
  vdriver: string,
340
+ clearStaleRefusal = false,
264
341
  ): void {
342
+ const refusal = join(workDir, '21-plan', `${runDate}_04_quality-control-content_floor-refusal.json`);
265
343
  const floor = Number(minMinutes);
266
- if (minMinutes === '' || !Number.isFinite(floor) || floor <= 0) return;
344
+ if (minMinutes === '' || !Number.isFinite(floor) || floor <= 0) {
345
+ if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
346
+ return;
347
+ }
267
348
  const content = readJson(candidates);
268
349
  const docs = metadataOf(content).docs;
269
- if (!Array.isArray(docs) || docs.length === 0) return;
350
+ if (!Array.isArray(docs) || docs.length === 0) {
351
+ if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
352
+ return;
353
+ }
270
354
  const { minutes: estimatedMinutes, seconds: estimatedSeconds, chars } = estimateSpokenMinutes(docs, vdriver);
271
355
  if (estimatedSeconds >= floor * 60) {
356
+ if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
272
357
  console.log(`floor gate: estimated ${estimatedMinutes} min from ${chars} chars — above the ${floor} min floor`);
273
358
  return;
274
359
  }
275
- const refusal = join(workDir, '21-plan', `${runDate}_04_quality-control-content_floor-refusal.json`);
276
360
  const ids = docs.map((doc) => String((doc as Record<string, unknown> | null)?.id ?? ''));
277
361
  sted('write', refusal, () =>
278
362
  writeJqText(refusal, {
@@ -1511,11 +1595,28 @@ export function dailyPlan(args: string[]): void {
1511
1595
  // translation. Voice1 omits the arg and still materializes the file (its translate node has no
1512
1596
  // skip guard and overwrites it).
1513
1597
  const deferDocs = deferPlanDocs === 'true';
1598
+ const target = deferDocs ? planContent : planDocs;
1599
+ const targetCache = `${target}.cache.json`;
1600
+ const fingerprint = stageCacheFingerprint(blended, {
1601
+ runDate,
1602
+ planMaxItems,
1603
+ deferPlanDocs,
1604
+ });
1514
1605
  timed(workDir, 'plan', () => {
1515
- if (deferDocs ? isFile(planContent) : isFile(planDocs)) {
1606
+ if (stageCacheMatches(target, targetCache, fingerprint)) {
1516
1607
  console.log(`plan cached: ${deferDocs ? planContent : planDocs}`);
1517
1608
  return;
1518
1609
  }
1610
+ if (isFile(target)) {
1611
+ const descendants = authoredPlanDescendants(workDir, runDate);
1612
+ if (descendants.length > 0) {
1613
+ failWith(
1614
+ 1,
1615
+ `plan cache changed after downstream artifacts were authored; refusing to pair a new plan with stale outputs (${descendants.join(', ')})`,
1616
+ );
1617
+ }
1618
+ console.log(`plan cache stale: ${target}`);
1619
+ }
1519
1620
  kk(
1520
1621
  ['executor', 'run', 'episode-plan-gen', '--in', blended, '--out', planContent, ...pluginsArgs(pluginsPath)],
1521
1622
  {
@@ -1524,9 +1625,17 @@ export function dailyPlan(args: string[]): void {
1524
1625
  );
1525
1626
  const docs = metadataOf(readJson(planContent)).docs;
1526
1627
  if (!Array.isArray(docs) || docs.length === 0) failWith(1, 'plan: content.metadata.docs missing or empty');
1527
- if (deferDocs) return;
1528
- sted('write', planDocs, () => writeJsonText(planDocs, docs));
1529
- if (!isFile(planDocs)) failWith(1, `plan: ${planDocs} not written after episode-plan-gen`);
1628
+ if (deferDocs) {
1629
+ // A translated plan is a descendant of this content plan. Remove only the stale
1630
+ // translation when no authored downstream artifact exists; otherwise the guard above
1631
+ // fails closed and leaves the operator a reviewable workspace.
1632
+ if (isFile(planDocs)) unlinkSync(planDocs);
1633
+ if (isFile(`${planDocs}.cache.json`)) unlinkSync(`${planDocs}.cache.json`);
1634
+ } else {
1635
+ sted('write', planDocs, () => writeJsonText(planDocs, docs));
1636
+ if (!isFile(planDocs)) failWith(1, `plan: ${planDocs} not written after episode-plan-gen`);
1637
+ }
1638
+ sted('write', targetCache, () => writeStageCache(targetCache, fingerprint));
1530
1639
  });
1531
1640
  }
1532
1641
 
@@ -1915,19 +2024,38 @@ export function readKeyPointItems(filePath: string): string[] | undefined {
1915
2024
  }
1916
2025
  const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1];
1917
2026
  if (!block) return undefined;
1918
- // Slice from `key_points` to the first top-level key (or block end), then take the item lines
1919
- // (`- `), so folded (`>-`) and quoted single-line items both read once.
2027
+ // Slice from `key_points` to the first top-level key (or block end), then read the item lines
2028
+ // (`- `). Folded (`>-`) YAML items need their indented continuation lines joined before the
2029
+ // strict scheduled validator can check the `Label — sentence` contract.
1920
2030
  const head = /^key_points:\n/m.exec(block);
1921
2031
  if (!head) return undefined;
1922
2032
  const body = block.slice(head.index + head[0].length);
1923
2033
  const endMatch = /^\S/m.exec(body);
1924
2034
  const list = endMatch ? body.slice(0, endMatch.index) : body;
1925
- // A quoted scalar's quotes are YAML syntax, not text — strip them so label lengths are real.
1926
- return list
1927
- .split('\n')
1928
- .filter((line) => /^[ \t]+- /.test(line))
1929
- .map((line) => line.replace(/^[ \t]+- /, '').trim())
1930
- .map((item) => item.replace(/^(["'])(.*)\1$/, '$2'));
2035
+ const items: string[] = [];
2036
+ let current: string | undefined;
2037
+ let folded = false;
2038
+ const flush = (): void => {
2039
+ if (current === undefined) return;
2040
+ // A quoted scalar's quotes are YAML syntax, not text — strip them so label lengths are real.
2041
+ items.push(current.replace(/^(['"])(.*)\1$/, '$2').trim());
2042
+ current = undefined;
2043
+ folded = false;
2044
+ };
2045
+ for (const line of list.split('\n')) {
2046
+ const item = /^[ \t]+- (.*)$/.exec(line);
2047
+ if (item !== null) {
2048
+ flush();
2049
+ current = item[1].trim();
2050
+ folded = current === '>' || current === '>-' || current === '|-' || current === '|';
2051
+ continue;
2052
+ }
2053
+ if (current === undefined || !folded) continue;
2054
+ const continuation = line.trim();
2055
+ if (continuation !== '') current = `${current} ${continuation}`;
2056
+ }
2057
+ flush();
2058
+ return items;
1931
2059
  }
1932
2060
 
1933
2061
  /** Count `key_points` list items in a Surfing post's frontmatter; undefined when absent/unreadable. */
@@ -2113,36 +2241,662 @@ export function dailyPublishPrep(args: string[]): void {
2113
2241
  });
2114
2242
  }
2115
2243
 
2244
+ type SurfdashDeliveryState = 'missing' | 'success' | 'failure' | 'uncertain';
2245
+
2246
+ interface SurfdashSendIntent {
2247
+ version: 1;
2248
+ target: 'surfdash-pub';
2249
+ runDate: string;
2250
+ payloadPath: string;
2251
+ payloadSha256: string;
2252
+ createdAt: string;
2253
+ }
2254
+
2255
+ function surfdashPayloadSha256(path: string): string {
2256
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
2257
+ }
2258
+
2259
+ function surfdashNotSentReceipt(resultPath: string, intentPath: string, runDate: string, reason: string): void {
2260
+ const error = `surfdash publish was not sent: ${reason}`;
2261
+ sted('write', resultPath, () =>
2262
+ writeJsonText(resultPath, {
2263
+ ok: false,
2264
+ summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
2265
+ targets: [
2266
+ {
2267
+ target: 'surfdash-pub',
2268
+ ok: false,
2269
+ status: 'failed',
2270
+ error,
2271
+ metadata: { deliveryState: 'not-sent', runDate, intentPath },
2272
+ },
2273
+ ],
2274
+ }),
2275
+ );
2276
+ console.error(error);
2277
+ }
2278
+
2279
+ function readSurfdashSendIntent(
2280
+ path: string,
2281
+ ): { kind: 'missing' } | { kind: 'valid'; value: SurfdashSendIntent } | { kind: 'invalid'; reason: string } {
2282
+ if (!isFile(path)) return { kind: 'missing' };
2283
+ const parsed = readJson(path);
2284
+ if (parsed === null) return { kind: 'invalid', reason: 'send intent is unreadable or invalid JSON' };
2285
+ if (
2286
+ parsed.version !== 1 ||
2287
+ parsed.target !== 'surfdash-pub' ||
2288
+ typeof parsed.runDate !== 'string' ||
2289
+ typeof parsed.payloadPath !== 'string' ||
2290
+ typeof parsed.createdAt !== 'string' ||
2291
+ typeof parsed.payloadSha256 !== 'string' ||
2292
+ !/^[0-9a-f]{64}$/.test(parsed.payloadSha256)
2293
+ ) {
2294
+ return { kind: 'invalid', reason: 'send intent is malformed' };
2295
+ }
2296
+ return { kind: 'valid', value: parsed as unknown as SurfdashSendIntent };
2297
+ }
2298
+
2299
+ function surfdashTargetEntries(result: Record<string, unknown> | null): Record<string, unknown>[] {
2300
+ const targets = result?.targets;
2301
+ if (!Array.isArray(targets)) return [];
2302
+ return targets.filter(
2303
+ (entry): entry is Record<string, unknown> =>
2304
+ entry !== null && typeof entry === 'object' && (entry as Record<string, unknown>).target === 'surfdash-pub',
2305
+ );
2306
+ }
2307
+
2308
+ /** A push can have reached the remote even when the publisher reports a failed receipt. */
2309
+ function surfdashFailureMayHaveRemoteEffect(entry: Record<string, unknown>): boolean {
2310
+ return (
2311
+ entry.ok === false &&
2312
+ entry.status === 'failed' &&
2313
+ (entry.metadata as Record<string, unknown> | undefined)?.deliveryState !== 'not-sent'
2314
+ );
2315
+ }
2316
+
2317
+ function surfdashTargetState(result: Record<string, unknown> | null): SurfdashDeliveryState {
2318
+ const matches = surfdashTargetEntries(result);
2319
+ if (matches.length === 0) return 'missing';
2320
+ // More than one receipt for the same target is ambiguous even when every row says success:
2321
+ // retrying could duplicate a remote post. Preserve the uncertainty until reconciliation.
2322
+ if (matches.length > 1) return 'uncertain';
2323
+ if (
2324
+ matches.some(
2325
+ (entry) =>
2326
+ (entry.metadata as Record<string, unknown> | undefined)?.deliveryState === 'uncertain' ||
2327
+ surfdashFailureMayHaveRemoteEffect(entry),
2328
+ )
2329
+ ) {
2330
+ return 'uncertain';
2331
+ }
2332
+ const target = matches[0];
2333
+ if (target.ok === true && target.status === 'published') return 'success';
2334
+ if (target.ok === false && target.status === 'failed') return 'failure';
2335
+ return 'missing';
2336
+ }
2337
+
2338
+ function writeSurfdashUncertainReceipt(
2339
+ resultPath: string,
2340
+ intentPath: string,
2341
+ runDate: string,
2342
+ payloadSha256: string | undefined,
2343
+ currentPayloadSha256: string | undefined,
2344
+ reason: string,
2345
+ identity: { postPath?: string; localeFingerprint?: string } = {},
2346
+ ): void {
2347
+ const metadata: Record<string, unknown> = {
2348
+ deliveryState: 'uncertain',
2349
+ runDate,
2350
+ intentPath,
2351
+ ...(payloadSha256 ? { payloadSha256 } : {}),
2352
+ ...(currentPayloadSha256 ? { currentPayloadSha256 } : {}),
2353
+ ...(identity.postPath ? { postPath: identity.postPath } : {}),
2354
+ ...(identity.localeFingerprint ? { localeFingerprint: identity.localeFingerprint } : {}),
2355
+ };
2356
+ const error = `surfdash delivery is uncertain: ${reason}; reconcile the dated remote post/commit before clearing this uncertainty and retrying`;
2357
+ sted('write', resultPath, () =>
2358
+ writeJsonText(resultPath, {
2359
+ ok: false,
2360
+ summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
2361
+ targets: [
2362
+ {
2363
+ target: 'surfdash-pub',
2364
+ ok: false,
2365
+ status: 'failed',
2366
+ error,
2367
+ metadata,
2368
+ },
2369
+ ],
2370
+ }),
2371
+ );
2372
+ console.error(error);
2373
+ }
2374
+
2375
+ const SURFDASH_LOCALES = ['zh', 'en', 'ja'] as const;
2376
+ type SurfdashLocale = (typeof SURFDASH_LOCALES)[number];
2377
+
2378
+ interface SurfdashLocaleValidationEvidence {
2379
+ runDate: string;
2380
+ postPath: string;
2381
+ localePaths: Record<SurfdashLocale, string>;
2382
+ localeDigests: Record<SurfdashLocale, string>;
2383
+ fingerprint: string;
2384
+ }
2385
+
2386
+ interface SurfdashPublishIdentity {
2387
+ postPath: string;
2388
+ payloadSha256: string;
2389
+ localeFingerprint: string;
2390
+ }
2391
+
2392
+ function surfdashPublishIdentity(entry: Record<string, unknown> | undefined): SurfdashPublishIdentity | undefined {
2393
+ if (entry === undefined) return undefined;
2394
+ const metadata = metadataOf(entry);
2395
+ if (
2396
+ typeof metadata.postPath !== 'string' ||
2397
+ typeof metadata.payloadSha256 !== 'string' ||
2398
+ !/^[0-9a-f]{64}$/.test(metadata.payloadSha256) ||
2399
+ typeof metadata.localeFingerprint !== 'string' ||
2400
+ !/^[0-9a-f]{64}$/.test(metadata.localeFingerprint)
2401
+ ) {
2402
+ return undefined;
2403
+ }
2404
+ return {
2405
+ postPath: metadata.postPath,
2406
+ payloadSha256: metadata.payloadSha256,
2407
+ localeFingerprint: metadata.localeFingerprint,
2408
+ };
2409
+ }
2410
+
2411
+ function localeSiblingPaths(zhPostPath: string): Record<SurfdashLocale, string> | undefined {
2412
+ if (!zhPostPath.includes('/zh/')) return undefined;
2413
+ const en = zhPostPath.replace('/zh/', '/en/');
2414
+ const ja = zhPostPath.replace('/zh/', '/ja/');
2415
+ if (en === zhPostPath || ja === zhPostPath) return undefined;
2416
+ return { zh: zhPostPath, en, ja };
2417
+ }
2418
+
2419
+ function validateLocaleMarkers(workDir: string, runDate: string, zhPostPath: string, step: string): void {
2420
+ const localePaths = localeSiblingPaths(zhPostPath);
2421
+ if (localePaths === undefined) {
2422
+ failWith(1, `${step}: zh post path does not contain a /zh/ locale segment: ${zhPostPath}`);
2423
+ }
2424
+ for (const locale of ['en', 'ja'] as const) {
2425
+ const marker = join(workDir, '31-publish', `${runDate}_20_translate-localize_${locale}-post-path.txt`);
2426
+ const markerPath = isFile(marker) ? readFileSync(marker, 'utf-8').trim() : '';
2427
+ const expectedPath = localePaths[locale];
2428
+ if (markerPath === '' || !isAbsolute(markerPath) || markerPath !== expectedPath || !isFile(expectedPath)) {
2429
+ failWith(1, `${step}: ${locale} marker must name the absolute derived sibling ${expectedPath}: ${marker}`);
2430
+ }
2431
+ }
2432
+ }
2433
+
2434
+ function surfdashLocaleValidationEvidence(
2435
+ zhPostPath: string,
2436
+ runDate: string,
2437
+ ): { ok: true; evidence: SurfdashLocaleValidationEvidence } | { ok: false; reason: string } {
2438
+ const localePaths = localeSiblingPaths(zhPostPath);
2439
+ if (localePaths === undefined) {
2440
+ return { ok: false, reason: `zh post path does not contain a /zh/ locale segment: ${zhPostPath}` };
2441
+ }
2442
+ const localeDigests = {} as Record<SurfdashLocale, string>;
2443
+ for (const locale of SURFDASH_LOCALES) {
2444
+ const path = localePaths[locale];
2445
+ if (!isFile(path)) return { ok: false, reason: `${locale} locale file is missing: ${path}` };
2446
+ try {
2447
+ localeDigests[locale] = createHash('sha256').update(readFileSync(path)).digest('hex');
2448
+ } catch (error: unknown) {
2449
+ return {
2450
+ ok: false,
2451
+ reason: `${locale} locale file cannot be fingerprinted: ${error instanceof Error ? error.message : String(error)}`,
2452
+ };
2453
+ }
2454
+ }
2455
+ const identity = { runDate, postPath: zhPostPath, localePaths, localeDigests };
2456
+ return {
2457
+ ok: true,
2458
+ evidence: {
2459
+ ...identity,
2460
+ fingerprint: createHash('sha256').update(JSON.stringify(identity)).digest('hex'),
2461
+ },
2462
+ };
2463
+ }
2464
+
2465
+ function validateSurfdashLocaleGate(
2466
+ validationPath: string,
2467
+ prepareResultPath: string,
2468
+ runDate: string,
2469
+ ): { ok: true; evidence: SurfdashLocaleValidationEvidence } | { ok: false; reason: string } {
2470
+ const validation = isFile(validationPath) ? readJson(validationPath) : null;
2471
+ if (validation?.ok !== true || validation.target !== 'surfdash-pub' || validation.status !== 'validated') {
2472
+ return { ok: false, reason: `missing or invalid validation receipt: ${validationPath}` };
2473
+ }
2474
+ const metadata = metadataOf(validation);
2475
+ if (metadata.runDate !== runDate || typeof metadata.postPath !== 'string') {
2476
+ return { ok: false, reason: `validation receipt date/path does not match run ${runDate}` };
2477
+ }
2478
+ const prepared = isFile(prepareResultPath) ? readJson(prepareResultPath) : null;
2479
+ const preparedTargets = Array.isArray(prepared?.targets)
2480
+ ? prepared.targets.filter(
2481
+ (entry): entry is Record<string, unknown> =>
2482
+ entry !== null &&
2483
+ typeof entry === 'object' &&
2484
+ (entry as Record<string, unknown>).target === 'surfdash-pub',
2485
+ )
2486
+ : [];
2487
+ if (
2488
+ preparedTargets.length !== 1 ||
2489
+ preparedTargets[0].ok !== true ||
2490
+ preparedTargets[0].status !== 'prepared' ||
2491
+ metadataOf(preparedTargets[0]).postPath !== metadata.postPath
2492
+ ) {
2493
+ return { ok: false, reason: `current Surfdash prepare receipt is missing or does not match ${validationPath}` };
2494
+ }
2495
+ const current = surfdashLocaleValidationEvidence(metadata.postPath, runDate);
2496
+ if (!current.ok) return current;
2497
+ const recordedPaths = metadata.localePaths;
2498
+ const recordedDigests = metadata.localeDigests;
2499
+ if (
2500
+ recordedPaths === null ||
2501
+ typeof recordedPaths !== 'object' ||
2502
+ recordedDigests === null ||
2503
+ typeof recordedDigests !== 'object' ||
2504
+ typeof metadata.fingerprint !== 'string'
2505
+ ) {
2506
+ return { ok: false, reason: `validation receipt fingerprint is missing or malformed: ${validationPath}` };
2507
+ }
2508
+ if (
2509
+ JSON.stringify(recordedPaths) !== JSON.stringify(current.evidence.localePaths) ||
2510
+ JSON.stringify(recordedDigests) !== JSON.stringify(current.evidence.localeDigests) ||
2511
+ metadata.fingerprint !== current.evidence.fingerprint
2512
+ ) {
2513
+ return { ok: false, reason: `locale files changed after validation; refresh ${validationPath}` };
2514
+ }
2515
+ return current;
2516
+ }
2517
+
2116
2518
  export function dailyPublishSurfdash(args: string[]): void {
2117
- const [workDir = '', runDate = '', pluginsPath = ''] = args;
2519
+ const [workDir = '', runDate = '', pluginsPath = '', requireLocaleValidation = 'false'] = args;
2118
2520
  const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
2119
2521
  const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
2522
+ const intentPath = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_intent.json`);
2523
+ const prepareResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_result.json`);
2524
+ const validationPath = join(workDir, '31-publish', `${runDate}_24_translate-validate_result.json`);
2525
+ const preparedInput = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_prepared_content.json`);
2526
+ const transportResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_transport_result.json`);
2120
2527
  timed(workDir, 'publish-surfdash', () => {
2121
- if (targetPublished(result, 'surfdash-pub')) {
2528
+ const priorResult = readJson(result);
2529
+ const priorTargets = surfdashTargetEntries(priorResult);
2530
+ const priorState = surfdashTargetState(priorResult);
2531
+ if (priorState === 'uncertain') {
2532
+ const priorTarget = priorTargets[0];
2533
+ if (
2534
+ priorTarget !== undefined &&
2535
+ (priorTarget.metadata as Record<string, unknown> | undefined)?.deliveryState !== 'uncertain'
2536
+ ) {
2537
+ const priorError =
2538
+ typeof priorTarget.error === 'string'
2539
+ ? priorTarget.error
2540
+ : 'publisher reported an ambiguous delivery failure';
2541
+ writeSurfdashUncertainReceipt(result, intentPath, runDate, undefined, undefined, priorError);
2542
+ }
2543
+ console.log(`publish-surfdash blocked: uncertain delivery is sticky in ${result}`);
2544
+ return;
2545
+ }
2546
+
2547
+ // The two-phase voice2 workflow explicitly requires a successful local locale gate before
2548
+ // the publisher can invoke refine/CMS/git. Legacy voice1/manual callers omit the fourth
2549
+ // argument and retain their historical publish behavior.
2550
+ let currentIdentity: SurfdashPublishIdentity | undefined;
2551
+ let currentLocaleEvidence: SurfdashLocaleValidationEvidence | undefined;
2552
+ if (requireLocaleValidation === 'true') {
2553
+ const validation = validateSurfdashLocaleGate(validationPath, prepareResult, runDate);
2554
+ if (!validation.ok) {
2555
+ const detail = validation.reason;
2556
+ if (priorState === 'success') {
2557
+ writeSurfdashUncertainReceipt(
2558
+ result,
2559
+ intentPath,
2560
+ runDate,
2561
+ undefined,
2562
+ undefined,
2563
+ `current locale validation is not the one used for the cached success: ${detail}`,
2564
+ );
2565
+ } else {
2566
+ surfdashNotSentReceipt(result, intentPath, runDate, `blocked by locale validation: ${detail}`);
2567
+ }
2568
+ return;
2569
+ }
2570
+ currentLocaleEvidence = validation.evidence;
2571
+ if (!isFile(prep)) {
2572
+ const detail = `prepared payload is missing: ${prep}`;
2573
+ if (priorState === 'success') {
2574
+ writeSurfdashUncertainReceipt(result, intentPath, runDate, undefined, undefined, detail);
2575
+ } else {
2576
+ surfdashNotSentReceipt(result, intentPath, runDate, detail);
2577
+ }
2578
+ return;
2579
+ }
2580
+ currentIdentity = {
2581
+ postPath: validation.evidence.postPath,
2582
+ payloadSha256: surfdashPayloadSha256(prep),
2583
+ localeFingerprint: validation.evidence.fingerprint,
2584
+ };
2585
+ }
2586
+
2587
+ if (priorState === 'success') {
2588
+ // Voice1 and manual callers omit the locale-validation argument and historically
2589
+ // treated a recorded success as a resume-safe cache hit. Voice2 supplies `true` and
2590
+ // must keep the exact identity binding below; do not make the legacy surface depend
2591
+ // on the new locale fingerprint receipt.
2592
+ if (requireLocaleValidation !== 'true') {
2593
+ console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
2594
+ return;
2595
+ }
2596
+ const cachedIdentity = surfdashPublishIdentity(priorTargets[0]);
2597
+ if (
2598
+ currentIdentity === undefined ||
2599
+ cachedIdentity === undefined ||
2600
+ cachedIdentity.postPath !== currentIdentity.postPath ||
2601
+ cachedIdentity.payloadSha256 !== currentIdentity.payloadSha256 ||
2602
+ cachedIdentity.localeFingerprint !== currentIdentity.localeFingerprint
2603
+ ) {
2604
+ writeSurfdashUncertainReceipt(
2605
+ result,
2606
+ intentPath,
2607
+ runDate,
2608
+ cachedIdentity?.payloadSha256,
2609
+ currentIdentity?.payloadSha256,
2610
+ 'cached success has no exact postPath/payloadSha256/localeFingerprint identity for the current material',
2611
+ currentIdentity,
2612
+ );
2613
+ return;
2614
+ }
2122
2615
  console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
2123
2616
  return;
2124
2617
  }
2125
- kk(
2126
- [
2127
- 'executor',
2128
- 'fan-out',
2129
- '--content',
2130
- prep,
2131
- '--target',
2132
- 'surfdash-pub',
2133
- '--out',
2618
+
2619
+ const intent = readSurfdashSendIntent(intentPath);
2620
+ if (intent.kind === 'invalid') {
2621
+ writeSurfdashUncertainReceipt(result, intentPath, runDate, undefined, undefined, intent.reason);
2622
+ return;
2623
+ }
2624
+
2625
+ let payloadSha256: string | undefined = currentIdentity?.payloadSha256;
2626
+ if (intent.kind === 'valid') {
2627
+ if (intent.value.runDate !== runDate) {
2628
+ writeSurfdashUncertainReceipt(
2629
+ result,
2630
+ intentPath,
2631
+ runDate,
2632
+ intent.value.payloadSha256,
2633
+ undefined,
2634
+ `send intent run date ${intent.value.runDate} does not match requested run date ${runDate}`,
2635
+ );
2636
+ return;
2637
+ }
2638
+ // An existing intent binds this dated post to the original bytes. A changed payload is
2639
+ // never allowed to overwrite the same slug after an interrupted remote send.
2640
+ if (isFile(prep)) payloadSha256 = surfdashPayloadSha256(prep);
2641
+ if (payloadSha256 !== undefined && payloadSha256 !== intent.value.payloadSha256) {
2642
+ writeSurfdashUncertainReceipt(
2643
+ result,
2644
+ intentPath,
2645
+ runDate,
2646
+ intent.value.payloadSha256,
2647
+ payloadSha256,
2648
+ 'prepared payload changed after the send intent',
2649
+ );
2650
+ return;
2651
+ }
2652
+ if (priorState === 'missing') {
2653
+ writeSurfdashUncertainReceipt(
2654
+ result,
2655
+ intentPath,
2656
+ runDate,
2657
+ intent.value.payloadSha256,
2658
+ payloadSha256,
2659
+ 'no publish result was recorded after the prior send intent',
2660
+ );
2661
+ return;
2662
+ }
2663
+ if (payloadSha256 === undefined) {
2664
+ writeSurfdashUncertainReceipt(
2665
+ result,
2666
+ intentPath,
2667
+ runDate,
2668
+ intent.value.payloadSha256,
2669
+ undefined,
2670
+ 'prepared payload is missing, so the original request cannot be replayed safely',
2671
+ );
2672
+ return;
2673
+ }
2674
+ } else {
2675
+ if (!isFile(prep)) failWith(1, `publish-surfdash: required input not found: ${prep}`);
2676
+ payloadSha256 = surfdashPayloadSha256(prep);
2677
+ const sendIntent: SurfdashSendIntent = {
2678
+ version: 1,
2679
+ target: 'surfdash-pub',
2680
+ runDate,
2681
+ payloadPath: prep,
2682
+ payloadSha256,
2683
+ createdAt: new Date().toISOString(),
2684
+ };
2685
+ sted('write', intentPath, () => writeJsonText(intentPath, sendIntent));
2686
+ }
2687
+
2688
+ if (requireLocaleValidation === 'true' && currentIdentity !== undefined) {
2689
+ const prepared = requireJson(prep, 'publish-surfdash');
2690
+ sted('write', preparedInput, () =>
2691
+ writeJqText(preparedInput, {
2692
+ ...prepared,
2693
+ options: {
2694
+ operation: 'publish-prepared',
2695
+ postPath: currentIdentity.postPath,
2696
+ runDate,
2697
+ expectedPostSha256: currentLocaleEvidence.localeDigests.zh,
2698
+ expectedLocaleFingerprint: currentIdentity.localeFingerprint,
2699
+ },
2700
+ }),
2701
+ );
2702
+ // `kk executor run` normally clears --out itself, but a discovery failure can return
2703
+ // before the executor owns that path. Remove any previous transport receipt here so a
2704
+ // stale success can never be promoted into this invocation's publish result.
2705
+ rmSync(transportResult, { force: true });
2706
+ kk(
2707
+ [
2708
+ 'executor',
2709
+ 'run',
2710
+ 'surfdash-pub',
2711
+ '--in',
2712
+ preparedInput,
2713
+ '--out',
2714
+ transportResult,
2715
+ ...pluginsArgs(pluginsPath),
2716
+ ],
2717
+ {
2718
+ unset: NESTED_SPUR_UNSET,
2719
+ env: { SURFDASH_LOCALE: 'zh' },
2720
+ soft: true,
2721
+ },
2722
+ );
2723
+ const transported = readJson(transportResult);
2724
+ const identityMetadata = {
2725
+ postPath: currentIdentity.postPath,
2726
+ payloadSha256: currentIdentity.payloadSha256,
2727
+ localeFingerprint: currentIdentity.localeFingerprint,
2728
+ runDate,
2729
+ };
2730
+ if (transported?.ok === true) {
2731
+ sted('write', result, () =>
2732
+ writeJsonText(result, {
2733
+ ok: true,
2734
+ summary: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
2735
+ targets: [
2736
+ {
2737
+ target: 'surfdash-pub',
2738
+ ok: true,
2739
+ status: 'published',
2740
+ ...(typeof transported.url === 'string' ? { url: transported.url } : {}),
2741
+ metadata: { ...metadataOf(transported), ...identityMetadata },
2742
+ },
2743
+ ],
2744
+ }),
2745
+ );
2746
+ } else if (transported?.ok === false && metadataOf(transported).deliveryState === 'not-sent') {
2747
+ sted('write', result, () =>
2748
+ writeJsonText(result, {
2749
+ ok: false,
2750
+ summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
2751
+ targets: [
2752
+ {
2753
+ target: 'surfdash-pub',
2754
+ ok: false,
2755
+ status: 'failed',
2756
+ error:
2757
+ typeof transported.error === 'string'
2758
+ ? transported.error
2759
+ : 'publisher rejected the prepared post',
2760
+ metadata: {
2761
+ ...metadataOf(transported),
2762
+ ...identityMetadata,
2763
+ deliveryState: 'not-sent',
2764
+ },
2765
+ },
2766
+ ],
2767
+ }),
2768
+ );
2769
+ } else {
2770
+ writeSurfdashUncertainReceipt(
2771
+ result,
2772
+ intentPath,
2773
+ runDate,
2774
+ payloadSha256,
2775
+ payloadSha256,
2776
+ typeof transported?.error === 'string'
2777
+ ? `publisher reported an ambiguous delivery failure: ${transported.error}`
2778
+ : 'no publish result was recorded after prepared dispatch',
2779
+ currentIdentity,
2780
+ );
2781
+ }
2782
+ } else {
2783
+ kk(
2784
+ [
2785
+ 'executor',
2786
+ 'fan-out',
2787
+ '--content',
2788
+ prep,
2789
+ '--target',
2790
+ 'surfdash-pub',
2791
+ '--out',
2792
+ result,
2793
+ ...pluginsArgs(pluginsPath),
2794
+ ],
2795
+ {
2796
+ unset: NESTED_SPUR_UNSET,
2797
+ env: { SURFDASH_LOCALE: 'zh' },
2798
+ soft: true,
2799
+ },
2800
+ );
2801
+ }
2802
+
2803
+ // A killed or unusable child can leave a real remote commit with no aggregate receipt. The
2804
+ // intent now makes that state explicit; the next invocation will refuse to dispatch again.
2805
+ const afterState = surfdashTargetState(readJson(result));
2806
+ if (afterState === 'missing' || afterState === 'uncertain') {
2807
+ const afterTarget = surfdashTargetEntries(readJson(result))[0];
2808
+ const afterError =
2809
+ afterState === 'uncertain' && afterTarget !== undefined && typeof afterTarget.error === 'string'
2810
+ ? `publisher reported an ambiguous delivery failure: ${afterTarget.error}`
2811
+ : 'no publish result was recorded after dispatch';
2812
+ writeSurfdashUncertainReceipt(
2134
2813
  result,
2135
- ...pluginsArgs(pluginsPath),
2136
- ],
2137
- {
2138
- unset: NESTED_SPUR_UNSET,
2139
- soft: true,
2140
- },
2141
- );
2814
+ intentPath,
2815
+ runDate,
2816
+ payloadSha256,
2817
+ payloadSha256,
2818
+ afterError,
2819
+ currentIdentity,
2820
+ );
2821
+ }
2142
2822
  console.log('publish-surfdash: surfdash target dispatched; classification happens after podcast publish');
2143
2823
  });
2144
2824
  }
2145
2825
 
2826
+ /**
2827
+ * Prepare the local zh post before locale scaffolding/translation. This stage deliberately stops
2828
+ * at the publisher's file-materialization seam; the normal `publish-surfdash` stage remains the
2829
+ * only operation allowed to invoke Surfdash's refine/CMS/git publishing path.
2830
+ */
2831
+ export function dailyPrepareSurfdash(args: string[]): void {
2832
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
2833
+ const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
2834
+ const input = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_content.json`);
2835
+ const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_result.json`);
2836
+ const transportResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare-transport_result.json`);
2837
+ timed(workDir, 'prepare-surfdash', () => {
2838
+ try {
2839
+ if (!isFile(prep)) failWith(1, `prepare-surfdash: required input not found: ${prep}`);
2840
+ const content = requireJson(prep, 'prepare-surfdash');
2841
+ sted('write', input, () => writeJqText(input, { ...content, options: { operation: 'prepare' } }));
2842
+ kk(
2843
+ [
2844
+ 'executor',
2845
+ 'run',
2846
+ 'surfdash-pub',
2847
+ '--in',
2848
+ input,
2849
+ '--out',
2850
+ transportResult,
2851
+ ...pluginsArgs(pluginsPath),
2852
+ ],
2853
+ {
2854
+ unset: NESTED_SPUR_UNSET,
2855
+ // The scheduled source post is always zh. Do not let a caller's inherited
2856
+ // locale select an en/ja materialization and then make the sibling gate
2857
+ // compare the wrong source tree.
2858
+ env: { SURFDASH_LOCALE: 'zh' },
2859
+ },
2860
+ );
2861
+ const prepared = readJson(transportResult);
2862
+ const postPath = metadataOf(prepared).postPath;
2863
+ if (prepared?.ok !== true || typeof postPath !== 'string') {
2864
+ failWith(1, `prepare-surfdash: publisher did not return a prepared zh post path: ${transportResult}`);
2865
+ }
2866
+ if (postPath === '' || !isFile(postPath)) {
2867
+ failWith(1, `prepare-surfdash: publisher did not return a prepared zh post path: ${result}`);
2868
+ }
2869
+ sted('write', result, () =>
2870
+ writeJqText(result, {
2871
+ ok: true,
2872
+ summary: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
2873
+ targets: [
2874
+ {
2875
+ target: 'surfdash-pub',
2876
+ ok: true,
2877
+ status: 'prepared',
2878
+ ...(typeof prepared?.url === 'string' ? { url: prepared.url } : {}),
2879
+ metadata: metadataOf(prepared),
2880
+ },
2881
+ ],
2882
+ }),
2883
+ );
2884
+ console.log(`prepare-surfdash: prepared zh post ${postPath}`);
2885
+ } catch (error: unknown) {
2886
+ if (error instanceof StageFailure && error.status === 127) throw error;
2887
+ const message = error instanceof Error ? error.message : String(error);
2888
+ sted('write', result, () =>
2889
+ writeJqText(result, {
2890
+ ok: false,
2891
+ summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
2892
+ targets: [{ target: 'surfdash-pub', ok: false, status: 'failed', error: message }],
2893
+ }),
2894
+ );
2895
+ console.error(message);
2896
+ }
2897
+ });
2898
+ }
2899
+
2146
2900
  /**
2147
2901
  * WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
2148
2902
  * draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
@@ -3063,22 +3817,29 @@ export function dailyShowNotes(args: string[]): void {
3063
3817
  * deterministic mechanics — `scaffold-locale` for both target locales — while the
3064
3818
  * workflow `translate` state's agent step owns the LLM translation of the drafts.
3065
3819
  * Writes one marker file per locale (zh post path + en/ja draft paths); empty
3066
- * marker = locale absent (callers skip). Reuses an existing on-disk draft as the
3067
- * cache across run retries — re-scaffolding would erase the agent's translation.
3820
+ * marker = locale absent (callers skip). Reuses an existing on-disk draft only when its
3821
+ * source-hash sidecar matches the current zh post — a changed source must invalidate the
3822
+ * translation cache before the next unattended run.
3068
3823
  */
3069
3824
  export function dailyTranslateLocalize(args: string[]): void {
3070
3825
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
3071
- // Same seam as show-notes (task 0137): `_17_publish_result.json` is written later by
3072
- // publish-classify, which depends on translate-sync. Reading it here always saw no zh
3073
- // path, so en/ja were never scaffolded (2026-10-08 dogfood).
3826
+ // The prepare result is available before translation; the final publish result is retained as
3827
+ // a compatibility fallback for manual recovery of pre-two-phase workspaces.
3828
+ const prepareResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_result.json`);
3074
3829
  const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
3075
3830
  const zhFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
3076
3831
  timed(workDir, 'translate-localize', () => {
3077
- const postPath = firstPostPath(readJson(result), 'surfdash-pub');
3832
+ const postPath =
3833
+ firstPostPath(readJson(prepareResult), 'surfdash-pub') || firstPostPath(readJson(result), 'surfdash-pub');
3078
3834
  const zh = postPath.endsWith('index.md') ? postPath : '';
3079
3835
  sted('write', zhFile, () => writeRaw(zhFile, zh));
3080
3836
  if (zh === '') {
3081
3837
  for (const target of ['en', 'ja'] as const) {
3838
+ const sourceHashFile = join(
3839
+ workDir,
3840
+ '31-publish',
3841
+ `${runDate}_20_translate-localize_${target}-source-sha256.txt`,
3842
+ );
3082
3843
  sted(
3083
3844
  'write',
3084
3845
  join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
@@ -3088,18 +3849,37 @@ export function dailyTranslateLocalize(args: string[]): void {
3088
3849
  '',
3089
3850
  ),
3090
3851
  );
3852
+ sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
3091
3853
  }
3092
3854
  console.log('no zh post path — skip localize');
3093
3855
  return;
3094
3856
  }
3857
+ let sourceHash: string;
3858
+ try {
3859
+ sourceHash = createHash('sha256').update(readFileSync(zh)).digest('hex');
3860
+ } catch (error: unknown) {
3861
+ failWith(
3862
+ 1,
3863
+ `translate-localize: cannot fingerprint zh source ${zh}: ${error instanceof Error ? error.message : String(error)}`,
3864
+ );
3865
+ }
3095
3866
  for (const target of ['en', 'ja'] as const) {
3096
3867
  const markerFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`);
3097
3868
  const existing = zh.replace('/zh/', `/${target}/`);
3098
- if (isFile(existing)) {
3869
+ const sourceHashFile = join(
3870
+ workDir,
3871
+ '31-publish',
3872
+ `${runDate}_20_translate-localize_${target}-source-sha256.txt`,
3873
+ );
3874
+ const cachedSourceHash = isFile(sourceHashFile) ? readFileSync(sourceHashFile, 'utf-8').trim() : '';
3875
+ if (isFile(existing) && cachedSourceHash === sourceHash) {
3099
3876
  sted('write', markerFile, () => writeRaw(markerFile, existing));
3100
3877
  console.log(`${target} draft cached: ${existing}`);
3101
3878
  continue;
3102
3879
  }
3880
+ if (isFile(existing)) {
3881
+ console.log(`${target} draft invalidated: zh source fingerprint changed or is missing`);
3882
+ }
3103
3883
  const scaffold = join(workDir, '31-publish', `${runDate}_20_translate-localize_scaffold-${target}.json`);
3104
3884
  const scaffoldResult = join(
3105
3885
  workDir,
@@ -3107,20 +3887,236 @@ export function dailyTranslateLocalize(args: string[]): void {
3107
3887
  `${runDate}_20_translate-localize_scaffold-${target}-result.json`,
3108
3888
  );
3109
3889
  sted('write', scaffold, () =>
3110
- writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target } }),
3890
+ writeJqText(scaffold, {
3891
+ body: '',
3892
+ options: {
3893
+ operation: 'scaffold-locale',
3894
+ postPath: zh,
3895
+ target,
3896
+ ...(isFile(existing) ? { force: true } : {}),
3897
+ },
3898
+ }),
3111
3899
  );
3112
- kk([
3113
- 'executor',
3114
- 'run',
3115
- 'surfdash-pub',
3116
- '--in',
3117
- scaffold,
3118
- '--out',
3119
- scaffoldResult,
3120
- ...pluginsArgs(pluginsPath),
3121
- ]);
3122
- const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
3123
- sted('write', markerFile, () => writeRaw(markerFile, typeof draftPath === 'string' ? draftPath : ''));
3900
+ try {
3901
+ kk([
3902
+ 'executor',
3903
+ 'run',
3904
+ 'surfdash-pub',
3905
+ '--in',
3906
+ scaffold,
3907
+ '--out',
3908
+ scaffoldResult,
3909
+ ...pluginsArgs(pluginsPath),
3910
+ ]);
3911
+ const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
3912
+ const draft = typeof draftPath === 'string' ? draftPath : '';
3913
+ sted('write', markerFile, () => writeRaw(markerFile, draft));
3914
+ if (draft !== '') {
3915
+ sted('write', sourceHashFile, () => writeRaw(sourceHashFile, sourceHash));
3916
+ } else {
3917
+ sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
3918
+ }
3919
+ } catch (error: unknown) {
3920
+ if (error instanceof StageFailure && error.status === 127) throw error;
3921
+ const message = error instanceof Error ? error.message : String(error);
3922
+ sted('write', markerFile, () => writeRaw(markerFile, ''));
3923
+ sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
3924
+ console.error(`translate-localize: ${target} scaffold failed: ${message}`);
3925
+ }
3926
+ }
3927
+ });
3928
+ }
3929
+
3930
+ /**
3931
+ * Run Surfdash's own refine command over the complete locale set before the validation fingerprint
3932
+ * is written. This is deliberately a separate local-only phase: the final publish FSM also runs
3933
+ * refine, but it must be idempotent by the time any CMS/git side effect is reachable.
3934
+ */
3935
+ function normalizeTranslatedLocales(zhPostPath: string, failUnsafe = true): void {
3936
+ for (const locale of ['zh', 'en', 'ja'] as const) {
3937
+ const sibling = locale === 'zh' ? zhPostPath : zhPostPath.replace('/zh/', `/${locale}/`);
3938
+ if (!isFile(sibling)) continue;
3939
+ const raw = readFileSync(sibling, 'utf-8');
3940
+ const { markdown, remaining } = normalizeSeriesNames(raw, locale);
3941
+ if (failUnsafe && remaining.length > 0) {
3942
+ failWith(1, `series-name normalization left unsafe names in ${locale}: ${remaining.join(' / ')}`);
3943
+ }
3944
+ if (markdown !== raw) sted('write', sibling, () => writeRaw(sibling, markdown));
3945
+ }
3946
+ }
3947
+
3948
+ export function dailySurfdashRefine(args: string[]): void {
3949
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
3950
+ const marker = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
3951
+ const input = join(workDir, '31-publish', `${runDate}_24_surfdash-refine_content.json`);
3952
+ const result = join(workDir, '31-publish', `${runDate}_24_surfdash-refine_result.json`);
3953
+ timed(workDir, 'surfdash-refine', () => {
3954
+ try {
3955
+ const zhPostPath = isFile(marker) ? readFileSync(marker, 'utf-8').trim() : '';
3956
+ if (zhPostPath === '' || !zhPostPath.startsWith('/') || !isFile(zhPostPath)) {
3957
+ failWith(1, `surfdash-refine: zh marker must name an existing post: ${marker}`);
3958
+ }
3959
+ validateLocaleMarkers(workDir, runDate, zhPostPath, 'surfdash-refine');
3960
+ // The validator is intentionally read-only. Repair known series spellings before
3961
+ // site-owned refine computes managed fields, otherwise the final publisher would
3962
+ // rewrite the refined bytes after the locale fingerprint was admitted.
3963
+ normalizeTranslatedLocales(zhPostPath);
3964
+ sted('write', input, () =>
3965
+ writeJqText(input, {
3966
+ body: '',
3967
+ format: 'markdown',
3968
+ options: { operation: 'refine-prepared', postPath: zhPostPath, runDate },
3969
+ }),
3970
+ );
3971
+ rmSync(result, { force: true });
3972
+ kk(['executor', 'run', 'surfdash-pub', '--in', input, '--out', result, ...pluginsArgs(pluginsPath)], {
3973
+ unset: NESTED_SPUR_UNSET,
3974
+ env: { SURFDASH_LOCALE: 'zh' },
3975
+ });
3976
+ const refined = readJson(result);
3977
+ if (refined?.ok !== true) {
3978
+ failWith(1, `surfdash-refine: publisher did not return a successful refine receipt: ${result}`);
3979
+ }
3980
+ sted('write', result, () =>
3981
+ writeJqText(result, {
3982
+ ok: true,
3983
+ summary: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
3984
+ targets: [
3985
+ {
3986
+ target: 'surfdash-pub',
3987
+ ok: true,
3988
+ status: 'refined',
3989
+ metadata: metadataOf(refined),
3990
+ },
3991
+ ],
3992
+ }),
3993
+ );
3994
+ console.log(`surfdash-refine: site-owned refine completed for ${zhPostPath}`);
3995
+ } catch (error: unknown) {
3996
+ if (error instanceof StageFailure && error.status === 127) throw error;
3997
+ const message = error instanceof Error ? error.message : String(error);
3998
+ sted('write', result, () =>
3999
+ writeJqText(result, {
4000
+ ok: false,
4001
+ summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
4002
+ targets: [{ target: 'surfdash-pub', ok: false, status: 'failed', error: message }],
4003
+ }),
4004
+ );
4005
+ console.error(`surfdash-refine: ${message}`);
4006
+ }
4007
+ });
4008
+ }
4009
+
4010
+ /** Validates locale siblings without contacting Surfdash or mutating their bytes. */
4011
+ function validateTranslatedLocales(zhPostPath: string, step: 'translate-validate' | 'translate-sync'): void {
4012
+ const zhPoints = countKeyPoints(zhPostPath);
4013
+ for (const locale of ['zh', 'en', 'ja'] as const) {
4014
+ const sibling = locale === 'zh' ? zhPostPath : zhPostPath.replace('/zh/', `/${locale}/`);
4015
+ if (!isFile(sibling)) {
4016
+ if (locale !== 'zh' && step === 'translate-validate') {
4017
+ failWith(1, `${step}: ${locale} draft is missing: ${sibling}`);
4018
+ }
4019
+ continue;
4020
+ }
4021
+ const problems: string[] = [];
4022
+ const raw = readFileSync(sibling, 'utf-8');
4023
+ const { markdown, remaining } = normalizeSeriesNames(raw, locale);
4024
+ if (markdown !== raw) problems.push('series-name normalization is pending; run the refine preparation first');
4025
+ if (remaining.length > 0) {
4026
+ problems.push(`retired series names left after rewrite: ${remaining.join(' / ')}`);
4027
+ }
4028
+ const items = readKeyPointItems(sibling);
4029
+ if (step === 'translate-validate') {
4030
+ if (readFrontmatterScalar(sibling, 'refined') !== 'true') {
4031
+ problems.push('site-owned refine has not completed (refined: true is required)');
4032
+ }
4033
+ if (items === undefined || items.length === 0) {
4034
+ problems.push('key_points missing or malformed');
4035
+ } else {
4036
+ const malformed = items.filter((item) => {
4037
+ const separator = item.indexOf(' — ');
4038
+ return separator <= 0 || item.slice(separator + 3).trim() === '';
4039
+ });
4040
+ if (malformed.length > 0) problems.push('key_points missing or malformed');
4041
+ }
4042
+ }
4043
+ if (locale !== 'zh') {
4044
+ if (readFrontmatterScalar(sibling, 'description') === undefined) {
4045
+ problems.push('description missing/empty');
4046
+ }
4047
+ const points = countKeyPoints(sibling);
4048
+ if (zhPoints !== undefined && points !== undefined && points !== zhPoints) {
4049
+ problems.push(`key_points count ${points} != zh ${zhPoints} (spliced or merged items)`);
4050
+ }
4051
+ // The scheduled path must not let an untranslated scaffold reach the strict CMS sync.
4052
+ if (step === 'translate-validate' && readFrontmatterScalar(sibling, 'draft') !== 'false') {
4053
+ problems.push('draft is still true — locale was scaffolded but not translated');
4054
+ }
4055
+ // Label discipline (same 2026-10-23 audit): a key_points item is `Label — sentence`
4056
+ // and the label is a short phrase — ≤14 chars for ja, ≤28 for en — never a
4057
+ // sentence and never an ellipsis-truncated fragment.
4058
+ if (items !== undefined) {
4059
+ const cap = locale === 'en' ? 28 : 14;
4060
+ const overLimit = items.filter((item) => {
4061
+ const separator = item.indexOf(' — ');
4062
+ if (separator < 0) return false;
4063
+ const label = item.slice(0, separator);
4064
+ return label.includes('…') || Array.from(label).length > cap;
4065
+ });
4066
+ if (overLimit.length > 0) {
4067
+ problems.push(
4068
+ `key_points label over ${cap} chars or …-truncated: ${overLimit.length} of ${items.length} items`,
4069
+ );
4070
+ }
4071
+ }
4072
+ }
4073
+ if (problems.length > 0) {
4074
+ failWith(
4075
+ 1,
4076
+ `${step}: ${locale} draft failed the pre-sync structural check (${problems.join('; ')}) — repair ${sibling} per the translate stage contract before syncing`,
4077
+ );
4078
+ }
4079
+ }
4080
+ }
4081
+
4082
+ /** Local pre-CMS gate for the scheduled two-phase Surfdash flow. */
4083
+ export function dailyTranslateValidate(args: string[]): void {
4084
+ const [workDir = '', runDate = '', _pluginsPath = '', zhPathFile = ''] = args;
4085
+ const validationResult = join(workDir, '31-publish', `${runDate}_24_translate-validate_result.json`);
4086
+ timed(workDir, 'translate-validate', () => {
4087
+ const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8').trim() : '';
4088
+ try {
4089
+ if (zhPathFile.endsWith('.md') || zhPostPath === '' || !zhPostPath.startsWith('/') || !isFile(zhPostPath)) {
4090
+ failWith(
4091
+ 1,
4092
+ `translate-validate: 4th argument must name an existing zh marker file (${runDate}_20_translate-localize_zh-post-path.txt)`,
4093
+ );
4094
+ }
4095
+ validateLocaleMarkers(workDir, runDate, zhPostPath, 'translate-validate');
4096
+ validateTranslatedLocales(zhPostPath, 'translate-validate');
4097
+ const evidence = surfdashLocaleValidationEvidence(zhPostPath, runDate);
4098
+ if (!evidence.ok) failWith(1, `translate-validate: ${evidence.reason}`);
4099
+ sted('write', validationResult, () =>
4100
+ writeJqText(validationResult, {
4101
+ ok: true,
4102
+ target: 'surfdash-pub',
4103
+ status: 'validated',
4104
+ metadata: evidence.evidence,
4105
+ }),
4106
+ );
4107
+ console.log(`translate-validate: locale files ready for final Surfdash publish (${zhPostPath})`);
4108
+ } catch (error: unknown) {
4109
+ const message = error instanceof Error ? error.message : String(error);
4110
+ sted('write', validationResult, () =>
4111
+ writeJqText(validationResult, {
4112
+ ok: false,
4113
+ target: 'surfdash-pub',
4114
+ status: 'rejected',
4115
+ error: message,
4116
+ metadata: { postPath: zhPostPath },
4117
+ }),
4118
+ );
4119
+ console.error(`translate-validate: ${message}`);
3124
4120
  }
3125
4121
  });
3126
4122
  }
@@ -3146,58 +4142,11 @@ export function dailyTranslateSync(args: string[]): void {
3146
4142
  `translate-sync: 4th argument must be the zh marker file (${runDate}_20_translate-localize_zh-post-path.txt), not the post itself — '${zhPathFile}' holds ${zhPostPath.includes('\n') ? 'a multi-line document' : `'${zhPostPath}'`}`,
3147
4143
  );
3148
4144
  }
3149
- // The 2026-10-23 translate run published spliced en/ja description/key_points; the locale
3150
- // channel has no judge, so gate the structural minimum here before the CMS sync:
3151
- // a summary must exist and the key_points count must match the zh post (splices merge items).
3152
- // The same run also invented retired series names (毎日AIブレンド, "Daily AI Medley",
3153
- // zh-name listen links inside en/ja): normalizeSeriesNames rewrites the known bad set per
3154
- // locale deterministically (URLs untouched) and any leftover it cannot make safe fails the
3155
- // sync. The zh post rides the same sync, so it gets the same series-name pass.
3156
- const zhPoints = countKeyPoints(zhPostPath);
3157
- for (const locale of ['zh', 'en', 'ja'] as const) {
3158
- const sibling = locale === 'zh' ? zhPostPath : zhPostPath.replace('/zh/', `/${locale}/`);
3159
- if (!isFile(sibling)) continue;
3160
- const problems: string[] = [];
3161
- const raw = readFileSync(sibling, 'utf-8');
3162
- const { markdown, remaining } = normalizeSeriesNames(raw, locale);
3163
- if (markdown !== raw) sted('write', sibling, () => writeRaw(sibling, markdown));
3164
- if (remaining.length > 0) {
3165
- problems.push(`retired series names left after rewrite: ${remaining.join(' / ')}`);
3166
- }
3167
- if (locale !== 'zh') {
3168
- if (readFrontmatterScalar(sibling, 'description') === undefined) {
3169
- problems.push('description missing/empty');
3170
- }
3171
- const points = countKeyPoints(sibling);
3172
- if (zhPoints !== undefined && points !== undefined && points !== zhPoints) {
3173
- problems.push(`key_points count ${points} != zh ${zhPoints} (spliced or merged items)`);
3174
- }
3175
- // Label discipline (same 2026-10-23 audit): a key_points item is `Label — sentence`
3176
- // and the label is a short phrase — ≤14 chars for ja, ≤28 for en — never a
3177
- // sentence and never an ellipsis-truncated fragment.
3178
- const items = readKeyPointItems(sibling);
3179
- if (items !== undefined) {
3180
- const cap = locale === 'en' ? 28 : 14;
3181
- const overLimit = items.filter((item) => {
3182
- const sep = item.indexOf(' — ');
3183
- if (sep < 0) return false;
3184
- const label = item.slice(0, sep);
3185
- return label.includes('…') || Array.from(label).length > cap;
3186
- });
3187
- if (overLimit.length > 0) {
3188
- problems.push(
3189
- `key_points label over ${cap} chars or …-truncated: ${overLimit.length} of ${items.length} items`,
3190
- );
3191
- }
3192
- }
3193
- }
3194
- if (problems.length > 0) {
3195
- failWith(
3196
- 1,
3197
- `translate-sync: ${locale} draft failed the pre-sync structural check (${problems.join('; ')}) — repair ${sibling} per the translate stage contract before syncing`,
3198
- );
3199
- }
3200
- }
4145
+ // Preserve the legacy explicit sync repair behavior before the shared read-only gate.
4146
+ normalizeTranslatedLocales(zhPostPath, false);
4147
+ // The 2026-10-23 locale structural gate is shared with the scheduled two-phase validator.
4148
+ // This explicit recovery command still performs the real CMS sync after validation.
4149
+ validateTranslatedLocales(zhPostPath, 'translate-sync');
3201
4150
  sted('write', sync, () =>
3202
4151
  writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
3203
4152
  );