@gobing-ai/knowledge-kit 0.0.16 → 0.0.18

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 (77) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +74 -0
  3. package/dist/index.js +46 -100
  4. package/package.json +19 -1
  5. package/plugins/generations/content-gen/dist/index.js +42 -94
  6. package/plugins/generations/core-facts-gen/dist/index.js +51 -100
  7. package/plugins/generations/daily-article-gen/dist/index.js +98 -100
  8. package/plugins/generations/daily-article-gen/src/index.ts +80 -6
  9. package/plugins/generations/dailynews-gen/dist/index.js +144 -130
  10. package/plugins/generations/dailynews-gen/src/script-builder.ts +141 -33
  11. package/plugins/generations/episode-plan-gen/dist/index.js +124 -115
  12. package/plugins/generations/episode-plan-gen/src/index.ts +111 -24
  13. package/plugins/generations/image-gen/dist/index.js +63 -115
  14. package/plugins/generations/news-report-gen/dist/index.js +164 -100
  15. package/plugins/generations/news-report-gen/src/index.ts +199 -7
  16. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +17 -1
  17. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +4 -22
  18. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +8 -0
  19. package/plugins/generations/voice-gen/dist/index.js +136 -117
  20. package/plugins/generations/voice-gen/src/concat.ts +34 -0
  21. package/plugins/generations/voice-gen/src/index.ts +6 -4
  22. package/plugins/generations/voice-gen/src/voicescript.ts +61 -0
  23. package/plugins/ingestions/aihot-ingest/dist/index.js +47 -97
  24. package/plugins/ingestions/horizon-ingest/dist/index.js +47 -96
  25. package/plugins/ingestions/karakeep-local/dist/index.js +217 -266
  26. package/plugins/ingestions/last30days-ingest/dist/index.js +42 -91
  27. package/plugins/ingestions/web-search/dist/index.js +68 -117
  28. package/plugins/kk/commands/workflow-run.md +25 -2
  29. package/plugins/kk/plugin.json +1 -1
  30. package/plugins/kk/scripts/kk-workflow-stages.ts +522 -163
  31. package/plugins/kk/scripts/validate-voicescript.ts +55 -0
  32. package/plugins/kk/skills/publish/SKILL.md +80 -0
  33. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +125 -120
  34. package/plugins/kk/workflows/leftover-publish-fanout-example.yaml +53 -0
  35. package/plugins/publishings/emdash-pub/dist/index.js +42 -91
  36. package/plugins/publishings/infoq-pub/dist/index.js +21994 -0
  37. package/plugins/publishings/infoq-pub/package.json +17 -0
  38. package/plugins/publishings/infoq-pub/plugin.json +7 -0
  39. package/plugins/publishings/infoq-pub/src/index.ts +123 -0
  40. package/plugins/publishings/infoq-pub/tsconfig.json +4 -0
  41. package/plugins/publishings/juejin-pub/dist/index.js +21994 -0
  42. package/plugins/publishings/juejin-pub/package.json +17 -0
  43. package/plugins/publishings/juejin-pub/plugin.json +7 -0
  44. package/plugins/publishings/juejin-pub/src/index.ts +123 -0
  45. package/plugins/publishings/juejin-pub/tsconfig.json +4 -0
  46. package/plugins/publishings/medium-pub/dist/index.js +22041 -0
  47. package/plugins/publishings/medium-pub/package.json +17 -0
  48. package/plugins/publishings/medium-pub/plugin.json +7 -0
  49. package/plugins/publishings/medium-pub/src/index.ts +215 -0
  50. package/plugins/publishings/medium-pub/tsconfig.json +4 -0
  51. package/plugins/publishings/podcast-pub/dist/index.js +183 -123
  52. package/plugins/publishings/podcast-pub/src/index.ts +47 -3
  53. package/plugins/publishings/podcast-pub/src/show-notes.ts +153 -34
  54. package/plugins/publishings/qiita-pub/dist/index.js +42 -91
  55. package/plugins/publishings/substack-pub/dist/index.js +21994 -0
  56. package/plugins/publishings/substack-pub/package.json +17 -0
  57. package/plugins/publishings/substack-pub/plugin.json +7 -0
  58. package/plugins/publishings/substack-pub/src/index.ts +123 -0
  59. package/plugins/publishings/substack-pub/tsconfig.json +4 -0
  60. package/plugins/publishings/surfdash-pub/dist/index.js +172 -103
  61. package/plugins/publishings/surfdash-pub/src/index.ts +32 -2
  62. package/plugins/publishings/wechatmp-pub/dist/index.js +22119 -0
  63. package/plugins/publishings/wechatmp-pub/package.json +17 -0
  64. package/plugins/publishings/wechatmp-pub/plugin.json +7 -0
  65. package/plugins/publishings/wechatmp-pub/src/index.ts +304 -0
  66. package/plugins/publishings/wechatmp-pub/tsconfig.json +4 -0
  67. package/plugins/publishings/x-pub/dist/index.js +22110 -0
  68. package/plugins/publishings/x-pub/package.json +17 -0
  69. package/plugins/publishings/x-pub/plugin.json +7 -0
  70. package/plugins/publishings/x-pub/src/index.ts +222 -0
  71. package/plugins/publishings/x-pub/tsconfig.json +4 -0
  72. package/plugins/publishings/xhs-pub/dist/index.js +21994 -0
  73. package/plugins/publishings/xhs-pub/package.json +17 -0
  74. package/plugins/publishings/xhs-pub/plugin.json +7 -0
  75. package/plugins/publishings/xhs-pub/src/index.ts +123 -0
  76. package/plugins/publishings/xhs-pub/tsconfig.json +4 -0
  77. package/plugins/publishings/zenn-pub/dist/index.js +42 -91
@@ -50,6 +50,10 @@ const VALIDATE_SIDECAR = 'validate-voicescript.ts';
50
50
  const WRAP_SIDECAR = 'wrap-voicescript-doc.ts';
51
51
  const RENDER_SIDECAR = 'render-md.ts';
52
52
  const REVISE_COUNT = '.revise-count';
53
+ // Post-translation title-dedup threshold (task 0138 R3). ponytail: the 2026-09-16 Gemini 3.8 Live
54
+ // pair measures 0.417 zh title-only Jaccard, the nearest distinct pair 0.1 (4x margin). Character
55
+ // bigrams need same-language input — pre-translate dedup over the mixed-language blend cannot work.
56
+ const DEDUP_TITLE_THRESHOLD = 0.4;
53
57
 
