@gobing-ai/knowledge-kit 0.1.11 → 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 (126) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +2359 -1721
  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-stages.ts +346 -48
  88. package/plugins/kk/scripts/kk-workflow-stages.ts +190 -0
  89. package/plugins/kk/scripts/safety-artifact-check.ts +70 -28
  90. package/plugins/kk/scripts/voice3-publish-window.ts +202 -0
  91. package/plugins/kk/skills/article-adapt/SKILL.md +1 -0
  92. package/plugins/kk/skills/audio-authoring/SKILL.md +1 -0
  93. package/plugins/kk/skills/content-judge/SKILL.md +1 -0
  94. package/plugins/kk/skills/content-safety/SKILL.md +1 -0
  95. package/plugins/kk/skills/explain-things/SKILL.md +1 -0
  96. package/plugins/kk/skills/image-authoring/SKILL.md +1 -0
  97. package/plugins/kk/skills/itc-generating/SKILL.md +1 -0
  98. package/plugins/kk/skills/publish/SKILL.md +1 -0
  99. package/plugins/kk/skills/smart-seo/SKILL.md +1 -0
  100. package/plugins/kk/skills/storm-research/SKILL.md +1 -0
  101. package/plugins/kk/skills/taste-unslop/SKILL.md +1 -0
  102. package/plugins/kk/skills/topic/SKILL.md +1 -0
  103. package/plugins/kk/skills/web-automation/SKILL.md +1 -0
  104. package/plugins/kk/skills/xhs-images/SKILL.md +1 -0
  105. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +9 -7
  106. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +161 -61
  107. package/plugins/kk/workflows/kk-daily-publish3.yaml +82 -26
  108. package/plugins/kk/workflows/kk-daily-safety.yaml +39 -31
  109. package/plugins/kk/workflows/kk-itc-author.yaml +75 -0
  110. package/plugins/kk/workflows/kk-itc-publish-tail.yaml +25 -3
  111. package/plugins/kk/workflows/kk-itc.yaml +36 -4
  112. package/plugins/publishings/linkedin-pub/dist/index.js +21333 -0
  113. package/plugins/publishings/linkedin-pub/dist/profile-check.js +21005 -0
  114. package/plugins/publishings/linkedin-pub/package.json +19 -0
  115. package/plugins/publishings/linkedin-pub/plugin.json +12 -0
  116. package/plugins/publishings/linkedin-pub/src/index.ts +705 -0
  117. package/plugins/publishings/linkedin-pub/src/profile-check.ts +17 -0
  118. package/plugins/publishings/linkedin-pub/tsconfig.json +4 -0
  119. package/plugins/publishings/surfdash-pub/dist/index.js +1 -1
  120. package/plugins/publishings/surfdash-pub/src/index.ts +3 -1
  121. package/plugins/publishings/threads-pub/dist/index.js +7 -0
  122. package/plugins/publishings/threads-pub/src/index.ts +12 -0
  123. package/plugins/publishings/wechatmp-pub/dist/index.js +5 -2
  124. package/plugins/publishings/wechatmp-pub/src/flow.ts +11 -3
  125. package/plugins/publishings/x-pub/dist/index.js +7 -0
  126. package/plugins/publishings/x-pub/src/index.ts +11 -0
