@gobing-ai/knowledge-kit 0.1.9 → 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 (72) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +132 -25
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +4 -0
  5. package/plugins/generations/core-facts-gen/dist/index.js +4 -0
  6. package/plugins/generations/daily-article-gen/dist/index.js +4 -0
  7. package/plugins/generations/dailynews-gen/dist/index.js +4 -0
  8. package/plugins/generations/episode-plan-gen/dist/index.js +102 -1
  9. package/plugins/generations/episode-plan-gen/src/index.ts +2 -4
  10. package/plugins/generations/gemini-tts/dist/index.js +4 -0
  11. package/plugins/generations/image-gen/dist/index.js +4 -0
  12. package/plugins/generations/news-report-gen/dist/index.js +4 -0
  13. package/plugins/generations/voice-gen/dist/index.js +4 -0
  14. package/plugins/generations/voxcpm-tts/dist/index.js +4 -0
  15. package/plugins/ingestions/aihot-ingest/dist/index.js +4 -0
  16. package/plugins/ingestions/horizon-ingest/dist/index.js +4 -0
  17. package/plugins/ingestions/last30days-ingest/dist/index.js +11 -3
  18. package/plugins/ingestions/last30days-ingest/src/index.ts +14 -3
  19. package/plugins/ingestions/web-search/dist/index.js +4 -0
  20. package/plugins/kk/plugin.json +1 -1
  21. package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
  22. package/plugins/kk/scripts/daily-stages.ts +1083 -49
  23. package/plugins/kk/scripts/kk-workflow-stages.ts +76 -10
  24. package/plugins/kk/scripts/safety-artifact-check.ts +235 -0
  25. package/plugins/kk/scripts/sentences.ts +134 -0
  26. package/plugins/kk/scripts/series-names.ts +124 -0
  27. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +194 -113
  28. package/plugins/kk/workflows/kk-daily-safety.yaml +160 -36
  29. package/plugins/publishings/bilibili-pub/dist/index.js +6 -1
  30. package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
  31. package/plugins/publishings/cnblogs-pub/dist/index.js +6 -1
  32. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
  33. package/plugins/publishings/csdn-pub/dist/index.js +6 -1
  34. package/plugins/publishings/csdn-pub/dist/profile-check.js +6 -1
  35. package/plugins/publishings/emdash-pub/dist/index.js +4 -0
  36. package/plugins/publishings/infoq-pub/dist/index.js +6 -1
  37. package/plugins/publishings/infoq-pub/dist/profile-check.js +6 -1
  38. package/plugins/publishings/jike-pub/dist/index.js +6 -1
  39. package/plugins/publishings/jike-pub/dist/profile-check.js +6 -1
  40. package/plugins/publishings/juejin-pub/dist/index.js +6 -1
  41. package/plugins/publishings/juejin-pub/dist/profile-check.js +6 -1
  42. package/plugins/publishings/medium-pub/dist/index.js +4 -0
  43. package/plugins/publishings/podcast-pub/dist/index.js +151 -16
  44. package/plugins/publishings/podcast-pub/src/index.ts +62 -5
  45. package/plugins/publishings/podcast-pub/src/seo.ts +2 -4
  46. package/plugins/publishings/podcast-pub/src/show-notes.ts +3 -8
  47. package/plugins/publishings/qiita-pub/dist/index.js +4 -0
  48. package/plugins/publishings/shipinhao-pub/dist/index.js +6 -1
  49. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
  50. package/plugins/publishings/sspai-pub/dist/index.js +6 -1
  51. package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
  52. package/plugins/publishings/substack-pub/dist/index.js +6 -1
  53. package/plugins/publishings/substack-pub/dist/profile-check.js +6 -1
  54. package/plugins/publishings/surfdash-pub/dist/index.js +691 -63
  55. package/plugins/publishings/surfdash-pub/src/digest-check.ts +284 -0
  56. package/plugins/publishings/surfdash-pub/src/index.ts +656 -84
  57. package/plugins/publishings/surfdash-pub/src/patch.ts +44 -7
  58. package/plugins/publishings/threads-pub/dist/index.js +4 -0
  59. package/plugins/publishings/wechatmp-pub/dist/index.js +937 -98
  60. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +64 -3
  61. package/plugins/publishings/wechatmp-pub/src/flow.ts +1125 -85
  62. package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
  63. package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
  64. package/plugins/publishings/weibo-pub/dist/index.js +6 -1
  65. package/plugins/publishings/weibo-pub/dist/profile-check.js +6 -1
  66. package/plugins/publishings/x-pub/dist/index.js +6 -1
  67. package/plugins/publishings/x-pub/dist/profile-check.js +6 -1
  68. package/plugins/publishings/xhs-pub/dist/index.js +6 -1
  69. package/plugins/publishings/xhs-pub/dist/profile-check.js +6 -1
  70. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +6 -1
  71. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +2 -1
  72. package/plugins/publishings/zenn-pub/dist/index.js +4 -0