54
58
  function fail(message: string): never {
55
59
  console.error(message);
@@ -496,6 +500,16 @@ function metadataOf(value: Record<string, unknown> | null): Record<string, unkno
496
500
  return metadata !== null && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {};
497
501
  }
498
502
 
503
+ /**
504
+ * Required stage input: a missing or unparsable file fails the stage naming the path, so a
505
+ * run-date/path mismatch surfaces as its real cause instead of a downstream "field missing" error.
506
+ */
507
+ function requireJson(path: string, label: string): Record<string, unknown> {
508
+ const parsed = readJson(path);
509
+ if (parsed === null) failWith(1, `${label}: required input not found or unparsable: ${path}`);
510
+ return parsed;
511
+ }
512
+
499
513
  /** `jq -e '.targets[]? | select(.target == t and .ok == true and .status == "published")'`. */
500
514
  function targetPublished(path: string, target: string): boolean {
501
515
  const parsed = readJson(path);
@@ -643,6 +657,72 @@ function dailyQcContent(args: string[]): void {
643
657
  });
644
658
  }
645
659
 
660
+ /**
661
+ * Character-bigram Jaccard over whitespace-normalized, lowercased titles — the twin of
662
+ * episode-plan-gen's `titleSimilarity`. Duplicated on purpose: this file is node-builtins-only
663
+ * (no workspace imports), so it cannot reach that module.
664
+ */
665
+ function titleSimilarity(a: string, b: string): number {
666
+ const bigrams = (text: string): Set<string> => {
667
+ const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase();
668
+ const out = new Set<string>();
669
+ for (let i = 0; i < normalized.length - 1; i++) out.add(normalized.slice(i, i + 2));
670
+ return out;
671
+ };
672
+ const aGrams = bigrams(a);
673
+ const bGrams = bigrams(b);
674
+ if (aGrams.size === 0 || bGrams.size === 0) return 0;
675
+ let intersection = 0;
676
+ for (const gram of aGrams) if (bGrams.has(gram)) intersection++;
677
+ return intersection / (aGrams.size + bGrams.size - intersection);
678
+ }
679
+
680
+ /**
681
+ * Post-translation near-duplicate dedup (task 0138 R3): drop any plan item whose title is a
682
+ * >=DEDUP_TITLE_THRESHOLD bigram match for an EARLIER item, so curated plan order decides the
683
+ * survivor. It runs on the translated plan because cross-language near-dups are invisible before
684
+ * plan-translate (the 2026-09-16 Gemini pair measures 0.208 mixed-language, 0.417 all-zh).
685
+ */
686
+ export function dedupPlanByTitle<T extends { id?: unknown; title?: unknown }>(
687
+ docs: T[],
688
+ ): { kept: T[]; dropped: string[] } {
689
+ const kept: T[] = [];
690
+ const dropped: string[] = [];
691
+ for (const doc of docs) {
692
+ const title = typeof doc.title === 'string' ? doc.title : '';
693
+ const duplicate =
694
+ title !== '' &&
695
+ kept.some((earlier) => {
696
+ const earlierTitle = typeof earlier.title === 'string' ? earlier.title : '';
697
+ return titleSimilarity(earlierTitle, title) >= DEDUP_TITLE_THRESHOLD;
698
+ });
699
+ if (duplicate) {
700
+ dropped.push(typeof doc.id === 'string' && doc.id !== '' ? doc.id : title);
701
+ continue;
702
+ }
703
+ kept.push(doc);
704
+ }
705
+ return { kept, dropped };
706
+ }
707
+
708
+ /**
709
+ * Run the VoiceScript content validator over a written script and fail the stage when it rejects
710
+ * (task 0139 R3). `spawnSync` rather than `delegate` on purpose: `delegate` exits the process
711
+ * directly, which would skip `timed`'s `finally` and lose the step-timing entry the run report
712
+ * reads. The sidecar's own stderr carries `content check failed: segment <idx> …`; this adds the
713
+ * stage-level naming so the operator sees which artifact was rejected.
714
+ */
715
+ function assertScriptContent(scriptPath: string): void {
716
+ // `spawnSync` + `failWith`, deliberately NOT `delegate`: delegate is `never`-returning (it calls
717
+ // process.exit), so using it mid-function would abort the calling stage on SUCCESS — dailyWrapDocs
718
+ // would exit before packaging its docs. A clean exit here falls through to the rest of the stage.
719
+ const sidecar = requireSidecar('', VALIDATE_SIDECAR, 'validate_script');
720
+ const runner = spawnSync(process.execPath, [sidecar, '--in', scriptPath], { stdio: 'inherit' });
721
+ if (runner.status !== 0) {
722
+ failWith(runner.status ?? 1, `script content check failed: ${scriptPath} was rejected (see above)`);
723
+ }
724
+ }
725
+
646
726
  function dailyArticle(args: string[]): void {
647
727
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
648
728
  const articleMd = join(workDir, '2-article', `${runDate}_06_article_article.md`);
@@ -650,10 +730,6 @@ function dailyArticle(args: string[]): void {
650
730
  const planIn = join(workDir, '2-plan', `${runDate}_03_plan_plan.json`);
651
731
  const candidates = join(workDir, '2-plan', `${runDate}_04_quality-control-content_candidates.json`);
652
732
  timed(workDir, 'article', () => {
653
- if (isFile(articleMd)) {
654
- console.log(`article cached: ${articleMd}`);
655
- return;
656
- }
657
733
  let planUsable = true;
658
734
  const raw = isFile(planIn) ? readFileSync(planIn, 'utf-8') : '';
659
735
  try {
@@ -671,6 +747,25 @@ function dailyArticle(args: string[]): void {
671
747
  }
672
748
  }
673
749
  if (!planUsable) failWith(1, 'article: no usable plan input');
750
+ // R3 (task 0138): dedup runs BEFORE the cache early-return — the script stage reads this same
751
+ // plan.json and must see one survivor even when the article is already cached. Idempotent:
752
+ // a no-op once the plan is deduped.
753
+ const planParsed: unknown = readJson(planIn);
754
+ if (Array.isArray(planParsed)) {
755
+ const { kept, dropped } = dedupPlanByTitle(planParsed as Record<string, unknown>[]);
756
+ if (dropped.length > 0) {
757
+ sted('write', planIn, () => writeJsonText(planIn, kept));
758
+ console.error(`article: dropped ${dropped.length} near-duplicate plan item(s): ${dropped.join(', ')}`);
759
+ }
760
+ }
761
+ if (isFile(articleMd)) {
762
+ console.log(`article cached: ${articleMd}`);
763
+ return;
764
+ }
765
+ // Agent-written prose body (workflow `article-write` state) replaces the mechanical
766
+ // per-item render when present — the 3-format split: surfdash gets an article,
767
+ // not a news list (2026-09-16 feedback).
768
+ const proseBody = join(workDir, '2-article', `${runDate}_06_article-write_body.md`);
674
769
  kk(
675
770
  [
676
771
  'executor',
@@ -683,7 +778,10 @@ function dailyArticle(args: string[]): void {
683
778
  ...pluginsArgs(pluginsPath),
684
779
  ],
685
780
  {
686
- env: { ARTICLE_DATE: runDate },
781
+ env: {
782
+ ARTICLE_DATE: runDate,
783
+ ...(isFile(proseBody) ? { ARTICLE_BODY_FILE: proseBody } : {}),
784
+ },
687
785
  },
688
786
  );
689
787
  const body = readJson(articleContent)?.body;
@@ -711,7 +809,10 @@ function dailyCoverNormalize(args: string[]): void {
711
809
  function coverNormalizeBody(workDir: string, runDate: string): void {
712
810
  const png = join(workDir, '2-cover', 'content-cover-01.png');
713
811
  const jpg = join(workDir, '2-cover', 'content-cover-01.jpg');
714
- const out = join(workDir, '2-cover', `cover-${runDate}.png`);
812
+ // Must match the name dailyPublishPrep and the article->cover workflow guard read:
813
+ // `${runDate}_07_cover_cover.png` (a plain `cover-${runDate}.png` left options.coverPath
814
+ // undefined and silently dropped the cover from both podcast and surfdash — 2026-09-16 run).
815
+ const out = join(workDir, '2-cover', `${runDate}_07_cover_cover.png`);
715
816
  const source = isFile(png) ? png : isFile(jpg) ? jpg : '';
716
817
  if (source === '') failWith(1, 'cover normalize: no content-cover-01.{png,jpg} artifact');
717
818
  if (source === jpg) {
@@ -734,7 +835,7 @@ function coverNormalizeBody(workDir: string, runDate: string): void {
734
835
  // `rm -f` is silent when the file is already gone.
735
836
  }
736
837
  }
737
- console.log(`cover normalized: cover-${runDate}.png`);
838
+ console.log(`cover normalized: ${runDate}_07_cover_cover.png`);
738
839
  }
739
840
 
740
841
  function dailyScript(args: string[]): void {
@@ -753,6 +854,9 @@ function dailyScript(args: string[]): void {
753
854
  const body = readJson(contentOut)?.body;
754
855
  if (typeof body !== 'string') failWith(1, `script: ${contentOut} has no string body`);
755
856
  sted('write', yamlOut, () => writeRaw(yamlOut, body));
857
+ // R3 (task 0139): fresh writes are validated here; the script cache path is covered by
858
+ // wrap-docs, which runs on every path that reaches audio.
859
+ assertScriptContent(yamlOut);
756
860
  });
757
861
  }
758
862
 
@@ -761,6 +865,9 @@ function dailyWrapDocs(args: string[]): void {
761
865
  const scriptPath = join(workDir, '2-script', `${runDate}_08_script_voicescript.yaml`);
762
866
  const docsPath = join(workDir, '3-audio', `${runDate}_10_wrap-docs_docs.json`);
763
867
  timed(workDir, 'wrap-docs', () => {
868
+ // R3 (task 0139): the second validator placement — covers a HITL-edited YAML and the
869
+ // script-stage cache path, neither of which re-enters dailyScript's fresh-write branch.
870
+ assertScriptContent(scriptPath);
764
871
  const url = new URL(scriptPath, `file://${process.cwd()}/`);
765
872
  const body = readFileSync(decodeURIComponent(url.pathname), 'utf-8');
766
873
  const profile = voiceProfile || process.env.VOICEBOX_DEFAULT_PROFILE || 'robin-news';
@@ -777,7 +884,17 @@ function dailyWrapDocs(args: string[]): void {
777
884
  }
778
885
 
779
886
  function dailyGenerate(args: string[]): void {
780
- const [workDir = '', runDate = '', pluginsPath = '', transcodeMp3 = '', voiceGenerator = ''] = args;
887
+ const [workDir = '', runDate = '', pluginsPath = '', transcodeMp3 = '', vdriver = ''] = args;
888
+ // 20260917 review: the workflow picks a voice driver by short name; both drivers' envs are
889
+ // passed unconditionally so switching `vdriver` needs no shell changes. Unknown -> fail loud.
890
+ const VOICE_DRIVERS: Record<string, string> = { ominivoice: 'omni-voice-gen', voicebox: 'voice-gen' };
891
+ const voiceGenerator = VOICE_DRIVERS[vdriver];
892
+ if (!voiceGenerator) {
893
+ failWith(
894
+ 1,
895
+ `generate: unknown vdriver '${vdriver}' (expected one of: ${Object.keys(VOICE_DRIVERS).join(', ')})`,
896
+ );
897
+ }
781
898
  const docs = join(workDir, '3-audio', `${runDate}_10_wrap-docs_docs.json`);
782
899
  const content = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
783
900
  timed(workDir, 'generate', () => {
@@ -794,6 +911,111 @@ function dailyGenerate(args: string[]): void {
794
911
  });
795
912
  }
796
913
 
914
+ /**
915
+ * 20260917 review #7: prepend the show's 8s intro sting to the generated audio.
916
+ * `intro_music` empty -> skip. Writes `<stem>.intro.wav` (original render kept), points
917
+ * `audioPath` at the merged file, extends `duration`, marks `introMerged` (idempotent).
918
+ */
919
+ function dailyIntro(args: string[]): void {
920
+ const [workDir = '', runDate = '', introSrc = ''] = args;
921
+ if (!introSrc || introSrc.trim() === '') {
922
+ console.log('intro-music: intro_music var empty — skip');
923
+ return;
924
+ }
925
+ const contentPath = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
926
+ timed(workDir, 'intro-music', () => {
927
+ const content = requireJson(contentPath, 'intro-music');
928
+ const meta = metadataOf(content);
929
+ if (meta.introMerged === true) {
930
+ console.log(`intro-music cached: ${contentPath}`);
931
+ return;
932
+ }
933
+ const rawFile = meta.mp3Path ?? meta.audioPath;
934
+ if (typeof rawFile !== 'string' || rawFile === '') {
935
+ failWith(1, 'intro-music: voice content carries no mp3Path/audioPath');
936
+ }
937
+ const introPath = resolve(introSrc);
938
+ if (!existsSync(introPath)) failWith(1, `intro-music: intro file not found: ${introPath}`);
939
+ const mainPath = resolve(rawFile);
940
+ if (!existsSync(mainPath)) failWith(1, `intro-music: generated audio not found: ${mainPath}`);
941
+ const mergedPath = mainPath.replace(/\.wav$/, '.intro.wav');
942
+ const probe = (file: string, entries: string): string =>
943
+ spawnSync('ffprobe', [
944
+ '-v',
945
+ 'error',
946
+ '-select_streams',
947
+ 'a:0',
948
+ '-show_entries',
949
+ entries,
950
+ '-of',
951
+ 'csv=p=0',
952
+ file,
953
+ ])
954
+ .stdout?.toString()
955
+ .trim() ?? '';
956
+ const mainDur = Number.parseFloat(probe(mainPath, 'format=duration'));
957
+ const introDur = Number.parseFloat(probe(introPath, 'format=duration'));
958
+ if (!Number.isFinite(mainDur) || !Number.isFinite(introDur)) {
959
+ failWith(1, `intro-music: ffprobe could not read durations (main=${mainDur} intro=${introDur})`);
960
+ }
961
+ const rate = probe(mainPath, 'stream=sample_rate') || '24000'; // match the generated audio's rate; mono s16 both sides
962
+ const norm = (label: string): string =>
963
+ `[${label}:a]aresample=${rate},aformat=sample_fmts=s16:channel_layouts=mono[${label}]`;
964
+ const fc = `${norm('0')};${norm('1')};[0][1]concat=n=2:v=0:a=1[out]`;
965
+ const conv = spawnSync('ffmpeg', [
966
+ '-y',
967
+ '-hide_banner',
968
+ '-loglevel',
969
+ 'error',
970
+ '-i',
971
+ introPath,
972
+ '-i',
973
+ mainPath,
974
+ '-filter_complex',
975
+ fc,
976
+ '-map',
977
+ '[out]',
978
+ '-c:a',
979
+ 'pcm_s16le',
980
+ mergedPath,
981
+ ]);
982
+ if (conv.status !== 0 || !existsSync(mergedPath)) {
983
+ failWith(1, `intro-music: ffmpeg concat failed: ${conv.stderr?.toString().trim()}`);
984
+ }
985
+ if (meta.mp3Path && typeof meta.mp3Path === 'string') {
986
+ // transcode_mp3=true: keep the mp3 path honest by re-transcoding the merged wav.
987
+ const mp3 = spawnSync('ffmpeg', [
988
+ '-y',
989
+ '-hide_banner',
990
+ '-loglevel',
991
+ 'error',
992
+ '-i',
993
+ mergedPath,
994
+ '-codec:a',
995
+ 'libmp3lame',
996
+ '-qscale:a',
997
+ '2',
998
+ resolve(meta.mp3Path),
999
+ ]);
1000
+ if (mp3.status !== 0) failWith(1, `intro-music: mp3 re-transcode failed: ${mp3.stderr?.toString().trim()}`);
1001
+ }
1002
+ const mergedDur = Number.parseFloat(probe(mergedPath, 'format=duration')) || mainDur + introDur;
1003
+ const updated = {
1004
+ ...content,
1005
+ metadata: {
1006
+ ...meta,
1007
+ audioPath: mergedPath,
1008
+ duration: mergedDur,
1009
+ introMerged: true,
1010
+ },
1011
+ };
1012
+ sted('write', contentPath, () => writeJsonText(contentPath, updated));
1013
+ console.log(
1014
+ `intro-music: merged ${introDur.toFixed(1)}s intro -> ${mergedPath} (${mergedDur.toFixed(1)}s total)`,
1015
+ );
1016
+ });
1017
+ }
1018
+
797
1019
  function dailyQualityReport(args: string[]): void {
798
1020
  const [workDir = '', runDate = ''] = args;
799
1021
  const content = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
@@ -817,7 +1039,7 @@ function dailyPublishPrep(args: string[]): void {
817
1039
  const prepPath = join(workDir, '3-publish', `${runDate}_13_publish-prep_content.json`);
818
1040
  const coverPath = join(workDir, '2-cover', `${runDate}_07_cover_cover.png`);
819
1041
  timed(workDir, 'publish-prep', () => {
820
- const article = readJson(articlePath) ?? {};
1042
+ const article = requireJson(articlePath, 'publish-prep');
821
1043
  const audioMeta = metadataOf(readJson(audioPath));
822
1044
  const rawFile = audioMeta.mp3Path ?? audioMeta.audioPath;
823
1045
  if (typeof rawFile !== 'string' || rawFile === '') {
@@ -825,13 +1047,21 @@ function dailyPublishPrep(args: string[]): void {
825
1047
  }
826
1048
  const audioFile = resolve(rawFile);
827
1049
  const resolvedCover = resolve(coverPath);
1050
+ const hasCover = existsSync(resolvedCover);
1051
+ // podcast-pub's documented contract reads metadata.coverImage (map.ts); options.coverPath
1052
+ // alone was silently dropped by the mapper — episodes published without a cover.
828
1053
  const merged = {
829
1054
  ...article,
830
- metadata: { ...metadataOf(article), audioFile, runDate },
1055
+ metadata: {
1056
+ ...metadataOf(article),
1057
+ audioFile,
1058
+ runDate,
1059
+ ...(hasCover ? { coverImage: resolvedCover } : {}),
1060
+ },
831
1061
  options: {
832
1062
  audioFile,
833
1063
  durationSec: audioMeta.duration,
834
- coverPath: existsSync(resolvedCover) ? resolvedCover : undefined,
1064
+ coverPath: hasCover ? resolvedCover : undefined,
835
1065
  },
836
1066
  };
837
1067
  sted('write', prepPath, () => writeJsonText(prepPath, merged));
@@ -877,6 +1107,9 @@ function dailyShowNotes(args: string[]): void {
877
1107
  const result = join(workDir, '3-publish', `${runDate}_14_publish-surfdash_result.json`);
878
1108
  const out = join(workDir, '3-publish', `${runDate}_15_show-notes_show-notes.md`);
879
1109
  const plan = join(workDir, '2-plan', `${runDate}_03_plan_plan.json`);
1110
+ // Generated-episode content carries `metadata.segments[]` — the source of real
1111
+ // audio offsets for the show-notes timeline (jump-to-item timestamps).
1112
+ const audioContent = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
880
1113
  timed(workDir, 'show-notes', () => {
881
1114
  const effective = readJson(article)?.title;
882
1115
  const titleArg = title !== '' ? title : typeof effective === 'string' ? effective : '';
@@ -887,6 +1120,7 @@ function dailyShowNotes(args: string[]): void {
887
1120
  PODCAST_PUB_RUN_DATE: runDate,
888
1121
  PODCAST_PUB_BRIEF_MODE: 'true',
889
1122
  PODCAST_PUB_ARTICLE_POST_PATH: postPath,
1123
+ ...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
890
1124
  },
891
1125
  });
892
1126
  });
@@ -917,7 +1151,7 @@ function dailyPublishPodcast(args: string[]): void {
917
1151
  return;
918
1152
  }
919
1153
  try {
920
- const content = readJson(prep) ?? {};
1154
+ const content = requireJson(prep, 'publish-podcast');
921
1155
  const showNotes = readFileSync(notes, 'utf-8');
922
1156
  const merged = { ...content, metadata: { ...metadataOf(content), showNotes } };
923
1157
  sted('write', prep, () => writeJsonText(prep, merged));
@@ -997,6 +1231,95 @@ function dailyPublishPartial(args: string[]): void {
997
1231
  });
998
1232
  }
999
1233
 
1234
+ /**
1235
+ * 20260917 review #4/#6: after both publishes land, patch the surfdash disk posts —
1236
+ * reuse the podcast-hosted cover URL in frontmatter (image/og_image; no re-upload; fallback:
1237
+ * copy the local cover into assets/) and append a podcast cross-link at the end of zh/en/ja.
1238
+ * Idempotent per post; commits + pushes the surfdash checkout (SURFDASH_AUTO_PUSH=0 keeps commit-only).
1239
+ */
1240
+ function dailyPatchSurfdash(args: string[]): void {
1241
+ const [workDir = '', runDate = '', surfdashRoot = ''] = args;
1242
+ if (!surfdashRoot || !existsSync(surfdashRoot)) {
1243
+ console.log(`patch-surfdash: surfdash root '${surfdashRoot}' not found — skip`);
1244
+ return;
1245
+ }
1246
+ timed(workDir, 'patch-surfdash', () => {
1247
+ const sdResult = join(workDir, '3-publish', `${runDate}_14_publish-surfdash_result.json`);
1248
+ const podcastResult = join(workDir, '3-publish', `${runDate}_16_publish-podcast_result.json`);
1249
+ if (!isFile(sdResult) || !isFile(podcastResult)) {
1250
+ failWith(1, 'patch-surfdash: missing surfdash/podcast publish results — run the publish states first');
1251
+ }
1252
+ const targetOf = (path: string): Record<string, unknown> => {
1253
+ const parsed = readJson(path) as { targets?: Array<Record<string, unknown>> };
1254
+ return parsed.targets?.[0] ?? {};
1255
+ };
1256
+ const sd = targetOf(sdResult);
1257
+ const pod = targetOf(podcastResult);
1258
+ const postPath = (sd.metadata as Record<string, unknown> | undefined)?.postPath;
1259
+ if (typeof postPath !== 'string' || postPath === '')
1260
+ failWith(1, 'patch-surfdash: surfdash result carries no postPath');
1261
+ const slugDir = dirname(postPath);
1262
+ const showNotesUrl = typeof pod.url === 'string' ? pod.url : '';
1263
+ const coverUrl = ((pod.metadata as Record<string, unknown> | undefined)?.imageUrl as string | undefined) ?? '';
1264
+ const localCover = join(workDir, '2-cover', `${runDate}_07_cover_cover.png`);
1265
+ const dateIso = `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}`;
1266
+ const crossLink: Record<string, string> = {
1267
+ zh: `🎧 本期已同步制作为播客节目,[点击收听《每日 AI 资讯 · ${dateIso}》](${showNotesUrl})。`,
1268
+ en: `🎧 This episode is also available as a podcast: [listen to the Daily AI Briefing · ${dateIso}](${showNotesUrl}).`,
1269
+ ja: `🎧 本エピソードはポッドキャストでも配信中:[Daily AI Briefing · ${dateIso} を聴く](${showNotesUrl})。`,
1270
+ };
1271
+ let changed = 0;
1272
+ for (const [locale, dir] of [
1273
+ ['zh', slugDir],
1274
+ ['en', join(dirname(slugDir), 'en', basename(slugDir))],
1275
+ ['ja', join(dirname(slugDir), 'ja', basename(slugDir))],
1276
+ ] as const) {
1277
+ const indexMd = join(dir, 'index.md');
1278
+ if (!existsSync(indexMd)) {
1279
+ console.log(`patch-surfdash: ${locale} post not published (${indexMd}) — skip`);
1280
+ continue;
1281
+ }
1282
+ let md = readFileSync(indexMd, 'utf-8');
1283
+ const fmEnd = md.indexOf('---', 3); // frontmatter is `---\n ... \n---\n`
1284
+ if (fmEnd < 0) failWith(1, `patch-surfdash: no frontmatter in ${indexMd}`);
1285
+ if (!/^image:/m.test(md.slice(0, fmEnd))) {
1286
+ let image = coverUrl;
1287
+ if (image === '' && existsSync(localCover)) {
1288
+ // Fallback for pre-#6 runs: copy the local cover instead of a hosted URL.
1289
+ const assets = join(dir, 'assets');
1290
+ mkdirSync(assets, { recursive: true });
1291
+ copyFileSync(localCover, join(assets, 'cover.png'));
1292
+ image = './assets/cover.png';
1293
+ }
1294
+ if (image !== '') {
1295
+ md = md.replace(/^publishDate: .*$/m, (m0) => `${m0}\nimage: '${image}'\nog_image: '${image}'`);
1296
+ }
1297
+ }
1298
+ if (showNotesUrl !== '' && !md.includes(showNotesUrl)) {
1299
+ md = `${md.replace(/\s*$/, '')}\n\n${crossLink[locale]}\n`;
1300
+ }
1301
+ if (md !== readFileSync(indexMd, 'utf-8')) {
1302
+ sted('write', indexMd, () => writeFileSync(indexMd, md));
1303
+ changed++;
1304
+ }
1305
+ }
1306
+ if (changed === 0) {
1307
+ console.log('patch-surfdash: nothing to change');
1308
+ return;
1309
+ }
1310
+ const git = (...a: string[]): void => {
1311
+ const r = spawnSync('git', ['-C', surfdashRoot, ...a]);
1312
+ if (r.status !== 0) failWith(1, `patch-surfdash: git ${a[0]} failed: ${r.stderr?.toString().trim()}`);
1313
+ };
1314
+ git('add', 'content/posts/articles');
1315
+ git('commit', '-m', `feat: cover + podcast cross-link for ${basename(slugDir)} (kk daily patch)`);
1316
+ if (process.env.SURFDASH_AUTO_PUSH !== '0') git('push');
1317
+ console.log(
1318
+ `patch-surfdash: patched ${changed} post(s), committed${process.env.SURFDASH_AUTO_PUSH !== '0' ? ' + pushed' : ' (push skipped: SURFDASH_AUTO_PUSH=0)'}`,
1319
+ );
1320
+ });
1321
+ }
1322
+
1000
1323
  function dailyRunReport(args: string[]): void {
1001
1324
  const [
1002
1325
  workDir = '',
@@ -1021,6 +1344,9 @@ function dailyRunReport(args: string[]): void {
1021
1344
  return;
1022
1345
  }
1023
1346
  sted('write', cands, () => writeJqText(cands, metadataOf(readJson(candidates)).docs ?? []));
1347
+ // Plugin env paths must be absolute: spawnPluginEntry runs plugins with cwd = the
1348
+ // plugin package dir, so a relative workDir silently misses every best-effort read.
1349
+ const absWorkDir = resolve(workDir);
1024
1350
  kk(['executor', 'run', 'news-report-gen', '--in', cands, '--out', reportJson, ...pluginsArgs(pluginsPath)], {
1025
1351
  env: {
1026
1352
  QC_MIN_QUALITY: minQuality,
@@ -1028,11 +1354,19 @@ function dailyRunReport(args: string[]): void {
1028
1354
  QC_MIN_URGENCY: minUrgency,
1029
1355
  QC_MIN_IMPACT: minImpact,
1030
1356
  NEWS_REPORT_REJECTED_FILE: join(
1031
- workDir,
1357
+ absWorkDir,
1032
1358
  '2-plan',
1033
1359
  `${runDate}_04_quality-control-content_candidates.rejected.json`,
1034
1360
  ),
1035
- NEWS_REPORT_TIMING_FILE: join(workDir, 'step-timing.json'),
1361
+ NEWS_REPORT_TIMING_FILE: join(absWorkDir, 'step-timing.json'),
1362
+ // 0139 R5b: the generate stage's QC audit, read best-effort (absent -> section omitted).
1363
+ NEWS_REPORT_QC_FILE: join(absWorkDir, '3-audio', `${runDate}_11_generate_content.json`),
1364
+ // 20260917 funnel: the plan stage's full selection trail (every fetched title +
1365
+ // scores + disposition), rendered as the 选稿漏斗 section (absent -> omitted).
1366
+ NEWS_REPORT_PLAN_AUDIT_FILE: join(absWorkDir, '2-plan', `${runDate}_03_plan_plan.json.audit.json`),
1367
+ // 20260917 funnel: the blended fan-in input, rendered as the 语料构成
1368
+ // source/category composition (absent -> omitted).
1369
+ NEWS_REPORT_BLENDED_FILE: join(absWorkDir, '1-ingest', `${runDate}_02_collect_blended.json`),
1036
1370
  NEWS_REPORT_DATE: runDate,
1037
1371
  },
1038
1372
  });
@@ -1043,68 +1377,78 @@ function dailyRunReport(args: string[]): void {
1043
1377
  });
1044
1378
  }
1045
1379
 
1046
- function dailyTranslateEn(args: string[]): void {
1380
+ /**
1381
+ * Collapsed translate scaffolding (2026-09-16 feedback): surfdash-pub owns the
1382
+ * deterministic mechanics — `scaffold-locale` for both target locales — while the
1383
+ * workflow `translate` state's agent step owns the LLM translation of the drafts.
1384
+ * Writes one marker file per locale (zh post path + en/ja draft paths); empty
1385
+ * marker = locale absent (callers skip). Reuses an existing on-disk draft as the
1386
+ * cache across run retries — re-scaffolding would erase the agent's translation.
1387
+ */
1388
+ function dailyTranslateLocalize(args: string[]): void {
1047
1389
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
1048
1390
  const result = join(workDir, '3-publish', `${runDate}_17_publish_result.json`);
1049
- const zhFile = join(workDir, '3-publish', `${runDate}_20_translate-en_zh-post-path.txt`);
1050
- const enFile = join(workDir, '3-publish', `${runDate}_20_translate-en_en-post-path.txt`);
1051
- const scaffold = join(workDir, '3-publish', `${runDate}_20_translate-en_scaffold-en.json`);
1052
- const scaffoldResult = join(workDir, '3-publish', `${runDate}_20_translate-en_scaffold-en-result.json`);
1053
- timed(workDir, 'translate-en', () => {
1391
+ const zhFile = join(workDir, '3-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
1392
+ timed(workDir, 'translate-localize', () => {
1054
1393
  const postPath = firstPostPath(readJson(result), 'surfdash-pub');
1055
1394
  const zh = postPath.endsWith('index.md') ? postPath : '';
1056
1395
  sted('write', zhFile, () => writeRaw(zhFile, zh));
1057
- const en = zh.replace('/zh/', '/en/');
1058
- sted('write', enFile, () => writeRaw(enFile, en));
1059
- if (zh === '') {
1060
- console.log('no zh post path — skip translate-en');
1061
- return;
1062
- }
1063
- if (isFile(en)) {
1064
- console.log(`en draft cached: ${en}`);
1065
- return;
1066
- }
1067
- sted('write', scaffold, () =>
1068
- writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target: 'en' } }),
1069
- );
1070
- kk(['executor', 'run', 'surfdash-pub', '--in', scaffold, '--out', scaffoldResult, ...pluginsArgs(pluginsPath)]);
1071
- const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
1072
- sted('write', enFile, () => writeRaw(enFile, typeof draftPath === 'string' ? draftPath : ''));
1073
- });
1074
- }
1075
-
1076
- function dailyTranslateJa(args: string[]): void {
1077
- const [workDir = '', runDate = '', pluginsPath = ''] = args;
1078
- const zhFile = join(workDir, '3-publish', `${runDate}_20_translate-en_zh-post-path.txt`);
1079
- const jaFile = join(workDir, '3-publish', `${runDate}_22_translate-ja_ja-post-path.txt`);
1080
- const scaffold = join(workDir, '3-publish', `${runDate}_22_translate-ja_scaffold-ja.json`);
1081
- const scaffoldResult = join(workDir, '3-publish', `${runDate}_22_translate-ja_scaffold-ja-result.json`);
1082
- timed(workDir, 'translate-ja', () => {
1083
- const zh = isFile(zhFile) ? readFileSync(zhFile, 'utf-8') : '';
1084
- const ja = zh.replace('/zh/', '/ja/');
1085
- sted('write', jaFile, () => writeRaw(jaFile, ja));
1086
1396
  if (zh === '') {
1087
- console.log('no zh post path — skip translate-ja');
1397
+ for (const target of ['en', 'ja'] as const) {
1398
+ sted(
1399
+ 'write',
1400
+ join(workDir, '3-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
1401
+ () =>
1402
+ writeRaw(
1403
+ join(workDir, '3-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
1404
+ '',
1405
+ ),
1406
+ );
1407
+ }
1408
+ console.log('no zh post path — skip localize');
1088
1409
  return;
1089
1410
  }
1090
- if (isFile(ja)) {
1091
- console.log(`ja draft cached: ${ja}`);
1092
- return;
1411
+ for (const target of ['en', 'ja'] as const) {
1412
+ const markerFile = join(workDir, '3-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`);
1413
+ const existing = zh.replace('/zh/', `/${target}/`);
1414
+ if (isFile(existing)) {
1415
+ sted('write', markerFile, () => writeRaw(markerFile, existing));
1416
+ console.log(`${target} draft cached: ${existing}`);
1417
+ continue;
1418
+ }
1419
+ const scaffold = join(workDir, '3-publish', `${runDate}_20_translate-localize_scaffold-${target}.json`);
1420
+ const scaffoldResult = join(
1421
+ workDir,
1422
+ '3-publish',
1423
+ `${runDate}_20_translate-localize_scaffold-${target}-result.json`,
1424
+ );
1425
+ sted('write', scaffold, () =>
1426
+ writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target } }),
1427
+ );
1428
+ kk([
1429
+ 'executor',
1430
+ 'run',
1431
+ 'surfdash-pub',
1432
+ '--in',
1433
+ scaffold,
1434
+ '--out',
1435
+ scaffoldResult,
1436
+ ...pluginsArgs(pluginsPath),
1437
+ ]);
1438
+ const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
1439
+ sted('write', markerFile, () => writeRaw(markerFile, typeof draftPath === 'string' ? draftPath : ''));
1093
1440
  }
1094
- sted('write', scaffold, () =>
1095
- writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target: 'ja' } }),
1096
- );
1097
- kk(['executor', 'run', 'surfdash-pub', '--in', scaffold, '--out', scaffoldResult, ...pluginsArgs(pluginsPath)]);
1098
- const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
1099
- sted('write', jaFile, () => writeRaw(jaFile, typeof draftPath === 'string' ? draftPath : ''));
1100
1441
  });
1101
1442
  }
1102
1443
 
1103
1444
  function dailyTranslateSync(args: string[]): void {
1104
- const [workDir = '', runDate = '', pluginsPath = '', zhPostPath = ''] = args;
1445
+ const [workDir = '', runDate = '', pluginsPath = '', zhPathFile = ''] = args;
1105
1446
  const sync = join(workDir, '3-publish', `${runDate}_24_translate-sync_sync.json`);
1106
1447
  const syncResult = join(workDir, '3-publish', `${runDate}_24_translate-sync_sync-result.json`);
1107
1448
  timed(workDir, 'translate-sync', () => {
1449
+ // Reads the marker file itself — the collapsed `translate` state has no
1450
+ // cross-state var to hand the path through.
1451
+ const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8') : '';
1108
1452
  if (zhPostPath === '') {
1109
1453
  console.log('no zh post path — skip translate-sync');
1110
1454
  return;
@@ -1118,109 +1462,124 @@ function dailyTranslateSync(args: string[]): void {
1118
1462
 
1119
1463
  const [stage, ...rest] = process.argv.slice(2);
1120
1464
 
1121
- switch (stage) {
1122
- case 'prepare-itc':
1123
- prepareItc(rest);
1124
- break;
1125
- case 'prepare-solo':
1126
- prepareSolo(rest);
1127
- break;
1128
- case 'prepare-storm':
1129
- prepareStorm(rest);
1130
- break;
1131
- case 'duration':
1132
- duration(rest);
1133
- break;
1134
- case 'validate': {
1135
- const { override, values } = parseStage(rest, { in: { type: 'string' } });
1136
- if (!values.in) fail('usage: kk-workflow-stages.ts validate [<override>] --in <voicescript.yaml>');
1137
- delegate(requireSidecar(override, VALIDATE_SIDECAR, 'validate_script'), ['--in', values.in]);
1138
- break;
1139
- }
1140
- case 'wrap': {
1141
- const { override, values } = parseStage(rest, {
1142
- in: { type: 'string' },
1143
- out: { type: 'string' },
1144
- profile: { type: 'string' },
1145
- });
1146
- if (!values.in || !values.out) {
1147
- fail(
1148
- 'usage: kk-workflow-stages.ts wrap [<override>] --in <voicescript.yaml> --out <docs.json> [--profile <name>]',
1149
- );
1465
+ // Imported as a module (unit tests reach `dedupPlanByTitle`) → exports only, never a stage run;
1466
+ // executed as the entrypoint → dispatch.
1467
+ if (import.meta.main)
1468
+ switch (stage) {
1469
+ case 'prepare-itc':
1470
+ prepareItc(rest);
1471
+ break;
1472
+ case 'prepare-solo':
1473
+ prepareSolo(rest);
1474
+ break;
1475
+ case 'prepare-storm':
1476
+ prepareStorm(rest);
1477
+ break;
1478
+ case 'duration':
1479
+ duration(rest);
1480
+ break;
1481
+ case 'validate': {
1482
+ const { override, values } = parseStage(rest, { in: { type: 'string' } });
1483
+ if (!values.in) fail('usage: kk-workflow-stages.ts validate [<override>] --in <voicescript.yaml>');
1484
+ delegate(requireSidecar(override, VALIDATE_SIDECAR, 'validate_script'), ['--in', values.in]);
1485
+ break;
1150
1486
  }
1151
- delegate(requireSidecar(override, WRAP_SIDECAR, 'wrap_script'), [values.in, values.out, values.profile ?? '']);
1152
- break;
1153
- }
1154
- case 'render-storm': {
1155
- const { override, values } = parseStage(rest, { in: { type: 'string' }, out: { type: 'string' } });
1156
- if (!values.in || !values.out) {
1157
- fail('usage: kk-workflow-stages.ts render-storm [<override>] --in <content.json> --out <content.md>');
1487
+ case 'wrap': {
1488
+ const { override, values } = parseStage(rest, {
1489
+ in: { type: 'string' },
1490
+ out: { type: 'string' },
1491
+ profile: { type: 'string' },
1492
+ });
1493
+ if (!values.in || !values.out) {
1494
+ fail(
1495
+ 'usage: kk-workflow-stages.ts wrap [<override>] --in <voicescript.yaml> --out <docs.json> [--profile <name>]',
1496
+ );
1497
+ }
1498
+ delegate(requireSidecar(override, WRAP_SIDECAR, 'wrap_script'), [
1499
+ values.in,
1500
+ values.out,
1501
+ values.profile ?? '',
1502
+ ]);
1503
+ break;
1504
+ }
1505
+ case 'render-storm': {
1506
+ const { override, values } = parseStage(rest, { in: { type: 'string' }, out: { type: 'string' } });
1507
+ if (!values.in || !values.out) {
1508
+ fail('usage: kk-workflow-stages.ts render-storm [<override>] --in <content.json> --out <content.md>');
1509
+ }
1510
+ delegate(requireSidecar(override, RENDER_SIDECAR, 'render_script'), [
1511
+ '--in',
1512
+ values.in,
1513
+ '--out',
1514
+ values.out,
1515
+ ]);
1516
+ break;
1158
1517
  }
1159
- delegate(requireSidecar(override, RENDER_SIDECAR, 'render_script'), ['--in', values.in, '--out', values.out]);
1160
- break;
1518
+ case 'daily-prepare':
1519
+ dailyPrepare(rest);
1520
+ break;
1521
+ case 'daily-collect-facts':
1522
+ dailyCollectFacts(rest);
1523
+ break;
1524
+ case 'daily-plan':
1525
+ dailyPlan(rest);
1526
+ break;
1527
+ case 'daily-qc-content':
1528
+ dailyQcContent(rest);
1529
+ break;
1530
+ case 'daily-article':
1531
+ dailyArticle(rest);
1532
+ break;
1533
+ case 'daily-cover-normalize':
1534
+ dailyCoverNormalize(rest);
1535
+ break;
1536
+ case 'daily-script':
1537
+ dailyScript(rest);
1538
+ break;
1539
+ case 'daily-wrap-docs':
1540
+ dailyWrapDocs(rest);
1541
+ break;
1542
+ case 'daily-generate':
1543
+ dailyGenerate(rest);
1544
+ break;
1545
+ case 'daily-intro':
1546
+ dailyIntro(rest);
1547
+ break;
1548
+ case 'daily-quality-report':
1549
+ dailyQualityReport(rest);
1550
+ break;
1551
+ case 'daily-publish-prep':
1552
+ dailyPublishPrep(rest);
1553
+ break;
1554
+ case 'daily-publish-surfdash':
1555
+ dailyPublishSurfdash(rest);
1556
+ break;
1557
+ case 'daily-show-notes':
1558
+ dailyShowNotes(rest);
1559
+ break;
1560
+ case 'daily-publish-podcast':
1561
+ dailyPublishPodcast(rest);
1562
+ break;
1563
+ case 'daily-publish-classify':
1564
+ dailyPublishClassify(rest);
1565
+ break;
1566
+ case 'daily-patch-surfdash':
1567
+ dailyPatchSurfdash(rest);
1568
+ break;
1569
+ case 'daily-publish-partial':
1570
+ dailyPublishPartial(rest);
1571
+ break;
1572
+ case 'daily-run-report':
1573
+ dailyRunReport(rest);
1574
+ break;
1575
+ case 'daily-translate-localize':
1576
+ dailyTranslateLocalize(rest);
1577
+ break;
1578
+ case 'daily-translate-sync':
1579
+ dailyTranslateSync(rest);
1580
+ break;
1581
+ default:
1582
+ fail(
1583
+ `unknown stage: ${stage ?? '(none)'} (expected prepare-itc|prepare-solo|prepare-storm|validate|wrap|duration|render-storm|daily-*)`,
1584
+ );
1161
1585
  }
1162
- case 'daily-prepare':
1163
- dailyPrepare(rest);
1164
- break;
1165
- case 'daily-collect-facts':
1166
- dailyCollectFacts(rest);
1167
- break;
1168
- case 'daily-plan':
1169
- dailyPlan(rest);
1170
- break;
1171
- case 'daily-qc-content':
1172
- dailyQcContent(rest);
1173
- break;
1174
- case 'daily-article':
1175
- dailyArticle(rest);
1176
- break;
1177
- case 'daily-cover-normalize':
1178
- dailyCoverNormalize(rest);
1179
- break;
1180
- case 'daily-script':
1181
- dailyScript(rest);
1182
- break;
1183
- case 'daily-wrap-docs':
1184
- dailyWrapDocs(rest);
1185
- break;
1186
- case 'daily-generate':
1187
- dailyGenerate(rest);
1188
- break;
1189
- case 'daily-quality-report':
1190
- dailyQualityReport(rest);
1191
- break;
1192
- case 'daily-publish-prep':
1193
- dailyPublishPrep(rest);
1194
- break;
1195
- case 'daily-publish-surfdash':
1196
- dailyPublishSurfdash(rest);
1197
- break;
1198
- case 'daily-show-notes':
1199
- dailyShowNotes(rest);
1200
- break;
1201
- case 'daily-publish-podcast':
1202
- dailyPublishPodcast(rest);
1203
- break;
1204
- case 'daily-publish-classify':
1205
- dailyPublishClassify(rest);
1206
- break;
1207
- case 'daily-publish-partial':
1208
- dailyPublishPartial(rest);
1209
- break;
1210
- case 'daily-run-report':
1211
- dailyRunReport(rest);
1212
- break;
1213
- case 'daily-translate-en':
1214
- dailyTranslateEn(rest);
1215
- break;
1216
- case 'daily-translate-ja':
1217
- dailyTranslateJa(rest);
1218
- break;
1219
- case 'daily-translate-sync':
1220
- dailyTranslateSync(rest);
1221
- break;
1222
- default:
1223
- fail(
1224
- `unknown stage: ${stage ?? '(none)'} (expected prepare-itc|prepare-solo|prepare-storm|validate|wrap|duration|render-storm|daily-*)`,
1225
- );
1226
- }