@@ -89,7 +89,6 @@ import {
89
89
  PII_FINDING_CATEGORY,
90
90
  pluginsArgs,
91
91
  readJson,
92
- readSafetyReviseCount,
93
92
  readTopicBoundary,
94
93
  requireJson,
95
94
  requireSidecar,
@@ -716,13 +715,23 @@ function refuseScriptBelowFloor(
716
715
  }
717
716
 
718
717
  export function dailyEpisodeDuration(args: string[]): void {
719
- const [workDir = '', runDate = '', minArg = '', maxArg = '', vdriver = ''] = args;
718
+ const [workDir = '', runDate = '', minArg = '', maxArg = '', vdriver = '', runId = ''] = args;
720
719
  if (!workDir) fail('missing work_dir');
721
720
  if (!runDate) fail('missing run_date');
722
721
  const minMinutes = Number(minArg);
723
722
  const maxMinutes = Number(maxArg);
724
723
  if (!Number.isFinite(minMinutes) || minMinutes <= 0) fail(`invalid min_minutes: ${minArg || '(empty)'}`);
725
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
+ };
726
735
  const contentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
727
736
  if (!isFile(contentPath)) failWith(1, `episode-duration: audio content not found: ${contentPath}`);
728
737
  let raw: unknown;
@@ -751,7 +760,8 @@ export function dailyEpisodeDuration(args: string[]): void {
751
760
  // let the pool-gated publish fan package a sub-floor episode — so finish the emptying here.
752
761
  if (isFile(refusal)) {
753
762
  if (docs.length > 0) emptyCandidates(workDir, runDate);
754
- 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`);
755
765
  return;
756
766
  }
757
767
  const estimated = estimateSpokenMinutes(docs, vdriver);
@@ -767,6 +777,7 @@ export function dailyEpisodeDuration(args: string[]): void {
767
777
  }),
768
778
  );
769
779
  emptyCandidates(workDir, runDate);
780
+ invalidatePublishPath();
770
781
  console.error(
771
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})`,
772
783
  );
@@ -1878,12 +1889,13 @@ export function buildSocialContent(
1878
1889
 
1879
1890
  /**
1880
1891
  * Length budgets the social authoring prompt targets, enforced here as a backstop (task 0327 R3).
1881
- * X counts CJK at 2 (`weighted`); weibo and threads count characters.
1892
+ * X counts CJK at 2 (`weighted`); weibo, threads and linkedin count characters.
1882
1893
  */
1883
1894
  const SOCIAL_LIMITS: Record<string, SocialLimit> = {
1884
1895
  x: { max: 280, weighted: true },
1885
1896
  weibo: { max: 2000 },
1886
1897
  threads: { max: 500 },
1898
+ linkedin: { max: 3000 },
1887
1899
  };
1888
1900
 
1889
1901
  /** Variant key → file name: `social:x` becomes `social_x.content.json`. */
@@ -1892,27 +1904,36 @@ function variantFileName(key: string): string {
1892
1904
  }
1893
1905
 
1894
1906
  /**
1895
- * `daily-bundle-variants <workDir> <runDate> <pluginsPath>` — the generate-side payload builder
1896
- * (task 0327 R2/R3).
1907
+ * `daily-bundle-variants <workDir> <runDate> <pluginsPath> [cardsEnabled] [coverEnabled]` — the
1908
+ * generate-side payload builder (task 0327 R2/R3).
1897
1909
  *
1898
1910
  * Everything the publish side used to assemble inline is produced here instead, once, as bundle
1899
1911
  * variants under `30-variants/`. Links are the `{{article_url}}` / `{{podcast_url}}` placeholders,
1900
1912
  * because at generate time nothing is published yet; `publish-matrix-send` resolves them from the
1901
1913
  * tier-0 receipts. A variant whose source artifact is absent (channel disabled, cards never
1902
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.
1903
1920
  */
1904
1921
  export function dailyBundleVariants(args: string[]): void {
1905
- const [workDir = '', runDate = '', _pluginsPath = ''] = args;
1922
+ const [workDir = '', runDate = '', _pluginsPath = '', cardsEnabledArg = '', coverEnabledArg = ''] = args;
1906
1923
  timed(workDir, 'bundle-variants', () => {
1907
1924
  const outDir = join(workDir, '30-variants');
1908
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
+ }
1909
1930
  const articlePath = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1910
1931
  const prepPath = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
1911
1932
  const notesPath = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
1912
1933
  const cardsPath = join(workDir, '25-xhs', 'content.json');
1913
1934
  const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1914
1935
  const socialDir = join(workDir, '26-social');
1915
- const cover = isFile(coverPath) ? coverPath : undefined;
1936
+ const cover = coverEnabledArg !== 'false' && isFile(coverPath) ? coverPath : undefined;
1916
1937
  const links = PLACEHOLDER_LINKS;
1917
1938
  const written: string[] = [];
1918
1939
  const skipped: string[] = [];
@@ -1966,9 +1987,12 @@ export function dailyBundleVariants(args: string[]): void {
1966
1987
  );
1967
1988
  }
1968
1989
 
1969
- const cards = readJson(cardsPath);
1970
- if (cards === null) skipped.push('cards (no rendered card series)');
1971
- 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
+ }
1972
1996
 
1973
1997
  for (const platform of Object.keys(SOCIAL_LIMITS)) {
1974
1998
  const copyPath = join(socialDir, `${platform}.md`);
@@ -2048,6 +2072,8 @@ export function readKeyPointItems(filePath: string): string[] | undefined {
2048
2072
  flush();
2049
2073
  current = item[1].trim();
2050
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 = '';
2051
2077
  continue;
2052
2078
  }
2053
2079
  if (current === undefined || !folded) continue;
@@ -3081,6 +3107,30 @@ export function dailyQualityReport(args: string[]): void {
3081
3107
  });
3082
3108
  }
3083
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
+
3084
3134
  /**
3085
3135
  * 0171 R3: Apple Podcasts reads the enclosure's ID3 tags, so the published MP3 carries
3086
3136
  * title/artist/album/date. `-c copy` moves the audio frames without a re-encode, and the temp
@@ -3111,18 +3161,19 @@ export function dailyQualityReport(args: string[]): void {
3111
3161
  * Fail-closed where it matters: a missing report, verdict or confidence still blocks.
3112
3162
  */
3113
3163
  export function dailySafetyGate(args: string[]): void {
3114
- const [workDir = '', runDate = '', maxReviseArg = '0'] = args;
3164
+ const [workDir = '', runDate = '', maxReviseArg = '0', scopeDir = ''] = args;
3165
+ const drafts = isDraftsScope(scopeDir);
3115
3166
  const maxRevise = Number.parseInt(maxReviseArg, 10);
3116
- const statusFile = safetyPath(workDir, runDate, 'safety-judge_status.txt');
3117
- 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');
3118
3169
  timed(workDir, 'safety-judge', () => {
3119
- const request = readJson(safetyPath(workDir, runDate, 'safety-review_request.json'));
3120
- const report = readJson(safetyPath(workDir, runDate, 'safety-review_report.json'));
3121
- 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'));
3122
3173
  const platforms = Array.isArray(report?.platforms) ? (report.platforms as Record<string, unknown>[]) : [];
3123
3174
  const decisions = platforms.map((platform) => platform?.decision);
3124
- const digest = safetyDigest(workDir, runDate);
3125
- const revisions = readSafetyReviseCount(workDir);
3175
+ const digest = scopeDigest(workDir, scopeDir, runDate);
3176
+ const revisions = readScopeCounter(workDir, scopeDir);
3126
3177
  const platformFindings = (platform: Record<string, unknown>): Record<string, unknown>[] =>
3127
3178
  Array.isArray(platform?.findings) ? (platform.findings as Record<string, unknown>[]) : [];
3128
3179
  // 20261007 narrowing (operator decision): on a clean pass, only a finding the reviewer
@@ -3153,9 +3204,12 @@ export function dailySafetyGate(args: string[]): void {
3153
3204
  // data-handling), which is where an independent reviewer can grade it.
3154
3205
  const blockedPlatforms = platforms.filter((platform) => platform?.decision === 'BLOCK');
3155
3206
  const excludedPlatforms = blockedPlatforms.map((platform) => String(platform?.platform ?? '')).filter(Boolean);
3156
- const piiBlock = blockedPlatforms.some((platform) =>
3157
- platformFindings(platform).some((finding) => Number(finding?.category) === PII_FINDING_CATEGORY),
3158
- );
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));
3159
3213
  // A BLOCK that names no finding is a contract violation, not a signal: there is nothing to
3160
3214
  // remediate and nothing to exclude, so it blocks (fail-closed).
3161
3215
  const unnamedBlock =
@@ -3219,7 +3273,7 @@ export function dailySafetyGate(args: string[]): void {
3219
3273
  status = 'block';
3220
3274
  const named = blockedPlatforms
3221
3275
  .flatMap((platform) => platformFindings(platform))
3222
- .filter((finding) => Number(finding?.category) === PII_FINDING_CATEGORY)
3276
+ .filter(isPiiFinding)
3223
3277
  .map((finding) => `${String(finding?.item ?? '?')}(category ${String(finding?.category)})`)
3224
3278
  .join(', ');
3225
3279
  const criteria = verdictFails.map((finding) => String(finding?.criterion)).join(', ');
@@ -3246,11 +3300,12 @@ export function dailySafetyGate(args: string[]): void {
3246
3300
  if (status === 'pass' || status === 'escalate' || status === 'exclude') {
3247
3301
  sted('write', stampFile, () => writeJsonText(stampFile, { digest, status, excludedPlatforms }));
3248
3302
  }
3249
- if (status === 'block' && !edited) {
3303
+ if (status === 'block' && drafts && !edited) {
3250
3304
  // A quality block poisons the isFile() draft cache: every same-day retry would
3251
3305
  // re-fail on unchanged bytes (20261002 dogfood — two judge-fail runs replayed until
3252
3306
  // the drafts were moved by hand). Quarantine (mv, never rm) so the next run
3253
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.
3254
3309
  const q = join(workDir, '24-script', `.quarantine-${runDate}-judge-block`);
3255
3310
  mkdirSync(q, { recursive: true });
3256
3311
  for (const path of safetyDraftPaths(workDir, runDate)) {
@@ -3291,10 +3346,27 @@ export function dailyPreflight(args: string[]): void {
3291
3346
  String(match[1]),
3292
3347
  ),
3293
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);
3294
3364
  const missing: string[] = [];
3295
3365
  for (const file of files) {
3296
3366
  if (!isFile(file)) failWith(1, `daily-preflight.workflow-missing: ${file}`);
3297
- 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)) {
3298
3370
  const name = String(match[1]);
3299
3371
  if (!known.has(name)) missing.push(`${name} (needed by ${basename(file)})`);
3300
3372
  }
@@ -3325,11 +3397,12 @@ export function dailyPreflight(args: string[]): void {
3325
3397
  * offending value on stderr. Never exits non-zero: routing belongs to the transitions.
3326
3398
  */
3327
3399
  export function dailySafetyVerdict(args: string[]): void {
3328
- const [workDir = '', runDate = ''] = args;
3329
- const statusFile = safetyPath(workDir, runDate, 'safety-judge_status.txt');
3330
- const stampFile = safetyPath(workDir, runDate, 'safety-judge_stamp.json');
3331
- const decisionFile = safetyPath(workDir, runDate, 'safety-verdict.decision');
3332
- 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');
3333
3406
  const ADMITTED = new Set(['pass', 'escalate', 'exclude']);
3334
3407
  timed(workDir, 'safety-verdict-check', () => {
3335
3408
  const status = isFile(statusFile) ? readFileSync(statusFile, 'utf-8').trim() : '';
@@ -3349,8 +3422,51 @@ export function dailySafetyVerdict(args: string[]): void {
3349
3422
  } else if (!ADMITTED.has(verdict)) {
3350
3423
  refusal = `kk-daily-safety.judge.decision-refused: kk-safety-verdict [${verdict}] must admit pass|escalate|exclude, so these drafts must not publish`;
3351
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
+ }
3352
3463
  if (refusal !== '') console.error(refusal);
3353
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
+ }
3354
3470
  });
3355
3471
  }
3356
3472
 
@@ -3361,41 +3477,183 @@ export function dailySafetyVerdict(args: string[]): void {
3361
3477
  * here fails inside the loop instead of at `wrap-docs`, after the run has moved on. A broken
3362
3478
  * revision spends the budget too: an unparsable draft must not be retried forever.
3363
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
+
3364
3596
  /**
3365
3597
  * Open a content-safety review of the current drafts: drop every artifact of an earlier review
3366
3598
  * (a retry must be re-reviewed on its final version) and write the request the two agent.run
3367
3599
  * steps read. The program passes context only — platform policy lives in the kk:content-safety
3368
- * 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).
3369
3602
  */
3370
3603
  export function dailySafetyPrep(args: string[]): void {
3371
- const [workDir = '', runDate = '', platforms = ''] = args;
3604
+ const [workDir = '', runDate = '', platforms = '', scopeDir = ''] = args;
3605
+ const drafts = isDraftsScope(scopeDir);
3372
3606
  timed(workDir, 'safety-review', () => {
3373
- const digest = safetyDigest(workDir, runDate);
3374
- // Structural snapshot of the VoiceScript BEFORE any remediation edit. `daily-safety-revise`
3375
- // restores it when the remediation pass changes the item-section structure without a recorded
3376
- // exclusion: the agent is allowed to drop a 播报形态 line, not an item the article kept,
3377
- // because `daily-wrap-docs` derives the spoken item sections from `gap_ms: 1200` and fails the
3378
- // run when they no longer match the episode's included items (2026-10-21 04:30 run: 3
3379
- // segments removed → 5 sections vs a 6-item plan → `generate` refused at 05:03 and the whole
3380
- // day published nothing).
3381
- const [, scriptPath] = safetyDraftPaths(workDir, runDate);
3382
- const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
3383
- 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
+ }
3384
3625
  for (const name of [
3385
3626
  'safety-review_report.json',
3386
3627
  'safety-judge_verdict.json',
3387
3628
  'safety-judge_status.txt',
3388
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',
3389
3636
  ]) {
3390
- 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);
3391
3641
  if (isFile(stale)) unlinkSync(stale);
3392
3642
  }
3393
- const request = safetyPath(workDir, runDate, 'safety-review_request.json');
3643
+ const request = scopeArtifact(workDir, scopeDir, runDate, 'safety-review_request.json');
3394
3644
  const targets = platforms
3395
3645
  .split(',')
3396
3646
  .map((platform) => platform.trim())
3397
3647
  .filter(Boolean);
3398
- 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
+ );
3399
3657
  });
3400
3658
  }
3401
3659
 
@@ -3429,8 +3687,30 @@ export function dailySafetyPrep(args: string[]): void {
3429
3687
  * revision spends the budget too: an unparsable draft must not be retried forever.
3430
3688
  */
3431
3689
  export function dailySafetyRevise(args: string[]): void {
3432
- const [workDir = '', runDate = ''] = args;
3690
+ const [workDir = '', runDate = '', scopeDir = ''] = args;
3691
+ const drafts = isDraftsScope(scopeDir);
3433
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
+ }
3434
3714
  const [articlePath, scriptPath] = safetyDraftPaths(workDir, runDate);
3435
3715
  const article = requireJson(articlePath, 'safety-revise');
3436
3716
  for (const field of ['title', 'body'] as const) {
@@ -3442,7 +3722,7 @@ export function dailySafetyRevise(args: string[]): void {
3442
3722
  assertScriptContent(scriptPath);
3443
3723
  restoreStructureIfBroken(workDir, runDate, scriptPath);
3444
3724
  const path = join(workDir, SAFETY_REVISE_COUNT);
3445
- const next = readSafetyReviseCount(workDir) + 1;
3725
+ const next = readScopeCounter(workDir, scopeDir) + 1;
3446
3726
  sted('write', path, () => writeFileSync(path, `${next}\n`));
3447
3727
  console.log(`safety-revise: remediation pass ${next} applied; drafts re-validated`);
3448
3728
  });
@@ -3945,6 +4225,23 @@ function normalizeTranslatedLocales(zhPostPath: string, failUnsafe = true): void
3945
4225
  }
3946
4226
  }
3947
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
+
3948
4245
  export function dailySurfdashRefine(args: string[]): void {
3949
4246
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
3950
4247
  const marker = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
@@ -3957,6 +4254,7 @@ export function dailySurfdashRefine(args: string[]): void {
3957
4254
  failWith(1, `surfdash-refine: zh marker must name an existing post: ${marker}`);
3958
4255
  }
3959
4256
  validateLocaleMarkers(workDir, runDate, zhPostPath, 'surfdash-refine');
4257
+ installTranslatedLocales(workDir, runDate, zhPostPath);
3960
4258
  // The validator is intentionally read-only. Repair known series spellings before
3961
4259
  // site-owned refine computes managed fields, otherwise the final publisher would
3962
4260
  // rewrite the refined bytes after the locale fingerprint was admitted.