@gobing-ai/knowledge-kit 0.1.10 → 0.1.12

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 (158) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +2478 -1737
  3. package/package.json +1 -1
  4. package/plugins/generations/image-gen/README.md +108 -0
  5. package/plugins/generations/image-gen/assets/sample/cover__clean-webapp-ui.jpg +0 -0
  6. package/plugins/generations/image-gen/assets/sample/cover__cute.jpg +0 -0
  7. package/plugins/generations/image-gen/assets/sample/cover__daily-news.jpg +0 -0
  8. package/plugins/generations/image-gen/assets/sample/cover__editorial.jpg +0 -0
  9. package/plugins/generations/image-gen/assets/sample/cover__fresh.jpg +0 -0
  10. package/plugins/generations/image-gen/assets/sample/cover__minimalist.jpg +0 -0
  11. package/plugins/generations/image-gen/assets/sample/cover__photorealistic.jpg +0 -0
  12. package/plugins/generations/image-gen/assets/sample/cover__sketch.jpg +0 -0
  13. package/plugins/generations/image-gen/assets/sample/cover__technical-diagram.jpg +0 -0
  14. package/plugins/generations/image-gen/assets/sample/cover__vibrant.jpg +0 -0
  15. package/plugins/generations/image-gen/assets/sample/cover__warm.jpg +0 -0
  16. package/plugins/generations/image-gen/assets/sample/cover__xhs-knowledge.jpg +0 -0
  17. package/plugins/generations/image-gen/assets/sample/daily-news-cover__clean-webapp-ui.jpg +0 -0
  18. package/plugins/generations/image-gen/assets/sample/daily-news-cover__cute.jpg +0 -0
  19. package/plugins/generations/image-gen/assets/sample/daily-news-cover__daily-news.jpg +0 -0
  20. package/plugins/generations/image-gen/assets/sample/daily-news-cover__editorial.jpg +0 -0
  21. package/plugins/generations/image-gen/assets/sample/daily-news-cover__fresh.jpg +0 -0
  22. package/plugins/generations/image-gen/assets/sample/daily-news-cover__minimalist.jpg +0 -0
  23. package/plugins/generations/image-gen/assets/sample/daily-news-cover__photorealistic.jpg +0 -0
  24. package/plugins/generations/image-gen/assets/sample/daily-news-cover__sketch.jpg +0 -0
  25. package/plugins/generations/image-gen/assets/sample/daily-news-cover__technical-diagram.jpg +0 -0
  26. package/plugins/generations/image-gen/assets/sample/daily-news-cover__vibrant.jpg +0 -0
  27. package/plugins/generations/image-gen/assets/sample/daily-news-cover__warm.jpg +0 -0
  28. package/plugins/generations/image-gen/assets/sample/daily-news-cover__xhs-knowledge.jpg +0 -0
  29. package/plugins/generations/image-gen/assets/sample/free__clean-webapp-ui.jpg +0 -0
  30. package/plugins/generations/image-gen/assets/sample/free__cute.jpg +0 -0
  31. package/plugins/generations/image-gen/assets/sample/free__daily-news.jpg +0 -0
  32. package/plugins/generations/image-gen/assets/sample/free__editorial.jpg +0 -0
  33. package/plugins/generations/image-gen/assets/sample/free__fresh.jpg +0 -0
  34. package/plugins/generations/image-gen/assets/sample/free__minimalist.jpg +0 -0
  35. package/plugins/generations/image-gen/assets/sample/free__photorealistic.jpg +0 -0
  36. package/plugins/generations/image-gen/assets/sample/free__sketch.jpg +0 -0
  37. package/plugins/generations/image-gen/assets/sample/free__technical-diagram.jpg +0 -0
  38. package/plugins/generations/image-gen/assets/sample/free__vibrant.jpg +0 -0
  39. package/plugins/generations/image-gen/assets/sample/free__warm.jpg +0 -0
  40. package/plugins/generations/image-gen/assets/sample/free__xhs-knowledge.jpg +0 -0
  41. package/plugins/generations/image-gen/assets/sample/illustration__clean-webapp-ui.jpg +0 -0
  42. package/plugins/generations/image-gen/assets/sample/illustration__cute.jpg +0 -0
  43. package/plugins/generations/image-gen/assets/sample/illustration__daily-news.jpg +0 -0
  44. package/plugins/generations/image-gen/assets/sample/illustration__editorial.jpg +0 -0
  45. package/plugins/generations/image-gen/assets/sample/illustration__fresh.jpg +0 -0
  46. package/plugins/generations/image-gen/assets/sample/illustration__minimalist.jpg +0 -0
  47. package/plugins/generations/image-gen/assets/sample/illustration__photorealistic.jpg +0 -0
  48. package/plugins/generations/image-gen/assets/sample/illustration__sketch.jpg +0 -0
  49. package/plugins/generations/image-gen/assets/sample/illustration__technical-diagram.jpg +0 -0
  50. package/plugins/generations/image-gen/assets/sample/illustration__vibrant.jpg +0 -0
  51. package/plugins/generations/image-gen/assets/sample/illustration__warm.jpg +0 -0
  52. package/plugins/generations/image-gen/assets/sample/illustration__xhs-knowledge.jpg +0 -0
  53. package/plugins/generations/image-gen/assets/sample/xhs-card__clean-webapp-ui.jpg +0 -0
  54. package/plugins/generations/image-gen/assets/sample/xhs-card__cute.jpg +0 -0
  55. package/plugins/generations/image-gen/assets/sample/xhs-card__daily-news.jpg +0 -0
  56. package/plugins/generations/image-gen/assets/sample/xhs-card__editorial.jpg +0 -0
  57. package/plugins/generations/image-gen/assets/sample/xhs-card__fresh.jpg +0 -0
  58. package/plugins/generations/image-gen/assets/sample/xhs-card__minimalist.jpg +0 -0
  59. package/plugins/generations/image-gen/assets/sample/xhs-card__photorealistic.jpg +0 -0
  60. package/plugins/generations/image-gen/assets/sample/xhs-card__sketch.jpg +0 -0
  61. package/plugins/generations/image-gen/assets/sample/xhs-card__technical-diagram.jpg +0 -0
  62. package/plugins/generations/image-gen/assets/sample/xhs-card__vibrant.jpg +0 -0
  63. package/plugins/generations/image-gen/assets/sample/xhs-card__warm.jpg +0 -0
  64. package/plugins/generations/image-gen/assets/sample/xhs-card__xhs-knowledge.jpg +0 -0
  65. package/plugins/generations/image-gen/dist/index.js +102 -58
  66. package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +5 -21
  67. package/plugins/generations/image-gen/scripts/gen-samples.ts +208 -0
  68. package/plugins/generations/image-gen/src/compose/chromium.ts +2 -3
  69. package/plugins/generations/image-gen/src/presets.ts +6 -3
  70. package/plugins/generations/image-gen/src/providers/errors.ts +16 -1
  71. package/plugins/generations/image-gen/src/providers/minimax.ts +46 -17
  72. package/plugins/ingestions/web-search/src/index.ts +1 -1
  73. package/plugins/kk/README.md +18 -0
  74. package/plugins/kk/commands/image-extract.md +1 -0
  75. package/plugins/kk/commands/image-generate.md +1 -0
  76. package/plugins/kk/commands/tell-me.md +1 -0
  77. package/plugins/kk/commands/workflow-run.md +7 -1
  78. package/plugins/kk/decisions/extract/it-topics.yaml +24 -0
  79. package/plugins/kk/decisions/kk-itc.yaml +6 -4
  80. package/plugins/kk/plugin.json +1 -1
  81. package/plugins/kk/publish-presets/daily-ai-voice-migration.json +146 -0
  82. package/plugins/kk/publish-presets/daily-ai-voice.json +11 -3
  83. package/plugins/kk/schedules/kk-collect.grok-bot.md.tmpl +69 -0
  84. package/plugins/kk/schedules/kk-daily-ai-voice3.cron.tmpl +18 -0
  85. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +15 -5
  86. package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +9 -4
  87. package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
  88. package/plugins/kk/scripts/daily-stages.ts +1403 -156
  89. package/plugins/kk/scripts/kk-workflow-stages.ts +266 -10
  90. package/plugins/kk/scripts/safety-artifact-check.ts +277 -0
  91. package/plugins/kk/scripts/voice3-publish-window.ts +202 -0
  92. package/plugins/kk/skills/article-adapt/SKILL.md +1 -0
  93. package/plugins/kk/skills/audio-authoring/SKILL.md +1 -0
  94. package/plugins/kk/skills/content-judge/SKILL.md +1 -0
  95. package/plugins/kk/skills/content-safety/SKILL.md +1 -0
  96. package/plugins/kk/skills/explain-things/SKILL.md +1 -0
  97. package/plugins/kk/skills/image-authoring/SKILL.md +1 -0
  98. package/plugins/kk/skills/itc-generating/SKILL.md +1 -0
  99. package/plugins/kk/skills/publish/SKILL.md +1 -0
  100. package/plugins/kk/skills/smart-seo/SKILL.md +1 -0
  101. package/plugins/kk/skills/storm-research/SKILL.md +1 -0
  102. package/plugins/kk/skills/taste-unslop/SKILL.md +1 -0
  103. package/plugins/kk/skills/topic/SKILL.md +1 -0
  104. package/plugins/kk/skills/web-automation/SKILL.md +1 -0
  105. package/plugins/kk/skills/xhs-images/SKILL.md +1 -0
  106. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +177 -128
  107. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +161 -61
  108. package/plugins/kk/workflows/kk-daily-publish3.yaml +82 -26
  109. package/plugins/kk/workflows/kk-daily-safety.yaml +156 -69
  110. package/plugins/kk/workflows/kk-itc-author.yaml +75 -0
  111. package/plugins/kk/workflows/kk-itc-publish-tail.yaml +25 -3
  112. package/plugins/kk/workflows/kk-itc.yaml +36 -4
  113. package/plugins/publishings/bilibili-pub/dist/index.js +2 -1
  114. package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
  115. package/plugins/publishings/cnblogs-pub/dist/index.js +2 -1
  116. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
  117. package/plugins/publishings/csdn-pub/dist/index.js +2 -1
  118. package/plugins/publishings/csdn-pub/dist/profile-check.js +2 -1
  119. package/plugins/publishings/infoq-pub/dist/index.js +2 -1
  120. package/plugins/publishings/infoq-pub/dist/profile-check.js +2 -1
  121. package/plugins/publishings/jike-pub/dist/index.js +2 -1
  122. package/plugins/publishings/jike-pub/dist/profile-check.js +2 -1
  123. package/plugins/publishings/juejin-pub/dist/index.js +2 -1
  124. package/plugins/publishings/juejin-pub/dist/profile-check.js +2 -1
  125. package/plugins/publishings/linkedin-pub/dist/index.js +21333 -0
  126. package/plugins/publishings/linkedin-pub/dist/profile-check.js +21005 -0
  127. package/plugins/publishings/linkedin-pub/package.json +19 -0
  128. package/plugins/publishings/linkedin-pub/plugin.json +12 -0
  129. package/plugins/publishings/linkedin-pub/src/index.ts +705 -0
  130. package/plugins/publishings/linkedin-pub/src/profile-check.ts +17 -0
  131. package/plugins/publishings/linkedin-pub/tsconfig.json +4 -0
  132. package/plugins/publishings/podcast-pub/dist/index.js +46 -8
  133. package/plugins/publishings/podcast-pub/src/index.ts +62 -5
  134. package/plugins/publishings/shipinhao-pub/dist/index.js +2 -1
  135. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
  136. package/plugins/publishings/sspai-pub/dist/index.js +2 -1
  137. package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
  138. package/plugins/publishings/substack-pub/dist/index.js +2 -1
  139. package/plugins/publishings/substack-pub/dist/profile-check.js +2 -1
  140. package/plugins/publishings/surfdash-pub/dist/index.js +461 -53
  141. package/plugins/publishings/surfdash-pub/src/digest-check.ts +85 -19
  142. package/plugins/publishings/surfdash-pub/src/index.ts +592 -65
  143. package/plugins/publishings/threads-pub/dist/index.js +7 -0
  144. package/plugins/publishings/threads-pub/src/index.ts +12 -0
  145. package/plugins/publishings/wechatmp-pub/dist/index.js +936 -98
  146. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +60 -3
  147. package/plugins/publishings/wechatmp-pub/src/flow.ts +1134 -86
  148. package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
  149. package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
  150. package/plugins/publishings/weibo-pub/dist/index.js +2 -1
  151. package/plugins/publishings/weibo-pub/dist/profile-check.js +2 -1
  152. package/plugins/publishings/x-pub/dist/index.js +9 -1
  153. package/plugins/publishings/x-pub/dist/profile-check.js +2 -1
  154. package/plugins/publishings/x-pub/src/index.ts +11 -0
  155. package/plugins/publishings/xhs-pub/dist/index.js +2 -1
  156. package/plugins/publishings/xhs-pub/dist/profile-check.js +2 -1
  157. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +2 -1
  158. 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,
