@gobing-ai/knowledge-kit 0.1.11 → 0.1.13

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 (137) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +2372 -1722
  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 +15 -7
  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 +91 -41
  88. package/plugins/kk/scripts/daily-stages.ts +394 -53
  89. package/plugins/kk/scripts/kk-workflow-stages.ts +190 -0
  90. package/plugins/kk/scripts/safety-artifact-check.ts +70 -28
  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/content-safety/references/platforms/podcast-salty.md +41 -0
  97. package/plugins/kk/skills/content-safety/references/platforms/surfdash.md +36 -0
  98. package/plugins/kk/skills/content-safety/references/platforms/xhs.md +39 -0
  99. package/plugins/kk/skills/explain-things/SKILL.md +1 -0
  100. package/plugins/kk/skills/image-authoring/SKILL.md +1 -0
  101. package/plugins/kk/skills/itc-generating/SKILL.md +1 -0
  102. package/plugins/kk/skills/publish/SKILL.md +1 -0
  103. package/plugins/kk/skills/smart-seo/SKILL.md +1 -0
  104. package/plugins/kk/skills/storm-research/SKILL.md +1 -0
  105. package/plugins/kk/skills/taste-unslop/SKILL.md +1 -0
  106. package/plugins/kk/skills/topic/SKILL.md +1 -0
  107. package/plugins/kk/skills/web-automation/SKILL.md +1 -0
  108. package/plugins/kk/skills/xhs-images/SKILL.md +1 -0
  109. package/plugins/kk/workflows/kk-collect.yaml +3 -3
  110. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +21 -19
  111. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +163 -63
  112. package/plugins/kk/workflows/kk-daily-publish3.yaml +82 -26
  113. package/plugins/kk/workflows/kk-daily-safety.yaml +39 -31
  114. package/plugins/kk/workflows/kk-itc-author.yaml +75 -0
  115. package/plugins/kk/workflows/kk-itc-publish-tail.yaml +25 -3
  116. package/plugins/kk/workflows/kk-itc.yaml +36 -4
  117. package/plugins/publishings/linkedin-pub/dist/index.js +21333 -0
  118. package/plugins/publishings/linkedin-pub/dist/profile-check.js +21005 -0
  119. package/plugins/publishings/linkedin-pub/package.json +19 -0
  120. package/plugins/publishings/linkedin-pub/plugin.json +12 -0
  121. package/plugins/publishings/linkedin-pub/src/index.ts +705 -0
  122. package/plugins/publishings/linkedin-pub/src/profile-check.ts +17 -0
  123. package/plugins/publishings/linkedin-pub/tsconfig.json +4 -0
  124. package/plugins/publishings/surfdash-pub/dist/index.js +53 -24
  125. package/plugins/publishings/surfdash-pub/src/index.ts +72 -25
  126. package/plugins/publishings/threads-pub/dist/index.js +7 -0
  127. package/plugins/publishings/threads-pub/src/index.ts +12 -0
  128. package/plugins/publishings/wechatmp-pub/dist/index.js +176 -32
  129. package/plugins/publishings/wechatmp-pub/src/flow.ts +298 -52
  130. package/plugins/publishings/weibo-pub/dist/index.js +100 -7
  131. package/plugins/publishings/weibo-pub/src/index.ts +111 -11
  132. package/plugins/publishings/x-pub/dist/index.js +7 -0
  133. package/plugins/publishings/x-pub/src/index.ts +11 -0
  134. package/plugins/publishings/xhs-pub/dist/index.js +164 -12
  135. package/plugins/publishings/xhs-pub/dist/profile-check.js +11 -1
  136. package/plugins/publishings/xhs-pub/src/flow.ts +194 -23
  137. package/plugins/publishings/xhs-pub/src/page.ts +26 -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
  );
@@ -1715,7 +1726,15 @@ export function buildWechatArticleContent(
1715
1726
  return {
1716
1727
  ...article,
1717
1728
  body: options.appendOutro === false ? body : `${body}${WECHAT_OUTRO}`,
1718
- metadata: { ...metadataOf(article), publishStatus: options.publishStatus, wechatmp },
1729
+ metadata: {
1730
+ ...metadataOf(article),
1731
+ // wechatmp-pub's 播客 operation resolves the episode mp3 through the generic media key
1732
+ // `metadata.audio` (`modes.ts` resolveMediaPath), while the 文章 audio-embed rail keeps
1733
+ // reading `wechatmp.audioPath` — carry both spellings so one variant serves both rows.
1734
+ ...(options.audioPath === undefined ? {} : { audio: options.audioPath }),
1735
+ publishStatus: options.publishStatus,
1736
+ wechatmp,
1737
+ },
1719
1738
  };
1720
1739
  }
1721
1740
 