@@ -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,
@@ -114,6 +115,8 @@ import {
114
115
  type XhsCard,
115
116
  xhsPlan,
116
117
  } from './kk-workflow-stages.ts';
118
+ import { splitSentences } from './sentences.ts';
119
+ import { normalizeSeriesNames } from './series-names.ts';
117
120
 
118
121
  export function dailyQcContent(args: string[]): void {
119
122
  const [
@@ -133,11 +136,42 @@ export function dailyQcContent(args: string[]): void {
133
136
  const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
134
137
  const exclusions = join(workDir, '21-plan', `${runDate}_04_quality-control-content_exclusions.json`);
135
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;
136
151
  timed(workDir, 'quality-control-content', () => {
137
- 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) {
138
157
  console.log(`candidates cached: ${candidates}`);
139
158
  return;
140
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
+ }
141
175
  sted('write', annotated, () => writeJqText(annotated, metadataOf(readJson(planContent)).docs ?? null));
142
176
  kk(
143
177
  [
@@ -162,6 +196,9 @@ export function dailyQcContent(args: string[]): void {
162
196
  },
163
197
  },
164
198
  );
199
+ if (!isFile(candidates)) failWith(1, `quality-control-content: ${candidates} not written`);
200
+ sted('write', cachePath, () => writeStageCache(cachePath, fingerprint));
201
+ recomputed = true;
165
202
  });
166
203
  // Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
167
204
  // candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
@@ -210,7 +247,48 @@ export function dailyQcContent(args: string[]): void {
210
247
  // already produced. 2026-10-22 cleared the floor on a 4-item pool (21.0 min estimated) that the
211
248
  // topic boundary then cut to 3 (14.4 min), so the run paid for a full synthesis the measured
212
249
  // gate refused. Covered here it also re-runs on a cached-rerun, which the timed block skipped.
213
- 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;
214
292
  }
215
293
 
216
294
  /**
@@ -259,18 +337,26 @@ function applyFloorGate(
259
337
  runDate: string,
260
338
  minMinutes: string,
261
339
  vdriver: string,
340
+ clearStaleRefusal = false,
262
341
  ): void {
342
+ const refusal = join(workDir, '21-plan', `${runDate}_04_quality-control-content_floor-refusal.json`);
263
343
  const floor = Number(minMinutes);
264
- 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
+ }
265
348
  const content = readJson(candidates);
266
349
  const docs = metadataOf(content).docs;
267
- 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
+ }
268
354
  const { minutes: estimatedMinutes, seconds: estimatedSeconds, chars } = estimateSpokenMinutes(docs, vdriver);
269
355
  if (estimatedSeconds >= floor * 60) {
356
+ if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
270
357
  console.log(`floor gate: estimated ${estimatedMinutes} min from ${chars} chars — above the ${floor} min floor`);
271
358
  return;
272
359
  }
273
- const refusal = join(workDir, '21-plan', `${runDate}_04_quality-control-content_floor-refusal.json`);
274
360
  const ids = docs.map((doc) => String((doc as Record<string, unknown> | null)?.id ?? ''));
275
361
  sted('write', refusal, () =>
276
362
  writeJqText(refusal, {
@@ -1509,11 +1595,28 @@ export function dailyPlan(args: string[]): void {
1509
1595
  // translation. Voice1 omits the arg and still materializes the file (its translate node has no
1510
1596
  // skip guard and overwrites it).
1511
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
+ });
1512
1605
  timed(workDir, 'plan', () => {
1513
- if (deferDocs ? isFile(planContent) : isFile(planDocs)) {
1606
+ if (stageCacheMatches(target, targetCache, fingerprint)) {
1514
1607
  console.log(`plan cached: ${deferDocs ? planContent : planDocs}`);
1515
1608
  return;
1516
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
+ }
1517
1620
  kk(
1518
1621
  ['executor', 'run', 'episode-plan-gen', '--in', blended, '--out', planContent, ...pluginsArgs(pluginsPath)],
1519
1622
  {
@@ -1522,9 +1625,17 @@ export function dailyPlan(args: string[]): void {
1522
1625
  );
1523
1626
  const docs = metadataOf(readJson(planContent)).docs;
1524
1627
  if (!Array.isArray(docs) || docs.length === 0) failWith(1, 'plan: content.metadata.docs missing or empty');
1525
- if (deferDocs) return;
1526
- sted('write', planDocs, () => writeJsonText(planDocs, docs));
1527
- 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));
1528
1639
  });
1529
1640
  }
1530
1641
 
@@ -1902,6 +2013,56 @@ function readFrontmatterScalar(filePath: string, key: string): string | undefine
1902
2013
  return value.length > 0 ? value : undefined;
1903
2014
  }
1904
2015
 
2016
+ /** The `key_points` item texts of a Surfing post's frontmatter; undefined when absent/unreadable. */
2017
+ export function readKeyPointItems(filePath: string): string[] | undefined {
2018
+ if (!isFile(filePath)) return undefined;
2019
+ let raw: string;
2020
+ try {
2021
+ raw = readFileSync(filePath, 'utf-8');
2022
+ } catch {
2023
+ return undefined;
2024
+ }
2025
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1];
2026
+ if (!block) return undefined;
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.
2030
+ const head = /^key_points:\n/m.exec(block);
2031
+ if (!head) return undefined;
2032
+ const body = block.slice(head.index + head[0].length);
2033
+ const endMatch = /^\S/m.exec(body);
2034
+ const list = endMatch ? body.slice(0, endMatch.index) : body;
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;
2059
+ }
2060
+
2061
+ /** Count `key_points` list items in a Surfing post's frontmatter; undefined when absent/unreadable. */
2062
+ export function countKeyPoints(filePath: string): number | undefined {
2063
+ return readKeyPointItems(filePath)?.length;
2064
+ }
2065
+
1905
2066
  function firstRefinedTitle(parsed: Record<string, unknown> | null): string {
1906
2067
  const targets = parsed?.targets;
1907
2068
  if (!Array.isArray(targets)) return '';
@@ -1940,7 +2101,7 @@ function planItemsForPodcast(workDir: string, runDate: string): Array<{ title: s
1940
2101
  ? String((meta as Record<string, unknown>).brief).trim()
1941
2102
  : '';
1942
2103
  const body = typeof record.body === 'string' ? record.body.trim() : '';
1943
- const brief = metaBrief || (body.split(/(?<=[。!?])\s*/)[0] ?? '').trim();
2104
+ const brief = metaBrief || (splitSentences(body, { cjkOnly: true })[0] ?? '').trim();
1944
2105
  items.push(brief.length > 0 ? { title, brief } : { title });
1945
2106
  }
1946
2107
  return items;
@@ -2080,36 +2241,662 @@ export function dailyPublishPrep(args: string[]): void {
2080
2241
  });
2081
2242
  }