@@ -88,7 +89,6 @@ import {
88
89
  PII_FINDING_CATEGORY,
89
90
  pluginsArgs,
90
91
  readJson,
91
- readSafetyReviseCount,
92
92
  readTopicBoundary,
93
93
  requireJson,
94
94
  requireSidecar,
@@ -135,11 +135,42 @@ export function dailyQcContent(args: string[]): void {
135
135
  const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
136
136
  const exclusions = join(workDir, '21-plan', `${runDate}_04_quality-control-content_exclusions.json`);
137
137
  const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
138
+ const cachePath = `${candidates}.cache.json`;
139
+ const fingerprint = stageCacheFingerprint(planContent, {
140
+ qcCategories,
141
+ minQuality,
142
+ minImportance,
143
+ minUrgency,
144
+ minImpact,
145
+ minEpisodeItems,
146
+ minMinutes,
147
+ vdriver,
148
+ });
149
+ let recomputed = false;
138
150
  timed(workDir, 'quality-control-content', () => {
139
- if (isFile(candidates)) {
151
+ // Voice1's standalone QC tests and old workspaces may carry a candidates artifact without
152
+ // the newer plan input or sidecar. Preserve that explicitly cache-only surface; voice2
153
+ // always has planContent, so its stale/malformed cache is recomputed below.
154
+ const legacyCandidatesCache = isFile(candidates) && !isFile(planContent) && !isFile(cachePath);
155
+ if (stageCacheMatches(candidates, cachePath, fingerprint) || legacyCandidatesCache) {
140
156
  console.log(`candidates cached: ${candidates}`);
141
157
  return;
142
158
  }
159
+ if (isFile(candidates)) {
160
+ const descendants = authoredPlanDescendants(workDir, runDate);
161
+ if (descendants.length > 0) {
162
+ failWith(
163
+ 1,
164
+ `QC cache changed after downstream artifacts were authored; refusing to pair new candidates with stale outputs (${descendants.join(', ')})`,
165
+ );
166
+ }
167
+ console.log(`candidates cache stale: ${candidates}`);
168
+ }
169
+ for (const stale of [exclusions, `${runDate}_04_quality-control-content_floor-refusal.json`].map((name) =>
170
+ name.startsWith('/') ? name : join(workDir, '21-plan', name),
171
+ )) {
172
+ if (isFile(stale)) unlinkSync(stale);
173
+ }
143
174
  sted('write', annotated, () => writeJqText(annotated, metadataOf(readJson(planContent)).docs ?? null));
144
175
  kk(
145
176
  [
@@ -164,6 +195,9 @@ export function dailyQcContent(args: string[]): void {
164
195
  },
165
196
  },
166
197
  );
198
+ if (!isFile(candidates)) failWith(1, `quality-control-content: ${candidates} not written`);
199
+ sted('write', cachePath, () => writeStageCache(cachePath, fingerprint));
200
+ recomputed = true;
167
201
  });
168
202
  // Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
169
203
  // candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
@@ -212,7 +246,48 @@ export function dailyQcContent(args: string[]): void {
212
246
  // already produced. 2026-10-22 cleared the floor on a 4-item pool (21.0 min estimated) that the
213
247
  // topic boundary then cut to 3 (14.4 min), so the run paid for a full synthesis the measured
214
248
  // gate refused. Covered here it also re-runs on a cached-rerun, which the timed block skipped.
215
- applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
249
+ applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver, recomputed);
250
+ }
251
+
252
+ /**
253
+ * Cache only deterministic stage inputs. The source bytes are part of the key so a new collect
254
+ * pool or translated plan cannot inherit a prior run's refusal; the invocation parameters cover
255
+ * every threshold that changes the QC/floor result.
256
+ */
257
+ function stageCacheFingerprint(sourcePath: string, inputs: Record<string, unknown>): string {
258
+ const source = isFile(sourcePath) ? readFileSync(sourcePath, 'utf-8') : '<missing>';
259
+ return createHash('sha256')
260
+ .update(JSON.stringify({ version: 1, source, inputs }))
261
+ .digest('hex');
262
+ }
263
+
264
+ function stageCacheMatches(outputPath: string, cachePath: string, fingerprint: string): boolean {
265
+ if (!isFile(outputPath) || !isFile(cachePath)) return false;
266
+ try {
267
+ const cache = JSON.parse(readFileSync(cachePath, 'utf-8')) as { version?: unknown; fingerprint?: unknown };
268
+ return cache.version === 1 && cache.fingerprint === fingerprint;
269
+ } catch {
270
+ return false;
271
+ }
272
+ }
273
+
274
+ function writeStageCache(path: string, fingerprint: string): void {
275
+ writeJsonText(path, { version: 1, fingerprint });
276
+ }
277
+
278
+ function authoredPlanDescendants(workDir: string, runDate: string): string[] {
279
+ const descendants: string[] = [];
280
+ for (const directory of ['22-article', '23-cover', '24-script', '30-audio', '31-publish']) {
281
+ const root = join(workDir, directory);
282
+ if (!existsSync(root)) continue;
283
+ // The directory contents are stage-owned artifacts. A date-prefixed file means that a
284
+ // previous non-empty run already authored downstream material that must not be silently
285
+ // paired with a newly generated plan.
286
+ for (const entry of readdirSync(root)) {
287
+ if (entry.startsWith(`${runDate}_`)) descendants.push(join(root, entry));
288
+ }
289
+ }
290
+ return descendants;
216
291
  }
217
292
 
218
293
  /**
@@ -261,18 +336,26 @@ function applyFloorGate(
261
336
  runDate: string,
262
337
  minMinutes: string,
263
338
  vdriver: string,
339
+ clearStaleRefusal = false,
264
340
  ): void {
341
+ const refusal = join(workDir, '21-plan', `${runDate}_04_quality-control-content_floor-refusal.json`);
265
342
  const floor = Number(minMinutes);
266
- if (minMinutes === '' || !Number.isFinite(floor) || floor <= 0) return;
343
+ if (minMinutes === '' || !Number.isFinite(floor) || floor <= 0) {
344
+ if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
345
+ return;
346
+ }
267
347
  const content = readJson(candidates);
268
348
  const docs = metadataOf(content).docs;
269
- if (!Array.isArray(docs) || docs.length === 0) return;
349
+ if (!Array.isArray(docs) || docs.length === 0) {
350
+ if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
351
+ return;
352
+ }
270
353
  const { minutes: estimatedMinutes, seconds: estimatedSeconds, chars } = estimateSpokenMinutes(docs, vdriver);
271
354
  if (estimatedSeconds >= floor * 60) {
355
+ if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
272
356
  console.log(`floor gate: estimated ${estimatedMinutes} min from ${chars} chars — above the ${floor} min floor`);
273
357
  return;
274
358
  }
275
- const refusal = join(workDir, '21-plan', `${runDate}_04_quality-control-content_floor-refusal.json`);
276
359
  const ids = docs.map((doc) => String((doc as Record<string, unknown> | null)?.id ?? ''));
277
360
  sted('write', refusal, () =>
278
361
  writeJqText(refusal, {
@@ -632,13 +715,23 @@ function refuseScriptBelowFloor(
632
715
  }
633
716
 
634
717
  export function dailyEpisodeDuration(args: string[]): void {
635
- const [workDir = '', runDate = '', minArg = '', maxArg = '', vdriver = ''] = args;
718
+ const [workDir = '', runDate = '', minArg = '', maxArg = '', vdriver = '', runId = ''] = args;
636
719
  if (!workDir) fail('missing work_dir');
637
720
  if (!runDate) fail('missing run_date');
638
721
  const minMinutes = Number(minArg);
639
722
  const maxMinutes = Number(maxArg);
640
723
  if (!Number.isFinite(minMinutes) || minMinutes <= 0) fail(`invalid min_minutes: ${minArg || '(empty)'}`);
641
724
  if (!Number.isFinite(maxMinutes) || maxMinutes < minMinutes) fail(`invalid max_minutes: ${maxArg || '(empty)'}`);
725
+ // 0329 F06: the publish path must not survive a refusal. The pool-gate token flips to EMPTY so
726
+ // this run's own remaining fan-out skips, and a stale bundle.json from an earlier run (whose
727
+ // runs[] a publish window could still consume) is dropped.
728
+ const invalidatePublishPath = (): void => {
729
+ if (runId !== '') {
730
+ const token = join('.spur', 'run', `${runId}-pool-gate.status`);
731
+ if (isFile(token)) sted('write', token, () => writeRaw(token, 'EMPTY\n'));
732
+ }
733
+ rmSync(join(workDir, 'bundle.json'), { force: true });
734
+ };
642
735
  const contentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
643
736
  if (!isFile(contentPath)) failWith(1, `episode-duration: audio content not found: ${contentPath}`);
644
737
  let raw: unknown;
@@ -667,7 +760,8 @@ export function dailyEpisodeDuration(args: string[]): void {
667
760
  // let the pool-gated publish fan package a sub-floor episode — so finish the emptying here.
668
761
  if (isFile(refusal)) {
669
762
  if (docs.length > 0) emptyCandidates(workDir, runDate);
670
- console.log(`episode-duration: duration refusal already recorded (${refusal}) — nothing to do`);
763
+ invalidatePublishPath();
764
+ console.error(`episode-duration: duration refusal already recorded (${refusal}) — nothing to do`);
671
765
  return;
672
766
  }
673
767
  const estimated = estimateSpokenMinutes(docs, vdriver);
@@ -683,6 +777,7 @@ export function dailyEpisodeDuration(args: string[]): void {
683
777
  }),
684
778
  );
685
779
  emptyCandidates(workDir, runDate);
780
+ invalidatePublishPath();
686
781
  console.error(
687
782
  `episode-duration: episode ran ${shown} min (estimate ${estimated.minutes} min), below the ${minMinutes}–${maxMinutes} min requirement — candidates emptied, the run closes as no-content (see ${refusal})`,
688
783
  );
@@ -1511,11 +1606,28 @@ export function dailyPlan(args: string[]): void {
1511
1606
  // translation. Voice1 omits the arg and still materializes the file (its translate node has no
1512
1607
  // skip guard and overwrites it).
1513
1608
  const deferDocs = deferPlanDocs === 'true';
1609
+ const target = deferDocs ? planContent : planDocs;
1610
+ const targetCache = `${target}.cache.json`;
1611
+ const fingerprint = stageCacheFingerprint(blended, {
1612
+ runDate,
1613
+ planMaxItems,
1614
+ deferPlanDocs,
1615
+ });
1514
1616
  timed(workDir, 'plan', () => {
1515
- if (deferDocs ? isFile(planContent) : isFile(planDocs)) {
1617
+ if (stageCacheMatches(target, targetCache, fingerprint)) {
1516
1618
  console.log(`plan cached: ${deferDocs ? planContent : planDocs}`);
1517
1619
  return;
1518
1620
  }
1621
+ if (isFile(target)) {
1622
+ const descendants = authoredPlanDescendants(workDir, runDate);
1623
+ if (descendants.length > 0) {
1624
+ failWith(
1625
+ 1,
1626
+ `plan cache changed after downstream artifacts were authored; refusing to pair a new plan with stale outputs (${descendants.join(', ')})`,
1627
+ );
1628
+ }
1629
+ console.log(`plan cache stale: ${target}`);
1630
+ }
1519
1631
  kk(
1520
1632
  ['executor', 'run', 'episode-plan-gen', '--in', blended, '--out', planContent, ...pluginsArgs(pluginsPath)],
1521
1633
  {
@@ -1524,9 +1636,17 @@ export function dailyPlan(args: string[]): void {
1524
1636
  );
1525
1637
  const docs = metadataOf(readJson(planContent)).docs;
1526
1638
  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`);
1639
+ if (deferDocs) {
1640
+ // A translated plan is a descendant of this content plan. Remove only the stale
1641
+ // translation when no authored downstream artifact exists; otherwise the guard above
1642
+ // fails closed and leaves the operator a reviewable workspace.
1643
+ if (isFile(planDocs)) unlinkSync(planDocs);
1644
+ if (isFile(`${planDocs}.cache.json`)) unlinkSync(`${planDocs}.cache.json`);
1645
+ } else {
1646
+ sted('write', planDocs, () => writeJsonText(planDocs, docs));
1647
+ if (!isFile(planDocs)) failWith(1, `plan: ${planDocs} not written after episode-plan-gen`);
1648
+ }
1649
+ sted('write', targetCache, () => writeStageCache(targetCache, fingerprint));
1530
1650
  });
1531
1651
  }
1532
1652
 
@@ -1769,12 +1889,13 @@ export function buildSocialContent(
1769
1889
 
1770
1890
  /**
1771
1891
  * Length budgets the social authoring prompt targets, enforced here as a backstop (task 0327 R3).
1772
- * X counts CJK at 2 (`weighted`); weibo and threads count characters.
1892
+ * X counts CJK at 2 (`weighted`); weibo, threads and linkedin count characters.
1773
1893
  */
1774
1894
  const SOCIAL_LIMITS: Record<string, SocialLimit> = {
1775
1895
  x: { max: 280, weighted: true },
1776
1896
  weibo: { max: 2000 },
1777
1897
  threads: { max: 500 },
1898
+ linkedin: { max: 3000 },
1778
1899
  };
1779
1900
 
1780
1901
  /** Variant key → file name: `social:x` becomes `social_x.content.json`. */
@@ -1783,27 +1904,36 @@ function variantFileName(key: string): string {
1783
1904
  }
1784
1905
 
1785
1906
  /**
1786
- * `daily-bundle-variants <workDir> <runDate> <pluginsPath>` — the generate-side payload builder
1787
- * (task 0327 R2/R3).
1907
+ * `daily-bundle-variants <workDir> <runDate> <pluginsPath> [cardsEnabled] [coverEnabled]` — the
1908
+ * generate-side payload builder (task 0327 R2/R3).
1788
1909
  *
1789
1910
  * Everything the publish side used to assemble inline is produced here instead, once, as bundle
1790
1911
  * variants under `30-variants/`. Links are the `{{article_url}}` / `{{podcast_url}}` placeholders,
1791
1912
  * because at generate time nothing is published yet; `publish-matrix-send` resolves them from the
1792
1913
  * tier-0 receipts. A variant whose source artifact is absent (channel disabled, cards never
1793
1914
  * rendered, social copy not authored) is skipped with a log line, never invented.
1915
+ *
1916
+ * 0329 F16: the caller forwards the run's `cards_enabled` / `cover_enabled` switches — a disabled
1917
+ * producer's variant is a skip, not a stale re-read of a previous run's file — and every stale
1918
+ * variant file is wiped before writing, so `30-variants/` after this stage contains only what
1919
+ * THIS pass produced.
1794
1920
  */
1795
1921
  export function dailyBundleVariants(args: string[]): void {
1796
- const [workDir = '', runDate = '', _pluginsPath = ''] = args;
1922
+ const [workDir = '', runDate = '', _pluginsPath = '', cardsEnabledArg = '', coverEnabledArg = ''] = args;
1797
1923
  timed(workDir, 'bundle-variants', () => {
1798
1924
  const outDir = join(workDir, '30-variants');
1799
1925
  sted('mkdir', outDir, () => mkdirSync(outDir, { recursive: true }));
1926
+ // 0329 F16: a rerun after a thinner day must not leave yesterday's variants behind.
1927
+ for (const entry of readdirSync(outDir)) {
1928
+ if (entry.endsWith('.content.json')) rmSync(join(outDir, entry), { force: true });
1929
+ }
1800
1930
  const articlePath = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1801
1931
  const prepPath = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
1802
1932
  const notesPath = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
1803
1933
  const cardsPath = join(workDir, '25-xhs', 'content.json');
1804
1934
  const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1805
1935
  const socialDir = join(workDir, '26-social');
1806
- const cover = isFile(coverPath) ? coverPath : undefined;
1936
+ const cover = coverEnabledArg !== 'false' && isFile(coverPath) ? coverPath : undefined;
1807
1937
  const links = PLACEHOLDER_LINKS;
1808
1938
  const written: string[] = [];
1809
1939
  const skipped: string[] = [];
@@ -1857,9 +1987,12 @@ export function dailyBundleVariants(args: string[]): void {
1857
1987
  );
1858
1988
  }
1859
1989
 
1860
- const cards = readJson(cardsPath);
1861
- if (cards === null) skipped.push('cards (no rendered card series)');
1862
- else write('cards@xhs-pub', buildXhsCardsContent(cards, { publishStatus: 'draft' }));
1990
+ if (cardsEnabledArg === 'false') skipped.push('cards (cards_enabled=false)');
1991
+ else {
1992
+ const cards = readJson(cardsPath);
1993
+ if (cards === null) skipped.push('cards (no rendered card series)');
1994
+ else write('cards@xhs-pub', buildXhsCardsContent(cards, { publishStatus: 'draft' }));
1995
+ }
1863
1996
 
1864
1997
  for (const platform of Object.keys(SOCIAL_LIMITS)) {
1865
1998
  const copyPath = join(socialDir, `${platform}.md`);
@@ -1915,19 +2048,40 @@ export function readKeyPointItems(filePath: string): string[] | undefined {
1915
2048
  }
1916
2049
  const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1];
1917
2050
  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.
2051
+ // Slice from `key_points` to the first top-level key (or block end), then read the item lines
2052
+ // (`- `). Folded (`>-`) YAML items need their indented continuation lines joined before the
2053
+ // strict scheduled validator can check the `Label — sentence` contract.
1920
2054
  const head = /^key_points:\n/m.exec(block);
1921
2055
  if (!head) return undefined;
1922
2056
  const body = block.slice(head.index + head[0].length);
1923
2057
  const endMatch = /^\S/m.exec(body);
1924
2058
  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'));
2059
+ const items: string[] = [];
2060
+ let current: string | undefined;
2061
+ let folded = false;
2062
+ const flush = (): void => {
2063
+ if (current === undefined) return;
2064
+ // A quoted scalar's quotes are YAML syntax, not text — strip them so label lengths are real.
2065
+ items.push(current.replace(/^(['"])(.*)\1$/, '$2').trim());
2066
+ current = undefined;
2067
+ folded = false;
2068
+ };
2069
+ for (const line of list.split('\n')) {
2070
+ const item = /^[ \t]+- (.*)$/.exec(line);
2071
+ if (item !== null) {
2072
+ flush();
2073
+ current = item[1].trim();
2074
+ folded = current === '>' || current === '>-' || current === '|-' || current === '|';
2075
+ // The block indicator is YAML syntax, not text: the item starts on the next line.
2076
+ if (folded) current = '';
2077
+ continue;
2078
+ }
2079
+ if (current === undefined || !folded) continue;
2080
+ const continuation = line.trim();
2081
+ if (continuation !== '') current = `${current} ${continuation}`;
2082
+ }
2083
+ flush();
2084
+ return items;
1931
2085
  }
1932
2086
 
1933
2087
  /** Count `key_points` list items in a Surfing post's frontmatter; undefined when absent/unreadable. */
@@ -2113,36 +2267,662 @@ export function dailyPublishPrep(args: string[]): void {
2113
2267
  });
2114
2268
  }
2115
2269
 
2270
+ type SurfdashDeliveryState = 'missing' | 'success' | 'failure' | 'uncertain';
2271
+
2272
+ interface SurfdashSendIntent {
2273
+ version: 1;
2274
+ target: 'surfdash-pub';
2275
+ runDate: string;
2276
+ payloadPath: string;
2277
+ payloadSha256: string;
2278
+ createdAt: string;
2279
+ }
2280
+
2281
+ function surfdashPayloadSha256(path: string): string {
2282
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
2283
+ }
2284
+
2285
+ function surfdashNotSentReceipt(resultPath: string, intentPath: string, runDate: string, reason: string): void {
2286
+ const error = `surfdash publish was not sent: ${reason}`;
2287
+ sted('write', resultPath, () =>
2288
+ writeJsonText(resultPath, {
2289
+ ok: false,
2290
+ summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
2291
+ targets: [
2292
+ {
2293
+ target: 'surfdash-pub',
2294
+ ok: false,
2295
+ status: 'failed',
2296
+ error,
2297
+ metadata: { deliveryState: 'not-sent', runDate, intentPath },
2298
+ },
2299
+ ],
2300
+ }),
2301
+ );
2302
+ console.error(error);
2303
+ }
2304
+
2305
+ function readSurfdashSendIntent(
2306
+ path: string,
2307
+ ): { kind: 'missing' } | { kind: 'valid'; value: SurfdashSendIntent } | { kind: 'invalid'; reason: string } {
2308
+ if (!isFile(path)) return { kind: 'missing' };
2309
+ const parsed = readJson(path);
2310
+ if (parsed === null) return { kind: 'invalid', reason: 'send intent is unreadable or invalid JSON' };
2311
+ if (
2312
+ parsed.version !== 1 ||
2313
+ parsed.target !== 'surfdash-pub' ||
2314
+ typeof parsed.runDate !== 'string' ||
2315
+ typeof parsed.payloadPath !== 'string' ||
2316
+ typeof parsed.createdAt !== 'string' ||
2317
+ typeof parsed.payloadSha256 !== 'string' ||
2318
+ !/^[0-9a-f]{64}$/.test(parsed.payloadSha256)
2319
+ ) {
2320
+ return { kind: 'invalid', reason: 'send intent is malformed' };
2321
+ }
2322
+ return { kind: 'valid', value: parsed as unknown as SurfdashSendIntent };
2323
+ }
2324
+
2325
+ function surfdashTargetEntries(result: Record<string, unknown> | null): Record<string, unknown>[] {
2326
+ const targets = result?.targets;
2327
+ if (!Array.isArray(targets)) return [];
2328
+ return targets.filter(
2329
+ (entry): entry is Record<string, unknown> =>
2330
+ entry !== null && typeof entry === 'object' && (entry as Record<string, unknown>).target === 'surfdash-pub',
2331
+ );
2332
+ }
2333
+
2334
+ /** A push can have reached the remote even when the publisher reports a failed receipt. */
2335
+ function surfdashFailureMayHaveRemoteEffect(entry: Record<string, unknown>): boolean {
2336
+ return (
2337
+ entry.ok === false &&
2338
+ entry.status === 'failed' &&
2339
+ (entry.metadata as Record<string, unknown> | undefined)?.deliveryState !== 'not-sent'
2340
+ );
2341
+ }
2342
+
2343
+ function surfdashTargetState(result: Record<string, unknown> | null): SurfdashDeliveryState {
2344
+ const matches = surfdashTargetEntries(result);
2345
+ if (matches.length === 0) return 'missing';
2346
+ // More than one receipt for the same target is ambiguous even when every row says success:
2347
+ // retrying could duplicate a remote post. Preserve the uncertainty until reconciliation.
2348
+ if (matches.length > 1) return 'uncertain';
2349
+ if (
2350
+ matches.some(
2351
+ (entry) =>
2352
+ (entry.metadata as Record<string, unknown> | undefined)?.deliveryState === 'uncertain' ||
2353
+ surfdashFailureMayHaveRemoteEffect(entry),
2354
+ )
2355
+ ) {
2356
+ return 'uncertain';
2357
+ }
2358
+ const target = matches[0];
2359
+ if (target.ok === true && target.status === 'published') return 'success';
2360
+ if (target.ok === false && target.status === 'failed') return 'failure';
2361
+ return 'missing';
2362
+ }
2363
+
2364
+ function writeSurfdashUncertainReceipt(
2365
+ resultPath: string,
2366
+ intentPath: string,
2367
+ runDate: string,
2368
+ payloadSha256: string | undefined,
2369
+ currentPayloadSha256: string | undefined,
2370
+ reason: string,
2371
+ identity: { postPath?: string; localeFingerprint?: string } = {},
2372
+ ): void {
2373
+ const metadata: Record<string, unknown> = {
2374
+ deliveryState: 'uncertain',
2375
+ runDate,
2376
+ intentPath,
2377
+ ...(payloadSha256 ? { payloadSha256 } : {}),
2378
+ ...(currentPayloadSha256 ? { currentPayloadSha256 } : {}),
2379
+ ...(identity.postPath ? { postPath: identity.postPath } : {}),
2380
+ ...(identity.localeFingerprint ? { localeFingerprint: identity.localeFingerprint } : {}),
2381
+ };
2382
+ const error = `surfdash delivery is uncertain: ${reason}; reconcile the dated remote post/commit before clearing this uncertainty and retrying`;
2383
+ sted('write', resultPath, () =>
2384
+ writeJsonText(resultPath, {
2385
+ ok: false,
2386
+ summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
2387
+ targets: [
2388
+ {
2389
+ target: 'surfdash-pub',
2390
+ ok: false,
2391
+ status: 'failed',
2392
+ error,
2393
+ metadata,
2394
+ },
2395
+ ],
2396
+ }),
2397
+ );
2398
+ console.error(error);
2399
+ }
2400
+
2401
+ const SURFDASH_LOCALES = ['zh', 'en', 'ja'] as const;
2402
+ type SurfdashLocale = (typeof SURFDASH_LOCALES)[number];
2403
+
2404
+ interface SurfdashLocaleValidationEvidence {
2405
+ runDate: string;
2406
+ postPath: string;
2407
+ localePaths: Record<SurfdashLocale, string>;
2408
+ localeDigests: Record<SurfdashLocale, string>;
2409
+ fingerprint: string;
2410
+ }
2411
+
2412
+ interface SurfdashPublishIdentity {
2413
+ postPath: string;
2414
+ payloadSha256: string;
2415
+ localeFingerprint: string;
2416
+ }
2417
+
2418
+ function surfdashPublishIdentity(entry: Record<string, unknown> | undefined): SurfdashPublishIdentity | undefined {
2419
+ if (entry === undefined) return undefined;
2420
+ const metadata = metadataOf(entry);
2421
+ if (
2422
+ typeof metadata.postPath !== 'string' ||
2423
+ typeof metadata.payloadSha256 !== 'string' ||
2424
+ !/^[0-9a-f]{64}$/.test(metadata.payloadSha256) ||
2425
+ typeof metadata.localeFingerprint !== 'string' ||
2426
+ !/^[0-9a-f]{64}$/.test(metadata.localeFingerprint)
2427
+ ) {
2428
+ return undefined;
2429
+ }
2430
+ return {
2431
+ postPath: metadata.postPath,
2432
+ payloadSha256: metadata.payloadSha256,
2433
+ localeFingerprint: metadata.localeFingerprint,
2434
+ };
2435
+ }
2436
+
2437
+ function localeSiblingPaths(zhPostPath: string): Record<SurfdashLocale, string> | undefined {
2438
+ if (!zhPostPath.includes('/zh/')) return undefined;
2439
+ const en = zhPostPath.replace('/zh/', '/en/');
2440
+ const ja = zhPostPath.replace('/zh/', '/ja/');
2441
+ if (en === zhPostPath || ja === zhPostPath) return undefined;
2442
+ return { zh: zhPostPath, en, ja };
2443
+ }
2444
+
2445
+ function validateLocaleMarkers(workDir: string, runDate: string, zhPostPath: string, step: string): void {
2446
+ const localePaths = localeSiblingPaths(zhPostPath);
2447
+ if (localePaths === undefined) {
2448
+ failWith(1, `${step}: zh post path does not contain a /zh/ locale segment: ${zhPostPath}`);
2449
+ }
2450
+ for (const locale of ['en', 'ja'] as const) {
2451
+ const marker = join(workDir, '31-publish', `${runDate}_20_translate-localize_${locale}-post-path.txt`);
2452
+ const markerPath = isFile(marker) ? readFileSync(marker, 'utf-8').trim() : '';
2453
+ const expectedPath = localePaths[locale];
2454
+ if (markerPath === '' || !isAbsolute(markerPath) || markerPath !== expectedPath || !isFile(expectedPath)) {
2455
+ failWith(1, `${step}: ${locale} marker must name the absolute derived sibling ${expectedPath}: ${marker}`);
2456
+ }
2457
+ }
2458
+ }
2459
+
2460
+ function surfdashLocaleValidationEvidence(
2461
+ zhPostPath: string,
2462
+ runDate: string,
2463
+ ): { ok: true; evidence: SurfdashLocaleValidationEvidence } | { ok: false; reason: string } {
2464
+ const localePaths = localeSiblingPaths(zhPostPath);
2465
+ if (localePaths === undefined) {
2466
+ return { ok: false, reason: `zh post path does not contain a /zh/ locale segment: ${zhPostPath}` };
2467
+ }
2468
+ const localeDigests = {} as Record<SurfdashLocale, string>;
2469
+ for (const locale of SURFDASH_LOCALES) {
2470
+ const path = localePaths[locale];
2471
+ if (!isFile(path)) return { ok: false, reason: `${locale} locale file is missing: ${path}` };
2472
+ try {
2473
+ localeDigests[locale] = createHash('sha256').update(readFileSync(path)).digest('hex');
2474
+ } catch (error: unknown) {
2475
+ return {
2476
+ ok: false,
2477
+ reason: `${locale} locale file cannot be fingerprinted: ${error instanceof Error ? error.message : String(error)}`,
2478
+ };
2479
+ }
2480
+ }
2481
+ const identity = { runDate, postPath: zhPostPath, localePaths, localeDigests };
2482
+ return {
2483
+ ok: true,
2484
+ evidence: {
2485
+ ...identity,
2486
+ fingerprint: createHash('sha256').update(JSON.stringify(identity)).digest('hex'),
2487
+ },
2488
+ };
2489
+ }
2490
+
2491
+ function validateSurfdashLocaleGate(
2492
+ validationPath: string,
2493
+ prepareResultPath: string,
2494
+ runDate: string,
2495
+ ): { ok: true; evidence: SurfdashLocaleValidationEvidence } | { ok: false; reason: string } {
2496
+ const validation = isFile(validationPath) ? readJson(validationPath) : null;
2497
+ if (validation?.ok !== true || validation.target !== 'surfdash-pub' || validation.status !== 'validated') {
2498
+ return { ok: false, reason: `missing or invalid validation receipt: ${validationPath}` };
2499
+ }
2500
+ const metadata = metadataOf(validation);
2501
+ if (metadata.runDate !== runDate || typeof metadata.postPath !== 'string') {
2502
+ return { ok: false, reason: `validation receipt date/path does not match run ${runDate}` };
2503
+ }
2504
+ const prepared = isFile(prepareResultPath) ? readJson(prepareResultPath) : null;
2505
+ const preparedTargets = Array.isArray(prepared?.targets)
2506
+ ? prepared.targets.filter(
2507
+ (entry): entry is Record<string, unknown> =>
2508
+ entry !== null &&
2509
+ typeof entry === 'object' &&
2510
+ (entry as Record<string, unknown>).target === 'surfdash-pub',
2511
+ )
2512
+ : [];
2513
+ if (
2514
+ preparedTargets.length !== 1 ||
2515
+ preparedTargets[0].ok !== true ||
2516
+ preparedTargets[0].status !== 'prepared' ||
2517
+ metadataOf(preparedTargets[0]).postPath !== metadata.postPath
2518
+ ) {
2519
+ return { ok: false, reason: `current Surfdash prepare receipt is missing or does not match ${validationPath}` };
2520
+ }
2521
+ const current = surfdashLocaleValidationEvidence(metadata.postPath, runDate);
2522
+ if (!current.ok) return current;
2523
+ const recordedPaths = metadata.localePaths;
2524
+ const recordedDigests = metadata.localeDigests;
2525
+ if (
2526
+ recordedPaths === null ||
2527
+ typeof recordedPaths !== 'object' ||
2528
+ recordedDigests === null ||
2529
+ typeof recordedDigests !== 'object' ||
2530
+ typeof metadata.fingerprint !== 'string'
2531
+ ) {
2532
+ return { ok: false, reason: `validation receipt fingerprint is missing or malformed: ${validationPath}` };
2533
+ }
2534
+ if (
2535
+ JSON.stringify(recordedPaths) !== JSON.stringify(current.evidence.localePaths) ||
2536
+ JSON.stringify(recordedDigests) !== JSON.stringify(current.evidence.localeDigests) ||
2537
+ metadata.fingerprint !== current.evidence.fingerprint
2538
+ ) {
2539
+ return { ok: false, reason: `locale files changed after validation; refresh ${validationPath}` };
2540
+ }
2541
+ return current;
2542
+ }
2543
+
2116
2544
  export function dailyPublishSurfdash(args: string[]): void {
2117
- const [workDir = '', runDate = '', pluginsPath = ''] = args;
2545
+ const [workDir = '', runDate = '', pluginsPath = '', requireLocaleValidation = 'false'] = args;
2118
2546
  const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
2119
2547
  const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
2548
+ const intentPath = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_intent.json`);
2549
+ const prepareResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_result.json`);
2550
+ const validationPath = join(workDir, '31-publish', `${runDate}_24_translate-validate_result.json`);
2551
+ const preparedInput = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_prepared_content.json`);
2552
+ const transportResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_transport_result.json`);
2120
2553
  timed(workDir, 'publish-surfdash', () => {
2121
- if (targetPublished(result, 'surfdash-pub')) {
2554
+ const priorResult = readJson(result);
2555
+ const priorTargets = surfdashTargetEntries(priorResult);
2556
+ const priorState = surfdashTargetState(priorResult);
2557
+ if (priorState === 'uncertain') {
2558
+ const priorTarget = priorTargets[0];
2559
+ if (
2560
+ priorTarget !== undefined &&
2561
+ (priorTarget.metadata as Record<string, unknown> | undefined)?.deliveryState !== 'uncertain'
2562
+ ) {
2563
+ const priorError =
2564
+ typeof priorTarget.error === 'string'
2565
+ ? priorTarget.error
2566
+ : 'publisher reported an ambiguous delivery failure';
2567
+ writeSurfdashUncertainReceipt(result, intentPath, runDate, undefined, undefined, priorError);
2568
+ }
2569
+ console.log(`publish-surfdash blocked: uncertain delivery is sticky in ${result}`);
2570
+ return;
2571
+ }
2572
+
2573
+ // The two-phase voice2 workflow explicitly requires a successful local locale gate before
2574
+ // the publisher can invoke refine/CMS/git. Legacy voice1/manual callers omit the fourth
2575
+ // argument and retain their historical publish behavior.
2576
+ let currentIdentity: SurfdashPublishIdentity | undefined;
2577
+ let currentLocaleEvidence: SurfdashLocaleValidationEvidence | undefined;
2578
+ if (requireLocaleValidation === 'true') {
2579
+ const validation = validateSurfdashLocaleGate(validationPath, prepareResult, runDate);
2580
+ if (!validation.ok) {
2581
+ const detail = validation.reason;
2582
+ if (priorState === 'success') {
2583
+ writeSurfdashUncertainReceipt(
2584
+ result,
2585
+ intentPath,
2586
+ runDate,
2587
+ undefined,
2588
+ undefined,
2589
+ `current locale validation is not the one used for the cached success: ${detail}`,
2590
+ );
2591
+ } else {
2592
+ surfdashNotSentReceipt(result, intentPath, runDate, `blocked by locale validation: ${detail}`);
2593
+ }
2594
+ return;
2595
+ }
2596
+ currentLocaleEvidence = validation.evidence;
2597
+ if (!isFile(prep)) {
2598
+ const detail = `prepared payload is missing: ${prep}`;
2599
+ if (priorState === 'success') {
2600
+ writeSurfdashUncertainReceipt(result, intentPath, runDate, undefined, undefined, detail);
2601
+ } else {
2602
+ surfdashNotSentReceipt(result, intentPath, runDate, detail);
2603
+ }
2604
+ return;
2605
+ }
2606
+ currentIdentity = {
2607
+ postPath: validation.evidence.postPath,
2608
+ payloadSha256: surfdashPayloadSha256(prep),
2609
+ localeFingerprint: validation.evidence.fingerprint,
2610
+ };
2611
+ }
2612
+
2613
+ if (priorState === 'success') {
2614
+ // Voice1 and manual callers omit the locale-validation argument and historically
2615
+ // treated a recorded success as a resume-safe cache hit. Voice2 supplies `true` and
2616
+ // must keep the exact identity binding below; do not make the legacy surface depend
2617
+ // on the new locale fingerprint receipt.
2618
+ if (requireLocaleValidation !== 'true') {
2619
+ console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
2620
+ return;
2621
+ }
2622
+ const cachedIdentity = surfdashPublishIdentity(priorTargets[0]);
2623
+ if (
2624
+ currentIdentity === undefined ||
2625
+ cachedIdentity === undefined ||
2626
+ cachedIdentity.postPath !== currentIdentity.postPath ||
2627
+ cachedIdentity.payloadSha256 !== currentIdentity.payloadSha256 ||
2628
+ cachedIdentity.localeFingerprint !== currentIdentity.localeFingerprint
2629
+ ) {
2630
+ writeSurfdashUncertainReceipt(
2631
+ result,
2632
+ intentPath,
2633
+ runDate,
2634
+ cachedIdentity?.payloadSha256,
2635
+ currentIdentity?.payloadSha256,
2636
+ 'cached success has no exact postPath/payloadSha256/localeFingerprint identity for the current material',
2637
+ currentIdentity,
2638
+ );
2639
+ return;
2640
+ }
2122
2641
  console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
2123
2642
  return;
2124
2643
  }
2125
- kk(
2126
- [
2127
- 'executor',
2128
- 'fan-out',
2129
- '--content',
2130
- prep,
2131
- '--target',
2132
- 'surfdash-pub',
2133
- '--out',
2644
+
2645
+ const intent = readSurfdashSendIntent(intentPath);
2646
+ if (intent.kind === 'invalid') {
2647
+ writeSurfdashUncertainReceipt(result, intentPath, runDate, undefined, undefined, intent.reason);
2648
+ return;
2649
+ }
2650
+
2651
+ let payloadSha256: string | undefined = currentIdentity?.payloadSha256;
2652
+ if (intent.kind === 'valid') {
2653
+ if (intent.value.runDate !== runDate) {
2654
+ writeSurfdashUncertainReceipt(
2655
+ result,
2656
+ intentPath,
2657
+ runDate,
2658
+ intent.value.payloadSha256,
2659
+ undefined,
2660
+ `send intent run date ${intent.value.runDate} does not match requested run date ${runDate}`,
2661
+ );
2662
+ return;
2663
+ }
2664
+ // An existing intent binds this dated post to the original bytes. A changed payload is
2665
+ // never allowed to overwrite the same slug after an interrupted remote send.
2666
+ if (isFile(prep)) payloadSha256 = surfdashPayloadSha256(prep);
2667
+ if (payloadSha256 !== undefined && payloadSha256 !== intent.value.payloadSha256) {
2668
+ writeSurfdashUncertainReceipt(
2669
+ result,
2670
+ intentPath,
2671
+ runDate,
2672
+ intent.value.payloadSha256,
2673
+ payloadSha256,
2674
+ 'prepared payload changed after the send intent',
2675
+ );
2676
+ return;
2677
+ }
2678
+ if (priorState === 'missing') {
2679
+ writeSurfdashUncertainReceipt(
2680
+ result,
2681
+ intentPath,
2682
+ runDate,
2683
+ intent.value.payloadSha256,
2684
+ payloadSha256,
2685
+ 'no publish result was recorded after the prior send intent',
2686
+ );
2687
+ return;
2688
+ }
2689
+ if (payloadSha256 === undefined) {
2690
+ writeSurfdashUncertainReceipt(
2691
+ result,
2692
+ intentPath,
2693
+ runDate,
2694
+ intent.value.payloadSha256,
2695
+ undefined,
2696
+ 'prepared payload is missing, so the original request cannot be replayed safely',
2697
+ );
2698
+ return;
2699
+ }
2700
+ } else {
2701
+ if (!isFile(prep)) failWith(1, `publish-surfdash: required input not found: ${prep}`);
2702
+ payloadSha256 = surfdashPayloadSha256(prep);
2703
+ const sendIntent: SurfdashSendIntent = {
2704
+ version: 1,
2705
+ target: 'surfdash-pub',
2706
+ runDate,
2707
+ payloadPath: prep,
2708
+ payloadSha256,
2709
+ createdAt: new Date().toISOString(),
2710
+ };
2711
+ sted('write', intentPath, () => writeJsonText(intentPath, sendIntent));
2712
+ }
2713
+
2714
+ if (requireLocaleValidation === 'true' && currentIdentity !== undefined) {
2715
+ const prepared = requireJson(prep, 'publish-surfdash');
2716
+ sted('write', preparedInput, () =>
2717
+ writeJqText(preparedInput, {
2718
+ ...prepared,
2719
+ options: {
2720
+ operation: 'publish-prepared',
2721
+ postPath: currentIdentity.postPath,
2722
+ runDate,
2723
+ expectedPostSha256: currentLocaleEvidence.localeDigests.zh,
2724
+ expectedLocaleFingerprint: currentIdentity.localeFingerprint,
2725
+ },
2726
+ }),
2727
+ );
2728
+ // `kk executor run` normally clears --out itself, but a discovery failure can return
2729
+ // before the executor owns that path. Remove any previous transport receipt here so a
2730
+ // stale success can never be promoted into this invocation's publish result.
2731
+ rmSync(transportResult, { force: true });
2732
+ kk(
2733
+ [
2734
+ 'executor',
2735
+ 'run',
2736
+ 'surfdash-pub',
2737
+ '--in',
2738
+ preparedInput,
2739
+ '--out',
2740
+ transportResult,
2741
+ ...pluginsArgs(pluginsPath),
2742
+ ],
2743
+ {
2744
+ unset: NESTED_SPUR_UNSET,
2745
+ env: { SURFDASH_LOCALE: 'zh' },
2746
+ soft: true,
2747
+ },
2748
+ );
2749
+ const transported = readJson(transportResult);
2750
+ const identityMetadata = {
2751
+ postPath: currentIdentity.postPath,
2752
+ payloadSha256: currentIdentity.payloadSha256,
2753
+ localeFingerprint: currentIdentity.localeFingerprint,
2754
+ runDate,
2755
+ };
2756
+ if (transported?.ok === true) {
2757
+ sted('write', result, () =>
2758
+ writeJsonText(result, {
2759
+ ok: true,
2760
+ summary: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
2761
+ targets: [
2762
+ {
2763
+ target: 'surfdash-pub',
2764
+ ok: true,
2765
+ status: 'published',
2766
+ ...(typeof transported.url === 'string' ? { url: transported.url } : {}),
2767
+ metadata: { ...metadataOf(transported), ...identityMetadata },
2768
+ },
2769
+ ],
2770
+ }),
2771
+ );
2772
+ } else if (transported?.ok === false && metadataOf(transported).deliveryState === 'not-sent') {
2773
+ sted('write', result, () =>
2774
+ writeJsonText(result, {
2775
+ ok: false,
2776
+ summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
2777
+ targets: [
2778
+ {
2779
+ target: 'surfdash-pub',
2780
+ ok: false,
2781
+ status: 'failed',
2782
+ error:
2783
+ typeof transported.error === 'string'
2784
+ ? transported.error
2785
+ : 'publisher rejected the prepared post',
2786
+ metadata: {
2787
+ ...metadataOf(transported),
2788
+ ...identityMetadata,
2789
+ deliveryState: 'not-sent',
2790
+ },
2791
+ },
2792
+ ],
2793
+ }),
2794
+ );
2795
+ } else {
2796
+ writeSurfdashUncertainReceipt(
2797
+ result,
2798
+ intentPath,
2799
+ runDate,
2800
+ payloadSha256,
2801
+ payloadSha256,
2802
+ typeof transported?.error === 'string'
2803
+ ? `publisher reported an ambiguous delivery failure: ${transported.error}`
2804
+ : 'no publish result was recorded after prepared dispatch',
2805
+ currentIdentity,
2806
+ );
2807
+ }
2808
+ } else {
2809
+ kk(
2810
+ [
2811
+ 'executor',
2812
+ 'fan-out',
2813
+ '--content',
2814
+ prep,
2815
+ '--target',
2816
+ 'surfdash-pub',
2817
+ '--out',
2818
+ result,
2819
+ ...pluginsArgs(pluginsPath),
2820
+ ],
2821
+ {
2822
+ unset: NESTED_SPUR_UNSET,
2823
+ env: { SURFDASH_LOCALE: 'zh' },
2824
+ soft: true,
2825
+ },
2826
+ );
2827
+ }
2828
+
2829
+ // A killed or unusable child can leave a real remote commit with no aggregate receipt. The
2830
+ // intent now makes that state explicit; the next invocation will refuse to dispatch again.
2831
+ const afterState = surfdashTargetState(readJson(result));
2832
+ if (afterState === 'missing' || afterState === 'uncertain') {
2833
+ const afterTarget = surfdashTargetEntries(readJson(result))[0];
2834
+ const afterError =
2835
+ afterState === 'uncertain' && afterTarget !== undefined && typeof afterTarget.error === 'string'
2836
+ ? `publisher reported an ambiguous delivery failure: ${afterTarget.error}`
2837
+ : 'no publish result was recorded after dispatch';
2838
+ writeSurfdashUncertainReceipt(
2134
2839
  result,
2135
- ...pluginsArgs(pluginsPath),
2136
- ],
2137
- {
2138
- unset: NESTED_SPUR_UNSET,
2139
- soft: true,
2140
- },
2141
- );
2840
+ intentPath,
2841
+ runDate,
2842
+ payloadSha256,
2843
+ payloadSha256,
2844
+ afterError,
2845
+ currentIdentity,
2846
+ );
2847
+ }
2142
2848
  console.log('publish-surfdash: surfdash target dispatched; classification happens after podcast publish');
2143
2849
  });
2144
2850
  }
2145
2851
 
2852
+ /**
2853
+ * Prepare the local zh post before locale scaffolding/translation. This stage deliberately stops
2854
+ * at the publisher's file-materialization seam; the normal `publish-surfdash` stage remains the
2855
+ * only operation allowed to invoke Surfdash's refine/CMS/git publishing path.
2856
+ */
2857
+ export function dailyPrepareSurfdash(args: string[]): void {
2858
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
2859
+ const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
2860
+ const input = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_content.json`);
2861
+ const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_result.json`);
2862
+ const transportResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare-transport_result.json`);
2863
+ timed(workDir, 'prepare-surfdash', () => {
2864
+ try {
2865
+ if (!isFile(prep)) failWith(1, `prepare-surfdash: required input not found: ${prep}`);
2866
+ const content = requireJson(prep, 'prepare-surfdash');
2867
+ sted('write', input, () => writeJqText(input, { ...content, options: { operation: 'prepare' } }));
2868
+ kk(
2869
+ [
2870
+ 'executor',
2871
+ 'run',
2872
+ 'surfdash-pub',
2873
+ '--in',
2874
+ input,
2875
+ '--out',
2876
+ transportResult,
2877
+ ...pluginsArgs(pluginsPath),
2878
+ ],
2879
+ {
2880
+ unset: NESTED_SPUR_UNSET,
2881
+ // The scheduled source post is always zh. Do not let a caller's inherited
2882
+ // locale select an en/ja materialization and then make the sibling gate
2883
+ // compare the wrong source tree.
2884
+ env: { SURFDASH_LOCALE: 'zh' },
2885
+ },
2886
+ );
2887
+ const prepared = readJson(transportResult);
2888
+ const postPath = metadataOf(prepared).postPath;
2889
+ if (prepared?.ok !== true || typeof postPath !== 'string') {
2890
+ failWith(1, `prepare-surfdash: publisher did not return a prepared zh post path: ${transportResult}`);
2891
+ }
2892
+ if (postPath === '' || !isFile(postPath)) {
2893
+ failWith(1, `prepare-surfdash: publisher did not return a prepared zh post path: ${result}`);
2894
+ }
2895
+ sted('write', result, () =>
2896
+ writeJqText(result, {
2897
+ ok: true,
2898
+ summary: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
2899
+ targets: [
2900
+ {
2901
+ target: 'surfdash-pub',
2902
+ ok: true,
2903
+ status: 'prepared',
2904
+ ...(typeof prepared?.url === 'string' ? { url: prepared.url } : {}),
2905
+ metadata: metadataOf(prepared),
2906
+ },
2907
+ ],
2908
+ }),
2909
+ );
2910
+ console.log(`prepare-surfdash: prepared zh post ${postPath}`);
2911
+ } catch (error: unknown) {
2912
+ if (error instanceof StageFailure && error.status === 127) throw error;
2913
+ const message = error instanceof Error ? error.message : String(error);
2914
+ sted('write', result, () =>
2915
+ writeJqText(result, {
2916
+ ok: false,
2917
+ summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
2918
+ targets: [{ target: 'surfdash-pub', ok: false, status: 'failed', error: message }],
2919
+ }),
2920
+ );
2921
+ console.error(message);
2922
+ }
2923
+ });
2924
+ }
2925
+
2146
2926
  /**
2147
2927
  * WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
2148
2928
  * draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
@@ -2327,6 +3107,30 @@ export function dailyQualityReport(args: string[]): void {
2327
3107
  });
2328
3108
  }
2329
3109
 
3110
+ /**
3111
+ * 0329 F23 — the verdict half of `quality`. The report only printed, so a failed audio qc sailed
3112
+ * on into show-notes, the bundle and the publish windows (2026-10-25: an episode at 41/100 was
3113
+ * packaged and queued for tier-0 sends). Reads the same `metadata.qc` as `daily-quality-report`
3114
+ * and fails loud when the verdict is missing or `passed` is false.
3115
+ */
3116
+ export function dailyQualityVerdict(args: string[]): void {
3117
+ const [workDir = '', runDate = ''] = args;
3118
+ const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
3119
+ timed(workDir, 'quality-verdict', () => {
3120
+ if (!isFile(content)) failWith(1, `quality-verdict: no audio content: ${content}`);
3121
+ const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
3122
+ if (qc === undefined) failWith(1, `quality-verdict: no qc verdict in ${content}`);
3123
+ if (qc.passed !== true) {
3124
+ const issues = Array.isArray(qc.criticalIssues) ? qc.criticalIssues : [];
3125
+ failWith(
3126
+ 1,
3127
+ `quality-verdict: audio qc failed (overallScore ${String(qc.overallScore ?? '?')}) — ${issues.map(String).join('; ') || 'no issue detail on the verdict'}`,
3128
+ );
3129
+ }
3130
+ console.log(`quality-verdict: audio qc passed (overallScore ${String(qc.overallScore ?? '?')})`);
3131
+ });
3132
+ }
3133
+
2330
3134
  /**
2331
3135
  * 0171 R3: Apple Podcasts reads the enclosure's ID3 tags, so the published MP3 carries
2332
3136
  * title/artist/album/date. `-c copy` moves the audio frames without a re-encode, and the temp
@@ -2357,18 +3161,19 @@ export function dailyQualityReport(args: string[]): void {
2357
3161
  * Fail-closed where it matters: a missing report, verdict or confidence still blocks.
2358
3162
  */
2359
3163
  export function dailySafetyGate(args: string[]): void {
2360
- const [workDir = '', runDate = '', maxReviseArg = '0'] = args;
3164
+ const [workDir = '', runDate = '', maxReviseArg = '0', scopeDir = ''] = args;
3165
+ const drafts = isDraftsScope(scopeDir);
2361
3166
  const maxRevise = Number.parseInt(maxReviseArg, 10);
2362
- const statusFile = safetyPath(workDir, runDate, 'safety-judge_status.txt');
2363
- const stampFile = safetyPath(workDir, runDate, 'safety-judge_stamp.json');
3167
+ const statusFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_status.txt');
3168
+ const stampFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_stamp.json');
2364
3169
  timed(workDir, 'safety-judge', () => {
2365
- const request = readJson(safetyPath(workDir, runDate, 'safety-review_request.json'));
2366
- const report = readJson(safetyPath(workDir, runDate, 'safety-review_report.json'));
2367
- const verdict = readJson(safetyPath(workDir, runDate, 'safety-judge_verdict.json'));
3170
+ const request = readJson(scopeArtifact(workDir, scopeDir, runDate, 'safety-review_request.json'));
3171
+ const report = readJson(scopeArtifact(workDir, scopeDir, runDate, 'safety-review_report.json'));
3172
+ const verdict = readJson(scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_verdict.json'));
2368
3173
  const platforms = Array.isArray(report?.platforms) ? (report.platforms as Record<string, unknown>[]) : [];
2369
3174
  const decisions = platforms.map((platform) => platform?.decision);
2370
- const digest = safetyDigest(workDir, runDate);
2371
- const revisions = readSafetyReviseCount(workDir);
3175
+ const digest = scopeDigest(workDir, scopeDir, runDate);
3176
+ const revisions = readScopeCounter(workDir, scopeDir);
2372
3177
  const platformFindings = (platform: Record<string, unknown>): Record<string, unknown>[] =>
2373
3178
  Array.isArray(platform?.findings) ? (platform.findings as Record<string, unknown>[]) : [];
2374
3179
  // 20261007 narrowing (operator decision): on a clean pass, only a finding the reviewer
@@ -2399,9 +3204,12 @@ export function dailySafetyGate(args: string[]): void {
2399
3204
  // data-handling), which is where an independent reviewer can grade it.
2400
3205
  const blockedPlatforms = platforms.filter((platform) => platform?.decision === 'BLOCK');
2401
3206
  const excludedPlatforms = blockedPlatforms.map((platform) => String(platform?.platform ?? '')).filter(Boolean);
2402
- const piiBlock = blockedPlatforms.some((platform) =>
2403
- platformFindings(platform).some((finding) => Number(finding?.category) === PII_FINDING_CATEGORY),
2404
- );
3207
+ // 20261104: a category-3 `note` is the reviewer recording that it checked and found NO PII —
3208
+ // counting it turned an admission-only BLOCK into a whole-run block. Any other disposition,
3209
+ // or none (legacy reports), still counts: fail-closed.
3210
+ const isPiiFinding = (finding: Record<string, unknown>): boolean =>
3211
+ Number(finding?.category) === PII_FINDING_CATEGORY && finding?.disposition !== 'note';
3212
+ const piiBlock = blockedPlatforms.some((platform) => platformFindings(platform).some(isPiiFinding));
2405
3213
  // A BLOCK that names no finding is a contract violation, not a signal: there is nothing to
2406
3214
  // remediate and nothing to exclude, so it blocks (fail-closed).
2407
3215
  const unnamedBlock =
@@ -2465,7 +3273,7 @@ export function dailySafetyGate(args: string[]): void {
2465
3273
  status = 'block';
2466
3274
  const named = blockedPlatforms
2467
3275
  .flatMap((platform) => platformFindings(platform))
2468
- .filter((finding) => Number(finding?.category) === PII_FINDING_CATEGORY)
3276
+ .filter(isPiiFinding)
2469
3277
  .map((finding) => `${String(finding?.item ?? '?')}(category ${String(finding?.category)})`)
2470
3278
  .join(', ');
2471
3279
  const criteria = verdictFails.map((finding) => String(finding?.criterion)).join(', ');
@@ -2492,11 +3300,12 @@ export function dailySafetyGate(args: string[]): void {
2492
3300
  if (status === 'pass' || status === 'escalate' || status === 'exclude') {
2493
3301
  sted('write', stampFile, () => writeJsonText(stampFile, { digest, status, excludedPlatforms }));
2494
3302
  }
2495
- if (status === 'block' && !edited) {
3303
+ if (status === 'block' && drafts && !edited) {
2496
3304
  // A quality block poisons the isFile() draft cache: every same-day retry would
2497
3305
  // re-fail on unchanged bytes (20261002 dogfood — two judge-fail runs replayed until
2498
3306
  // the drafts were moved by hand). Quarantine (mv, never rm) so the next run
2499
3307
  // regenerates from scratch; the 09_* verdict artifacts stay in 24-script for audit.
3308
+ // Variants scope never quarantines: the bundle is regenerated, not hand-repaired.
2500
3309
  const q = join(workDir, '24-script', `.quarantine-${runDate}-judge-block`);
2501
3310
  mkdirSync(q, { recursive: true });
2502
3311
  for (const path of safetyDraftPaths(workDir, runDate)) {
@@ -2537,10 +3346,27 @@ export function dailyPreflight(args: string[]): void {
2537
3346
  String(match[1]),
2538
3347
  ),
2539
3348
  );
3349
+ // 0329 F21: the `publish-matrix-*` stages never appear in kk-workflow-stages' switch —
3350
+ // `kk stage` routes them through apps/cli's publish-matrix-stage.ts — so without this
3351
+ // roster every workflow invoking them died as runner-stale.
3352
+ const PUBLISH_MATRIX_STAGES = [
3353
+ 'publish-matrix-plan',
3354
+ 'publish-matrix-check',
3355
+ 'publish-matrix-send',
3356
+ 'publish-matrix-classify',
3357
+ 'publish-matrix-bundle',
3358
+ 'publish-matrix-rss-gate',
3359
+ 'publish-matrix-wait-until',
3360
+ 'publish-matrix-validate',
3361
+ 'publish-matrix-settle',
3362
+ ] as const;
3363
+ for (const name of PUBLISH_MATRIX_STAGES) known.add(name);
2540
3364
  const missing: string[] = [];
2541
3365
  for (const file of files) {
2542
3366
  if (!isFile(file)) failWith(1, `daily-preflight.workflow-missing: ${file}`);
2543
- for (const match of readFileSync(file, 'utf-8').matchAll(/\bkk stage ([a-z][a-z0-9-]*)/g)) {
3367
+ // 0329 F21: accept both spellings — `kk stage <name>` and the ADR-037 canonical
3368
+ // `kk stage run <name>`.
3369
+ for (const match of readFileSync(file, 'utf-8').matchAll(/\bkk stage (?:run )?([a-z][a-z0-9-]*)/g)) {
2544
3370
  const name = String(match[1]);
2545
3371
  if (!known.has(name)) missing.push(`${name} (needed by ${basename(file)})`);
2546
3372
  }
@@ -2571,11 +3397,12 @@ export function dailyPreflight(args: string[]): void {
2571
3397
  * offending value on stderr. Never exits non-zero: routing belongs to the transitions.
2572
3398
  */
2573
3399
  export function dailySafetyVerdict(args: string[]): void {
2574
- const [workDir = '', runDate = ''] = args;
2575
- const statusFile = safetyPath(workDir, runDate, 'safety-judge_status.txt');
2576
- const stampFile = safetyPath(workDir, runDate, 'safety-judge_stamp.json');
2577
- const decisionFile = safetyPath(workDir, runDate, 'safety-verdict.decision');
2578
- const token = safetyPath(workDir, runDate, 'safety-verdict.status');
3400
+ const [workDir = '', runDate = '', scopeDir = ''] = args;
3401
+ const drafts = isDraftsScope(scopeDir);
3402
+ const statusFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_status.txt');
3403
+ const stampFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_stamp.json');
3404
+ const decisionFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-verdict.decision');
3405
+ const token = scopeArtifact(workDir, scopeDir, runDate, 'safety-verdict.status');
2579
3406
  const ADMITTED = new Set(['pass', 'escalate', 'exclude']);
2580
3407
  timed(workDir, 'safety-verdict-check', () => {
2581
3408
  const status = isFile(statusFile) ? readFileSync(statusFile, 'utf-8').trim() : '';
@@ -2595,8 +3422,51 @@ export function dailySafetyVerdict(args: string[]): void {
2595
3422
  } else if (!ADMITTED.has(verdict)) {
2596
3423
  refusal = `kk-daily-safety.judge.decision-refused: kk-safety-verdict [${verdict}] must admit pass|escalate|exclude, so these drafts must not publish`;
2597
3424
  }
3425
+ // 0331 variants scope: an admitted outcome also writes the evidence the publish matrix
3426
+ // freezes into the bundle — status, the exclusion record and the sha256 of every reviewed
3427
+ // variant's CURRENT bytes. Recomputed here (not copied from the stamp) so bytes edited
3428
+ // after the gate's verdict are caught at the last instant; a mismatch is a refusal, never
3429
+ // silently re-bound evidence.
3430
+ let evidence: Record<string, unknown> | null = null;
3431
+ if (refusal === '' && !drafts) {
3432
+ const stampJson = readJson(stampFile) as {
3433
+ digest?: unknown;
3434
+ status?: unknown;
3435
+ excludedPlatforms?: unknown;
3436
+ } | null;
3437
+ const manifest = readManifest(workDir);
3438
+ const hash = createHash('sha256');
3439
+ const variants: Record<string, string> = {};
3440
+ for (const key of Object.keys(manifest.variants).sort()) {
3441
+ const path = join(workDir, manifest.variants[key].path);
3442
+ if (!isFile(path)) {
3443
+ refusal = `kk-daily-safety.judge.verdict-refused: registered variant file is missing: ${path} — re-run the review on a fresh manifest`;
3444
+ break;
3445
+ }
3446
+ const sha = createHash('sha256').update(readFileSync(path)).digest('hex');
3447
+ variants[key] = sha;
3448
+ hash.update(key).update('\0').update(readFileSync(path)).update('\0');
3449
+ }
3450
+ const digest = hash.digest('hex');
3451
+ if (refusal === '' && stampJson?.digest !== digest) {
3452
+ refusal = `kk-daily-safety.judge.verdict-refused: variant bytes changed after the review (reviewed ${String(stampJson?.digest ?? 'missing')}, on disk ${digest}) — re-run the variants safety review`;
3453
+ }
3454
+ if (refusal === '') {
3455
+ evidence = {
3456
+ status: stampJson?.status,
3457
+ digest,
3458
+ excludedPlatforms: Array.isArray(stampJson?.excludedPlatforms) ? stampJson.excludedPlatforms : [],
3459
+ variants,
3460
+ };
3461
+ }
3462
+ }
2598
3463
  if (refusal !== '') console.error(refusal);
2599
3464
  sted('write', token, () => writeRaw(token, refusal === '' ? 'accept' : 'refuse'));
3465
+ if (evidence !== null) {
3466
+ const evidencePath = join(scopeDirOf(workDir, scopeDir), 'safety.json');
3467
+ sted('write', evidencePath, () => writeJsonText(evidencePath, evidence));
3468
+ console.log(`safety-verdict: evidence written to ${evidencePath}`);
3469
+ }
2600
3470
  });
2601
3471
  }
2602
3472
 
@@ -2607,41 +3477,183 @@ export function dailySafetyVerdict(args: string[]): void {
2607
3477
  * here fails inside the loop instead of at `wrap-docs`, after the run has moved on. A broken
2608
3478
  * revision spends the budget too: an unparsable draft must not be retried forever.
2609
3479
  */
3480
+ // ---- 0331 scope machinery -------------------------------------------------
3481
+ // The nested kk-daily-safety machine serves two review surfaces: the classic drafts scope
3482
+ // (article + VoiceScript under 24-script) and the variants scope (final publish variants under
3483
+ // 30-variants, registered by daily-safety-manifest, artifacts under 4-safety-variants). The scope
3484
+ // is ONE trailing `scope_dir` argument: `24-script` (or empty) keeps every path byte-identical to
3485
+ // the pre-0331 behavior, any other value re-roots the artifact set and switches the digest to the
3486
+ // manifest-listed variant bytes.
3487
+
3488
+ export const SAFETY_VARIANTS_DIR = '4-safety-variants';
3489
+
3490
+ function isDraftsScope(scopeDir: string): boolean {
3491
+ const dir = scopeDir.trim();
3492
+ return dir === '' || dir === '24-script';
3493
+ }
3494
+
3495
+ function scopeDirOf(workDir: string, scopeDir: string): string {
3496
+ return isDraftsScope(scopeDir) ? join(workDir, '24-script') : join(workDir, scopeDir.trim());
3497
+ }
3498
+
3499
+ function scopeArtifact(workDir: string, scopeDir: string, runDate: string, name: string): string {
3500
+ return join(scopeDirOf(workDir, scopeDir), `${runDate}_09_${name}`);
3501
+ }
3502
+
3503
+ /** The remediation budget is per scope: a variants review never inherits the drafts pass count. */
3504
+ function scopeCounter(workDir: string, scopeDir: string): string {
3505
+ return isDraftsScope(scopeDir)
3506
+ ? join(workDir, SAFETY_REVISE_COUNT)
3507
+ : join(workDir, `${SAFETY_REVISE_COUNT}-${scopeDir.trim().replace(/[^\w-]/g, '_')}`);
3508
+ }
3509
+
3510
+ function readScopeCounter(workDir: string, scopeDir: string): number {
3511
+ const path = scopeCounter(workDir, scopeDir);
3512
+ if (!isFile(path)) return 0;
3513
+ const value = Number.parseInt(readFileSync(path, 'utf-8'), 10);
3514
+ return Number.isNaN(value) ? 0 : value;
3515
+ }
3516
+
3517
+ interface SafetyManifest {
3518
+ schemaVersion: number;
3519
+ runDate: string;
3520
+ variants: Record<string, { path: string; sha256: string }>;
3521
+ }
3522
+
3523
+ function readManifest(workDir: string): SafetyManifest {
3524
+ const path = join(workDir, SAFETY_VARIANTS_DIR, 'manifest.json');
3525
+ const manifest = readJson(path) as SafetyManifest | null;
3526
+ if (manifest === null || !manifest.variants || typeof manifest.variants !== 'object') {
3527
+ failWith(
3528
+ 1,
3529
+ `safety-variants: ${path} is missing or malformed — run "kk stage run daily-safety-manifest <work_dir> <run_date>" first`,
3530
+ );
3531
+ }
3532
+ return manifest;
3533
+ }
3534
+
3535
+ /** Digest over the exact bytes under review: drafts = safetyDigest; variants = sorted manifest keys + current bytes. */
3536
+ function scopeDigest(workDir: string, scopeDir: string, runDate: string): string {
3537
+ if (isDraftsScope(scopeDir)) return safetyDigest(workDir, runDate);
3538
+ const manifest = readManifest(workDir);
3539
+ const hash = createHash('sha256');
3540
+ for (const key of Object.keys(manifest.variants).sort()) {
3541
+ const path = join(workDir, manifest.variants[key].path);
3542
+ if (!isFile(path)) {
3543
+ failWith(
3544
+ 1,
3545
+ `safety-variants: registered variant file is missing: ${path} — re-run daily-bundle-variants and the manifest`,
3546
+ );
3547
+ }
3548
+ hash.update(key).update('\0').update(readFileSync(path)).update('\0');
3549
+ }
3550
+ return hash.digest('hex');
3551
+ }
3552
+
3553
+ /** The `content` array the review request hands the agents: what to read, per scope. */
3554
+ function scopeContent(workDir: string, scopeDir: string, runDate: string): unknown[] {
3555
+ if (isDraftsScope(scopeDir)) return safetyDraftPaths(workDir, runDate);
3556
+ return Object.entries(readManifest(workDir).variants)
3557
+ .sort(([a], [b]) => (a < b ? -1 : 1))
3558
+ .map(([key, entry]) => ({ key, path: entry.path, sha256: entry.sha256 }));
3559
+ }
3560
+
3561
+ /**
3562
+ * Register the run's final publish variants for the variants-scope safety review (0331): every
3563
+ * `30-variants/*.content.json` becomes one manifest entry keyed by its file name with `_` read as
3564
+ * the plugin.operation separator. The manifest is the review contract — the request digest, the
3565
+ * gate's edit check and the bundle's frozen evidence all resolve through it, so an empty or
3566
+ * missing variants directory fails here, before any reviewer spends tokens.
3567
+ */
3568
+ export function dailySafetyManifest(args: string[]): void {
3569
+ const [workDir = '', runDate = ''] = args;
3570
+ timed(workDir, 'safety-manifest', () => {
3571
+ const dir = join(workDir, '30-variants');
3572
+ if (!existsSync(dir)) {
3573
+ failWith(1, `safety-manifest: ${dir} does not exist — run the bundle-variants stage first`);
3574
+ }
3575
+ const files = readdirSync(dir)
3576
+ .filter((file) => file.endsWith('.content.json'))
3577
+ .sort();
3578
+ if (files.length === 0) {
3579
+ failWith(1, `safety-manifest: no *.content.json under ${dir} — nothing to review; was the bundle built?`);
3580
+ }
3581
+ const variants: Record<string, { path: string; sha256: string }> = {};
3582
+ for (const file of files) {
3583
+ const text = readFileSync(join(dir, file), 'utf-8');
3584
+ variants[file.replace(/\.content\.json$/, '').replace(/_/g, ':')] = {
3585
+ path: `30-variants/${file}`,
3586
+ sha256: createHash('sha256').update(text).digest('hex'),
3587
+ };
3588
+ }
3589
+ mkdirSync(join(workDir, SAFETY_VARIANTS_DIR), { recursive: true });
3590
+ const manifestPath = join(workDir, SAFETY_VARIANTS_DIR, 'manifest.json');
3591
+ sted('write', manifestPath, () => writeJsonText(manifestPath, { schemaVersion: 1, runDate, variants }));
3592
+ console.log(`safety-manifest: ${files.length} variant(s) registered for review`);
3593
+ });
3594
+ }
3595
+
2610
3596
  /**
2611
3597
  * Open a content-safety review of the current drafts: drop every artifact of an earlier review
2612
3598
  * (a retry must be re-reviewed on its final version) and write the request the two agent.run
2613
3599
  * steps read. The program passes context only — platform policy lives in the kk:content-safety
2614
- * skill's reference files, never here.
3600
+ * skill's reference files, never here. 0331: an optional trailing scope_dir re-roots the whole
3601
+ * artifact set for the variants review (see the scope machinery above).
2615
3602
  */
2616
3603
  export function dailySafetyPrep(args: string[]): void {
2617
- const [workDir = '', runDate = '', platforms = ''] = args;
3604
+ const [workDir = '', runDate = '', platforms = '', scopeDir = ''] = args;
3605
+ const drafts = isDraftsScope(scopeDir);
2618
3606
  timed(workDir, 'safety-review', () => {
2619
- const digest = safetyDigest(workDir, runDate);
2620
- // Structural snapshot of the VoiceScript BEFORE any remediation edit. `daily-safety-revise`
2621
- // restores it when the remediation pass changes the item-section structure without a recorded
2622
- // exclusion: the agent is allowed to drop a 播报形态 line, not an item the article kept,
2623
- // because `daily-wrap-docs` derives the spoken item sections from `gap_ms: 1200` and fails the
2624
- // run when they no longer match the episode's included items (2026-10-21 04:30 run: 3
2625
- // segments removed → 5 sections vs a 6-item plan → `generate` refused at 05:03 and the whole
2626
- // day published nothing).
2627
- const [, scriptPath] = safetyDraftPaths(workDir, runDate);
2628
- const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
2629
- if (isFile(scriptPath)) sted('write', snapshot, () => writeRaw(snapshot, readFileSync(scriptPath, 'utf-8')));
3607
+ const digest = scopeDigest(workDir, scopeDir, runDate);
3608
+ if (drafts) {
3609
+ // Structural snapshot of the VoiceScript BEFORE any remediation edit. `daily-safety-revise`
3610
+ // restores it when the remediation pass changes the item-section structure without a recorded
3611
+ // exclusion: the agent is allowed to drop a 播报形态 line, not an item the article kept,
3612
+ // because `daily-wrap-docs` derives the spoken item sections from `gap_ms: 1200` and fails the
3613
+ // run when they no longer match the episode's included items (2026-10-21 04:30 run: 3
3614
+ // segments removed → 5 sections vs a 6-item plan → `generate` refused at 05:03 and the whole
3615
+ // day published nothing).
3616
+ const [, scriptPath] = safetyDraftPaths(workDir, runDate);
3617
+ const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
3618
+ if (isFile(scriptPath))
3619
+ sted('write', snapshot, () => writeRaw(snapshot, readFileSync(scriptPath, 'utf-8')));
3620
+ } else {
3621
+ // The remediation budget belongs to this scope's attempt, not to the day.
3622
+ const counter = scopeCounter(workDir, scopeDir);
3623
+ sted('write', counter, () => writeFileSync(counter, '0\n'));
3624
+ }
2630
3625
  for (const name of [
2631
3626
  'safety-review_report.json',
2632
3627
  'safety-judge_verdict.json',
2633
3628
  'safety-judge_status.txt',
2634
3629
  'safety-judge_stamp.json',
3630
+ 'safety-verdict.decision',
3631
+ 'safety-verdict.status',
3632
+ // A previous review's evidence must never survive into a new request: the bundle
3633
+ // freezes exactly what the current request's verdict admits. Evidence is the one
3634
+ // unprefixed artifact, so the runDate-prefixed sweep would miss it.
3635
+ 'safety.json',
2635
3636
  ]) {
2636
- const stale = safetyPath(workDir, runDate, name);
3637
+ const stale =
3638
+ name === 'safety.json' && !drafts
3639
+ ? join(scopeDirOf(workDir, scopeDir), name)
3640
+ : scopeArtifact(workDir, scopeDir, runDate, name);
2637
3641
  if (isFile(stale)) unlinkSync(stale);
2638
3642
  }
2639
- const request = safetyPath(workDir, runDate, 'safety-review_request.json');
3643
+ const request = scopeArtifact(workDir, scopeDir, runDate, 'safety-review_request.json');
2640
3644
  const targets = platforms
2641
3645
  .split(',')
2642
3646
  .map((platform) => platform.trim())
2643
3647
  .filter(Boolean);
2644
- sted('write', request, () => writeJsonText(request, { digest, platforms: targets, mode: 'detect-only' }));
3648
+ sted('write', request, () =>
3649
+ writeJsonText(request, {
3650
+ digest,
3651
+ platforms: targets,
3652
+ mode: 'detect-only',
3653
+ scope: drafts ? 'drafts' : 'variants',
3654
+ content: scopeContent(workDir, scopeDir, runDate),
3655
+ }),
3656
+ );
2645
3657
  });
2646
3658
  }
2647
3659
 
@@ -2675,8 +3687,30 @@ export function dailySafetyPrep(args: string[]): void {
2675
3687
  * revision spends the budget too: an unparsable draft must not be retried forever.
2676
3688
  */
2677
3689
  export function dailySafetyRevise(args: string[]): void {
2678
- const [workDir = '', runDate = ''] = args;
3690
+ const [workDir = '', runDate = '', scopeDir = ''] = args;
3691
+ const drafts = isDraftsScope(scopeDir);
2679
3692
  timed(workDir, 'safety-revise', () => {
3693
+ if (!drafts) {
3694
+ // Variants scope: every manifest-listed file must stay parseable Content with the
3695
+ // fields the publish half reads; the structural VoiceScript machinery does not apply.
3696
+ for (const entry of Object.values(readManifest(workDir).variants)) {
3697
+ const path = join(workDir, entry.path);
3698
+ const value = requireJson(path, 'safety-revise') as {
3699
+ title?: unknown;
3700
+ body?: unknown;
3701
+ };
3702
+ for (const field of ['title', 'body'] as const) {
3703
+ if (typeof value[field] !== 'string' || String(value[field]).trim() === '') {
3704
+ failWith(1, `safety-revise: variant ${path} ${field} must be a non-empty string`);
3705
+ }
3706
+ }
3707
+ }
3708
+ const counter = scopeCounter(workDir, scopeDir);
3709
+ const next = readScopeCounter(workDir, scopeDir) + 1;
3710
+ sted('write', counter, () => writeFileSync(counter, `${next}\n`));
3711
+ console.log(`safety-revise: remediation pass ${next} applied to manifest variants; re-validated`);
3712
+ return;
3713
+ }
2680
3714
  const [articlePath, scriptPath] = safetyDraftPaths(workDir, runDate);
2681
3715
  const article = requireJson(articlePath, 'safety-revise');
2682
3716
  for (const field of ['title', 'body'] as const) {
@@ -2688,7 +3722,7 @@ export function dailySafetyRevise(args: string[]): void {
2688
3722
  assertScriptContent(scriptPath);
2689
3723
  restoreStructureIfBroken(workDir, runDate, scriptPath);
2690
3724
  const path = join(workDir, SAFETY_REVISE_COUNT);
2691
- const next = readSafetyReviseCount(workDir) + 1;
3725
+ const next = readScopeCounter(workDir, scopeDir) + 1;
2692
3726
  sted('write', path, () => writeFileSync(path, `${next}\n`));
2693
3727
  console.log(`safety-revise: remediation pass ${next} applied; drafts re-validated`);
2694
3728
  });
@@ -3063,22 +4097,29 @@ export function dailyShowNotes(args: string[]): void {
3063
4097
  * deterministic mechanics — `scaffold-locale` for both target locales — while the
3064
4098
  * workflow `translate` state's agent step owns the LLM translation of the drafts.
3065
4099
  * 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.
4100
+ * marker = locale absent (callers skip). Reuses an existing on-disk draft only when its
4101
+ * source-hash sidecar matches the current zh post — a changed source must invalidate the
4102
+ * translation cache before the next unattended run.
3068
4103
  */
3069
4104
  export function dailyTranslateLocalize(args: string[]): void {
3070
4105
  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).
4106
+ // The prepare result is available before translation; the final publish result is retained as
4107
+ // a compatibility fallback for manual recovery of pre-two-phase workspaces.
4108
+ const prepareResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_result.json`);
3074
4109
  const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
3075
4110
  const zhFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
3076
4111
  timed(workDir, 'translate-localize', () => {
3077
- const postPath = firstPostPath(readJson(result), 'surfdash-pub');
4112
+ const postPath =
4113
+ firstPostPath(readJson(prepareResult), 'surfdash-pub') || firstPostPath(readJson(result), 'surfdash-pub');
3078
4114
  const zh = postPath.endsWith('index.md') ? postPath : '';
3079
4115
  sted('write', zhFile, () => writeRaw(zhFile, zh));
3080
4116
  if (zh === '') {
3081
4117
  for (const target of ['en', 'ja'] as const) {
4118
+ const sourceHashFile = join(
4119
+ workDir,
4120
+ '31-publish',
4121
+ `${runDate}_20_translate-localize_${target}-source-sha256.txt`,
4122
+ );
3082
4123
  sted(
3083
4124
  'write',
3084
4125
  join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
@@ -3088,18 +4129,37 @@ export function dailyTranslateLocalize(args: string[]): void {
3088
4129
  '',
3089
4130
  ),
3090
4131
  );
4132
+ sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
3091
4133
  }
3092
4134
  console.log('no zh post path — skip localize');
3093
4135
  return;
3094
4136
  }
4137
+ let sourceHash: string;
4138
+ try {
4139
+ sourceHash = createHash('sha256').update(readFileSync(zh)).digest('hex');
4140
+ } catch (error: unknown) {
4141
+ failWith(
4142
+ 1,
4143
+ `translate-localize: cannot fingerprint zh source ${zh}: ${error instanceof Error ? error.message : String(error)}`,
4144
+ );
4145
+ }
3095
4146
  for (const target of ['en', 'ja'] as const) {
3096
4147
  const markerFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`);
3097
4148
  const existing = zh.replace('/zh/', `/${target}/`);
3098
- if (isFile(existing)) {
4149
+ const sourceHashFile = join(
4150
+ workDir,
4151
+ '31-publish',
4152
+ `${runDate}_20_translate-localize_${target}-source-sha256.txt`,
4153
+ );
4154
+ const cachedSourceHash = isFile(sourceHashFile) ? readFileSync(sourceHashFile, 'utf-8').trim() : '';
4155
+ if (isFile(existing) && cachedSourceHash === sourceHash) {
3099
4156
  sted('write', markerFile, () => writeRaw(markerFile, existing));
3100
4157
  console.log(`${target} draft cached: ${existing}`);
3101
4158
  continue;
3102
4159
  }
4160
+ if (isFile(existing)) {
4161
+ console.log(`${target} draft invalidated: zh source fingerprint changed or is missing`);
4162
+ }
3103
4163
  const scaffold = join(workDir, '31-publish', `${runDate}_20_translate-localize_scaffold-${target}.json`);
3104
4164
  const scaffoldResult = join(
3105
4165
  workDir,
@@ -3107,20 +4167,254 @@ export function dailyTranslateLocalize(args: string[]): void {
3107
4167
  `${runDate}_20_translate-localize_scaffold-${target}-result.json`,
3108
4168
  );
3109
4169
  sted('write', scaffold, () =>
3110
- writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target } }),
4170
+ writeJqText(scaffold, {
4171
+ body: '',
4172
+ options: {
4173
+ operation: 'scaffold-locale',
4174
+ postPath: zh,
4175
+ target,
4176
+ ...(isFile(existing) ? { force: true } : {}),
4177
+ },
4178
+ }),
4179
+ );
4180
+ try {
4181
+ kk([
4182
+ 'executor',
4183
+ 'run',
4184
+ 'surfdash-pub',
4185
+ '--in',
4186
+ scaffold,
4187
+ '--out',
4188
+ scaffoldResult,
4189
+ ...pluginsArgs(pluginsPath),
4190
+ ]);
4191
+ const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
4192
+ const draft = typeof draftPath === 'string' ? draftPath : '';
4193
+ sted('write', markerFile, () => writeRaw(markerFile, draft));
4194
+ if (draft !== '') {
4195
+ sted('write', sourceHashFile, () => writeRaw(sourceHashFile, sourceHash));
4196
+ } else {
4197
+ sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
4198
+ }
4199
+ } catch (error: unknown) {
4200
+ if (error instanceof StageFailure && error.status === 127) throw error;
4201
+ const message = error instanceof Error ? error.message : String(error);
4202
+ sted('write', markerFile, () => writeRaw(markerFile, ''));
4203
+ sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
4204
+ console.error(`translate-localize: ${target} scaffold failed: ${message}`);
4205
+ }
4206
+ }
4207
+ });
4208
+ }
4209
+
4210
+ /**
4211
+ * Run Surfdash's own refine command over the complete locale set before the validation fingerprint
4212
+ * is written. This is deliberately a separate local-only phase: the final publish FSM also runs
4213
+ * refine, but it must be idempotent by the time any CMS/git side effect is reachable.
4214
+ */
4215
+ function normalizeTranslatedLocales(zhPostPath: string, failUnsafe = true): void {
4216
+ for (const locale of ['zh', 'en', 'ja'] as const) {
4217
+ const sibling = locale === 'zh' ? zhPostPath : zhPostPath.replace('/zh/', `/${locale}/`);
4218
+ if (!isFile(sibling)) continue;
4219
+ const raw = readFileSync(sibling, 'utf-8');
4220
+ const { markdown, remaining } = normalizeSeriesNames(raw, locale);
4221
+ if (failUnsafe && remaining.length > 0) {
4222
+ failWith(1, `series-name normalization left unsafe names in ${locale}: ${remaining.join(' / ')}`);
4223
+ }
4224
+ if (markdown !== raw) sted('write', sibling, () => writeRaw(sibling, markdown));
4225
+ }
4226
+ }
4227
+
4228
+ /**
4229
+ * The translate agents run with cwd = this repo, and both executor sandboxes (codex workspace-write,
4230
+ * claude acceptEdits) refuse writes into the Surfdash checkout — on 2026-11-04 both exited 0 with
4231
+ * en/ja untouched. So they write `<runDate>_21_translate_<locale>.md` under the work dir and this
4232
+ * installs each over its scaffold. The output is consumed (renamed) so a rerun never clobbers an
4233
+ * operator's later repair of the installed post.
4234
+ */
4235
+ function installTranslatedLocales(workDir: string, runDate: string, zhPostPath: string): void {
4236
+ for (const locale of ['en', 'ja'] as const) {
4237
+ const translated = join(workDir, '31-publish', `${runDate}_21_translate_${locale}.md`);
4238
+ if (!isFile(translated)) continue;
4239
+ const target = zhPostPath.replace('/zh/', `/${locale}/`);
4240
+ sted('write', target, () => writeRaw(target, readFileSync(translated, 'utf-8')));
4241
+ renameSync(translated, `${translated}.installed`);
4242
+ }
4243
+ }
4244
+
4245
+ export function dailySurfdashRefine(args: string[]): void {
4246
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
4247
+ const marker = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
4248
+ const input = join(workDir, '31-publish', `${runDate}_24_surfdash-refine_content.json`);
4249
+ const result = join(workDir, '31-publish', `${runDate}_24_surfdash-refine_result.json`);
4250
+ timed(workDir, 'surfdash-refine', () => {
4251
+ try {
4252
+ const zhPostPath = isFile(marker) ? readFileSync(marker, 'utf-8').trim() : '';
4253
+ if (zhPostPath === '' || !zhPostPath.startsWith('/') || !isFile(zhPostPath)) {
4254
+ failWith(1, `surfdash-refine: zh marker must name an existing post: ${marker}`);
4255
+ }
4256
+ validateLocaleMarkers(workDir, runDate, zhPostPath, 'surfdash-refine');
4257
+ installTranslatedLocales(workDir, runDate, zhPostPath);
4258
+ // The validator is intentionally read-only. Repair known series spellings before
4259
+ // site-owned refine computes managed fields, otherwise the final publisher would
4260
+ // rewrite the refined bytes after the locale fingerprint was admitted.
4261
+ normalizeTranslatedLocales(zhPostPath);
4262
+ sted('write', input, () =>
4263
+ writeJqText(input, {
4264
+ body: '',
4265
+ format: 'markdown',
4266
+ options: { operation: 'refine-prepared', postPath: zhPostPath, runDate },
4267
+ }),
4268
+ );
4269
+ rmSync(result, { force: true });
4270
+ kk(['executor', 'run', 'surfdash-pub', '--in', input, '--out', result, ...pluginsArgs(pluginsPath)], {
4271
+ unset: NESTED_SPUR_UNSET,
4272
+ env: { SURFDASH_LOCALE: 'zh' },
4273
+ });
4274
+ const refined = readJson(result);
4275
+ if (refined?.ok !== true) {
4276
+ failWith(1, `surfdash-refine: publisher did not return a successful refine receipt: ${result}`);
4277
+ }
4278
+ sted('write', result, () =>
4279
+ writeJqText(result, {
4280
+ ok: true,
4281
+ summary: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
4282
+ targets: [
4283
+ {
4284
+ target: 'surfdash-pub',
4285
+ ok: true,
4286
+ status: 'refined',
4287
+ metadata: metadataOf(refined),
4288
+ },
4289
+ ],
4290
+ }),
4291
+ );
4292
+ console.log(`surfdash-refine: site-owned refine completed for ${zhPostPath}`);
4293
+ } catch (error: unknown) {
4294
+ if (error instanceof StageFailure && error.status === 127) throw error;
4295
+ const message = error instanceof Error ? error.message : String(error);
4296
+ sted('write', result, () =>
4297
+ writeJqText(result, {
4298
+ ok: false,
4299
+ summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
4300
+ targets: [{ target: 'surfdash-pub', ok: false, status: 'failed', error: message }],
4301
+ }),
4302
+ );
4303
+ console.error(`surfdash-refine: ${message}`);
4304
+ }
4305
+ });
4306
+ }
4307
+
4308
+ /** Validates locale siblings without contacting Surfdash or mutating their bytes. */
4309
+ function validateTranslatedLocales(zhPostPath: string, step: 'translate-validate' | 'translate-sync'): void {
4310
+ const zhPoints = countKeyPoints(zhPostPath);
4311
+ for (const locale of ['zh', 'en', 'ja'] as const) {
4312
+ const sibling = locale === 'zh' ? zhPostPath : zhPostPath.replace('/zh/', `/${locale}/`);
4313
+ if (!isFile(sibling)) {
4314
+ if (locale !== 'zh' && step === 'translate-validate') {
4315
+ failWith(1, `${step}: ${locale} draft is missing: ${sibling}`);
4316
+ }
4317
+ continue;
4318
+ }
4319
+ const problems: string[] = [];
4320
+ const raw = readFileSync(sibling, 'utf-8');
4321
+ const { markdown, remaining } = normalizeSeriesNames(raw, locale);
4322
+ if (markdown !== raw) problems.push('series-name normalization is pending; run the refine preparation first');
4323
+ if (remaining.length > 0) {
4324
+ problems.push(`retired series names left after rewrite: ${remaining.join(' / ')}`);
4325
+ }
4326
+ const items = readKeyPointItems(sibling);
4327
+ if (step === 'translate-validate') {
4328
+ if (readFrontmatterScalar(sibling, 'refined') !== 'true') {
4329
+ problems.push('site-owned refine has not completed (refined: true is required)');
4330
+ }
4331
+ if (items === undefined || items.length === 0) {
4332
+ problems.push('key_points missing or malformed');
4333
+ } else {
4334
+ const malformed = items.filter((item) => {
4335
+ const separator = item.indexOf(' — ');
4336
+ return separator <= 0 || item.slice(separator + 3).trim() === '';
4337
+ });
4338
+ if (malformed.length > 0) problems.push('key_points missing or malformed');
4339
+ }
4340
+ }
4341
+ if (locale !== 'zh') {
4342
+ if (readFrontmatterScalar(sibling, 'description') === undefined) {
4343
+ problems.push('description missing/empty');
4344
+ }
4345
+ const points = countKeyPoints(sibling);
4346
+ if (zhPoints !== undefined && points !== undefined && points !== zhPoints) {
4347
+ problems.push(`key_points count ${points} != zh ${zhPoints} (spliced or merged items)`);
4348
+ }
4349
+ // The scheduled path must not let an untranslated scaffold reach the strict CMS sync.
4350
+ if (step === 'translate-validate' && readFrontmatterScalar(sibling, 'draft') !== 'false') {
4351
+ problems.push('draft is still true — locale was scaffolded but not translated');
4352
+ }
4353
+ // Label discipline (same 2026-10-23 audit): a key_points item is `Label — sentence`
4354
+ // and the label is a short phrase — ≤14 chars for ja, ≤28 for en — never a
4355
+ // sentence and never an ellipsis-truncated fragment.
4356
+ if (items !== undefined) {
4357
+ const cap = locale === 'en' ? 28 : 14;
4358
+ const overLimit = items.filter((item) => {
4359
+ const separator = item.indexOf(' — ');
4360
+ if (separator < 0) return false;
4361
+ const label = item.slice(0, separator);
4362
+ return label.includes('…') || Array.from(label).length > cap;
4363
+ });
4364
+ if (overLimit.length > 0) {
4365
+ problems.push(
4366
+ `key_points label over ${cap} chars or …-truncated: ${overLimit.length} of ${items.length} items`,
4367
+ );
4368
+ }
4369
+ }
4370
+ }
4371
+ if (problems.length > 0) {
4372
+ failWith(
4373
+ 1,
4374
+ `${step}: ${locale} draft failed the pre-sync structural check (${problems.join('; ')}) — repair ${sibling} per the translate stage contract before syncing`,
3111
4375
  );
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 : ''));
4376
+ }
4377
+ }
4378
+ }
4379
+
4380
+ /** Local pre-CMS gate for the scheduled two-phase Surfdash flow. */
4381
+ export function dailyTranslateValidate(args: string[]): void {
4382
+ const [workDir = '', runDate = '', _pluginsPath = '', zhPathFile = ''] = args;
4383
+ const validationResult = join(workDir, '31-publish', `${runDate}_24_translate-validate_result.json`);
4384
+ timed(workDir, 'translate-validate', () => {
4385
+ const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8').trim() : '';
4386
+ try {
4387
+ if (zhPathFile.endsWith('.md') || zhPostPath === '' || !zhPostPath.startsWith('/') || !isFile(zhPostPath)) {
4388
+ failWith(
4389
+ 1,
4390
+ `translate-validate: 4th argument must name an existing zh marker file (${runDate}_20_translate-localize_zh-post-path.txt)`,
4391
+ );
4392
+ }
4393
+ validateLocaleMarkers(workDir, runDate, zhPostPath, 'translate-validate');
4394
+ validateTranslatedLocales(zhPostPath, 'translate-validate');
4395
+ const evidence = surfdashLocaleValidationEvidence(zhPostPath, runDate);
4396
+ if (!evidence.ok) failWith(1, `translate-validate: ${evidence.reason}`);
4397
+ sted('write', validationResult, () =>
4398
+ writeJqText(validationResult, {
4399
+ ok: true,
4400
+ target: 'surfdash-pub',
4401
+ status: 'validated',
4402
+ metadata: evidence.evidence,
4403
+ }),
4404
+ );
4405
+ console.log(`translate-validate: locale files ready for final Surfdash publish (${zhPostPath})`);
4406
+ } catch (error: unknown) {
4407
+ const message = error instanceof Error ? error.message : String(error);
4408
+ sted('write', validationResult, () =>
4409
+ writeJqText(validationResult, {
4410
+ ok: false,
4411
+ target: 'surfdash-pub',
4412
+ status: 'rejected',
4413
+ error: message,
4414
+ metadata: { postPath: zhPostPath },
4415
+ }),
4416
+ );
4417
+ console.error(`translate-validate: ${message}`);
3124
4418
  }
3125
4419
  });
3126
4420
  }
@@ -3146,58 +4440,11 @@ export function dailyTranslateSync(args: string[]): void {
3146
4440
  `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
4441
  );
3148
4442
  }
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
- }
4443
+ // Preserve the legacy explicit sync repair behavior before the shared read-only gate.
4444
+ normalizeTranslatedLocales(zhPostPath, false);
4445
+ // The 2026-10-23 locale structural gate is shared with the scheduled two-phase validator.
4446
+ // This explicit recovery command still performs the real CMS sync after validation.
4447
+ validateTranslatedLocales(zhPostPath, 'translate-sync');
3201
4448
  sted('write', sync, () =>
3202
4449
  writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
3203
4450
  );