@@ -1789,9 +1808,24 @@ export function buildPodcastContent(
1789
1808
  *
1790
1809
  * surfdash is the article's own publisher, so it consumes what `22-article` already composed — the
1791
1810
  * builder exists so the variant set is uniform, not because it reshapes anything.
1811
+ *
1812
+ * surfdash-pub's `post` operation refuses a digest post without `metadata.coverImage`
1813
+ * (surfdash-pub/src/index.ts), so the run's cover rides along exactly as the episode/wechat
1814
+ * builders stamp it. voice2's prepare mode never exercised this gate; the frozen-bundle matrix
1815
+ * send surfaced it on 2026-11-05 (empty run → 'digest post has no cover image').
1792
1816
  */
1793
- export function buildSurfdashContent(article: Record<string, unknown>, _links: PublishLinks): Record<string, unknown> {
1794
- return { ...article };
1817
+ export function buildSurfdashContent(
1818
+ article: Record<string, unknown>,
1819
+ _links: PublishLinks,
1820
+ options: { coverPath?: string } = {},
1821
+ ): Record<string, unknown> {
1822
+ return {
1823
+ ...article,
1824
+ metadata: {
1825
+ ...metadataOf(article),
1826
+ ...(options.coverPath === undefined ? {} : { coverImage: options.coverPath }),
1827
+ },
1828
+ };
1795
1829
  }
1796
1830
 
1797
1831
  /**
@@ -1878,12 +1912,13 @@ export function buildSocialContent(
1878
1912
 
1879
1913
  /**
1880
1914
  * 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.
1915
+ * X counts CJK at 2 (`weighted`); weibo, threads and linkedin count characters.
1882
1916
  */
1883
1917
  const SOCIAL_LIMITS: Record<string, SocialLimit> = {
1884
1918
  x: { max: 280, weighted: true },
1885
1919
  weibo: { max: 2000 },
1886
1920
  threads: { max: 500 },
1921
+ linkedin: { max: 3000 },
1887
1922
  };
1888
1923
 
1889
1924
  /** Variant key → file name: `social:x` becomes `social_x.content.json`. */
@@ -1892,27 +1927,36 @@ function variantFileName(key: string): string {
1892
1927
  }
1893
1928
 
1894
1929
  /**
1895
- * `daily-bundle-variants <workDir> <runDate> <pluginsPath>` — the generate-side payload builder
1896
- * (task 0327 R2/R3).
1930
+ * `daily-bundle-variants <workDir> <runDate> <pluginsPath> [cardsEnabled] [coverEnabled]` — the
1931
+ * generate-side payload builder (task 0327 R2/R3).
1897
1932
  *
1898
1933
  * Everything the publish side used to assemble inline is produced here instead, once, as bundle
1899
1934
  * variants under `30-variants/`. Links are the `{{article_url}}` / `{{podcast_url}}` placeholders,
1900
1935
  * because at generate time nothing is published yet; `publish-matrix-send` resolves them from the
1901
1936
  * tier-0 receipts. A variant whose source artifact is absent (channel disabled, cards never
1902
1937
  * rendered, social copy not authored) is skipped with a log line, never invented.
1938
+ *
1939
+ * 0329 F16: the caller forwards the run's `cards_enabled` / `cover_enabled` switches — a disabled
1940
+ * producer's variant is a skip, not a stale re-read of a previous run's file — and every stale
1941
+ * variant file is wiped before writing, so `30-variants/` after this stage contains only what
1942
+ * THIS pass produced.
1903
1943
  */
1904
1944
  export function dailyBundleVariants(args: string[]): void {
1905
- const [workDir = '', runDate = '', _pluginsPath = ''] = args;
1945
+ const [workDir = '', runDate = '', _pluginsPath = '', cardsEnabledArg = '', coverEnabledArg = ''] = args;
1906
1946
  timed(workDir, 'bundle-variants', () => {
1907
1947
  const outDir = join(workDir, '30-variants');
1908
1948
  sted('mkdir', outDir, () => mkdirSync(outDir, { recursive: true }));
1949
+ // 0329 F16: a rerun after a thinner day must not leave yesterday's variants behind.
1950
+ for (const entry of readdirSync(outDir)) {
1951
+ if (entry.endsWith('.content.json')) rmSync(join(outDir, entry), { force: true });
1952
+ }
1909
1953
  const articlePath = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1910
1954
  const prepPath = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
1911
1955
  const notesPath = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
1912
1956
  const cardsPath = join(workDir, '25-xhs', 'content.json');
1913
1957
  const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1914
1958
  const socialDir = join(workDir, '26-social');
1915
- const cover = isFile(coverPath) ? coverPath : undefined;
1959
+ const cover = coverEnabledArg !== 'false' && isFile(coverPath) ? resolve(coverPath) : undefined;
1916
1960
  const links = PLACEHOLDER_LINKS;
1917
1961
  const written: string[] = [];
1918
1962
  const skipped: string[] = [];
@@ -1929,8 +1973,8 @@ export function dailyBundleVariants(args: string[]): void {
1929
1973
  // shared copy must exist for the channels the preset does not specialise.
1930
1974
  if (article === null) skipped.push('article (no 22-article content)');
1931
1975
  else {
1932
- write('article', buildSurfdashContent(article, links));
1933
- write('article@surfdash-pub', buildSurfdashContent(article, links));
1976
+ write('article', buildSurfdashContent(article, links, { coverPath: cover }));
1977
+ write('article@surfdash-pub', buildSurfdashContent(article, links, { coverPath: cover }));
1934
1978
  write('article@xhs-pub', buildXhsArticleContent(article, { publishStatus: 'draft' }));
1935
1979
  write(
1936
1980
  'article@wechatmp-pub',
@@ -1966,9 +2010,12 @@ export function dailyBundleVariants(args: string[]): void {
1966
2010
  );
1967
2011
  }
1968
2012
 
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' }));
2013
+ if (cardsEnabledArg === 'false') skipped.push('cards (cards_enabled=false)');
2014
+ else {
2015
+ const cards = readJson(cardsPath);
2016
+ if (cards === null) skipped.push('cards (no rendered card series)');
2017
+ else write('cards@xhs-pub', buildXhsCardsContent(cards, { publishStatus: 'draft' }));
2018
+ }
1972
2019
 
1973
2020
  for (const platform of Object.keys(SOCIAL_LIMITS)) {
1974
2021
  const copyPath = join(socialDir, `${platform}.md`);
@@ -2048,6 +2095,8 @@ export function readKeyPointItems(filePath: string): string[] | undefined {
2048
2095
  flush();
2049
2096
  current = item[1].trim();
2050
2097
  folded = current === '>' || current === '>-' || current === '|-' || current === '|';
2098
+ // The block indicator is YAML syntax, not text: the item starts on the next line.
2099
+ if (folded) current = '';
2051
2100
  continue;
2052
2101
  }
2053
2102
  if (current === undefined || !folded) continue;
@@ -3081,6 +3130,30 @@ export function dailyQualityReport(args: string[]): void {
3081
3130
  });
3082
3131
  }
3083
3132
 
3133
+ /**
3134
+ * 0329 F23 — the verdict half of `quality`. The report only printed, so a failed audio qc sailed
3135
+ * on into show-notes, the bundle and the publish windows (2026-10-25: an episode at 41/100 was
3136
+ * packaged and queued for tier-0 sends). Reads the same `metadata.qc` as `daily-quality-report`
3137
+ * and fails loud when the verdict is missing or `passed` is false.
3138
+ */
3139
+ export function dailyQualityVerdict(args: string[]): void {
3140
+ const [workDir = '', runDate = ''] = args;
3141
+ const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
3142
+ timed(workDir, 'quality-verdict', () => {
3143
+ if (!isFile(content)) failWith(1, `quality-verdict: no audio content: ${content}`);
3144
+ const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
3145
+ if (qc === undefined) failWith(1, `quality-verdict: no qc verdict in ${content}`);
3146
+ if (qc.passed !== true) {
3147
+ const issues = Array.isArray(qc.criticalIssues) ? qc.criticalIssues : [];
3148
+ failWith(
3149
+ 1,
3150
+ `quality-verdict: audio qc failed (overallScore ${String(qc.overallScore ?? '?')}) — ${issues.map(String).join('; ') || 'no issue detail on the verdict'}`,
3151
+ );
3152
+ }
3153
+ console.log(`quality-verdict: audio qc passed (overallScore ${String(qc.overallScore ?? '?')})`);
3154
+ });
3155
+ }
3156
+
3084
3157
  /**
3085
3158
  * 0171 R3: Apple Podcasts reads the enclosure's ID3 tags, so the published MP3 carries
3086
3159
  * title/artist/album/date. `-c copy` moves the audio frames without a re-encode, and the temp
@@ -3111,18 +3184,19 @@ export function dailyQualityReport(args: string[]): void {
3111
3184
  * Fail-closed where it matters: a missing report, verdict or confidence still blocks.
3112
3185
  */
3113
3186
  export function dailySafetyGate(args: string[]): void {
3114
- const [workDir = '', runDate = '', maxReviseArg = '0'] = args;
3187
+ const [workDir = '', runDate = '', maxReviseArg = '0', scopeDir = ''] = args;
3188
+ const drafts = isDraftsScope(scopeDir);
3115
3189
  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');
3190
+ const statusFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_status.txt');
3191
+ const stampFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_stamp.json');
3118
3192
  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'));
3193
+ const request = readJson(scopeArtifact(workDir, scopeDir, runDate, 'safety-review_request.json'));
3194
+ const report = readJson(scopeArtifact(workDir, scopeDir, runDate, 'safety-review_report.json'));
3195
+ const verdict = readJson(scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_verdict.json'));
3122
3196
  const platforms = Array.isArray(report?.platforms) ? (report.platforms as Record<string, unknown>[]) : [];
3123
3197
  const decisions = platforms.map((platform) => platform?.decision);
3124
- const digest = safetyDigest(workDir, runDate);
3125
- const revisions = readSafetyReviseCount(workDir);
3198
+ const digest = scopeDigest(workDir, scopeDir, runDate);
3199
+ const revisions = readScopeCounter(workDir, scopeDir);
3126
3200
  const platformFindings = (platform: Record<string, unknown>): Record<string, unknown>[] =>
3127
3201
  Array.isArray(platform?.findings) ? (platform.findings as Record<string, unknown>[]) : [];
3128
3202
  // 20261007 narrowing (operator decision): on a clean pass, only a finding the reviewer
@@ -3153,9 +3227,12 @@ export function dailySafetyGate(args: string[]): void {
3153
3227
  // data-handling), which is where an independent reviewer can grade it.
3154
3228
  const blockedPlatforms = platforms.filter((platform) => platform?.decision === 'BLOCK');
3155
3229
  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
- );
3230
+ // 20261104: a category-3 `note` is the reviewer recording that it checked and found NO PII —
3231
+ // counting it turned an admission-only BLOCK into a whole-run block. Any other disposition,
3232
+ // or none (legacy reports), still counts: fail-closed.
3233
+ const isPiiFinding = (finding: Record<string, unknown>): boolean =>
3234
+ Number(finding?.category) === PII_FINDING_CATEGORY && finding?.disposition !== 'note';
3235
+ const piiBlock = blockedPlatforms.some((platform) => platformFindings(platform).some(isPiiFinding));
3159
3236
  // A BLOCK that names no finding is a contract violation, not a signal: there is nothing to
3160
3237
  // remediate and nothing to exclude, so it blocks (fail-closed).
3161
3238
  const unnamedBlock =
@@ -3219,7 +3296,7 @@ export function dailySafetyGate(args: string[]): void {
3219
3296
  status = 'block';
3220
3297
  const named = blockedPlatforms
3221
3298
  .flatMap((platform) => platformFindings(platform))
3222
- .filter((finding) => Number(finding?.category) === PII_FINDING_CATEGORY)
3299
+ .filter(isPiiFinding)
3223
3300
  .map((finding) => `${String(finding?.item ?? '?')}(category ${String(finding?.category)})`)
3224
3301
  .join(', ');
3225
3302
  const criteria = verdictFails.map((finding) => String(finding?.criterion)).join(', ');
@@ -3246,11 +3323,12 @@ export function dailySafetyGate(args: string[]): void {
3246
3323
  if (status === 'pass' || status === 'escalate' || status === 'exclude') {
3247
3324
  sted('write', stampFile, () => writeJsonText(stampFile, { digest, status, excludedPlatforms }));
3248
3325
  }
3249
- if (status === 'block' && !edited) {
3326
+ if (status === 'block' && drafts && !edited) {
3250
3327
  // A quality block poisons the isFile() draft cache: every same-day retry would
3251
3328
  // re-fail on unchanged bytes (20261002 dogfood — two judge-fail runs replayed until
3252
3329
  // the drafts were moved by hand). Quarantine (mv, never rm) so the next run
3253
3330
  // regenerates from scratch; the 09_* verdict artifacts stay in 24-script for audit.
3331
+ // Variants scope never quarantines: the bundle is regenerated, not hand-repaired.
3254
3332
  const q = join(workDir, '24-script', `.quarantine-${runDate}-judge-block`);
3255
3333
  mkdirSync(q, { recursive: true });
3256
3334
  for (const path of safetyDraftPaths(workDir, runDate)) {
@@ -3291,10 +3369,27 @@ export function dailyPreflight(args: string[]): void {
3291
3369
  String(match[1]),
3292
3370
  ),
3293
3371
  );
3372
+ // 0329 F21: the `publish-matrix-*` stages never appear in kk-workflow-stages' switch —
3373
+ // `kk stage` routes them through apps/cli's publish-matrix-stage.ts — so without this
3374
+ // roster every workflow invoking them died as runner-stale.
3375
+ const PUBLISH_MATRIX_STAGES = [
3376
+ 'publish-matrix-plan',
3377
+ 'publish-matrix-check',
3378
+ 'publish-matrix-send',
3379
+ 'publish-matrix-classify',
3380
+ 'publish-matrix-bundle',
3381
+ 'publish-matrix-rss-gate',
3382
+ 'publish-matrix-wait-until',
3383
+ 'publish-matrix-validate',
3384
+ 'publish-matrix-settle',
3385
+ ] as const;
3386
+ for (const name of PUBLISH_MATRIX_STAGES) known.add(name);
3294
3387
  const missing: string[] = [];
3295
3388
  for (const file of files) {
3296
3389
  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)) {
3390
+ // 0329 F21: accept both spellings — `kk stage <name>` and the ADR-037 canonical
3391
+ // `kk stage run <name>`.
3392
+ for (const match of readFileSync(file, 'utf-8').matchAll(/\bkk stage (?:run )?([a-z][a-z0-9-]*)/g)) {
3298
3393
  const name = String(match[1]);
3299
3394
  if (!known.has(name)) missing.push(`${name} (needed by ${basename(file)})`);
3300
3395
  }
@@ -3325,11 +3420,12 @@ export function dailyPreflight(args: string[]): void {
3325
3420
  * offending value on stderr. Never exits non-zero: routing belongs to the transitions.
3326
3421
  */
3327
3422
  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');
3423
+ const [workDir = '', runDate = '', scopeDir = ''] = args;
3424
+ const drafts = isDraftsScope(scopeDir);
3425
+ const statusFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_status.txt');
3426
+ const stampFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_stamp.json');
3427
+ const decisionFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-verdict.decision');
3428
+ const token = scopeArtifact(workDir, scopeDir, runDate, 'safety-verdict.status');
3333
3429
  const ADMITTED = new Set(['pass', 'escalate', 'exclude']);
3334
3430
  timed(workDir, 'safety-verdict-check', () => {
3335
3431
  const status = isFile(statusFile) ? readFileSync(statusFile, 'utf-8').trim() : '';
@@ -3349,8 +3445,51 @@ export function dailySafetyVerdict(args: string[]): void {
3349
3445
  } else if (!ADMITTED.has(verdict)) {
3350
3446
  refusal = `kk-daily-safety.judge.decision-refused: kk-safety-verdict [${verdict}] must admit pass|escalate|exclude, so these drafts must not publish`;
3351
3447
  }
3448
+ // 0331 variants scope: an admitted outcome also writes the evidence the publish matrix
3449
+ // freezes into the bundle — status, the exclusion record and the sha256 of every reviewed
3450
+ // variant's CURRENT bytes. Recomputed here (not copied from the stamp) so bytes edited
3451
+ // after the gate's verdict are caught at the last instant; a mismatch is a refusal, never
3452
+ // silently re-bound evidence.
3453
+ let evidence: Record<string, unknown> | null = null;
3454
+ if (refusal === '' && !drafts) {
3455
+ const stampJson = readJson(stampFile) as {
3456
+ digest?: unknown;
3457
+ status?: unknown;
3458
+ excludedPlatforms?: unknown;
3459
+ } | null;
3460
+ const manifest = readManifest(workDir);
3461
+ const hash = createHash('sha256');
3462
+ const variants: Record<string, string> = {};
3463
+ for (const key of Object.keys(manifest.variants).sort()) {
3464
+ const path = join(workDir, manifest.variants[key].path);
3465
+ if (!isFile(path)) {
3466
+ refusal = `kk-daily-safety.judge.verdict-refused: registered variant file is missing: ${path} — re-run the review on a fresh manifest`;
3467
+ break;
3468
+ }
3469
+ const sha = createHash('sha256').update(readFileSync(path)).digest('hex');
3470
+ variants[key] = sha;
3471
+ hash.update(key).update('\0').update(readFileSync(path)).update('\0');
3472
+ }
3473
+ const digest = hash.digest('hex');
3474
+ if (refusal === '' && stampJson?.digest !== digest) {
3475
+ 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`;
3476
+ }
3477
+ if (refusal === '') {
3478
+ evidence = {
3479
+ status: stampJson?.status,
3480
+ digest,
3481
+ excludedPlatforms: Array.isArray(stampJson?.excludedPlatforms) ? stampJson.excludedPlatforms : [],
3482
+ variants,
3483
+ };
3484
+ }
3485
+ }
3352
3486
  if (refusal !== '') console.error(refusal);
3353
3487
  sted('write', token, () => writeRaw(token, refusal === '' ? 'accept' : 'refuse'));
3488
+ if (evidence !== null) {
3489
+ const evidencePath = join(scopeDirOf(workDir, scopeDir), 'safety.json');
3490
+ sted('write', evidencePath, () => writeJsonText(evidencePath, evidence));
3491
+ console.log(`safety-verdict: evidence written to ${evidencePath}`);
3492
+ }
3354
3493
  });
3355
3494
  }
3356
3495
 
@@ -3361,41 +3500,187 @@ export function dailySafetyVerdict(args: string[]): void {
3361
3500
  * here fails inside the loop instead of at `wrap-docs`, after the run has moved on. A broken
3362
3501
  * revision spends the budget too: an unparsable draft must not be retried forever.
3363
3502
  */
3503
+ // ---- 0331 scope machinery -------------------------------------------------
3504
+ // The nested kk-daily-safety machine serves two review surfaces: the classic drafts scope
3505
+ // (article + VoiceScript under 24-script) and the variants scope (final publish variants under
3506
+ // 30-variants, registered by daily-safety-manifest, artifacts under 4-safety-variants). The scope
3507
+ // is ONE trailing `scope_dir` argument: `24-script` (or empty) keeps every path byte-identical to
3508
+ // the pre-0331 behavior, any other value re-roots the artifact set and switches the digest to the
3509
+ // manifest-listed variant bytes.
3510
+
3511
+ export const SAFETY_VARIANTS_DIR = '4-safety-variants';
3512
+
3513
+ function isDraftsScope(scopeDir: string): boolean {
3514
+ const dir = scopeDir.trim();
3515
+ return dir === '' || dir === '24-script';
3516
+ }
3517
+
3518
+ function scopeDirOf(workDir: string, scopeDir: string): string {
3519
+ return isDraftsScope(scopeDir) ? join(workDir, '24-script') : join(workDir, scopeDir.trim());
3520
+ }
3521
+
3522
+ function scopeArtifact(workDir: string, scopeDir: string, runDate: string, name: string): string {
3523
+ return join(scopeDirOf(workDir, scopeDir), `${runDate}_09_${name}`);
3524
+ }
3525
+
3526
+ /** The remediation budget is per scope: a variants review never inherits the drafts pass count. */
3527
+ function scopeCounter(workDir: string, scopeDir: string): string {
3528
+ return isDraftsScope(scopeDir)
3529
+ ? join(workDir, SAFETY_REVISE_COUNT)
3530
+ : join(workDir, `${SAFETY_REVISE_COUNT}-${scopeDir.trim().replace(/[^\w-]/g, '_')}`);
3531
+ }
3532
+
3533
+ function readScopeCounter(workDir: string, scopeDir: string): number {
3534
+ const path = scopeCounter(workDir, scopeDir);
3535
+ if (!isFile(path)) return 0;
3536
+ const value = Number.parseInt(readFileSync(path, 'utf-8'), 10);
3537
+ return Number.isNaN(value) ? 0 : value;
3538
+ }
3539
+
3540
+ interface SafetyManifest {
3541
+ schemaVersion: number;
3542
+ runDate: string;
3543
+ variants: Record<string, { path: string; sha256: string }>;
3544
+ }
3545
+
3546
+ function readManifest(workDir: string): SafetyManifest {
3547
+ const path = join(workDir, SAFETY_VARIANTS_DIR, 'manifest.json');
3548
+ const manifest = readJson(path) as SafetyManifest | null;
3549
+ if (manifest === null || !manifest.variants || typeof manifest.variants !== 'object') {
3550
+ failWith(
3551
+ 1,
3552
+ `safety-variants: ${path} is missing or malformed — run "kk stage run daily-safety-manifest <work_dir> <run_date>" first`,
3553
+ );
3554
+ }
3555
+ return manifest;
3556
+ }
3557
+
3558
+ /** Digest over the exact bytes under review: drafts = safetyDigest; variants = sorted manifest keys + current bytes. */
3559
+ function scopeDigest(workDir: string, scopeDir: string, runDate: string): string {
3560
+ if (isDraftsScope(scopeDir)) return safetyDigest(workDir, runDate);
3561
+ const manifest = readManifest(workDir);
3562
+ const hash = createHash('sha256');
3563
+ for (const key of Object.keys(manifest.variants).sort()) {
3564
+ const path = join(workDir, manifest.variants[key].path);
3565
+ if (!isFile(path)) {
3566
+ failWith(
3567
+ 1,
3568
+ `safety-variants: registered variant file is missing: ${path} — re-run daily-bundle-variants and the manifest`,
3569
+ );
3570
+ }
3571
+ hash.update(key).update('\0').update(readFileSync(path)).update('\0');
3572
+ }
3573
+ return hash.digest('hex');
3574
+ }
3575
+
3576
+ /** The `content` array the review request hands the agents: what to read, per scope. */
3577
+ function scopeContent(workDir: string, scopeDir: string, runDate: string): unknown[] {
3578
+ if (isDraftsScope(scopeDir)) return safetyDraftPaths(workDir, runDate);
3579
+ return Object.entries(readManifest(workDir).variants)
3580
+ .sort(([a], [b]) => (a < b ? -1 : 1))
3581
+ .map(([key, entry]) => ({ key, path: entry.path, sha256: entry.sha256 }));
3582
+ }
3583
+
3584
+ /**
3585
+ * Register the run's final publish variants for the variants-scope safety review (0331): every
3586
+ * `30-variants/*.content.json` becomes one manifest entry keyed by its file name with `_` read as
3587
+ * the plugin.operation separator. The manifest is the review contract — the request digest, the
3588
+ * gate's edit check and the bundle's frozen evidence all resolve through it, so an empty or
3589
+ * missing variants directory fails here, before any reviewer spends tokens.
3590
+ */
3591
+ export function dailySafetyManifest(args: string[]): void {
3592
+ const [workDir = '', runDate = ''] = args;
3593
+ timed(workDir, 'safety-manifest', () => {
3594
+ const dir = join(workDir, '30-variants');
3595
+ if (!existsSync(dir)) {
3596
+ failWith(1, `safety-manifest: ${dir} does not exist — run the bundle-variants stage first`);
3597
+ }
3598
+ const files = readdirSync(dir)
3599
+ .filter((file) => file.endsWith('.content.json'))
3600
+ .sort();
3601
+ if (files.length === 0) {
3602
+ failWith(1, `safety-manifest: no *.content.json under ${dir} — nothing to review; was the bundle built?`);
3603
+ }
3604
+ const variants: Record<string, { path: string; sha256: string }> = {};
3605
+ for (const file of files) {
3606
+ const text = readFileSync(join(dir, file), 'utf-8');
3607
+ variants[file.replace(/\.content\.json$/, '').replace(/_/g, ':')] = {
3608
+ path: `30-variants/${file}`,
3609
+ sha256: createHash('sha256').update(text).digest('hex'),
3610
+ };
3611
+ }
3612
+ mkdirSync(join(workDir, SAFETY_VARIANTS_DIR), { recursive: true });
3613
+ const manifestPath = join(workDir, SAFETY_VARIANTS_DIR, 'manifest.json');
3614
+ sted('write', manifestPath, () => writeJsonText(manifestPath, { schemaVersion: 1, runDate, variants }));
3615
+ console.log(`safety-manifest: ${files.length} variant(s) registered for review`);
3616
+ });
3617
+ }
3618
+
3364
3619
  /**
3365
3620
  * Open a content-safety review of the current drafts: drop every artifact of an earlier review
3366
3621
  * (a retry must be re-reviewed on its final version) and write the request the two agent.run
3367
3622
  * steps read. The program passes context only — platform policy lives in the kk:content-safety
3368
- * skill's reference files, never here.
3623
+ * skill's reference files, never here. 0331: an optional trailing scope_dir re-roots the whole
3624
+ * artifact set for the variants review (see the scope machinery above).
3369
3625
  */
3370
3626
  export function dailySafetyPrep(args: string[]): void {
3371
- const [workDir = '', runDate = '', platforms = ''] = args;
3627
+ const [workDir = '', runDate = '', platforms = '', scopeDir = ''] = args;
3628
+ const drafts = isDraftsScope(scopeDir);
3372
3629
  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')));
3630
+ const digest = scopeDigest(workDir, scopeDir, runDate);
3631
+ if (drafts) {
3632
+ // Structural snapshot of the VoiceScript BEFORE any remediation edit. `daily-safety-revise`
3633
+ // restores it when the remediation pass changes the item-section structure without a recorded
3634
+ // exclusion: the agent is allowed to drop a 播报形态 line, not an item the article kept,
3635
+ // because `daily-wrap-docs` derives the spoken item sections from `gap_ms: 1200` and fails the
3636
+ // run when they no longer match the episode's included items (2026-10-21 04:30 run: 3
3637
+ // segments removed → 5 sections vs a 6-item plan → `generate` refused at 05:03 and the whole
3638
+ // day published nothing).
3639
+ const [, scriptPath] = safetyDraftPaths(workDir, runDate);
3640
+ const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
3641
+ if (isFile(scriptPath))
3642
+ sted('write', snapshot, () => writeRaw(snapshot, readFileSync(scriptPath, 'utf-8')));
3643
+ } else {
3644
+ // The remediation budget belongs to this scope's attempt, not to the day. The machine
3645
+ // re-enters this state once per remediation loop, so initializing unconditionally
3646
+ // reset the budget every cycle and the loop could never classify its residue
3647
+ // (20261105 dogfood: the nested variants review revised forever). Like the drafts
3648
+ // scope, only the FIRST entry initializes.
3649
+ const counter = scopeCounter(workDir, scopeDir);
3650
+ if (!isFile(counter)) sted('write', counter, () => writeFileSync(counter, '0\n'));
3651
+ }
3384
3652
  for (const name of [
3385
3653
  'safety-review_report.json',
3386
3654
  'safety-judge_verdict.json',
3387
3655
  'safety-judge_status.txt',
3388
3656
  'safety-judge_stamp.json',
3657
+ 'safety-verdict.decision',
3658
+ 'safety-verdict.status',
3659
+ // A previous review's evidence must never survive into a new request: the bundle
3660
+ // freezes exactly what the current request's verdict admits. Evidence is the one
3661
+ // unprefixed artifact, so the runDate-prefixed sweep would miss it.
3662
+ 'safety.json',
3389
3663
  ]) {
3390
- const stale = safetyPath(workDir, runDate, name);
3664
+ const stale =
3665
+ name === 'safety.json' && !drafts
3666
+ ? join(scopeDirOf(workDir, scopeDir), name)
3667
+ : scopeArtifact(workDir, scopeDir, runDate, name);
3391
3668
  if (isFile(stale)) unlinkSync(stale);
3392
3669
  }
3393
- const request = safetyPath(workDir, runDate, 'safety-review_request.json');
3670
+ const request = scopeArtifact(workDir, scopeDir, runDate, 'safety-review_request.json');
3394
3671
  const targets = platforms
3395
3672
  .split(',')
3396
3673
  .map((platform) => platform.trim())
3397
3674
  .filter(Boolean);
3398
- sted('write', request, () => writeJsonText(request, { digest, platforms: targets, mode: 'detect-only' }));
3675
+ sted('write', request, () =>
3676
+ writeJsonText(request, {
3677
+ digest,
3678
+ platforms: targets,
3679
+ mode: 'detect-only',
3680
+ scope: drafts ? 'drafts' : 'variants',
3681
+ content: scopeContent(workDir, scopeDir, runDate),
3682
+ }),
3683
+ );
3399
3684
  });
3400
3685
  }
3401
3686
 
@@ -3429,8 +3714,46 @@ export function dailySafetyPrep(args: string[]): void {
3429
3714
  * revision spends the budget too: an unparsable draft must not be retried forever.
3430
3715
  */
3431
3716
  export function dailySafetyRevise(args: string[]): void {
3432
- const [workDir = '', runDate = ''] = args;
3717
+ const [workDir = '', runDate = '', scopeDir = ''] = args;
3718
+ const drafts = isDraftsScope(scopeDir);
3433
3719
  timed(workDir, 'safety-revise', () => {
3720
+ if (!drafts) {
3721
+ // Variants scope: every manifest-listed file must stay parseable Content with the
3722
+ // fields the publish half reads; the structural VoiceScript machinery does not apply.
3723
+ // Refresh each entry's sha256 to the post-remediation bytes: the manifest is the
3724
+ // contract the request/digest/stamp resolve through, so leaving the pre-remediation
3725
+ // digests in place made every re-review report a request-integrity mismatch and the
3726
+ // loop spin (20261105 dogfood). Structural drift above the hash level stays owned by
3727
+ // daily-bundle-variants (never run here).
3728
+ const manifestPath = join(workDir, SAFETY_VARIANTS_DIR, 'manifest.json');
3729
+ const manifest = readManifest(workDir);
3730
+ for (const [key, entry] of Object.entries(manifest.variants)) {
3731
+ const path = join(workDir, entry.path);
3732
+ const value = requireJson(path, 'safety-revise') as {
3733
+ title?: unknown;
3734
+ body?: unknown;
3735
+ };
3736
+ for (const field of ['title', 'body'] as const) {
3737
+ if (typeof value[field] !== 'string' || String(value[field]).trim() === '') {
3738
+ failWith(1, `safety-revise: variant ${path} ${field} must be a non-empty string`);
3739
+ }
3740
+ }
3741
+ entry.sha256 = createHash('sha256').update(readFileSync(path)).digest('hex');
3742
+ manifest.variants[key] = entry;
3743
+ }
3744
+ sted('write', manifestPath, () =>
3745
+ writeJsonText(manifestPath, {
3746
+ schemaVersion: manifest.schemaVersion,
3747
+ runDate: manifest.runDate,
3748
+ variants: manifest.variants,
3749
+ }),
3750
+ );
3751
+ const counter = scopeCounter(workDir, scopeDir);
3752
+ const next = readScopeCounter(workDir, scopeDir) + 1;
3753
+ sted('write', counter, () => writeFileSync(counter, `${next}\n`));
3754
+ console.log(`safety-revise: remediation pass ${next} applied to manifest variants; re-validated`);
3755
+ return;
3756
+ }
3434
3757
  const [articlePath, scriptPath] = safetyDraftPaths(workDir, runDate);
3435
3758
  const article = requireJson(articlePath, 'safety-revise');
3436
3759
  for (const field of ['title', 'body'] as const) {
@@ -3442,7 +3765,7 @@ export function dailySafetyRevise(args: string[]): void {
3442
3765
  assertScriptContent(scriptPath);
3443
3766
  restoreStructureIfBroken(workDir, runDate, scriptPath);
3444
3767
  const path = join(workDir, SAFETY_REVISE_COUNT);
3445
- const next = readSafetyReviseCount(workDir) + 1;
3768
+ const next = readScopeCounter(workDir, scopeDir) + 1;
3446
3769
  sted('write', path, () => writeFileSync(path, `${next}\n`));
3447
3770
  console.log(`safety-revise: remediation pass ${next} applied; drafts re-validated`);
3448
3771
  });
@@ -3945,6 +4268,23 @@ function normalizeTranslatedLocales(zhPostPath: string, failUnsafe = true): void
3945
4268
  }
3946
4269
  }
3947
4270
 
4271
+ /**
4272
+ * The translate agents run with cwd = this repo, and both executor sandboxes (codex workspace-write,
4273
+ * claude acceptEdits) refuse writes into the Surfdash checkout — on 2026-11-04 both exited 0 with
4274
+ * en/ja untouched. So they write `<runDate>_21_translate_<locale>.md` under the work dir and this
4275
+ * installs each over its scaffold. The output is consumed (renamed) so a rerun never clobbers an
4276
+ * operator's later repair of the installed post.
4277
+ */
4278
+ function installTranslatedLocales(workDir: string, runDate: string, zhPostPath: string): void {
4279
+ for (const locale of ['en', 'ja'] as const) {
4280
+ const translated = join(workDir, '31-publish', `${runDate}_21_translate_${locale}.md`);
4281
+ if (!isFile(translated)) continue;
4282
+ const target = zhPostPath.replace('/zh/', `/${locale}/`);
4283
+ sted('write', target, () => writeRaw(target, readFileSync(translated, 'utf-8')));
4284
+ renameSync(translated, `${translated}.installed`);
4285
+ }
4286
+ }
4287
+
3948
4288
  export function dailySurfdashRefine(args: string[]): void {
3949
4289
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
3950
4290
  const marker = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
@@ -3957,6 +4297,7 @@ export function dailySurfdashRefine(args: string[]): void {
3957
4297
  failWith(1, `surfdash-refine: zh marker must name an existing post: ${marker}`);
3958
4298
  }
3959
4299
  validateLocaleMarkers(workDir, runDate, zhPostPath, 'surfdash-refine');
4300
+ installTranslatedLocales(workDir, runDate, zhPostPath);
3960
4301
  // The validator is intentionally read-only. Repair known series spellings before
3961
4302
  // site-owned refine computes managed fields, otherwise the final publisher would
3962
4303
  // rewrite the refined bytes after the locale fingerprint was admitted.