2082
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
+
2083
2518
  export function dailyPublishSurfdash(args: string[]): void {
2084
- const [workDir = '', runDate = '', pluginsPath = ''] = args;
2519
+ const [workDir = '', runDate = '', pluginsPath = '', requireLocaleValidation = 'false'] = args;
2085
2520
  const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
2086
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`);
2087
2527
  timed(workDir, 'publish-surfdash', () => {
2088
- 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
+ }
2089
2615
  console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
2090
2616
  return;
2091
2617
  }
2092
- kk(
2093
- [
2094
- 'executor',
2095
- 'fan-out',
2096
- '--content',
2097
- prep,
2098
- '--target',
2099
- 'surfdash-pub',
2100
- '--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(
2101
2813
  result,
2102
- ...pluginsArgs(pluginsPath),
2103
- ],
2104
- {
2105
- unset: NESTED_SPUR_UNSET,
2106
- soft: true,
2107
- },
2108
- );
2814
+ intentPath,
2815
+ runDate,
2816
+ payloadSha256,
2817
+ payloadSha256,
2818
+ afterError,
2819
+ currentIdentity,
2820
+ );
2821
+ }
2109
2822
  console.log('publish-surfdash: surfdash target dispatched; classification happens after podcast publish');
2110
2823
  });
2111
2824
  }
2112
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
+
2113
2900
  /**
2114
2901
  * WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
2115
2902
  * draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
@@ -3030,22 +3817,29 @@ export function dailyShowNotes(args: string[]): void {
3030
3817
  * deterministic mechanics — `scaffold-locale` for both target locales — while the
3031
3818
  * workflow `translate` state's agent step owns the LLM translation of the drafts.
3032
3819
  * Writes one marker file per locale (zh post path + en/ja draft paths); empty
3033
- * marker = locale absent (callers skip). Reuses an existing on-disk draft as the
3034
- * 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.
3035
3823
  */
3036
3824
  export function dailyTranslateLocalize(args: string[]): void {
3037
3825
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
3038
- // Same seam as show-notes (task 0137): `_17_publish_result.json` is written later by
3039
- // publish-classify, which depends on translate-sync. Reading it here always saw no zh
3040
- // 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`);
3041
3829
  const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
3042
3830
  const zhFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
3043
3831
  timed(workDir, 'translate-localize', () => {
3044
- const postPath = firstPostPath(readJson(result), 'surfdash-pub');
3832
+ const postPath =
3833
+ firstPostPath(readJson(prepareResult), 'surfdash-pub') || firstPostPath(readJson(result), 'surfdash-pub');
3045
3834
  const zh = postPath.endsWith('index.md') ? postPath : '';
3046
3835
  sted('write', zhFile, () => writeRaw(zhFile, zh));
3047
3836
  if (zh === '') {
3048
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
+ );
3049
3843
  sted(
3050
3844
  'write',
3051
3845
  join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
@@ -3055,18 +3849,37 @@ export function dailyTranslateLocalize(args: string[]): void {
3055
3849
  '',
3056
3850
  ),
3057
3851
  );
3852
+ sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
3058
3853
  }
3059
3854
  console.log('no zh post path — skip localize');
3060
3855
  return;
3061
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
+ }
3062
3866
  for (const target of ['en', 'ja'] as const) {
3063
3867
  const markerFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`);
3064
3868
  const existing = zh.replace('/zh/', `/${target}/`);
3065
- 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) {
3066
3876
  sted('write', markerFile, () => writeRaw(markerFile, existing));
3067
3877
  console.log(`${target} draft cached: ${existing}`);
3068
3878
  continue;
3069
3879
  }
3880
+ if (isFile(existing)) {
3881
+ console.log(`${target} draft invalidated: zh source fingerprint changed or is missing`);
3882
+ }
3070
3883
  const scaffold = join(workDir, '31-publish', `${runDate}_20_translate-localize_scaffold-${target}.json`);
3071
3884
  const scaffoldResult = join(
3072
3885
  workDir,
@@ -3074,20 +3887,236 @@ export function dailyTranslateLocalize(args: string[]): void {
3074
3887
  `${runDate}_20_translate-localize_scaffold-${target}-result.json`,
3075
3888
  );
3076
3889
  sted('write', scaffold, () =>
3077
- 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
+ }),
3078
3899
  );
3079
- kk([
3080
- 'executor',
3081
- 'run',
3082
- 'surfdash-pub',
3083
- '--in',
3084
- scaffold,
3085
- '--out',
3086
- scaffoldResult,
3087
- ...pluginsArgs(pluginsPath),
3088
- ]);
3089
- const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
3090
- 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}`);
3091
4120
  }
3092
4121
  });
3093
4122
  }
@@ -3113,6 +4142,11 @@ export function dailyTranslateSync(args: string[]): void {
3113
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}'`}`,
3114
4143
  );
3115
4144
  }
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');
3116
4150
  sted('write', sync, () =>
3117
4151
  writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
3118
4152
  );